imetting_frontend/src/pages/EditMeeting.jsx

639 lines
21 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

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: <span style={{fontSize:12}}>UploadImage</span>,
execute: () => {
imageInputRef.current?.click();
}
};
// 创建修改后的图片URL命令
const imageUrlCommand = {
...commands.image,
name: 'image-url',
icon: <span style={{fontSize:12}}>AddImageURL</span>,
};
// 自定义工具栏命令配置
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: (
<svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor">
<path d="M21 19V5c0-1.1-.9-2-2-2H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2zM8.5 13.5l2.5 3.01L14.5 12l4.5 6H5l3.5-4.5z"/>
</svg>
)
}),
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 (
<div className="edit-meeting-page">
<div className="loading-container">
<div className="loading-spinner"></div>
<p>加载中...</p>
</div>
</div>
);
}
return (
<div className="edit-meeting-page">
<div className="edit-header">
<Link to="/dashboard">
<span className="back-link">
<ArrowLeft size={20} />
<span>返回首页</span>
</span>
</Link>
</div>
<div className="edit-content">
<div className="edit-card">
<header className="edit-card-header">
<h1>编辑会议纪要</h1>
<p>修改会议信息参会人员和摘要内容</p>
</header>
<form onSubmit={handleSubmit} className="edit-form">
<div className="form-group">
<label htmlFor="title">
<FileText size={18} />
会议标题 *
</label>
<input
type="text"
id="title"
name="title"
value={formData.title}
onChange={handleInputChange}
placeholder="请输入会议标题"
required
/>
</div>
<div className="form-group">
<label>
<Calendar size={18} />
会议时间
</label>
<DateTimePicker
value={formData.meeting_time}
onChange={(value) => setFormData(prev => ({ ...prev, meeting_time: value }))}
placeholder="选择会议时间(可选)"
/>
</div>
<div className="form-group">
<label htmlFor="tags">
<Tag size={18} />
会议标签
</label>
<TagEditor
value={formData.tags}
onChange={(value) => setFormData(prev => ({ ...prev, tags: value }))}
placeholder="输入标签,按回车或逗号分隔"
/>
</div>
<div className="form-group">
<label>
<Users size={18} />
参会人员
</label>
<div className="attendees-container">
<div className="selected-attendees">
{formData.attendees.map(attendee => (
<div key={attendee.user_id} className="attendee-chip">
<User size={16} />
<span>{attendee.caption}</span>
<button
type="button"
onClick={() => handleRemoveAttendee(attendee.user_id)}
className="remove-attendee"
title="移除参会人"
>
<X size={14} />
</button>
</div>
))}
</div>
<div className="user-search-container">
<input
type="text"
value={userSearch}
onChange={(e) => {
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 && (
<div className="user-dropdown">
{filteredUsers.length > 0 ? (
filteredUsers.map(user => (
<div
key={user.user_id}
className="user-option"
onClick={() => handleAddAttendee(user)}
>
<User size={16} />
<div className="user-info">
<span className="user-name">{user.caption}</span>
<span className="user-username">@{user.username}</span>
</div>
</div>
))
) : (
<div className="no-users">未找到匹配的用户</div>
)}
</div>
)}
</div>
</div>
</div>
<div className="form-group">
<div className="audio-upload-section">
{!showUploadArea ? (
<button
type="button"
onClick={() => setShowUploadArea(true)}
className="show-upload-btn"
>
<Upload size={16} />
<span>重新上传录音文件</span>
</button>
) : (
<div className="file-upload-container">
<div className="upload-header">
<button
type="button"
onClick={() => {
setShowUploadArea(false);
setAudioFile(null);
// Reset file input
const fileInput = document.getElementById('audio-file');
if (fileInput) fileInput.value = '';
}}
className="close-upload-btn"
>
<X size={16} />
</button>
</div>
<input
type="file"
id="audio-file"
accept="audio/*"
onChange={handleFileChange}
className="file-input"
/>
<label htmlFor="audio-file" className="file-upload-label">
<Plus size={20} />
<span>选择新的音频文件</span>
<small>支持 MP3, WAV, M4A 格式最大100MB</small>
</label>
{audioFile && (
<div className="selected-file">
<span>已选择: {audioFile.name}</span>
<button
type="button"
onClick={() => setAudioFile(null)}
className="remove-file"
>
<X size={16} />
</button>
</div>
)}
{audioFile && (
<button
type="button"
onClick={() => setShowUploadConfirm(true)}
className="upload-btn"
disabled={isUploading}
>
<Upload size={16} />
{isUploading ? '上传并分析中...' : '上传并重新分析'}
</button>
)}
</div>
)}
</div>
</div>
<div className="form-group">
<div className="summary-header">
<label htmlFor="summary">
<FileText size={18} />
会议摘要
</label>
</div>
<div className="markdown-editor-container">
<MDEditor
key="summary-editor"
value={formData.summary}
onChange={handleSummaryChange}
data-color-mode="light"
height={400}
preview="edit"
hideToolbar={false}
toolbarBottom={false}
commands={customCommands}
extraCommands={customExtraCommands}
autoFocus={false}
textareaProps={{
placeholder: '在这里编写会议摘要...',
style: {
fontSize: '14px',
lineHeight: '1.5',
fontFamily: 'inherit'
},
spellCheck: false,
autoComplete: 'off',
autoCapitalize: 'off',
autoCorrect: 'off'
}}
/>
<input
ref={imageInputRef}
type="file"
accept="image/*"
onChange={handleImageSelect}
style={{ display: 'none' }}
/>
</div>
<div className="markdown-hint">
<small>使用Markdown格式编写会议摘要支持**粗体***斜体*# 标题- 列表等格式工具栏中可以上传图片或插入图片URL</small>
</div>
{isUploadingImage && (
<div className="uploading-indicator">
<span>正在上传图片...</span>
</div>
)}
</div>
{error && (
<div className="error-message">{error}</div>
)}
<div className="form-actions">
<Link to={`/meetings/${meeting_id}`}>
<span className="btn-cancel">取消</span>
</Link>
<button
type="submit"
className="btn-submit"
disabled={isSaving}
>
<Save size={16} />
{isSaving ? '保存中...' : '保存更改'}
</button>
</div>
</form>
</div>
</div>
{/* Upload Confirmation Modal */}
{showUploadConfirm && (
<div className="delete-modal-overlay" onClick={() => setShowUploadConfirm(false)}>
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
<h3>确认重新上传</h3>
<p>重传音频文件将清空已有的会话转录是否继续</p>
<div className="modal-actions">
<button
className="btn-cancel"
onClick={() => setShowUploadConfirm(false)}
>
取消
</button>
<button
className="btn-submit"
onClick={handleUploadAudio}
disabled={isUploading}
>
确定重传
</button>
</div>
</div>
</div>
)}
</div>
);
};
export default EditMeeting;