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-25 08:11:29 +00:00
|
|
|
|
import { ArrowLeft, Clock, Users, FileText, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X } 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-25 08:11:29 +00:00
|
|
|
|
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
|
|
|
|
|
|
const [editingSpeakers, setEditingSpeakers] = useState({});
|
|
|
|
|
|
const [speakerList, setSpeakerList] = useState([]);
|
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
|
2025-08-25 03:30:27 +00:00
|
|
|
|
const baseUrl = ""
|
2025-08-05 01:44:28 +00:00
|
|
|
|
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
|
2025-08-25 03:30:27 +00:00
|
|
|
|
setAudioUrl(`${baseUrl}${audioResponse.data.file_path}`);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
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);
|
2025-08-25 08:11:29 +00:00
|
|
|
|
|
|
|
|
|
|
console.log('First transcript item:', transcriptResponse.data[0]);
|
|
|
|
|
|
|
|
|
|
|
|
// 现在使用speaker_id字段进行分组
|
|
|
|
|
|
const allSpeakerIds = transcriptResponse.data
|
|
|
|
|
|
.map(item => item.speaker_id)
|
|
|
|
|
|
.filter(speakerId => speakerId !== null && speakerId !== undefined);
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Extracted speaker IDs:', allSpeakerIds);
|
|
|
|
|
|
|
|
|
|
|
|
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); // 按speaker_id数值排序
|
|
|
|
|
|
|
|
|
|
|
|
console.log('Final unique speakers:', uniqueSpeakers);
|
|
|
|
|
|
setSpeakerList(uniqueSpeakers);
|
|
|
|
|
|
|
|
|
|
|
|
// 初始化编辑状态
|
|
|
|
|
|
const initialEditingState = {};
|
|
|
|
|
|
uniqueSpeakers.forEach(speaker => {
|
|
|
|
|
|
initialEditingState[speaker.speaker_id] = speaker.speaker_tag;
|
|
|
|
|
|
});
|
|
|
|
|
|
setEditingSpeakers(initialEditingState);
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
} catch (transcriptError) {
|
|
|
|
|
|
console.warn('No transcript data available:', transcriptError);
|
|
|
|
|
|
setTranscript([]);
|
2025-08-25 08:11:29 +00:00
|
|
|
|
setSpeakerList([]);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
} 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-25 03:30:27 +00:00
|
|
|
|
audioRef.current.play();
|
|
|
|
|
|
setIsPlaying(true);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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('删除会议失败,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-25 08:11:29 +00:00
|
|
|
|
const handleSpeakerTagUpdate = async (speakerId, newTag) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
|
|
|
|
|
await axios.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags`, {
|
|
|
|
|
|
speaker_id: speakerId,
|
|
|
|
|
|
new_tag: newTag
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 更新本地状态
|
|
|
|
|
|
setTranscript(prev => prev.map(item =>
|
|
|
|
|
|
item.speaker_id === speakerId
|
|
|
|
|
|
? { ...item, speaker_tag: newTag }
|
|
|
|
|
|
: item
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
setSpeakerList(prev => prev.map(speaker =>
|
|
|
|
|
|
speaker.speaker_id === speakerId
|
|
|
|
|
|
? { ...speaker, speaker_tag: newTag }
|
|
|
|
|
|
: speaker
|
|
|
|
|
|
));
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error updating speaker tag:', err);
|
|
|
|
|
|
setError('更新发言人标签失败,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleBatchSpeakerUpdate = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const baseUrl = "";
|
|
|
|
|
|
const updates = Object.entries(editingSpeakers).map(([speakerId, newTag]) => ({
|
|
|
|
|
|
speaker_id: parseInt(speakerId), // 确保传递整数类型
|
|
|
|
|
|
new_tag: newTag
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
await axios.put(`${baseUrl}/api/meetings/${meeting_id}/speaker-tags/batch`, {
|
|
|
|
|
|
updates: updates
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
// 更新本地状态
|
|
|
|
|
|
setTranscript(prev => prev.map(item => {
|
|
|
|
|
|
const newTag = editingSpeakers[item.speaker_id];
|
|
|
|
|
|
return newTag ? { ...item, speaker_tag: newTag } : item;
|
|
|
|
|
|
}));
|
|
|
|
|
|
|
|
|
|
|
|
setSpeakerList(prev => prev.map(speaker => ({
|
|
|
|
|
|
...speaker,
|
|
|
|
|
|
speaker_tag: editingSpeakers[speaker.speaker_id] || speaker.speaker_tag
|
|
|
|
|
|
})));
|
|
|
|
|
|
|
|
|
|
|
|
setShowSpeakerEdit(false);
|
|
|
|
|
|
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error batch updating speaker tags:', err);
|
|
|
|
|
|
setError('批量更新发言人标签失败,请重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleEditingSpeakerChange = (speakerId, newTag) => {
|
|
|
|
|
|
setEditingSpeakers(prev => ({
|
|
|
|
|
|
...prev,
|
|
|
|
|
|
[speakerId]: newTag
|
|
|
|
|
|
}));
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleSpeakerEditOpen = () => {
|
|
|
|
|
|
console.log('Opening speaker edit modal');
|
|
|
|
|
|
console.log('Current transcript:', transcript);
|
|
|
|
|
|
console.log('Current speakerList:', speakerList);
|
|
|
|
|
|
console.log('Current editingSpeakers:', editingSpeakers);
|
|
|
|
|
|
setShowSpeakerEdit(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-05 02:58:13 +00:00
|
|
|
|
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>
|
2025-08-25 08:11:29 +00:00
|
|
|
|
<div className="transcript-controls">
|
|
|
|
|
|
{isCreator && (
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="edit-speakers-btn"
|
|
|
|
|
|
onClick={handleSpeakerEditOpen}
|
|
|
|
|
|
title="编辑发言人标签"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Settings size={16} />
|
|
|
|
|
|
<span>编辑标签</span>
|
|
|
|
|
|
</button>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="toggle-transcript"
|
|
|
|
|
|
onClick={() => setShowTranscript(!showTranscript)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{showTranscript ? '隐藏' : '显示'}
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{showTranscript && (
|
|
|
|
|
|
<div className="transcript-content">
|
|
|
|
|
|
{transcript.map((item) => (
|
2025-08-25 03:30:27 +00:00
|
|
|
|
<div
|
|
|
|
|
|
key={item.segment_id}
|
|
|
|
|
|
className="transcript-item"
|
|
|
|
|
|
>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
<div className="transcript-header-item">
|
2025-08-25 08:11:29 +00:00
|
|
|
|
<span
|
|
|
|
|
|
className="speaker-name clickable"
|
|
|
|
|
|
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
title="跳转到此时间点播放"
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.speaker_tag}
|
2025-08-25 03:30:27 +00:00
|
|
|
|
</span>
|
2025-08-25 08:11:29 +00:00
|
|
|
|
<div className="transcript-item-actions">
|
|
|
|
|
|
<span
|
|
|
|
|
|
className="timestamp clickable"
|
|
|
|
|
|
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
title="跳转到此时间点播放"
|
|
|
|
|
|
>
|
|
|
|
|
|
{formatTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
className="transcript-text clickable"
|
|
|
|
|
|
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
|
|
|
|
|
title="跳转到此时间点播放"
|
|
|
|
|
|
>
|
|
|
|
|
|
{item.text_content}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</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-25 08:11:29 +00:00
|
|
|
|
|
|
|
|
|
|
{/* Speaker Tags Edit Modal */}
|
|
|
|
|
|
{showSpeakerEdit && (
|
|
|
|
|
|
<div className="speaker-edit-modal-overlay" onClick={() => setShowSpeakerEdit(false)}>
|
|
|
|
|
|
<div className="speaker-edit-modal" onClick={(e) => e.stopPropagation()}>
|
|
|
|
|
|
<div className="modal-header">
|
|
|
|
|
|
<h3>编辑发言人标签</h3>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="close-btn"
|
|
|
|
|
|
onClick={() => setShowSpeakerEdit(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
<X size={20} />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="speaker-edit-content">
|
|
|
|
|
|
<p className="modal-description">根据AI识别的发言人ID,为每个发言人设置自定义标签:</p>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="speaker-list">
|
|
|
|
|
|
{speakerList.length > 0 ? (
|
|
|
|
|
|
speakerList.map((speaker) => {
|
|
|
|
|
|
const segmentCount = transcript.filter(item => item.speaker_id === speaker.speaker_id).length;
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div key={speaker.speaker_id} className="speaker-edit-item">
|
|
|
|
|
|
<div className="speaker-info">
|
|
|
|
|
|
<label className="speaker-id">发言人 {speaker.speaker_id}</label>
|
|
|
|
|
|
<span className="segment-count">({segmentCount} 条发言)</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<input
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
value={editingSpeakers[speaker.speaker_id] || ''}
|
|
|
|
|
|
onChange={(e) => handleEditingSpeakerChange(speaker.speaker_id, e.target.value)}
|
|
|
|
|
|
className="speaker-tag-input"
|
|
|
|
|
|
placeholder="输入发言人姓名或标签"
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
})
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className="no-speakers-message">
|
|
|
|
|
|
<p>暂无发言人数据,请检查转录内容是否正确加载。</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-cancel"
|
|
|
|
|
|
onClick={() => setShowSpeakerEdit(false)}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</button>
|
|
|
|
|
|
<button
|
|
|
|
|
|
className="btn-save"
|
|
|
|
|
|
onClick={handleBatchSpeakerUpdate}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Save size={16} />
|
|
|
|
|
|
保存修改
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default MeetingDetails;
|