411 lines
14 KiB
JavaScript
411 lines
14 KiB
JavaScript
import { useState, useEffect, useRef } from 'react';
|
||
import { useNavigate } from 'react-router-dom';
|
||
import { ArrowLeft, Clock, User, QrCode, ChevronUp, ChevronDown, Trash2, Play, Pause, X } from 'lucide-react';
|
||
import { meetingService } from '../../services/meeting';
|
||
import { authService } from '../../services/auth';
|
||
import Toast from '../../components/Toast';
|
||
import './Meetings.css';
|
||
|
||
function Meetings() {
|
||
const navigate = useNavigate();
|
||
const [meetings, setMeetings] = useState([]);
|
||
const [loading, setLoading] = useState(true);
|
||
const [pagination, setPagination] = useState({
|
||
page: 1,
|
||
page_size: 5,
|
||
total: 0,
|
||
total_pages: 0
|
||
});
|
||
const [toast, setToast] = useState(null);
|
||
const [showQRModal, setShowQRModal] = useState(false);
|
||
const [selectedMeetingUrl, setSelectedMeetingUrl] = useState('');
|
||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||
const [deletingMeeting, setDeletingMeeting] = useState(null);
|
||
const [isDeleting, setIsDeleting] = useState(false);
|
||
const currentUser = authService.getLocalUser();
|
||
|
||
// 音频播放相关状态
|
||
const [playingMeetingId, setPlayingMeetingId] = useState(null);
|
||
const [isPlaying, setIsPlaying] = useState(false);
|
||
const [currentTime, setCurrentTime] = useState(0);
|
||
const [duration, setDuration] = useState(0);
|
||
const audioRef = useRef(null);
|
||
|
||
useEffect(() => {
|
||
fetchMeetings(1);
|
||
}, []);
|
||
|
||
const fetchMeetings = async (page) => {
|
||
setLoading(true);
|
||
try {
|
||
const response = await meetingService.getMeetings({
|
||
page,
|
||
page_size: pagination.page_size
|
||
});
|
||
|
||
// API响应格式: { code, message, data: { meetings, page, page_size, total, total_pages } }
|
||
const data = response.data;
|
||
setMeetings(data.meetings || []);
|
||
setPagination({
|
||
page: data.page,
|
||
page_size: data.page_size,
|
||
total: data.total,
|
||
total_pages: data.total_pages
|
||
});
|
||
} catch (error) {
|
||
console.error('Failed to fetch meetings:', error);
|
||
setToast({ message: '加载会议列表失败', type: 'error' });
|
||
} finally {
|
||
setLoading(false);
|
||
}
|
||
};
|
||
|
||
const handlePageChange = (newPage) => {
|
||
if (newPage >= 1 && newPage <= pagination.total_pages) {
|
||
fetchMeetings(newPage);
|
||
}
|
||
};
|
||
|
||
const handleShowQR = (meetingId) => {
|
||
const url = `${window.location.origin}/meetings/preview/${meetingId}`;
|
||
setSelectedMeetingUrl(url);
|
||
setShowQRModal(true);
|
||
};
|
||
|
||
const handleDeleteClick = (meeting, e) => {
|
||
e.stopPropagation();
|
||
setDeletingMeeting(meeting);
|
||
setShowDeleteModal(true);
|
||
};
|
||
|
||
const handleConfirmDelete = async () => {
|
||
if (!deletingMeeting) return;
|
||
|
||
setIsDeleting(true);
|
||
try {
|
||
await meetingService.deleteMeeting(deletingMeeting.meeting_id);
|
||
setToast({ message: '会议删除成功', type: 'success' });
|
||
setShowDeleteModal(false);
|
||
setDeletingMeeting(null);
|
||
|
||
// 重新加载当前页
|
||
// 如果当前页只有一条记录且不是第一页,则返回上一页
|
||
const isLastItemOnPage = meetings.length === 1 && pagination.page > 1;
|
||
const targetPage = isLastItemOnPage ? pagination.page - 1 : pagination.page;
|
||
fetchMeetings(targetPage);
|
||
} catch (error) {
|
||
console.error('Failed to delete meeting:', error);
|
||
setToast({
|
||
message: error.response?.data?.message || '删除会议失败',
|
||
type: 'error'
|
||
});
|
||
} finally {
|
||
setIsDeleting(false);
|
||
}
|
||
};
|
||
|
||
const handleCancelDelete = () => {
|
||
setShowDeleteModal(false);
|
||
setDeletingMeeting(null);
|
||
};
|
||
|
||
// 音频播放相关函数
|
||
const handlePlayAudio = (meetingId, e) => {
|
||
e.stopPropagation();
|
||
|
||
// 如果点击的是同一个会议,且正在播放,则暂停
|
||
if (playingMeetingId === meetingId && isPlaying) {
|
||
audioRef.current?.pause();
|
||
setIsPlaying(false);
|
||
return;
|
||
}
|
||
|
||
// 如果点击的是不同的会议,或者同一个会议但暂停状态
|
||
if (playingMeetingId !== meetingId) {
|
||
// 停止之前的播放
|
||
if (audioRef.current) {
|
||
audioRef.current.pause();
|
||
audioRef.current = null;
|
||
}
|
||
|
||
// 创建新的音频元素
|
||
const audio = new Audio(`/api/meetings/${meetingId}/audio/stream`);
|
||
audioRef.current = audio;
|
||
|
||
// 监听音频事件
|
||
audio.addEventListener('loadedmetadata', () => {
|
||
setDuration(audio.duration);
|
||
});
|
||
|
||
audio.addEventListener('timeupdate', () => {
|
||
setCurrentTime(audio.currentTime);
|
||
});
|
||
|
||
audio.addEventListener('ended', () => {
|
||
setIsPlaying(false);
|
||
setCurrentTime(0);
|
||
});
|
||
|
||
audio.addEventListener('error', (e) => {
|
||
console.error('Audio error:', e);
|
||
setToast({ message: '音频加载失败', type: 'error' });
|
||
setPlayingMeetingId(null);
|
||
setIsPlaying(false);
|
||
});
|
||
|
||
setPlayingMeetingId(meetingId);
|
||
setCurrentTime(0);
|
||
audio.play();
|
||
setIsPlaying(true);
|
||
} else {
|
||
// 同一个会议,从暂停恢复播放
|
||
audioRef.current?.play();
|
||
setIsPlaying(true);
|
||
}
|
||
};
|
||
|
||
const handlePlayPause = (e) => {
|
||
e.stopPropagation();
|
||
if (isPlaying) {
|
||
audioRef.current?.pause();
|
||
setIsPlaying(false);
|
||
} else {
|
||
audioRef.current?.play();
|
||
setIsPlaying(true);
|
||
}
|
||
};
|
||
|
||
const handleSeek = (e) => {
|
||
const progressBar = e.currentTarget;
|
||
const rect = progressBar.getBoundingClientRect();
|
||
const percent = (e.clientX - rect.left) / rect.width;
|
||
const newTime = percent * duration;
|
||
|
||
if (audioRef.current) {
|
||
audioRef.current.currentTime = newTime;
|
||
setCurrentTime(newTime);
|
||
}
|
||
};
|
||
|
||
const handleClosePlayer = (e) => {
|
||
e.stopPropagation();
|
||
if (audioRef.current) {
|
||
audioRef.current.pause();
|
||
audioRef.current = null;
|
||
}
|
||
setPlayingMeetingId(null);
|
||
setIsPlaying(false);
|
||
setCurrentTime(0);
|
||
setDuration(0);
|
||
};
|
||
|
||
const formatTime = (seconds) => {
|
||
if (isNaN(seconds)) return '0:00';
|
||
const mins = Math.floor(seconds / 60);
|
||
const secs = Math.floor(seconds % 60);
|
||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||
};
|
||
|
||
const formatDateTime = (dateString) => {
|
||
const date = new Date(dateString);
|
||
return date.toLocaleString('zh-CN', {
|
||
year: 'numeric',
|
||
month: '2-digit',
|
||
day: '2-digit',
|
||
hour: '2-digit',
|
||
minute: '2-digit'
|
||
});
|
||
};
|
||
|
||
return (
|
||
<div className="meetings-page">
|
||
{toast && (
|
||
<Toast
|
||
message={toast.message}
|
||
type={toast.type}
|
||
duration={3000}
|
||
onClose={() => setToast(null)}
|
||
/>
|
||
)}
|
||
|
||
<div className="page-header">
|
||
<button className="back-button" onClick={() => navigate('/')} title="返回首页">
|
||
<ArrowLeft size={24} />
|
||
</button>
|
||
<h1 className="page-title">会议记录</h1>
|
||
</div>
|
||
|
||
<div className="meetings-container">
|
||
{loading ? (
|
||
<div className="loading-state">
|
||
<p>加载中...</p>
|
||
</div>
|
||
) : meetings.length === 0 ? (
|
||
<div className="empty-state">
|
||
<p>暂无会议记录</p>
|
||
</div>
|
||
) : (
|
||
<>
|
||
<div className="meetings-content-wrapper">
|
||
<div className="meetings-list">
|
||
{meetings.map((meeting) => {
|
||
const isCreator = String(meeting.creator_id) === String(currentUser?.user_id);
|
||
const cardClass = isCreator ? 'created-by-me' : 'attended-by-me';
|
||
const isPlaying = playingMeetingId === meeting.meeting_id;
|
||
|
||
return (
|
||
<div key={meeting.meeting_id} className={`meeting-item ${cardClass} ${isPlaying ? 'playing' : ''}`}>
|
||
<div
|
||
className="meeting-main"
|
||
onClick={(e) => {
|
||
if (!isPlaying) {
|
||
handlePlayAudio(meeting.meeting_id, e);
|
||
}
|
||
}}
|
||
>
|
||
{isPlaying ? (
|
||
/* 播放器模式 */
|
||
<div className="audio-player">
|
||
<button className="play-btn" onClick={handlePlayPause} title={isPlaying ? '暂停' : '播放'}>
|
||
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
|
||
</button>
|
||
|
||
<div className="player-info">
|
||
<div className="player-title">{meeting.title}</div>
|
||
<div className="progress-container">
|
||
<span className="time-current">{formatTime(currentTime)}</span>
|
||
<div className="progress-bar" onClick={handleSeek}>
|
||
<div
|
||
className="progress-fill"
|
||
style={{ width: `${duration ? (currentTime / duration) * 100 : 0}%` }}
|
||
></div>
|
||
</div>
|
||
<span className="time-duration">{formatTime(duration)}</span>
|
||
</div>
|
||
</div>
|
||
|
||
<button className="close-player-btn" onClick={handleClosePlayer} title="关闭播放器">
|
||
<X size={20} />
|
||
</button>
|
||
</div>
|
||
) : (
|
||
/* 正常信息显示 */
|
||
<div className="meeting-info">
|
||
<h3 className="meeting-title">{meeting.title}</h3>
|
||
<div className="meeting-meta">
|
||
<div className="meta-item">
|
||
<Clock size={14} />
|
||
<span>{formatDateTime(meeting.meeting_time)}</span>
|
||
</div>
|
||
<div className="meta-item">
|
||
<User size={14} />
|
||
<span>创建人: {meeting.creator_username}</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
{!isPlaying && (
|
||
<div className="meeting-actions">
|
||
<button
|
||
className="qr-btn"
|
||
onClick={(e) => {
|
||
e.stopPropagation();
|
||
handleShowQR(meeting.meeting_id);
|
||
}}
|
||
title="查看二维码"
|
||
>
|
||
<QrCode size={18} />
|
||
</button>
|
||
{isCreator && (
|
||
<button
|
||
className="delete-btn"
|
||
onClick={(e) => handleDeleteClick(meeting, e)}
|
||
title="删除会议"
|
||
>
|
||
<Trash2 size={18} />
|
||
</button>
|
||
)}
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
|
||
{/* 竖直分页器 */}
|
||
{pagination.total_pages > 1 && (
|
||
<div className="vertical-pagination">
|
||
<button
|
||
className="pagination-arrow-btn"
|
||
onClick={() => handlePageChange(pagination.page - 1)}
|
||
disabled={pagination.page === 1}
|
||
title="上一页"
|
||
>
|
||
<ChevronUp size={20} />
|
||
</button>
|
||
<div className="pagination-page-info">
|
||
{pagination.page}/{pagination.total_pages}
|
||
</div>
|
||
<button
|
||
className="pagination-arrow-btn"
|
||
onClick={() => handlePageChange(pagination.page + 1)}
|
||
disabled={pagination.page === pagination.total_pages}
|
||
title="下一页"
|
||
>
|
||
<ChevronDown size={20} />
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* QR码模态框 */}
|
||
{showQRModal && (
|
||
<div className="modal-overlay" onClick={() => setShowQRModal(false)}>
|
||
<div className="qr-modal" onClick={(e) => e.stopPropagation()}>
|
||
<h3>扫码查看会议</h3>
|
||
<div className="qr-code-container">
|
||
<img
|
||
src={`https://api.qrserver.com/v1/create-qr-code/?size=200x200&data=${encodeURIComponent(selectedMeetingUrl)}`}
|
||
alt="QR Code"
|
||
/>
|
||
</div>
|
||
<p className="qr-url">{selectedMeetingUrl}</p>
|
||
<button className="close-btn" onClick={() => setShowQRModal(false)}>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
{/* 删除确认模态框 */}
|
||
{showDeleteModal && (
|
||
<div className="modal-overlay" onClick={handleCancelDelete}>
|
||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
||
<div className="delete-icon">
|
||
<Trash2 size={48} />
|
||
</div>
|
||
<h3>确认删除会议?</h3>
|
||
<p className="delete-warning">
|
||
确定要删除会议 "<strong>{deletingMeeting?.title}</strong>" 吗?
|
||
</p>
|
||
<div className="modal-actions">
|
||
<button className="cancel-btn" onClick={handleCancelDelete} disabled={isDeleting}>
|
||
取消
|
||
</button>
|
||
<button className="confirm-delete-btn" onClick={handleConfirmDelete} disabled={isDeleting}>
|
||
{isDeleting ? '删除中...' : '确认删除'}
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
export default Meetings;
|