Some checks failed
CI Check / check (push) Has been cancelled
- 创建统一的Modal组件,采用Apple风格设计 - 实现毛玻璃效果、圆角、动画和细边框描边 - 修复弹窗打开时背景滚动问题 - 修复分类管理中重复卡片和数组字符串显示问题 - 优化选项解析逻辑,添加去重和过滤机制
380 lines
16 KiB
TypeScript
380 lines
16 KiB
TypeScript
/**
|
||
* MonitorTab 组件
|
||
*
|
||
* 系统监控仪表板Tab组件
|
||
* 从主页面拆分出来,负责显示系统监控数据
|
||
* 包括用户统计、素材统计、系统状态等信息
|
||
* 使用三层架构样式系统,支持深浅色主题
|
||
*
|
||
* @created 2025-01-XX(拆分自 page.tsx)
|
||
* @component
|
||
*/
|
||
|
||
'use client'
|
||
|
||
import { useEffect, useMemo } from 'react'
|
||
import { useSession } from 'next-auth/react'
|
||
import { useThemeStyles, useTheme, cn } from '@/lib/ui-styles'
|
||
import { formatDateTimeLocale } from '../_utils/formatters'
|
||
import type { ToastFn, DashboardData } from './types'
|
||
import { useDashboardData } from '../_hooks/useDashboardData'
|
||
import LoadingState from './ui/LoadingState'
|
||
import ErrorState from './ui/ErrorState'
|
||
import EmptyState from './ui/EmptyState'
|
||
|
||
interface MonitorTabProps {
|
||
showToast: ToastFn
|
||
}
|
||
|
||
const AUTO_REFRESH_INTERVAL = 60_000
|
||
|
||
export default function MonitorTab({ showToast }: MonitorTabProps) {
|
||
const styles = useThemeStyles()
|
||
const theme = useTheme()
|
||
|
||
// 根据主题选择颜色类名
|
||
const borderColor = theme === 'dark' ? 'border-slate-800/70' : 'border-slate-200/70'
|
||
const textTitle = theme === 'dark' ? 'text-white' : 'text-slate-900'
|
||
const textDesc = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
|
||
const textLabel = theme === 'dark' ? 'text-slate-400' : 'text-slate-500'
|
||
const textValue = theme === 'dark' ? 'text-white' : 'text-slate-900'
|
||
const borderRow = theme === 'dark' ? 'border-slate-700' : 'border-slate-200'
|
||
const textRow = theme === 'dark' ? 'text-slate-400' : 'text-slate-600'
|
||
const { data: session } = useSession()
|
||
const currentIsSuperAdmin = Boolean((session?.user as any)?.isSuperAdmin)
|
||
const { data, loading, error, refresh, updateSystemSetting, lastUpdated, refreshing } = useDashboardData({ showToast })
|
||
|
||
useEffect(() => {
|
||
refresh()
|
||
}, [refresh])
|
||
|
||
useEffect(() => {
|
||
const timer = setInterval(() => {
|
||
void refresh({ background: true })
|
||
}, AUTO_REFRESH_INTERVAL)
|
||
return () => clearInterval(timer)
|
||
}, [refresh])
|
||
|
||
const materialUploadMax = useMemo(() => {
|
||
if (!data?.trends.materialUploads?.length) return 1
|
||
return Math.max(...data.trends.materialUploads.map((item) => item.count), 1)
|
||
}, [data?.trends.materialUploads])
|
||
|
||
const formattedLastUpdated = useMemo(() => {
|
||
if (!lastUpdated) return '尚未获取'
|
||
return formatDateTimeLocale(lastUpdated, 'zh-CN', { hour12: false })
|
||
}, [lastUpdated])
|
||
|
||
const handleManualRefresh = () => {
|
||
void refresh({ background: Boolean(data) })
|
||
}
|
||
|
||
if (loading) {
|
||
return <LoadingState message="加载系统监控数据..." />
|
||
}
|
||
|
||
if (error) {
|
||
return <ErrorState message={error} onRetry={() => refresh()} />
|
||
}
|
||
|
||
if (!data) {
|
||
return <EmptyState message="暂无数据" />
|
||
}
|
||
|
||
const handleToggle = (key: SystemSettingKey, value: boolean) => {
|
||
void updateSystemSetting(key, value).catch(() => undefined)
|
||
}
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<section
|
||
className={cn(
|
||
'relative overflow-hidden rounded-3xl border mb-6 flex-shrink-0',
|
||
theme === 'dark'
|
||
? 'border-slate-600/60 text-white shadow-[0_28px_60px_rgba(139,92,246,0.2)] hover:shadow-[0_35px_65px_rgba(139,92,246,0.3)]'
|
||
: 'border-slate-200/70 text-slate-900 shadow-[0_28px_60px_rgba(15,23,42,0.28)] hover:shadow-[0_35px_65px_rgba(15,23,42,0.35)]',
|
||
'transition-all duration-500 p-6',
|
||
)}
|
||
>
|
||
<div className="relative z-10 flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="space-y-3">
|
||
<p className={cn('text-sm font-semibold tracking-[0.2em] uppercase', theme === 'dark' ? 'text-indigo-400' : 'text-indigo-600')}>
|
||
System Monitor
|
||
</p>
|
||
<h2 className={cn('text-2xl font-semibold', textTitle)}>系统监控概览</h2>
|
||
<p className={cn('text-sm leading-relaxed', textDesc)}>最近更新:{formattedLastUpdated}</p>
|
||
</div>
|
||
<div className="flex items-center gap-3">
|
||
{refreshing && (
|
||
<span className={cn('text-xs flex items-center gap-1', textDesc)}>
|
||
<span className="inline-block h-3 w-3 rounded-full border-2 border-slate-300 border-t-indigo-500 animate-spin" />
|
||
后台同步中...
|
||
</span>
|
||
)}
|
||
<button
|
||
type="button"
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
'flex items-center gap-2',
|
||
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200' // 显式添加背景色、边框和文字色
|
||
)}
|
||
onClick={handleManualRefresh}
|
||
>
|
||
<svg className={cn('w-4 h-4', refreshing && 'animate-spin')} fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 4v6h6M20 20v-6h-6M5 19a9 9 0 0014-7 9 9 0 00-9-9" />
|
||
</svg>
|
||
刷新数据
|
||
</button>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-5 gap-6">
|
||
{OVERVIEW_CARDS.map((card) => (
|
||
<div key={card.label} className={cn(styles.cardStyles.base, 'overflow-hidden')}>
|
||
<div className="p-5">
|
||
<div className="flex items-center">
|
||
<div className="flex-shrink-0">
|
||
<div className={cn('w-8 h-8 rounded-full flex items-center justify-center shadow-sm', card.className)}>
|
||
<span className="text-white font-medium">{card.icon}</span>
|
||
</div>
|
||
</div>
|
||
<div className="ml-5 w-0 flex-1">
|
||
<dl>
|
||
<dt className={cn('text-sm font-medium truncate', textLabel)}>{card.label}</dt>
|
||
<dd className={cn('text-lg font-semibold', textValue)}>{card.getValue(data)}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>📊 素材上传趋势(最近12个月)</h3>
|
||
<div className="h-64 overflow-y-auto">
|
||
{data.trends.materialUploads.length ? (
|
||
<div className="space-y-2">
|
||
{data.trends.materialUploads.map((item) => (
|
||
<div
|
||
key={item.date}
|
||
className={cn('flex justify-between items-center py-2 border-b', borderRow)}
|
||
>
|
||
<span className={cn('text-sm', textRow)}>{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 transition-all"
|
||
style={{ width: `${Math.min(100, (item.count / materialUploadMax) * 100)}%` }}
|
||
/>
|
||
</div>
|
||
<span className={cn('text-sm font-semibold', textValue)}>{item.count}</span>
|
||
</div>
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className={cn('h-full flex items-center justify-center text-sm', textDesc)}>
|
||
暂无趋势数据
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h3 className="text-lg font-semibold text-slate-900 dark:text-white mb-4">🏢 部门分布</h3>
|
||
<div className="space-y-3">
|
||
{data.distribution.departments.length ? (
|
||
data.distribution.departments.map((item) => (
|
||
<div key={`${item.department || 'unknown'}`} className="flex justify-between items-center">
|
||
<span className={cn('text-sm', textRow)}>{item.department || '未设置'}</span>
|
||
<span className={cn('text-sm font-semibold', textValue)}>{item.count}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className={cn('text-sm', textDesc)}>暂无部门分布数据</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>🛡️ 权限分布</h3>
|
||
<div className="space-y-3">
|
||
{data.distribution.roles.length ? (
|
||
data.distribution.roles.map((item, index) => (
|
||
<div key={`${item.role}-${index}`} className="flex justify-between items-center">
|
||
<span className={cn('text-sm', textRow)}>
|
||
{item.role === 'ADMIN' && item.isSuperAdmin
|
||
? '超级管理员'
|
||
: item.role === 'ADMIN'
|
||
? '管理员'
|
||
: '普通用户'}
|
||
</span>
|
||
<span className={cn('text-sm font-semibold', textValue)}>{item.count}</span>
|
||
</div>
|
||
))
|
||
) : (
|
||
<p className={cn('text-sm', textDesc)}>暂无权限分布数据</p>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>⚙️ 系统信息</h3>
|
||
<div className="space-y-3">
|
||
<SystemToggleRow
|
||
label="注册功能"
|
||
editable={currentIsSuperAdmin}
|
||
enabled={data.systemInfo.registrationEnabled}
|
||
onToggle={(value) => handleToggle('registrationEnabled', value)}
|
||
activeText="开启"
|
||
inactiveText="关闭"
|
||
/>
|
||
<SystemToggleRow
|
||
label="用户激活"
|
||
editable={currentIsSuperAdmin}
|
||
enabled={data.systemInfo.userActivationRequired}
|
||
onToggle={(value) => handleToggle('userActivationRequired', value)}
|
||
activeText="需要"
|
||
inactiveText="关闭"
|
||
/>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-400">数据库状态</span>
|
||
<span
|
||
className={cn(
|
||
'text-sm font-medium',
|
||
data.status.database === 'healthy' ? 'text-green-600 dark:text-green-400' : 'text-red-600 dark:text-red-400',
|
||
)}
|
||
>
|
||
{data.status.database === 'healthy' ? '正常' : '异常'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-400">运行时间</span>
|
||
<span className="text-sm font-semibold text-slate-900 dark:text-white">
|
||
{Math.floor(data.status.uptime / 3600)} 小时
|
||
</span>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>🕒 最近活动</h3>
|
||
<div className="overflow-x-auto">
|
||
<table className={cn('min-w-full divide-y', theme === 'dark' ? 'divide-slate-700' : 'divide-slate-200')}>
|
||
<thead className={theme === 'dark' ? 'bg-slate-700' : 'bg-slate-50'}>
|
||
<tr>
|
||
<th className={cn('px-6 py-3 text-left text-xs font-medium uppercase tracking-wider', theme === 'dark' ? 'text-slate-300' : 'text-slate-500')}>
|
||
素材标题
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider">
|
||
上传者
|
||
</th>
|
||
<th className="px-6 py-3 text-left text-xs font-medium text-slate-500 dark:text-slate-300 uppercase tracking-wider">
|
||
上传时间
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody className={cn('divide-y', theme === 'dark' ? 'bg-slate-800 divide-slate-700' : 'bg-white divide-slate-200')}>
|
||
{data.recentActivity.map((activity) => (
|
||
<tr key={activity.id} className={theme === 'dark' ? 'hover:bg-slate-700' : 'hover:bg-slate-50'}>
|
||
<td className={cn('px-6 py-4 whitespace-nowrap text-sm', textValue)}>{activity.title}</td>
|
||
<td className={cn('px-6 py-4 whitespace-nowrap text-sm', textDesc)}>
|
||
{activity.uploadedBy.username}
|
||
</td>
|
||
<td className={cn('px-6 py-4 whitespace-nowrap text-sm', textDesc)}>
|
||
{new Date(activity.createdAt).toLocaleString('zh-CN')}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
type SystemSettingKey = 'registrationEnabled' | 'userActivationRequired'
|
||
|
||
const OVERVIEW_CARDS: Array<{
|
||
label: string
|
||
icon: string
|
||
className: string
|
||
getValue: (data: DashboardData) => number
|
||
}> = [
|
||
{
|
||
label: '总用户数',
|
||
icon: '👥',
|
||
className: 'bg-blue-500 shadow-blue-500/40',
|
||
getValue: (data) => data.overview.totalUsers,
|
||
},
|
||
{
|
||
label: '活跃用户',
|
||
icon: '⚡',
|
||
className: 'bg-green-500 shadow-green-500/40',
|
||
getValue: (data) => data.overview.activeUsers,
|
||
},
|
||
{
|
||
label: '总素材数',
|
||
icon: '📦',
|
||
className: 'bg-purple-500 shadow-purple-500/40',
|
||
getValue: (data) => data.overview.totalMaterials,
|
||
},
|
||
{
|
||
label: '本月新用户',
|
||
icon: '🆕',
|
||
className: 'bg-yellow-500 shadow-yellow-500/40',
|
||
getValue: (data) => data.overview.newUsersThisMonth,
|
||
},
|
||
{
|
||
label: '本月新素材',
|
||
icon: '📈',
|
||
className: 'bg-indigo-500 shadow-indigo-500/40',
|
||
getValue: (data) => data.overview.newMaterialsThisMonth,
|
||
},
|
||
]
|
||
|
||
interface SystemToggleRowProps {
|
||
label: string
|
||
enabled: boolean
|
||
editable: boolean
|
||
onToggle: (value: boolean) => void
|
||
activeText: string
|
||
inactiveText: string
|
||
}
|
||
|
||
function SystemToggleRow({ label, enabled, editable, onToggle, activeText, inactiveText }: SystemToggleRowProps) {
|
||
// 需要使用主题 Hook,但这个组件在函数外部定义,暂时保留 dark: 前缀
|
||
// 可以考虑将这个组件移到函数内部或传入 theme 参数
|
||
if (!editable) {
|
||
return (
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-400">{label}</span>
|
||
<span className={cn('text-sm font-medium', enabled ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400')}>
|
||
{enabled ? activeText : inactiveText}
|
||
</span>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div className="flex justify-between items-center">
|
||
<span className="text-sm text-slate-600 dark:text-slate-400">{label}</span>
|
||
<label className="relative inline-flex items-center cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
className="sr-only peer"
|
||
checked={enabled}
|
||
onChange={(event) => onToggle(event.target.checked)}
|
||
/>
|
||
<div className="w-11 h-6 bg-slate-200 peer-focus:outline-none peer-focus:ring-4 peer-focus:ring-blue-300 dark:peer-focus:ring-blue-800 rounded-full peer dark:bg-slate-700 peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-white after:border-slate-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all dark:border-slate-600 peer-checked:bg-blue-600" />
|
||
</label>
|
||
</div>
|
||
)
|
||
}
|
||
|