import React, { useState, useEffect, useRef, useCallback } from 'react'; import { Link, useNavigate, useParams } from 'react-router-dom'; import apiClient from '../utils/apiClient'; import { ArrowLeft, Users, Calendar, FileText, X, User, Save, Upload, Plus, Image, Tag } from 'lucide-react'; import MDEditor, * as commands from '@uiw/react-md-editor'; import '@uiw/react-md-editor/markdown-editor.css'; import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api'; import DateTimePicker from '../components/DateTimePicker'; import TagEditor from '../components/TagEditor'; import './EditMeeting.css'; const EditMeeting = ({ user }) => { const navigate = useNavigate(); const { meeting_id } = useParams(); const imageInputRef = useRef(null); const [formData, setFormData] = useState({ title: '', meeting_time: '', summary: '', attendees: [], tags: '' }); const [availableUsers, setAvailableUsers] = useState([]); const [userSearch, setUserSearch] = useState(''); const [showUserDropdown, setShowUserDropdown] = useState(false); const [audioFile, setAudioFile] = useState(null); const [isLoading, setIsLoading] = useState(true); const [isSaving, setIsSaving] = useState(false); const [isUploading, setIsUploading] = useState(false); const [isUploadingImage, setIsUploadingImage] = useState(false); const [error, setError] = useState(''); const [meeting, setMeeting] = useState(null); const [showUploadArea, setShowUploadArea] = useState(false); const [showUploadConfirm, setShowUploadConfirm] = useState(false); const handleSummaryChange = useCallback((value) => { setFormData(prev => ({ ...prev, summary: value || '' })); }, []); useEffect(() => { fetchMeetingData(); fetchUsers(); }, [meeting_id]); const fetchMeetingData = async () => { try { const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.EDIT(meeting_id))); const meetingData = response.data; // Check if current user is the creator if (meetingData.creator_id !== user.user_id) { navigate('/dashboard'); return; } setMeeting(meetingData); setFormData({ title: meetingData.title, meeting_time: meetingData.meeting_time || '', summary: meetingData.summary || '', attendees: meetingData.attendees || [], tags: meetingData.tags ? meetingData.tags.map(t => t.name).join(', ') : '' }); } catch (err) { setError('无法加载会议信息'); console.error('Error fetching meeting:', err); } finally { setIsLoading(false); } }; const fetchUsers = async () => { try { // 获取所有用户,设置较大的size参数 const response = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.USERS.LIST}?page=1&size=1000`)); setAvailableUsers(response.data.users.filter(u => u.user_id !== user.user_id)); } catch (err) { console.error('Error fetching users:', err); } }; const handleInputChange = (e) => { const { name, value } = e.target; setFormData(prev => ({ ...prev, [name]: value })); }; const handleAddAttendee = (selectedUser) => { if (!formData.attendees.find(a => a.user_id === selectedUser.user_id)) { setFormData(prev => ({ ...prev, attendees: [...prev.attendees, selectedUser] })); } setUserSearch(''); setShowUserDropdown(false); }; const handleRemoveAttendee = (userId) => { setFormData(prev => ({ ...prev, attendees: prev.attendees.filter(a => a.user_id !== userId) })); }; const handleFileChange = (e) => { const file = e.target.files[0]; if (file) { // Check file type - include both MIME types and extensions const allowedMimeTypes = ['audio/mp3', 'audio/wav', 'audio/m4a', 'audio/mpeg', 'audio/mp4', 'audio/x-m4a']; const fileExtension = file.name.toLowerCase().split('.').pop(); const allowedExtensions = ['mp3', 'wav', 'm4a', 'mpeg']; if (!allowedMimeTypes.includes(file.type) && !allowedExtensions.includes(fileExtension)) { setError('请上传支持的音频格式 (MP3, WAV, M4A)'); return; } // Check file size (max 100MB) if (file.size > 100 * 1024 * 1024) { setError('音频文件大小不能超过100MB'); return; } setAudioFile(file); setError(''); } }; const handleSubmit = async (e) => { e.preventDefault(); if (!formData.title.trim()) { setError('请输入会议标题'); return; } setIsSaving(true); setError(''); try { const updateData = { title: formData.title, meeting_time: formData.meeting_time || null, summary: formData.summary, attendee_ids: formData.attendees.map(a => a.user_id), tags: formData.tags }; await apiClient.put(buildApiUrl(API_ENDPOINTS.MEETINGS.UPDATE(meeting_id)), updateData); navigate(`/meetings/${meeting_id}`); } catch (err) { setError(err.response?.data?.detail || '更新会议失败,请重试'); } finally { setIsSaving(false); } }; const handleUploadAudio = async () => { if (!audioFile) { setError('请先选择音频文件'); return; } setIsUploading(true); setError(''); try { const formDataUpload = new FormData(); formDataUpload.append('audio_file', audioFile); formDataUpload.append('meeting_id', meeting_id); formDataUpload.append('force_replace', 'true'); // Always force replace in edit mode const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, { headers: { 'Content-Type': 'multipart/form-data', }, }); setAudioFile(null); setShowUploadArea(false); setShowUploadConfirm(false); // Reset file input const fileInput = document.getElementById('audio-file'); if (fileInput) fileInput.value = ''; } catch (err) { console.error('Upload error:', err); setError(err.response?.data?.detail || '上传音频文件失败,请重试'); } finally { setIsUploading(false); } }; const handleImageUpload = async (file) => { if (!file) return null; // Validate file type const allowedTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp']; if (!allowedTypes.includes(file.type)) { setError('请上传支持的图片格式 (JPG, PNG, GIF, WebP)'); return null; } // Validate file size (10MB) if (file.size > 10 * 1024 * 1024) { setError('图片大小不能超过10MB'); return null; } setIsUploadingImage(true); setError(''); try { const formData = new FormData(); formData.append('image_file', file); const response = await apiClient.post( buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_IMAGE(meeting_id)), formData, { headers: { 'Content-Type': 'multipart/form-data', }, } ); return `${API_BASE_URL}${response.data.file_path}`; } catch (err) { setError('上传图片失败,请重试'); return null; } finally { setIsUploadingImage(false); } }; const insertImageMarkdown = (imageUrl, altText = '图片') => { const imageMarkdown = `![${altText}](${imageUrl})`; setFormData(prev => ({ ...prev, summary: prev.summary + '\n\n' + imageMarkdown })); }; const handleImageSelect = async (event) => { const file = event.target.files[0]; if (file) { const imageUrl = await handleImageUpload(file); if (imageUrl) { insertImageMarkdown(imageUrl, file.name); } } // Reset file input if (imageInputRef.current) { imageInputRef.current.value = ''; } }; // 创建自定义上传图片命令(只显示文字,不显示图标) const uploadImageCommand = { name: 'upload-image', keyCommand: 'upload-image', buttonProps: { 'aria-label': '上传本地图片', title: '上传本地图片' }, icon: UploadImage, execute: () => { imageInputRef.current?.click(); } }; // 创建修改后的图片URL命令 const imageUrlCommand = { ...commands.image, name: 'image-url', icon: AddImageURL, }; // 自定义工具栏命令配置 const customCommands = [ commands.bold, commands.italic, commands.strikethrough, commands.hr, commands.group([ commands.title1, commands.title2, commands.title3, commands.title4, commands.title5, commands.title6, ], { name: 'title', groupName: 'title', buttonProps: { 'aria-label': '插入标题', title: '插入标题' } }), commands.divider, commands.link, commands.quote, commands.code, commands.codeBlock, // 创建图片功能组,使用系统自带的image命令和自定义上传命令 commands.group([ imageUrlCommand, uploadImageCommand ], { name: 'image-group', groupName: 'image', buttonProps: { 'aria-label': '添加图片', title: '添加图片' }, icon: ( ) }), commands.divider, commands.unorderedListCommand, commands.orderedListCommand, commands.checkedListCommand, ]; // 右侧额外命令(预览、全屏等) const customExtraCommands = [ commands.codeEdit, commands.codeLive, commands.codePreview, commands.divider, commands.fullscreen, ]; const filteredUsers = availableUsers.filter(user => { // Exclude users already selected as attendees const isAlreadySelected = formData.attendees.some(attendee => attendee.user_id === user.user_id); if (isAlreadySelected) return false; // Filter by search text return user.caption.toLowerCase().includes(userSearch.toLowerCase()) || user.username.toLowerCase().includes(userSearch.toLowerCase()); }); if (isLoading) { return (

加载中...

); } return (
返回首页

编辑会议纪要

修改会议信息、参会人员和摘要内容

setFormData(prev => ({ ...prev, meeting_time: value }))} placeholder="选择会议时间(可选)" />
setFormData(prev => ({ ...prev, tags: value }))} placeholder="输入标签,按回车或逗号分隔" />
{formData.attendees.map(attendee => (
{attendee.caption}
))}
{ setUserSearch(e.target.value); setShowUserDropdown(true); }} onFocus={() => setShowUserDropdown(true)} onBlur={() => { // Delay hiding dropdown to allow click events setTimeout(() => setShowUserDropdown(false), 200); }} placeholder="搜索用户名或姓名添加参会人..." className="user-search-input" /> {showUserDropdown && userSearch && (
{filteredUsers.length > 0 ? ( filteredUsers.map(user => (
handleAddAttendee(user)} >
{user.caption} @{user.username}
)) ) : (
未找到匹配的用户
)}
)}
{!showUploadArea ? ( ) : (
{audioFile && (
已选择: {audioFile.name}
)} {audioFile && ( )}
)}
使用Markdown格式编写会议摘要,支持**粗体**、*斜体*、# 标题、- 列表等格式。工具栏中可以上传图片或插入图片URL。
{isUploadingImage && (
正在上传图片...
)}
{error && (
{error}
)}
取消
{/* Upload Confirmation Modal */} {showUploadConfirm && (
setShowUploadConfirm(false)}>
e.stopPropagation()}>

确认重新上传

重传音频文件将清空已有的会话转录,是否继续?

)}
); }; export default EditMeeting;