diff --git a/src/components/ConfirmDialog.css b/src/components/ConfirmDialog.css new file mode 100644 index 0000000..585da1b --- /dev/null +++ b/src/components/ConfirmDialog.css @@ -0,0 +1,188 @@ +.confirm-dialog-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 2000; + backdrop-filter: blur(4px); + animation: fadeIn 0.2s ease-out; +} + +@keyframes fadeIn { + from { + opacity: 0; + } + to { + opacity: 1; + } +} + +.confirm-dialog-content { + background: white; + border-radius: 16px; + width: 90%; + max-width: 420px; + padding: 32px; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: slideUp 0.3s ease-out; + display: flex; + flex-direction: column; + align-items: center; + text-align: center; +} + +@keyframes slideUp { + from { + opacity: 0; + transform: translateY(20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.confirm-dialog-icon { + width: 80px; + height: 80px; + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + margin-bottom: 24px; +} + +.confirm-dialog-icon.warning { + background: #fff7e6; + color: #fa8c16; +} + +.confirm-dialog-icon.danger { + background: #fff2f0; + color: #ff4d4f; +} + +.confirm-dialog-icon.info { + background: #e6f7ff; + color: #1890ff; +} + +.confirm-dialog-body { + margin-bottom: 28px; +} + +.confirm-dialog-title { + margin: 0 0 12px 0; + font-size: 20px; + font-weight: 600; + color: #262626; +} + +.confirm-dialog-message { + margin: 0; + font-size: 15px; + color: #595959; + line-height: 1.6; +} + +.confirm-dialog-actions { + display: flex; + gap: 12px; + width: 100%; +} + +.confirm-dialog-btn { + flex: 1; + padding: 12px 24px; + border: none; + border-radius: 8px; + font-size: 15px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.confirm-dialog-btn.cancel { + background: white; + color: #595959; + border: 1px solid #d9d9d9; +} + +.confirm-dialog-btn.cancel:hover { + color: #262626; + border-color: #40a9ff; + background: #fafafa; +} + +.confirm-dialog-btn.confirm.warning { + background: #fa8c16; + color: white; +} + +.confirm-dialog-btn.confirm.warning:hover { + background: #ff9c2e; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(250, 140, 22, 0.4); +} + +.confirm-dialog-btn.confirm.danger { + background: #ff4d4f; + color: white; +} + +.confirm-dialog-btn.confirm.danger:hover { + background: #ff7875; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(255, 77, 79, 0.4); +} + +.confirm-dialog-btn.confirm.info { + background: #1890ff; + color: white; +} + +.confirm-dialog-btn.confirm.info:hover { + background: #40a9ff; + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(24, 144, 255, 0.4); +} + +/* 响应式 */ +@media (max-width: 640px) { + .confirm-dialog-content { + width: 95%; + padding: 24px; + } + + .confirm-dialog-icon { + width: 64px; + height: 64px; + margin-bottom: 20px; + } + + .confirm-dialog-icon svg { + width: 36px; + height: 36px; + } + + .confirm-dialog-title { + font-size: 18px; + } + + .confirm-dialog-message { + font-size: 14px; + } + + .confirm-dialog-actions { + flex-direction: column; + } + + .confirm-dialog-btn { + width: 100%; + } +} diff --git a/src/components/ConfirmDialog.jsx b/src/components/ConfirmDialog.jsx new file mode 100644 index 0000000..22f0f92 --- /dev/null +++ b/src/components/ConfirmDialog.jsx @@ -0,0 +1,53 @@ +import React from 'react'; +import { AlertTriangle } from 'lucide-react'; +import './ConfirmDialog.css'; + +const ConfirmDialog = ({ + isOpen, + onClose, + onConfirm, + title = '确认操作', + message, + confirmText = '确认', + cancelText = '取消', + type = 'warning' // 'warning', 'danger', 'info' +}) => { + if (!isOpen) return null; + + const handleConfirm = () => { + onConfirm(); + onClose(); + }; + + return ( +
+
e.stopPropagation()}> +
+ +
+ +
+

{title}

+

{message}

+
+ +
+ + +
+
+
+ ); +}; + +export default ConfirmDialog; diff --git a/src/components/MeetingSummary.jsx b/src/components/MeetingSummary.jsx index c0480c0..82b5517 100644 --- a/src/components/MeetingSummary.jsx +++ b/src/components/MeetingSummary.jsx @@ -12,7 +12,8 @@ const MeetingSummary = ({ summaryHistory, isCreator, onOpenSummaryModal, - formatDateTime + formatDateTime, + showToast }) => { const exportRef = useRef(null); @@ -24,12 +25,12 @@ const MeetingSummary = ({ (summaryHistory.length > 0 ? summaryHistory[0].content : null); if (!summaryContent) { - alert('暂无会议总结内容,请先生成AI总结。'); + showToast?.('暂无会议总结内容,请先生成AI总结。', 'warning'); return; } if (!exportRef.current) { - alert('内容尚未渲染完成,请稍后重试。'); + showToast?.('内容尚未渲染完成,请稍后重试。', 'warning'); return; } @@ -195,7 +196,7 @@ const MeetingSummary = ({ } catch (error) { console.error('图片导出失败:', error); - alert('图片导出失败,请重试。'); + showToast?.('图片导出失败,请重试。', 'error'); } }; diff --git a/src/components/MeetingTimeline.css b/src/components/MeetingTimeline.css index 012cba9..25e2afc 100644 --- a/src/components/MeetingTimeline.css +++ b/src/components/MeetingTimeline.css @@ -83,6 +83,7 @@ /* Meeting Cards */ .meeting-card-wrapper { position: relative; + padding: 0 5px; } .meeting-card { diff --git a/src/components/MeetingTimeline.jsx b/src/components/MeetingTimeline.jsx index e9e6e5b..a991a3a 100644 --- a/src/components/MeetingTimeline.jsx +++ b/src/components/MeetingTimeline.jsx @@ -6,10 +6,11 @@ import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import TagDisplay from './TagDisplay'; +import ConfirmDialog from './ConfirmDialog'; import './MeetingTimeline.css'; const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { - const [showDeleteConfirm, setShowDeleteConfirm] = useState(null); + const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null); const [showDropdown, setShowDropdown] = useState(null); const navigate = useNavigate(); // Close dropdown when clicking outside @@ -72,17 +73,20 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { setShowDropdown(null); }; - const handleDeleteClick = (meetingId, e) => { + const handleDeleteClick = (meeting, e) => { e.preventDefault(); - setShowDeleteConfirm(meetingId); + setDeleteConfirmInfo({ + id: meeting.meeting_id, + title: meeting.title + }); setShowDropdown(null); }; - const handleConfirmDelete = async (meetingId) => { - if (onDeleteMeeting) { - await onDeleteMeeting(meetingId); + const handleConfirmDelete = async () => { + if (onDeleteMeeting && deleteConfirmInfo) { + await onDeleteMeeting(deleteConfirmInfo.id); } - setShowDeleteConfirm(null); + setDeleteConfirmInfo(null); }; const toggleDropdown = (meetingId, e) => { @@ -153,9 +157,9 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => { 编辑 - - - - - - )} ); })} ))} + + {/* 删除会议确认对话框 */} + setDeleteConfirmInfo(null)} + onConfirm={handleConfirmDelete} + title="删除会议" + message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`} + confirmText="删除" + cancelText="取消" + type="danger" + /> ); }; diff --git a/src/components/PageLoading.css b/src/components/PageLoading.css new file mode 100644 index 0000000..976373e --- /dev/null +++ b/src/components/PageLoading.css @@ -0,0 +1,36 @@ +/* 页面级加载组件样式 - 全屏加载 */ +.page-loading-container { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + background-color: #ffffff; + z-index: 9999; + color: #64748b; +} + +.page-loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #e2e8f0; + border-top: 3px solid #667eea; + border-radius: 50%; + animation: page-loading-spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes page-loading-spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.page-loading-message { + margin: 0; + font-size: 0.95rem; + color: #64748b; +} diff --git a/src/components/PageLoading.jsx b/src/components/PageLoading.jsx new file mode 100644 index 0000000..8f573d0 --- /dev/null +++ b/src/components/PageLoading.jsx @@ -0,0 +1,13 @@ +import React from 'react'; +import './PageLoading.css'; + +const PageLoading = ({ message = '加载中...' }) => { + return ( +
+
+

{message}

+
+ ); +}; + +export default PageLoading; diff --git a/src/components/Toast.css b/src/components/Toast.css new file mode 100644 index 0000000..5843a6f --- /dev/null +++ b/src/components/Toast.css @@ -0,0 +1,105 @@ +.toast { + position: fixed; + top: 24px; + right: 24px; + min-width: 320px; + max-width: 480px; + padding: 16px 20px; + border-radius: 8px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + display: flex; + align-items: center; + gap: 12px; + z-index: 3000; + animation: slideInRight 0.3s ease-out; + background: white; +} + +@keyframes slideInRight { + from { + opacity: 0; + transform: translateX(100%); + } + to { + opacity: 1; + transform: translateX(0); + } +} + +.toast-icon { + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; +} + +.toast-message { + flex: 1; + font-size: 14px; + line-height: 1.5; + color: #262626; +} + +.toast-close { + background: none; + border: none; + color: #8c8c8c; + font-size: 20px; + cursor: pointer; + padding: 0; + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + flex-shrink: 0; + transition: color 0.2s; +} + +.toast-close:hover { + color: #262626; +} + +/* 不同类型的样式 */ +.toast-success { + border-left: 4px solid #52c41a; +} + +.toast-success .toast-icon { + color: #52c41a; +} + +.toast-error { + border-left: 4px solid #ff4d4f; +} + +.toast-error .toast-icon { + color: #ff4d4f; +} + +.toast-warning { + border-left: 4px solid #fa8c16; +} + +.toast-warning .toast-icon { + color: #fa8c16; +} + +.toast-info { + border-left: 4px solid #1890ff; +} + +.toast-info .toast-icon { + color: #1890ff; +} + +/* 响应式 */ +@media (max-width: 640px) { + .toast { + top: 12px; + right: 12px; + left: 12px; + min-width: auto; + max-width: none; + } +} diff --git a/src/components/Toast.jsx b/src/components/Toast.jsx new file mode 100644 index 0000000..d8e0fd3 --- /dev/null +++ b/src/components/Toast.jsx @@ -0,0 +1,38 @@ +import React, { useEffect } from 'react'; +import { CheckCircle, XCircle, AlertCircle, Info } from 'lucide-react'; +import './Toast.css'; + +const Toast = ({ message, type = 'info', duration = 3000, onClose }) => { + useEffect(() => { + if (duration > 0) { + const timer = setTimeout(() => { + onClose(); + }, duration); + return () => clearTimeout(timer); + } + }, [duration, onClose]); + + const getIcon = () => { + switch (type) { + case 'success': + return ; + case 'error': + return ; + case 'warning': + return ; + case 'info': + default: + return ; + } + }; + + return ( +
+
{getIcon()}
+
{message}
+ +
+ ); +}; + +export default Toast; diff --git a/src/components/VoiceprintCollectionModal.css b/src/components/VoiceprintCollectionModal.css new file mode 100644 index 0000000..85ccd0e --- /dev/null +++ b/src/components/VoiceprintCollectionModal.css @@ -0,0 +1,393 @@ +.voiceprint-modal-overlay { + position: fixed; + top: 0; + left: 0; + right: 0; + bottom: 0; + background: rgba(0, 0, 0, 0.6); + display: flex; + align-items: center; + justify-content: center; + z-index: 1000; + backdrop-filter: blur(4px); +} + +.voiceprint-modal-content { + background: white; + border-radius: 16px; + width: 90%; + max-width: 600px; + max-height: 90vh; + overflow-y: auto; + box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3); + animation: modalSlideIn 0.3s ease-out; +} + +@keyframes modalSlideIn { + from { + opacity: 0; + transform: translateY(-20px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.voiceprint-modal-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 24px 28px; + border-bottom: 1px solid #e8e8e8; +} + +.voiceprint-modal-header h2 { + margin: 0; + font-size: 20px; + font-weight: 600; + color: #262626; +} + +.close-btn { + background: none; + border: none; + cursor: pointer; + color: #8c8c8c; + padding: 4px; + display: flex; + align-items: center; + justify-content: center; + border-radius: 4px; + transition: all 0.2s; +} + +.close-btn:hover { + background: #f5f5f5; + color: #262626; +} + +.voiceprint-modal-body { + padding: 28px; +} + +/* 朗读文本区域 */ +.template-text-container { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + border-radius: 12px; + padding: 24px; + margin-bottom: 24px; + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.2); +} + +.template-text { + color: white; + font-size: 16px; + line-height: 1.8; + margin: 0; + text-align: justify; +} + +/* 提示信息 */ +.recording-hint { + background: #f6f9fc; + border-left: 4px solid #1890ff; + border-radius: 8px; + padding: 16px 20px; + margin-bottom: 32px; +} + +.recording-hint p { + margin: 0; + font-size: 14px; + color: #595959; + line-height: 1.6; +} + +.hint-duration { + margin-top: 8px !important; + font-weight: 600; + color: #1890ff; +} + +/* 录制控制区 */ +.recording-control { + display: flex; + flex-direction: column; + align-items: center; + padding: 32px 0; +} + +.record-btn { + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; + padding: 24px 48px; + border: none; + border-radius: 50px; + font-size: 16px; + font-weight: 600; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); +} + +.record-btn.start { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.record-btn.start:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4); +} + +/* 圆形录制按钮 */ +.record-btn-circle { + width: 100px; + height: 100px; + border-radius: 50%; + border: none; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; + cursor: pointer; + transition: all 0.3s; + box-shadow: 0 8px 24px rgba(102, 126, 234, 0.3); + display: flex; + align-items: center; + justify-content: center; + position: relative; +} + +.record-btn-circle::before { + content: ''; + position: absolute; + inset: -4px; + border-radius: 50%; + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + opacity: 0; + transition: opacity 0.3s; + z-index: -1; + filter: blur(8px); +} + +.record-btn-circle:hover:not(:disabled)::before { + opacity: 0.6; +} + +.record-btn-circle:hover:not(:disabled) { + transform: scale(1.05); + box-shadow: 0 12px 32px rgba(102, 126, 234, 0.5); +} + +.record-btn-circle:active:not(:disabled) { + transform: scale(0.95); +} + +.record-btn.stop { + background: #ff4d4f; + color: white; + margin-top: 20px; + padding: 12px 32px; +} + +.record-btn.stop:hover { + background: #ff7875; + transform: translateY(-2px); +} + +.record-btn:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 录制中状态 */ +.recording-active { + display: flex; + flex-direction: column; + align-items: center; + gap: 24px; +} + +.progress-circle { + position: relative; + width: 120px; + height: 120px; +} + +.progress-ring { + transform: rotate(-90deg); +} + +.progress-ring-circle-bg { + fill: none; + stroke: #f0f0f0; + stroke-width: 8; +} + +.progress-ring-circle { + fill: none; + stroke: #667eea; + stroke-width: 8; + stroke-linecap: round; + transition: stroke-dashoffset 0.3s ease; +} + +.countdown-text { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); + display: flex; + flex-direction: column; + align-items: center; + gap: 8px; +} + +.recording-icon { + color: #ff4d4f; + animation: pulse 1.5s ease-in-out infinite; +} + +@keyframes pulse { + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} + +.countdown-number { + font-size: 24px; + font-weight: 700; + color: #262626; +} + +/* 录制完成状态 */ +.recording-complete { + display: flex; + flex-direction: column; + align-items: center; + gap: 20px; +} + +.complete-icon { + width: 80px; + height: 80px; + background: linear-gradient(135deg, #52c41a 0%, #73d13d 100%); + border-radius: 50%; + display: flex; + align-items: center; + justify-content: center; + color: white; + animation: scaleIn 0.4s ease-out; +} + +@keyframes scaleIn { + from { + transform: scale(0); + } + to { + transform: scale(1); + } +} + +.complete-text { + font-size: 18px; + font-weight: 600; + color: #52c41a; + margin: 0; +} + +.complete-actions { + display: flex; + gap: 16px; + margin-top: 12px; +} + +.btn-secondary, +.btn-primary { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 24px; + border: none; + border-radius: 8px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.btn-secondary { + background: white; + color: #595959; + border: 1px solid #d9d9d9; +} + +.btn-secondary:hover:not(:disabled) { + color: #262626; + border-color: #40a9ff; +} + +.btn-primary { + background: linear-gradient(135deg, #667eea 0%, #764ba2 100%); + color: white; +} + +.btn-primary:hover:not(:disabled) { + transform: translateY(-2px); + box-shadow: 0 4px 12px rgba(102, 126, 234, 0.4); +} + +.btn-secondary:disabled, +.btn-primary:disabled { + opacity: 0.6; + cursor: not-allowed; +} + +/* 错误信息 */ +.error-message { + margin-top: 20px; + padding: 12px 16px; + background: #fff2f0; + border: 1px solid #ffccc7; + border-radius: 8px; + color: #ff4d4f; + font-size: 14px; + text-align: center; +} + +/* 响应式 */ +@media (max-width: 640px) { + .voiceprint-modal-content { + width: 95%; + margin: 20px; + } + + .voiceprint-modal-header, + .voiceprint-modal-body { + padding: 20px; + } + + .template-text { + font-size: 14px; + } + + .record-btn.start { + padding: 20px 32px; + } + + .complete-actions { + flex-direction: column; + width: 100%; + } + + .btn-secondary, + .btn-primary { + width: 100%; + justify-content: center; + } +} diff --git a/src/components/VoiceprintCollectionModal.jsx b/src/components/VoiceprintCollectionModal.jsx new file mode 100644 index 0000000..ba81a25 --- /dev/null +++ b/src/components/VoiceprintCollectionModal.jsx @@ -0,0 +1,252 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Mic, MicOff, X, RefreshCw } from 'lucide-react'; +import './VoiceprintCollectionModal.css'; + +const VoiceprintCollectionModal = ({ isOpen, onClose, onSuccess, templateConfig }) => { + const [isRecording, setIsRecording] = useState(false); + const [countdown, setCountdown] = useState(0); + const [audioBlob, setAudioBlob] = useState(null); + const [error, setError] = useState(''); + const [isUploading, setIsUploading] = useState(false); + + const mediaRecorderRef = useRef(null); + const audioChunksRef = useRef([]); + const countdownIntervalRef = useRef(null); + + // 当模态框打开时,重置所有状态,不设置错误提示 + useEffect(() => { + if (isOpen) { + setError(''); + setAudioBlob(null); + setIsRecording(false); + setCountdown(0); + setIsUploading(false); + } + }, [isOpen]); + + useEffect(() => { + // 清理函数 + return () => { + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + } + if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { + mediaRecorderRef.current.stop(); + } + }; + }, []); + + const startRecording = async () => { + try { + setError(''); + + // 请求麦克风权限 + const stream = await navigator.mediaDevices.getUserMedia({ audio: true }); + + // 创建MediaRecorder实例 + const mediaRecorder = new MediaRecorder(stream, { + mimeType: 'audio/webm' + }); + + mediaRecorderRef.current = mediaRecorder; + audioChunksRef.current = []; + + mediaRecorder.ondataavailable = (event) => { + if (event.data.size > 0) { + audioChunksRef.current.push(event.data); + } + }; + + mediaRecorder.onstop = () => { + const audioBlob = new Blob(audioChunksRef.current, { type: 'audio/webm' }); + setAudioBlob(audioBlob); + + // 停止所有音频轨道 + stream.getTracks().forEach(track => track.stop()); + }; + + // 开始录制 + mediaRecorder.start(); + setIsRecording(true); + + // 开始倒计时 + const duration = templateConfig?.duration_seconds || 10; + setCountdown(duration); + + countdownIntervalRef.current = setInterval(() => { + setCountdown(prev => { + if (prev <= 1) { + stopRecording(); + return 0; + } + return prev - 1; + }); + }, 1000); + + } catch (err) { + console.error('麦克风访问错误:', err); + setError('无法访问麦克风,请检查权限设置'); + } + }; + + const stopRecording = () => { + if (mediaRecorderRef.current && mediaRecorderRef.current.state === 'recording') { + mediaRecorderRef.current.stop(); + } + + if (countdownIntervalRef.current) { + clearInterval(countdownIntervalRef.current); + countdownIntervalRef.current = null; + } + + setIsRecording(false); + setCountdown(0); + }; + + const resetRecording = () => { + setAudioBlob(null); + setCountdown(0); + setError(''); + }; + + const handleUpload = async () => { + if (!audioBlob || isUploading) return; + + try { + setIsUploading(true); + setError(''); + + // 转换webm到wav格式(这里简化处理,实际可能需要AudioContext转换) + const formData = new FormData(); + formData.append('audio_file', audioBlob, 'voiceprint.wav'); + + await onSuccess(formData); + + } catch (err) { + console.error('上传声纹错误:', err); + setError(err.message || '声纹采集失败,请重试'); + } finally { + setIsUploading(false); + } + }; + + if (!isOpen) return null; + + console.log('VoiceprintCollectionModal - templateConfig:', templateConfig); + + const duration = templateConfig?.duration_seconds || 10; + const progress = countdown > 0 ? ((duration - countdown) / duration) * 100 : 0; + const templateText = templateConfig?.template_text || '朗读文本未配置。'; + + console.log('显示的朗读文本:', templateText); + + return ( +
+
e.stopPropagation()}> +
+

声纹采集

+ +
+ +
+ {/* 朗读文本 */} +
+

+ {templateText} +

+
+ + {/* 提示信息 */} +
+

请在安静环境下,用自然语速和音量朗读上述文字

+

建议录制时长:{duration}秒

+
+ + {/* 录制控制区 */} +
+ {!isRecording && !audioBlob && ( + + )} + + {isRecording && ( +
+
+ + + + +
+ + {countdown}s +
+
+ +
+ )} + + {audioBlob && !isRecording && ( +
+
+ +
+

录制完成

+
+ + +
+
+ )} +
+ + {/* 错误信息 */} + {error && ( +
+ {error} +
+ )} +
+
+
+ ); +}; + +export default VoiceprintCollectionModal; diff --git a/src/config/api.js b/src/config/api.js index 7ca43e7..df8435e 100644 --- a/src/config/api.js +++ b/src/config/api.js @@ -64,6 +64,12 @@ const API_CONFIG = { CREATE: '/api/clients/downloads', UPDATE: (id) => `/api/clients/downloads/${id}`, DELETE: (id) => `/api/clients/downloads/${id}` + }, + VOICEPRINT: { + STATUS: (userId) => `/api/voiceprint/${userId}`, + TEMPLATE: '/api/voiceprint/template', + UPLOAD: (userId) => `/api/voiceprint/${userId}`, + DELETE: (userId) => `/api/voiceprint/${userId}` } } }; diff --git a/src/hooks/useToast.js b/src/hooks/useToast.js new file mode 100644 index 0000000..dc4475a --- /dev/null +++ b/src/hooks/useToast.js @@ -0,0 +1,29 @@ +import { useState, useCallback } from 'react'; + +export const useToast = () => { + const [toasts, setToasts] = useState([]); + + const showToast = useCallback((message, type = 'info', duration = 3000) => { + const id = Date.now(); + setToasts(prev => [...prev, { id, message, type, duration }]); + }, []); + + const removeToast = useCallback((id) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }, []); + + const success = useCallback((message, duration) => showToast(message, 'success', duration), [showToast]); + const error = useCallback((message, duration) => showToast(message, 'error', duration), [showToast]); + const warning = useCallback((message, duration) => showToast(message, 'warning', duration), [showToast]); + const info = useCallback((message, duration) => showToast(message, 'info', duration), [showToast]); + + return { + toasts, + removeToast, + showToast, + success, + error, + warning, + info + }; +}; diff --git a/src/pages/ClientDownloadPage.css b/src/pages/ClientDownloadPage.css index 670198e..d9343d3 100644 --- a/src/pages/ClientDownloadPage.css +++ b/src/pages/ClientDownloadPage.css @@ -9,13 +9,13 @@ .download-page-header { background: white; border-bottom: 1px solid #e2e8f0; - padding: 1.5rem 2rem; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); } .download-page-header .header-content { max-width: 1200px; margin: 0 auto; + padding: 1rem 2rem; } .download-page-header .logo { diff --git a/src/pages/ClientManagement.jsx b/src/pages/ClientManagement.jsx index e6643fd..c6d9e6c 100644 --- a/src/pages/ClientManagement.jsx +++ b/src/pages/ClientManagement.jsx @@ -14,6 +14,8 @@ import { } from 'lucide-react'; import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; +import ConfirmDialog from '../components/ConfirmDialog'; +import Toast from '../components/Toast'; import './ClientManagement.css'; const ClientManagement = ({ user }) => { @@ -21,11 +23,12 @@ const ClientManagement = ({ user }) => { const [loading, setLoading] = useState(true); const [showCreateModal, setShowCreateModal] = useState(false); const [showEditModal, setShowEditModal] = useState(false); - const [showDeleteModal, setShowDeleteModal] = useState(false); + const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null); const [selectedClient, setSelectedClient] = useState(null); const [filterPlatformType, setFilterPlatformType] = useState(''); const [searchQuery, setSearchQuery] = useState(''); const [expandedNotes, setExpandedNotes] = useState({}); + const [toasts, setToasts] = useState([]); const [formData, setFormData] = useState({ platform_type: 'mobile', @@ -53,6 +56,16 @@ const ClientManagement = ({ user }) => { ] }; + // Toast helper functions + const showToast = (message, type = 'info') => { + const id = Date.now(); + setToasts(prev => [...prev, { id, message, type }]); + }; + + const removeToast = (id) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }; + useEffect(() => { fetchClients(); }, []); @@ -74,7 +87,7 @@ const ClientManagement = ({ user }) => { try { // 验证必填字段 if (!formData.version_code || !formData.version || !formData.download_url) { - alert('请填写所有必填字段'); + showToast('请填写所有必填字段', 'warning'); return; } @@ -86,12 +99,12 @@ const ClientManagement = ({ user }) => { // 验证转换后的数字 if (isNaN(payload.version_code)) { - alert('版本代码必须是有效的数字'); + showToast('版本代码必须是有效的数字', 'warning'); return; } if (formData.file_size && isNaN(payload.file_size)) { - alert('文件大小必须是有效的数字'); + showToast('文件大小必须是有效的数字', 'warning'); return; } @@ -101,7 +114,7 @@ const ClientManagement = ({ user }) => { fetchClients(); } catch (error) { console.error('创建客户端失败:', error); - alert(error.response?.data?.message || '创建失败,请重试'); + showToast(error.response?.data?.message || '创建失败,请重试', 'error'); } }; @@ -109,7 +122,7 @@ const ClientManagement = ({ user }) => { try { // 验证必填字段 if (!formData.version_code || !formData.version || !formData.download_url) { - alert('请填写所有必填字段'); + showToast('请填写所有必填字段', 'warning'); return; } @@ -126,12 +139,12 @@ const ClientManagement = ({ user }) => { // 验证转换后的数字 if (isNaN(payload.version_code)) { - alert('版本代码必须是有效的数字'); + showToast('版本代码必须是有效的数字', 'warning'); return; } if (formData.file_size && isNaN(payload.file_size)) { - alert('文件大小必须是有效的数字'); + showToast('文件大小必须是有效的数字', 'warning'); return; } @@ -144,19 +157,21 @@ const ClientManagement = ({ user }) => { fetchClients(); } catch (error) { console.error('更新客户端失败:', error); - alert(error.response?.data?.message || '更新失败,请重试'); + showToast(error.response?.data?.message || '更新失败,请重试', 'error'); } }; const handleDelete = async () => { try { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(selectedClient.id))); - setShowDeleteModal(false); + await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(deleteConfirmInfo.id))); + setDeleteConfirmInfo(null); setSelectedClient(null); + showToast('删除成功', 'success'); fetchClients(); } catch (error) { console.error('删除客户端失败:', error); - alert('删除失败,请重试'); + showToast('删除失败,请重试', 'error'); + setDeleteConfirmInfo(null); } }; @@ -178,8 +193,11 @@ const ClientManagement = ({ user }) => { }; const openDeleteModal = (client) => { - setSelectedClient(client); - setShowDeleteModal(true); + setDeleteConfirmInfo({ + id: client.id, + platform_name: getPlatformLabel(client.platform_name), + version: client.version + }); }; const resetForm = () => { @@ -572,26 +590,27 @@ const ClientManagement = ({ user }) => { )} - {/* 删除确认模态框 */} - {showDeleteModal && selectedClient && ( -
setShowDeleteModal(false)}> -
e.stopPropagation()}> -

确认删除

-

- 确定要删除 {getPlatformLabel(selectedClient.platform_name)} 版本{' '} - {selectedClient.version} 吗?此操作无法撤销。 -

-
- - -
-
-
- )} + {/* 删除确认对话框 */} + setDeleteConfirmInfo(null)} + onConfirm={handleDelete} + title="删除客户端" + message={`确定要删除 ${deleteConfirmInfo?.platform_name} 版本 ${deleteConfirmInfo?.version} 吗?此操作无法撤销。`} + confirmText="确定删除" + cancelText="取消" + type="danger" + /> + + {/* Toast notifications */} + {toasts.map(toast => ( + removeToast(toast.id)} + /> + ))} ); }; diff --git a/src/pages/Dashboard.css b/src/pages/Dashboard.css index fc9b44e..25fc74f 100644 --- a/src/pages/Dashboard.css +++ b/src/pages/Dashboard.css @@ -162,8 +162,15 @@ flex: 1; } +.user-name-row { + display: flex; + align-items: center; + gap: 0.75rem; + margin-bottom: 0.15rem; +} + .user-details h2 { - margin: 0 0 0.15rem 0; + margin: 0; color: #1e293b; font-size: 1.1rem; font-weight: 600; @@ -747,3 +754,54 @@ opacity: 1; transform: translateX(4px); } + +/* Voiceprint Badge - 紧凑徽章样式 */ +.voiceprint-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.25rem 0.6rem; + border-radius: 12px; + font-size: 0.75rem; + font-weight: 600; + cursor: pointer; + transition: all 0.2s ease; + border: none; + white-space: nowrap; +} + +.voiceprint-badge.uncollected { + background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%); + color: white; + box-shadow: 0 1px 3px rgba(245, 158, 11, 0.3); +} + +.voiceprint-badge.uncollected:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(245, 158, 11, 0.4); +} + +.voiceprint-badge.collected { + background: linear-gradient(135deg, #10b981 0%, #059669 100%); + color: white; + box-shadow: 0 1px 3px rgba(16, 185, 129, 0.3); +} + +.voiceprint-badge.collected:hover { + transform: translateY(-1px); + box-shadow: 0 2px 6px rgba(16, 185, 129, 0.4); + background: linear-gradient(135deg, #059669 0%, #047857 100%); +} + +.voiceprint-badge .voiceprint-icon { + animation: wave 2s ease-in-out infinite; +} + +@keyframes wave { + 0%, 100% { + transform: scaleX(1); + } + 50% { + transform: scaleX(1.1); + } +} diff --git a/src/pages/Dashboard.jsx b/src/pages/Dashboard.jsx index b8fbd84..cda0f28 100644 --- a/src/pages/Dashboard.jsx +++ b/src/pages/Dashboard.jsx @@ -1,10 +1,13 @@ import React, { useState, useEffect, useRef } from 'react'; -import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText } from 'lucide-react'; +import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText, Waves } from 'lucide-react'; import apiClient from '../utils/apiClient'; import { Link } from 'react-router-dom'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import MeetingTimeline from '../components/MeetingTimeline'; import TagCloud from '../components/TagCloud'; +import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal'; +import ConfirmDialog from '../components/ConfirmDialog'; +import PageLoading from '../components/PageLoading'; import './Dashboard.css'; const Dashboard = ({ user, onLogout }) => { @@ -24,10 +27,38 @@ const Dashboard = ({ user, onLogout }) => { const [passwordChangeSuccess, setPasswordChangeSuccess] = useState(''); const dropdownRef = useRef(null); + // 声纹相关状态 + const [voiceprintStatus, setVoiceprintStatus] = useState(null); + const [showVoiceprintModal, setShowVoiceprintModal] = useState(false); + const [voiceprintTemplate, setVoiceprintTemplate] = useState(null); + const [voiceprintLoading, setVoiceprintLoading] = useState(true); + const [showDeleteVoiceprintDialog, setShowDeleteVoiceprintDialog] = useState(false); + useEffect(() => { fetchUserData(); + fetchVoiceprintData(); }, [user.user_id]); + const fetchVoiceprintData = async () => { + try { + setVoiceprintLoading(true); + + // 获取声纹状态 + const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id))); + console.log('声纹状态响应:', statusResponse); + setVoiceprintStatus(statusResponse.data); + + // 获取朗读模板 + const templateResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.TEMPLATE)); + console.log('朗读模板响应:', templateResponse); + setVoiceprintTemplate(templateResponse.data); + } catch (err) { + console.error('获取声纹数据失败:', err); + } finally { + setVoiceprintLoading(false); + } + }; + // 过滤会议 useEffect(() => { filterMeetings(); @@ -151,6 +182,35 @@ const Dashboard = ({ user, onLogout }) => { } }; + const handleVoiceprintUpload = async (formData) => { + try { + await apiClient.post( + buildApiUrl(API_ENDPOINTS.VOICEPRINT.UPLOAD(user.user_id)), + formData, + { + headers: { + 'Content-Type': 'multipart/form-data', + }, + } + ); + + // 上传成功后刷新声纹状态并关闭模态框 + await fetchVoiceprintData(); + setShowVoiceprintModal(false); + } catch (err) { + throw new Error(err.response?.data?.message || '声纹上传失败'); + } + }; + + const handleDeleteVoiceprint = async () => { + try { + await apiClient.delete(buildApiUrl(API_ENDPOINTS.VOICEPRINT.DELETE(user.user_id))); + await fetchVoiceprintData(); + } catch (err) { + console.error('删除声纹失败:', err); + } + }; + const groupMeetingsByDate = (meetingsToGroup) => { return meetingsToGroup.reduce((acc, meeting) => { const date = new Date(meeting.meeting_time || meeting.created_at).toISOString().split('T')[0]; @@ -172,14 +232,7 @@ const Dashboard = ({ user, onLogout }) => { }; if (loading || !meetings) { - return ( -
-
-
-

加载中...

-
-
- ); + return ; } if (error) { @@ -240,7 +293,33 @@ const Dashboard = ({ user, onLogout }) => {
-

{userInfo?.caption}

+
+

{userInfo?.caption}

+ {/* 声纹采集按钮 - 放在姓名后 */} + {!voiceprintLoading && ( + <> + {voiceprintStatus?.has_voiceprint ? ( + setShowDeleteVoiceprintDialog(true)} + title="点击删除声纹" + > + + 声纹 + + ) : ( + + )} + + )} +

{userInfo?.email}

加入时间:{formatDate(userInfo?.created_at)}

@@ -369,6 +448,26 @@ const Dashboard = ({ user, onLogout }) => { )} + + {/* 声纹采集模态框 */} + setShowVoiceprintModal(false)} + onSuccess={handleVoiceprintUpload} + templateConfig={voiceprintTemplate} + /> + + {/* 删除声纹确认对话框 */} + setShowDeleteVoiceprintDialog(false)} + onConfirm={handleDeleteVoiceprint} + title="删除声纹" + message="确定要删除声纹数据吗?删除后可以重新采集。" + confirmText="删除" + cancelText="取消" + type="danger" + /> ); }; diff --git a/src/pages/KnowledgeBasePage.css b/src/pages/KnowledgeBasePage.css index 10ae4a8..b0b1965 100644 --- a/src/pages/KnowledgeBasePage.css +++ b/src/pages/KnowledgeBasePage.css @@ -6,6 +6,31 @@ flex-direction: column; } +/* 加载和错误状态 */ +.loading-container { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 50vh; + color: #64748b; +} + +.loading-spinner { + width: 40px; + height: 40px; + border: 3px solid #e2e8f0; + border-top: 3px solid #667eea; + border-radius: 50%; + animation: spin 1s linear infinite; + margin-bottom: 1rem; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + /* 顶部Header */ .kb-header { background: white; @@ -888,3 +913,146 @@ .meeting-list::-webkit-scrollbar-thumb:hover { background: #94a3b8; } + +/* 步骤指示器 - 紧凑版 */ +.step-indicator { + display: flex; + align-items: center; + justify-content: center; + padding: 0.75rem 2rem; + background: #f8fafc; + border-bottom: 1px solid #e2e8f0; +} + +.step-item { + display: flex; + align-items: center; + gap: 0.5rem; + position: relative; +} + +.step-number { + width: 24px; + height: 24px; + border-radius: 50%; + background: #e2e8f0; + color: #64748b; + display: flex; + align-items: center; + justify-content: center; + font-weight: 600; + font-size: 0.75rem; + transition: all 0.3s ease; + flex-shrink: 0; +} + +.step-item.active .step-number { + background: #667eea; + color: white; + box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1); +} + +.step-item.completed .step-number { + background: #10b981; + color: white; +} + +.step-item.completed .step-number::before { + content: "✓"; + font-size: 0.85rem; +} + +.step-label { + font-size: 0.8rem; + color: #64748b; + font-weight: 500; + transition: color 0.3s ease; + white-space: nowrap; +} + +.step-item.active .step-label { + color: #667eea; + font-weight: 600; +} + +.step-item.completed .step-label { + color: #10b981; +} + +.step-line { + width: 40px; + height: 2px; + background: #e2e8f0; + margin: 0 0.75rem; +} + +/* 步骤内容 */ +.form-step { + animation: fadeIn 0.3s ease; +} + +@keyframes fadeIn { + from { + opacity: 0; + transform: translateY(10px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +/* 步骤2摘要信息 - 紧凑版 */ +.step-summary { + background: #f0fdf4; + border: 1px solid #bbf7d0; + border-radius: 6px; + padding: 0.5rem 0.75rem; + margin-bottom: 1rem; +} + +.summary-item { + display: flex; + align-items: center; + gap: 0.5rem; + font-size: 0.85rem; +} + +.summary-label { + font-weight: 500; + color: #166534; +} + +.summary-value { + font-weight: 600; + color: #15803d; +} + +/* 字段提示文字 - 紧凑版 */ +.field-hint { + font-size: 0.8rem; + color: #64748b; + margin-bottom: 0.5rem; + line-height: 1.4; + background: #f8fafc; + padding: 0.5rem; + border-radius: 4px; + border-left: 2px solid #667eea; +} + +/* 二级按钮 */ +.btn-secondary { + padding: 0.75rem 1.5rem; + border-radius: 8px; + border: 1px solid #e2e8f0; + background: white; + color: #475569; + font-weight: 500; + cursor: pointer; + transition: all 0.2s ease; +} + +.btn-secondary:hover { + background: #f8fafc; + border-color: #cbd5e1; +} diff --git a/src/pages/KnowledgeBasePage.jsx b/src/pages/KnowledgeBasePage.jsx index 7589113..3663527 100644 --- a/src/pages/KnowledgeBasePage.jsx +++ b/src/pages/KnowledgeBasePage.jsx @@ -5,10 +5,13 @@ import apiClient from '../utils/apiClient'; import { buildApiUrl, API_ENDPOINTS } from '../config/api'; import ContentViewer from '../components/ContentViewer'; import TagDisplay from '../components/TagDisplay'; +import Toast from '../components/Toast'; +import ConfirmDialog from '../components/ConfirmDialog'; import remarkGfm from 'remark-gfm'; import rehypeRaw from 'rehype-raw'; import rehypeSanitize from 'rehype-sanitize'; import html2canvas from 'html2canvas'; +import PageLoading from '../components/PageLoading'; import './KnowledgeBasePage.css'; const KnowledgeBasePage = ({ user }) => { @@ -27,8 +30,19 @@ const KnowledgeBasePage = ({ user }) => { const [generating, setGenerating] = useState(false); const [taskId, setTaskId] = useState(null); const [progress, setProgress] = useState(0); - const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); - const [deletingKb, setDeletingKb] = useState(null); + const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null); + const [toasts, setToasts] = useState([]); + const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词 + + // Toast helper functions + const showToast = (message, type = 'info') => { + const id = Date.now(); + setToasts(prev => [...prev, { id, message, type }]); + }; + + const removeToast = (id) => { + setToasts(prev => prev.filter(toast => toast.id !== id)); + }; useEffect(() => { fetchAllKbs(); @@ -50,13 +64,16 @@ const KnowledgeBasePage = ({ user }) => { setUserPrompt(''); setSelectedMeetings([]); setShowCreateForm(false); + setCreateStep(1); // 重置步骤 + setSearchQuery(''); + setSelectedTags([]); fetchAllKbs(); } else if (status === 'failed') { clearInterval(interval); setTaskId(null); setGenerating(false); setProgress(0); - alert('知识库生成失败,请稍后重试'); + showToast('知识库生成失败,请稍后重试', 'error'); } }) .catch(error => { @@ -140,7 +157,7 @@ const KnowledgeBasePage = ({ user }) => { const handleGenerate = async () => { if (!selectedMeetings || selectedMeetings.length === 0) { - alert('请至少选择一个会议'); + showToast('请至少选择一个会议', 'warning'); return; } @@ -210,25 +227,22 @@ const KnowledgeBasePage = ({ user }) => { }; const handleDelete = async (kb) => { - setDeletingKb(kb); - setShowDeleteConfirm(true); + setDeleteConfirmInfo({ kb_id: kb.kb_id, title: kb.title }); }; const confirmDelete = async () => { try { - await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deletingKb.kb_id))); - setShowDeleteConfirm(false); - setDeletingKb(null); + await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deleteConfirmInfo.kb_id))); // 如果删除的是当前选中的,清除选中 - if (selectedKb && selectedKb.kb_id === deletingKb.kb_id) { + if (selectedKb && selectedKb.kb_id === deleteConfirmInfo.kb_id) { setSelectedKb(null); } + setDeleteConfirmInfo(null); fetchAllKbs(); } catch (error) { console.error("Error deleting knowledge base:", error); - alert('删除失败,请稍后重试'); - setShowDeleteConfirm(false); - setDeletingKb(null); + showToast('删除失败,请稍后重试', 'error'); + setDeleteConfirmInfo(null); } }; @@ -290,7 +304,7 @@ const KnowledgeBasePage = ({ user }) => { const exportSummaryToImage = async () => { try { if (!selectedKb?.content) { - alert('暂无知识库内容,请稍后再试。'); + showToast('暂无知识库内容,请稍后再试。', 'warning'); return; } @@ -453,7 +467,7 @@ const KnowledgeBasePage = ({ user }) => { } catch (error) { console.error('图片导出失败:', error); - alert('图片导出失败,请重试。'); + showToast('图片导出失败,请重试。', 'error'); } }; @@ -461,14 +475,14 @@ const KnowledgeBasePage = ({ user }) => { const exportMindMapToImage = async () => { try { if (!selectedKb?.content) { - alert('暂无内容,无法导出思维导图。'); + showToast('暂无内容,无法导出思维导图。', 'warning'); return; } // 查找SVG元素 const svgElement = document.querySelector('.markmap-render-area svg'); if (!svgElement) { - alert('未找到思维导图,请先切换到脑图标签页。'); + showToast('未找到思维导图,请先切换到脑图标签页。', 'warning'); return; } @@ -496,14 +510,14 @@ const KnowledgeBasePage = ({ user }) => { } catch (error) { console.error('思维导图导出失败:', error); - alert('思维导图导出失败,请重试。'); + showToast('思维导图导出失败,请重试。', 'error'); } }; const isCreator = selectedKb && user && String(selectedKb.creator_id) === String(user.user_id); if (loading) { - return
Loading...
; + return ; } return ( @@ -771,126 +785,203 @@ const KnowledgeBasePage = ({ user }) => {

新增知识库

- +
-
-
- - {/* 紧凑的搜索和过滤区 */} -
- setSearchQuery(e.target.value)} - className="compact-search-input" - /> - - {availableTags.length > 0 && ( -
-
- {availableTags.map(tag => ( - - ))} -
-
- )} - - {(searchQuery || selectedTags.length > 0) && ( - - )} -
- -
- {filteredMeetings.length === 0 ? ( -
-

未找到匹配的会议

-
- ) : ( - filteredMeetings.map(meeting => ( -
toggleMeetingSelection(meeting.meeting_id)} - > - { - e.stopPropagation(); - toggleMeetingSelection(meeting.meeting_id); - }} - onClick={(e) => e.stopPropagation()} - /> -
-
{meeting.title}
- {meeting.creator_username && ( -
创建人: {meeting.creator_username}
- )} -
-
- )) - )} -
+ {/* 步骤指示器 */} +
+
1 ? 'completed' : ''}`}> +
1
+
选择会议
- {selectedMeetings.length > 0 && ( -
- 已选择 {selectedMeetings.length} 个会议 +
+
+
2
+
自定义提示词
+
+
+ +
+ {/* 步骤 1: 选择会议 */} + {createStep === 1 && ( +
+
+ + + {/* 紧凑的搜索和过滤区 */} +
+ setSearchQuery(e.target.value)} + className="compact-search-input" + /> + + {availableTags.length > 0 && ( +
+
+ {availableTags.map(tag => ( + + ))} +
+
+ )} + + {(searchQuery || selectedTags.length > 0) && ( + + )} +
+ +
+ {filteredMeetings.length === 0 ? ( +
+

未找到匹配的会议

+
+ ) : ( + filteredMeetings.map(meeting => ( +
toggleMeetingSelection(meeting.meeting_id)} + > + { + e.stopPropagation(); + toggleMeetingSelection(meeting.meeting_id); + }} + onClick={(e) => e.stopPropagation()} + /> +
+
{meeting.title}
+ {meeting.creator_username && ( +
创建人: {meeting.creator_username}
+ )} +
+
+ )) + )} +
+
+ {selectedMeetings.length > 0 && ( +
+ 已选择 {selectedMeetings.length} 个会议 +
+ )} +
+ )} + + {/* 步骤 2: 输入提示词 */} + {createStep === 2 && ( +
+
+
+ 已选择会议: + {selectedMeetings.length} 个 +
+
+
+ +

您可以添加额外的要求来定制知识库生成内容,例如重点关注某个主题、提取特定信息等。如不填写,系统将使用默认提示词。

+