将重传和重转录的入口移到了会议详情
parent
6c549eca15
commit
0391dd9cb3
|
|
@ -8,48 +8,13 @@ import rehypeSanitize from 'rehype-sanitize';
|
|||
import TagDisplay from './TagDisplay';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import Dropdown from './Dropdown';
|
||||
import tools from '../utils/tools';
|
||||
import './MeetingTimeline.css';
|
||||
|
||||
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore = false, onLoadMore, loadingMore = false }) => {
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
|
||||
const formatDateTime = (dateTimeString) => {
|
||||
if (!dateTimeString) return '时间待定';
|
||||
const date = new Date(dateTimeString);
|
||||
return date.toLocaleString('zh-CN', { hour: '2-digit', minute: '2-digit' });
|
||||
};
|
||||
|
||||
const formatDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', { month: 'long', day: 'numeric' });
|
||||
};
|
||||
|
||||
const truncateSummary = (summary, maxLines = 3, maxLength = 100) => {
|
||||
if (!summary) return '暂无摘要';
|
||||
|
||||
// Split by lines and check line count
|
||||
const lines = summary.split('\n');
|
||||
const hasMoreLines = lines.length > maxLines;
|
||||
|
||||
// Also check character length
|
||||
const hasMoreChars = summary.length > maxLength;
|
||||
|
||||
if (hasMoreLines || hasMoreChars) {
|
||||
// Take first few lines or characters, whichever is shorter
|
||||
const truncatedByLines = lines.slice(0, maxLines).join('\n');
|
||||
const truncatedByChars = summary.substring(0, maxLength);
|
||||
|
||||
const result = truncatedByLines.length <= truncatedByChars.length
|
||||
? truncatedByLines
|
||||
: truncatedByChars;
|
||||
|
||||
return result + '...';
|
||||
}
|
||||
|
||||
return summary;
|
||||
};
|
||||
|
||||
const shouldShowMoreButton = (summary, maxLines = 3, maxLength = 100) => {
|
||||
if (!summary) return false;
|
||||
const lines = summary.split('\n');
|
||||
|
|
@ -92,7 +57,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
|
|||
{sortedDates.map(date => (
|
||||
<div key={date} className="timeline-date-section">
|
||||
<div className="timeline-date-node">
|
||||
<span className="date-text">{formatDate(date)}</span>
|
||||
<span className="date-text">{tools.formatDateLong(date)}</span>
|
||||
</div>
|
||||
<div className="meetings-for-date">
|
||||
{meetingsByDate[date].map(meeting => {
|
||||
|
|
@ -147,7 +112,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
|
|||
<div className="meeting-meta">
|
||||
<div className="meta-item">
|
||||
<Clock size={16} />
|
||||
<span>{formatDateTime(meeting.meeting_time)}</span>
|
||||
<span>{tools.formatTime(meeting.meeting_time)}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<Users size={16} />
|
||||
|
|
@ -182,7 +147,7 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting, hasMore
|
|||
remarkPlugins={[remarkGfm]}
|
||||
rehypePlugins={[rehypeRaw, rehypeSanitize]}
|
||||
>
|
||||
{truncateSummary(meeting.summary)}
|
||||
{tools.truncateSummary(meeting.summary)}
|
||||
</ReactMarkdown>
|
||||
</div>
|
||||
{shouldShowMoreButton(meeting.summary) && (
|
||||
|
|
|
|||
|
|
@ -22,16 +22,11 @@ const EditMeeting = ({ user }) => {
|
|||
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 [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 默认100MB
|
||||
const [maxImageSize, setMaxImageSize] = useState(10 * 1024 * 1024); // 默认10MB
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -42,9 +37,7 @@ const EditMeeting = ({ user }) => {
|
|||
|
||||
const loadFileSizeConfig = async () => {
|
||||
try {
|
||||
const fileSize = await configService.getMaxFileSize();
|
||||
const imageSize = await configService.getMaxImageSize();
|
||||
setMaxFileSize(fileSize);
|
||||
setMaxImageSize(imageSize);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load file size config:', error);
|
||||
|
|
@ -114,29 +107,6 @@ const EditMeeting = ({ user }) => {
|
|||
}));
|
||||
};
|
||||
|
||||
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 using dynamic config
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
|
||||
setError(`音频文件大小不能超过${maxSizeMB}MB`);
|
||||
return;
|
||||
}
|
||||
setAudioFile(file);
|
||||
setError('');
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e) => {
|
||||
e.preventDefault();
|
||||
if (!formData.title.trim()) {
|
||||
|
|
@ -165,42 +135,6 @@ const EditMeeting = ({ user }) => {
|
|||
}
|
||||
};
|
||||
|
||||
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?.message || '上传音频文件失败,请重试');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleImageUpload = async (file) => {
|
||||
if (!file) return null;
|
||||
|
||||
|
|
@ -387,105 +321,6 @@ const EditMeeting = ({ user }) => {
|
|||
</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);
|
||||
setError('');
|
||||
// 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 格式</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>
|
||||
)}
|
||||
|
||||
{/* Error message for audio upload - shown right after upload area */}
|
||||
{error && showUploadArea && (
|
||||
<div className="error-message">{error}</div>
|
||||
)}
|
||||
|
||||
{/* Upload Confirmation Modal - moved here to be right after upload area */}
|
||||
{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>
|
||||
</div>
|
||||
|
||||
<div className="form-group">
|
||||
<div className="summary-header">
|
||||
<label htmlFor="summary">
|
||||
|
|
|
|||
|
|
@ -13,7 +13,8 @@ import SimpleSearchInput from '../components/SimpleSearchInput';
|
|||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import html2canvas from 'html2canvas';
|
||||
import exportService from '../services/exportService';
|
||||
import tools from '../utils/tools';
|
||||
import PageLoading from '../components/PageLoading';
|
||||
import meetingCacheService from '../services/meetingCacheService';
|
||||
import './KnowledgeBasePage.css';
|
||||
|
|
@ -290,58 +291,22 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const formatDate = (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'
|
||||
});
|
||||
};
|
||||
|
||||
const formatTime = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
};
|
||||
|
||||
const formatShortDate = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
});
|
||||
};
|
||||
|
||||
const formatMeetingDate = (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'
|
||||
});
|
||||
};
|
||||
|
||||
const isToday = (dateString) => {
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const groupKbsByDate = (kbList) => {
|
||||
const todayKbs = [];
|
||||
const pastKbs = [];
|
||||
|
||||
kbList.forEach(kb => {
|
||||
if (isToday(kb.created_at)) {
|
||||
if (tools.isToday(kb.created_at)) {
|
||||
todayKbs.push(kb);
|
||||
} else {
|
||||
pastKbs.push(kb);
|
||||
|
|
@ -363,163 +328,21 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// 创建完整的导出内容
|
||||
const exportContainer = document.createElement('div');
|
||||
exportContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
width: 800px;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
`;
|
||||
const createdAt = tools.formatDate(selectedKb.created_at);
|
||||
const tags = selectedKb.tags?.join('、') || '';
|
||||
|
||||
const createdAt = formatDate(selectedKb.created_at);
|
||||
const sourceMeetings = selectedKb.source_meetings?.map(m => m.title).join('、') || '无';
|
||||
|
||||
exportContainer.innerHTML = `
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
|
||||
${selectedKb.title || '知识库'}
|
||||
</h1>
|
||||
|
||||
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📋 知识库信息
|
||||
</h2>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>创建时间:</strong>${createdAt}</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>创建者:</strong>${selectedKb.created_by_name || '未知'}</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>数据源数量:</strong>${selectedKb.source_meetings?.length || 0}个</p>
|
||||
${selectedKb.user_prompt ? `<p style="margin: 12px 0; font-size: 16px;"><strong>用户提示词:</strong>${selectedKb.user_prompt}</p>` : ''}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📝 知识库内容
|
||||
</h2>
|
||||
<div id="summary-content" style="font-size: 15px; line-height: 1.8;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
|
||||
导出时间:${new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(exportContainer);
|
||||
|
||||
// 渲染Markdown内容
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
const ReactMarkdownModule = (await import('react-markdown')).default;
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
|
||||
const root = createRoot(tempDiv);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
root.render(
|
||||
React.createElement(ReactMarkdownModule, {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeRaw, rehypeSanitize],
|
||||
children: selectedKb.content
|
||||
})
|
||||
);
|
||||
setTimeout(resolve, 200);
|
||||
await exportService.exportKnowledgeBaseToImage({
|
||||
title: selectedKb.title || '知识库',
|
||||
content: selectedKb.content,
|
||||
metadata: {
|
||||
creator: selectedKb.created_by_name || '未知',
|
||||
createdTime: createdAt,
|
||||
tags: tags,
|
||||
sourceMeetings: selectedKb.source_meetings?.length || 0
|
||||
}
|
||||
});
|
||||
|
||||
// 获取渲染后的HTML并添加样式
|
||||
const renderedHTML = tempDiv.innerHTML;
|
||||
const summaryContentDiv = exportContainer.querySelector('#summary-content');
|
||||
summaryContentDiv.innerHTML = renderedHTML;
|
||||
|
||||
// 为渲染后的内容添加样式
|
||||
const styles = `
|
||||
<style>
|
||||
#summary-content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
|
||||
#summary-content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
|
||||
#summary-content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
|
||||
#summary-content p { margin: 12px 0; color: #475569; }
|
||||
#summary-content ul, #summary-content ol { margin: 12px 0; padding-left: 25px; }
|
||||
#summary-content li { margin: 8px 0; color: #475569; }
|
||||
#summary-content strong { color: #1e293b; font-weight: 600; }
|
||||
#summary-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
margin: 15px 0;
|
||||
padding: 15px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
#summary-content code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
#summary-content pre {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content th, #summary-content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
#summary-content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
exportContainer.insertAdjacentHTML('afterbegin', styles);
|
||||
|
||||
// 等待图片和样式加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 使用html2canvas生成图片
|
||||
const canvas = await html2canvas(exportContainer, {
|
||||
width: 880,
|
||||
height: exportContainer.scrollHeight + 80,
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${selectedKb.title || '知识库'}_内容_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 清理DOM
|
||||
root.unmount();
|
||||
document.body.removeChild(tempDiv);
|
||||
document.body.removeChild(exportContainer);
|
||||
|
||||
showToast('内容已成功导出为图片', 'success');
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
showToast('图片导出失败,请重试。', 'error');
|
||||
|
|
@ -534,38 +357,14 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// 查找SVG元素
|
||||
const svgElement = document.querySelector('.markmap-render-area svg');
|
||||
if (!svgElement) {
|
||||
showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用html2canvas导出SVG
|
||||
const mindmapContainer = svgElement.parentElement;
|
||||
|
||||
const canvas = await html2canvas(mindmapContainer, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
await exportService.exportMindMapToImage({
|
||||
title: selectedKb.title || '知识库'
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${selectedKb.title || '知识库'}_思维导图_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('思维导图已成功导出为图片', 'success');
|
||||
} catch (error) {
|
||||
console.error('思维导图导出失败:', error);
|
||||
showToast('思维导图导出失败,请重试。', 'error');
|
||||
showToast(error.message || '思维导图导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -661,7 +460,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
)}
|
||||
</div>
|
||||
<div className="kb-list-item-meta">
|
||||
<span className="meta-time">{formatTime(kb.created_at)}</span>
|
||||
<span className="meta-time">{tools.formatTime(kb.created_at)}</span>
|
||||
<span className="meta-item">
|
||||
<Database size={12} />
|
||||
{kb.source_meeting_count || 0} 个数据源
|
||||
|
|
@ -710,7 +509,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
)}
|
||||
</div>
|
||||
<div className="kb-list-item-meta">
|
||||
<span className="meta-date">{formatShortDate(kb.created_at)}</span>
|
||||
<span className="meta-date">{tools.formatShortDate(kb.created_at)}</span>
|
||||
<span className="meta-item">
|
||||
<Database size={12} />
|
||||
{kb.source_meeting_count || 0} 个数据源
|
||||
|
|
@ -754,7 +553,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
)}
|
||||
<span className="meta-item">
|
||||
<Calendar size={14} />
|
||||
{formatDate(selectedKb.created_at)}
|
||||
{tools.formatDate(selectedKb.created_at)}
|
||||
</span>
|
||||
{selectedKb.source_meetings && selectedKb.source_meetings.length > 0 && (
|
||||
<span className="meta-item">
|
||||
|
|
@ -955,7 +754,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
<span className="meeting-item-creator">创建人: {meeting.creator_username}</span>
|
||||
)}
|
||||
{meeting.created_at && (
|
||||
<span className="meeting-item-date">创建时间: {formatMeetingDate(meeting.created_at)}</span>
|
||||
<span className="meeting-item-date">创建时间: {tools.formatMeetingDate(meeting.created_at)}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -340,9 +340,58 @@
|
|||
color: white;
|
||||
}
|
||||
|
||||
.section-header-with-menu {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.audio-section h2 {
|
||||
color: white;
|
||||
margin-bottom: 1.5rem;
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
/* Audio Menu Button */
|
||||
.audio-menu-button {
|
||||
background: none;
|
||||
border: none;
|
||||
color: white;
|
||||
cursor: pointer;
|
||||
padding: 8px;
|
||||
outline: none;
|
||||
border-radius: 6px;
|
||||
transition: background-color 0.2s ease;
|
||||
}
|
||||
|
||||
.audio-menu-button:hover {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.audio-menu-button:focus {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.audio-menu-button:active {
|
||||
background: rgba(255, 255, 255, 0.2);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.selected-file-info {
|
||||
margin: 1rem 0;
|
||||
padding: 0.75rem;
|
||||
background: #f1f5f9;
|
||||
border-radius: 6px;
|
||||
font-size: 0.875rem;
|
||||
color: #475569;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
}
|
||||
|
||||
.selected-file-info .file-size {
|
||||
font-size: 0.75rem;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.audio-player {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,9 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { useParams, Link, useNavigate } from 'react-router-dom';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode } from 'lucide-react';
|
||||
import configService from '../utils/configService';
|
||||
import tools from '../utils/tools';
|
||||
import { ArrowLeft, Clock, Users, User, Calendar, Play, Pause, Volume2, MessageCircle, Edit, Trash2, Settings, Save, X, Edit3, Brain, Sparkles, Download, ArrowDown, RefreshCw, RefreshCwOff, Image, QrCode, MoreVertical, Upload } from 'lucide-react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
|
|
@ -13,8 +15,9 @@ import ConfirmDialog from '../components/ConfirmDialog';
|
|||
import Toast from '../components/Toast';
|
||||
import PageLoading from '../components/PageLoading';
|
||||
import QRCodeModal from '../components/QRCodeModal';
|
||||
import Dropdown from '../components/Dropdown';
|
||||
import exportService from '../services/exportService';
|
||||
import { Tabs } from 'antd';
|
||||
import html2canvas from 'html2canvas';
|
||||
import './MeetingDetails.css';
|
||||
|
||||
const { TabPane } = Tabs;
|
||||
|
|
@ -59,6 +62,11 @@ const MeetingDetails = ({ user }) => {
|
|||
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [showQRModal, setShowQRModal] = useState(false);
|
||||
const [audioFile, setAudioFile] = useState(null);
|
||||
const [isUploading, setIsUploading] = useState(false);
|
||||
const [showUploadConfirm, setShowUploadConfirm] = useState(false);
|
||||
const [maxFileSize, setMaxFileSize] = useState(100 * 1024 * 1024); // 默认100MB
|
||||
const [uploadError, setUploadError] = useState('');
|
||||
const audioRef = useRef(null);
|
||||
const transcriptRefs = useRef([]);
|
||||
|
||||
|
|
@ -74,7 +82,8 @@ const MeetingDetails = ({ user }) => {
|
|||
|
||||
useEffect(() => {
|
||||
fetchMeetingDetails();
|
||||
|
||||
loadFileSizeConfig();
|
||||
|
||||
// Cleanup interval on unmount
|
||||
return () => {
|
||||
if (statusCheckInterval) {
|
||||
|
|
@ -281,29 +290,116 @@ const MeetingDetails = ({ user }) => {
|
|||
}
|
||||
};
|
||||
|
||||
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 hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
// 超过60分钟显示小时:分钟:秒格式
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
// 60分钟内显示分钟:秒格式
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
const loadFileSizeConfig = async () => {
|
||||
try {
|
||||
const fileSize = await configService.getMaxFileSize();
|
||||
setMaxFileSize(fileSize);
|
||||
} catch (error) {
|
||||
console.warn('Failed to load file size config:', error);
|
||||
}
|
||||
};
|
||||
|
||||
const handleFileChange = (e) => {
|
||||
const file = e.target.files[0];
|
||||
if (file) {
|
||||
// Check file type
|
||||
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)) {
|
||||
setUploadError('请上传支持的音频格式 (MP3, WAV, M4A)');
|
||||
showToast('请上传支持的音频格式 (MP3, WAV, M4A)', 'error');
|
||||
return;
|
||||
}
|
||||
// Check file size
|
||||
if (file.size > maxFileSize) {
|
||||
const maxSizeMB = Math.round(maxFileSize / (1024 * 1024));
|
||||
setUploadError(`音频文件大小不能超过${maxSizeMB}MB`);
|
||||
showToast(`音频文件大小不能超过${maxSizeMB}MB`, 'error');
|
||||
return;
|
||||
}
|
||||
setAudioFile(file);
|
||||
setUploadError('');
|
||||
setShowUploadConfirm(true);
|
||||
}
|
||||
};
|
||||
|
||||
const handleUploadAudio = async () => {
|
||||
if (!audioFile) {
|
||||
setUploadError('请先选择音频文件');
|
||||
showToast('请先选择音频文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsUploading(true);
|
||||
setUploadError('');
|
||||
|
||||
try {
|
||||
const formDataUpload = new FormData();
|
||||
formDataUpload.append('audio_file', audioFile);
|
||||
formDataUpload.append('meeting_id', meeting_id);
|
||||
formDataUpload.append('force_replace', 'true');
|
||||
|
||||
const response = await apiClient.post(buildApiUrl(API_ENDPOINTS.MEETINGS.UPLOAD_AUDIO), formDataUpload, {
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
});
|
||||
|
||||
setAudioFile(null);
|
||||
setShowUploadConfirm(false);
|
||||
setShowAudioDropdown(false);
|
||||
showToast('音频上传成功,正在进行智能转录...', 'success');
|
||||
|
||||
// Reset file input
|
||||
const fileInput = document.getElementById('audio-file-upload');
|
||||
if (fileInput) fileInput.value = '';
|
||||
|
||||
// Refresh meeting details to get new audio and transcription status
|
||||
await fetchMeetingDetails();
|
||||
|
||||
} catch (err) {
|
||||
console.error('Upload error:', err);
|
||||
setUploadError(err.response?.data?.message || '上传音频文件失败,请重试');
|
||||
showToast(err.response?.data?.message || '上传音频文件失败,请重试', 'error');
|
||||
} finally {
|
||||
setIsUploading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleStartTranscription = async () => {
|
||||
try {
|
||||
if (!audioUrl) {
|
||||
showToast('没有可用的音频文件', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status)) {
|
||||
showToast('转录任务正在进行中', 'info');
|
||||
return;
|
||||
}
|
||||
|
||||
// 调用后端API启动转录
|
||||
const response = await apiClient.post(buildApiUrl(`/api/meetings/${meeting_id}/transcription/start`));
|
||||
|
||||
if (response.data.task_id) {
|
||||
showToast('智能转录已启动', 'success');
|
||||
setShowAudioDropdown(false);
|
||||
// 开始轮询转录状态
|
||||
startStatusPolling(response.data.task_id);
|
||||
}
|
||||
|
||||
} catch (err) {
|
||||
console.error('Start transcription error:', err);
|
||||
showToast(err.response?.data?.message || '启动转录失败,请重试', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const handlePlayPause = () => {
|
||||
if (audioRef.current) {
|
||||
if (isPlaying) {
|
||||
|
|
@ -749,165 +845,23 @@ const MeetingDetails = ({ user }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// 创建完整的导出内容
|
||||
const exportContainer = document.createElement('div');
|
||||
exportContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
width: 800px;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const meetingTime = formatDateTime(meeting.meeting_time);
|
||||
const meetingTime = tools.formatDateTime(meeting.meeting_time);
|
||||
const attendeesList = meeting.attendees.map(attendee =>
|
||||
typeof attendee === 'string' ? attendee : attendee.caption
|
||||
).join('、');
|
||||
|
||||
exportContainer.innerHTML = `
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
|
||||
${meeting.title || '会议总结'}
|
||||
</h1>
|
||||
|
||||
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📋 会议信息
|
||||
</h2>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间:</strong>${meetingTime}</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>创建人:</strong>${meeting.creator_username}</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人数:</strong>${meeting.attendees.length}人</p>
|
||||
<p style="margin: 12px 0; font-size: 16px;"><strong>参会人员:</strong>${attendeesList}</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📝 会议摘要
|
||||
</h2>
|
||||
<div id="summary-content" style="font-size: 15px; line-height: 1.8;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
|
||||
导出时间:${new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(exportContainer);
|
||||
|
||||
// 渲染Markdown内容
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
const ReactMarkdownModule = (await import('react-markdown')).default;
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
|
||||
const root = createRoot(tempDiv);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
root.render(
|
||||
React.createElement(ReactMarkdownModule, {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeRaw, rehypeSanitize],
|
||||
children: meeting.summary
|
||||
})
|
||||
);
|
||||
setTimeout(resolve, 200);
|
||||
await exportService.exportMeetingSummaryToImage({
|
||||
title: meeting.title || '会议总结',
|
||||
summary: meeting.summary,
|
||||
metadata: {
|
||||
meetingTime,
|
||||
creator: meeting.creator_username,
|
||||
attendeeCount: meeting.attendees.length,
|
||||
attendees: attendeesList
|
||||
}
|
||||
});
|
||||
|
||||
// 获取渲染后的HTML并添加样式
|
||||
const renderedHTML = tempDiv.innerHTML;
|
||||
const summaryContentDiv = exportContainer.querySelector('#summary-content');
|
||||
summaryContentDiv.innerHTML = renderedHTML;
|
||||
|
||||
// 为渲染后的内容添加样式
|
||||
const styles = `
|
||||
<style>
|
||||
#summary-content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
|
||||
#summary-content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
|
||||
#summary-content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
|
||||
#summary-content p { margin: 12px 0; color: #475569; }
|
||||
#summary-content ul, #summary-content ol { margin: 12px 0; padding-left: 25px; }
|
||||
#summary-content li { margin: 8px 0; color: #475569; }
|
||||
#summary-content strong { color: #1e293b; font-weight: 600; }
|
||||
#summary-content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
margin: 15px 0;
|
||||
padding: 15px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
#summary-content code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
#summary-content pre {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#summary-content th, #summary-content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
#summary-content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
</style>
|
||||
`;
|
||||
|
||||
exportContainer.insertAdjacentHTML('afterbegin', styles);
|
||||
|
||||
// 等待图片和样式加载
|
||||
await new Promise(resolve => setTimeout(resolve, 500));
|
||||
|
||||
// 使用html2canvas生成图片
|
||||
const canvas = await html2canvas(exportContainer, {
|
||||
width: 880,
|
||||
height: exportContainer.scrollHeight + 80,
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${meeting.title || '会议总结'}_总结_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 清理DOM
|
||||
root.unmount();
|
||||
document.body.removeChild(tempDiv);
|
||||
document.body.removeChild(exportContainer);
|
||||
|
||||
showToast('总结已成功导出为图片', 'success');
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
showToast('图片导出失败,请重试。', 'error');
|
||||
|
|
@ -922,38 +876,14 @@ const MeetingDetails = ({ user }) => {
|
|||
return;
|
||||
}
|
||||
|
||||
// 查找SVG元素
|
||||
const svgElement = document.querySelector('.markmap-render-area svg');
|
||||
if (!svgElement) {
|
||||
showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 使用html2canvas导出SVG
|
||||
const mindmapContainer = svgElement.parentElement;
|
||||
|
||||
const canvas = await html2canvas(mindmapContainer, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
await exportService.exportMindMapToImage({
|
||||
title: meeting.title || '会议'
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${meeting.title || '会议'}_思维导图_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
showToast('思维导图已成功导出为图片', 'success');
|
||||
} catch (error) {
|
||||
console.error('思维导图导出失败:', error);
|
||||
showToast('思维导图导出失败,请重试。', 'error');
|
||||
showToast(error.message || '思维导图导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -1024,12 +954,12 @@ const MeetingDetails = ({ user }) => {
|
|||
<div className="meta-item">
|
||||
<Calendar size={18} />
|
||||
<strong>会议日期:</strong>
|
||||
<span>{formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}</span>
|
||||
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[0].slice(2)}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<Clock size={18} />
|
||||
<strong>会议时间:</strong>
|
||||
<span>{formatDateTime(meeting.meeting_time).split(' ')[1]}</span>
|
||||
<span>{tools.formatDateTime(meeting.meeting_time).split(' ')[1]}</span>
|
||||
</div>
|
||||
<div className="meta-item">
|
||||
<User size={18} />
|
||||
|
|
@ -1058,7 +988,39 @@ const MeetingDetails = ({ user }) => {
|
|||
|
||||
{/* Audio Player Section */}
|
||||
<section className="card-section audio-section">
|
||||
<h2><Volume2 size={20} /> 会议录音</h2>
|
||||
<div className="section-header-with-menu">
|
||||
<h2><Volume2 size={20} /> 会议录音</h2>
|
||||
{meeting?.creator_id === user?.user_id && (
|
||||
<Dropdown
|
||||
trigger={
|
||||
<button className="audio-menu-button" title="音频操作">
|
||||
<MoreVertical size={20} />
|
||||
</button>
|
||||
}
|
||||
items={[
|
||||
{
|
||||
label: '音频上传',
|
||||
icon: <Upload size={16} />,
|
||||
onClick: () => document.getElementById('audio-file-upload').click()
|
||||
},
|
||||
...(audioUrl ? [{
|
||||
label: '智能转录',
|
||||
icon: <Brain size={16} />,
|
||||
onClick: handleStartTranscription,
|
||||
disabled: transcriptionStatus && ['pending', 'processing'].includes(transcriptionStatus.status)
|
||||
}] : [])
|
||||
]}
|
||||
align="right"
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
type="file"
|
||||
id="audio-file-upload"
|
||||
accept="audio/*"
|
||||
onChange={handleFileChange}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
</div>
|
||||
{audioUrl ? (
|
||||
<div className="audio-player">
|
||||
{audioFileName && (
|
||||
|
|
@ -1129,7 +1091,7 @@ const MeetingDetails = ({ user }) => {
|
|||
</button>
|
||||
|
||||
<div className="time-info">
|
||||
<span>{formatTime(currentTime)}</span>
|
||||
<span>{tools.formatDuration(currentTime)}</span>
|
||||
</div>
|
||||
|
||||
<div className="progress-container"
|
||||
|
|
@ -1144,7 +1106,7 @@ const MeetingDetails = ({ user }) => {
|
|||
</div>
|
||||
|
||||
<div className="time-info">
|
||||
<span>{formatTime(duration)}</span>
|
||||
<span>{tools.formatDuration(duration)}</span>
|
||||
</div>
|
||||
|
||||
<div className="volume-control">
|
||||
|
|
@ -1193,6 +1155,47 @@ const MeetingDetails = ({ user }) => {
|
|||
)}
|
||||
</section>
|
||||
|
||||
{/* Upload Confirmation Modal */}
|
||||
{showUploadConfirm && (
|
||||
<div className="delete-modal-overlay" onClick={() => setShowUploadConfirm(false)}>
|
||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认上传音频</h3>
|
||||
<p>重新上传音频文件将清空已有的会话转录,是否继续?</p>
|
||||
{audioFile && (
|
||||
<div className="selected-file-info">
|
||||
<span>已选择: {audioFile.name}</span>
|
||||
<span className="file-size">
|
||||
({(audioFile.size / (1024 * 1024)).toFixed(2)} MB)
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{uploadError && (
|
||||
<div className="error-message">{uploadError}</div>
|
||||
)}
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="btn-cancel"
|
||||
onClick={() => {
|
||||
setShowUploadConfirm(false);
|
||||
setAudioFile(null);
|
||||
const fileInput = document.getElementById('audio-file-upload');
|
||||
if (fileInput) fileInput.value = '';
|
||||
}}
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
className="btn-submit"
|
||||
onClick={handleUploadAudio}
|
||||
disabled={isUploading}
|
||||
>
|
||||
{isUploading ? '上传中...' : '确定上传'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<section className="card-section summary-tabs-section">
|
||||
<ContentViewer
|
||||
content={meeting.summary}
|
||||
|
|
@ -1293,7 +1296,7 @@ const MeetingDetails = ({ user }) => {
|
|||
onClick={() => jumpToTime(item.start_time_ms / 1000)}
|
||||
title="跳转到此时间点播放"
|
||||
>
|
||||
{formatTime(item.start_time_ms / 1000)}
|
||||
{tools.formatDuration(item.start_time_ms / 1000)}
|
||||
</span>
|
||||
{isCreator && (
|
||||
<button
|
||||
|
|
@ -1416,7 +1419,7 @@ const MeetingDetails = ({ user }) => {
|
|||
<div key={item.originalIndex} className={`transcript-edit-item ${item.position}`}>
|
||||
<div className="transcript-edit-header">
|
||||
<span className="speaker-name">{item.speaker_tag}</span>
|
||||
<span className="timestamp">{formatTime(item.start_time_ms / 1000)}</span>
|
||||
<span className="timestamp">{tools.formatDuration(item.start_time_ms / 1000)}</span>
|
||||
{item.position === 'current' && (
|
||||
<span className="current-indicator">当前编辑</span>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,348 @@
|
|||
import React from 'react';
|
||||
import html2canvas from 'html2canvas';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
|
||||
/**
|
||||
* 通用导出服务
|
||||
* 用于将内容导出为图片
|
||||
*/
|
||||
|
||||
/**
|
||||
* Markdown内容样式模板
|
||||
*/
|
||||
const markdownStyles = `
|
||||
#content h1 { color: #1e293b; font-size: 22px; margin: 25px 0 15px; font-weight: 600; }
|
||||
#content h2 { color: #374151; font-size: 20px; margin: 20px 0 12px; font-weight: 600; }
|
||||
#content h3 { color: #475569; font-size: 18px; margin: 18px 0 10px; font-weight: 600; }
|
||||
#content p { margin: 12px 0; color: #475569; }
|
||||
#content ul, #content ol { margin: 12px 0; padding-left: 25px; }
|
||||
#content li { margin: 8px 0; color: #475569; }
|
||||
#content strong { color: #1e293b; font-weight: 600; }
|
||||
#content blockquote {
|
||||
border-left: 4px solid #3b82f6;
|
||||
background: #f8fafc;
|
||||
margin: 15px 0;
|
||||
padding: 15px 20px;
|
||||
font-style: italic;
|
||||
}
|
||||
#content code {
|
||||
background: #f1f5f9;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
}
|
||||
#content pre {
|
||||
background: #f8fafc;
|
||||
padding: 20px;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#content table {
|
||||
border-collapse: collapse;
|
||||
width: 100%;
|
||||
margin: 15px 0;
|
||||
border: 1px solid #e2e8f0;
|
||||
}
|
||||
#content th, #content td {
|
||||
border: 1px solid #e2e8f0;
|
||||
padding: 12px;
|
||||
text-align: left;
|
||||
}
|
||||
#content th {
|
||||
background: #f8fafc;
|
||||
font-weight: 600;
|
||||
color: #374151;
|
||||
}
|
||||
`;
|
||||
|
||||
/**
|
||||
* 渲染Markdown为HTML
|
||||
* @param {string} markdown - Markdown内容
|
||||
* @returns {Promise<string>} 渲染后的HTML
|
||||
*/
|
||||
async function renderMarkdownToHTML(markdown) {
|
||||
const tempDiv = document.createElement('div');
|
||||
tempDiv.style.display = 'none';
|
||||
document.body.appendChild(tempDiv);
|
||||
|
||||
const ReactMarkdownModule = (await import('react-markdown')).default;
|
||||
const { createRoot } = await import('react-dom/client');
|
||||
|
||||
const root = createRoot(tempDiv);
|
||||
|
||||
await new Promise((resolve) => {
|
||||
root.render(
|
||||
React.createElement(ReactMarkdownModule, {
|
||||
remarkPlugins: [remarkGfm],
|
||||
rehypePlugins: [rehypeRaw, rehypeSanitize],
|
||||
children: markdown
|
||||
})
|
||||
);
|
||||
setTimeout(resolve, 200);
|
||||
});
|
||||
|
||||
const renderedHTML = tempDiv.innerHTML;
|
||||
|
||||
// 清理
|
||||
root.unmount();
|
||||
document.body.removeChild(tempDiv);
|
||||
|
||||
return renderedHTML;
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出会议总结为图片
|
||||
* @param {Object} params
|
||||
* @param {string} params.title - 标题
|
||||
* @param {string} params.summary - Markdown格式的总结内容
|
||||
* @param {Object} params.metadata - 元数据(会议时间、创建人等)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function exportMeetingSummaryToImage({ title, summary, metadata }) {
|
||||
try {
|
||||
// 创建导出容器
|
||||
const exportContainer = document.createElement('div');
|
||||
exportContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
width: 800px;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const metadataHTML = metadata ? `
|
||||
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📋 会议信息
|
||||
</h2>
|
||||
${metadata.meetingTime ? `<p style="margin: 12px 0; font-size: 16px;"><strong>会议时间:</strong>${metadata.meetingTime}</p>` : ''}
|
||||
${metadata.creator ? `<p style="margin: 12px 0; font-size: 16px;"><strong>创建人:</strong>${metadata.creator}</p>` : ''}
|
||||
${metadata.attendeeCount ? `<p style="margin: 12px 0; font-size: 16px;"><strong>参会人数:</strong>${metadata.attendeeCount}人</p>` : ''}
|
||||
${metadata.attendees ? `<p style="margin: 12px 0; font-size: 16px;"><strong>参会人员:</strong>${metadata.attendees}</p>` : ''}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
exportContainer.innerHTML = `
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
|
||||
${title}
|
||||
</h1>
|
||||
|
||||
${metadataHTML}
|
||||
|
||||
<div>
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📝 会议摘要
|
||||
</h2>
|
||||
<div id="content" style="font-size: 15px; line-height: 1.8;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
|
||||
导出时间:${new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(exportContainer);
|
||||
|
||||
// 渲染Markdown内容
|
||||
const renderedHTML = await renderMarkdownToHTML(summary);
|
||||
const contentDiv = exportContainer.querySelector('#content');
|
||||
contentDiv.innerHTML = renderedHTML;
|
||||
|
||||
// 添加样式
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = markdownStyles;
|
||||
exportContainer.insertBefore(styleElement, exportContainer.firstChild);
|
||||
|
||||
// 使用html2canvas生成图片
|
||||
const canvas = await html2canvas(exportContainer, {
|
||||
width: 880,
|
||||
height: exportContainer.scrollHeight + 80,
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${title}_总结_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 清理DOM
|
||||
document.body.removeChild(exportContainer);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出知识库内容为图片
|
||||
* @param {Object} params
|
||||
* @param {string} params.title - 标题
|
||||
* @param {string} params.content - Markdown格式的内容
|
||||
* @param {Object} params.metadata - 元数据(创建时间等)
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function exportKnowledgeBaseToImage({ title, content, metadata }) {
|
||||
try {
|
||||
// 创建导出容器
|
||||
const exportContainer = document.createElement('div');
|
||||
exportContainer.style.cssText = `
|
||||
position: fixed;
|
||||
top: -10000px;
|
||||
left: -10000px;
|
||||
width: 800px;
|
||||
background: white;
|
||||
padding: 40px;
|
||||
font-family: "PingFang SC", "Microsoft YaHei", "Hiragino Sans GB", sans-serif;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
`;
|
||||
|
||||
const metadataHTML = metadata ? `
|
||||
<div style="background: #f9fafb; padding: 25px; margin-bottom: 35px; border-radius: 8px; border: 1px solid #e5e7eb;">
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📚 知识库信息
|
||||
</h2>
|
||||
${metadata.creator ? `<p style="margin: 12px 0; font-size: 16px;"><strong>创建人:</strong>${metadata.creator}</p>` : ''}
|
||||
${metadata.createdTime ? `<p style="margin: 12px 0; font-size: 16px;"><strong>创建时间:</strong>${metadata.createdTime}</p>` : ''}
|
||||
${metadata.tags ? `<p style="margin: 12px 0; font-size: 16px;"><strong>标签:</strong>${metadata.tags}</p>` : ''}
|
||||
</div>
|
||||
` : '';
|
||||
|
||||
exportContainer.innerHTML = `
|
||||
<div style="margin-bottom: 40px;">
|
||||
<h1 style="color: #2563eb; font-size: 28px; margin-bottom: 30px; border-bottom: 2px solid #e5e7eb; padding-bottom: 15px; text-align: center;">
|
||||
${title}
|
||||
</h1>
|
||||
|
||||
${metadataHTML}
|
||||
|
||||
<div>
|
||||
<h2 style="color: #374151; font-size: 20px; margin: 0 0 20px; border-bottom: 1px solid #d1d5db; padding-bottom: 10px;">
|
||||
📝 内容
|
||||
</h2>
|
||||
<div id="content" style="font-size: 15px; line-height: 1.8;">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div style="margin-top: 40px; padding-top: 20px; border-top: 1px solid #e5e7eb; text-align: center; color: #6b7280; font-size: 14px;">
|
||||
导出时间:${new Date().toLocaleString('zh-CN')}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
|
||||
document.body.appendChild(exportContainer);
|
||||
|
||||
// 渲染Markdown内容
|
||||
const renderedHTML = await renderMarkdownToHTML(content);
|
||||
const contentDiv = exportContainer.querySelector('#content');
|
||||
contentDiv.innerHTML = renderedHTML;
|
||||
|
||||
// 添加样式
|
||||
const styleElement = document.createElement('style');
|
||||
styleElement.textContent = markdownStyles;
|
||||
exportContainer.insertBefore(styleElement, exportContainer.firstChild);
|
||||
|
||||
// 使用html2canvas生成图片
|
||||
const canvas = await html2canvas(exportContainer, {
|
||||
width: 880,
|
||||
height: exportContainer.scrollHeight + 80,
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${title}_内容_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
// 清理DOM
|
||||
document.body.removeChild(exportContainer);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 导出思维导图为图片
|
||||
* @param {Object} params
|
||||
* @param {string} params.title - 标题
|
||||
* @param {string} params.selector - SVG选择器,默认为'.markmap-render-area svg'
|
||||
* @returns {Promise<void>}
|
||||
*/
|
||||
export async function exportMindMapToImage({ title, selector = '.markmap-render-area svg' }) {
|
||||
try {
|
||||
// 查找SVG元素
|
||||
const svgElement = document.querySelector(selector);
|
||||
if (!svgElement) {
|
||||
throw new Error('未找到思维导图,请先切换到脑图标签页。');
|
||||
}
|
||||
|
||||
// 使用html2canvas导出SVG
|
||||
const mindmapContainer = svgElement.parentElement;
|
||||
|
||||
const canvas = await html2canvas(mindmapContainer, {
|
||||
scale: 2,
|
||||
useCORS: true,
|
||||
allowTaint: true,
|
||||
backgroundColor: '#ffffff',
|
||||
scrollX: 0,
|
||||
scrollY: 0
|
||||
});
|
||||
|
||||
// 创建下载链接
|
||||
const link = document.createElement('a');
|
||||
link.download = `${title}_思维导图_${new Date().toISOString().slice(0, 10)}.png`;
|
||||
link.href = canvas.toDataURL('image/png', 1.0);
|
||||
|
||||
// 触发下载
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('思维导图导出失败:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
export default {
|
||||
exportMeetingSummaryToImage,
|
||||
exportKnowledgeBaseToImage,
|
||||
exportMindMapToImage
|
||||
};
|
||||
|
|
@ -0,0 +1,416 @@
|
|||
/**
|
||||
* 工具函数服务
|
||||
* 统一管理各页面共用的工具函数,提高代码复用性
|
||||
*/
|
||||
|
||||
/**
|
||||
* 日期时间格式化函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化日期时间(完整格式)
|
||||
* @param {string} dateTimeString - 日期时间字符串
|
||||
* @returns {string} 格式化后的日期时间,如 "2024年1月15日 14:30"
|
||||
*/
|
||||
export function 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'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期时间(简短格式,只显示时间)
|
||||
* @param {string} dateTimeString - 日期时间字符串
|
||||
* @returns {string} 格式化后的时间,如 "14:30"
|
||||
*/
|
||||
export function formatTime(dateTimeString) {
|
||||
if (!dateTimeString) return '';
|
||||
const date = new Date(dateTimeString);
|
||||
return date.toLocaleTimeString('zh-CN', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(完整日期带时间)
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {string} 格式化后的日期时间,如 "2024/01/15 14:30"
|
||||
*/
|
||||
export function formatDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleString('zh-CN', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化短日期(月/日)
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {string} 格式化后的日期,如 "1月15日"
|
||||
*/
|
||||
export function formatShortDate(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'numeric',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化日期(月/日,长格式)
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {string} 格式化后的日期,如 "1月15日"
|
||||
*/
|
||||
export function formatDateLong(dateString) {
|
||||
if (!dateString) return '';
|
||||
const date = new Date(dateString);
|
||||
return date.toLocaleDateString('zh-CN', {
|
||||
month: 'long',
|
||||
day: 'numeric'
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化会议日期(与formatDate相同,保留用于向后兼容)
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {string} 格式化后的日期时间
|
||||
*/
|
||||
export function formatMeetingDate(dateString) {
|
||||
return formatDate(dateString);
|
||||
}
|
||||
|
||||
/**
|
||||
* 时间相关工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 格式化音频时长(秒转时:分:秒)
|
||||
* @param {number} seconds - 秒数
|
||||
* @returns {string} 格式化后的时间,如 "1:23:45" 或 "23:45"
|
||||
*/
|
||||
export function formatDuration(seconds) {
|
||||
if (!seconds || isNaN(seconds)) return '0:00';
|
||||
|
||||
const hours = Math.floor(seconds / 3600);
|
||||
const mins = Math.floor((seconds % 3600) / 60);
|
||||
const secs = Math.floor(seconds % 60);
|
||||
|
||||
if (hours > 0) {
|
||||
// 超过60分钟显示小时:分钟:秒格式
|
||||
return `${hours}:${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
|
||||
} else {
|
||||
// 60分钟内显示分钟:秒格式
|
||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为今天
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {boolean} 是否为今天
|
||||
*/
|
||||
export function isToday(dateString) {
|
||||
if (!dateString) return false;
|
||||
const date = new Date(dateString);
|
||||
const today = new Date();
|
||||
return date.getDate() === today.getDate() &&
|
||||
date.getMonth() === today.getMonth() &&
|
||||
date.getFullYear() === today.getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 判断是否为昨天
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {boolean} 是否为昨天
|
||||
*/
|
||||
export function isYesterday(dateString) {
|
||||
if (!dateString) return false;
|
||||
const date = new Date(dateString);
|
||||
const yesterday = new Date();
|
||||
yesterday.setDate(yesterday.getDate() - 1);
|
||||
return date.getDate() === yesterday.getDate() &&
|
||||
date.getMonth() === yesterday.getMonth() &&
|
||||
date.getFullYear() === yesterday.getFullYear();
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取相对时间描述
|
||||
* @param {string} dateString - 日期字符串
|
||||
* @returns {string} 相对时间描述,如 "今天"、"昨天"、"3天前"
|
||||
*/
|
||||
export function getRelativeTimeLabel(dateString) {
|
||||
if (!dateString) return '';
|
||||
|
||||
if (isToday(dateString)) return '今天';
|
||||
if (isYesterday(dateString)) return '昨天';
|
||||
|
||||
const date = new Date(dateString);
|
||||
const now = new Date();
|
||||
const diffTime = Math.abs(now - date);
|
||||
const diffDays = Math.ceil(diffTime / (1000 * 60 * 60 * 24));
|
||||
|
||||
if (diffDays < 7) {
|
||||
return `${diffDays}天前`;
|
||||
} else if (diffDays < 30) {
|
||||
const weeks = Math.floor(diffDays / 7);
|
||||
return `${weeks}周前`;
|
||||
} else if (diffDays < 365) {
|
||||
const months = Math.floor(diffDays / 30);
|
||||
return `${months}个月前`;
|
||||
} else {
|
||||
const years = Math.floor(diffDays / 365);
|
||||
return `${years}年前`;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 文本处理工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 截断文本
|
||||
* @param {string} text - 原始文本
|
||||
* @param {number} maxLength - 最大长度
|
||||
* @param {string} suffix - 后缀,默认为 "..."
|
||||
* @returns {string} 截断后的文本
|
||||
*/
|
||||
export function truncateText(text, maxLength = 100, suffix = '...') {
|
||||
if (!text) return '';
|
||||
if (text.length <= maxLength) return text;
|
||||
return text.substring(0, maxLength) + suffix;
|
||||
}
|
||||
|
||||
/**
|
||||
* 截断摘要(支持多行)
|
||||
* @param {string} summary - 摘要文本
|
||||
* @param {number} maxLines - 最大行数
|
||||
* @param {number} maxLength - 最大字符数
|
||||
* @returns {string} 截断后的摘要
|
||||
*/
|
||||
export function truncateSummary(summary, maxLines = 3, maxLength = 100) {
|
||||
if (!summary) return '暂无摘要';
|
||||
|
||||
// Split by lines and check line count
|
||||
const lines = summary.split('\n');
|
||||
const hasMoreLines = lines.length > maxLines;
|
||||
|
||||
// Also check character length
|
||||
const hasMoreChars = summary.length > maxLength;
|
||||
|
||||
if (hasMoreLines || hasMoreChars) {
|
||||
// Take first few lines or characters, whichever is shorter
|
||||
const truncatedByLines = lines.slice(0, maxLines).join('\n');
|
||||
const truncatedByChars = summary.substring(0, maxLength);
|
||||
|
||||
const result = truncatedByLines.length <= truncatedByChars.length
|
||||
? truncatedByLines
|
||||
: truncatedByChars;
|
||||
|
||||
return result + '...';
|
||||
}
|
||||
|
||||
return summary;
|
||||
}
|
||||
|
||||
/**
|
||||
* 移除Markdown标记
|
||||
* @param {string} markdown - Markdown文本
|
||||
* @returns {string} 纯文本
|
||||
*/
|
||||
export function stripMarkdown(markdown) {
|
||||
if (!markdown) return '';
|
||||
|
||||
return markdown
|
||||
// 移除标题标记
|
||||
.replace(/#{1,6}\s+/g, '')
|
||||
// 移除粗体和斜体
|
||||
.replace(/(\*\*|__)(.*?)\1/g, '$2')
|
||||
.replace(/(\*|_)(.*?)\1/g, '$2')
|
||||
// 移除链接
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
// 移除代码块
|
||||
.replace(/```[\s\S]*?```/g, '')
|
||||
.replace(/`([^`]+)`/g, '$1')
|
||||
// 移除列表标记
|
||||
.replace(/^\s*[-*+]\s+/gm, '')
|
||||
.replace(/^\s*\d+\.\s+/gm, '')
|
||||
// 移除引用
|
||||
.replace(/^\s*>\s+/gm, '')
|
||||
// 移除图片
|
||||
.replace(/!\[([^\]]*)\]\([^)]+\)/g, '')
|
||||
.trim();
|
||||
}
|
||||
|
||||
/**
|
||||
* 数据分组工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 按日期分组数据
|
||||
* @param {Array} items - 数据数组
|
||||
* @param {string} dateField - 日期字段名,默认为 'created_at'
|
||||
* @returns {Array} 分组后的数据,格式为 [{date, label, items}]
|
||||
*/
|
||||
export function groupByDate(items, dateField = 'created_at') {
|
||||
if (!items || items.length === 0) return [];
|
||||
|
||||
const groups = {};
|
||||
|
||||
items.forEach(item => {
|
||||
const dateStr = item[dateField];
|
||||
if (!dateStr) return;
|
||||
|
||||
const date = new Date(dateStr);
|
||||
const dateKey = date.toDateString();
|
||||
|
||||
if (!groups[dateKey]) {
|
||||
groups[dateKey] = {
|
||||
date: dateKey,
|
||||
label: getRelativeTimeLabel(dateStr),
|
||||
items: []
|
||||
};
|
||||
}
|
||||
|
||||
groups[dateKey].items.push(item);
|
||||
});
|
||||
|
||||
// 转换为数组并按日期排序(最新的在前)
|
||||
return Object.values(groups).sort((a, b) => {
|
||||
return new Date(b.date) - new Date(a.date);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 数组和对象工具函数
|
||||
*/
|
||||
|
||||
/**
|
||||
* 深拷贝对象
|
||||
* @param {any} obj - 要拷贝的对象
|
||||
* @returns {any} 拷贝后的对象
|
||||
*/
|
||||
export function deepClone(obj) {
|
||||
if (obj === null || typeof obj !== 'object') return obj;
|
||||
|
||||
if (obj instanceof Date) {
|
||||
return new Date(obj.getTime());
|
||||
}
|
||||
|
||||
if (obj instanceof Array) {
|
||||
return obj.map(item => deepClone(item));
|
||||
}
|
||||
|
||||
if (obj instanceof Object) {
|
||||
const clonedObj = {};
|
||||
for (const key in obj) {
|
||||
if (obj.hasOwnProperty(key)) {
|
||||
clonedObj[key] = deepClone(obj[key]);
|
||||
}
|
||||
}
|
||||
return clonedObj;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 防抖函数
|
||||
* @param {Function} func - 要防抖的函数
|
||||
* @param {number} delay - 延迟时间(毫秒)
|
||||
* @returns {Function} 防抖后的函数
|
||||
*/
|
||||
export function debounce(func, delay = 300) {
|
||||
let timeoutId;
|
||||
return function (...args) {
|
||||
clearTimeout(timeoutId);
|
||||
timeoutId = setTimeout(() => func.apply(this, args), delay);
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 节流函数
|
||||
* @param {Function} func - 要节流的函数
|
||||
* @param {number} limit - 时间限制(毫秒)
|
||||
* @returns {Function} 节流后的函数
|
||||
*/
|
||||
export function throttle(func, limit = 300) {
|
||||
let inThrottle;
|
||||
return function (...args) {
|
||||
if (!inThrottle) {
|
||||
func.apply(this, args);
|
||||
inThrottle = true;
|
||||
setTimeout(() => inThrottle = false, limit);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 文件大小格式化
|
||||
* @param {number} bytes - 字节数
|
||||
* @param {number} decimals - 小数位数
|
||||
* @returns {string} 格式化后的大小,如 "1.5 MB"
|
||||
*/
|
||||
export function formatFileSize(bytes, decimals = 2) {
|
||||
if (bytes === 0) return '0 Bytes';
|
||||
|
||||
const k = 1024;
|
||||
const dm = decimals < 0 ? 0 : decimals;
|
||||
const sizes = ['Bytes', 'KB', 'MB', 'GB', 'TB'];
|
||||
|
||||
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||
|
||||
return parseFloat((bytes / Math.pow(k, i)).toFixed(dm)) + ' ' + sizes[i];
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一ID
|
||||
* @returns {string} 唯一ID
|
||||
*/
|
||||
export function generateUniqueId() {
|
||||
return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 默认导出所有函数
|
||||
*/
|
||||
export default {
|
||||
// 日期时间格式化
|
||||
formatDateTime,
|
||||
formatTime,
|
||||
formatDate,
|
||||
formatShortDate,
|
||||
formatDateLong,
|
||||
formatMeetingDate,
|
||||
formatDuration,
|
||||
|
||||
// 时间判断
|
||||
isToday,
|
||||
isYesterday,
|
||||
getRelativeTimeLabel,
|
||||
|
||||
// 文本处理
|
||||
truncateText,
|
||||
truncateSummary,
|
||||
stripMarkdown,
|
||||
|
||||
// 数据分组
|
||||
groupByDate,
|
||||
|
||||
// 工具函数
|
||||
deepClone,
|
||||
debounce,
|
||||
throttle,
|
||||
formatFileSize,
|
||||
generateUniqueId
|
||||
};
|
||||
Loading…
Reference in New Issue