2025-08-05 01:44:28 +00:00
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
2025-08-05 02:58:13 +00:00
|
|
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
2025-08-29 08:37:55 +00:00
|
|
|
|
import apiClient from '../utils/apiClient';
|
2025-11-17 02:31:26 +00:00
|
|
|
|
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';
|
2025-08-05 01:44:28 +00:00
|
|
|
|
import ReactMarkdown from 'react-markdown';
|
2025-08-05 02:58:13 +00:00
|
|
|
|
import remarkGfm from 'remark-gfm';
|
|
|
|
|
|
import rehypeRaw from 'rehype-raw';
|
|
|
|
|
|
import rehypeSanitize from 'rehype-sanitize';
|
2025-10-16 10:47:53 +00:00
|
|
|
|
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
|
2025-10-16 09:15:07 +00:00
|
|
|
|
import ContentViewer from '../components/ContentViewer';
|
2025-09-19 08:51:49 +00:00
|
|
|
|
import TagDisplay from '../components/TagDisplay';
|
2025-10-31 06:55:19 +00:00
|
|
|
|
import ConfirmDialog from '../components/ConfirmDialog';
|
|
|
|
|
|
import Toast from '../components/Toast';
|
|
|
|
|
|
import PageLoading from '../components/PageLoading';
|
2025-11-07 09:11:54 +00:00
|
|
|
|
import QRCodeModal from '../components/QRCodeModal';
|
2025-11-17 02:31:26 +00:00
|
|
|
|
import Dropdown from '../components/Dropdown';
|
|
|
|
|
|
import exportService from '../services/exportService';
|
2025-09-16 09:00:09 +00:00
|
|
|
|
import { Tabs } from 'antd';
|
2025-08-05 01:44:28 +00:00
|
|
|
|
import './MeetingDetails.css';
|
|
|
|
|
|
|
2025-09-16 09:00:09 +00:00
|
|
|
|
const { TabPane } = Tabs;
|
|
|
|
|
|
|
2025-08-05 02:58:13 +00:00
|
|
|
|
const MeetingDetails = ({ user }) => {
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const { meeting_id } = useParams();
|
2025-08-05 02:58:13 +00:00
|
|
|
|
const navigate = useNavigate();
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const [meeting, setMeeting] = useState(null);
|
|
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState('');
|
|
|
|
|
|
const [isPlaying, setIsPlaying] = useState(false);
|
|
|
|
|
|
const [currentTime, setCurrentTime] = useState(0);
|
|
|
|
|
|
const [duration, setDuration] = useState(0);
|
|
|
|
|
|
const [volume, setVolume] = useState(1);
|
|
|
|
|
|
const [transcript, setTranscript] = useState([]);
|
|
|
|
|
|
const [showTranscript, setShowTranscript] = useState(true);
|
|
|
|
|
|
const [audioUrl, setAudioUrl] = useState(null);
|
|
|
|
|
|
const [audioFileName, setAudioFileName] = useState(null);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
2025-08-25 08:11:29 +00:00
|
|
|
|
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
|
|
|
|
|
|
const [editingSpeakers, setEditingSpeakers] = useState({});
|
|
|
|
|
|
const [speakerList, setSpeakerList] = useState([]);
|
2025-08-26 13:59:15 +00:00
|
|
|
|
const [showTranscriptEdit, setShowTranscriptEdit] = useState(false);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
const [transcriptionStatus, setTranscriptionStatus] = useState(null);
|
|
|
|
|
|
const [transcriptionProgress, setTranscriptionProgress] = useState(0);
|
|
|
|
|
|
const [statusCheckInterval, setStatusCheckInterval] = useState(null);
|
|
|
|
|
|
const [autoScrollEnabled, setAutoScrollEnabled] = useState(false); // 控制自动滚动
|
2025-08-26 13:59:15 +00:00
|
|
|
|
const [editingTranscriptIndex, setEditingTranscriptIndex] = useState(-1);
|
|
|
|
|
|
const [editingTranscripts, setEditingTranscripts] = useState({});
|
|
|
|
|
|
const [currentSubtitle, setCurrentSubtitle] = useState('');
|
|
|
|
|
|
const [currentSpeaker, setCurrentSpeaker] = useState('');
|
|
|
|
|
|
const [showSummaryModal, setShowSummaryModal] = useState(false);
|
|
|
|
|
|
const [summaryLoading, setSummaryLoading] = useState(false);
|
|
|
|
|
|
const [summaryResult, setSummaryResult] = useState(null);
|
|
|
|
|
|
const [userPrompt, setUserPrompt] = useState('');
|
|
|
|
|
|
const [summaryHistory, setSummaryHistory] = useState([]);
|
|
|
|
|
|
const [currentHighlightIndex, setCurrentHighlightIndex] = useState(-1);
|
2025-09-09 03:36:26 +00:00
|
|
|
|
const [summaryTaskId, setSummaryTaskId] = useState(null);
|
|
|
|
|
|
const [summaryTaskStatus, setSummaryTaskStatus] = useState(null);
|
|
|
|
|
|
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
|
|
|
|
|
|
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
|
|
|
|
|
|
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
const [toasts, setToasts] = useState([]);
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const [showQRModal, setShowQRModal] = useState(false);
|
2025-11-17 02:31:26 +00:00
|
|
|
|
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('');
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const audioRef = useRef(null);
|
2025-08-26 13:59:15 +00:00
|
|
|
|
const transcriptRefs = useRef([]);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
// 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));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchMeetingDetails();
|
2025-11-17 02:31:26 +00:00
|
|
|
|
loadFileSizeConfig();
|
|
|
|
|
|
|
2025-08-28 08:02:34 +00:00
|
|
|
|
// Cleanup interval on unmount
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
if (statusCheckInterval) {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
console.log('组件卸载,清理转录状态轮询定时器');
|
2025-08-28 08:02:34 +00:00
|
|
|
|
clearInterval(statusCheckInterval);
|
2025-08-29 08:37:55 +00:00
|
|
|
|
setStatusCheckInterval(null);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
2025-09-09 03:36:26 +00:00
|
|
|
|
if (summaryPollInterval) {
|
|
|
|
|
|
console.log('组件卸载,清理总结任务轮询定时器');
|
|
|
|
|
|
clearInterval(summaryPollInterval);
|
|
|
|
|
|
setSummaryPollInterval(null);
|
|
|
|
|
|
}
|
2025-08-28 08:02:34 +00:00
|
|
|
|
};
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}, [meeting_id]);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
|
|
|
|
|
|
// Cleanup interval when status changes
|
|
|
|
|
|
useEffect(() => {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
if (transcriptionStatus) {
|
|
|
|
|
|
// 如果转录已完成、失败或取消,清除轮询
|
|
|
|
|
|
if (['completed', 'failed', 'error', 'cancelled'].includes(transcriptionStatus.status)) {
|
|
|
|
|
|
if (statusCheckInterval) {
|
|
|
|
|
|
clearInterval(statusCheckInterval);
|
|
|
|
|
|
setStatusCheckInterval(null);
|
|
|
|
|
|
}
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [transcriptionStatus, statusCheckInterval]);
|
|
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-28 08:02:34 +00:00
|
|
|
|
const startStatusPolling = (taskId) => {
|
|
|
|
|
|
// Clear existing interval
|
|
|
|
|
|
if (statusCheckInterval) {
|
|
|
|
|
|
clearInterval(statusCheckInterval);
|
2025-08-29 08:37:55 +00:00
|
|
|
|
setStatusCheckInterval(null);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Poll every 3 seconds
|
|
|
|
|
|
const interval = setInterval(async () => {
|
|
|
|
|
|
try {
|
2025-09-30 04:13:54 +00:00
|
|
|
|
const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.TRANSCRIPTION_STATUS(taskId)));
|
2025-08-28 08:02:34 +00:00
|
|
|
|
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);
|
|
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
// Refresh transcript data only if completed successfully
|
2025-08-28 08:02:34 +00:00
|
|
|
|
if (status.status === 'completed') {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
console.log('转录完成,刷新转录数据(无loading)');
|
|
|
|
|
|
await refreshTranscriptData();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.log('转录失败或取消,状态:', status.status);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
2025-08-29 08:37:55 +00:00
|
|
|
|
|
|
|
|
|
|
// 再次确保清除状态
|
|
|
|
|
|
setTranscriptionStatus(status);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
} 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);
|
|
|
|
|
|
};
|
2025-08-05 01:44:28 +00:00
|
|
|
|
|
|
|
|
|
|
const fetchMeetingDetails = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
|
|
|
|
|
|
// Fallback URL construction in case config fails
|
2025-08-25 03:30:27 +00:00
|
|
|
|
const baseUrl = ""
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const detailEndpoint = API_ENDPOINTS?.MEETINGS?.DETAIL?.(meeting_id) || `/api/meetings/${meeting_id}`;
|
|
|
|
|
|
const audioEndpoint = API_ENDPOINTS?.MEETINGS?.AUDIO?.(meeting_id) || `/api/meetings/${meeting_id}/audio`;
|
|
|
|
|
|
const transcriptEndpoint = API_ENDPOINTS?.MEETINGS?.TRANSCRIPT?.(meeting_id) || `/api/meetings/${meeting_id}/transcript`;
|
|
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
const response = await apiClient.get(`${baseUrl}${detailEndpoint}`);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
setMeeting(response.data);
|
|
|
|
|
|
|
2025-08-28 08:02:34 +00:00
|
|
|
|
// Handle transcription status from meeting details
|
|
|
|
|
|
if (response.data.transcription_status) {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
const newStatus = response.data.transcription_status;
|
|
|
|
|
|
setTranscriptionStatus(newStatus);
|
|
|
|
|
|
setTranscriptionProgress(newStatus.progress || 0);
|
2025-08-28 08:02:34 +00:00
|
|
|
|
|
|
|
|
|
|
// If transcription is in progress, start polling for updates
|
2025-08-29 08:37:55 +00:00
|
|
|
|
// 但只有当前没有在轮询时才启动新的轮询
|
|
|
|
|
|
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);
|
|
|
|
|
|
}
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setTranscriptionStatus(null);
|
|
|
|
|
|
setTranscriptionProgress(0);
|
2025-08-29 08:37:55 +00:00
|
|
|
|
// 清除轮询
|
|
|
|
|
|
if (statusCheckInterval) {
|
|
|
|
|
|
clearInterval(statusCheckInterval);
|
|
|
|
|
|
setStatusCheckInterval(null);
|
|
|
|
|
|
}
|
2025-08-28 08:02:34 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
// Fetch audio file if available
|
|
|
|
|
|
try {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
const audioResponse = await apiClient.get(`${baseUrl}${audioEndpoint}`);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
// Construct URL using uploads path and relative path from database
|
2025-08-25 03:30:27 +00:00
|
|
|
|
setAudioUrl(`${baseUrl}${audioResponse.data.file_path}`);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
setAudioFileName(audioResponse.data.file_name);
|
|
|
|
|
|
} catch (audioError) {
|
|
|
|
|
|
console.warn('No audio file available:', audioError);
|
|
|
|
|
|
setAudioUrl(null);
|
|
|
|
|
|
setAudioFileName(null);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// Fetch transcript segments from database
|
|
|
|
|
|
try {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
const transcriptResponse = await apiClient.get(`${baseUrl}${transcriptEndpoint}`);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
setTranscript(transcriptResponse.data);
|
2025-08-25 08:11:29 +00:00
|
|
|
|
|
|
|
|
|
|
console.log('First transcript item:', transcriptResponse.data[0]);
|
|
|
|
|
|
|
|
|
|
|
|
// 现在使用speaker_id字段进行分组
|
|
|
|
|
|
const allSpeakerIds = transcriptResponse.data
|
|
|
|
|
|
.map(item => item.speaker_id)
|
|
|
|
|
|
.filter(speakerId => speakerId !== null && speakerId !== undefined);
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Extracted speaker IDs:', allSpeakerIds);
|
|
|
|
|
|
|
|
|
|
|
|
const uniqueSpeakers = [...new Set(allSpeakerIds)]
|
|
|
|
|
|
.map(speakerId => {
|
|
|
|
|
|
const segment = transcriptResponse.data.find(item => item.speaker_id === speakerId);
|
|
|
|
|
|
return {
|
|
|
|
|
|
speaker_id: speakerId,
|
|
|
|
|
|
speaker_tag: segment ? (segment.speaker_tag || `发言人 ${speakerId}`) : `发言人 ${speakerId}`
|
|
|
|
|
|
};
|
|
|
|
|
|
})
|
|
|
|
|
|
.sort((a, b) => a.speaker_id - b.speaker_id); // 按speaker_id数值排序
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Final unique speakers:', uniqueSpeakers);
|
|
|
|
|
|
setSpeakerList(uniqueSpeakers);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化编辑状态
|
|
|
|
|
|
const initialEditingState = {};
|
|
|
|
|
|
uniqueSpeakers.forEach(speaker => {
|
|
|
|
|
|
initialEditingState[speaker.speaker_id] = speaker.speaker_tag;
|
|
|
|
|
|
});
|
|
|
|
|
|
setEditingSpeakers(initialEditingState);
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
} catch (transcriptError) {
|
|
|
|
|
|
console.warn('No transcript data available:', transcriptError);
|
|
|
|
|
|
setTranscript([]);
|
2025-08-25 08:11:29 +00:00
|
|
|
|
setSpeakerList([]);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching meeting details:', err);
|
|
|
|
|
|
setError('无法加载会议详情,请稍后重试。');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
const loadFileSizeConfig = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const fileSize = await configService.getMaxFileSize();
|
|
|
|
|
|
setMaxFileSize(fileSize);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.warn('Failed to load file size config:', error);
|
|
|
|
|
|
}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
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);
|
2025-09-03 10:10:52 +00:00
|
|
|
|
}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
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');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const handlePlayPause = () => {
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
if (isPlaying) {
|
|
|
|
|
|
audioRef.current.pause();
|
|
|
|
|
|
} else {
|
|
|
|
|
|
audioRef.current.play();
|
|
|
|
|
|
}
|
|
|
|
|
|
setIsPlaying(!isPlaying);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleTimeUpdate = () => {
|
|
|
|
|
|
if (audioRef.current) {
|
2025-08-26 13:59:15 +00:00
|
|
|
|
const currentTime = audioRef.current.currentTime;
|
|
|
|
|
|
setCurrentTime(currentTime);
|
|
|
|
|
|
|
|
|
|
|
|
// 更新字幕显示
|
|
|
|
|
|
updateSubtitle(currentTime);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const updateSubtitle = (currentTime) => {
|
|
|
|
|
|
const currentTimeMs = currentTime * 1000;
|
|
|
|
|
|
const currentSegment = transcript.find(item =>
|
|
|
|
|
|
currentTimeMs >= item.start_time_ms && currentTimeMs <= item.end_time_ms
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
if (currentSegment) {
|
|
|
|
|
|
setCurrentSubtitle(currentSegment.text_content);
|
|
|
|
|
|
// 确保使用 speaker_tag 来保持一致性
|
|
|
|
|
|
setCurrentSpeaker(currentSegment.speaker_tag || `发言人 ${currentSegment.speaker_id}`);
|
|
|
|
|
|
|
|
|
|
|
|
// 找到当前segment在transcript数组中的索引
|
|
|
|
|
|
const currentIndex = transcript.findIndex(item => item.segment_id === currentSegment.segment_id);
|
|
|
|
|
|
setCurrentHighlightIndex(currentIndex);
|
|
|
|
|
|
|
2025-08-28 08:02:34 +00:00
|
|
|
|
// 滚动到对应的转录条目(仅在启用自动滚动时)
|
|
|
|
|
|
if (autoScrollEnabled && currentIndex !== -1 && transcriptRefs.current[currentIndex]) {
|
2025-08-26 13:59:15 +00:00
|
|
|
|
transcriptRefs.current[currentIndex].scrollIntoView({
|
|
|
|
|
|
behavior: 'smooth',
|
|
|
|
|
|
block: 'center'
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setCurrentSubtitle('');
|
|
|
|
|
|
setCurrentSpeaker('');
|
|
|
|
|
|
setCurrentHighlightIndex(-1);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleLoadedMetadata = () => {
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
setDuration(audioRef.current.duration);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSeek = (e) => {
|
|
|
|
|
|
if (!audioRef.current || !duration) return;
|
|
|
|
|
|
|
|
|
|
|
|
const rect = e.currentTarget.getBoundingClientRect();
|
|
|
|
|
|
const percent = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
|
|
|
|
|
|
const seekTime = percent * duration;
|
|
|
|
|
|
|
|
|
|
|
|
audioRef.current.currentTime = seekTime;
|
|
|
|
|
|
setCurrentTime(seekTime);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleProgressMouseDown = (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
const handleMouseMove = (moveEvent) => {
|
|
|
|
|
|
handleSeek(moveEvent);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleMouseUp = () => {
|
|
|
|
|
|
document.removeEventListener('mousemove', handleMouseMove);
|
|
|
|
|
|
document.removeEventListener('mouseup', handleMouseUp);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
document.addEventListener('mousemove', handleMouseMove);
|
|
|
|
|
|
document.addEventListener('mouseup', handleMouseUp);
|
|
|
|
|
|
|
|
|
|
|
|
handleSeek(e);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleVolumeChange = (e) => {
|
|
|
|
|
|
const newVolume = parseFloat(e.target.value);
|
|
|
|
|
|
setVolume(newVolume);
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
audioRef.current.volume = newVolume;
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const jumpToTime = (timestamp) => {
|
|
|
|
|
|
if (audioRef.current) {
|
|
|
|
|
|
audioRef.current.currentTime = timestamp;
|
|
|
|
|
|
setCurrentTime(timestamp);
|
2025-08-25 03:30:27 +00:00
|
|
|
|
audioRef.current.play();
|
|
|
|
|
|
setIsPlaying(true);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-05 02:58:13 +00:00
|
|
|
|
const handleDeleteMeeting = async () => {
|
|
|
|
|
|
try {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id)));
|
2025-08-05 02:58:13 +00:00
|
|
|
|
navigate('/dashboard');
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error deleting meeting:', err);
|
2025-09-30 04:13:54 +00:00
|
|
|
|
setError(err.response?.data?.message || '删除会议失败,请重试');
|
2025-08-05 02:58:13 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-25 08:11:29 +00:00
|
|
|
|
const handleSpeakerTagUpdate = async (speakerId, newTag) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
2025-08-29 08:37:55 +00:00
|
|
|
|
await apiClient.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags`, {
|
2025-08-25 08:11:29 +00:00
|
|
|
|
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);
|
2025-09-30 04:13:54 +00:00
|
|
|
|
setError(err.response?.data?.message || '更新发言人标签失败,请重试');
|
2025-08-25 08:11:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBatchSpeakerUpdate = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
|
|
|
|
|
const updates = Object.entries(editingSpeakers).map(([speakerId, newTag]) => ({
|
|
|
|
|
|
speaker_id: parseInt(speakerId), // 确保传递整数类型
|
|
|
|
|
|
new_tag: newTag
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
await apiClient.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags/batch`, {
|
2025-08-25 08:11:29 +00:00
|
|
|
|
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);
|
2025-09-30 04:13:54 +00:00
|
|
|
|
setError(err.response?.data?.message || '批量更新发言人标签失败,请重试');
|
2025-08-25 08:11:29 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditingSpeakerChange = (speakerId, newTag) => {
|
|
|
|
|
|
setEditingSpeakers(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[speakerId]: newTag
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSpeakerEditOpen = () => {
|
|
|
|
|
|
console.log('Opening speaker edit modal');
|
|
|
|
|
|
console.log('Current transcript:', transcript);
|
|
|
|
|
|
console.log('Current speakerList:', speakerList);
|
|
|
|
|
|
console.log('Current editingSpeakers:', editingSpeakers);
|
|
|
|
|
|
setShowSpeakerEdit(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
const handleTranscriptEdit = (index) => {
|
|
|
|
|
|
setEditingTranscriptIndex(index);
|
|
|
|
|
|
|
|
|
|
|
|
// 获取前一条、当前条、后一条的数据
|
|
|
|
|
|
const editItems = [];
|
|
|
|
|
|
if (index > 0) editItems.push({ ...transcript[index - 1], originalIndex: index - 1 });
|
|
|
|
|
|
editItems.push({ ...transcript[index], originalIndex: index });
|
|
|
|
|
|
if (index < transcript.length - 1) editItems.push({ ...transcript[index + 1], originalIndex: index + 1 });
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化编辑状态
|
|
|
|
|
|
const initialEditState = {};
|
|
|
|
|
|
editItems.forEach(item => {
|
|
|
|
|
|
initialEditState[item.originalIndex] = item.text_content;
|
|
|
|
|
|
});
|
|
|
|
|
|
setEditingTranscripts(initialEditState);
|
|
|
|
|
|
setShowTranscriptEdit(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleTranscriptTextChange = (index, newText) => {
|
|
|
|
|
|
setEditingTranscripts(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[index]: newText
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSaveTranscriptEdits = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
|
|
|
|
|
const updates = Object.entries(editingTranscripts).map(([index, text_content]) => ({
|
|
|
|
|
|
segment_id: transcript[index].segment_id,
|
|
|
|
|
|
text_content: text_content
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
await apiClient.put(`${baseUrl}/api/meetings/${meeting_id}/transcript/batch`, {
|
2025-08-26 13:59:15 +00:00
|
|
|
|
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);
|
2025-09-30 04:13:54 +00:00
|
|
|
|
setError(err.response?.data?.message || '更新转录内容失败,请重试');
|
2025-08-26 13:59:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-09 03:36:26 +00:00
|
|
|
|
// AI总结相关函数 - 使用异步API
|
2025-08-26 13:59:15 +00:00
|
|
|
|
const generateSummary = async () => {
|
|
|
|
|
|
if (summaryLoading) return;
|
|
|
|
|
|
|
|
|
|
|
|
setSummaryLoading(true);
|
2025-09-09 03:36:26 +00:00
|
|
|
|
setSummaryTaskProgress(0);
|
|
|
|
|
|
setSummaryTaskMessage('正在启动AI分析...');
|
|
|
|
|
|
setSummaryTaskStatus('pending');
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
2025-09-09 03:36:26 +00:00
|
|
|
|
// 使用异步API
|
|
|
|
|
|
const response = await apiClient.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary-async`, {
|
2025-08-26 13:59:15 +00:00
|
|
|
|
user_prompt: userPrompt
|
|
|
|
|
|
});
|
|
|
|
|
|
|
2025-09-09 03:36:26 +00:00
|
|
|
|
const taskId = response.data.task_id;
|
|
|
|
|
|
setSummaryTaskId(taskId);
|
2025-08-26 13:59:15 +00:00
|
|
|
|
|
2025-09-09 03:36:26 +00:00
|
|
|
|
// 开始轮询任务状态
|
|
|
|
|
|
const interval = setInterval(async () => {
|
|
|
|
|
|
try {
|
2025-09-30 04:13:54 +00:00
|
|
|
|
const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(taskId)));
|
2025-09-09 03:36:26 +00:00
|
|
|
|
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秒查询一次
|
2025-08-26 13:59:15 +00:00
|
|
|
|
|
2025-09-09 03:36:26 +00:00
|
|
|
|
setSummaryPollInterval(interval);
|
2025-08-29 08:37:55 +00:00
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
} catch (err) {
|
2025-09-09 03:36:26 +00:00
|
|
|
|
console.error('Error starting summary generation:', err);
|
2025-09-30 04:13:54 +00:00
|
|
|
|
const errorMessage = err.response?.data?.message || '启动AI总结失败,请重试。';
|
2025-09-16 09:00:09 +00:00
|
|
|
|
|
|
|
|
|
|
setError(errorMessage); // Set the more specific error
|
|
|
|
|
|
setSummaryTaskMessage(`生成失败:${errorMessage}`); // Also show it in the modal
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
setSummaryLoading(false);
|
2025-09-09 03:36:26 +00:00
|
|
|
|
setSummaryTaskProgress(0);
|
2025-08-26 13:59:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const fetchSummaryHistory = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
2025-09-09 03:36:26 +00:00
|
|
|
|
// 获取所有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 {
|
2025-09-30 04:13:54 +00:00
|
|
|
|
const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.TASKS.SUMMARY_STATUS(runningTask.task_id)));
|
2025-09-09 03:36:26 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching summary history:', err);
|
2025-09-09 03:36:26 +00:00
|
|
|
|
setSummaryHistory([]);
|
2025-08-26 13:59:15 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const openSummaryModal = async () => {
|
2025-09-16 09:00:09 +00:00
|
|
|
|
// Frontend check before opening the modal
|
|
|
|
|
|
if (!transcriptionStatus || transcriptionStatus.status !== 'completed') {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('会议转录尚未完成或处理失败,请在转录成功后再生成AI总结。', 'warning');
|
2025-09-16 09:00:09 +00:00
|
|
|
|
return; // Prevent modal from opening
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
setShowSummaryModal(true);
|
|
|
|
|
|
setUserPrompt('');
|
|
|
|
|
|
setSummaryResult(null);
|
|
|
|
|
|
await fetchSummaryHistory();
|
|
|
|
|
|
};
|
2025-08-29 08:37:55 +00:00
|
|
|
|
|
|
|
|
|
|
const closeSummaryModal = async () => {
|
|
|
|
|
|
setShowSummaryModal(false);
|
|
|
|
|
|
// 关闭弹窗时只刷新摘要部分,避免整页刷新
|
|
|
|
|
|
if (summaryResult) {
|
|
|
|
|
|
await refreshMeetingSummary();
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
2025-09-16 09:00:09 +00:00
|
|
|
|
|
2025-10-16 09:15:07 +00:00
|
|
|
|
// 导出会议总结为图片
|
|
|
|
|
|
const exportSummaryToImage = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!meeting?.summary) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('暂无会议总结内容,请先生成AI总结。', 'warning');
|
2025-10-16 09:15:07 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
const meetingTime = tools.formatDateTime(meeting.meeting_time);
|
2025-10-16 09:15:07 +00:00
|
|
|
|
const attendeesList = meeting.attendees.map(attendee =>
|
|
|
|
|
|
typeof attendee === 'string' ? attendee : attendee.caption
|
|
|
|
|
|
).join('、');
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
await exportService.exportMeetingSummaryToImage({
|
|
|
|
|
|
title: meeting.title || '会议总结',
|
|
|
|
|
|
summary: meeting.summary,
|
|
|
|
|
|
metadata: {
|
|
|
|
|
|
meetingTime,
|
|
|
|
|
|
creator: meeting.creator_username,
|
|
|
|
|
|
attendeeCount: meeting.attendees.length,
|
|
|
|
|
|
attendees: attendeesList
|
|
|
|
|
|
}
|
2025-10-16 09:15:07 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
showToast('总结已成功导出为图片', 'success');
|
2025-10-16 09:15:07 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('图片导出失败:', error);
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('图片导出失败,请重试。', 'error');
|
2025-10-16 09:15:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 导出思维导图为图片
|
|
|
|
|
|
const exportMindMapToImage = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
if (!meeting?.summary) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
showToast('暂无内容,无法导出思维导图。', 'warning');
|
2025-10-16 09:15:07 +00:00
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
await exportService.exportMindMapToImage({
|
|
|
|
|
|
title: meeting.title || '会议'
|
2025-10-16 09:15:07 +00:00
|
|
|
|
});
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
showToast('思维导图已成功导出为图片', 'success');
|
2025-10-16 09:15:07 +00:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('思维导图导出失败:', error);
|
2025-11-17 02:31:26 +00:00
|
|
|
|
showToast(error.message || '思维导图导出失败,请重试。', 'error');
|
2025-10-16 09:15:07 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-05 02:58:13 +00:00
|
|
|
|
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
if (loading) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
return <PageLoading message="加载中..." />;
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return <div className="error-container"><p>{error}</p><Link to="/dashboard"><span className="btn-secondary">返回首页</span></Link></div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (!meeting) {
|
|
|
|
|
|
return <div className="error-container"><p>未找到会议信息。</p><Link to="/dashboard"><span className="btn-secondary">返回首页</span></Link></div>;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="meeting-details-page">
|
|
|
|
|
|
<div className="details-header">
|
|
|
|
|
|
<Link to="/dashboard">
|
|
|
|
|
|
<span className="back-link">
|
|
|
|
|
|
<ArrowLeft size={20} />
|
|
|
|
|
|
<span>返回首页</span>
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Link>
|
2025-08-05 02:58:13 +00:00
|
|
|
|
{isCreator && (
|
|
|
|
|
|
<div className="meeting-actions">
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<Link to={`/meetings/edit/${meeting_id}`} className="action-btn icon-only edit-btn" title="编辑会议">
|
2025-08-05 02:58:13 +00:00
|
|
|
|
<Edit size={16} />
|
|
|
|
|
|
</Link>
|
2025-10-31 06:55:19 +00:00
|
|
|
|
<button
|
2025-11-07 09:11:54 +00:00
|
|
|
|
className="action-btn icon-only delete-btn"
|
2025-10-31 06:55:19 +00:00
|
|
|
|
onClick={() => setDeleteConfirmInfo({ id: meeting_id, title: meeting.title })}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
title="删除会议"
|
2025-08-05 02:58:13 +00:00
|
|
|
|
>
|
|
|
|
|
|
<Trash2 size={16} />
|
2025-11-07 09:11:54 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="action-btn icon-only qr-btn"
|
|
|
|
|
|
onClick={() => setShowQRModal(true)}
|
|
|
|
|
|
title="分享会议二维码"
|
|
|
|
|
|
>
|
|
|
|
|
|
<QrCode size={16} />
|
2025-08-05 02:58:13 +00:00
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="details-layout">
|
|
|
|
|
|
<div className="main-content">
|
|
|
|
|
|
<div className="details-content-card">
|
2025-10-15 06:30:41 +00:00
|
|
|
|
<header className="detail-card-header">
|
2025-09-19 08:51:49 +00:00
|
|
|
|
<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>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
<div className="meta-grid">
|
|
|
|
|
|
<div className="meta-item">
|
|
|
|
|
|
<Calendar size={18} />
|
|
|
|
|
|
<strong>会议日期:</strong>
|
2025-11-17 02:31:26 +00:00
|
|
|
|
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}</span>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meta-item">
|
|
|
|
|
|
<Clock size={18} />
|
|
|
|
|
|
<strong>会议时间:</strong>
|
2025-11-17 02:31:26 +00:00
|
|
|
|
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[1]}</span>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meta-item">
|
|
|
|
|
|
<User size={18} />
|
|
|
|
|
|
<strong>创建人:</strong>
|
|
|
|
|
|
<span>{meeting.creator_username}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="meta-item">
|
|
|
|
|
|
<Users size={18} />
|
|
|
|
|
|
<strong>参会人数:</strong>
|
|
|
|
|
|
<span>{meeting.attendees.length}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
2025-08-05 02:58:13 +00:00
|
|
|
|
<section className="card-section">
|
|
|
|
|
|
<h2><Users size={20} /> 参会人员</h2>
|
|
|
|
|
|
<div className="attendees-list">
|
|
|
|
|
|
{meeting.attendees.map((attendee, index) => (
|
|
|
|
|
|
<div key={index} className="attendee-chip">
|
|
|
|
|
|
<User size={16} />
|
|
|
|
|
|
<span>{typeof attendee === 'string' ? attendee : attendee.caption}</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
{/* Audio Player Section */}
|
|
|
|
|
|
<section className="card-section audio-section">
|
2025-11-17 02:31:26 +00:00
|
|
|
|
<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>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
|
2025-08-28 08:02:34 +00:00
|
|
|
|
{/* 转录状态显示 */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
<div className="player-controls">
|
|
|
|
|
|
<button className="play-button" onClick={handlePlayPause}>
|
|
|
|
|
|
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="time-info">
|
2025-11-17 02:31:26 +00:00
|
|
|
|
<span>{tools.formatDuration(currentTime)}</span>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</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">
|
2025-11-17 02:31:26 +00:00
|
|
|
|
<span>{tools.formatDuration(duration)}</span>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="volume-control">
|
|
|
|
|
|
<Volume2 size={18} />
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="range"
|
|
|
|
|
|
min="0"
|
|
|
|
|
|
max="1"
|
|
|
|
|
|
step="0.1"
|
|
|
|
|
|
value={volume}
|
|
|
|
|
|
onChange={handleVolumeChange}
|
|
|
|
|
|
className="volume-slider"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-26 13:59:15 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 分割线 */}
|
|
|
|
|
|
<div className="audio-divider"></div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 动态字幕显示 */}
|
|
|
|
|
|
<div className="subtitle-display">
|
|
|
|
|
|
{currentSubtitle ? (
|
|
|
|
|
|
<div className="subtitle-content">
|
|
|
|
|
|
<div className="speaker-indicator">
|
|
|
|
|
|
{currentSpeaker}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="subtitle-text">
|
|
|
|
|
|
{currentSubtitle}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="subtitle-placeholder">
|
|
|
|
|
|
<div className="placeholder-text">播放音频时将在此处显示实时字幕</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="no-audio">
|
|
|
|
|
|
<div className="no-audio-icon">
|
|
|
|
|
|
<Volume2 size={48} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<p className="no-audio-message">该会议没有录音文件</p>
|
|
|
|
|
|
<p className="no-audio-hint">录音功能可能未开启或录音文件丢失</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</section>
|
|
|
|
|
|
|
2025-11-17 02:31:26 +00:00
|
|
|
|
{/* 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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-09-16 09:00:09 +00:00
|
|
|
|
<section className="card-section summary-tabs-section">
|
2025-10-16 09:15:07 +00:00
|
|
|
|
<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>
|
|
|
|
|
|
)
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Transcript Sidebar */}
|
|
|
|
|
|
<div className="transcript-sidebar">
|
|
|
|
|
|
<div className="transcript-header">
|
2025-08-28 08:02:34 +00:00
|
|
|
|
<h3>
|
|
|
|
|
|
<MessageCircle size={20} />
|
|
|
|
|
|
对话转录
|
2025-08-29 08:37:55 +00:00
|
|
|
|
<span
|
|
|
|
|
|
className="sync-scroll-icon"
|
2025-08-28 08:02:34 +00:00
|
|
|
|
onClick={() => setAutoScrollEnabled(!autoScrollEnabled)}
|
2025-08-29 08:37:55 +00:00
|
|
|
|
title={autoScrollEnabled ? "关闭同步滚动" : "开启同步滚动"}
|
2025-08-28 08:02:34 +00:00
|
|
|
|
>
|
2025-08-29 08:37:55 +00:00
|
|
|
|
{autoScrollEnabled ? <RefreshCw size={16} /> : <RefreshCwOff size={16} />}
|
|
|
|
|
|
</span>
|
2025-08-28 08:02:34 +00:00
|
|
|
|
</h3>
|
2025-08-25 08:11:29 +00:00
|
|
|
|
<div className="transcript-controls">
|
|
|
|
|
|
{isCreator && (
|
2025-08-26 13:59:15 +00:00
|
|
|
|
<>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="edit-speakers-btn"
|
|
|
|
|
|
onClick={handleSpeakerEditOpen}
|
|
|
|
|
|
title="编辑发言人标签"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings size={16} />
|
|
|
|
|
|
<span>编辑标签</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="ai-summary-btn"
|
|
|
|
|
|
onClick={openSummaryModal}
|
|
|
|
|
|
title="AI总结"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Brain size={16} />
|
|
|
|
|
|
<span>AI总结</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</>
|
2025-08-25 08:11:29 +00:00
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
<div className="transcript-content">
|
2025-11-10 11:42:44 +00:00
|
|
|
|
{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}
|
2025-08-26 13:59:15 +00:00
|
|
|
|
ref={(el) => transcriptRefs.current[index] = el}
|
|
|
|
|
|
className={`transcript-item ${currentHighlightIndex === index ? 'active' : ''}`}
|
2025-08-25 03:30:27 +00:00
|
|
|
|
>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
<div className="transcript-header-item">
|
2025-11-10 11:42:44 +00:00
|
|
|
|
<span
|
2025-08-25 08:11:29 +00:00
|
|
|
|
className="speaker-name clickable"
|
|
|
|
|
|
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
title="跳转到此时间点播放"
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.speaker_tag}
|
2025-11-10 11:42:44 +00:00
|
|
|
|
<span className="speaker-index"> {currentSpeakerIndex}/{totalSpeakerSegments}</span>
|
2025-08-25 03:30:27 +00:00
|
|
|
|
</span>
|
2025-08-25 08:11:29 +00:00
|
|
|
|
<div className="transcript-item-actions">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="timestamp clickable"
|
|
|
|
|
|
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
title="跳转到此时间点播放"
|
|
|
|
|
|
>
|
2025-11-17 02:31:26 +00:00
|
|
|
|
{tools.formatDuration(item.start_time_ms / 1000)}
|
2025-08-25 08:11:29 +00:00
|
|
|
|
</span>
|
2025-08-26 13:59:15 +00:00
|
|
|
|
{isCreator && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="edit-transcript-btn"
|
|
|
|
|
|
onClick={() => handleTranscriptEdit(index)}
|
|
|
|
|
|
title="编辑转录内容"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Edit3 size={14} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
2025-08-25 08:11:29 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-10 11:42:44 +00:00
|
|
|
|
<div
|
2025-08-25 08:11:29 +00:00
|
|
|
|
className="transcript-text clickable"
|
|
|
|
|
|
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
title="跳转到此时间点播放"
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.text_content}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-11-10 11:42:44 +00:00
|
|
|
|
);
|
|
|
|
|
|
})}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-08-05 02:58:13 +00:00
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
{/* Delete Confirmation Dialog */}
|
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
|
isOpen={!!deleteConfirmInfo}
|
|
|
|
|
|
onClose={() => setDeleteConfirmInfo(null)}
|
|
|
|
|
|
onConfirm={handleDeleteMeeting}
|
|
|
|
|
|
title="删除会议"
|
|
|
|
|
|
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
|
|
|
|
|
confirmText="确定删除"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
/>
|
2025-09-16 09:00:09 +00:00
|
|
|
|
|
2025-08-25 08:11:29 +00:00
|
|
|
|
{/* Speaker Tags Edit Modal */}
|
|
|
|
|
|
{showSpeakerEdit && (
|
|
|
|
|
|
<div className="speaker-edit-modal-overlay" onClick={() => setShowSpeakerEdit(false)}>
|
|
|
|
|
|
<div className="speaker-edit-modal" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-header">
|
|
|
|
|
|
<h3>编辑发言人标签</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="close-btn"
|
|
|
|
|
|
onClick={() => setShowSpeakerEdit(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={20} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
<div className="speaker-edit-content">
|
2025-08-25 08:11:29 +00:00
|
|
|
|
<div className="speaker-list">
|
|
|
|
|
|
{speakerList.length > 0 ? (
|
|
|
|
|
|
speakerList.map((speaker) => {
|
|
|
|
|
|
const segmentCount = transcript.filter(item => item.speaker_id === speaker.speaker_id).length;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={speaker.speaker_id} className="speaker-edit-item">
|
|
|
|
|
|
<div className="speaker-info">
|
|
|
|
|
|
<label className="speaker-id">发言人 {speaker.speaker_id}</label>
|
|
|
|
|
|
<span className="segment-count">({segmentCount} 条发言)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={editingSpeakers[speaker.speaker_id] || ''}
|
|
|
|
|
|
onChange={(e) => handleEditingSpeakerChange(speaker.speaker_id, e.target.value)}
|
|
|
|
|
|
className="speaker-tag-input"
|
|
|
|
|
|
placeholder="输入发言人姓名或标签"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="no-speakers-message">
|
|
|
|
|
|
<p>暂无发言人数据,请检查转录内容是否正确加载。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-cancel"
|
|
|
|
|
|
onClick={() => setShowSpeakerEdit(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-save"
|
|
|
|
|
|
onClick={handleBatchSpeakerUpdate}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Save size={16} />
|
|
|
|
|
|
保存修改
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-08-26 13:59:15 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Transcript Edit Modal */}
|
|
|
|
|
|
{showTranscriptEdit && (
|
|
|
|
|
|
<div className="transcript-edit-modal-overlay" onClick={() => setShowTranscriptEdit(false)}>
|
|
|
|
|
|
<div className="transcript-edit-modal" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-header">
|
|
|
|
|
|
<h3>编辑转录内容</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="close-btn"
|
|
|
|
|
|
onClick={() => setShowTranscriptEdit(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={20} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="transcript-edit-content">
|
|
|
|
|
|
<p className="modal-description">修改选中转录条目及其上下文内容:</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="transcript-edit-list">
|
|
|
|
|
|
{getEditingItems().map((item) => (
|
|
|
|
|
|
<div key={item.originalIndex} className={`transcript-edit-item ${item.position}`}>
|
|
|
|
|
|
<div className="transcript-edit-header">
|
|
|
|
|
|
<span className="speaker-name">{item.speaker_tag}</span>
|
2025-11-17 02:31:26 +00:00
|
|
|
|
<span className="timestamp">{tools.formatDuration(item.start_time_ms / 1000)}</span>
|
2025-08-26 13:59:15 +00:00
|
|
|
|
{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 && (
|
2025-08-29 08:37:55 +00:00
|
|
|
|
<div className="summary-modal-overlay" onClick={closeSummaryModal}>
|
2025-08-26 13:59:15 +00:00
|
|
|
|
<div className="summary-modal" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-header">
|
|
|
|
|
|
<h3><Brain size={20} /> AI会议总结</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="close-btn"
|
2025-08-29 08:37:55 +00:00
|
|
|
|
onClick={closeSummaryModal}
|
2025-08-26 13:59:15 +00:00
|
|
|
|
>
|
|
|
|
|
|
<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>
|
|
|
|
|
|
|
2025-09-09 03:36:26 +00:00
|
|
|
|
{/* 任务进度显示 */}
|
|
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2025-08-26 13:59:15 +00:00
|
|
|
|
{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>
|
|
|
|
|
|
)}
|
2025-10-31 06:55:19 +00:00
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
{/* QR Code Sharing Modal */}
|
|
|
|
|
|
<QRCodeModal
|
|
|
|
|
|
isOpen={showQRModal}
|
|
|
|
|
|
onClose={() => setShowQRModal(false)}
|
2025-11-10 11:42:44 +00:00
|
|
|
|
url={`${window.location.origin}/meetings/preview/${meeting_id}`}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
title={meeting.title}
|
|
|
|
|
|
description="扫描二维码查看会议总结"
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
{/* Toast notifications */}
|
|
|
|
|
|
{toasts.map(toast => (
|
|
|
|
|
|
<Toast
|
|
|
|
|
|
key={toast.id}
|
|
|
|
|
|
message={toast.message}
|
|
|
|
|
|
type={toast.type}
|
|
|
|
|
|
onClose={() => removeToast(toast.id)}
|
|
|
|
|
|
/>
|
|
|
|
|
|
))}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingDetails;
|