411 lines
14 KiB
React
411 lines
14 KiB
React
|
|
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;
|