imetting_frontend/src/pages/MeetingDetails.jsx

393 lines
14 KiB
React
Raw Normal View History

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-05 01:44:28 +00:00
import axios from 'axios';
2025-08-05 02:58:13 +00:00
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2 } 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-08-05 01:44:28 +00:00
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
import './MeetingDetails.css';
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-08-05 02:58:13 +00:00
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
2025-08-05 01:44:28 +00:00
const audioRef = useRef(null);
useEffect(() => {
fetchMeetingDetails();
}, [meeting_id]);
const fetchMeetingDetails = async () => {
try {
setLoading(true);
// Fallback URL construction in case config fails
const baseUrl = API_BASE_URL || 'http://localhost:8000';
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`;
const response = await axios.get(`${baseUrl}${detailEndpoint}`);
setMeeting(response.data);
// Fetch audio file if available
try {
const audioResponse = await axios.get(`${baseUrl}${audioEndpoint}`);
// Construct URL using uploads path and relative path from database
setAudioUrl(`${baseUrl}/uploads/${audioResponse.data.file_path}`);
setAudioFileName(audioResponse.data.file_name);
} catch (audioError) {
console.warn('No audio file available:', audioError);
setAudioUrl(null);
setAudioFileName(null);
}
// Fetch transcript segments from database
try {
const transcriptResponse = await axios.get(`${baseUrl}${transcriptEndpoint}`);
setTranscript(transcriptResponse.data);
} catch (transcriptError) {
console.warn('No transcript data available:', transcriptError);
setTranscript([]);
}
} catch (err) {
console.error('Error fetching meeting details:', err);
setError('无法加载会议详情,请稍后重试。');
} finally {
setLoading(false);
}
};
const formatDateTime = (dateTimeString) => {
if (!dateTimeString) return '时间待定';
const date = new Date(dateTimeString);
return date.toLocaleString('zh-CN', {
year: 'numeric', month: 'long', day: 'numeric',
hour: '2-digit', minute: '2-digit'
});
};
const formatTime = (seconds) => {
const mins = Math.floor(seconds / 60);
const secs = Math.floor(seconds % 60);
return `${mins}:${secs.toString().padStart(2, '0')}`;
};
const handlePlayPause = () => {
if (audioRef.current) {
if (isPlaying) {
audioRef.current.pause();
} else {
audioRef.current.play();
}
setIsPlaying(!isPlaying);
}
};
const handleTimeUpdate = () => {
if (audioRef.current) {
setCurrentTime(audioRef.current.currentTime);
}
};
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-05 02:58:13 +00:00
const handleDeleteMeeting = async () => {
try {
await axios.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meeting_id)));
navigate('/dashboard');
} catch (err) {
console.error('Error deleting meeting:', err);
setError('删除会议失败,请重试');
}
};
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
2025-08-05 01:44:28 +00:00
if (loading) {
return <div className="loading-container"><div className="loading-spinner"></div><p>加载中...</p></div>;
}
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">
<Link to={`/meetings/edit/${meeting_id}`} className="action-btn edit-btn">
<Edit size={16} />
<span>编辑会议</span>
</Link>
<button
className="action-btn delete-btn"
onClick={() => setShowDeleteConfirm(true)}
>
<Trash2 size={16} />
<span>删除会议</span>
</button>
</div>
)}
2025-08-05 01:44:28 +00:00
</div>
<div className="details-layout">
<div className="main-content">
<div className="details-content-card">
<header className="card-header">
<h1>{meeting.title}</h1>
<div className="meta-grid">
<div className="meta-item">
<Calendar size={18} />
<strong>会议日期:</strong>
<span>{formatDateTime(meeting.meeting_time).split(' ')[0]}</span>
</div>
<div className="meta-item">
<Clock size={18} />
<strong>会议时间:</strong>
<span>{formatDateTime(meeting.meeting_time).split(' ')[1]}</span>
</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">
<h2><Volume2 size={20} /> 会议录音</h2>
{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>
<div className="player-controls">
<button className="play-button" onClick={handlePlayPause}>
{isPlaying ? <Pause size={24} /> : <Play size={24} />}
</button>
<div className="time-info">
<span>{formatTime(currentTime)}</span>
</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">
<span>{formatTime(duration)}</span>
</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>
</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>
<section className="card-section">
<h2><FileText size={20} /> 会议摘要</h2>
<div className="summary-content">
<div className="markdown-content">
2025-08-05 02:58:13 +00:00
<ReactMarkdown
remarkPlugins={[remarkGfm]}
rehypePlugins={[rehypeRaw, rehypeSanitize]}
>
2025-08-05 01:44:28 +00:00
{meeting.summary || '暂无摘要信息。'}
</ReactMarkdown>
</div>
</div>
</section>
</div>
</div>
{/* Transcript Sidebar */}
<div className="transcript-sidebar">
<div className="transcript-header">
<h3><MessageCircle size={20} /> 对话转录</h3>
<button
className="toggle-transcript"
onClick={() => setShowTranscript(!showTranscript)}
>
{showTranscript ? '隐藏' : '显示'}
</button>
</div>
{showTranscript && (
<div className="transcript-content">
{transcript.map((item) => (
<div key={item.segment_id} className="transcript-item">
<div className="transcript-header-item">
<span className="speaker-name">{item.speaker_tag}</span>
<button
className="timestamp-button"
onClick={() => jumpToTime(item.start_time_ms / 1000)}
title="跳转到此时间点"
>
{formatTime(item.start_time_ms / 1000)}
</button>
</div>
<div className="transcript-text">{item.text_content}</div>
</div>
))}
</div>
)}
</div>
</div>
2025-08-05 02:58:13 +00:00
{/* Delete Confirmation Modal */}
{showDeleteConfirm && (
<div className="delete-modal-overlay" onClick={() => setShowDeleteConfirm(false)}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>确认删除</h3>
<p>确定要删除会议 "{meeting.title}" 此操作无法撤销</p>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowDeleteConfirm(false)}
>
取消
</button>
<button
className="btn-delete"
onClick={handleDeleteMeeting}
>
确定删除
</button>
</div>
</div>
</div>
)}
2025-08-05 01:44:28 +00:00
</div>
);
};
export default MeetingDetails;