imetting_frontend/src/pages/MeetingDetails.jsx

1607 lines
60 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 configService from '../utils/configService';
import tools from '../utils/tools';
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload } 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 ContentViewer from '../components/ContentViewer';
import TagDisplay from '../components/TagDisplay';
import ConfirmDialog from '../components/ConfirmDialog';
import Toast from '../components/Toast';
import PageLoading from '../components/PageLoading';
import QRCodeModal from '../components/QRCodeModal';
import Dropdown from '../components/Dropdown';
import exportService from '../services/exportService';
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 [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
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 [toasts, setToasts] = useState([]);
const [showQRModal, setShowQRModal] = useState(false);
const [audioFile, setAudioFile] = useState(null);
const [isUploading, setIsUploading] = useState(false);
const [showUploadConfirm, setShowUploadConfirm] = useState(false);
const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 默认100MB
const [uploadError, setUploadError] = useState('');
const audioRef = useRef(null);
const transcriptRefs = useRef([]);
// Toast 辅助函数
const showToast = (message, type = 'info') => {
const id = Date.now();
setToasts(prev => [...prev, { id, message, type }]);
};
const removeToast = (id) => {
setToasts(prev => prev.filter(toast => toast.id !== id));
};
useEffect(() => {
fetchMeetingDetails();
loadFileSizeConfig();
// 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 statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId)));
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 loadFileSizeConfig = async () => {
try {
const fileSize = await configService.getMaxFileSize();
setMaxFileSize(fileSize);
} catch (error) {
console.warn('Failed to load file size config:', error);
}
};
const handleFileChange = (e) => {
const file = e.target.files[0];
if (file) {
// Check file type
const allowedMimeTypes = ['audio/mp3', 'audio/wav', 'audio/m4a', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a'];
const fileExtension = file.name.toLowerCase().split('.').pop();
const allowedExtensions = ['mp3', 'wav', 'm4a', 'mpeg'];
if (!allowedMimeTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) {
setUploadError('请上传支持的音频格式 (MP3, WAV, M4A)');
showToast('请上传支持的音频格式 (MP3, WAV, M4A)', 'error');
return;
}
// Check file size
if (file.size > maxFileSize) {
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
setUploadError(`音频文件大小不能超过${maxSizeMB}MB`);
showToast(`音频文件大小不能超过${maxSizeMB}MB`, 'error');
return;
}
setAudioFile(file);
setUploadError('');
setShowUploadConfirm(true);
}
};
const handleUploadAudio = async () => {
if (!audioFile) {
setUploadError('请先选择音频文件');
showToast('请先选择音频文件', 'error');
return;
}
setIsUploading(true);
setUploadError('');
try {
const formDataUpload = new FormData();
formDataUpload.append('audio_file', audioFile);
formDataUpload.append('meeting_id', meeting_id);
formDataUpload.append('force_replace', 'true');
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
setAudioFile(null);
setShowUploadConfirm(false);
setShowAudioDropdown(false);
showToast('音频上传成功,正在进行智能转录...', 'success');
// Reset file input
const fileInput = document.getElementById('audio-file-upload');
if (fileInput) fileInput.value = '';
// Refresh meeting details to get new audio and transcription status
await fetchMeetingDetails();
} catch (err) {
console.error('Upload error:', err);
setUploadError(err.response?.data?.message || '上传音频文件失败,请重试');
showToast(err.response?.data?.message || '上传音频文件失败,请重试', 'error');
} finally {
setIsUploading(false);
}
};
const handleStartTranscription = async () => {
try {
if (!audioUrl) {
showToast('没有可用的音频文件', 'error');
return;
}
if (transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status)) {
showToast('转录任务正在进行中', 'info');
return;
}
// 调用后端API启动转录
const response = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/transcription/start`));
if (response.data.task_id) {
showToast('智能转录已启动', 'success');
setShowAudioDropdown(false);
// 开始轮询转录状态
startStatusPolling(response.data.task_id);
}
} catch (err) {
console.error('Start transcription error:', err);
showToast(err.response?.data?.message || '启动转录失败,请重试', 'error');
}
};
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(err.response?.data?.message || '删除会议失败,请重试');
}
};
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(err.response?.data?.message || '更新发言人标签失败,请重试');
}
};
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(err.response?.data?.message || '批量更新发言人标签失败,请重试');
}
};
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(err.response?.data?.message || '更新转录内容失败,请重试');
}
};
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(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
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);
const errorMessage = err.response?.data?.message || '启动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(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(runningTask.task_id)));
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') {
showToast('会议转录尚未完成或处理失败请在转录成功后再生成AI总结。', 'warning');
return; // Prevent modal from opening
}
setShowSummaryModal(true);
setUserPrompt('');
setSummaryResult(null);
await fetchSummaryHistory();
};
const closeSummaryModal = async () => {
setShowSummaryModal(false);
// 关闭弹窗时只刷新摘要部分,避免整页刷新
if (summaryResult) {
await refreshMeetingSummary();
}
};
// 导出会议总结为图片
const exportSummaryToImage = async () => {
try {
if (!meeting?.summary) {
showToast('暂无会议总结内容请先生成AI总结。', 'warning');
return;
}
const meetingTime = tools.formatDateTime(meeting.meeting_time);
const attendeesList = meeting.attendees.map(attendee =>
typeof attendee === 'string' ? attendee : attendee.caption
).join('、');
await exportService.exportMeetingSummaryToImage({
title: meeting.title || '会议总结',
summary: meeting.summary,
metadata: {
meetingTime,
creator: meeting.creator_username,
attendeeCount: meeting.attendees.length,
attendees: attendeesList
}
});
showToast('总结已成功导出为图片', 'success');
} catch (error) {
console.error('图片导出失败:', error);
showToast('图片导出失败,请重试。', 'error');
}
};
// 导出思维导图为图片
const exportMindMapToImage = async () => {
try {
if (!meeting?.summary) {
showToast('暂无内容,无法导出思维导图。', 'warning');
return;
}
await exportService.exportMindMapToImage({
title: meeting.title || '会议'
});
showToast('思维导图已成功导出为图片', 'success');
} catch (error) {
console.error('思维导图导出失败:', error);
showToast(error.message || '思维导图导出失败,请重试。', 'error');
}
};
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
if (loading) {
return <PageLoading message="加载中..." />;
}
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 icon-only edit-btn" title="编辑会议">
<Edit size={16} />
</Link>
<button
className="action-btn icon-only delete-btn"
onClick={() => setDeleteConfirmInfo({ id: meeting_id, title: meeting.title })}
title="删除会议"
>
<Trash2 size={16} />
</button>
<button
className="action-btn icon-only qr-btn"
onClick={() => setShowQRModal(true)}
title="分享会议二维码"
>
<QrCode size={16} />
</button>
</div>
)}
</div>
<div className="details-layout">
<div className="main-content">
<div className="details-content-card">
<header className="detail-card-header">
<div className="meeting-header-title">
<h1>
{meeting.title}
{meeting.tags && meeting.tags.length > 0 && (
<TagDisplay
tags={meeting.tags.map(tag => tag.name)}
size="medium"
showIcon={true}
className="inline-title-tags"
/>
)}
</h1>
</div>
<div className="meta-grid">
<div className="meta-item">
<Calendar size={18} />
<strong>会议日期:</strong>
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}</span>
</div>
<div className="meta-item">
<Clock size={18} />
<strong>会议时间:</strong>
<span>{tools.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">
<div className="section-header-with-menu">
<h2><Volume2 size={20} /> 会议录音</h2>
{meeting?.creator_id === user?.user_id && (
<Dropdown
trigger={
<button className="audio-menu-button" title="音频操作">
<MoreVertical size={20} />
</button>
}
items={[
{
label: '音频上传',
icon: <Upload size={16} />,
onClick: () => document.getElementById('audio-file-upload').click()
},
...(audioUrl ? [{
label: '智能转录',
icon: <Brain size={16} />,
onClick: handleStartTranscription,
disabled: transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status)
}] : [])
]}
align="right"
/>
)}
<input
type="file"
id="audio-file-upload"
accept="audio/*"
onChange={handleFileChange}
style={{ display: 'none' }}
/>
</div>
{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>{tools.formatDuration(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>{tools.formatDuration(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>
{/* Upload Confirmation Modal */}
{showUploadConfirm && (
<div className="delete-modal-overlay" onClick={() => setShowUploadConfirm(false)}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>确认上传音频</h3>
<p>重新上传音频文件将清空已有的会话转录是否继续</p>
{audioFile && (
<div className="selected-file-info">
<span>已选择: {audioFile.name}</span>
<span className="file-size">
({(audioFile.size / (1024 * 1024)).toFixed(2)} MB)
</span>
</div>
)}
{uploadError && (
<div className="error-message">{uploadError}</div>
)}
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => {
setShowUploadConfirm(false);
setAudioFile(null);
const fileInput = document.getElementById('audio-file-upload');
if (fileInput) fileInput.value = '';
}}
>
取消
</button>
<button
className="btn-submit"
onClick={handleUploadAudio}
disabled={isUploading}
>
{isUploading ? '上传中...' : '确定上传'}
</button>
</div>
</div>
</div>
)}
<section className="card-section summary-tabs-section">
<ContentViewer
content={meeting.summary}
title={meeting.title}
emptyMessage="暂无会议总结"
summaryActions={
meeting.summary && (
<button
className="action-btn export-btn"
onClick={exportSummaryToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
mindmapActions={
meeting.summary && (
<button
className="action-btn export-btn"
onClick={exportMindMapToImage}
title="导出图片"
>
<Image size={16} />
<span>导出图片</span>
</button>
)
}
/>
</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) => {
// 计算当前发言人的序号:这是该发言人的第几条发言
const speakerSegments = transcript.filter(seg => seg.speaker_id === item.speaker_id);
const currentSpeakerIndex = speakerSegments.findIndex(seg => seg.segment_id === item.segment_id) + 1;
const totalSpeakerSegments = speakerSegments.length;
return (
<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 className="speaker-index"> {currentSpeakerIndex}/{totalSpeakerSegments}</span>
</span>
<div className="transcript-item-actions">
<span
className="timestamp clickable"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点播放"
>
{tools.formatDuration(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 Dialog */}
<ConfirmDialog
isOpen={!!deleteConfirmInfo}
onClose={() => setDeleteConfirmInfo(null)}
onConfirm={handleDeleteMeeting}
title="删除会议"
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
confirmText="确定删除"
cancelText="取消"
type="danger"
/>
{/* 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">
<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">{tools.formatDuration(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>
)}
{/* QR Code Sharing Modal */}
<QRCodeModal
isOpen={showQRModal}
onClose={() => setShowQRModal(false)}
url={`${window.location.origin}/meetings/preview/${meeting_id}`}
title={meeting.title}
description="扫描二维码查看会议总结"
/>
{/* Toast notifications */}
{toasts.map(toast => (
<Toast
key={toast.id}
message={toast.message}
type={toast.type}
onClose={() => removeToast(toast.id)}
/>
))}
</div>
);
};
export default MeetingDetails;