将重传和重转录的入口移到了会议详情

main
mula.liu 2025-11-17 10:31:26 +08:00
parent 6c549eca15
commit 0391dd9cb3
9 changed files with 1060 additions and 645 deletions

BIN
.DS_Store vendored

Binary file not shown.

BIN
dist.zip

Binary file not shown.

View File

@ -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) && (

View File

@ -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">

View File

@ -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;
}
// 使html2canvasSVG
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>

View File

@ -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 {

View File

@ -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;
}
// 使html2canvasSVG
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>
)}

View File

@ -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
};

416
src/utils/tools.js 100644
View File

@ -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
};