2025-09-11 05:16:24 +00:00
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
2025-10-31 06:55:19 +00:00
|
|
|
|
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library, BookText, Waves } from 'lucide-react';
|
2025-08-29 08:37:55 +00:00
|
|
|
|
import apiClient from '../utils/apiClient';
|
2025-08-05 01:44:28 +00:00
|
|
|
|
import { Link } from 'react-router-dom';
|
|
|
|
|
|
import { buildApiUrl, API_ENDPOINTS } from '../config/api';
|
|
|
|
|
|
import MeetingTimeline from '../components/MeetingTimeline';
|
2025-09-19 08:51:49 +00:00
|
|
|
|
import TagCloud from '../components/TagCloud';
|
2025-11-11 10:38:51 +00:00
|
|
|
|
import SimpleSearchInput from '../components/SimpleSearchInput';
|
2025-10-31 06:55:19 +00:00
|
|
|
|
import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal';
|
|
|
|
|
|
import ConfirmDialog from '../components/ConfirmDialog';
|
|
|
|
|
|
import PageLoading from '../components/PageLoading';
|
2025-11-10 01:28:48 +00:00
|
|
|
|
import ScrollToTop from '../components/ScrollToTop';
|
2025-11-11 10:38:51 +00:00
|
|
|
|
import Dropdown from '../components/Dropdown';
|
2025-11-07 09:11:54 +00:00
|
|
|
|
import meetingCacheService from '../services/meetingCacheService';
|
2025-12-11 08:47:46 +00:00
|
|
|
|
import menuService from '../services/menuService';
|
2025-08-05 01:44:28 +00:00
|
|
|
|
import './Dashboard.css';
|
|
|
|
|
|
|
|
|
|
|
|
const Dashboard = ({ user, onLogout }) => {
|
|
|
|
|
|
const [userInfo, setUserInfo] = useState(null);
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const [meetings, setMeetings] = useState([]);
|
|
|
|
|
|
const [meetingsStats, setMeetingsStats] = useState({ all_meetings: 0, created_meetings: 0, attended_meetings: 0 });
|
|
|
|
|
|
const [pagination, setPagination] = useState({ page: 1, total: 0, has_more: false });
|
2025-09-19 08:51:49 +00:00
|
|
|
|
const [selectedTags, setSelectedTags] = useState([]);
|
|
|
|
|
|
const [filterType, setFilterType] = useState('all'); // 'all', 'created', 'attended'
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const [searchQuery, setSearchQuery] = useState(''); // 搜索关键词
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const [loadingMore, setLoadingMore] = useState(false);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const [error, setError] = useState('');
|
2025-09-11 05:16:24 +00:00
|
|
|
|
const [showChangePasswordModal, setShowChangePasswordModal] = useState(false);
|
|
|
|
|
|
const [oldPassword, setOldPassword] = useState('');
|
|
|
|
|
|
const [newPassword, setNewPassword] = useState('');
|
|
|
|
|
|
const [confirmPassword, setConfirmPassword] = useState('');
|
|
|
|
|
|
const [passwordChangeError, setPasswordChangeError] = useState('');
|
|
|
|
|
|
const [passwordChangeSuccess, setPasswordChangeSuccess] = useState('');
|
2025-08-05 01:44:28 +00:00
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
// 声纹相关状态
|
|
|
|
|
|
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);
|
|
|
|
|
|
|
2025-12-11 08:47:46 +00:00
|
|
|
|
// 菜单权限相关状态
|
|
|
|
|
|
const [userMenus, setUserMenus] = useState([]);
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchUserData();
|
2025-11-07 09:11:54 +00:00
|
|
|
|
fetchMeetingsStats();
|
2025-10-31 06:55:19 +00:00
|
|
|
|
fetchVoiceprintData();
|
2025-12-11 08:47:46 +00:00
|
|
|
|
fetchUserMenus();
|
2025-11-07 09:11:54 +00:00
|
|
|
|
|
|
|
|
|
|
// 开发环境下,在控制台添加缓存调试命令
|
|
|
|
|
|
if (process.env.NODE_ENV === 'development') {
|
|
|
|
|
|
window.meetingCache = {
|
|
|
|
|
|
stats: () => meetingCacheService.getStats(),
|
|
|
|
|
|
clear: () => {
|
|
|
|
|
|
meetingCacheService.clearAll();
|
|
|
|
|
|
console.log('Cache cleared!');
|
|
|
|
|
|
},
|
|
|
|
|
|
info: () => {
|
|
|
|
|
|
const stats = meetingCacheService.getStats();
|
|
|
|
|
|
console.log('Meeting Cache Stats:', stats);
|
|
|
|
|
|
console.log(`- Cached filters: ${stats.filterCount}`);
|
|
|
|
|
|
console.log(`- Total pages: ${stats.totalPages}`);
|
|
|
|
|
|
console.log(`- Total meetings: ${stats.totalMeetings}`);
|
|
|
|
|
|
console.log(`- Cache size: ~${stats.cacheSize} KB`);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
console.log('💡 Cache debug commands available: window.meetingCache.stats(), window.meetingCache.clear(), window.meetingCache.info()');
|
|
|
|
|
|
}
|
2025-11-21 07:47:08 +00:00
|
|
|
|
|
|
|
|
|
|
// 清理函数: 当组件卸载或用户切换时清空缓存
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
console.log('Dashboard unmounting, clearing meeting cache');
|
|
|
|
|
|
meetingCacheService.clearAll();
|
|
|
|
|
|
};
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}, [user.user_id]);
|
|
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
// 当筛选条件变化时,重新加载第一页
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchMeetings(1, false);
|
|
|
|
|
|
}, [selectedTags, filterType, searchQuery]);
|
|
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-11 08:47:46 +00:00
|
|
|
|
const fetchUserMenus = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('[Dashboard] 开始获取用户菜单...');
|
|
|
|
|
|
const response = await menuService.getUserMenus();
|
|
|
|
|
|
console.log('[Dashboard] 菜单API响应:', response);
|
|
|
|
|
|
|
|
|
|
|
|
if (response.code === '200') {
|
|
|
|
|
|
const menus = response.data.menus || [];
|
|
|
|
|
|
console.log('[Dashboard] 用户菜单获取成功,菜单数量:', menus.length, '菜单内容:', menus);
|
|
|
|
|
|
setUserMenus(menus);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
console.error('[Dashboard] 获取用户菜单失败:', response.message);
|
|
|
|
|
|
// 使用默认菜单作为fallback
|
|
|
|
|
|
setUserMenus(getDefaultMenus());
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('[Dashboard] 获取用户菜单异常:', err);
|
|
|
|
|
|
// 使用默认菜单作为fallback
|
|
|
|
|
|
setUserMenus(getDefaultMenus());
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 获取默认菜单(fallback)
|
|
|
|
|
|
const getDefaultMenus = () => {
|
|
|
|
|
|
const defaultMenus = [
|
|
|
|
|
|
{ menu_code: 'change_password', menu_name: '修改密码', menu_type: 'action', sort_order: 1 },
|
|
|
|
|
|
{ menu_code: 'prompt_management', menu_name: '提示词仓库', menu_type: 'link', menu_url: '/prompt-management', sort_order: 2 },
|
|
|
|
|
|
{ menu_code: 'logout', menu_name: '退出登录', menu_type: 'action', sort_order: 99 }
|
|
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
// 如果是管理员,添加平台管理菜单
|
|
|
|
|
|
if (user.role_id === 1) {
|
|
|
|
|
|
defaultMenus.splice(2, 0, {
|
|
|
|
|
|
menu_code: 'platform_admin',
|
|
|
|
|
|
menu_name: '平台管理',
|
|
|
|
|
|
menu_type: 'link',
|
|
|
|
|
|
menu_url: '/admin/management',
|
|
|
|
|
|
sort_order: 3
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
console.log('[Dashboard] 使用默认菜单:', defaultMenus);
|
|
|
|
|
|
return defaultMenus;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 将菜单code映射到图标和行为
|
|
|
|
|
|
const getMenuItemConfig = (menu) => {
|
|
|
|
|
|
const iconMap = {
|
|
|
|
|
|
'change_password': <KeyRound size={16} />,
|
|
|
|
|
|
'prompt_management': <BookText size={16} />,
|
|
|
|
|
|
'platform_admin': <Shield size={16} />,
|
|
|
|
|
|
'logout': <LogOut size={16} />
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const actionMap = {
|
|
|
|
|
|
'change_password': () => setShowChangePasswordModal(true),
|
|
|
|
|
|
'prompt_management': () => window.location.href = '/prompt-management',
|
|
|
|
|
|
'platform_admin': () => window.location.href = '/admin/management',
|
|
|
|
|
|
'logout': onLogout
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
return {
|
|
|
|
|
|
icon: iconMap[menu.menu_code] || null,
|
|
|
|
|
|
label: menu.menu_name,
|
|
|
|
|
|
onClick: menu.menu_type === 'link' && menu.menu_url
|
|
|
|
|
|
? () => window.location.href = menu.menu_url
|
|
|
|
|
|
: actionMap[menu.menu_code] || (() => {})
|
|
|
|
|
|
};
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-19 08:51:49 +00:00
|
|
|
|
// 过滤会议
|
|
|
|
|
|
useEffect(() => {
|
2025-11-07 09:11:54 +00:00
|
|
|
|
fetchMeetings(1, false);
|
|
|
|
|
|
}, [selectedTags, filterType, searchQuery]);
|
|
|
|
|
|
|
|
|
|
|
|
const fetchMeetings = async (page = 1, isLoadMore = false) => {
|
|
|
|
|
|
try {
|
2025-11-21 07:47:08 +00:00
|
|
|
|
// 生成当前过滤器的键(包含user_id)
|
|
|
|
|
|
const filterKey = meetingCacheService.generateFilterKey(user.user_id, filterType, searchQuery, selectedTags);
|
2025-11-07 09:11:54 +00:00
|
|
|
|
|
|
|
|
|
|
// 如果不是加载更多,先检查是否有该过滤器的缓存
|
|
|
|
|
|
if (!isLoadMore) {
|
|
|
|
|
|
const allCached = meetingCacheService.getAllPages(filterKey);
|
|
|
|
|
|
if (allCached && allCached.pages[1]) {
|
|
|
|
|
|
console.log('Using cached data for filter:', filterKey);
|
|
|
|
|
|
|
|
|
|
|
|
// 恢复第一页数据
|
|
|
|
|
|
const firstPage = allCached.pages[1];
|
|
|
|
|
|
setMeetings(firstPage.meetings);
|
|
|
|
|
|
setPagination(firstPage.pagination);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 加载更多时,检查该页是否有缓存
|
|
|
|
|
|
const cachedPage = meetingCacheService.getPage(filterKey, page);
|
|
|
|
|
|
if (cachedPage) {
|
|
|
|
|
|
console.log('Using cached page:', page, 'for filter:', filterKey);
|
|
|
|
|
|
setMeetings(prev => [...prev, ...cachedPage.meetings]);
|
|
|
|
|
|
setPagination(cachedPage.pagination);
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 没有缓存,从服务器获取
|
|
|
|
|
|
if (isLoadMore) {
|
|
|
|
|
|
setLoadingMore(true);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
const params = {
|
|
|
|
|
|
user_id: user.user_id,
|
|
|
|
|
|
page: page,
|
|
|
|
|
|
filter_type: filterType,
|
|
|
|
|
|
search: searchQuery || undefined,
|
|
|
|
|
|
tags: selectedTags.length > 0 ? selectedTags.join(',') : undefined
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.LIST), { params });
|
|
|
|
|
|
|
|
|
|
|
|
const newMeetings = response.data.meetings;
|
|
|
|
|
|
const newPagination = {
|
|
|
|
|
|
page: response.data.page,
|
|
|
|
|
|
total: response.data.total,
|
|
|
|
|
|
has_more: response.data.has_more
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
// 缓存当前页数据
|
|
|
|
|
|
meetingCacheService.setPage(filterKey, page, newMeetings, newPagination);
|
|
|
|
|
|
|
|
|
|
|
|
if (isLoadMore) {
|
|
|
|
|
|
// 加载更多:追加数据
|
|
|
|
|
|
setMeetings(prev => [...prev, ...newMeetings]);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
// 新查询:替换数据
|
|
|
|
|
|
setMeetings(newMeetings);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setPagination(newPagination);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching meetings:', err);
|
|
|
|
|
|
setError('获取会议列表失败,请刷新重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
setLoadingMore(false);
|
2025-09-19 08:51:49 +00:00
|
|
|
|
}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
};
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const fetchMeetingsStats = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), {
|
|
|
|
|
|
params: { user_id: user.user_id }
|
2025-09-19 08:51:49 +00:00
|
|
|
|
});
|
2025-11-07 09:11:54 +00:00
|
|
|
|
setMeetingsStats(response.data);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching meetings stats:', err);
|
2025-09-19 08:51:49 +00:00
|
|
|
|
}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
};
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const handleLoadMore = () => {
|
|
|
|
|
|
if (!loadingMore && pagination.has_more) {
|
|
|
|
|
|
fetchMeetings(pagination.page + 1, true);
|
|
|
|
|
|
}
|
2025-09-19 08:51:49 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleTagClick = (tagName) => {
|
|
|
|
|
|
setSelectedTags(prev => {
|
|
|
|
|
|
if (prev.includes(tagName)) {
|
|
|
|
|
|
return prev.filter(tag => tag !== tagName);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return [...prev, tagName];
|
|
|
|
|
|
}
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleFilterTypeChange = (type) => {
|
|
|
|
|
|
setFilterType(type);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const clearFilters = () => {
|
|
|
|
|
|
setSelectedTags([]);
|
|
|
|
|
|
setFilterType('all');
|
2025-11-07 09:11:54 +00:00
|
|
|
|
setSearchQuery('');
|
2025-09-19 08:51:49 +00:00
|
|
|
|
};
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const fetchUserData = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
console.log('Fetching user data for user_id:', user.user_id);
|
2025-11-07 09:11:54 +00:00
|
|
|
|
|
2025-08-29 08:37:55 +00:00
|
|
|
|
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
|
2025-08-05 01:44:28 +00:00
|
|
|
|
console.log('User response:', userResponse.data);
|
2025-11-07 09:11:54 +00:00
|
|
|
|
setUserInfo(userResponse.data);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching data:', err);
|
|
|
|
|
|
setError('获取数据失败,请刷新重试');
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteMeeting = async (meetingId) => {
|
|
|
|
|
|
try {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId)));
|
2025-11-07 09:11:54 +00:00
|
|
|
|
// 清除所有缓存,因为删除会影响统计和列表
|
|
|
|
|
|
meetingCacheService.clearAll();
|
|
|
|
|
|
// 刷新会议列表和统计
|
|
|
|
|
|
await fetchMeetings(1, false);
|
|
|
|
|
|
await fetchMeetingsStats();
|
2025-08-05 01:44:28 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error deleting meeting:', err);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-11 05:16:24 +00:00
|
|
|
|
const handlePasswordChange = async (e) => {
|
|
|
|
|
|
e.preventDefault();
|
|
|
|
|
|
if (newPassword !== confirmPassword) {
|
|
|
|
|
|
setPasswordChangeError('新密码不匹配');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
if (newPassword.length < 6) {
|
|
|
|
|
|
setPasswordChangeError('新密码长度不能少于6位');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setPasswordChangeError('');
|
|
|
|
|
|
setPasswordChangeSuccess('');
|
|
|
|
|
|
|
|
|
|
|
|
try {
|
|
|
|
|
|
await apiClient.put(buildApiUrl(API_ENDPOINTS.USERS.UPDATE_PASSWORD(user.user_id)), {
|
|
|
|
|
|
old_password: oldPassword,
|
|
|
|
|
|
new_password: newPassword,
|
|
|
|
|
|
});
|
|
|
|
|
|
setPasswordChangeSuccess('密码修改成功!');
|
|
|
|
|
|
// 清空输入框并准备关闭模态框
|
|
|
|
|
|
setOldPassword('');
|
|
|
|
|
|
setNewPassword('');
|
|
|
|
|
|
setConfirmPassword('');
|
|
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setShowChangePasswordModal(false);
|
|
|
|
|
|
setPasswordChangeSuccess('');
|
|
|
|
|
|
}, 2000);
|
|
|
|
|
|
} catch (err) {
|
2025-09-30 04:13:54 +00:00
|
|
|
|
setPasswordChangeError(err.response?.data?.message || '密码修改失败');
|
2025-09-11 05:16:24 +00:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-10-31 06:55:19 +00:00
|
|
|
|
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);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-09-19 08:51:49 +00:00
|
|
|
|
const groupMeetingsByDate = (meetingsToGroup) => {
|
|
|
|
|
|
return meetingsToGroup.reduce((acc, meeting) => {
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const date = new Date(meeting.meeting_time || meeting.created_at).toISOString().split('T')[0];
|
|
|
|
|
|
if (!acc[date]) {
|
|
|
|
|
|
acc[date] = [];
|
|
|
|
|
|
}
|
|
|
|
|
|
acc[date].push(meeting);
|
|
|
|
|
|
return acc;
|
|
|
|
|
|
}, {});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const formatDate = (dateString) => {
|
|
|
|
|
|
const date = new Date(dateString);
|
|
|
|
|
|
return date.toLocaleDateString('zh-CN', {
|
|
|
|
|
|
year: 'numeric',
|
|
|
|
|
|
month: 'long',
|
|
|
|
|
|
day: 'numeric'
|
|
|
|
|
|
});
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
if (loading && meetings.length === 0) {
|
2025-10-31 06:55:19 +00:00
|
|
|
|
return <PageLoading message="加载中..." />;
|
2025-08-05 01:44:28 +00:00
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="dashboard">
|
|
|
|
|
|
<div className="error-container">
|
|
|
|
|
|
<p>{error}</p>
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<button onClick={() => { fetchUserData(); fetchMeetings(1, false); fetchMeetingsStats(); }} className="retry-btn">重试</button>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
const groupedMeetings = groupMeetingsByDate(meetings);
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
// 使用统计数据
|
|
|
|
|
|
const createdMeetings = meetingsStats.created_meetings;
|
|
|
|
|
|
const attendedMeetings = meetingsStats.attended_meetings;
|
|
|
|
|
|
const allMeetings = meetingsStats.all_meetings;
|
2025-08-05 01:44:28 +00:00
|
|
|
|
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="dashboard">
|
|
|
|
|
|
|
|
|
|
|
|
{/* Header */}
|
|
|
|
|
|
<header className="dashboard-header">
|
|
|
|
|
|
<div className="header-content">
|
|
|
|
|
|
<div className="logo">
|
|
|
|
|
|
<MessageSquare className="logo-icon" />
|
|
|
|
|
|
<span className="logo-text">iMeeting</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="user-actions">
|
2025-11-11 10:38:51 +00:00
|
|
|
|
<Dropdown
|
|
|
|
|
|
trigger={
|
|
|
|
|
|
<div className="user-menu-trigger">
|
|
|
|
|
|
<span className="welcome-text">欢迎,{userInfo?.caption}</span>
|
|
|
|
|
|
<ChevronDown size={20} />
|
2025-09-11 05:16:24 +00:00
|
|
|
|
</div>
|
2025-11-11 10:38:51 +00:00
|
|
|
|
}
|
2025-12-11 08:47:46 +00:00
|
|
|
|
items={userMenus.map(menu => getMenuItemConfig(menu))}
|
2025-11-11 10:38:51 +00:00
|
|
|
|
align="right"
|
|
|
|
|
|
className="user-menu-dropdown"
|
|
|
|
|
|
/>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</header>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="dashboard-content">
|
2025-09-19 08:51:49 +00:00
|
|
|
|
{/* 用户信息、统计和标签云一行布局 */}
|
|
|
|
|
|
<section className="dashboard-overview">
|
2025-10-16 09:15:07 +00:00
|
|
|
|
{/* 左侧列:用户卡片和知识库入口 */}
|
|
|
|
|
|
<div className="left-column">
|
|
|
|
|
|
<div className="user-card">
|
|
|
|
|
|
<div className="user-avatar">
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<User size={28} />
|
2025-10-16 09:15:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="user-details">
|
2025-10-31 06:55:19 +00:00
|
|
|
|
<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>
|
2025-10-16 09:15:07 +00:00
|
|
|
|
<p className="user-email">{userInfo?.email}</p>
|
|
|
|
|
|
<p className="join-date">加入时间:{formatDate(userInfo?.created_at)}</p>
|
2025-10-16 03:09:41 +00:00
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
2025-10-16 09:15:07 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 知识库入口卡片 */}
|
|
|
|
|
|
<Link to="/knowledge-base" className="kb-entry-card">
|
|
|
|
|
|
<div className="kb-entry-icon">
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<Library size={22} />
|
2025-10-16 09:15:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kb-entry-content">
|
|
|
|
|
|
<h2>知识库</h2>
|
|
|
|
|
|
<p>贯穿内容,生成知识库</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kb-entry-arrow">
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<ChevronDown size={18} style={{ transform: 'rotate(-90deg)' }} />
|
2025-10-16 09:15:07 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</Link>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-09-19 08:51:49 +00:00
|
|
|
|
{/* 统一的统计卡片 */}
|
|
|
|
|
|
<div className="unified-stats-card">
|
|
|
|
|
|
<h3>会议统计</h3>
|
|
|
|
|
|
<div className="stats-rows">
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`stat-row ${filterType === 'created' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => handleFilterTypeChange('created')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="stat-icon">
|
|
|
|
|
|
<Calendar className="icon" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="stat-text">
|
|
|
|
|
|
<span className="stat-label">我创建的会议</span>
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<span className="stat-value">{createdMeetings}</span>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`stat-row ${filterType === 'attended' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => handleFilterTypeChange('attended')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="stat-icon">
|
|
|
|
|
|
<Users className="icon" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="stat-text">
|
|
|
|
|
|
<span className="stat-label">我参加的会议</span>
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<span className="stat-value">{attendedMeetings}</span>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
|
|
|
|
|
<div
|
|
|
|
|
|
className={`stat-row ${filterType === 'all' ? 'active' : ''}`}
|
|
|
|
|
|
onClick={() => handleFilterTypeChange('all')}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className="stat-icon">
|
|
|
|
|
|
<TrendingUp className="icon" />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="stat-text">
|
|
|
|
|
|
<span className="stat-label">全部会议</span>
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<span className="stat-value">{allMeetings}</span>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
{/* 搜索和标签过滤卡片 */}
|
|
|
|
|
|
<div className="filter-card-wrapper">
|
|
|
|
|
|
<div className="filter-card-search">
|
2025-11-11 10:38:51 +00:00
|
|
|
|
<SimpleSearchInput
|
|
|
|
|
|
value={searchQuery}
|
|
|
|
|
|
onChange={setSearchQuery}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
placeholder="搜索会议名称或发起人..."
|
|
|
|
|
|
realTimeSearch={true}
|
2025-11-11 10:38:51 +00:00
|
|
|
|
debounceDelay={500}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div className="filter-card-tags">
|
|
|
|
|
|
<TagCloud
|
|
|
|
|
|
onTagClick={handleTagClick}
|
|
|
|
|
|
selectedTags={selectedTags}
|
|
|
|
|
|
showHeader={false}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</section>
|
|
|
|
|
|
|
|
|
|
|
|
{/* Meetings Timeline Section */}
|
|
|
|
|
|
<section className="meetings-section">
|
|
|
|
|
|
<div className="section-header">
|
|
|
|
|
|
<div className="section-title">
|
|
|
|
|
|
<h2>
|
|
|
|
|
|
<Clock size={24} />
|
|
|
|
|
|
会议时间轴
|
|
|
|
|
|
</h2>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Link to="/meetings/create">
|
|
|
|
|
|
<span className="create-meeting-btn">
|
|
|
|
|
|
<Plus size={20} />
|
|
|
|
|
|
新建会议纪要
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2025-11-07 09:11:54 +00:00
|
|
|
|
<MeetingTimeline
|
|
|
|
|
|
meetingsByDate={groupedMeetings}
|
|
|
|
|
|
currentUser={user}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
onDeleteMeeting={handleDeleteMeeting}
|
2025-11-07 09:11:54 +00:00
|
|
|
|
hasMore={pagination.has_more}
|
|
|
|
|
|
onLoadMore={handleLoadMore}
|
|
|
|
|
|
loadingMore={loadingMore}
|
2025-11-19 03:48:37 +00:00
|
|
|
|
filterType={filterType}
|
|
|
|
|
|
searchQuery={searchQuery}
|
|
|
|
|
|
selectedTags={selectedTags}
|
2025-08-05 01:44:28 +00:00
|
|
|
|
/>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
</div>
|
2025-09-11 05:16:24 +00:00
|
|
|
|
|
|
|
|
|
|
{showChangePasswordModal && (
|
|
|
|
|
|
<div className="modal-overlay">
|
|
|
|
|
|
<div className="modal-content">
|
|
|
|
|
|
<form onSubmit={handlePasswordChange}>
|
|
|
|
|
|
<h2>修改密码</h2>
|
|
|
|
|
|
{passwordChangeError && <p className="error-message">{passwordChangeError}</p>}
|
|
|
|
|
|
{passwordChangeSuccess && <p className="success-message">{passwordChangeSuccess}</p>}
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label>旧密码</label>
|
|
|
|
|
|
<input type="password" value={oldPassword} onChange={(e) => setOldPassword(e.target.value)} required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label>新密码</label>
|
|
|
|
|
|
<input type="password" value={newPassword} onChange={(e) => setNewPassword(e.target.value)} required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="form-group">
|
|
|
|
|
|
<label>确认新密码</label>
|
|
|
|
|
|
<input type="password" value={confirmPassword} onChange={(e) => setConfirmPassword(e.target.value)} required />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="modal-actions">
|
|
|
|
|
|
<button type="button" className="btn btn-secondary" onClick={() => setShowChangePasswordModal(false)}>取消</button>
|
|
|
|
|
|
<button type="submit" className="btn btn-primary">确认修改</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2025-10-31 06:55:19 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 声纹采集模态框 */}
|
|
|
|
|
|
<VoiceprintCollectionModal
|
|
|
|
|
|
isOpen={showVoiceprintModal}
|
|
|
|
|
|
onClose={() => setShowVoiceprintModal(false)}
|
|
|
|
|
|
onSuccess={handleVoiceprintUpload}
|
|
|
|
|
|
templateConfig={voiceprintTemplate}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 删除声纹确认对话框 */}
|
|
|
|
|
|
<ConfirmDialog
|
|
|
|
|
|
isOpen={showDeleteVoiceprintDialog}
|
|
|
|
|
|
onClose={() => setShowDeleteVoiceprintDialog(false)}
|
|
|
|
|
|
onConfirm={handleDeleteVoiceprint}
|
|
|
|
|
|
title="删除声纹"
|
|
|
|
|
|
message="确定要删除声纹数据吗?删除后可以重新采集。"
|
|
|
|
|
|
confirmText="删除"
|
|
|
|
|
|
cancelText="取消"
|
|
|
|
|
|
type="danger"
|
|
|
|
|
|
/>
|
2025-11-10 01:28:48 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 回到顶部按钮 */}
|
|
|
|
|
|
<ScrollToTop />
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default Dashboard;
|