imetting_frontend/src/pages/Dashboard.jsx

654 lines
23 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

import React, { useState, useEffect, useRef } from '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 SimpleSearchInput from '../components/SimpleSearchInput';
import VoiceprintCollectionModal from '../components/VoiceprintCollectionModal';
import ConfirmDialog from '../components/ConfirmDialog';
import PageLoading from '../components/PageLoading';
import ScrollToTop from '../components/ScrollToTop';
import Dropdown from '../components/Dropdown';
import meetingCacheService from '../services/meetingCacheService';
import menuService from '../services/menuService';
import './Dashboard.css';
const Dashboard = ({ user, onLogout }) => {
const [userInfo, setUserInfo] = useState(null);
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 });
const [selectedTags, setSelectedTags] = useState([]);
const [filterType, setFilterType] = useState('all'); // 'all', 'created', 'attended'
const [searchQuery, setSearchQuery] = useState(''); // 搜索关键词
const [loading, setLoading] = useState(true);
const [loadingMore, setLoadingMore] = useState(false);
const [error, setError] = useState('');
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('');
// 声纹相关状态
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);
// 菜单权限相关状态
const [userMenus, setUserMenus] = useState([]);
useEffect(() => {
fetchUserData();
fetchMeetingsStats();
fetchVoiceprintData();
fetchUserMenus();
// 开发环境下,在控制台添加缓存调试命令
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()');
}
// 清理函数: 当组件卸载或用户切换时清空缓存
return () => {
console.log('Dashboard unmounting, clearing meeting cache');
meetingCacheService.clearAll();
};
}, [user.user_id]);
// 当筛选条件变化时,重新加载第一页
useEffect(() => {
fetchMeetings(1, false);
}, [selectedTags, filterType, searchQuery]);
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);
}
};
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] || (() => {})
};
};
// 过滤会议
useEffect(() => {
fetchMeetings(1, false);
}, [selectedTags, filterType, searchQuery]);
const fetchMeetings = async (page = 1, isLoadMore = false) => {
try {
// 生成当前过滤器的键(包含user_id)
const filterKey = meetingCacheService.generateFilterKey(user.user_id, filterType, searchQuery, selectedTags);
// 如果不是加载更多,先检查是否有该过滤器的缓存
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);
}
};
const fetchMeetingsStats = async () => {
try {
const response = await apiClient.get(buildApiUrl(API_ENDPOINTS.MEETINGS.STATS), {
params: { user_id: user.user_id }
});
setMeetingsStats(response.data);
} catch (err) {
console.error('Error fetching meetings stats:', err);
}
};
const handleLoadMore = () => {
if (!loadingMore && pagination.has_more) {
fetchMeetings(pagination.page + 1, true);
}
};
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');
setSearchQuery('');
};
const fetchUserData = async () => {
try {
console.log('Fetching user data for user_id:', user.user_id);
const userResponse = await apiClient.get(buildApiUrl(API_ENDPOINTS.USERS.DETAIL(user.user_id)));
console.log('User response:', userResponse.data);
setUserInfo(userResponse.data);
} catch (err) {
console.error('Error fetching data:', err);
setError('获取数据失败,请刷新重试');
}
};
const handleDeleteMeeting = async (meetingId) => {
try {
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId)));
// 清除所有缓存,因为删除会影响统计和列表
meetingCacheService.clearAll();
// 刷新会议列表和统计
await fetchMeetings(1, false);
await fetchMeetingsStats();
} catch (err) {
console.error('Error deleting meeting:', err);
}
};
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) {
setPasswordChangeError(err.response?.data?.message || '密码修改失败');
}
};
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];
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'
});
};
if (loading && meetings.length === 0) {
return <PageLoading message="加载中..." />;
}
if (error) {
return (
<div className="dashboard">
<div className="error-container">
<p>{error}</p>
<button onClick={() => { fetchUserData(); fetchMeetings(1, false); fetchMeetingsStats(); }} className="retry-btn">重试</button>
</div>
</div>
);
}
const groupedMeetings = groupMeetingsByDate(meetings);
// 使用统计数据
const createdMeetings = meetingsStats.created_meetings;
const attendedMeetings = meetingsStats.attended_meetings;
const allMeetings = meetingsStats.all_meetings;
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">
<Dropdown
trigger={
<div className="user-menu-trigger">
<span className="welcome-text">欢迎{userInfo?.caption}</span>
<ChevronDown size={20} />
</div>
}
items={userMenus.map(menu => getMenuItemConfig(menu))}
align="right"
className="user-menu-dropdown"
/>
</div>
</div>
</header>
<div className="dashboard-content">
{/* 用户信息、统计和标签云一行布局 */}
<section className="dashboard-overview">
{/* 左侧列:用户卡片和知识库入口 */}
<div className="left-column">
<div className="user-card">
<div className="user-avatar">
<User size={28} />
</div>
<div className="user-details">
<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>
</div>
{/* 知识库入口卡片 */}
<Link to="/knowledge-base" className="kb-entry-card">
<div className="kb-entry-icon">
<Library size={22} />
</div>
<div className="kb-entry-content">
<h2>知识库</h2>
<p>贯穿内容生成知识库</p>
</div>
<div className="kb-entry-arrow">
<ChevronDown size={18} style={{ transform: 'rotate(-90deg)' }} />
</div>
</Link>
</div>
{/* 统一的统计卡片 */}
<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>
<span className="stat-value">{createdMeetings}</span>
</div>
</div>
<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>
<span className="stat-value">{attendedMeetings}</span>
</div>
</div>
<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>
<span className="stat-value">{allMeetings}</span>
</div>
</div>
</div>
</div>
{/* 搜索和标签过滤卡片 */}
<div className="filter-card-wrapper">
<div className="filter-card-search">
<SimpleSearchInput
value={searchQuery}
onChange={setSearchQuery}
placeholder="搜索会议名称或发起人..."
realTimeSearch={true}
debounceDelay={500}
/>
</div>
<div className="filter-card-tags">
<TagCloud
onTagClick={handleTagClick}
selectedTags={selectedTags}
showHeader={false}
/>
</div>
</div>
</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>
<MeetingTimeline
meetingsByDate={groupedMeetings}
currentUser={user}
onDeleteMeeting={handleDeleteMeeting}
hasMore={pagination.has_more}
onLoadMore={handleLoadMore}
loadingMore={loadingMore}
filterType={filterType}
searchQuery={searchQuery}
selectedTags={selectedTags}
/>
</section>
</div>
{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>
)}
{/* 声纹采集模态框 */}
<VoiceprintCollectionModal
isOpen={showVoiceprintModal}
onClose={() => setShowVoiceprintModal(false)}
onSuccess={handleVoiceprintUpload}
templateConfig={voiceprintTemplate}
/>
{/* 删除声纹确认对话框 */}
<ConfirmDialog
isOpen={showDeleteVoiceprintDialog}
onClose={() => setShowDeleteVoiceprintDialog(false)}
onConfirm={handleDeleteVoiceprint}
title="删除声纹"
message="确定要删除声纹数据吗?删除后可以重新采集。"
confirmText="删除"
cancelText="取消"
type="danger"
/>
{/* 回到顶部按钮 */}
<ScrollToTop />
</div>
);
};
export default Dashboard;