imetting_frontend/src/pages/MeetingDetails.jsx

1348 lines
50 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 apiClient from '../utils/apiClient';
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff } 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 MindMap from '../components/MindMap';
import MeetingSummary from '../components/MeetingSummary';
import { Tabs } from 'antd';
import './MeetingDetails.css';
const { TabPane } = Tabs;
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 [showSummaryError, setShowSummaryError] = useState(false);
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
const [editingSpeakers, setEditingSpeakers] = useState({});
const [speakerList, setSpeakerList] = useState([]);
const [showTranscriptEdit, setShowTranscriptEdit] = useState(false);
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
const [statusCheckInterval, setStatusCheckInterval] = useState(null);
const [autoScrollEnabled, setAutoScrollEnabled] = 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 [summaryTaskId, setSummaryTaskId] = useState(null);
const [summaryTaskStatus, setSummaryTaskStatus] = useState(null);
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
useEffect(() => {
fetchMeetingDetails();
// Cleanup interval on unmount
return () => {
if (statusCheckInterval) {
console.log('组件卸载,清理转录状态轮询定时器');
clearInterval(statusCheckInterval);
setStatusCheckInterval(null);
}
if (summaryPollInterval) {
console.log('组件卸载,清理总结任务轮询定时器');
clearInterval(summaryPollInterval);
setSummaryPollInterval(null);
}
};
}, [meeting_id]);
// Cleanup interval when status changes
useEffect(() => {
if (transcriptionStatus) {
// 如果转录已完成、失败或取消,清除轮询
if (['completed', 'failed', 'error', 'cancelled'].includes(transcriptionStatus.status)) {
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
setStatusCheckInterval(null);
}
}
}
}, [transcriptionStatus, statusCheckInterval]);
const refreshTranscriptData = async () => {
try {
const baseUrl = "";
const transcriptEndpoint = API_ENDPOINTS?.MEETINGS?.TRANSCRIPT?.(meeting_id) || `/api/meetings/${meeting_id}/transcript`;
// 只刷新转录数据不显示loading
const transcriptResponse = await apiClient.get(`${baseUrl}${transcriptEndpoint}`);
setTranscript(transcriptResponse.data);
// 更新发言人列表
const allSpeakerIds = transcriptResponse.data
.map(item => item.speaker_id)
.filter(speakerId => speakerId !== null && speakerId !== undefined);
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);
setSpeakerList(uniqueSpeakers);
console.log('转录数据已刷新无loading状态');
} catch (error) {
console.error('刷新转录数据失败:', error);
}
};
const startStatusPolling = (taskId) => {
// Clear existing interval
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
setStatusCheckInterval(null);
}
// Poll every 3 seconds
const interval = setInterval(async () => {
try {
const baseUrl = "";
const statusResponse = await apiClient.get(`${baseUrl}/api/transcription/tasks/${taskId}/status`);
const status = statusResponse.data;
setTranscriptionStatus(status);
setTranscriptionProgress(status.progress || 0);
// Stop polling if task is completed or failed
if (['completed', 'failed', 'error', 'cancelled'].includes(status.status)) {
clearInterval(interval);
setStatusCheckInterval(null);
// Refresh transcript data only if completed successfully
if (status.status === 'completed') {
console.log('转录完成刷新转录数据无loading');
await refreshTranscriptData();
} else {
console.log('转录失败或取消,状态:', status.status);
}
// 再次确保清除状态
setTranscriptionStatus(status);
}
} catch (error) {
console.error('Failed to fetch transcription status:', error);
// Clear interval on error to prevent endless polling
clearInterval(interval);
setStatusCheckInterval(null);
}
}, 3000);
setStatusCheckInterval(interval);
};
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 apiClient.get(`${baseUrl}${detailEndpoint}`);
setMeeting(response.data);
// Handle transcription status from meeting details
if (response.data.transcription_status) {
const newStatus = response.data.transcription_status;
setTranscriptionStatus(newStatus);
setTranscriptionProgress(newStatus.progress || 0);
// If transcription is in progress, start polling for updates
// 但只有当前没有在轮询时才启动新的轮询
if (['pending', 'processing'].includes(newStatus.status)) {
if (!statusCheckInterval) {
console.log('转录进行中,开始轮询状态');
startStatusPolling(newStatus.task_id);
}
} else {
// 如果转录已完成,确保清除任何现有的轮询
console.log('转录已完成或失败,状态:', newStatus.status);
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
setStatusCheckInterval(null);
}
}
} else {
setTranscriptionStatus(null);
setTranscriptionProgress(0);
// 清除轮询
if (statusCheckInterval) {
clearInterval(statusCheckInterval);
setStatusCheckInterval(null);
}
}
// Fetch audio file if available
try {
const audioResponse = await apiClient.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 apiClient.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 hours = Math.floor(seconds / 3600);
const mins = Math.floor((seconds % 3600) / 60);
const secs = Math.floor(seconds % 60);
if (hours > 0) {
// 超过60分钟显示小时:分钟:秒格式
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
} else {
// 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 (autoScrollEnabled && 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 apiClient.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 apiClient.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 apiClient.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 apiClient.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;
};
const refreshMeetingSummary = async () => {
try {
const baseUrl = "";
const detailEndpoint = API_ENDPOINTS?.MEETINGS?.DETAIL?.(meeting_id) || `/api/meetings/${meeting_id}`;
// 只获取会议详情中的summary字段不显示loading
const response = await apiClient.get(`${baseUrl}${detailEndpoint}`);
// 只更新summary相关的字段保持其他数据不变
setMeeting(prevMeeting => ({
...prevMeeting,
summary: response.data.summary
}));
} catch (error) {
console.error('刷新会议摘要失败:', error);
}
};
// AI总结相关函数 - 使用异步API
const generateSummary = async () => {
if (summaryLoading) return;
setSummaryLoading(true);
setSummaryTaskProgress(0);
setSummaryTaskMessage('正在启动AI分析...');
setSummaryTaskStatus('pending');
try {
const baseUrl = "";
// 使用异步API
const response = await apiClient.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary-async`, {
user_prompt: userPrompt
});
const taskId = response.data.task_id;
setSummaryTaskId(taskId);
// 开始轮询任务状态
const interval = setInterval(async () => {
try {
const statusResponse = await apiClient.get(`${baseUrl}/api/llm-tasks/${taskId}/status`);
const status = statusResponse.data;
setSummaryTaskStatus(status.status);
setSummaryTaskProgress(status.progress || 0);
setSummaryTaskMessage(status.message || '处理中...');
if (status.status === 'completed') {
clearInterval(interval);
setSummaryPollInterval(null);
// 设置结果
setSummaryResult({
content: status.result,
task_id: taskId
});
// 刷新总结历史(包含所有任务)
await fetchSummaryHistory();
// 刷新会议摘要
await refreshMeetingSummary();
setSummaryLoading(false);
setSummaryTaskMessage('AI总结生成成功');
// 3秒后清除成功消息
setTimeout(() => {
setSummaryTaskMessage('');
setSummaryTaskProgress(0);
}, 3000);
} else if (status.status === 'failed') {
clearInterval(interval);
setSummaryPollInterval(null);
setSummaryLoading(false);
setError(status.error_message || '生成AI总结失败');
setSummaryTaskMessage('生成失败:' + (status.error_message || '未知错误'));
}
} catch (err) {
console.error('Error polling task status:', err);
// 继续轮询,不中断
}
}, 3000); // 每3秒查询一次
setSummaryPollInterval(interval);
} catch (err) {
console.error('Error starting summary generation:', err);
// Check for detailed error message from backend
const detail = err.response?.data?.detail;
const errorMessage = detail || '启动AI总结失败请重试。';
setError(errorMessage); // Set the more specific error
setSummaryTaskMessage(`生成失败:${errorMessage}`); // Also show it in the modal
setSummaryLoading(false);
setSummaryTaskProgress(0);
}
};
const fetchSummaryHistory = async () => {
try {
const baseUrl = "";
// 获取所有LLM任务历史包含进度和状态
const tasksResponse = await apiClient.get(`${baseUrl}/api/meetings/${meeting_id}/llm-tasks`);
const tasks = tasksResponse.data.tasks || [];
// 转换为历史记录格式,包含任务信息
const summaries = tasks
.filter(task => task.status === 'completed' && task.result)
.map(task => ({
id: task.task_id,
content: task.result,
user_prompt: task.user_prompt,
created_at: task.created_at,
task_info: {
task_id: task.task_id,
status: task.status,
progress: task.progress
}
}));
setSummaryHistory(summaries);
// 如果有进行中的任务,恢复轮询
const runningTask = tasks.find(task => ['pending', 'processing'].includes(task.status));
if (runningTask && !summaryPollInterval) {
setSummaryTaskId(runningTask.task_id);
setSummaryTaskStatus(runningTask.status);
setSummaryTaskProgress(runningTask.progress || 0);
setSummaryLoading(true);
// 恢复轮询
const interval = setInterval(async () => {
try {
const statusResponse = await apiClient.get(`${baseUrl}/api/llm-tasks/${runningTask.task_id}/status`);
const status = statusResponse.data;
setSummaryTaskStatus(status.status);
setSummaryTaskProgress(status.progress || 0);
setSummaryTaskMessage(status.message || '处理中...');
if (['completed', 'failed'].includes(status.status)) {
clearInterval(interval);
setSummaryPollInterval(null);
setSummaryLoading(false);
if (status.status === 'completed') {
await fetchSummaryHistory();
await refreshMeetingSummary();
}
}
} catch (err) {
console.error('Error polling task status:', err);
}
}, 3000);
setSummaryPollInterval(interval);
}
} catch (err) {
console.error('Error fetching summary history:', err);
setSummaryHistory([]);
}
};
const openSummaryModal = async () => {
// Frontend check before opening the modal
if (!transcriptionStatus || transcriptionStatus.status !== 'completed') {
setShowSummaryError(true);
return; // Prevent modal from opening
}
setShowSummaryModal(true);
setUserPrompt('');
setSummaryResult(null);
await fetchSummaryHistory();
};
const closeSummaryModal = async () => {
setShowSummaryModal(false);
// 关闭弹窗时只刷新摘要部分,避免整页刷新
if (summaryResult) {
await refreshMeetingSummary();
}
};
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>
{/* 转录状态显示 */}
{transcriptionStatus && (
<div className="transcription-status">
<div className="status-content-inline">
<div className="status-header-inline">
<Brain size={16} />
<span>语音转录进度</span>
</div>
{transcriptionStatus.status === 'pending' && (
<div className="status-pending-inline">
<div className="status-indicator pending"></div>
<span>等待处理中...</span>
</div>
)}
{transcriptionStatus.status === 'processing' && (
<div className="status-processing-inline">
<div className="status-indicator processing"></div>
<span>转录进行中</span>
<div className="progress-bar-small">
<div
className="progress-fill-small"
style={{ width: `${transcriptionProgress}%` }}
></div>
</div>
<span className="progress-text">{transcriptionProgress}%</span>
</div>
)}
{transcriptionStatus.status === 'completed' && (
<div className="status-completed-inline">
<div className="status-indicator completed"></div>
<span>转录已完成</span>
<Sparkles size={14} />
</div>
)}
{transcriptionStatus.status === 'failed' && (
<div className="status-failed-inline">
<div className="status-indicator failed"></div>
<span>转录失败</span>
{transcriptionStatus.error_message && (
<span className="error-message-inline">({transcriptionStatus.error_message})</span>
)}
</div>
)}
</div>
</div>
)}
<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 summary-tabs-section">
<Tabs defaultActiveKey="1">
<TabPane tab={<span><FileText size={16} /> 会议总结</span>} key="1">
<MeetingSummary
meeting={meeting}
summaryResult={summaryResult}
summaryHistory={summaryHistory}
isCreator={isCreator}
onOpenSummaryModal={openSummaryModal}
formatDateTime={formatDateTime}
/>
</TabPane>
<TabPane tab={<span><Brain size={16} /> 会议脑图</span>} key="2">
<MindMap meetingId={meeting_id} meetingTitle={meeting.title} />
</TabPane>
</Tabs>
</section>
</div>
</div>
{/* Transcript Sidebar */}
<div className="transcript-sidebar">
<div className="transcript-header">
<h3>
<MessageCircle size={20} />
对话转录
<span
className="sync-scroll-icon"
onClick={() => setAutoScrollEnabled(!autoScrollEnabled)}
title={autoScrollEnabled ? "关闭同步滚动" : "开启同步滚动"}
>
{autoScrollEnabled ? <RefreshCw size={16} /> : <RefreshCwOff size={16} />}
</span>
</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>
)}
{/* Summary Error Modal */}
{showSummaryError && (
<div className="delete-modal-overlay" onClick={() => setShowSummaryError(false)}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>操作无法进行</h3>
<p>会议转录尚未完成或处理失败请在转录成功后再生成AI总结</p>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowSummaryError(false)}
>
知道了
</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={closeSummaryModal}>
<div className="summary-modal" onClick={(e) => e.stopPropagation()}>
<div className="modal-header">
<h3><Brain size={20} /> AI会议总结</h3>
<button
className="close-btn"
onClick={closeSummaryModal}
>
<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>
{/* 任务进度显示 */}
{summaryLoading && (
<div className="summary-progress-section">
<div className="progress-header">
<span className="progress-title">生成进度</span>
<span className="progress-percentage">{summaryTaskProgress}%</span>
</div>
<div className="progress-bar-container">
<div
className="progress-bar-fill"
style={{ width: `${summaryTaskProgress}%` }}
>
<div className="progress-bar-animate"></div>
</div>
</div>
{summaryTaskMessage && (
<div className="progress-message">
<div className="status-indicator processing"></div>
<span>{summaryTaskMessage}</span>
</div>
)}
</div>
)}
{/* 成功消息 */}
{!summaryLoading && summaryTaskMessage && summaryTaskStatus === 'completed' && (
<div className="success-message">
<Sparkles size={16} />
<span>{summaryTaskMessage}</span>
</div>
)}
{summaryResult && (
<div className="summary-result-section">
<div className="summary-result-header">
<h4>最新生成的总结</h4>
</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;