imetting/frontend/src/pages/MeetingDetails.jsx

2019 lines
74 KiB
React
Raw Normal View History

import React, { useState, useEffect, useRef } from 'react';
import { useParams, Link, useNavigate, useLocation } 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, ChevronLeft, ChevronRight, Loader, Lock, Unlock, Eye, EyeOff, Copy, Check } from 'lucide-react';
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
import ContentViewer from '../components/ContentViewer';
import MarkdownRenderer from '../components/MarkdownRenderer';
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 location = useLocation();
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 [promptList, setPromptList] = useState([]);
const [selectedPromptId, setSelectedPromptId] = useState(null);
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 [accessPassword, setAccessPassword] = useState(null);
const [passwordEnabled, setPasswordEnabled] = useState(false);
const [showPassword, setShowPassword] = useState(false);
const [passwordCopied, setPasswordCopied] = useState(false);
const [passwordLoading, setPasswordLoading] = useState(false);
// 音频加载状态
const [audioLoading, setAudioLoading] = useState(true); // 音频是否正在加载
const [audioCanPlay, setAudioCanPlay] = useState(false); // 音频是否可以播放
const [audioBuffering, setAudioBuffering] = useState(false); // 音频是否正在缓冲
const [audioError, setAudioError] = useState(null); // 音频加载错误
// 导航相关状态
const [navigationInfo, setNavigationInfo] = useState({
prev_meeting_id: null,
next_meeting_id: null,
current_index: null,
total_count: null
});
const [filterContext, setFilterContext] = useState({
filterType: 'all',
searchQuery: '',
selectedTags: []
});
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));
};
// 获取从Dashboard传递的筛选上下文
useEffect(() => {
if (location.state?.filterContext) {
setFilterContext(location.state.filterContext);
}
}, [location.state]);
useEffect(() => {
fetchMeetingDetails();
loadFileSizeConfig();
fetchPromptList();
// Cleanup interval on unmount
return () => {
if (statusCheckInterval) {
console.log('组件卸载,清理转录状态轮询定时器');
clearInterval(statusCheckInterval);
setStatusCheckInterval(null);
}
if (summaryPollInterval) {
console.log('组件卸载,清理总结任务轮询定时器');
clearInterval(summaryPollInterval);
setSummaryPollInterval(null);
}
// 停止音频播放
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
}
};
}, [meeting_id]);
// 监听audioUrl变化确保音频正确重置
useEffect(() => {
// 当audioUrl变化时停止当前播放并重置audio元素
if (audioRef.current) {
audioRef.current.pause();
audioRef.current.currentTime = 0;
audioRef.current.load(); // 重新加载audio元素
}
}, [audioUrl]);
// 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);
// 设置访问密码状态
if (response.data.access_password) {
setAccessPassword(response.data.access_password);
setPasswordEnabled(true);
} else {
setAccessPassword(null);
setPasswordEnabled(false);
}
// 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}`);
// Use streaming API endpoint for Safari compatibility (supports HTTP Range requests)
setAudioUrl(`${baseUrl}/api/meetings/${meeting_id}/audio/stream`);
setAudioFileName(audioResponse.data.file_name);
// 重置音频状态(新会议的音频)
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setAudioLoading(true);
setAudioCanPlay(false);
setAudioBuffering(false);
setAudioError(null);
setCurrentSubtitle('');
setCurrentSpeaker('');
setCurrentHighlightIndex(-1);
} catch (audioError) {
console.warn('No audio file available:', audioError);
setAudioUrl(null);
setAudioFileName(null);
// 重置音频状态(无音频)
setIsPlaying(false);
setCurrentTime(0);
setDuration(0);
setAudioLoading(false);
setAudioCanPlay(false);
setAudioBuffering(false);
setAudioError(null);
setCurrentSubtitle('');
setCurrentSpeaker('');
setCurrentHighlightIndex(-1);
}
// 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 fetchPromptList = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.PROMPTS.ACTIVE('MEETING_TASK')));
const prompts = response.data.prompts || []; // 修正:从 data.prompts 取值
setPromptList(prompts);
// 设置默认选中的模板(默认模板或第一个)
const defaultPrompt = prompts.find(p => p.is_default) || prompts[0];
if (defaultPrompt) {
setSelectedPromptId(defaultPrompt.id);
}
} catch (error) {
console.warn('Failed to load prompt list:', error);
setPromptList([]);
}
};
// 获取导航信息(上一条/下一条会议)
const fetchNavigationInfo = async () => {
try {
const params = {
user_id: user.user_id,
filter_type: filterContext.filterType,
search: filterContext.searchQuery || undefined,
tags: filterContext.selectedTags.length > 0 ? filterContext.selectedTags.join(',') : undefined
};
const response = await apiClient.get(
buildApiUrl(API_ENDPOINTS.MEETINGS.NAVIGATION(meeting_id)),
{ params }
);
setNavigationInfo(response.data);
} catch (err) {
console.error('Error fetching navigation info:', err);
// 不显示错误提示,静默失败
}
};
// 当会议详情加载完成后,获取导航信息
useEffect(() => {
if (meeting && user && filterContext) {
fetchNavigationInfo();
}
}, [meeting, user, filterContext, meeting_id]);
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');
formDataUpload.append('auto_summarize', 'false');
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, {
headers: {
'Content-Type': 'multipart/form-data',
},
});
// 上传成功后,刷新整个会议详情数据
await fetchMeetingDetails();
// 清理上传状态
setAudioFile(null);
setShowUploadConfirm(false);
// Reset file input
const fileInput = document.getElementById('audio-file-upload');
if (fileInput) fileInput.value = '';
showToast('音频上传成功,正在进行智能转录...', 'success');
} 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`));
// response 是 {code, message, data}task_id 在 data 中
if (response.data && response.data.task_id) {
// 立即更新本地状态为新任务的pending状态
const newStatus = {
task_id: response.data.task_id,
status: 'pending',
progress: 0,
meeting_id: parseInt(meeting_id)
};
setTranscriptionStatus(newStatus);
setTranscriptionProgress(0);
// 清空现有的转录数据和会议摘要(因为后端已经删除了)
setTranscript([]);
setSpeakerList([]);
setMeeting(prevMeeting => ({
...prevMeeting,
summary: null
}));
showToast('智能转录已启动', 'success');
// 开始轮询转录状态
startStatusPolling(response.data.task_id);
} else {
showToast('启动转录成功但未获取到任务ID', 'warning');
}
} catch (err) {
console.error('Start transcription error:', err);
showToast(err.response?.data?.message || err.message || '启动转录失败,请重试', 'error');
}
};
const handlePlayPause = () => {
if (audioRef.current) {
// 如果音频还未加载完成,不允许播放
if (!audioCanPlay && !isPlaying) {
showToast('音频正在加载中,请稍候...', 'info');
return;
}
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play().catch(err => {
console.error('播放失败:', err);
showToast('播放失败,请重试', 'error');
});
}
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);
// Safari在preload="metadata"模式下可能不触发onCanPlay所以这里也更新状态
setAudioLoading(false);
setAudioCanPlay(true);
setAudioError(null);
}
};
// 音频可以开始播放
const handleCanPlay = () => {
setAudioLoading(false);
setAudioCanPlay(true);
setAudioError(null);
};
// 音频可以完整播放(不需要再缓冲)
const handleCanPlayThrough = () => {
setAudioLoading(false);
setAudioBuffering(false);
};
// 音频开始加载
const handleLoadStart = () => {
setAudioLoading(true);
setAudioCanPlay(false);
setAudioError(null);
};
// 音频因缓冲而等待
const handleWaiting = () => {
setAudioBuffering(true);
};
// 音频开始播放(缓冲结束)
const handlePlaying = () => {
setAudioBuffering(false);
};
// 音频加载错误
const handleAudioError = (e) => {
setAudioLoading(false);
setAudioCanPlay(false);
const error = e.target?.error;
let errorMessage = '音频加载失败';
if (error) {
switch (error.code) {
case 1:
errorMessage = '音频加载被中止';
break;
case 2:
errorMessage = '网络错误,无法加载音频';
break;
case 3:
errorMessage = '音频解码失败';
break;
case 4:
errorMessage = '不支持的音频格式';
break;
default:
errorMessage = '音频加载失败';
}
}
setAudioError(errorMessage);
showToast(errorMessage, 'error');
};
// 音频播放结束
const handleEnded = () => {
setIsPlaying(false);
};
const handleSeek = (e, progressElement) => {
if (!audioRef.current || !duration) return;
// 使用传入的元素或 currentTarget
const element = progressElement || e.currentTarget;
const rect = element.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 progressElement = e.currentTarget; // 保存进度条元素引用
const handleMouseMove = (moveEvent) => {
handleSeek(moveEvent, progressElement);
};
const handleMouseUp = () => {
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
};
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
handleSeek(e, progressElement);
};
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传递 prompt_id
const response = await apiClient.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary-async`, {
user_prompt: userPrompt,
prompt_id: selectedPromptId
});
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 generatePassword = () => {
// 生成4位混合密码数字+字母)
const chars = 'ABCDEFGHJKLMNPQRSTUVWXYZabcdefghijkmnpqrstuvwxyz23456789';
let password = '';
for (let i = 0; i < 4; i++) {
password += chars.charAt(Math.floor(Math.random() * chars.length));
}
return password;
};
const handleTogglePassword = async () => {
try {
setPasswordLoading(true);
if (passwordEnabled) {
// 关闭密码
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/access-password`), {
password: null
});
setAccessPassword(null);
setPasswordEnabled(false);
setShowPassword(false);
showToast('访问密码已关闭', 'success');
} else {
// 生成并开启密码
const newPassword = generatePassword();
await apiClient.put(buildApiUrl(`/api/meetings/${meeting_id}/access-password`), {
password: newPassword
});
setAccessPassword(newPassword);
setPasswordEnabled(true);
setShowPassword(true);
showToast('访问密码已生成', 'success');
}
} catch (err) {
console.error('密码操作失败:', err);
showToast(err.response?.data?.message || '密码操作失败,请重试', 'error');
} finally {
setPasswordLoading(false);
}
};
const handleCopyPassword = () => {
if (accessPassword) {
navigator.clipboard.writeText(accessPassword);
setPasswordCopied(true);
showToast('密码已复制到剪贴板', 'success');
setTimeout(() => setPasswordCopied(false), 2000);
}
};
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">
<div className="header-left">
{/* 融合导航栏:返回首页 + 上一条/下一条 */}
<div className="unified-navigation">
<Link to="/dashboard" className="nav-btn back-home-btn">
<ArrowLeft size={18} />
<span>返回首页</span>
</Link>
{navigationInfo.total_count !== null && navigationInfo.total_count > 0 && (
<>
<Link
to={navigationInfo.prev_meeting_id ? `/meetings/${navigationInfo.prev_meeting_id}` : '#'}
state={{ filterContext }}
className={`nav-btn prev-btn ${!navigationInfo.prev_meeting_id ? 'disabled' : ''}`}
onClick={(e) => !navigationInfo.prev_meeting_id && e.preventDefault()}
>
<ChevronLeft size={18} />
<span>上一条</span>
</Link>
{navigationInfo.current_index !== null && (
<span className="nav-position">
{navigationInfo.current_index + 1} / {navigationInfo.total_count}
</span>
)}
<Link
to={navigationInfo.next_meeting_id ? `/meetings/${navigationInfo.next_meeting_id}` : '#'}
state={{ filterContext }}
className={`nav-btn next-btn ${!navigationInfo.next_meeting_id ? 'disabled' : ''}`}
onClick={(e) => !navigationInfo.next_meeting_id && e.preventDefault()}
>
<span>下一条</span>
<ChevronRight size={18} />
</Link>
</>
)}
</div>
</div>
{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 meta-item-password">
<Lock size={18} />
<strong>访问密码:</strong>
{isCreator ? (
// 创建人:显示开启/关闭按钮
<div className="password-control-inline">
<button
className={`password-toggle-inline ${passwordEnabled ? 'enabled' : 'disabled'}`}
onClick={handleTogglePassword}
disabled={passwordLoading}
title={passwordEnabled ? '点击关闭密码' : '点击开启密码'}
>
{passwordLoading ? (
<Loader size={14} className="spin" />
) : passwordEnabled ? (
<>
<span className="password-text">{showPassword ? accessPassword : '••••'}</span>
</>
) : (
<>
<span>开启</span>
</>
)}
</button>
{passwordEnabled && accessPassword && (
<button
className="password-icon-btn"
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
)}
</div>
) : (
// 非创建人:显示密码或"未设置"
passwordEnabled && accessPassword ? (
<div className="password-view-inline">
<span className="password-text">{showPassword ? accessPassword : '••••'}</span>
<button
className="password-icon-btn"
onClick={() => setShowPassword(!showPassword)}
title={showPassword ? '隐藏密码' : '显示密码'}
>
{showPassword ? <EyeOff size={14} /> : <Eye size={14} />}
</button>
</div>
) : (
<span className="no-password-text">未设置</span>
)
)}
</div>
<div className="meta-item meta-item-attendees">
<Users size={18} />
<strong>参会人员<span className="attendee-count">{meeting.attendees.length}</span>:</strong>
<span className="attendees-inline">
{meeting.attendees.map((attendee, index) => (
<span key={index} className="attendee-name">
{typeof attendee === 'string' ? attendee : attendee.caption}
{index < meeting.attendees.length - 1 && '、'}
</span>
))}
</span>
</div>
</div>
</header>
{/* 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>
{audioLoading && (
<span className="audio-loading-hint">
<Loader size={14} className="spin" />
加载中...
</span>
)}
{audioError && (
<span className="audio-error-hint">{audioError}</span>
)}
</div>
)}
<audio
key={audioUrl || 'no-audio'} // 使用audioUrl作为key确保切换会议时重新创建audio元素
ref={audioRef}
src={audioUrl}
style={{ display: 'none' }}
onTimeUpdate={handleTimeUpdate}
onLoadedMetadata={handleLoadedMetadata}
onCanPlay={handleCanPlay}
onCanPlayThrough={handleCanPlayThrough}
onLoadStart={handleLoadStart}
onWaiting={handleWaiting}
onPlaying={handlePlaying}
onError={handleAudioError}
onEnded={handleEnded}
preload="metadata"
/>
{/* 转录状态显示 */}
{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 ${audioLoading ? 'loading' : ''} ${audioBuffering ? 'buffering' : ''} ${!audioCanPlay ? 'disabled' : ''}`}
onClick={handlePlayPause}
disabled={audioLoading && !isPlaying}
title={audioLoading ? '音频加载中...' : (audioBuffering ? '缓冲中...' : (isPlaying ? '暂停' : '播放'))}
>
{audioLoading && !isPlaying ? (
<Loader size={24} className="spin" />
) : audioBuffering ? (
<Loader size={24} className="spin" />
) : 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>
{/* 模板选择器 */}
{promptList.length > 0 && (
<div className="prompt-selector">
<label className="prompt-label">选择总结模板</label>
<div className="prompt-tabs">
{promptList.map(prompt => (
<button
key={prompt.id}
className={`prompt-tab ${selectedPromptId === prompt.id ? 'active' : ''}`}
onClick={() => setSelectedPromptId(prompt.id)}
title={prompt.description}
>
{prompt.name}
{!!prompt.is_default && <span className="default-badge">默认</span>}
</button>
))}
</div>
</div>
)}
<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>
<MarkdownRenderer
content={summaryResult.content}
className="summary-result-content"
/>
</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;