Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一UI三层架构系统(基础层、颜色层、组合层) - 拆分Admin页面为独立Tab组件(MonitorTab、UsersTab、DepartmentsTab等) - 提取公共逻辑到Hooks(useToast、useDataFetch、useUserOperations等) - 提取工具函数(validators、formatters、api等) - 创建统一弹窗组件(Modal)和useModal Hook - 添加错误边界和统一状态组件 - 完善类型定义和可访问性支持 - 更新文档(优化记录、优化计划、更新日志)
465 lines
20 KiB
TypeScript
465 lines
20 KiB
TypeScript
'use client'
|
||
|
||
import React, { useState, useEffect } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useRouter } from 'next/navigation'
|
||
import { cardStyles, buttonStyles, spacingStyles, cn } from '@/lib/ui-styles'
|
||
|
||
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-slate-50 dark:bg-slate-950 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-slate-600">加载系统监控数据...</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
if (error) {
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950 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-slate-50 dark:bg-slate-950 flex items-center justify-center">
|
||
<p className="text-slate-600 dark:text-slate-300">暂无数据</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
const { overview, trends, distribution, systemInfo, recentActivity, status: systemStatus } = dashboardData
|
||
|
||
return (
|
||
<div className="min-h-screen bg-slate-50 dark:bg-slate-950">
|
||
{/* 头部 */}
|
||
<div className="bg-white/90 dark:bg-slate-900/90 backdrop-blur-sm shadow-sm border-b border-slate-200/70 dark:border-slate-800/70">
|
||
<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-slate-900 dark:text-white">📊 系统监控仪表板</h1>
|
||
<p className="mt-1 text-sm text-slate-600 dark:text-slate-300">
|
||
{systemInfo.siteName} - 实时系统状态监控
|
||
</p>
|
||
</div>
|
||
<div className="flex items-center space-x-4">
|
||
<button
|
||
onClick={handleRefresh}
|
||
disabled={loading}
|
||
className={cn(
|
||
buttonStyles.primary,
|
||
'py-2 px-4 rounded-xl disabled:opacity-60 disabled:cursor-not-allowed'
|
||
)}
|
||
>
|
||
{loading ? '刷新中...' : '🔄 刷新'}
|
||
</button>
|
||
<div className="text-sm text-slate-500 dark:text-slate-400">
|
||
最后更新:{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 space-y-8">
|
||
{/* 系统概览卡片 */}
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6 mb-8">
|
||
<div className={cn(cardStyles.base, 'overflow-hidden')}>
|
||
<div className="p-5 space-y-2">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-blue-500/90 dark:bg-blue-500 rounded-full flex items-center justify-center shadow-sm shadow-blue-500/40">
|
||
<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-slate-500 dark:text-slate-400 truncate">总用户数</dt>
|
||
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.totalUsers}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(cardStyles.base, 'overflow-hidden')}>
|
||
<div className="p-5 space-y-2">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-green-500/90 dark:bg-green-500 rounded-full flex items-center justify-center shadow-sm shadow-green-500/40">
|
||
<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-slate-500 dark:text-slate-400 truncate">活跃用户</dt>
|
||
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.activeUsers}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(cardStyles.base, 'overflow-hidden')}>
|
||
<div className="p-5 space-y-2">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-purple-500/90 dark:bg-purple-500 rounded-full flex items-center justify-center shadow-sm shadow-purple-500/40">
|
||
<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-slate-500 dark:text-slate-400 truncate">总素材数</dt>
|
||
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.totalMaterials}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(cardStyles.base, 'overflow-hidden')}>
|
||
<div className="p-5 space-y-2">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-yellow-500/90 dark:bg-yellow-400 rounded-full flex items-center justify-center shadow-sm shadow-yellow-500/40">
|
||
<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-slate-500 dark:text-slate-400 truncate">本月新用户</dt>
|
||
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{overview.newUsersThisMonth}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(cardStyles.base, 'overflow-hidden')}>
|
||
<div className="p-5 space-y-2">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className="w-8 h-8 bg-indigo-500/90 dark:bg-indigo-500 rounded-full flex items-center justify-center shadow-sm shadow-indigo-500/40">
|
||
<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-slate-500 dark:text-slate-400 truncate">本月新素材</dt>
|
||
<dd className="text-lg font-semibold text-slate-900 dark:text-white">{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={cn(cardStyles.base, 'p-6')}>
|
||
<h3 className="text-lg font-medium text-slate-900 dark:text-white 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-slate-100 dark:border-slate-800/80">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">{formatDate(item.date)}</span>
|
||
<div className="flex items-center">
|
||
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
|
||
<div
|
||
className="bg-blue-600 dark:bg-blue-500 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-slate-900 dark:text-white">{item.count}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 素材上传趋势 */}
|
||
<div className={cn(cardStyles.base, 'p-6')}>
|
||
<h3 className="text-lg font-medium text-slate-900 dark:text-white 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-slate-100 dark:border-slate-800/80">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">{formatDate(item.date)}</span>
|
||
<div className="flex items-center">
|
||
<div className="w-32 bg-slate-200 dark:bg-slate-700 rounded-full h-2 mr-3">
|
||
<div
|
||
className="bg-purple-600 dark:bg-purple-500 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-slate-900 dark:text-white">{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={cn(cardStyles.base, 'p-6')}>
|
||
<h3 className="text-lg font-medium text-slate-900 dark:text-white 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-slate-600 dark:text-slate-300">{item.department || '未设置'}</span>
|
||
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 权限分布 */}
|
||
<div className={cn(cardStyles.base, 'p-6')}>
|
||
<h3 className="text-lg font-medium text-slate-900 dark:text-white 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-slate-600 dark:text-slate-300">
|
||
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' :
|
||
item.role === 'ADMIN' ? '管理员' : '普通用户'}
|
||
</span>
|
||
<span className="text-sm font-medium text-slate-900 dark:text-white">{item.count}</span>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* 系统信息 - 只读状态展示 */}
|
||
<div className={cn(cardStyles.base, 'p-6')}>
|
||
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">⚙️ 系统信息</h3>
|
||
<div className="space-y-3">
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">注册功能</span>
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||
systemInfo.registrationEnabled
|
||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
|
||
)}
|
||
>
|
||
{systemInfo.registrationEnabled ? '开启' : '关闭'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">用户激活</span>
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||
systemInfo.userActivationRequired
|
||
? 'bg-yellow-100 dark:bg-yellow-900/30 text-yellow-700 dark:text-yellow-300'
|
||
: 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||
)}
|
||
>
|
||
{systemInfo.userActivationRequired ? '需要' : '关闭'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">数据库状态</span>
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center px-2 py-0.5 rounded-full text-xs font-medium',
|
||
systemStatus.database === 'healthy'
|
||
? 'bg-green-100 dark:bg-green-900/30 text-green-700 dark:text-green-300'
|
||
: 'bg-red-100 dark:bg-red-900/30 text-red-700 dark:text-red-300'
|
||
)}
|
||
>
|
||
{systemStatus.database === 'healthy' ? '正常' : '异常'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-300">运行时间</span>
|
||
<span className="text-sm font-medium text-slate-900 dark:text-white">
|
||
{Math.floor(systemStatus.uptime / 3600)}小时
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 最近活动 */}
|
||
<div className={cn(cardStyles.base, 'p-6')}>
|
||
<h3 className="text-lg font-medium text-slate-900 dark:text-white mb-4">🕒 最近活动</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className="min-w-full divide-y divide-slate-200 dark:divide-slate-700/80">
|
||
<thead className="bg-slate-50 dark:bg-slate-900/60">
|
||
<tr>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||
素材标题
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||
上传者
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 uppercase tracking-wider">
|
||
上传时间
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className="bg-white dark:bg-slate-900/40 divide-y divide-slate-200 dark:divide-slate-800/80">
|
||
{recentActivity.map((activity) => (
|
||
<tr key={activity.id} className="hover:bg-slate-50 dark:hover:bg-slate-800/60 transition-colors">
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-900 dark:text-white">
|
||
{activity.title}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500 dark:text-slate-300">
|
||
{activity.uploadedBy.username}
|
||
</td>
|
||
<td className="px-6 py-4 whitespace-nowrap text-sm text-slate-500">
|
||
{formatTimestamp(activity.createdAt)}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
} |