2025-09-11 05:16:24 +00:00
|
|
|
|
import React, { useState, useEffect, useRef } from 'react';
|
2025-10-16 03:09:41 +00:00
|
|
|
|
import { LogOut, User, Calendar, Users, TrendingUp, Clock, MessageSquare, Plus, ChevronDown, KeyRound, Shield, Filter, X, Library } 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-08-05 01:44:28 +00:00
|
|
|
|
import './Dashboard.css';
|
|
|
|
|
|
|
|
|
|
|
|
const Dashboard = ({ user, onLogout }) => {
|
|
|
|
|
|
const [userInfo, setUserInfo] = useState(null);
|
2025-10-16 03:09:41 +00:00
|
|
|
|
const [meetings, setMeetings] = useState(null);
|
2025-09-19 08:51:49 +00:00
|
|
|
|
const [filteredMeetings, setFilteredMeetings] = useState([]);
|
|
|
|
|
|
const [selectedTags, setSelectedTags] = useState([]);
|
|
|
|
|
|
const [filterType, setFilterType] = useState('all'); // 'all', 'created', 'attended'
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const [loading, setLoading] = useState(true);
|
|
|
|
|
|
const [error, setError] = useState('');
|
2025-09-11 05:16:24 +00:00
|
|
|
|
const [dropdownOpen, setDropdownOpen] = useState(false);
|
|
|
|
|
|
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 dropdownRef = useRef(null);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchUserData();
|
|
|
|
|
|
}, [user.user_id]);
|
|
|
|
|
|
|
2025-09-19 08:51:49 +00:00
|
|
|
|
// 过滤会议
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
filterMeetings();
|
|
|
|
|
|
}, [meetings, selectedTags, filterType]);
|
|
|
|
|
|
|
|
|
|
|
|
const filterMeetings = () => {
|
2025-10-16 03:09:41 +00:00
|
|
|
|
if (!meetings) return;
|
2025-09-19 08:51:49 +00:00
|
|
|
|
let filtered = [...meetings];
|
|
|
|
|
|
|
|
|
|
|
|
// 根据创建/参与类型过滤
|
|
|
|
|
|
if (filterType === 'created') {
|
|
|
|
|
|
filtered = filtered.filter(meeting => String(meeting.creator_id) === String(user.user_id));
|
|
|
|
|
|
} else if (filterType === 'attended') {
|
|
|
|
|
|
filtered = filtered.filter(meeting => String(meeting.creator_id) !== String(user.user_id));
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
// 根据选中的标签过滤
|
|
|
|
|
|
if (selectedTags.length > 0) {
|
|
|
|
|
|
filtered = filtered.filter(meeting => {
|
|
|
|
|
|
if (!meeting.tags || meeting.tags.length === 0) return false;
|
|
|
|
|
|
const meetingTags = meeting.tags.map(tag => typeof tag === 'string' ? tag : tag.name);
|
|
|
|
|
|
return selectedTags.some(selectedTag => meetingTags.includes(selectedTag));
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
setFilteredMeetings(filtered);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
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-09-11 05:16:24 +00:00
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
const handleClickOutside = (event) => {
|
|
|
|
|
|
if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
|
|
|
|
|
|
setDropdownOpen(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
document.addEventListener("mousedown", handleClickOutside);
|
|
|
|
|
|
return () => {
|
|
|
|
|
|
document.removeEventListener("mousedown", handleClickOutside);
|
|
|
|
|
|
};
|
|
|
|
|
|
}, [dropdownRef]);
|
|
|
|
|
|
|
2025-08-05 01:44:28 +00:00
|
|
|
|
const fetchUserData = async () => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
console.log('Fetching user data for user_id:', user.user_id);
|
|
|
|
|
|
|
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-10-16 03:09:41 +00:00
|
|
|
|
setUserInfo(userResponse.data);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
|
2025-10-16 03:09:41 +00:00
|
|
|
|
const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`));
|
|
|
|
|
|
setMeetings(meetingsResponse.data);
|
2025-08-05 01:44:28 +00:00
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error fetching data:', err);
|
|
|
|
|
|
setError('获取数据失败,请刷新重试');
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleDeleteMeeting = async (meetingId) => {
|
|
|
|
|
|
try {
|
2025-08-29 08:37:55 +00:00
|
|
|
|
await apiClient.delete(buildApiUrl(API_ENDPOINTS.MEETINGS.DELETE(meetingId)));
|
2025-08-05 01:44:28 +00:00
|
|
|
|
// Refresh meetings list
|
2025-08-29 08:37:55 +00:00
|
|
|
|
const meetingsResponse = await apiClient.get(buildApiUrl(`${API_ENDPOINTS.MEETINGS.LIST}?user_id=${user.user_id}`));
|
2025-08-05 01:44:28 +00:00
|
|
|
|
setMeetings(meetingsResponse.data);
|
|
|
|
|
|
} catch (err) {
|
|
|
|
|
|
console.error('Error deleting meeting:', err);
|
|
|
|
|
|
// You might want to show an error message to the user here
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
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-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-10-16 03:09:41 +00:00
|
|
|
|
if (loading || !meetings) {
|
2025-08-05 01:44:28 +00:00
|
|
|
|
return (
|
|
|
|
|
|
<div className="dashboard">
|
|
|
|
|
|
<div className="loading-container">
|
|
|
|
|
|
<div className="loading-spinner"></div>
|
|
|
|
|
|
<p>加载中...</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
if (error) {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className="dashboard">
|
|
|
|
|
|
<div className="error-container">
|
|
|
|
|
|
<p>{error}</p>
|
|
|
|
|
|
<button onClick={fetchUserData} className="retry-btn">重试</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-09-19 08:51:49 +00:00
|
|
|
|
const groupedMeetings = groupMeetingsByDate(filteredMeetings);
|
|
|
|
|
|
|
|
|
|
|
|
// 计算统计数据
|
|
|
|
|
|
const createdMeetings = meetings.filter(m => String(m.creator_id) === String(user.user_id));
|
|
|
|
|
|
const attendedMeetings = meetings.filter(m => String(m.creator_id) !== String(user.user_id));
|
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-09-11 05:16:24 +00:00
|
|
|
|
<div className="user-menu-container" ref={dropdownRef}>
|
|
|
|
|
|
<div className="user-menu-trigger" onClick={() => setDropdownOpen(!dropdownOpen)}>
|
|
|
|
|
|
<span className="welcome-text">欢迎,{userInfo?.caption}</span>
|
|
|
|
|
|
<ChevronDown size={20} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
{dropdownOpen && (
|
|
|
|
|
|
<div className="dropdown-menu">
|
|
|
|
|
|
<button onClick={() => { setShowChangePasswordModal(true); setDropdownOpen(false); }}><KeyRound size={16} /> 修改密码</button>
|
|
|
|
|
|
{user.role_id === 1 && (
|
|
|
|
|
|
<Link to="/admin/management" onClick={() => setDropdownOpen(false)}><Shield size={16} /> 平台管理</Link>
|
|
|
|
|
|
)}
|
|
|
|
|
|
<button onClick={onLogout}><LogOut size={16} /> 退出</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
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">
|
|
|
|
|
|
<User size={24} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="user-details">
|
|
|
|
|
|
<h2>{userInfo?.caption}</h2>
|
|
|
|
|
|
<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">
|
|
|
|
|
|
<Library size={28} />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kb-entry-content">
|
|
|
|
|
|
<h2>知识库</h2>
|
|
|
|
|
|
<p>贯穿内容,生成知识库</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div className="kb-entry-arrow">
|
|
|
|
|
|
<ChevronDown size={20} style={{ transform: 'rotate(-90deg)' }} />
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<span className="stat-value">{createdMeetings.length}</span>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<span className="stat-value">{attendedMeetings.length}</span>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<span className="stat-value">{meetings.length}</span>
|
|
|
|
|
|
</div>
|
2025-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
2025-09-19 08:51:49 +00:00
|
|
|
|
|
|
|
|
|
|
{/* 标签云卡片 */}
|
|
|
|
|
|
<div className="tag-cloud-wrapper">
|
|
|
|
|
|
<TagCloud
|
|
|
|
|
|
meetings={meetings}
|
|
|
|
|
|
onTagClick={handleTagClick}
|
|
|
|
|
|
selectedTags={selectedTags}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
|
|
|
|
|
<p>按时间顺序展示您参与的所有会议</p>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Link to="/meetings/create">
|
|
|
|
|
|
<span className="create-meeting-btn">
|
|
|
|
|
|
<Plus size={20} />
|
|
|
|
|
|
新建会议纪要
|
|
|
|
|
|
</span>
|
|
|
|
|
|
</Link>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<MeetingTimeline
|
|
|
|
|
|
meetingsByDate={groupedMeetings}
|
|
|
|
|
|
currentUser={user}
|
|
|
|
|
|
onDeleteMeeting={handleDeleteMeeting}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</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-08-05 01:44:28 +00:00
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
export default Dashboard;
|