diff --git a/src/App.jsx b/src/App.jsx index 0ac9952..cce8f5a 100644 --- a/src/App.jsx +++ b/src/App.jsx @@ -1,5 +1,7 @@ import React, { useState, useEffect } from 'react'; import { BrowserRouter as Router, Routes, Route, Navigate } from 'react-router-dom'; +import apiClient from './utils/apiClient'; +import { buildApiUrl, API_ENDPOINTS } from './config/api'; import HomePage from './pages/HomePage'; import Dashboard from './pages/Dashboard'; import MeetingDetails from './pages/MeetingDetails'; @@ -33,9 +35,18 @@ function App() { localStorage.setItem('iMeetingUser', JSON.stringify(userData)); }; - const handleLogout = () => { - setUser(null); - localStorage.removeItem('iMeetingUser'); + const handleLogout = async () => { + try { + // 调用后端登出API撤销token + await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGOUT)); + } catch (error) { + console.error('Logout API error:', error); + // 即使API调用失败也继续登出流程 + } finally { + // 清除本地状态和存储 + setUser(null); + localStorage.removeItem('iMeetingUser'); + } }; if (isLoading) { diff --git a/src/config/api.js b/src/config/api.js index 1f24f66..eeb337d 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -3,7 +3,11 @@ const API_CONFIG = { BASE_URL: "", ENDPOINTS: { AUTH: { - LOGIN: '/api/auth/login' + LOGIN: '/api/auth/login', + LOGOUT: '/api/auth/logout', + LOGOUT_ALL: '/api/auth/logout-all', + ME: '/api/auth/me', + REFRESH: '/api/auth/refresh' }, USERS: { LIST: '/api/users', diff --git a/src/pages/CreateMeeting.jsx b/src/pages/CreateMeeting.jsx index 92db85d..7c94092 100644 --- a/src/pages/CreateMeeting.jsx +++ b/src/pages/CreateMeeting.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { Link, useNavigate } from 'react-router-dom'; -import axios from 'axios'; +import apiClient from '../utils/apiClient'; import { ArrowLeft, Upload, Users, Calendar, FileText, X, User, Plus } from 'lucide-react'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import DateTimePicker from '../components/DateTimePicker'; @@ -26,7 +26,7 @@ const CreateMeeting = ({ user }) => { const fetchUsers = async () => { try { - const response = await axios.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); setAvailableUsers(response.data.filter(u => u.user_id !== user.user_id)); } catch (err) { console.error('Error fetching users:', err); @@ -100,7 +100,7 @@ const CreateMeeting = ({ user }) => { attendee_ids: formData.attendees.map(a => a.user_id) }; - const response = await axios.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), meetingData); + const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.CREATE), meetingData); const meetingId = response.data.meeting_id; // Upload audio file if provided @@ -109,7 +109,7 @@ const CreateMeeting = ({ user }) => { formDataUpload.append('audio_file', audioFile); formDataUpload.append('meeting_id', meetingId); - await axios.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, { + await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, { headers: { 'Content-Type': 'multipart/form-data', }, diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 3be8416..aeb3aa2 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus } from 'lucide-react'; -import axios from 'axios'; +import apiClient from '../utils/apiClient'; import { Link } from 'react-router-dom'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import MeetingTimeline from '../components/MeetingTimeline'; @@ -21,11 +21,11 @@ const Dashboard = ({ user, onLogout }) => { setLoading(true); console.log('Fetching user data for user_id:', user.user_id); - const userResponse = await axios.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); + const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id))); console.log('User response:', userResponse.data); setUserInfo(userResponse.data); - const meetingsResponse = await axios.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`)); + const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`)); console.log('Meetings response:', meetingsResponse.data); setMeetings(meetingsResponse.data); @@ -39,9 +39,9 @@ const Dashboard = ({ user, onLogout }) => { const handleDeleteMeeting = async (meetingId) => { try { - await axios.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId))); + await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId))); // Refresh meetings list - const meetingsResponse = await axios.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`)); + const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`)); setMeetings(meetingsResponse.data); } catch (err) { console.error('Error deleting meeting:', err); diff --git a/src/pages/EditMeeting.jsx b/src/pages/EditMeeting.jsx index c902449..8d71cb6 100644 --- a/src/pages/EditMeeting.jsx +++ b/src/pages/EditMeeting.jsx @@ -1,6 +1,6 @@ import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; -import axios from 'axios'; +import apiClient from '../utils/apiClient'; import { ArrowLeft, Users, Calendar, FileText, X, User, Save, Upload, Plus, Image } from 'lucide-react'; import MDEditor, * as commands from '@uiw/react-md-editor'; import '@uiw/react-md-editor/markdown-editor.css'; @@ -42,7 +42,7 @@ const EditMeeting = ({ user }) => { const fetchMeetingData = async () => { try { - const response = await axios.get(buildApiUrl(API_ENDPOINTS.MEETINGS.EDIT(meeting_id))); + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.EDIT(meeting_id))); const meetingData = response.data; // Check if current user is the creator @@ -68,7 +68,7 @@ const EditMeeting = ({ user }) => { const fetchUsers = async () => { try { - const response = await axios.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); + const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.LIST)); setAvailableUsers(response.data.filter(u => u.user_id !== user.user_id)); } catch (err) { console.error('Error fetching users:', err); @@ -141,7 +141,7 @@ const EditMeeting = ({ user }) => { attendee_ids: formData.attendees.map(a => a.user_id) }; - await axios.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), updateData); + await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), updateData); navigate(`/meetings/${meeting_id}`); } catch (err) { setError(err.response?.data?.detail || '更新会议失败,请重试'); @@ -165,7 +165,7 @@ const EditMeeting = ({ user }) => { formDataUpload.append('meeting_id', meeting_id); formDataUpload.append('force_replace', 'true'); // Always force replace in edit mode - const response = await axios.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, { + const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, { headers: { 'Content-Type': 'multipart/form-data', }, @@ -209,7 +209,7 @@ const EditMeeting = ({ user }) => { const formData = new FormData(); formData.append('image_file', file); - const response = await axios.post( + const response = await apiClient.post( buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_IMAGE(meeting_id)), formData, { diff --git a/src/pages/HomePage.jsx b/src/pages/HomePage.jsx index 7aa2718..802ba2f 100644 --- a/src/pages/HomePage.jsx +++ b/src/pages/HomePage.jsx @@ -1,6 +1,6 @@ import React, { useState } from 'react'; import { Brain, Users, Calendar, TrendingUp, X, User, Lock } from 'lucide-react'; -import axios from 'axios'; +import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import './HomePage.css'; @@ -16,7 +16,7 @@ const HomePage = ({ onLogin }) => { setLoginError(''); try { - const response = await axios.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), loginForm); + const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), loginForm); onLogin(response.data); setShowLoginModal(false); } catch (error) { diff --git a/src/pages/MeetingDetails.css b/src/pages/MeetingDetails.css index 1ff9533..ad4aa83 100644 --- a/src/pages/MeetingDetails.css +++ b/src/pages/MeetingDetails.css @@ -738,6 +738,22 @@ gap: 12px; } +.sync-scroll-icon { + display: inline-flex; + align-items: center; + justify-content: center; + margin-left: auto; + color: #64748b; + cursor: pointer; + transition: all 0.2s ease; + padding: 2px; +} + +.sync-scroll-icon:hover { + color: #3b82f6; + transform: scale(1.1); +} + .auto-scroll-btn { display: flex; align-items: center; diff --git a/src/pages/MeetingDetails.jsx b/src/pages/MeetingDetails.jsx index c330bb3..71d8ea0 100644 --- a/src/pages/MeetingDetails.jsx +++ b/src/pages/MeetingDetails.jsx @@ -1,7 +1,7 @@ import React, { useState, useEffect, useRef } from 'react'; import { useParams, Link, useNavigate } from 'react-router-dom'; -import axios from 'axios'; -import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, Lock, Unlock } from 'lucide-react'; +import apiClient from '../utils/apiClient'; +import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -51,32 +51,70 @@ const MeetingDetails = ({ user }) => { // Cleanup interval on unmount return () => { if (statusCheckInterval) { + console.log('组件卸载,清理转录状态轮询定时器'); clearInterval(statusCheckInterval); + setStatusCheckInterval(null); } }; }, [meeting_id]); // Cleanup interval when status changes useEffect(() => { - if (transcriptionStatus && !['pending', 'processing'].includes(transcriptionStatus.status)) { - if (statusCheckInterval) { - clearInterval(statusCheckInterval); - setStatusCheckInterval(null); + if (transcriptionStatus) { + // 如果转录已完成、失败或取消,清除轮询 + if (['completed', 'failed', 'error', 'cancelled'].includes(transcriptionStatus.status)) { + if (statusCheckInterval) { + clearInterval(statusCheckInterval); + setStatusCheckInterval(null); + } } } }, [transcriptionStatus, statusCheckInterval]); + const refreshTranscriptData = async () => { + try { + const baseUrl = ""; + const transcriptEndpoint = API_ENDPOINTS?.MEETINGS?.TRANSCRIPT?.(meeting_id) || `/api/meetings/${meeting_id}/transcript`; + + // 只刷新转录数据,不显示loading + const transcriptResponse = await apiClient.get(`${baseUrl}${transcriptEndpoint}`); + setTranscript(transcriptResponse.data); + + // 更新发言人列表 + const allSpeakerIds = transcriptResponse.data + .map(item => item.speaker_id) + .filter(speakerId => speakerId !== null && speakerId !== undefined); + + const uniqueSpeakers = [...new Set(allSpeakerIds)] + .map(speakerId => { + const segment = transcriptResponse.data.find(item => item.speaker_id === speakerId); + return { + speaker_id: speakerId, + speaker_tag: segment ? (segment.speaker_tag || `发言人 ${speakerId}`) : `发言人 ${speakerId}` + }; + }) + .sort((a, b) => a.speaker_id - b.speaker_id); + + setSpeakerList(uniqueSpeakers); + console.log('转录数据已刷新,无loading状态'); + + } catch (error) { + console.error('刷新转录数据失败:', error); + } + }; + const startStatusPolling = (taskId) => { // Clear existing interval if (statusCheckInterval) { clearInterval(statusCheckInterval); + setStatusCheckInterval(null); } // Poll every 3 seconds const interval = setInterval(async () => { try { const baseUrl = ""; - const statusResponse = await axios.get(`${baseUrl}/api/transcription/tasks/${taskId}/status`); + const statusResponse = await apiClient.get(`${baseUrl}/api/transcription/tasks/${taskId}/status`); const status = statusResponse.data; setTranscriptionStatus(status); @@ -87,10 +125,16 @@ const MeetingDetails = ({ user }) => { clearInterval(interval); setStatusCheckInterval(null); - // Refresh meeting details to get updated transcript + // Refresh transcript data only if completed successfully if (status.status === 'completed') { - fetchMeetingDetails(); + console.log('转录完成,刷新转录数据(无loading)'); + await refreshTranscriptData(); + } else { + console.log('转录失败或取消,状态:', status.status); } + + // 再次确保清除状态 + setTranscriptionStatus(status); } } catch (error) { console.error('Failed to fetch transcription status:', error); @@ -113,26 +157,43 @@ const MeetingDetails = ({ user }) => { const audioEndpoint = API_ENDPOINTS?.MEETINGS?.AUDIO?.(meeting_id) || `/api/meetings/${meeting_id}/audio`; const transcriptEndpoint = API_ENDPOINTS?.MEETINGS?.TRANSCRIPT?.(meeting_id) || `/api/meetings/${meeting_id}/transcript`; - const response = await axios.get(`${baseUrl}${detailEndpoint}`); + const response = await apiClient.get(`${baseUrl}${detailEndpoint}`); setMeeting(response.data); // Handle transcription status from meeting details if (response.data.transcription_status) { - setTranscriptionStatus(response.data.transcription_status); - setTranscriptionProgress(response.data.transcription_status.progress || 0); + 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(response.data.transcription_status.status)) { - startStatusPolling(response.data.transcription_status.task_id); + // 但只有当前没有在轮询时才启动新的轮询 + 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 axios.get(`${baseUrl}${audioEndpoint}`); + const audioResponse = await apiClient.get(`${baseUrl}${audioEndpoint}`); // Construct URL using uploads path and relative path from database setAudioUrl(`${baseUrl}${audioResponse.data.file_path}`); setAudioFileName(audioResponse.data.file_name); @@ -144,7 +205,7 @@ const MeetingDetails = ({ user }) => { // Fetch transcript segments from database try { - const transcriptResponse = await axios.get(`${baseUrl}${transcriptEndpoint}`); + const transcriptResponse = await apiClient.get(`${baseUrl}${transcriptEndpoint}`); setTranscript(transcriptResponse.data); console.log('First transcript item:', transcriptResponse.data[0]); @@ -307,7 +368,7 @@ const MeetingDetails = ({ user }) => { const handleDeleteMeeting = async () => { try { - await axios.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id))); + await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id))); navigate('/dashboard'); } catch (err) { console.error('Error deleting meeting:', err); @@ -318,7 +379,7 @@ const MeetingDetails = ({ user }) => { const handleSpeakerTagUpdate = async (speakerId, newTag) => { try { const baseUrl = ""; - await axios.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags`, { + await apiClient.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags`, { speaker_id: speakerId, new_tag: newTag }); @@ -350,7 +411,7 @@ const MeetingDetails = ({ user }) => { new_tag: newTag })); - await axios.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags/batch`, { + await apiClient.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags/batch`, { updates: updates }); @@ -421,7 +482,7 @@ const MeetingDetails = ({ user }) => { text_content: text_content })); - await axios.put(`${baseUrl}/api/meetings/${meeting_id}/transcript/batch`, { + await apiClient.put(`${baseUrl}/api/meetings/${meeting_id}/transcript/batch`, { updates: updates }); @@ -454,6 +515,27 @@ const MeetingDetails = ({ user }) => { 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 + })); + + console.log('会议摘要已更新,无loading状态'); + + } catch (error) { + console.error('刷新会议摘要失败:', error); + } + }; + // AI总结相关函数 const generateSummary = async () => { if (summaryLoading) return; @@ -461,7 +543,7 @@ const MeetingDetails = ({ user }) => { setSummaryLoading(true); try { const baseUrl = ""; - const response = await axios.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary`, { + const response = await apiClient.post(`${baseUrl}/api/meetings/${meeting_id}/generate-summary`, { user_prompt: userPrompt }); @@ -470,6 +552,9 @@ const MeetingDetails = ({ user }) => { // 刷新总结历史 await fetchSummaryHistory(); + // 只刷新会议摘要部分,避免整页刷新 + await refreshMeetingSummary(); + } catch (err) { console.error('Error generating summary:', err); setError('生成AI总结失败,请重试'); @@ -481,7 +566,7 @@ const MeetingDetails = ({ user }) => { const fetchSummaryHistory = async () => { try { const baseUrl = ""; - const response = await axios.get(`${baseUrl}/api/meetings/${meeting_id}/summaries`); + const response = await apiClient.get(`${baseUrl}/api/meetings/${meeting_id}/summaries`); setSummaryHistory(response.data.summaries); } catch (err) { console.error('Error fetching summary history:', err); @@ -494,6 +579,14 @@ const MeetingDetails = ({ user }) => { setSummaryResult(null); await fetchSummaryHistory(); }; + + const closeSummaryModal = async () => { + setShowSummaryModal(false); + // 关闭弹窗时只刷新摘要部分,避免整页刷新 + if (summaryResult) { + await refreshMeetingSummary(); + } + }; const exportToPDF = async () => { try { @@ -900,13 +993,13 @@ const MeetingDetails = ({ user }) => {