diff --git a/.DS_Store b/.DS_Store index 77f4a83..26c14c2 100644 Binary files a/.DS_Store and b/.DS_Store differ diff --git a/dist.zip b/dist.zip index 0903ee5..5414239 100644 Binary files a/dist.zip and b/dist.zip differ diff --git a/src/components/MeetingTimeline.jsx b/src/components/MeetingTimeline.jsx index 3aa4885..ceff9ea 100644 --- a/src/components/MeetingTimeline.jsx +++ b/src/components/MeetingTimeline.jsx @@ -11,7 +11,7 @@ import Dropdown from './Dropdown'; import tools from '../utils/tools'; import './MeetingTimeline.css'; -const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false }) => { +const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false, filterType = 'all', searchQuery = '', selectedTags = [] }) => { const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null); const navigate = useNavigate(); @@ -66,7 +66,16 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore return (
- +
diff --git a/src/config/api.js b/src/config/api.js index e1be57f..9bed12c 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -31,7 +31,8 @@ const API_CONFIG = { AUDIO: (meetingId) => `/api/meetings/${meetingId}/audio`, UPLOAD_AUDIO: '/api/meetings/upload-audio', UPLOAD_IMAGE: (meetingId) => `/api/meetings/${meetingId}/upload-image`, - REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary` + REGENERATE_SUMMARY: (meetingId) => `/api/meetings/${meetingId}/regenerate-summary`, + NAVIGATION: (meetingId) => `/api/meetings/${meetingId}/navigation` }, ADMIN: { SYSTEM_CONFIG: '/api/admin/system-config' diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index 0a726ed..c89233a 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -530,6 +530,9 @@ const Dashboard = ({ user, onLogout }) => { hasMore={pagination.has_more} onLoadMore={handleLoadMore} loadingMore={loadingMore} + filterType={filterType} + searchQuery={searchQuery} + selectedTags={selectedTags} />
diff --git a/src/pages/MeetingDetails.css b/src/pages/MeetingDetails.css index 8ba07dc..c913949 100644 --- a/src/pages/MeetingDetails.css +++ b/src/pages/MeetingDetails.css @@ -12,6 +12,132 @@ align-items: center; } +.header-left { + display: flex; + align-items: center; + gap: 1.5rem; +} + +/* 融合导航栏样式 */ +.unified-navigation { + display: flex; + align-items: center; + gap: 0; + background: linear-gradient(135deg, #ffffff 0%, #f8fafc 100%); + border-radius: 10px; + border: 1.5px solid #e2e8f0; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.06); + padding: 0.25rem; + overflow: hidden; +} + +.nav-btn { + display: flex; + align-items: center; + gap: 0.3rem; + padding: 0.4rem 0.9rem; + background: transparent; + color: #64748b; + border-radius: 6px; + text-decoration: none; + font-size: 0.8rem; + font-weight: 500; + transition: all 0.25s ease; + cursor: pointer; + border: none; + position: relative; + overflow: hidden; + white-space: nowrap; +} + +.nav-btn svg { + flex-shrink: 0; + width: 16px; + height: 16px; +} + +/* 返回首页按钮样式 */ +.back-home-btn { + color: #475569; + margin-right: 0.2rem; + border-right: 1.5px solid #e2e8f0; + border-radius: 6px 0 0 6px; + padding-right: 1rem; +} + +.back-home-btn:hover { + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); + color: #334155; +} + +/* 上一条/下一条按钮激活状态 */ +.nav-btn.prev-btn, +.nav-btn.next-btn { + color: white; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + position: relative; +} + +.nav-btn.prev-btn::before, +.nav-btn.next-btn::before { + content: ''; + position: absolute; + top: 0; + left: -100%; + width: 100%; + height: 100%; + background: linear-gradient(90deg, transparent, rgba(255, 255, 255, 0.2), transparent); + transition: left 0.5s; +} + +.nav-btn.prev-btn:hover::before, +.nav-btn.next-btn:hover::before { + left: 100%; +} + +.nav-btn.prev-btn:hover, +.nav-btn.next-btn:hover { + background: linear-gradient(135deg, #5a67d8 0%, #6b46c1 100%); + transform: translateY(-1px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.nav-btn.prev-btn:active, +.nav-btn.next-btn:active { + transform: translateY(0); + box-shadow: 0 2px 6px rgba(102, 126, 234, 0.3); +} + +/* 禁用状态 */ +.nav-btn.disabled { + background: linear-gradient(135deg, #cbd5e1 0%, #94a3b8 100%); + color: #f1f5f9; + cursor: not-allowed; + pointer-events: none; + opacity: 0.5; + box-shadow: none; +} + +.nav-btn.disabled:hover { + transform: none; + box-shadow: none; +} + +/* 位置指示器 */ +.nav-position { + padding: 0.4rem 0.8rem; + font-size: 0.75rem; + font-weight: 600; + color: #475569; + white-space: nowrap; + text-align: center; + background: linear-gradient(135deg, #f1f5f9 0%, #e2e8f0 100%); + border-radius: 6px; + margin: 0 0.2rem; + min-width: 50px; + border: 1px solid #cbd5e1; +} + .meeting-actions { display: flex; gap: 0.5rem; @@ -73,20 +199,6 @@ min-height: 0; } -.back-link { - display: inline-flex; - align-items: center; - gap: 0.5rem; - color: #475569; - text-decoration: none; - font-weight: 500; - transition: color 0.3s ease; -} - -.back-link:hover { - color: #667eea; -} - .details-content-card { background: white; border-radius: 16px; @@ -326,6 +438,10 @@ background: #f8fafc; border-radius: 6px; border-left: 3px solid #667eea; + display: flex; + align-items: center; + flex-wrap: wrap; + gap: 0.75rem; } .audio-file-name { @@ -334,6 +450,28 @@ font-weight: 500; } +.audio-loading-hint { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: #667eea; + padding: 0.2rem 0.6rem; + background: rgba(102, 126, 234, 0.1); + border-radius: 4px; +} + +.audio-error-hint { + display: inline-flex; + align-items: center; + gap: 0.4rem; + font-size: 0.8rem; + color: #ef4444; + padding: 0.2rem 0.6rem; + background: rgba(239, 68, 68, 0.1); + border-radius: 4px; +} + /* Audio Player Styles */ .audio-section { background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); @@ -522,6 +660,11 @@ color: white; cursor: pointer; transition: all 0.3s ease; + outline: none; /* 去掉点击后的蓝色边框 */ +} + +.play-button:focus { + outline: white 1px solid; /* 确保聚焦时也没有边框 */ } .play-button:hover { @@ -529,6 +672,42 @@ transform: scale(1.05); } +/* 播放按钮加载状态 */ +.play-button.loading, +.play-button.buffering { + background: rgba(255, 255, 255, 0.15); + cursor: wait; +} + +.play-button.loading:hover, +.play-button.buffering:hover { + transform: none; +} + +.play-button.disabled { + opacity: 0.6; + cursor: not-allowed; +} + +.play-button.disabled:hover { + transform: none; + background: rgba(255, 255, 255, 0.2); +} + +/* 旋转动画 */ +.spin { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} + .time-info { font-size: 0.9rem; font-weight: 500; diff --git a/src/pages/MeetingDetails.jsx b/src/pages/MeetingDetails.jsx index 35dd37a..1244fc4 100644 --- a/src/pages/MeetingDetails.jsx +++ b/src/pages/MeetingDetails.jsx @@ -1,9 +1,9 @@ import React, { useState, useEffect, useRef } from 'react'; -import { useParams, Link, useNavigate } from 'react-router-dom'; +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 } from 'lucide-react'; +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 } from 'lucide-react'; import ReactMarkdown from 'react-markdown'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; @@ -25,6 +25,7 @@ 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(''); @@ -67,6 +68,26 @@ const MeetingDetails = ({ user }) => { const [showUploadConfirm, setShowUploadConfirm] = useState(false); const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 默认100MB const [uploadError, setUploadError] = useState(''); + + // 音频加载状态 + 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([]); @@ -80,6 +101,13 @@ const MeetingDetails = ({ user }) => { setToasts(prev => prev.filter(toast => toast.id !== id)); }; + // 获取从Dashboard传递的筛选上下文 + useEffect(() => { + if (location.state?.filterContext) { + setFilterContext(location.state.filterContext); + } + }, [location.state]); + useEffect(() => { fetchMeetingDetails(); loadFileSizeConfig(); @@ -299,6 +327,35 @@ const MeetingDetails = ({ user }) => { } }; + // 获取导航信息(上一条/下一条会议) + 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) { @@ -402,10 +459,19 @@ const MeetingDetails = ({ user }) => { const handlePlayPause = () => { if (audioRef.current) { + // 如果音频还未加载完成,不允许播放 + if (!audioCanPlay && !isPlaying) { + showToast('音频正在加载中,请稍候...', 'info'); + return; + } + if (isPlaying) { audioRef.current.pause(); } else { - audioRef.current.play(); + audioRef.current.play().catch(err => { + console.error('播放失败:', err); + showToast('播放失败,请重试', 'error'); + }); } setIsPlaying(!isPlaying); } @@ -456,32 +522,99 @@ const MeetingDetails = ({ user }) => { } }; - const handleSeek = (e) => { + // 音频可以开始播放 + 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; - - const rect = e.currentTarget.getBoundingClientRect(); + + // 使用传入的元素或 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); + handleSeek(moveEvent, progressElement); }; - + const handleMouseUp = () => { document.removeEventListener('mousemove', handleMouseMove); document.removeEventListener('mouseup', handleMouseUp); }; - + document.addEventListener('mousemove', handleMouseMove); document.addEventListener('mouseup', handleMouseUp); - - handleSeek(e); + + handleSeek(e, progressElement); }; const handleVolumeChange = (e) => { @@ -904,12 +1037,46 @@ const MeetingDetails = ({ user }) => { return (
- - - - 返回首页 - - +
+ {/* 融合导航栏:返回首页 + 上一条/下一条 */} +
+ + + 返回首页 + + + {navigationInfo.total_count !== null && navigationInfo.total_count > 0 && ( + <> + !navigationInfo.prev_meeting_id && e.preventDefault()} + > + + 上一条 + + + {navigationInfo.current_index !== null && ( + + {navigationInfo.current_index + 1} / {navigationInfo.total_count} + + )} + + !navigationInfo.next_meeting_id && e.preventDefault()} + > + 下一条 + + + + )} +
+
+ {isCreator && (
@@ -1026,13 +1193,29 @@ const MeetingDetails = ({ user }) => { {audioFileName && (
{audioFileName} + {audioLoading && ( + + + 加载中... + + )} + {audioError && ( + {audioError} + )}
)}