440 lines
17 KiB
TypeScript
440 lines
17 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
|
||
interface DashboardData {
|
||
overview: {
|
||
totalUsers: number
|
||
activeUsers: number
|
||
totalMaterials: number
|
||
newUsersThisMonth: number
|
||
newMaterialsThisMonth: number
|
||
}
|
||
trends: {
|
||
userGrowth: Array<{ date: string; count: number }>
|
||
materialUploads: Array<{ date: string; count: number }>
|
||
}
|
||
distribution: {
|
||
departments: Array<{ department: string; count: number }>
|
||
roles: Array<{ role: string; isSuperAdmin: boolean; count: number }>
|
||
}
|
||
systemInfo: {
|
||
registrationEnabled: boolean
|
||
userActivationRequired: boolean
|
||
siteName: string
|
||
}
|
||
recentActivity: Array<{
|
||
id: string
|
||
title: string
|
||
createdAt: string
|
||
uploadedBy: {
|
||
username: string
|
||
email: string
|
||
}
|
||
}>
|
||
status: {
|
||
database: string
|
||
timestamp: string
|
||
uptime: number
|
||
}
|
||
}
|
||
|
||
export default function SystemMonitor() {
|
||
const { data: session, status } = useSession()
|
||
const router = useRouter()
|
||
const [dashboardData, setDashboardData] = useState<DashboardData | null>(null)
|
||
const [loading, setLoading] = useState(true)
|
||
const [error, setError] = useState<string | null>(null)
|
||
const [refreshInterval, setRefreshInterval] = useState<NodeJS.Timeout | null>(null)
|
||
|
||
// 权限检查
|
||
useEffect(() => {
|
||
if (status === 'loading') return
|
||
|
||
const isSuperAdmin = (session?.user as any)?.isSuperAdmin
|
||
if (status === 'authenticated' && (session.user as any)?.role !== 'ADMIN' && !isSuperAdmin) {
|
||
router.push('/')
|
||
return
|
||
}
|
||
}, [session, status, router])
|
||
|
||
// 获取仪表板数据
|
||
const fetchDashboardData = async () => {
|
||
try {
|
||
setError(null)
|
||
const response = await fetch('/api/admin/system-status', {
|
||
credentials: 'include'
|
||
})
|
||
|
||
if (!response.ok) {
|
||
throw new Error(`HTTP ${response.status}: ${response.statusText}`)
|
||
}
|
||
|
||
const data = await response.json()
|
||
setDashboardData(data)
|
||
} catch (err) {
|
||
console.error('Failed to fetch dashboard data:', err)
|
||
setError(err instanceof Error ? err.message : '获取数据失败')
|
||
} finally {
|
||
setLoading(false)
|
||
}
|
||
}
|
||
|
||
// 初始化数据加载
|
||
useEffect(() => {
|
||
if (session) {
|
||
fetchDashboardData()
|
||
}
|
||
}, [session])
|
||
|
||
// 自动刷新(每30秒)
|
||
useEffect(() => {
|
||
if (session) {
|
||
const interval = setInterval(fetchDashboardData, 30000)
|
||
setRefreshInterval(interval)
|
||
|
||
return () => {
|
||
if (interval) clearInterval(interval)
|
||
}
|
||
}
|
||
}, [session])
|
||
|
||
// 手动刷新
|
||
const handleRefresh = () => {
|
||
setLoading(true)
|
||
fetchDashboardData()
|
||
}
|
||
|
||
// 格式化时间
|
||
const formatDate = (dateString: string) => {
|
||
return new Date(dateString).toLocaleDateString('zh-CN', {
|
||
month: 'short',
|
||
day: 'numeric'
|
||
})
|
||
}
|
||
|
||
// 格式化时间戳
|
||
const formatTimestamp = (timestamp: string) => {
|
||
return new Date(timestamp).toLocaleString('zh-CN')
|
||
}
|
||
|
||
// 计算增长百分比
|
||
const calculateGrowth = (current: number, previous: number) => {
|
||
if (previous === 0) return current > 0 ? 100 : 0
|
||
return Math.round(((current - previous) / previous) * 100)
|
||
}
|
||
|
||
if (status === 'loading' || loading) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-32 w-32 border-b-2 border-blue-600 mx-auto"></div>
|
||
<p className="mt-4 text-gray-600">加载系统监控数据...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="bg-red-100 border border-red-400 text-red-700 px-4 py-3 rounded">
|
||
<strong className="font-bold">错误:</strong>
|
||
<span className="block sm:inline">{error}</span>
|
||
</div>
|
||
<button
|
||
onClick={handleRefresh}
|
||
className="mt-4 bg-blue-600 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||
>
|
||
重试
|
||
</button>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (!dashboardData) {
|
||
return (
|
||
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
|
||
<p className="text-gray-600">暂无数据</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const { overview, trends, distribution, systemInfo, recentActivity, status: systemStatus } = dashboardData
|
||
|
||
return (
|
||
<div className="min-h-screen bg-gray-50">
|
||
{/* 头部 */}
|
||
<div className="bg-white shadow-sm border-b">
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||
<div className="flex justify-between items-center py-6">
|
||
<div>
|
||
<h1 className="text-3xl font-bold text-gray-900">📊 系统监控仪表板</h1>
|
||
<p className="mt-1 text-sm text-gray-500">
|
||
{systemInfo.siteName} - 实时系统状态监控
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center space-x-4">
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={loading}
|
||
className="bg-blue-600 hover:bg-blue-700 disabled:bg-blue-300 text-white font-medium py-2 px-4 rounded-md"
|
||
>
|
||
{loading ? '刷新中...' : '🔄 刷新'}
|
||
</button>
|
||
<div className="text-sm text-gray-500">
|
||
最后更新:{formatTimestamp(systemStatus.timestamp)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
|
||
{/* 系统概览卡片 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||
<div className="p-5">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-blue-500 rounded-full flex items-center justify-center">
|
||
<span className="text-white font-medium">👥</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-5 w-0 flex-1">
|
||
<dl>
|
||
<dt className="text-sm font-medium text-gray-500 truncate">总用户数</dt>
|
||
<dd className="text-lg font-medium text-gray-900">{overview.totalUsers}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||
<div className="p-5">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-green-500 rounded-full flex items-center justify-center">
|
||
<span className="text-white font-medium">⚡</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-5 w-0 flex-1">
|
||
<dl>
|
||
<dt className="text-sm font-medium text-gray-500 truncate">活跃用户</dt>
|
||
<dd className="text-lg font-medium text-gray-900">{overview.activeUsers}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||
<div className="p-5">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-purple-500 rounded-full flex items-center justify-center">
|
||
<span className="text-white font-medium">📦</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-5 w-0 flex-1">
|
||
<dl>
|
||
<dt className="text-sm font-medium text-gray-500 truncate">总素材数</dt>
|
||
<dd className="text-lg font-medium text-gray-900">{overview.totalMaterials}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||
<div className="p-5">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-yellow-500 rounded-full flex items-center justify-center">
|
||
<span className="text-white font-medium">🆕</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-5 w-0 flex-1">
|
||
<dl>
|
||
<dt className="text-sm font-medium text-gray-500 truncate">本月新用户</dt>
|
||
<dd className="text-lg font-medium text-gray-900">{overview.newUsersThisMonth}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="bg-white overflow-hidden shadow rounded-lg">
|
||
<div className="p-5">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-indigo-500 rounded-full flex items-center justify-center">
|
||
<span className="text-white font-medium">📈</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-5 w-0 flex-1">
|
||
<dl>
|
||
<dt className="text-sm font-medium text-gray-500 truncate">本月新素材</dt>
|
||
<dd className="text-lg font-medium text-gray-900">{overview.newMaterialsThisMonth}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
|
||
{/* 用户增长趋势 */}
|
||
<div className="bg-white shadow rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-gray-900 mb-4">📈 用户增长趋势(最近30天)</h3>
|
||
<div className="h-64 overflow-y-auto">
|
||
<div className="space-y-2">
|
||
{trends.userGrowth.map((item, index) => (
|
||
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
|
||
<span className="text-sm text-gray-600">{formatDate(item.date)}</span>
|
||
<div className="flex items-center">
|
||
<div className="w-32 bg-gray-200 rounded-full h-2 mr-3">
|
||
<div
|
||
className="bg-blue-600 h-2 rounded-full"
|
||
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.userGrowth.map(d => d.count))) * 100)}%` }}
|
||
></div>
|
||
</div>
|
||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 素材上传趋势 */}
|
||
<div className="bg-white shadow rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-gray-900 mb-4">📊 素材上传趋势(最近30天)</h3>
|
||
<div className="h-64 overflow-y-auto">
|
||
<div className="space-y-2">
|
||
{trends.materialUploads.map((item, index) => (
|
||
<div key={index} className="flex justify-between items-center py-2 border-b border-gray-100">
|
||
<span className="text-sm text-gray-600">{formatDate(item.date)}</span>
|
||
<div className="flex items-center">
|
||
<div className="w-32 bg-gray-200 rounded-full h-2 mr-3">
|
||
<div
|
||
className="bg-purple-600 h-2 rounded-full"
|
||
style={{ width: `${Math.min(100, (item.count / Math.max(...trends.materialUploads.map(d => d.count))) * 100)}%` }}
|
||
></div>
|
||
</div>
|
||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8 mb-8">
|
||
{/* 部门分布 */}
|
||
<div className="bg-white shadow rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-gray-900 mb-4">🏢 部门分布</h3>
|
||
<div className="space-y-3">
|
||
{distribution.departments.map((item, index) => (
|
||
<div key={index} className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">{item.department || '未设置'}</span>
|
||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 权限分布 */}
|
||
<div className="bg-white shadow rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-gray-900 mb-4">🛡️ 权限分布</h3>
|
||
<div className="space-y-3">
|
||
{distribution.roles.map((item, index) => (
|
||
<div key={index} className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">
|
||
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' :
|
||
item.role === 'ADMIN' ? '管理员' : '普通用户'}
|
||
</span>
|
||
<span className="text-sm font-medium text-gray-900">{item.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 系统信息 */}
|
||
<div className="bg-white shadow rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-gray-900 mb-4">⚙️ 系统信息</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">注册功能</span>
|
||
<span className={`text-sm font-medium ${systemInfo.registrationEnabled ? 'text-green-600' : 'text-red-600'}`}>
|
||
{systemInfo.registrationEnabled ? '开启' : '关闭'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">用户激活</span>
|
||
<span className={`text-sm font-medium ${systemInfo.userActivationRequired ? 'text-yellow-600' : 'text-green-600'}`}>
|
||
{systemInfo.userActivationRequired ? '需要' : '关闭'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">数据库状态</span>
|
||
<span className={`text-sm font-medium ${systemStatus.database === 'healthy' ? 'text-green-600' : 'text-red-600'}`}>
|
||
{systemStatus.database === 'healthy' ? '正常' : '异常'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-gray-600">运行时间</span>
|
||
<span className="text-sm font-medium text-gray-900">
|
||
{Math.floor(systemStatus.uptime / 3600)}小时
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 最近活动 */}
|
||
<div className="bg-white shadow rounded-lg p-6">
|
||
<h3 className="text-lg font-medium text-gray-900 mb-4">🕒 最近活动</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-gray-200">
|
||
<thead className="bg-gray-50">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
素材标题
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
上传者
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
|
||
上传时间
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white divide-y divide-gray-200">
|
||
{recentActivity.map((activity) => (
|
||
<tr key={activity.id} className="hover:bg-gray-50">
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||
{activity.title}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{activity.uploadedBy.username}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
|
||
{formatTimestamp(activity.createdAt)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |