1.0.3
parent
46358a623b
commit
cdfcceaf47
|
|
@ -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,
|
||||
isCreator,
|
||||
onOpenSummaryModal,
|
||||
formatDateTime
|
||||
formatDateTime,
|
||||
showToast
|
||||
}) => {
|
||||
const exportRef = useRef(null);
|
||||
|
||||
|
|
@ -24,12 +25,12 @@ const MeetingSummary = ({
|
|||
(summaryHistory.length > 0 ? summaryHistory[0].content : null);
|
||||
|
||||
if (!summaryContent) {
|
||||
alert('暂无会议总结内容,请先生成AI总结。');
|
||||
showToast?.('暂无会议总结内容,请先生成AI总结。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!exportRef.current) {
|
||||
alert('内容尚未渲染完成,请稍后重试。');
|
||||
showToast?.('内容尚未渲染完成,请稍后重试。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -195,7 +196,7 @@ const MeetingSummary = ({
|
|||
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
alert('图片导出失败,请重试。');
|
||||
showToast?.('图片导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -83,6 +83,7 @@
|
|||
/* Meeting Cards */
|
||||
.meeting-card-wrapper {
|
||||
position: relative;
|
||||
padding: 0 5px;
|
||||
}
|
||||
|
||||
.meeting-card {
|
||||
|
|
|
|||
|
|
@ -6,10 +6,11 @@ import remarkGfm from 'remark-gfm';
|
|||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import TagDisplay from './TagDisplay';
|
||||
import ConfirmDialog from './ConfirmDialog';
|
||||
import './MeetingTimeline.css';
|
||||
|
||||
const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(null);
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [showDropdown, setShowDropdown] = useState(null);
|
||||
const navigate = useNavigate();
|
||||
// Close dropdown when clicking outside
|
||||
|
|
@ -72,17 +73,20 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
|||
setShowDropdown(null);
|
||||
};
|
||||
|
||||
const handleDeleteClick = (meetingId, e) => {
|
||||
const handleDeleteClick = (meeting, e) => {
|
||||
e.preventDefault();
|
||||
setShowDeleteConfirm(meetingId);
|
||||
setDeleteConfirmInfo({
|
||||
id: meeting.meeting_id,
|
||||
title: meeting.title
|
||||
});
|
||||
setShowDropdown(null);
|
||||
};
|
||||
|
||||
const handleConfirmDelete = async (meetingId) => {
|
||||
if (onDeleteMeeting) {
|
||||
await onDeleteMeeting(meetingId);
|
||||
const handleConfirmDelete = async () => {
|
||||
if (onDeleteMeeting && deleteConfirmInfo) {
|
||||
await onDeleteMeeting(deleteConfirmInfo.id);
|
||||
}
|
||||
setShowDeleteConfirm(null);
|
||||
setDeleteConfirmInfo(null);
|
||||
};
|
||||
|
||||
const toggleDropdown = (meetingId, e) => {
|
||||
|
|
@ -153,9 +157,9 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
|||
<Edit size={16} />
|
||||
编辑
|
||||
</button>
|
||||
<button
|
||||
<button
|
||||
className="dropdown-item delete-item"
|
||||
onClick={(e) => handleDeleteClick(meeting.meeting_id, e)}
|
||||
onClick={(e) => handleDeleteClick(meeting, e)}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
删除
|
||||
|
|
@ -225,36 +229,24 @@ const MeetingTimeline = ({ meetingsByDate, currentUser, onDeleteMeeting }) => {
|
|||
</div>
|
||||
</div>
|
||||
</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>
|
||||
))}
|
||||
|
||||
{/* 删除会议确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="删除会议"
|
||||
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
||||
confirmText="删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
</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',
|
||||
UPDATE: (id) => `/api/clients/downloads/${id}`,
|
||||
DELETE: (id) => `/api/clients/downloads/${id}`
|
||||
},
|
||||
VOICEPRINT: {
|
||||
STATUS: (userId) => `/api/voiceprint/${userId}`,
|
||||
TEMPLATE: '/api/voiceprint/template',
|
||||
UPLOAD: (userId) => `/api/voiceprint/${userId}`,
|
||||
DELETE: (userId) => `/api/voiceprint/${userId}`
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 1.5rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.download-page-header .header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
}
|
||||
|
||||
.download-page-header .logo {
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
} from 'lucide-react';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import Toast from '../components/Toast';
|
||||
import './ClientManagement.css';
|
||||
|
||||
const ClientManagement = ({ user }) => {
|
||||
|
|
@ -21,11 +23,12 @@ const ClientManagement = ({ user }) => {
|
|||
const [loading, setLoading] = useState(true);
|
||||
const [showCreateModal, setShowCreateModal] = useState(false);
|
||||
const [showEditModal, setShowEditModal] = useState(false);
|
||||
const [showDeleteModal, setShowDeleteModal] = useState(false);
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [selectedClient, setSelectedClient] = useState(null);
|
||||
const [filterPlatformType, setFilterPlatformType] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [expandedNotes, setExpandedNotes] = useState({});
|
||||
const [toasts, setToasts] = useState([]);
|
||||
|
||||
const [formData, setFormData] = useState({
|
||||
platform_type: 'mobile',
|
||||
|
|
@ -53,6 +56,16 @@ const ClientManagement = ({ user }) => {
|
|||
]
|
||||
};
|
||||
|
||||
// Toast helper functions
|
||||
const showToast = (message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
};
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchClients();
|
||||
}, []);
|
||||
|
|
@ -74,7 +87,7 @@ const ClientManagement = ({ user }) => {
|
|||
try {
|
||||
// 验证必填字段
|
||||
if (!formData.version_code || !formData.version || !formData.download_url) {
|
||||
alert('请填写所有必填字段');
|
||||
showToast('请填写所有必填字段', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -86,12 +99,12 @@ const ClientManagement = ({ user }) => {
|
|||
|
||||
// 验证转换后的数字
|
||||
if (isNaN(payload.version_code)) {
|
||||
alert('版本代码必须是有效的数字');
|
||||
showToast('版本代码必须是有效的数字', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.file_size && isNaN(payload.file_size)) {
|
||||
alert('文件大小必须是有效的数字');
|
||||
showToast('文件大小必须是有效的数字', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -101,7 +114,7 @@ const ClientManagement = ({ user }) => {
|
|||
fetchClients();
|
||||
} catch (error) {
|
||||
console.error('创建客户端失败:', error);
|
||||
alert(error.response?.data?.message || '创建失败,请重试');
|
||||
showToast(error.response?.data?.message || '创建失败,请重试', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -109,7 +122,7 @@ const ClientManagement = ({ user }) => {
|
|||
try {
|
||||
// 验证必填字段
|
||||
if (!formData.version_code || !formData.version || !formData.download_url) {
|
||||
alert('请填写所有必填字段');
|
||||
showToast('请填写所有必填字段', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -126,12 +139,12 @@ const ClientManagement = ({ user }) => {
|
|||
|
||||
// 验证转换后的数字
|
||||
if (isNaN(payload.version_code)) {
|
||||
alert('版本代码必须是有效的数字');
|
||||
showToast('版本代码必须是有效的数字', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
if (formData.file_size && isNaN(payload.file_size)) {
|
||||
alert('文件大小必须是有效的数字');
|
||||
showToast('文件大小必须是有效的数字', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -144,19 +157,21 @@ const ClientManagement = ({ user }) => {
|
|||
fetchClients();
|
||||
} catch (error) {
|
||||
console.error('更新客户端失败:', error);
|
||||
alert(error.response?.data?.message || '更新失败,请重试');
|
||||
showToast(error.response?.data?.message || '更新失败,请重试', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDelete = async () => {
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(selectedClient.id)));
|
||||
setShowDeleteModal(false);
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.CLIENT_DOWNLOADS.DELETE(deleteConfirmInfo.id)));
|
||||
setDeleteConfirmInfo(null);
|
||||
setSelectedClient(null);
|
||||
showToast('删除成功', 'success');
|
||||
fetchClients();
|
||||
} catch (error) {
|
||||
console.error('删除客户端失败:', error);
|
||||
alert('删除失败,请重试');
|
||||
showToast('删除失败,请重试', 'error');
|
||||
setDeleteConfirmInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -178,8 +193,11 @@ const ClientManagement = ({ user }) => {
|
|||
};
|
||||
|
||||
const openDeleteModal = (client) => {
|
||||
setSelectedClient(client);
|
||||
setShowDeleteModal(true);
|
||||
setDeleteConfirmInfo({
|
||||
id: client.id,
|
||||
platform_name: getPlatformLabel(client.platform_name),
|
||||
version: client.version
|
||||
});
|
||||
};
|
||||
|
||||
const resetForm = () => {
|
||||
|
|
@ -572,26 +590,27 @@ const ClientManagement = ({ user }) => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除确认模态框 */}
|
||||
{showDeleteModal && selectedClient && (
|
||||
<div className="modal-overlay" onClick={() => setShowDeleteModal(false)}>
|
||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除</h3>
|
||||
<p>
|
||||
确定要删除 <strong>{getPlatformLabel(selectedClient.platform_name)}</strong> 版本{' '}
|
||||
<strong>{selectedClient.version}</strong> 吗?此操作无法撤销。
|
||||
</p>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-cancel" onClick={() => setShowDeleteModal(false)}>
|
||||
取消
|
||||
</button>
|
||||
<button className="btn-delete" onClick={handleDelete}>
|
||||
确定删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 删除确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
onConfirm={handleDelete}
|
||||
title="删除客户端"
|
||||
message={`确定要删除 ${deleteConfirmInfo?.platform_name} 版本 ${deleteConfirmInfo?.version} 吗?此操作无法撤销。`}
|
||||
confirmText="确定删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
|
||||
{/* Toast notifications */}
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -162,8 +162,15 @@
|
|||
flex: 1;
|
||||
}
|
||||
|
||||
.user-name-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-bottom: 0.15rem;
|
||||
}
|
||||
|
||||
.user-details h2 {
|
||||
margin: 0 0 0.15rem 0;
|
||||
margin: 0;
|
||||
color: #1e293b;
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
|
|
@ -747,3 +754,54 @@
|
|||
opacity: 1;
|
||||
transform: translateX(4px);
|
||||
}
|
||||
|
||||
/* Voiceprint Badge - 紧凑徽章样式 */
|
||||
.voiceprint-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 0.35rem;
|
||||
padding: 0.25rem 0.6rem;
|
||||
border-radius: 12px;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.voiceprint-badge.uncollected {
|
||||
background: linear-gradient(135deg, #f59e0b 0%, #d97706 100%);
|
||||
color: white;
|
||||
box-shadow: 0 1px 3px rgba(245, 158, 11, 0.3);
|
||||
}
|
||||
|
||||
.voiceprint-badge.uncollected:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(245, 158, 11, 0.4);
|
||||
}
|
||||
|
||||
.voiceprint-badge.collected {
|
||||
background: linear-gradient(135deg, #10b981 0%, #059669 100%);
|
||||
color: white;
|
||||
box-shadow: 0 1px 3px rgba(16, 185, 129, 0.3);
|
||||
}
|
||||
|
||||
.voiceprint-badge.collected:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 6px rgba(16, 185, 129, 0.4);
|
||||
background: linear-gradient(135deg, #059669 0%, #047857 100%);
|
||||
}
|
||||
|
||||
.voiceprint-badge .voiceprint-icon {
|
||||
animation: wave 2s ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes wave {
|
||||
0%, 100% {
|
||||
transform: scaleX(1);
|
||||
}
|
||||
50% {
|
||||
transform: scaleX(1.1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,10 +1,13 @@
|
|||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText } from 'lucide-react';
|
||||
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText, Waves } from 'lucide-react';
|
||||
import apiClient from '../utils/apiClient';
|
||||
import { Link } from 'react-router-dom';
|
||||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||
import MeetingTimeline from '../components/MeetingTimeline';
|
||||
import TagCloud from '../components/TagCloud';
|
||||
import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import PageLoading from '../components/PageLoading';
|
||||
import './Dashboard.css';
|
||||
|
||||
const Dashboard = ({ user, onLogout }) => {
|
||||
|
|
@ -24,10 +27,38 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
const [passwordChangeSuccess, setPasswordChangeSuccess] = useState('');
|
||||
const dropdownRef = useRef(null);
|
||||
|
||||
// 声纹相关状态
|
||||
const [voiceprintStatus, setVoiceprintStatus] = useState(null);
|
||||
const [showVoiceprintModal, setShowVoiceprintModal] = useState(false);
|
||||
const [voiceprintTemplate, setVoiceprintTemplate] = useState(null);
|
||||
const [voiceprintLoading, setVoiceprintLoading] = useState(true);
|
||||
const [showDeleteVoiceprintDialog, setShowDeleteVoiceprintDialog] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchUserData();
|
||||
fetchVoiceprintData();
|
||||
}, [user.user_id]);
|
||||
|
||||
const fetchVoiceprintData = async () => {
|
||||
try {
|
||||
setVoiceprintLoading(true);
|
||||
|
||||
// 获取声纹状态
|
||||
const statusResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.STATUS(user.user_id)));
|
||||
console.log('声纹状态响应:', statusResponse);
|
||||
setVoiceprintStatus(statusResponse.data);
|
||||
|
||||
// 获取朗读模板
|
||||
const templateResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.VOICEPRINT.TEMPLATE));
|
||||
console.log('朗读模板响应:', templateResponse);
|
||||
setVoiceprintTemplate(templateResponse.data);
|
||||
} catch (err) {
|
||||
console.error('获取声纹数据失败:', err);
|
||||
} finally {
|
||||
setVoiceprintLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 过滤会议
|
||||
useEffect(() => {
|
||||
filterMeetings();
|
||||
|
|
@ -151,6 +182,35 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleVoiceprintUpload = async (formData) => {
|
||||
try {
|
||||
await apiClient.post(
|
||||
buildApiUrl(API_ENDPOINTS.VOICEPRINT.UPLOAD(user.user_id)),
|
||||
formData,
|
||||
{
|
||||
headers: {
|
||||
'Content-Type': 'multipart/form-data',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
// 上传成功后刷新声纹状态并关闭模态框
|
||||
await fetchVoiceprintData();
|
||||
setShowVoiceprintModal(false);
|
||||
} catch (err) {
|
||||
throw new Error(err.response?.data?.message || '声纹上传失败');
|
||||
}
|
||||
};
|
||||
|
||||
const handleDeleteVoiceprint = async () => {
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.VOICEPRINT.DELETE(user.user_id)));
|
||||
await fetchVoiceprintData();
|
||||
} catch (err) {
|
||||
console.error('删除声纹失败:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const groupMeetingsByDate = (meetingsToGroup) => {
|
||||
return meetingsToGroup.reduce((acc, meeting) => {
|
||||
const date = new Date(meeting.meeting_time || meeting.created_at).toISOString().split('T')[0];
|
||||
|
|
@ -172,14 +232,7 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
};
|
||||
|
||||
if (loading || !meetings) {
|
||||
return (
|
||||
<div className="dashboard">
|
||||
<div className="loading-container">
|
||||
<div className="loading-spinner"></div>
|
||||
<p>加载中...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
return <PageLoading message="加载中..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -240,7 +293,33 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
<User size={24} />
|
||||
</div>
|
||||
<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="join-date">加入时间:{formatDate(userInfo?.created_at)}</p>
|
||||
</div>
|
||||
|
|
@ -369,6 +448,26 @@ const Dashboard = ({ user, onLogout }) => {
|
|||
</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>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -6,6 +6,31 @@
|
|||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* 加载和错误状态 */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 50vh;
|
||||
color: #64748b;
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid #e2e8f0;
|
||||
border-top: 3px solid #667eea;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
0% { transform: rotate(0deg); }
|
||||
100% { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
/* 顶部Header */
|
||||
.kb-header {
|
||||
background: white;
|
||||
|
|
@ -888,3 +913,146 @@
|
|||
.meeting-list::-webkit-scrollbar-thumb:hover {
|
||||
background: #94a3b8;
|
||||
}
|
||||
|
||||
/* 步骤指示器 - 紧凑版 */
|
||||
.step-indicator {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0.75rem 2rem;
|
||||
background: #f8fafc;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
}
|
||||
|
||||
.step-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.step-number {
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 50%;
|
||||
background: #e2e8f0;
|
||||
color: #64748b;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-weight: 600;
|
||||
font-size: 0.75rem;
|
||||
transition: all 0.3s ease;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-item.active .step-number {
|
||||
background: #667eea;
|
||||
color: white;
|
||||
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
|
||||
}
|
||||
|
||||
.step-item.completed .step-number {
|
||||
background: #10b981;
|
||||
color: white;
|
||||
}
|
||||
|
||||
.step-item.completed .step-number::before {
|
||||
content: "✓";
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.step-label {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
font-weight: 500;
|
||||
transition: color 0.3s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-item.active .step-label {
|
||||
color: #667eea;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-item.completed .step-label {
|
||||
color: #10b981;
|
||||
}
|
||||
|
||||
.step-line {
|
||||
width: 40px;
|
||||
height: 2px;
|
||||
background: #e2e8f0;
|
||||
margin: 0 0.75rem;
|
||||
}
|
||||
|
||||
/* 步骤内容 */
|
||||
.form-step {
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* 步骤2摘要信息 - 紧凑版 */
|
||||
.step-summary {
|
||||
background: #f0fdf4;
|
||||
border: 1px solid #bbf7d0;
|
||||
border-radius: 6px;
|
||||
padding: 0.5rem 0.75rem;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.summary-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.summary-label {
|
||||
font-weight: 500;
|
||||
color: #166534;
|
||||
}
|
||||
|
||||
.summary-value {
|
||||
font-weight: 600;
|
||||
color: #15803d;
|
||||
}
|
||||
|
||||
/* 字段提示文字 - 紧凑版 */
|
||||
.field-hint {
|
||||
font-size: 0.8rem;
|
||||
color: #64748b;
|
||||
margin-bottom: 0.5rem;
|
||||
line-height: 1.4;
|
||||
background: #f8fafc;
|
||||
padding: 0.5rem;
|
||||
border-radius: 4px;
|
||||
border-left: 2px solid #667eea;
|
||||
}
|
||||
|
||||
/* 二级按钮 */
|
||||
.btn-secondary {
|
||||
padding: 0.75rem 1.5rem;
|
||||
border-radius: 8px;
|
||||
border: 1px solid #e2e8f0;
|
||||
background: white;
|
||||
color: #475569;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-secondary:hover {
|
||||
background: #f8fafc;
|
||||
border-color: #cbd5e1;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,10 +5,13 @@ import apiClient from '../utils/apiClient';
|
|||
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
||||
import ContentViewer from '../components/ContentViewer';
|
||||
import TagDisplay from '../components/TagDisplay';
|
||||
import Toast from '../components/Toast';
|
||||
import ConfirmDialog from '../components/ConfirmDialog';
|
||||
import remarkGfm from 'remark-gfm';
|
||||
import rehypeRaw from 'rehype-raw';
|
||||
import rehypeSanitize from 'rehype-sanitize';
|
||||
import html2canvas from 'html2canvas';
|
||||
import PageLoading from '../components/PageLoading';
|
||||
import './KnowledgeBasePage.css';
|
||||
|
||||
const KnowledgeBasePage = ({ user }) => {
|
||||
|
|
@ -27,8 +30,19 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
const [generating, setGenerating] = useState(false);
|
||||
const [taskId, setTaskId] = useState(null);
|
||||
const [progress, setProgress] = useState(0);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [deletingKb, setDeletingKb] = useState(null);
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const [createStep, setCreateStep] = useState(1); // 1: 选择会议, 2: 输入提示词
|
||||
|
||||
// Toast helper functions
|
||||
const showToast = (message, type = 'info') => {
|
||||
const id = Date.now();
|
||||
setToasts(prev => [...prev, { id, message, type }]);
|
||||
};
|
||||
|
||||
const removeToast = (id) => {
|
||||
setToasts(prev => prev.filter(toast => toast.id !== id));
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
fetchAllKbs();
|
||||
|
|
@ -50,13 +64,16 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
setUserPrompt('');
|
||||
setSelectedMeetings([]);
|
||||
setShowCreateForm(false);
|
||||
setCreateStep(1); // 重置步骤
|
||||
setSearchQuery('');
|
||||
setSelectedTags([]);
|
||||
fetchAllKbs();
|
||||
} else if (status === 'failed') {
|
||||
clearInterval(interval);
|
||||
setTaskId(null);
|
||||
setGenerating(false);
|
||||
setProgress(0);
|
||||
alert('知识库生成失败,请稍后重试');
|
||||
showToast('知识库生成失败,请稍后重试', 'error');
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
|
|
@ -140,7 +157,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
|
||||
const handleGenerate = async () => {
|
||||
if (!selectedMeetings || selectedMeetings.length === 0) {
|
||||
alert('请至少选择一个会议');
|
||||
showToast('请至少选择一个会议', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -210,25 +227,22 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
};
|
||||
|
||||
const handleDelete = async (kb) => {
|
||||
setDeletingKb(kb);
|
||||
setShowDeleteConfirm(true);
|
||||
setDeleteConfirmInfo({ kb_id: kb.kb_id, title: kb.title });
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deletingKb.kb_id)));
|
||||
setShowDeleteConfirm(false);
|
||||
setDeletingKb(null);
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.KNOWLEDGE_BASE.DELETE(deleteConfirmInfo.kb_id)));
|
||||
// 如果删除的是当前选中的,清除选中
|
||||
if (selectedKb && selectedKb.kb_id === deletingKb.kb_id) {
|
||||
if (selectedKb && selectedKb.kb_id === deleteConfirmInfo.kb_id) {
|
||||
setSelectedKb(null);
|
||||
}
|
||||
setDeleteConfirmInfo(null);
|
||||
fetchAllKbs();
|
||||
} catch (error) {
|
||||
console.error("Error deleting knowledge base:", error);
|
||||
alert('删除失败,请稍后重试');
|
||||
setShowDeleteConfirm(false);
|
||||
setDeletingKb(null);
|
||||
showToast('删除失败,请稍后重试', 'error');
|
||||
setDeleteConfirmInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -290,7 +304,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
const exportSummaryToImage = async () => {
|
||||
try {
|
||||
if (!selectedKb?.content) {
|
||||
alert('暂无知识库内容,请稍后再试。');
|
||||
showToast('暂无知识库内容,请稍后再试。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -453,7 +467,7 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
alert('图片导出失败,请重试。');
|
||||
showToast('图片导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -461,14 +475,14 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
const exportMindMapToImage = async () => {
|
||||
try {
|
||||
if (!selectedKb?.content) {
|
||||
alert('暂无内容,无法导出思维导图。');
|
||||
showToast('暂无内容,无法导出思维导图。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找SVG元素
|
||||
const svgElement = document.querySelector('.markmap-render-area svg');
|
||||
if (!svgElement) {
|
||||
alert('未找到思维导图,请先切换到脑图标签页。');
|
||||
showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -496,14 +510,14 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
|
||||
} catch (error) {
|
||||
console.error('思维导图导出失败:', error);
|
||||
alert('思维导图导出失败,请重试。');
|
||||
showToast('思维导图导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const isCreator = selectedKb && user && String(selectedKb.creator_id) === String(user.user_id);
|
||||
|
||||
if (loading) {
|
||||
return <div>Loading...</div>;
|
||||
return <PageLoading message="加载中..." />;
|
||||
}
|
||||
|
||||
return (
|
||||
|
|
@ -771,126 +785,203 @@ const KnowledgeBasePage = ({ user }) => {
|
|||
<div className="modal-content create-kb-modal">
|
||||
<div className="modal-header">
|
||||
<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 className="modal-body">
|
||||
<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 className="step-indicator">
|
||||
<div className={`step-item ${createStep === 1 ? 'active' : ''} ${createStep > 1 ? 'completed' : ''}`}>
|
||||
<div className="step-number">1</div>
|
||||
<div className="step-label">选择会议</div>
|
||||
</div>
|
||||
{selectedMeetings.length > 0 && (
|
||||
<div className="selected-meetings-info">
|
||||
已选择 {selectedMeetings.length} 个会议
|
||||
<div className="step-line"></div>
|
||||
<div className={`step-item ${createStep === 2 ? 'active' : ''}`}>
|
||||
<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 className="form-group">
|
||||
<label>用户提示词(可选)</label>
|
||||
<textarea
|
||||
placeholder="请输入您的提示词..."
|
||||
value={userPrompt}
|
||||
onChange={(e) => setUserPrompt(e.target.value)}
|
||||
className="kb-prompt-input"
|
||||
rows={4}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="modal-actions">
|
||||
<button className="btn-cancel" onClick={() => setShowCreateForm(false)}>取消</button>
|
||||
<button
|
||||
className="btn-primary"
|
||||
onClick={handleGenerate}
|
||||
disabled={generating || selectedMeetings.length === 0}
|
||||
>
|
||||
{generating ? `生成中... ${progress}%` : '生成知识库'}
|
||||
</button>
|
||||
{createStep === 1 ? (
|
||||
<>
|
||||
<button className="btn-cancel" onClick={() => {
|
||||
setShowCreateForm(false);
|
||||
setCreateStep(1);
|
||||
setSelectedMeetings([]);
|
||||
setUserPrompt('');
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 删除确认弹窗 */}
|
||||
{showDeleteConfirm && deletingKb && (
|
||||
<div className="delete-modal-overlay" onClick={() => { setShowDeleteConfirm(false); setDeletingKb(null); }}>
|
||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除</h3>
|
||||
<p>确定要删除知识库条目 "{deletingKb.title}" 吗?此操作无法撤销。</p>
|
||||
<div className="modal-actions">
|
||||
<button className="btn-cancel" onClick={() => { setShowDeleteConfirm(false); setDeletingKb(null); }}>取消</button>
|
||||
<button className="btn-delete" onClick={confirmDelete}>确定删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 删除确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
onConfirm={confirmDelete}
|
||||
title="删除知识库"
|
||||
message={`确定要删除知识库条目"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
||||
confirmText="确定删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
|
||||
{/* Toast notifications */}
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -9,6 +9,9 @@ import rehypeSanitize from 'rehype-sanitize';
|
|||
import { buildApiUrl, API_ENDPOINTS, API_BASE_URL } from '../config/api';
|
||||
import ContentViewer from '../components/ContentViewer';
|
||||
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 html2canvas from 'html2canvas';
|
||||
import './MeetingDetails.css';
|
||||
|
|
@ -29,8 +32,7 @@ const MeetingDetails = ({ user }) => {
|
|||
const [showTranscript, setShowTranscript] = useState(true);
|
||||
const [audioUrl, setAudioUrl] = useState(null);
|
||||
const [audioFileName, setAudioFileName] = useState(null);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showSummaryError, setShowSummaryError] = useState(false);
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [showSpeakerEdit, setShowSpeakerEdit] = useState(false);
|
||||
const [editingSpeakers, setEditingSpeakers] = useState({});
|
||||
const [speakerList, setSpeakerList] = useState([]);
|
||||
|
|
@ -54,9 +56,20 @@ const MeetingDetails = ({ user }) => {
|
|||
const [summaryTaskProgress, setSummaryTaskProgress] = useState(0);
|
||||
const [summaryTaskMessage, setSummaryTaskMessage] = useState('');
|
||||
const [summaryPollInterval, setSummaryPollInterval] = useState(null);
|
||||
const [toasts, setToasts] = useState([]);
|
||||
const audioRef = useRef(null);
|
||||
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(() => {
|
||||
fetchMeetingDetails();
|
||||
|
||||
|
|
@ -708,7 +721,7 @@ const MeetingDetails = ({ user }) => {
|
|||
const openSummaryModal = async () => {
|
||||
// Frontend check before opening the modal
|
||||
if (!transcriptionStatus || transcriptionStatus.status !== 'completed') {
|
||||
setShowSummaryError(true);
|
||||
showToast('会议转录尚未完成或处理失败,请在转录成功后再生成AI总结。', 'warning');
|
||||
return; // Prevent modal from opening
|
||||
}
|
||||
|
||||
|
|
@ -730,7 +743,7 @@ const MeetingDetails = ({ user }) => {
|
|||
const exportSummaryToImage = async () => {
|
||||
try {
|
||||
if (!meeting?.summary) {
|
||||
alert('暂无会议总结内容,请先生成AI总结。');
|
||||
showToast('暂无会议总结内容,请先生成AI总结。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -895,7 +908,7 @@ const MeetingDetails = ({ user }) => {
|
|||
|
||||
} catch (error) {
|
||||
console.error('图片导出失败:', error);
|
||||
alert('图片导出失败,请重试。');
|
||||
showToast('图片导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -903,14 +916,14 @@ const MeetingDetails = ({ user }) => {
|
|||
const exportMindMapToImage = async () => {
|
||||
try {
|
||||
if (!meeting?.summary) {
|
||||
alert('暂无内容,无法导出思维导图。');
|
||||
showToast('暂无内容,无法导出思维导图。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
// 查找SVG元素
|
||||
const svgElement = document.querySelector('.markmap-render-area svg');
|
||||
if (!svgElement) {
|
||||
alert('未找到思维导图,请先切换到脑图标签页。');
|
||||
showToast('未找到思维导图,请先切换到脑图标签页。', 'warning');
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -938,14 +951,14 @@ const MeetingDetails = ({ user }) => {
|
|||
|
||||
} catch (error) {
|
||||
console.error('思维导图导出失败:', error);
|
||||
alert('思维导图导出失败,请重试。');
|
||||
showToast('思维导图导出失败,请重试。', 'error');
|
||||
}
|
||||
};
|
||||
|
||||
const isCreator = meeting && user && String(meeting.creator_id) === String(user.user_id);
|
||||
|
||||
if (loading) {
|
||||
return <div className="loading-container"><div className="loading-spinner"></div><p>加载中...</p></div>;
|
||||
return <PageLoading message="加载中..." />;
|
||||
}
|
||||
|
||||
if (error) {
|
||||
|
|
@ -971,9 +984,9 @@ const MeetingDetails = ({ user }) => {
|
|||
<Edit size={16} />
|
||||
<span>编辑会议</span>
|
||||
</Link>
|
||||
<button
|
||||
<button
|
||||
className="action-btn delete-btn"
|
||||
onClick={() => setShowDeleteConfirm(true)}
|
||||
onClick={() => setDeleteConfirmInfo({ id: meeting_id, title: meeting.title })}
|
||||
>
|
||||
<Trash2 size={16} />
|
||||
<span>删除会议</span>
|
||||
|
|
@ -1291,47 +1304,17 @@ const MeetingDetails = ({ user }) => {
|
|||
</div>
|
||||
</div>
|
||||
|
||||
{/* Delete Confirmation Modal */}
|
||||
{showDeleteConfirm && (
|
||||
<div className="delete-modal-overlay" onClick={() => setShowDeleteConfirm(false)}>
|
||||
<div className="delete-modal" onClick={(e) => e.stopPropagation()}>
|
||||
<h3>确认删除</h3>
|
||||
<p>确定要删除会议 "{meeting.title}" 吗?此操作无法撤销。</p>
|
||||
<div className="modal-actions">
|
||||
<button
|
||||
className="btn-cancel"
|
||||
onClick={() => setShowDeleteConfirm(false)}
|
||||
>
|
||||
取消
|
||||
</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>
|
||||
)}
|
||||
{/* Delete Confirmation Dialog */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
onConfirm={handleDeleteMeeting}
|
||||
title="删除会议"
|
||||
message={`确定要删除会议"${deleteConfirmInfo?.title}"吗?此操作无法撤销。`}
|
||||
confirmText="确定删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
|
||||
{/* Speaker Tags Edit Modal */}
|
||||
{showSpeakerEdit && (
|
||||
|
|
@ -1347,9 +1330,7 @@ const MeetingDetails = ({ user }) => {
|
|||
</button>
|
||||
</div>
|
||||
|
||||
<div className="speaker-edit-content">
|
||||
<p className="modal-description">根据AI识别的发言人ID,为每个发言人设置自定义标签:</p>
|
||||
|
||||
<div className="speaker-edit-content">
|
||||
<div className="speaker-list">
|
||||
{speakerList.length > 0 ? (
|
||||
speakerList.map((speaker) => {
|
||||
|
|
@ -1580,6 +1561,16 @@ const MeetingDetails = ({ user }) => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Toast notifications */}
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -7,13 +7,13 @@
|
|||
.prompt-header {
|
||||
background: white;
|
||||
border-bottom: 1px solid #e2e8f0;
|
||||
padding: 1rem 2rem;
|
||||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
|
||||
.prompt-header .header-content {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 1rem 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
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 './PromptManagement.css';
|
||||
import TagEditor from '../../components/TagEditor'; // Reusing the TagEditor component
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
|
||||
const PromptManagement = () => {
|
||||
const [prompts, setPrompts] = useState([]);
|
||||
|
|
@ -16,6 +17,7 @@ const PromptManagement = () => {
|
|||
const [isEditing, setIsEditing] = useState(false);
|
||||
const [currentPrompt, setCurrentPrompt] = useState(null);
|
||||
const [activeMenu, setActiveMenu] = useState(null); // For dropdown menu
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const menuRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
|
|
@ -80,16 +82,22 @@ const PromptManagement = () => {
|
|||
}
|
||||
};
|
||||
|
||||
const handleDelete = async (promptId) => {
|
||||
const handleDelete = async (prompt) => {
|
||||
setActiveMenu(null); // Close menu
|
||||
if (window.confirm('您确定要删除这个提示词吗?')) {
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.PROMPTS.DELETE(promptId)));
|
||||
fetchPrompts(); // Refresh list
|
||||
} catch (err) {
|
||||
setError(err.response?.data?.message || '删除失败');
|
||||
}
|
||||
setDeleteConfirmInfo({
|
||||
id: prompt.id,
|
||||
name: prompt.name
|
||||
});
|
||||
};
|
||||
|
||||
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) => {
|
||||
|
|
@ -120,7 +128,7 @@ const PromptManagement = () => {
|
|||
{activeMenu === prompt.id && (
|
||||
<div className="dropdown-menu">
|
||||
<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>
|
||||
|
|
@ -169,6 +177,18 @@ const PromptManagement = () => {
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 删除提示词确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
onConfirm={handleConfirmDelete}
|
||||
title="删除提示词"
|
||||
message={`确定要删除提示词"${deleteConfirmInfo?.name}"吗?此操作无法撤销。`}
|
||||
confirmText="删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,6 +2,8 @@ import React, { useState, useEffect } from 'react';
|
|||
import apiClient from '../../utils/apiClient';
|
||||
import { buildApiUrl, API_ENDPOINTS } from '../../config/api';
|
||||
import { Plus, Edit, Trash2, KeyRound } from 'lucide-react';
|
||||
import ConfirmDialog from '../../components/ConfirmDialog';
|
||||
import Toast from '../../components/Toast';
|
||||
import './UserManagement.css';
|
||||
|
||||
const UserManagement = () => {
|
||||
|
|
@ -13,12 +15,22 @@ const UserManagement = () => {
|
|||
const [error, setError] = useState('');
|
||||
const [showAddUserModal, setShowAddUserModal] = useState(false);
|
||||
const [showEditUserModal, setShowEditUserModal] = useState(false);
|
||||
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
|
||||
const [showResetConfirm, setShowResetConfirm] = useState(false);
|
||||
const [processingUser, setProcessingUser] = useState(null);
|
||||
const [deleteConfirmInfo, setDeleteConfirmInfo] = useState(null);
|
||||
const [resetConfirmInfo, setResetConfirmInfo] = useState(null);
|
||||
const [newUser, setNewUser] = useState({ username: '', caption: '', email: '', role_id: 2 });
|
||||
const [editingUser, setEditingUser] = useState(null);
|
||||
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(() => {
|
||||
fetchUsers();
|
||||
|
|
@ -96,20 +108,26 @@ const UserManagement = () => {
|
|||
|
||||
const handleDeleteUser = async () => {
|
||||
try {
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.USERS.DELETE(processingUser.user_id)));
|
||||
setShowDeleteConfirm(false);
|
||||
await apiClient.delete(buildApiUrl(API_ENDPOINTS.USERS.DELETE(deleteConfirmInfo.user_id)));
|
||||
setDeleteConfirmInfo(null);
|
||||
showToast('用户删除成功', 'success');
|
||||
fetchUsers(); // Refresh user list
|
||||
} catch (err) {
|
||||
console.error('Error deleting user:', err);
|
||||
showToast('删除用户失败,请重试', 'error');
|
||||
setDeleteConfirmInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleResetPassword = async () => {
|
||||
try {
|
||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.RESET_PASSWORD(processingUser.user_id)));
|
||||
setShowResetConfirm(false);
|
||||
await apiClient.post(buildApiUrl(API_ENDPOINTS.USERS.RESET_PASSWORD(resetConfirmInfo.user_id)));
|
||||
setResetConfirmInfo(null);
|
||||
showToast('密码重置成功', 'success');
|
||||
} catch (err) {
|
||||
console.error('Error resetting password:', err);
|
||||
showToast('重置密码失败,请重试', 'error');
|
||||
setResetConfirmInfo(null);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -119,13 +137,11 @@ const UserManagement = () => {
|
|||
};
|
||||
|
||||
const openDeleteConfirm = (user) => {
|
||||
setProcessingUser(user);
|
||||
setShowDeleteConfirm(true);
|
||||
setDeleteConfirmInfo({ user_id: user.user_id, caption: user.caption });
|
||||
};
|
||||
|
||||
const openResetConfirm = (user) => {
|
||||
setProcessingUser(user);
|
||||
setShowResetConfirm(true);
|
||||
setResetConfirmInfo({ user_id: user.user_id, caption: user.caption });
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
@ -252,31 +268,39 @@ const UserManagement = () => {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{showDeleteConfirm && processingUser && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h2>确认删除</h2>
|
||||
<p>您确定要删除用户 <strong>{processingUser.caption}</strong> 吗?此操作无法撤销。</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowDeleteConfirm(false)}>取消</button>
|
||||
<button type="button" className="btn btn-danger" onClick={handleDeleteUser}>确认删除</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 删除用户确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!deleteConfirmInfo}
|
||||
onClose={() => setDeleteConfirmInfo(null)}
|
||||
onConfirm={handleDeleteUser}
|
||||
title="删除用户"
|
||||
message={`确定要删除用户"${deleteConfirmInfo?.caption}"吗?此操作无法撤销。`}
|
||||
confirmText="确定删除"
|
||||
cancelText="取消"
|
||||
type="danger"
|
||||
/>
|
||||
|
||||
{showResetConfirm && processingUser && (
|
||||
<div className="modal-overlay">
|
||||
<div className="modal-content">
|
||||
<h2>确认重置密码</h2>
|
||||
<p>您确定要重置用户 <strong>{processingUser.caption}</strong> 的密码吗?</p>
|
||||
<div className="modal-actions">
|
||||
<button type="button" className="btn btn-secondary" onClick={() => setShowResetConfirm(false)}>取消</button>
|
||||
<button type="button" className="btn btn-warning" onClick={handleResetPassword}>确认重置</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
{/* 重置密码确认对话框 */}
|
||||
<ConfirmDialog
|
||||
isOpen={!!resetConfirmInfo}
|
||||
onClose={() => setResetConfirmInfo(null)}
|
||||
onConfirm={handleResetPassword}
|
||||
title="重置密码"
|
||||
message={`确定要重置用户"${resetConfirmInfo?.caption}"的密码吗?重置后密码将恢复为系统默认密码。`}
|
||||
confirmText="确定重置"
|
||||
cancelText="取消"
|
||||
type="warning"
|
||||
/>
|
||||
|
||||
{/* Toast notifications */}
|
||||
{toasts.map(toast => (
|
||||
<Toast
|
||||
key={toast.id}
|
||||
message={toast.message}
|
||||
type={toast.type}
|
||||
onClose={() => removeToast(toast.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
Loading…
Reference in New Issue