Compare commits
2 Commits
493e632c1f
...
cdfcceaf47
| Author | SHA1 | Date |
|---|---|---|
|
|
cdfcceaf47 | |
|
|
46358a623b |
|
|
@ -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%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="confirm-dialog-overlay" onClick={onClose}>
|
||||||
|
<div className="confirm-dialog-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className={`confirm-dialog-icon ${type}`}>
|
||||||
|
<AlertTriangle size={48} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-body">
|
||||||
|
<h3 className="confirm-dialog-title">{title}</h3>
|
||||||
|
<p className="confirm-dialog-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="confirm-dialog-actions">
|
||||||
|
<button
|
||||||
|
className="confirm-dialog-btn cancel"
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
{cancelText}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className={`confirm-dialog-btn confirm ${type}`}
|
||||||
|
onClick={handleConfirm}
|
||||||
|
>
|
||||||
|
{confirmText}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default ConfirmDialog;
|
||||||
|
|
@ -12,7 +12,8 @@ const MeetingSummary = ({
|
||||||
summaryHistory,
|
summaryHistory,
|
||||||
isCreator,
|
isCreator,
|
||||||
onOpenSummaryModal,
|
onOpenSummaryModal,
|
||||||
formatDateTime
|
formatDateTime,
|
||||||
|
showToast
|
||||||
}) => {
|
}) => {
|
||||||
const exportRef = useRef(null);
|
const exportRef = useRef(null);
|
||||||
|
|
||||||
|
|
@ -24,12 +25,12 @@ const MeetingSummary = ({
|
||||||
(summaryHistory.length > 0 ? summaryHistory[0].content : null);
|
(summaryHistory.length > 0 ? summaryHistory[0].content : null);
|
||||||
|
|
||||||
if (!summaryContent) {
|
if (!summaryContent) {
|
||||||
alert('暂无会议总结内容,请先生成AI总结。');
|
showToast?.('暂无会议总结内容,请先生成AI总结。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!exportRef.current) {
|
if (!exportRef.current) {
|
||||||
alert('内容尚未渲染完成,请稍后重试。');
|
showToast?.('内容尚未渲染完成,请稍后重试。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -195,7 +196,7 @@ const MeetingSummary = ({
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('图片导出失败:', error);
|
console.error('图片导出失败:', error);
|
||||||
alert('图片导出失败,请重试。');
|
showToast?.('图片导出失败,请重试。', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -83,6 +83,7 @@
|
||||||
/* Meeting Cards */
|
/* Meeting Cards */
|
||||||
.meeting-card-wrapper {
|
.meeting-card-wrapper {
|
||||||
position: relative;
|
position: relative;
|
||||||
|
padding: 0 5px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.meeting-card {
|
.meeting-card {
|
||||||
|
|
|
||||||
|
|
@ -6,10 +6,11 @@ import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import TagDisplay from './TagDisplay';
|
import TagDisplay from './TagDisplay';
|
||||||
|
import ConfirmDialog from './ConfirmDialog';
|
||||||
import './MeetingTimeline.css';
|
import './MeetingTimeline.css';
|
||||||
|
|
||||||
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(null);
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const [showDropdown, setShowDropdown] = useState(null);
|
const [showDropdown, setShowDropdown] = useState(null);
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
// Close dropdown when clicking outside
|
// Close dropdown when clicking outside
|
||||||
|
|
@ -72,17 +73,20 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
||||||
setShowDropdown(null);
|
setShowDropdown(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDeleteClick = (meetingId, e) => {
|
const handleDeleteClick = (meeting, e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setShowDeleteConfirm(meetingId);
|
setDeleteConfirmInfo({
|
||||||
|
id: meeting.meeting_id,
|
||||||
|
title: meeting.title
|
||||||
|
});
|
||||||
setShowDropdown(null);
|
setShowDropdown(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleConfirmDelete = async (meetingId) => {
|
const handleConfirmDelete = async () => {
|
||||||
if (onDeleteMeeting) {
|
if (onDeleteMeeting && deleteConfirmInfo) {
|
||||||
await onDeleteMeeting(meetingId);
|
await onDeleteMeeting(deleteConfirmInfo.id);
|
||||||
}
|
}
|
||||||
setShowDeleteConfirm(null);
|
setDeleteConfirmInfo(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const toggleDropdown = (meetingId, e) => {
|
const toggleDropdown = (meetingId, e) => {
|
||||||
|
|
@ -153,9 +157,9 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
||||||
<Edit size={16} />
|
<Edit size={16} />
|
||||||
编辑
|
编辑
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
className="dropdown-item delete-item"
|
className="dropdown-item delete-item"
|
||||||
onClick={(e) => handleDeleteClick(meeting.meeting_id, e)}
|
onClick={(e) => handleDeleteClick(meeting, e)}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
删除
|
删除
|
||||||
|
|
@ -225,36 +229,24 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
|
||||||
{showDeleteConfirm === meeting.meeting_id && (
|
|
||||||
<div className="delete-modal-overlay" onClick={() => setShowDeleteConfirm(null)}>
|
|
||||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h3>确认删除</h3>
|
|
||||||
<p>确定要删除会议 "{meeting.title}" 吗?此操作无法撤销。</p>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button
|
|
||||||
className="btn-cancel"
|
|
||||||
onClick={() => setShowDeleteConfirm(null)}
|
|
||||||
>
|
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-delete"
|
|
||||||
onClick={() => handleConfirmDelete(meeting.meeting_id)}
|
|
||||||
>
|
|
||||||
确定删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
||||||
|
{/* 删除会议确认对话框 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteConfirmInfo}
|
||||||
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="删除会议"
|
||||||
|
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
||||||
|
confirmText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
type="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,13 @@
|
||||||
|
import React from 'react';
|
||||||
|
import './PageLoading.css';
|
||||||
|
|
||||||
|
const PageLoading = ({ message = '加载中...' }) => {
|
||||||
|
return (
|
||||||
|
<div className="page-loading-container">
|
||||||
|
<div className="page-loading-spinner"></div>
|
||||||
|
<p className="page-loading-message">{message}</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default PageLoading;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 <CheckCircle size={20} />;
|
||||||
|
case 'error':
|
||||||
|
return <XCircle size={20} />;
|
||||||
|
case 'warning':
|
||||||
|
return <AlertCircle size={20} />;
|
||||||
|
case 'info':
|
||||||
|
default:
|
||||||
|
return <Info size={20} />;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className={`toast toast-${type}`}>
|
||||||
|
<div className="toast-icon">{getIcon()}</div>
|
||||||
|
<div className="toast-message">{message}</div>
|
||||||
|
<button className="toast-close" onClick={onClose}>×</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default Toast;
|
||||||
|
|
@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 (
|
||||||
|
<div className="voiceprint-modal-overlay" onClick={onClose}>
|
||||||
|
<div className="voiceprint-modal-content" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<div className="voiceprint-modal-header">
|
||||||
|
<h2>声纹采集</h2>
|
||||||
|
<button className="close-btn" onClick={onClose}>
|
||||||
|
<X size={24} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="voiceprint-modal-body">
|
||||||
|
{/* 朗读文本 */}
|
||||||
|
<div className="template-text-container">
|
||||||
|
<p className="template-text" style={{ whiteSpace: 'pre-line' }}>
|
||||||
|
{templateText}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提示信息 */}
|
||||||
|
<div className="recording-hint">
|
||||||
|
<p>请在安静环境下,用自然语速和音量朗读上述文字</p>
|
||||||
|
<p className="hint-duration">建议录制时长:{duration}秒</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 录制控制区 */}
|
||||||
|
<div className="recording-control">
|
||||||
|
{!isRecording && !audioBlob && (
|
||||||
|
<button
|
||||||
|
className="record-btn-circle"
|
||||||
|
onClick={startRecording}
|
||||||
|
disabled={isUploading}
|
||||||
|
title="点击开始录制"
|
||||||
|
>
|
||||||
|
<Mic size={40} />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{isRecording && (
|
||||||
|
<div className="recording-active">
|
||||||
|
<div className="progress-circle">
|
||||||
|
<svg className="progress-ring" width="120" height="120">
|
||||||
|
<circle
|
||||||
|
className="progress-ring-circle-bg"
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="54"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
className="progress-ring-circle"
|
||||||
|
cx="60"
|
||||||
|
cy="60"
|
||||||
|
r="54"
|
||||||
|
strokeDasharray={`${2 * Math.PI * 54}`}
|
||||||
|
strokeDashoffset={`${2 * Math.PI * 54 * (1 - progress / 100)}`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
<div className="countdown-text">
|
||||||
|
<MicOff size={32} className="recording-icon" />
|
||||||
|
<span className="countdown-number">{countdown}s</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
className="record-btn stop"
|
||||||
|
onClick={stopRecording}
|
||||||
|
>
|
||||||
|
停止录制
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{audioBlob && !isRecording && (
|
||||||
|
<div className="recording-complete">
|
||||||
|
<div className="complete-icon">
|
||||||
|
<Mic size={48} />
|
||||||
|
</div>
|
||||||
|
<p className="complete-text">录制完成</p>
|
||||||
|
<div className="complete-actions">
|
||||||
|
<button
|
||||||
|
className="btn-secondary"
|
||||||
|
onClick={resetRecording}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
<RefreshCw size={16} />
|
||||||
|
重新录制
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleUpload}
|
||||||
|
disabled={isUploading}
|
||||||
|
>
|
||||||
|
{isUploading ? '上传中...' : '确认提交'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 错误信息 */}
|
||||||
|
{error && (
|
||||||
|
<div className="error-message">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
export default VoiceprintCollectionModal;
|
||||||
|
|
@ -64,6 +64,12 @@ const API_CONFIG = {
|
||||||
CREATE: '/api/clients/downloads',
|
CREATE: '/api/clients/downloads',
|
||||||
UPDATE: (id) => `/api/clients/downloads/${id}`,
|
UPDATE: (id) => `/api/clients/downloads/${id}`,
|
||||||
DELETE: (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}`
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
@ -9,13 +9,13 @@
|
||||||
.download-page-header {
|
.download-page-header {
|
||||||
background: white;
|
background: white;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
padding: 1.5rem 2rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-page-header .header-content {
|
.download-page-header .header-content {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.download-page-header .logo {
|
.download-page-header .logo {
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,8 @@ import {
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
import Toast from '../components/Toast';
|
||||||
import './ClientManagement.css';
|
import './ClientManagement.css';
|
||||||
|
|
||||||
const ClientManagement = ({ user }) => {
|
const ClientManagement = ({ user }) => {
|
||||||
|
|
@ -21,11 +23,12 @@ const ClientManagement = ({ user }) => {
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||||
const [showEditModal, setShowEditModal] = useState(false);
|
const [showEditModal, setShowEditModal] = useState(false);
|
||||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const [selectedClient, setSelectedClient] = useState(null);
|
const [selectedClient, setSelectedClient] = useState(null);
|
||||||
const [filterPlatformType, setFilterPlatformType] = useState('');
|
const [filterPlatformType, setFilterPlatformType] = useState('');
|
||||||
const [searchQuery, setSearchQuery] = useState('');
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [expandedNotes, setExpandedNotes] = useState({});
|
const [expandedNotes, setExpandedNotes] = useState({});
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
const [formData, setFormData] = useState({
|
const [formData, setFormData] = useState({
|
||||||
platform_type: 'mobile',
|
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(() => {
|
useEffect(() => {
|
||||||
fetchClients();
|
fetchClients();
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -74,7 +87,7 @@ const ClientManagement = ({ user }) => {
|
||||||
try {
|
try {
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
if (!formData.version_code || !formData.version || !formData.download_url) {
|
if (!formData.version_code || !formData.version || !formData.download_url) {
|
||||||
alert('请填写所有必填字段');
|
showToast('请填写所有必填字段', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,12 +99,12 @@ const ClientManagement = ({ user }) => {
|
||||||
|
|
||||||
// 验证转换后的数字
|
// 验证转换后的数字
|
||||||
if (isNaN(payload.version_code)) {
|
if (isNaN(payload.version_code)) {
|
||||||
alert('版本代码必须是有效的数字');
|
showToast('版本代码必须是有效的数字', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.file_size && isNaN(payload.file_size)) {
|
if (formData.file_size && isNaN(payload.file_size)) {
|
||||||
alert('文件大小必须是有效的数字');
|
showToast('文件大小必须是有效的数字', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,7 +114,7 @@ const ClientManagement = ({ user }) => {
|
||||||
fetchClients();
|
fetchClients();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('创建客户端失败:', error);
|
console.error('创建客户端失败:', error);
|
||||||
alert(error.response?.data?.message || '创建失败,请重试');
|
showToast(error.response?.data?.message || '创建失败,请重试', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -109,7 +122,7 @@ const ClientManagement = ({ user }) => {
|
||||||
try {
|
try {
|
||||||
// 验证必填字段
|
// 验证必填字段
|
||||||
if (!formData.version_code || !formData.version || !formData.download_url) {
|
if (!formData.version_code || !formData.version || !formData.download_url) {
|
||||||
alert('请填写所有必填字段');
|
showToast('请填写所有必填字段', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -126,12 +139,12 @@ const ClientManagement = ({ user }) => {
|
||||||
|
|
||||||
// 验证转换后的数字
|
// 验证转换后的数字
|
||||||
if (isNaN(payload.version_code)) {
|
if (isNaN(payload.version_code)) {
|
||||||
alert('版本代码必须是有效的数字');
|
showToast('版本代码必须是有效的数字', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (formData.file_size && isNaN(payload.file_size)) {
|
if (formData.file_size && isNaN(payload.file_size)) {
|
||||||
alert('文件大小必须是有效的数字');
|
showToast('文件大小必须是有效的数字', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,19 +157,21 @@ const ClientManagement = ({ user }) => {
|
||||||
fetchClients();
|
fetchClients();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('更新客户端失败:', error);
|
console.error('更新客户端失败:', error);
|
||||||
alert(error.response?.data?.message || '更新失败,请重试');
|
showToast(error.response?.data?.message || '更新失败,请重试', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async () => {
|
const handleDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(selectedClient.id)));
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(deleteConfirmInfo.id)));
|
||||||
setShowDeleteModal(false);
|
setDeleteConfirmInfo(null);
|
||||||
setSelectedClient(null);
|
setSelectedClient(null);
|
||||||
|
showToast('删除成功', 'success');
|
||||||
fetchClients();
|
fetchClients();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('删除客户端失败:', error);
|
console.error('删除客户端失败:', error);
|
||||||
alert('删除失败,请重试');
|
showToast('删除失败,请重试', 'error');
|
||||||
|
setDeleteConfirmInfo(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -178,8 +193,11 @@ const ClientManagement = ({ user }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteModal = (client) => {
|
const openDeleteModal = (client) => {
|
||||||
setSelectedClient(client);
|
setDeleteConfirmInfo({
|
||||||
setShowDeleteModal(true);
|
id: client.id,
|
||||||
|
platform_name: getPlatformLabel(client.platform_name),
|
||||||
|
version: client.version
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const resetForm = () => {
|
const resetForm = () => {
|
||||||
|
|
@ -572,26 +590,27 @@ const ClientManagement = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 删除确认模态框 */}
|
{/* 删除确认对话框 */}
|
||||||
{showDeleteModal && selectedClient && (
|
<ConfirmDialog
|
||||||
<div className="modal-overlay" onClick={() => setShowDeleteModal(false)}>
|
isOpen={!!deleteConfirmInfo}
|
||||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
<h3>确认删除</h3>
|
onConfirm={handleDelete}
|
||||||
<p>
|
title="删除客户端"
|
||||||
确定要删除 <strong>{getPlatformLabel(selectedClient.platform_name)}</strong> 版本{' '}
|
message={`确定要删除 ${deleteConfirmInfo?.platform_name} 版本 ${deleteConfirmInfo?.version} 吗?此操作无法撤销。`}
|
||||||
<strong>{selectedClient.version}</strong> 吗?此操作无法撤销。
|
confirmText="确定删除"
|
||||||
</p>
|
cancelText="取消"
|
||||||
<div className="modal-actions">
|
type="danger"
|
||||||
<button className="btn-cancel" onClick={() => setShowDeleteModal(false)}>
|
/>
|
||||||
取消
|
|
||||||
</button>
|
{/* Toast notifications */}
|
||||||
<button className="btn-delete" onClick={handleDelete}>
|
{toasts.map(toast => (
|
||||||
确定删除
|
<Toast
|
||||||
</button>
|
key={toast.id}
|
||||||
</div>
|
message={toast.message}
|
||||||
</div>
|
type={toast.type}
|
||||||
</div>
|
onClose={() => removeToast(toast.id)}
|
||||||
)}
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -162,8 +162,15 @@
|
||||||
flex: 1;
|
flex: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.user-name-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
margin-bottom: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
.user-details h2 {
|
.user-details h2 {
|
||||||
margin: 0 0 0.15rem 0;
|
margin: 0;
|
||||||
color: #1e293b;
|
color: #1e293b;
|
||||||
font-size: 1.1rem;
|
font-size: 1.1rem;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
|
|
@ -747,3 +754,54 @@
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
transform: translateX(4px);
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,13 @@
|
||||||
import React, { useState, useEffect, useRef } from 'react';
|
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 apiClient from '../utils/apiClient';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import MeetingTimeline from '../components/MeetingTimeline';
|
import MeetingTimeline from '../components/MeetingTimeline';
|
||||||
import TagCloud from '../components/TagCloud';
|
import TagCloud from '../components/TagCloud';
|
||||||
|
import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
import PageLoading from '../components/PageLoading';
|
||||||
import './Dashboard.css';
|
import './Dashboard.css';
|
||||||
|
|
||||||
const Dashboard = ({ user, onLogout }) => {
|
const Dashboard = ({ user, onLogout }) => {
|
||||||
|
|
@ -24,10 +27,38 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
const [passwordChangeSuccess, setPasswordChangeSuccess] = useState('');
|
const [passwordChangeSuccess, setPasswordChangeSuccess] = useState('');
|
||||||
const dropdownRef = useRef(null);
|
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(() => {
|
useEffect(() => {
|
||||||
fetchUserData();
|
fetchUserData();
|
||||||
|
fetchVoiceprintData();
|
||||||
}, [user.user_id]);
|
}, [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(() => {
|
useEffect(() => {
|
||||||
filterMeetings();
|
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) => {
|
const groupMeetingsByDate = (meetingsToGroup) => {
|
||||||
return meetingsToGroup.reduce((acc, meeting) => {
|
return meetingsToGroup.reduce((acc, meeting) => {
|
||||||
const date = new Date(meeting.meeting_time || meeting.created_at).toISOString().split('T')[0];
|
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) {
|
if (loading || !meetings) {
|
||||||
return (
|
return <PageLoading message="加载中..." />;
|
||||||
<div className="dashboard">
|
|
||||||
<div className="loading-container">
|
|
||||||
<div className="loading-spinner"></div>
|
|
||||||
<p>加载中...</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -240,7 +293,33 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
<User size={24} />
|
<User size={24} />
|
||||||
</div>
|
</div>
|
||||||
<div className="user-details">
|
<div className="user-details">
|
||||||
<h2>{userInfo?.caption}</h2>
|
<div className="user-name-row">
|
||||||
|
<h2>{userInfo?.caption}</h2>
|
||||||
|
{/* 声纹采集按钮 - 放在姓名后 */}
|
||||||
|
{!voiceprintLoading && (
|
||||||
|
<>
|
||||||
|
{voiceprintStatus?.has_voiceprint ? (
|
||||||
|
<span
|
||||||
|
className="voiceprint-badge collected"
|
||||||
|
onClick={() => setShowDeleteVoiceprintDialog(true)}
|
||||||
|
title="点击删除声纹"
|
||||||
|
>
|
||||||
|
<Waves size={14} className="voiceprint-icon" />
|
||||||
|
声纹
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
className="voiceprint-badge uncollected"
|
||||||
|
onClick={() => setShowVoiceprintModal(true)}
|
||||||
|
title="采集声纹"
|
||||||
|
>
|
||||||
|
<Waves size={14} />
|
||||||
|
声纹
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
<p className="user-email">{userInfo?.email}</p>
|
<p className="user-email">{userInfo?.email}</p>
|
||||||
<p className="join-date">加入时间:{formatDate(userInfo?.created_at)}</p>
|
<p className="join-date">加入时间:{formatDate(userInfo?.created_at)}</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -369,6 +448,26 @@ const Dashboard = ({ user, onLogout }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 声纹采集模态框 */}
|
||||||
|
<VoiceprintCollectionModal
|
||||||
|
isOpen={showVoiceprintModal}
|
||||||
|
onClose={() => setShowVoiceprintModal(false)}
|
||||||
|
onSuccess={handleVoiceprintUpload}
|
||||||
|
templateConfig={voiceprintTemplate}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 删除声纹确认对话框 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={showDeleteVoiceprintDialog}
|
||||||
|
onClose={() => setShowDeleteVoiceprintDialog(false)}
|
||||||
|
onConfirm={handleDeleteVoiceprint}
|
||||||
|
title="删除声纹"
|
||||||
|
message="确定要删除声纹数据吗?删除后可以重新采集。"
|
||||||
|
confirmText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
type="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -296,12 +296,19 @@
|
||||||
.modal-header h2 { margin: 0; font-size: 1.75rem; font-weight: 600; }
|
.modal-header h2 { margin: 0; font-size: 1.75rem; font-weight: 600; }
|
||||||
.close-btn { background: none; border: none; cursor: pointer; color: #888; padding: 0.5rem; border-radius: 50%; transition: all 0.3s ease; }
|
.close-btn { background: none; border: none; cursor: pointer; color: #888; padding: 0.5rem; border-radius: 50%; transition: all 0.3s ease; }
|
||||||
.close-btn:hover { background: #f1f3f5; color: #333; }
|
.close-btn:hover { background: #f1f3f5; color: #333; }
|
||||||
.login-form { display: flex; flex-direction: column; gap: 1.5rem; }
|
.login-form { display: flex; flex-direction: column; gap: 1rem; }
|
||||||
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
.form-group { display: flex; flex-direction: column; gap: 0.5rem; }
|
||||||
.form-group label { display: flex; align-items: center; gap: 0.5rem; font-weight: 500; color: #555; }
|
.form-group label { display: flex; align-items: center; gap: 0.5rem; font-weight: 500; color: #555; }
|
||||||
.form-group input { padding: 0.85rem 1rem; border: 1px solid #ced4da; border-radius: 10px; font-size: 1rem; transition: all 0.3s ease; }
|
.form-group input[type="text"],
|
||||||
.form-group input:focus { outline: none; border-color: #8a63d2; box-shadow: 0 0 0 4px rgba(111, 66, 193, 0.1); }
|
.form-group input[type="password"] { padding: 0.85rem 1rem; border: 1px solid #ced4da; border-radius: 10px; font-size: 1rem; transition: all 0.3s ease; }
|
||||||
.error-message { background: #fff5f5; color: #c53030; padding: 0.85rem; border-radius: 8px; border: 1px solid #fed7d7; font-size: 0.9rem; }
|
.form-group input[type="text"]:focus,
|
||||||
|
.form-group input[type="password"]:focus { outline: none; border-color: #8a63d2; box-shadow: 0 0 0 4px rgba(111, 66, 193, 0.1); }
|
||||||
|
.form-group.password-group { position: relative; margin-bottom: 0.5rem; }
|
||||||
|
.remember-me-label { display: flex; align-items: center; gap: 0.5rem; cursor: pointer; font-weight: 400; color: #555; font-size: 0.9rem; margin-top: 0.5rem; align-self: flex-end; }
|
||||||
|
.remember-me-label input[type="checkbox"] { width: 16px; height: 16px; cursor: pointer; accent-color: var(--accent-color); }
|
||||||
|
.remember-me-label span { user-select: none; }
|
||||||
|
.error-message-container { min-height: 52px; display: flex; align-items: center; }
|
||||||
|
.error-message { background: #fff5f5; color: #c53030; padding: 0.85rem; border-radius: 8px; border: 1px solid #fed7d7; font-size: 0.9rem; width: 100%; }
|
||||||
.submit-btn { background: var(--accent-color); color: white; border: none; padding: 0.85rem; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; }
|
.submit-btn { background: var(--accent-color); color: white; border: none; padding: 0.85rem; border-radius: 10px; font-size: 1rem; font-weight: 600; cursor: pointer; transition: all 0.3s ease; }
|
||||||
.submit-btn:hover:not(:disabled) { background-color: var(--accent-color-dark); transform: translateY(-2px); box-shadow: 0 4px 15px rgba(111, 66, 193, 0.2); }
|
.submit-btn:hover:not(:disabled) { background-color: var(--accent-color-dark); transform: translateY(-2px); box-shadow: 0 4px 15px rgba(111, 66, 193, 0.2); }
|
||||||
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
.submit-btn:disabled { opacity: 0.6; cursor: not-allowed; }
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import { Brain, Users, Calendar, TrendingUp, X, User, Lock, Library, Download, LogIn } from 'lucide-react';
|
import { Brain, Users, Calendar, TrendingUp, X, User, Lock, Library, Download, LogIn } from 'lucide-react';
|
||||||
import { Link } from 'react-router-dom';
|
import { Link } from 'react-router-dom';
|
||||||
import apiClient from '../utils/apiClient';
|
import apiClient from '../utils/apiClient';
|
||||||
|
|
@ -10,6 +10,18 @@ const HomePage = ({ onLogin }) => {
|
||||||
const [loginForm, setLoginForm] = useState({ username: '', password: '' });
|
const [loginForm, setLoginForm] = useState({ username: '', password: '' });
|
||||||
const [loginError, setLoginError] = useState('');
|
const [loginError, setLoginError] = useState('');
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [rememberMe, setRememberMe] = useState(false);
|
||||||
|
|
||||||
|
// 组件挂载时,从localStorage读取保存的用户名
|
||||||
|
useEffect(() => {
|
||||||
|
const savedUsername = localStorage.getItem('rememberedUsername');
|
||||||
|
const isRemembered = localStorage.getItem('rememberMe') === 'true';
|
||||||
|
|
||||||
|
if (savedUsername && isRemembered) {
|
||||||
|
setLoginForm(prev => ({ ...prev, username: savedUsername }));
|
||||||
|
setRememberMe(true);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
const handleLogin = async (e) => {
|
const handleLogin = async (e) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
|
|
@ -18,10 +30,20 @@ const HomePage = ({ onLogin }) => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const loginResponse = await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), loginForm);
|
const loginResponse = await apiClient.post(buildApiUrl(API_ENDPOINTS.AUTH.LOGIN), loginForm);
|
||||||
|
|
||||||
|
// 处理记住用户名
|
||||||
|
if (rememberMe) {
|
||||||
|
localStorage.setItem('rememberedUsername', loginForm.username);
|
||||||
|
localStorage.setItem('rememberMe', 'true');
|
||||||
|
} else {
|
||||||
|
localStorage.removeItem('rememberedUsername');
|
||||||
|
localStorage.removeItem('rememberMe');
|
||||||
|
}
|
||||||
|
|
||||||
onLogin(loginResponse.data);
|
onLogin(loginResponse.data);
|
||||||
setShowLoginModal(false);
|
setShowLoginModal(false);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setLoginError(error.response?.data?.message || '登录失败,请重试');
|
setLoginError(error.response?.data?.message || '登录失败,请重试');
|
||||||
} finally {
|
} finally {
|
||||||
setIsLoading(false);
|
setIsLoading(false);
|
||||||
}
|
}
|
||||||
|
|
@ -153,8 +175,8 @@ const HomePage = ({ onLogin }) => {
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="form-group">
|
<div className="form-group password-group">
|
||||||
<label htmlFor="password">
|
<label htmlFor="password">
|
||||||
<Lock size={18} />
|
<Lock size={18} />
|
||||||
密码
|
密码
|
||||||
|
|
@ -168,25 +190,31 @@ const HomePage = ({ onLogin }) => {
|
||||||
placeholder="请输入密码"
|
placeholder="请输入密码"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
|
<label htmlFor="rememberMe" className="remember-me-label">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
id="rememberMe"
|
||||||
|
checked={rememberMe}
|
||||||
|
onChange={(e) => setRememberMe(e.target.checked)}
|
||||||
|
/>
|
||||||
|
<span>记住用户名</span>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{loginError && (
|
<div className="error-message-container">
|
||||||
<div className="error-message">{loginError}</div>
|
{loginError && (
|
||||||
)}
|
<div className="error-message">{loginError}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
className="submit-btn"
|
className="submit-btn"
|
||||||
disabled={isLoading}
|
disabled={isLoading}
|
||||||
>
|
>
|
||||||
{isLoading ? '登录中...' : '登录'}
|
{isLoading ? '登录中...' : '登录'}
|
||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
<div className="demo-info">
|
|
||||||
<p>开通业务账号:</p>
|
|
||||||
<p>请联系平台管理员</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,31 @@
|
||||||
flex-direction: column;
|
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 */
|
/* 顶部Header */
|
||||||
.kb-header {
|
.kb-header {
|
||||||
background: white;
|
background: white;
|
||||||
|
|
@ -888,3 +913,146 @@
|
||||||
.meeting-list::-webkit-scrollbar-thumb:hover {
|
.meeting-list::-webkit-scrollbar-thumb:hover {
|
||||||
background: #94a3b8;
|
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;
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,10 +5,13 @@ import apiClient from '../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||||
import ContentViewer from '../components/ContentViewer';
|
import ContentViewer from '../components/ContentViewer';
|
||||||
import TagDisplay from '../components/TagDisplay';
|
import TagDisplay from '../components/TagDisplay';
|
||||||
|
import Toast from '../components/Toast';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
import remarkGfm from 'remark-gfm';
|
import remarkGfm from 'remark-gfm';
|
||||||
import rehypeRaw from 'rehype-raw';
|
import rehypeRaw from 'rehype-raw';
|
||||||
import rehypeSanitize from 'rehype-sanitize';
|
import rehypeSanitize from 'rehype-sanitize';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
|
import PageLoading from '../components/PageLoading';
|
||||||
import './KnowledgeBasePage.css';
|
import './KnowledgeBasePage.css';
|
||||||
|
|
||||||
const KnowledgeBasePage = ({ user }) => {
|
const KnowledgeBasePage = ({ user }) => {
|
||||||
|
|
@ -27,8 +30,19 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
const [generating, setGenerating] = useState(false);
|
const [generating, setGenerating] = useState(false);
|
||||||
const [taskId, setTaskId] = useState(null);
|
const [taskId, setTaskId] = useState(null);
|
||||||
const [progress, setProgress] = useState(0);
|
const [progress, setProgress] = useState(0);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const [deletingKb, setDeletingKb] = 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(() => {
|
useEffect(() => {
|
||||||
fetchAllKbs();
|
fetchAllKbs();
|
||||||
|
|
@ -50,13 +64,16 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
setUserPrompt('');
|
setUserPrompt('');
|
||||||
setSelectedMeetings([]);
|
setSelectedMeetings([]);
|
||||||
setShowCreateForm(false);
|
setShowCreateForm(false);
|
||||||
|
setCreateStep(1); // 重置步骤
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedTags([]);
|
||||||
fetchAllKbs();
|
fetchAllKbs();
|
||||||
} else if (status === 'failed') {
|
} else if (status === 'failed') {
|
||||||
clearInterval(interval);
|
clearInterval(interval);
|
||||||
setTaskId(null);
|
setTaskId(null);
|
||||||
setGenerating(false);
|
setGenerating(false);
|
||||||
setProgress(0);
|
setProgress(0);
|
||||||
alert('知识库生成失败,请稍后重试');
|
showToast('知识库生成失败,请稍后重试', 'error');
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
.catch(error => {
|
.catch(error => {
|
||||||
|
|
@ -140,7 +157,7 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
|
|
||||||
const handleGenerate = async () => {
|
const handleGenerate = async () => {
|
||||||
if (!selectedMeetings || selectedMeetings.length === 0) {
|
if (!selectedMeetings || selectedMeetings.length === 0) {
|
||||||
alert('请至少选择一个会议');
|
showToast('请至少选择一个会议', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -210,25 +227,22 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (kb) => {
|
const handleDelete = async (kb) => {
|
||||||
setDeletingKb(kb);
|
setDeleteConfirmInfo({ kb_id: kb.kb_id, title: kb.title });
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const confirmDelete = async () => {
|
const confirmDelete = async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deletingKb.kb_id)));
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deleteConfirmInfo.kb_id)));
|
||||||
setShowDeleteConfirm(false);
|
|
||||||
setDeletingKb(null);
|
|
||||||
// 如果删除的是当前选中的,清除选中
|
// 如果删除的是当前选中的,清除选中
|
||||||
if (selectedKb && selectedKb.kb_id === deletingKb.kb_id) {
|
if (selectedKb && selectedKb.kb_id === deleteConfirmInfo.kb_id) {
|
||||||
setSelectedKb(null);
|
setSelectedKb(null);
|
||||||
}
|
}
|
||||||
|
setDeleteConfirmInfo(null);
|
||||||
fetchAllKbs();
|
fetchAllKbs();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error("Error deleting knowledge base:", error);
|
console.error("Error deleting knowledge base:", error);
|
||||||
alert('删除失败,请稍后重试');
|
showToast('删除失败,请稍后重试', 'error');
|
||||||
setShowDeleteConfirm(false);
|
setDeleteConfirmInfo(null);
|
||||||
setDeletingKb(null);
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -290,7 +304,7 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
const exportSummaryToImage = async () => {
|
const exportSummaryToImage = async () => {
|
||||||
try {
|
try {
|
||||||
if (!selectedKb?.content) {
|
if (!selectedKb?.content) {
|
||||||
alert('暂无知识库内容,请稍后再试。');
|
showToast('暂无知识库内容,请稍后再试。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -453,7 +467,7 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('图片导出失败:', error);
|
console.error('图片导出失败:', error);
|
||||||
alert('图片导出失败,请重试。');
|
showToast('图片导出失败,请重试。', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -461,14 +475,14 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
const exportMindMapToImage = async () => {
|
const exportMindMapToImage = async () => {
|
||||||
try {
|
try {
|
||||||
if (!selectedKb?.content) {
|
if (!selectedKb?.content) {
|
||||||
alert('暂无内容,无法导出思维导图。');
|
showToast('暂无内容,无法导出思维导图。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找SVG元素
|
// 查找SVG元素
|
||||||
const svgElement = document.querySelector('.markmap-render-area svg');
|
const svgElement = document.querySelector('.markmap-render-area svg');
|
||||||
if (!svgElement) {
|
if (!svgElement) {
|
||||||
alert('未找到思维导图,请先切换到脑图标签页。');
|
showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -496,14 +510,14 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('思维导图导出失败:', error);
|
console.error('思维导图导出失败:', error);
|
||||||
alert('思维导图导出失败,请重试。');
|
showToast('思维导图导出失败,请重试。', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCreator = selectedKb && user && String(selectedKb.creator_id) === String(user.user_id);
|
const isCreator = selectedKb && user && String(selectedKb.creator_id) === String(user.user_id);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div>Loading...</div>;
|
return <PageLoading message="加载中..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -771,126 +785,203 @@ const KnowledgeBasePage = ({ user }) => {
|
||||||
<div className="modal-content create-kb-modal">
|
<div className="modal-content create-kb-modal">
|
||||||
<div className="modal-header">
|
<div className="modal-header">
|
||||||
<h2>新增知识库</h2>
|
<h2>新增知识库</h2>
|
||||||
<button onClick={() => setShowCreateForm(false)} className="close-btn">×</button>
|
<button onClick={() => {
|
||||||
|
setShowCreateForm(false);
|
||||||
|
setCreateStep(1);
|
||||||
|
setSelectedMeetings([]);
|
||||||
|
setUserPrompt('');
|
||||||
|
setSearchQuery('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
}} className="close-btn">×</button>
|
||||||
</div>
|
</div>
|
||||||
<div className="modal-body">
|
|
||||||
<div className="form-group">
|
|
||||||
<label>选择会议数据源</label>
|
|
||||||
|
|
||||||
{/* 紧凑的搜索和过滤区 */}
|
{/* 步骤指示器 */}
|
||||||
<div className="search-filter-area">
|
<div className="step-indicator">
|
||||||
<input
|
<div className={`step-item ${createStep === 1 ? 'active' : ''} ${createStep > 1 ? 'completed' : ''}`}>
|
||||||
type="text"
|
<div className="step-number">1</div>
|
||||||
placeholder="搜索会议名称或创建人..."
|
<div className="step-label">选择会议</div>
|
||||||
value={searchQuery}
|
|
||||||
onChange={(e) => setSearchQuery(e.target.value)}
|
|
||||||
className="compact-search-input"
|
|
||||||
/>
|
|
||||||
|
|
||||||
{availableTags.length > 0 && (
|
|
||||||
<div className="tag-filter-section">
|
|
||||||
<div className="tag-filter-chips">
|
|
||||||
{availableTags.map(tag => (
|
|
||||||
<button
|
|
||||||
key={tag}
|
|
||||||
type="button"
|
|
||||||
className={`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`}
|
|
||||||
onClick={() => handleTagToggle(tag)}
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{(searchQuery || selectedTags.length > 0) && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="clear-filters-btn"
|
|
||||||
onClick={clearFilters}
|
|
||||||
>
|
|
||||||
<X size={14} />
|
|
||||||
清除筛选
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="meeting-list">
|
|
||||||
{filteredMeetings.length === 0 ? (
|
|
||||||
<div className="empty-state">
|
|
||||||
<p>未找到匹配的会议</p>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
filteredMeetings.map(meeting => (
|
|
||||||
<div
|
|
||||||
key={meeting.meeting_id}
|
|
||||||
className={`meeting-item ${selectedMeetings.includes(meeting.meeting_id) ? 'selected' : ''}`}
|
|
||||||
onClick={() => toggleMeetingSelection(meeting.meeting_id)}
|
|
||||||
>
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
checked={selectedMeetings.includes(meeting.meeting_id)}
|
|
||||||
onChange={(e) => {
|
|
||||||
e.stopPropagation();
|
|
||||||
toggleMeetingSelection(meeting.meeting_id);
|
|
||||||
}}
|
|
||||||
onClick={(e) => e.stopPropagation()}
|
|
||||||
/>
|
|
||||||
<div className="meeting-item-content">
|
|
||||||
<div className="meeting-item-title">{meeting.title}</div>
|
|
||||||
{meeting.creator_username && (
|
|
||||||
<div className="meeting-item-creator">创建人: {meeting.creator_username}</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
{selectedMeetings.length > 0 && (
|
<div className="step-line"></div>
|
||||||
<div className="selected-meetings-info">
|
<div className={`step-item ${createStep === 2 ? 'active' : ''}`}>
|
||||||
已选择 {selectedMeetings.length} 个会议
|
<div className="step-number">2</div>
|
||||||
|
<div className="step-label">自定义提示词</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="modal-body">
|
||||||
|
{/* 步骤 1: 选择会议 */}
|
||||||
|
{createStep === 1 && (
|
||||||
|
<div className="form-step">
|
||||||
|
<div className="form-group">
|
||||||
|
<label>选择会议数据源</label>
|
||||||
|
|
||||||
|
{/* 紧凑的搜索和过滤区 */}
|
||||||
|
<div className="search-filter-area">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
placeholder="搜索会议名称或创建人..."
|
||||||
|
value={searchQuery}
|
||||||
|
onChange={(e) => setSearchQuery(e.target.value)}
|
||||||
|
className="compact-search-input"
|
||||||
|
/>
|
||||||
|
|
||||||
|
{availableTags.length > 0 && (
|
||||||
|
<div className="tag-filter-section">
|
||||||
|
<div className="tag-filter-chips">
|
||||||
|
{availableTags.map(tag => (
|
||||||
|
<button
|
||||||
|
key={tag}
|
||||||
|
type="button"
|
||||||
|
className={`tag-chip ${selectedTags.includes(tag) ? 'selected' : ''}`}
|
||||||
|
onClick={() => handleTagToggle(tag)}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{(searchQuery || selectedTags.length > 0) && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="clear-filters-btn"
|
||||||
|
onClick={clearFilters}
|
||||||
|
>
|
||||||
|
<X size={14} />
|
||||||
|
清除筛选
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="meeting-list">
|
||||||
|
{filteredMeetings.length === 0 ? (
|
||||||
|
<div className="empty-state">
|
||||||
|
<p>未找到匹配的会议</p>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
filteredMeetings.map(meeting => (
|
||||||
|
<div
|
||||||
|
key={meeting.meeting_id}
|
||||||
|
className={`meeting-item ${selectedMeetings.includes(meeting.meeting_id) ? 'selected' : ''}`}
|
||||||
|
onClick={() => toggleMeetingSelection(meeting.meeting_id)}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={selectedMeetings.includes(meeting.meeting_id)}
|
||||||
|
onChange={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
toggleMeetingSelection(meeting.meeting_id);
|
||||||
|
}}
|
||||||
|
onClick={(e) => e.stopPropagation()}
|
||||||
|
/>
|
||||||
|
<div className="meeting-item-content">
|
||||||
|
<div className="meeting-item-title">{meeting.title}</div>
|
||||||
|
{meeting.creator_username && (
|
||||||
|
<div className="meeting-item-creator">创建人: {meeting.creator_username}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedMeetings.length > 0 && (
|
||||||
|
<div className="selected-meetings-info">
|
||||||
|
已选择 {selectedMeetings.length} 个会议
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* 步骤 2: 输入提示词 */}
|
||||||
|
{createStep === 2 && (
|
||||||
|
<div className="form-step">
|
||||||
|
<div className="step-summary">
|
||||||
|
<div className="summary-item">
|
||||||
|
<span className="summary-label">已选择会议:</span>
|
||||||
|
<span className="summary-value">{selectedMeetings.length} 个</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="form-group">
|
||||||
|
<label>用户提示词(可选)</label>
|
||||||
|
<p className="field-hint">您可以添加额外的要求来定制知识库生成内容,例如重点关注某个主题、提取特定信息等。如不填写,系统将使用默认提示词。</p>
|
||||||
|
<textarea
|
||||||
|
placeholder="例如:请重点关注会议中的决策事项和待办任务..."
|
||||||
|
value={userPrompt}
|
||||||
|
onChange={(e) => setUserPrompt(e.target.value)}
|
||||||
|
className="kb-prompt-input"
|
||||||
|
rows={8}
|
||||||
|
autoFocus
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
<div className="form-group">
|
|
||||||
<label>用户提示词(可选)</label>
|
|
||||||
<textarea
|
|
||||||
placeholder="请输入您的提示词..."
|
|
||||||
value={userPrompt}
|
|
||||||
onChange={(e) => setUserPrompt(e.target.value)}
|
|
||||||
className="kb-prompt-input"
|
|
||||||
rows={4}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="modal-actions">
|
<div className="modal-actions">
|
||||||
<button className="btn-cancel" onClick={() => setShowCreateForm(false)}>取消</button>
|
{createStep === 1 ? (
|
||||||
<button
|
<>
|
||||||
className="btn-primary"
|
<button className="btn-cancel" onClick={() => {
|
||||||
onClick={handleGenerate}
|
setShowCreateForm(false);
|
||||||
disabled={generating || selectedMeetings.length === 0}
|
setCreateStep(1);
|
||||||
>
|
setSelectedMeetings([]);
|
||||||
{generating ? `生成中... ${progress}%` : '生成知识库'}
|
setUserPrompt('');
|
||||||
</button>
|
setSearchQuery('');
|
||||||
|
setSelectedTags([]);
|
||||||
|
}}>取消</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={() => {
|
||||||
|
if (selectedMeetings.length === 0) {
|
||||||
|
showToast('请至少选择一个会议', 'warning');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
setCreateStep(2);
|
||||||
|
}}
|
||||||
|
disabled={selectedMeetings.length === 0}
|
||||||
|
>
|
||||||
|
下一步
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<button className="btn-secondary" onClick={() => setCreateStep(1)}>上一步</button>
|
||||||
|
<button
|
||||||
|
className="btn-primary"
|
||||||
|
onClick={handleGenerate}
|
||||||
|
disabled={generating}
|
||||||
|
>
|
||||||
|
{generating ? `生成中... ${progress}%` : '生成知识库'}
|
||||||
|
</button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* 删除确认弹窗 */}
|
{/* 删除确认对话框 */}
|
||||||
{showDeleteConfirm && deletingKb && (
|
<ConfirmDialog
|
||||||
<div className="delete-modal-overlay" onClick={() => { setShowDeleteConfirm(false); setDeletingKb(null); }}>
|
isOpen={!!deleteConfirmInfo}
|
||||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
<h3>确认删除</h3>
|
onConfirm={confirmDelete}
|
||||||
<p>确定要删除知识库条目 "{deletingKb.title}" 吗?此操作无法撤销。</p>
|
title="删除知识库"
|
||||||
<div className="modal-actions">
|
message={`确定要删除知识库条目"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
||||||
<button className="btn-cancel" onClick={() => { setShowDeleteConfirm(false); setDeletingKb(null); }}>取消</button>
|
confirmText="确定删除"
|
||||||
<button className="btn-delete" onClick={confirmDelete}>确定删除</button>
|
cancelText="取消"
|
||||||
</div>
|
type="danger"
|
||||||
</div>
|
/>
|
||||||
</div>
|
|
||||||
)}
|
{/* Toast notifications */}
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,9 @@ import rehypeSanitize from 'rehype-sanitize';
|
||||||
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
|
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
|
||||||
import ContentViewer from '../components/ContentViewer';
|
import ContentViewer from '../components/ContentViewer';
|
||||||
import TagDisplay from '../components/TagDisplay';
|
import TagDisplay from '../components/TagDisplay';
|
||||||
|
import ConfirmDialog from '../components/ConfirmDialog';
|
||||||
|
import Toast from '../components/Toast';
|
||||||
|
import PageLoading from '../components/PageLoading';
|
||||||
import { Tabs } from 'antd';
|
import { Tabs } from 'antd';
|
||||||
import html2canvas from 'html2canvas';
|
import html2canvas from 'html2canvas';
|
||||||
import './MeetingDetails.css';
|
import './MeetingDetails.css';
|
||||||
|
|
@ -29,8 +32,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
const [showTranscript, setShowTranscript] = useState(true);
|
const [showTranscript, setShowTranscript] = useState(true);
|
||||||
const [audioUrl, setAudioUrl] = useState(null);
|
const [audioUrl, setAudioUrl] = useState(null);
|
||||||
const [audioFileName, setAudioFileName] = useState(null);
|
const [audioFileName, setAudioFileName] = useState(null);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const [showSummaryError, setShowSummaryError] = useState(false);
|
|
||||||
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
|
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
|
||||||
const [editingSpeakers, setEditingSpeakers] = useState({});
|
const [editingSpeakers, setEditingSpeakers] = useState({});
|
||||||
const [speakerList, setSpeakerList] = useState([]);
|
const [speakerList, setSpeakerList] = useState([]);
|
||||||
|
|
@ -54,9 +56,20 @@ const MeetingDetails = ({ user }) => {
|
||||||
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
|
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
|
||||||
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
|
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
|
||||||
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
const audioRef = useRef(null);
|
const audioRef = useRef(null);
|
||||||
const transcriptRefs = useRef([]);
|
const transcriptRefs = useRef([]);
|
||||||
|
|
||||||
|
// Toast 辅助函数
|
||||||
|
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(() => {
|
useEffect(() => {
|
||||||
fetchMeetingDetails();
|
fetchMeetingDetails();
|
||||||
|
|
||||||
|
|
@ -708,7 +721,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
const openSummaryModal = async () => {
|
const openSummaryModal = async () => {
|
||||||
// Frontend check before opening the modal
|
// Frontend check before opening the modal
|
||||||
if (!transcriptionStatus || transcriptionStatus.status !== 'completed') {
|
if (!transcriptionStatus || transcriptionStatus.status !== 'completed') {
|
||||||
setShowSummaryError(true);
|
showToast('会议转录尚未完成或处理失败,请在转录成功后再生成AI总结。', 'warning');
|
||||||
return; // Prevent modal from opening
|
return; // Prevent modal from opening
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -730,7 +743,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
const exportSummaryToImage = async () => {
|
const exportSummaryToImage = async () => {
|
||||||
try {
|
try {
|
||||||
if (!meeting?.summary) {
|
if (!meeting?.summary) {
|
||||||
alert('暂无会议总结内容,请先生成AI总结。');
|
showToast('暂无会议总结内容,请先生成AI总结。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -895,7 +908,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('图片导出失败:', error);
|
console.error('图片导出失败:', error);
|
||||||
alert('图片导出失败,请重试。');
|
showToast('图片导出失败,请重试。', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -903,14 +916,14 @@ const MeetingDetails = ({ user }) => {
|
||||||
const exportMindMapToImage = async () => {
|
const exportMindMapToImage = async () => {
|
||||||
try {
|
try {
|
||||||
if (!meeting?.summary) {
|
if (!meeting?.summary) {
|
||||||
alert('暂无内容,无法导出思维导图。');
|
showToast('暂无内容,无法导出思维导图。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 查找SVG元素
|
// 查找SVG元素
|
||||||
const svgElement = document.querySelector('.markmap-render-area svg');
|
const svgElement = document.querySelector('.markmap-render-area svg');
|
||||||
if (!svgElement) {
|
if (!svgElement) {
|
||||||
alert('未找到思维导图,请先切换到脑图标签页。');
|
showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -938,14 +951,14 @@ const MeetingDetails = ({ user }) => {
|
||||||
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('思维导图导出失败:', error);
|
console.error('思维导图导出失败:', error);
|
||||||
alert('思维导图导出失败,请重试。');
|
showToast('思维导图导出失败,请重试。', 'error');
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
|
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
|
||||||
|
|
||||||
if (loading) {
|
if (loading) {
|
||||||
return <div className="loading-container"><div className="loading-spinner"></div><p>加载中...</p></div>;
|
return <PageLoading message="加载中..." />;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error) {
|
if (error) {
|
||||||
|
|
@ -971,9 +984,9 @@ const MeetingDetails = ({ user }) => {
|
||||||
<Edit size={16} />
|
<Edit size={16} />
|
||||||
<span>编辑会议</span>
|
<span>编辑会议</span>
|
||||||
</Link>
|
</Link>
|
||||||
<button
|
<button
|
||||||
className="action-btn delete-btn"
|
className="action-btn delete-btn"
|
||||||
onClick={() => setShowDeleteConfirm(true)}
|
onClick={() => setDeleteConfirmInfo({ id: meeting_id, title: meeting.title })}
|
||||||
>
|
>
|
||||||
<Trash2 size={16} />
|
<Trash2 size={16} />
|
||||||
<span>删除会议</span>
|
<span>删除会议</span>
|
||||||
|
|
@ -1291,47 +1304,17 @@ const MeetingDetails = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Delete Confirmation Modal */}
|
{/* Delete Confirmation Dialog */}
|
||||||
{showDeleteConfirm && (
|
<ConfirmDialog
|
||||||
<div className="delete-modal-overlay" onClick={() => setShowDeleteConfirm(false)}>
|
isOpen={!!deleteConfirmInfo}
|
||||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
<h3>确认删除</h3>
|
onConfirm={handleDeleteMeeting}
|
||||||
<p>确定要删除会议 "{meeting.title}" 吗?此操作无法撤销。</p>
|
title="删除会议"
|
||||||
<div className="modal-actions">
|
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
||||||
<button
|
confirmText="确定删除"
|
||||||
className="btn-cancel"
|
cancelText="取消"
|
||||||
onClick={() => setShowDeleteConfirm(false)}
|
type="danger"
|
||||||
>
|
/>
|
||||||
取消
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
className="btn-delete"
|
|
||||||
onClick={handleDeleteMeeting}
|
|
||||||
>
|
|
||||||
确定删除
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Summary Error Modal */}
|
|
||||||
{showSummaryError && (
|
|
||||||
<div className="delete-modal-overlay" onClick={() => setShowSummaryError(false)}>
|
|
||||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
|
||||||
<h3>操作无法进行</h3>
|
|
||||||
<p>会议转录尚未完成或处理失败,请在转录成功后再生成AI总结。</p>
|
|
||||||
<div className="modal-actions">
|
|
||||||
<button
|
|
||||||
className="btn-cancel"
|
|
||||||
onClick={() => setShowSummaryError(false)}
|
|
||||||
>
|
|
||||||
知道了
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Speaker Tags Edit Modal */}
|
{/* Speaker Tags Edit Modal */}
|
||||||
{showSpeakerEdit && (
|
{showSpeakerEdit && (
|
||||||
|
|
@ -1347,9 +1330,7 @@ const MeetingDetails = ({ user }) => {
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="speaker-edit-content">
|
<div className="speaker-edit-content">
|
||||||
<p className="modal-description">根据AI识别的发言人ID,为每个发言人设置自定义标签:</p>
|
|
||||||
|
|
||||||
<div className="speaker-list">
|
<div className="speaker-list">
|
||||||
{speakerList.length > 0 ? (
|
{speakerList.length > 0 ? (
|
||||||
speakerList.map((speaker) => {
|
speakerList.map((speaker) => {
|
||||||
|
|
@ -1580,6 +1561,16 @@ const MeetingDetails = ({ user }) => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Toast notifications */}
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -7,13 +7,13 @@
|
||||||
.prompt-header {
|
.prompt-header {
|
||||||
background: white;
|
background: white;
|
||||||
border-bottom: 1px solid #e2e8f0;
|
border-bottom: 1px solid #e2e8f0;
|
||||||
padding: 1rem 2rem;
|
|
||||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||||
}
|
}
|
||||||
|
|
||||||
.prompt-header .header-content {
|
.prompt-header .header-content {
|
||||||
max-width: 1200px;
|
max-width: 1200px;
|
||||||
margin: 0 auto;
|
margin: 0 auto;
|
||||||
|
padding: 1rem 2rem;
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
gap: 2rem;
|
gap: 2rem;
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,7 @@ import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||||
import { Plus, MoreVertical, Edit, Trash2, BookText, Tag, FileText } from 'lucide-react';
|
import { Plus, MoreVertical, Edit, Trash2, BookText, Tag, FileText } from 'lucide-react';
|
||||||
import './PromptManagement.css';
|
import './PromptManagement.css';
|
||||||
import TagEditor from '../../components/TagEditor'; // Reusing the TagEditor component
|
import TagEditor from '../../components/TagEditor'; // Reusing the TagEditor component
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
|
||||||
const PromptManagement = () => {
|
const PromptManagement = () => {
|
||||||
const [prompts, setPrompts] = useState([]);
|
const [prompts, setPrompts] = useState([]);
|
||||||
|
|
@ -16,6 +17,7 @@ const PromptManagement = () => {
|
||||||
const [isEditing, setIsEditing] = useState(false);
|
const [isEditing, setIsEditing] = useState(false);
|
||||||
const [currentPrompt, setCurrentPrompt] = useState(null);
|
const [currentPrompt, setCurrentPrompt] = useState(null);
|
||||||
const [activeMenu, setActiveMenu] = useState(null); // For dropdown menu
|
const [activeMenu, setActiveMenu] = useState(null); // For dropdown menu
|
||||||
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const menuRef = useRef(null);
|
const menuRef = useRef(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -80,16 +82,22 @@ const PromptManagement = () => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (promptId) => {
|
const handleDelete = async (prompt) => {
|
||||||
setActiveMenu(null); // Close menu
|
setActiveMenu(null); // Close menu
|
||||||
if (window.confirm('您确定要删除这个提示词吗?')) {
|
setDeleteConfirmInfo({
|
||||||
try {
|
id: prompt.id,
|
||||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(promptId)));
|
name: prompt.name
|
||||||
fetchPrompts(); // Refresh list
|
});
|
||||||
} catch (err) {
|
};
|
||||||
setError(err.response?.data?.message || '删除失败');
|
|
||||||
}
|
const handleConfirmDelete = async () => {
|
||||||
|
try {
|
||||||
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(deleteConfirmInfo.id)));
|
||||||
|
fetchPrompts(); // Refresh list
|
||||||
|
} catch (err) {
|
||||||
|
setError(err.response?.data?.message || '删除失败');
|
||||||
}
|
}
|
||||||
|
setDeleteConfirmInfo(null);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleInputChange = (field, value) => {
|
const handleInputChange = (field, value) => {
|
||||||
|
|
@ -120,7 +128,7 @@ const PromptManagement = () => {
|
||||||
{activeMenu === prompt.id && (
|
{activeMenu === prompt.id && (
|
||||||
<div className="dropdown-menu">
|
<div className="dropdown-menu">
|
||||||
<button className="dropdown-item" onClick={() => handleOpenModal(prompt)}><Edit size={16}/> 编辑</button>
|
<button className="dropdown-item" onClick={() => handleOpenModal(prompt)}><Edit size={16}/> 编辑</button>
|
||||||
<button className="dropdown-item delete-item" onClick={() => handleDelete(prompt.id)}><Trash2 size={16}/> 删除</button>
|
<button className="dropdown-item delete-item" onClick={() => handleDelete(prompt)}><Trash2 size={16}/> 删除</button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -169,6 +177,18 @@ const PromptManagement = () => {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* 删除提示词确认对话框 */}
|
||||||
|
<ConfirmDialog
|
||||||
|
isOpen={!!deleteConfirmInfo}
|
||||||
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
|
onConfirm={handleConfirmDelete}
|
||||||
|
title="删除提示词"
|
||||||
|
message={`确定要删除提示词"${deleteConfirmInfo?.name}"吗?此操作无法撤销。`}
|
||||||
|
confirmText="删除"
|
||||||
|
cancelText="取消"
|
||||||
|
type="danger"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
||||||
import apiClient from '../../utils/apiClient';
|
import apiClient from '../../utils/apiClient';
|
||||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||||
import { Plus, Edit, Trash2, KeyRound } from 'lucide-react';
|
import { Plus, Edit, Trash2, KeyRound } from 'lucide-react';
|
||||||
|
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||||
|
import Toast from '../../components/Toast';
|
||||||
import './UserManagement.css';
|
import './UserManagement.css';
|
||||||
|
|
||||||
const UserManagement = () => {
|
const UserManagement = () => {
|
||||||
|
|
@ -13,12 +15,22 @@ const UserManagement = () => {
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
||||||
const [showEditUserModal, setShowEditUserModal] = useState(false);
|
const [showEditUserModal, setShowEditUserModal] = useState(false);
|
||||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
const [resetConfirmInfo, setResetConfirmInfo] = useState(null);
|
||||||
const [processingUser, setProcessingUser] = useState(null);
|
|
||||||
const [newUser, setNewUser] = useState({ username: '', caption: '', email: '', role_id: 2 });
|
const [newUser, setNewUser] = useState({ username: '', caption: '', email: '', role_id: 2 });
|
||||||
const [editingUser, setEditingUser] = useState(null);
|
const [editingUser, setEditingUser] = useState(null);
|
||||||
const [roles, setRoles] = useState([]);
|
const [roles, setRoles] = useState([]);
|
||||||
|
const [toasts, setToasts] = useState([]);
|
||||||
|
|
||||||
|
// 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(() => {
|
useEffect(() => {
|
||||||
fetchUsers();
|
fetchUsers();
|
||||||
|
|
@ -96,20 +108,26 @@ const UserManagement = () => {
|
||||||
|
|
||||||
const handleDeleteUser = async () => {
|
const handleDeleteUser = async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.USERS.DELETE(processingUser.user_id)));
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.USERS.DELETE(deleteConfirmInfo.user_id)));
|
||||||
setShowDeleteConfirm(false);
|
setDeleteConfirmInfo(null);
|
||||||
|
showToast('用户删除成功', 'success');
|
||||||
fetchUsers(); // Refresh user list
|
fetchUsers(); // Refresh user list
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error deleting user:', err);
|
console.error('Error deleting user:', err);
|
||||||
|
showToast('删除用户失败,请重试', 'error');
|
||||||
|
setDeleteConfirmInfo(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleResetPassword = async () => {
|
const handleResetPassword = async () => {
|
||||||
try {
|
try {
|
||||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.RESET_PASSWORD(processingUser.user_id)));
|
await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.RESET_PASSWORD(resetConfirmInfo.user_id)));
|
||||||
setShowResetConfirm(false);
|
setResetConfirmInfo(null);
|
||||||
|
showToast('密码重置成功', 'success');
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Error resetting password:', err);
|
console.error('Error resetting password:', err);
|
||||||
|
showToast('重置密码失败,请重试', 'error');
|
||||||
|
setResetConfirmInfo(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -119,13 +137,11 @@ const UserManagement = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const openDeleteConfirm = (user) => {
|
const openDeleteConfirm = (user) => {
|
||||||
setProcessingUser(user);
|
setDeleteConfirmInfo({ user_id: user.user_id, caption: user.caption });
|
||||||
setShowDeleteConfirm(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const openResetConfirm = (user) => {
|
const openResetConfirm = (user) => {
|
||||||
setProcessingUser(user);
|
setResetConfirmInfo({ user_id: user.user_id, caption: user.caption });
|
||||||
setShowResetConfirm(true);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -252,31 +268,39 @@ const UserManagement = () => {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{showDeleteConfirm && processingUser && (
|
{/* 删除用户确认对话框 */}
|
||||||
<div className="modal-overlay">
|
<ConfirmDialog
|
||||||
<div className="modal-content">
|
isOpen={!!deleteConfirmInfo}
|
||||||
<h2>确认删除</h2>
|
onClose={() => setDeleteConfirmInfo(null)}
|
||||||
<p>您确定要删除用户 <strong>{processingUser.caption}</strong> 吗?此操作无法撤销。</p>
|
onConfirm={handleDeleteUser}
|
||||||
<div className="modal-actions">
|
title="删除用户"
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteConfirm(false)}>取消</button>
|
message={`确定要删除用户"${deleteConfirmInfo?.caption}"吗?此操作无法撤销。`}
|
||||||
<button type="button" className="btn btn-danger" onClick={handleDeleteUser}>确认删除</button>
|
confirmText="确定删除"
|
||||||
</div>
|
cancelText="取消"
|
||||||
</div>
|
type="danger"
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
{showResetConfirm && processingUser && (
|
{/* 重置密码确认对话框 */}
|
||||||
<div className="modal-overlay">
|
<ConfirmDialog
|
||||||
<div className="modal-content">
|
isOpen={!!resetConfirmInfo}
|
||||||
<h2>确认重置密码</h2>
|
onClose={() => setResetConfirmInfo(null)}
|
||||||
<p>您确定要重置用户 <strong>{processingUser.caption}</strong> 的密码吗?</p>
|
onConfirm={handleResetPassword}
|
||||||
<div className="modal-actions">
|
title="重置密码"
|
||||||
<button type="button" className="btn btn-secondary" onClick={() => setShowResetConfirm(false)}>取消</button>
|
message={`确定要重置用户"${resetConfirmInfo?.caption}"的密码吗?重置后密码将恢复为系统默认密码。`}
|
||||||
<button type="button" className="btn btn-warning" onClick={handleResetPassword}>确认重置</button>
|
confirmText="确定重置"
|
||||||
</div>
|
cancelText="取消"
|
||||||
</div>
|
type="warning"
|
||||||
</div>
|
/>
|
||||||
)}
|
|
||||||
|
{/* Toast notifications */}
|
||||||
|
{toasts.map(toast => (
|
||||||
|
<Toast
|
||||||
|
key={toast.id}
|
||||||
|
message={toast.message}
|
||||||
|
type={toast.type}
|
||||||
|
onClose={() => removeToast(toast.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue