flnxi-Wu/app/admin/_components/MonitorTab.tsx
WuYongJie 8705b9846a
Some checks failed
CI Check / check (push) Has been cancelled
feat: 统一弹窗样式系统与分类管理优化
- 创建统一的Modal组件,采用Apple风格设计
- 实现毛玻璃效果、圆角、动画和细边框描边
- 修复弹窗打开时背景滚动问题
- 修复分类管理中重复卡片和数组字符串显示问题
- 优化选项解析逻辑,添加去重和过滤机制
2025-11-25 09:37:00 +08:00

380 lines
16 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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>
)
}