main
mula.liu 2025-10-31 14:55:19 +08:00
parent 46358a623b
commit cdfcceaf47
23 changed files with 1887 additions and 310 deletions

View File

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

View File

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

View File

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

View File

@ -83,6 +83,7 @@
/* Meeting Cards */
.meeting-card-wrapper {
position: relative;
padding: 0 5px;
}
.meeting-card {

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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('');
// webmwavAudioContext
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;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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