/** * MonitorTab 组件 * * 系统监控仪表板Tab组件 * 从主页面拆分出来,负责显示系统监控数据 * 包括用户统计、素材统计、系统状态等信息 * 使用三层架构样式系统,支持深浅色主题 * * @created 2025-01-XX(拆分自 page.tsx) * @component */ 'use client' import { useEffect, useMemo, useState } from 'react' import { useSession } from 'next-auth/react' import { useRouter } from 'next/navigation' 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' import Modal from '@/components/ui/Modal' interface MonitorTabProps { showToast: ToastFn } const AUTO_REFRESH_INTERVAL = 60_000 export default function MonitorTab({ showToast }: MonitorTabProps) { const styles = useThemeStyles() const theme = useTheme() const router = useRouter() // 根据主题选择颜色类名 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 currentIsAdmin = Boolean((session?.user as any)?.role === 'ADMIN') const { data, loading, error, refresh, lastUpdated, refreshing } = useDashboardData({ showToast }) const [showExportModal, setShowExportModal] = useState(false) const [showLogsModal, setShowLogsModal] = useState(false) const [exportOptions, setExportOptions] = useState({ includeBasicInfo: true, includeStats: true, includeUserInfo: true, includeDates: true, includeFiles: false, }) const [logs, setLogs] = useState>([]) const [materialLogs, setMaterialLogs] = useState>([]) const [loadingMaterialLogs, setLoadingMaterialLogs] = useState(false) useEffect(() => { refresh() addLog('info', '监控仪表板已加载') }, [refresh]) useEffect(() => { const timer = setInterval(() => { void refresh({ background: true }) addLog('info', '自动刷新数据') }, AUTO_REFRESH_INTERVAL) return () => clearInterval(timer) }, [refresh]) useEffect(() => { if (data) { addLog('info', '数据加载成功') // 检查数据完整性 console.log('[MonitorTab] Raw data.trends:', data.trends) console.log('[MonitorTab] materialUploadsDaily:', data.trends?.materialUploadsDaily) console.log('[MonitorTab] materialUploadsMonthly:', data.trends?.materialUploadsMonthly) if (!data.trends?.materialUploadsDaily || data.trends.materialUploadsDaily.length === 0) { addLog('warn', '本月新素材数据为空,请检查API返回') console.warn('[MonitorTab] materialUploadsDaily is empty') } else { console.log('[MonitorTab] materialUploadsDaily sample:', data.trends.materialUploadsDaily.slice(0, 5)) } if (!data.trends?.materialUploadsMonthly || data.trends.materialUploadsMonthly.length === 0) { addLog('warn', '素材上传趋势(月度)数据为空,请检查API返回') console.warn('[MonitorTab] materialUploadsMonthly is empty') } else { console.log('[MonitorTab] materialUploadsMonthly sample:', data.trends.materialUploadsMonthly.slice(0, 5)) } } }, [data]) useEffect(() => { if (error) { addLog('error', `数据加载失败: ${error}`) } }, [error]) const formattedLastUpdated = useMemo(() => { if (!lastUpdated) return '尚未获取' return formatDateTimeLocale(lastUpdated, 'zh-CN', { hour12: false }) }, [lastUpdated]) const handleManualRefresh = () => { addLog('info', '手动刷新数据') void refresh({ background: Boolean(data) }) } // 导出素材CSV功能 const handleExportMaterials = async () => { const escapeCell = (value: string | number | null | undefined) => `"${String(value ?? '').replace(/"/g, '""')}"` try { const [materialsResponse, logsResponse] = await Promise.all([ fetch('/api/admin/materials/export?limit=10000'), fetch('/api/admin/material-logs?limit=200'), ]) if (!materialsResponse.ok) throw new Error('获取素材数据失败') const result = await materialsResponse.json() const materials = result.materials || [] const logsPayload = logsResponse.ok ? await logsResponse.json() : null const materialLogsForExport = (logsPayload?.logs || []).slice(0, 200) const csvRows: string[] = [] const headers: string[] = [] if (exportOptions.includeBasicInfo) { headers.push('ID', '产品型号', '品牌名称', '系列名称', 'SKU编码', '材质纹理') } if (exportOptions.includeStats) { headers.push('下载次数', '点赞次数', '是否精品') } if (exportOptions.includeUserInfo) { headers.push('上传者', '作者名称') } if (exportOptions.includeDates) { headers.push('创建时间', '更新时间') } if (exportOptions.includeFiles) { headers.push('ZIP文件名', 'ZIP文件大小(字节)') } headers.push('分类', '场景', '平台', '素材类型', '渲染器', '渲染时间', '图像分辨率', '颜色模式') csvRows.push(headers.join(',')) materials.forEach((material: any) => { const row: string[] = [] if (exportOptions.includeBasicInfo) { row.push( material.id || '', `"${(material.productModel || '').replace(/"/g, '""')}"`, `"${(material.brandName || '').replace(/"/g, '""')}"`, `"${(material.seriesName || '').replace(/"/g, '""')}"`, `"${(material.skuCode || '').replace(/"/g, '""')}"`, `"${(material.materialTexture || '').replace(/"/g, '""')}"` ) } if (exportOptions.includeStats) { row.push( material.downloadCount || 0, material.likeCount || 0, material.isPremium ? '是' : '否' ) } if (exportOptions.includeUserInfo) { row.push( `"${(material.user?.username || '').replace(/"/g, '""')}"`, `"${(material.authorName || '').replace(/"/g, '""')}"` ) } if (exportOptions.includeDates) { row.push( material.createdAt ? new Date(material.createdAt).toLocaleString('zh-CN') : '', material.updatedAt ? new Date(material.updatedAt).toLocaleString('zh-CN') : '' ) } if (exportOptions.includeFiles) { row.push( `"${(material.zipFileName || '').replace(/"/g, '""')}"`, material.zipFileSize?.toString() || '0' ) } row.push( `"${(material.categoryName || '').replace(/"/g, '""')}"`, `"${(material.sceneName || '').replace(/"/g, '""')}"`, `"${(material.platformName || '').replace(/"/g, '""')}"`, `"${(material.materialType || '').replace(/"/g, '""')}"`, `"${(material.renderer || '').replace(/"/g, '""')}"`, `"${(material.renderTime || '').replace(/"/g, '""')}"`, `"${(material.imageResolution || '').replace(/"/g, '""')}"`, `"${(material.colorMode || '').replace(/"/g, '""')}"` ) csvRows.push(row.join(',')) }) if (materialLogsForExport.length) { csvRows.push('') csvRows.push('=== 素材操作日志(最近200条) ===') csvRows.push('时间,操作,素材名称,操作人,邮箱') materialLogsForExport.forEach((log: any) => { const row = [ escapeCell(log.timestamp), escapeCell(log.action), escapeCell(log.materialName), escapeCell(log.username), escapeCell(log.userEmail), ] csvRows.push(row.join(',')) }) } const csvContent = csvRows.join('\n') const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `素材导出_${new Date().toISOString().split('T')[0]}.csv` link.click() URL.revokeObjectURL(url) setShowExportModal(false) showToast('素材数据导出成功', 'success') // 添加日志 addLog('info', `导出素材数据,共 ${materials.length} 条记录`) } catch (error) { console.error('导出失败:', error) showToast('导出失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error') addLog('error', `导出素材数据失败: ${error instanceof Error ? error.message : '未知错误'}`) } } // 添加日志 const addLog = (level: string, message: string) => { const newLog = { time: new Date().toLocaleString('zh-CN'), level, message, } setLogs((prev) => [newLog, ...prev].slice(0, 100)) // 只保留最近100条 } // 获取素材操作日志 const fetchMaterialLogs = async () => { setLoadingMaterialLogs(true) try { const response = await fetch('/api/admin/material-logs?limit=100') if (!response.ok) { const errorData = await response.json().catch(() => ({})) throw new Error(errorData.error || `获取素材日志失败 (状态码: ${response.status})`) } const result = await response.json() setMaterialLogs(result.logs || []) } catch (error: any) { console.error('获取素材日志失败:', error) const errorMessage = error?.message || '获取素材日志失败,请稍后重试' showToast(errorMessage, 'error') } finally { setLoadingMaterialLogs(false) } } // 导出素材日志CSV const handleExportMaterialLogs = () => { try { const escapeCell = (value: string | number | null | undefined) => `"${String(value ?? '').replace(/"/g, '""')}"` const csvRows: string[] = [] csvRows.push('时间,操作,素材名称,素材ID,操作人,邮箱') materialLogs.forEach((log) => { const row = [ escapeCell(new Date(log.timestamp).toLocaleString('zh-CN')), escapeCell(log.action), escapeCell(log.materialName), escapeCell(log.materialId), escapeCell(log.username), escapeCell(log.userEmail), ] csvRows.push(row.join(',')) }) const csvContent = csvRows.join('\n') const blob = new Blob(['\ufeff' + csvContent], { type: 'text/csv;charset=utf-8;' }) const url = URL.createObjectURL(blob) const link = document.createElement('a') link.href = url link.download = `素材操作日志_${new Date().toISOString().split('T')[0]}.csv` link.click() URL.revokeObjectURL(url) showToast('素材日志导出成功', 'success') addLog('info', `导出素材日志,共 ${materialLogs.length} 条记录`) } catch (error) { console.error('导出日志失败:', error) showToast('导出日志失败: ' + (error instanceof Error ? error.message : '未知错误'), 'error') addLog('error', `导出素材日志失败: ${error instanceof Error ? error.message : '未知错误'}`) } } const monthlyUploads = useMemo(() => { if (!data?.trends?.materialUploadsMonthly) { console.log('[MonitorTab] No materialUploadsMonthly data') return [] } // 只显示最近12个月的数据 const now = new Date() const twelveMonthsAgo = new Date(now.getFullYear(), now.getMonth() - 12, 1) console.log('[MonitorTab] Filtering monthly data from:', twelveMonthsAgo.toISOString()) console.log('[MonitorTab] Total monthly records:', data.trends.materialUploadsMonthly.length) const filtered = data.trends.materialUploadsMonthly .filter((item) => { try { if (!item.date) { console.warn('[MonitorTab] Monthly item has no date:', item) return false } // 解析日期,支持 YYYY-MM 格式 let date: Date if (typeof item.date === 'string') { if (item.date.match(/^\d{4}-\d{2}$/)) { const parts = item.date.split('-').map((part) => Number(part)) date = new Date(parts[0], parts[1] - 1, 1) } else { date = new Date(item.date) } } else { date = new Date(item.date) } const matches = date >= twelveMonthsAgo if (matches) { console.log('[MonitorTab] Matched monthly item:', item.date, '->', date.toISOString(), 'count:', item.count) } return matches } catch (error) { console.error('[MonitorTab] Error parsing monthly date:', item.date, error) return false } }) console.log('[MonitorTab] Filtered monthly data:', filtered.length, 'items') return filtered.sort((a, b) => { try { const dateA = typeof a.date === 'string' && a.date.match(/^\d{4}-\d{2}$/) ? new Date(a.date.split('-').map(Number).join('-') + '-01') : new Date(a.date) const dateB = typeof b.date === 'string' && b.date.match(/^\d{4}-\d{2}$/) ? new Date(b.date.split('-').map(Number).join('-') + '-01') : new Date(b.date) return dateA.getTime() - dateB.getTime() } catch { return 0 } }) }, [data?.trends?.materialUploadsMonthly]) const materialUploadMonthlyMax = monthlyUploads.length ? Math.max(...monthlyUploads.map((item) => item.count), 1) : 1 const currentMonthDailyData = useMemo(() => { if (!data?.trends?.materialUploadsDaily) { console.log('[MonitorTab] No materialUploadsDaily data') return [] } const now = new Date() const currentYear = now.getFullYear() const currentMonth = now.getMonth() console.log('[MonitorTab] Filtering for current month:', currentYear, currentMonth + 1) console.log('[MonitorTab] Total daily records:', data.trends.materialUploadsDaily.length) // 过滤出本月的数据,使用更宽松的匹配 const filtered = data.trends.materialUploadsDaily.filter((item) => { try { if (!item.date) { console.warn('[MonitorTab] Item has no date:', item) return false } // 尝试多种日期格式解析 let date: Date if (typeof item.date === 'string') { // 如果是 YYYY-MM-DD 格式 if (item.date.match(/^\d{4}-\d{2}-\d{2}$/)) { const parts = item.date.split('-').map((part) => Number(part)) date = new Date(parts[0], parts[1] - 1, parts[2]) } else { date = new Date(item.date) } } else { date = new Date(item.date) } const itemYear = date.getFullYear() const itemMonth = date.getMonth() const matches = itemMonth === currentMonth && itemYear === currentYear if (matches) { console.log('[MonitorTab] Matched item:', item.date, '->', date.toISOString(), 'count:', item.count) } return matches } catch (error) { console.error('[MonitorTab] Error parsing date:', item.date, error) return false } }) console.log('[MonitorTab] Filtered current month daily data:', filtered.length, 'items') // 按日期排序 return filtered.sort((a, b) => { try { const dateA = typeof a.date === 'string' && a.date.match(/^\d{4}-\d{2}-\d{2}$/) ? new Date(a.date.split('-').map(Number).join('-')) : new Date(a.date) const dateB = typeof b.date === 'string' && b.date.match(/^\d{4}-\d{2}-\d{2}$/) ? new Date(b.date.split('-').map(Number).join('-')) : new Date(b.date) return dateA.getTime() - dateB.getTime() } catch { return 0 } }) }, [data?.trends?.materialUploadsDaily]) const currentMonthDailyMax = currentMonthDailyData.length ? Math.max(...currentMonthDailyData.map((item) => item.count), 1) : 1 const parseDateStringToLocal = (value: string) => { const parts = value.split('-').map((part) => Number(part)) if (parts.length < 3) return new Date(value) const [year, month, day] = parts return new Date(year, month - 1, day) } const formatDayLabel = (value: string) => parseDateStringToLocal(value).toLocaleDateString('zh-CN', { day: '2-digit', }) if (loading) { return } if (error) { return refresh()} /> } if (!data) { return } return (

系统监控概览

最近更新:{formattedLastUpdated}

{refreshing && ( 后台同步中... )} {currentIsAdmin && ( <> )}
{OVERVIEW_CARDS.map((card) => { const CardContent = (
) if (card.href) { return (
router.push(card.href!)} title={`点击查看${card.label}详情`} > {CardContent}
) } return (
{CardContent}
) })}

本月新素材(每日上传)

累计 {currentMonthDailyData.reduce((sum, item) => sum + item.count, 0)}
{currentMonthDailyData.length ? (
{currentMonthDailyData.map((item) => { const barHeight = Math.max(8, (item.count / currentMonthDailyMax) * 100) return (
{item.count}
{formatDayLabel(item.date)}
) })}
) : (
本月暂未上传素材
)}

素材上传趋势(最近12个月)

{monthlyUploads.length > 0 && (
最近月份上传: {monthlyUploads[monthlyUploads.length - 1]?.count || 0}
)}
{monthlyUploads.length ? (
{monthlyUploads.map((item) => { const barHeight = Math.max(8, (item.count / materialUploadMonthlyMax) * 100) return (
{item.count}
{item.date}
) })}
) : (
暂无趋势数据
)}

架构分布(一级架构)

{(() => { // 只显示一级部门(没有"-"分隔符的部门) if (!data.distribution.departments.length) { return

暂无架构分布数据

} const deptMap = new Map() data.distribution.departments.forEach((item) => { const dept = item.department || '未设置' // 如果部门名包含"-",取第一部分作为一级部门 const firstLevel = dept.includes('-') ? dept.split('-')[0] : dept deptMap.set(firstLevel, (deptMap.get(firstLevel) || 0) + item.count) }) const firstLevelDepartments = Array.from(deptMap.entries()) .map(([department, count]) => ({ department, count })) .sort((a, b) => b.count - a.count) return firstLevelDepartments.length ? ( firstLevelDepartments.map((item) => (
{item.department} {item.count}
)) ) : (

暂无架构分布数据

) })()}

权限分布

{data.distribution.roles.length ? ( data.distribution.roles.map((item, index) => (
{item.role === 'ADMIN' && item.isSuperAdmin ? '超级管理员' : item.role === 'ADMIN' ? '管理员' : '普通用户'} {item.count}
)) ) : (

暂无权限分布数据

)}

系统信息

站点名称 {data.systemInfo.siteName || '未设置'}
注册功能 {data.systemInfo.registrationEnabled ? '开启' : '关闭'}
用户激活 {data.systemInfo.userActivationRequired ? '需要' : '关闭'}
数据库状态 {data.status.database === 'healthy' ? '正常' : '异常'}
运行时间 {Math.floor(data.status.uptime / 3600)} 小时 {Math.floor((data.status.uptime % 3600) / 60)} 分钟

最近活动

{data.recentActivity && data.recentActivity.length > 0 ? ( data.recentActivity.map((activity) => ( )) ) : ( )}
素材标题 上传者 上传时间
{activity.title} {activity.uploadedBy.username} {new Date(activity.createdAt).toLocaleString('zh-CN')}
暂无最近活动
{/* 导出素材CSV弹窗 */} setShowExportModal(false)} title="导出素材数据" size="medium" footer={
} >

请选择要导出的字段:

{/* 素材操作日志弹窗 */} setShowLogsModal(false)} title="素材操作日志" size="large" footer={
} >
{loadingMaterialLogs ? (
加载中...
) : materialLogs.length === 0 ? (

暂无操作日志

) : ( materialLogs.map((log) => { const cardColorMap: Record = { upload: 'border-green-500/50 bg-green-50/50 dark:bg-green-900/20', update: 'border-slate-300 bg-slate-50 dark:border-slate-700 dark:bg-slate-800/50', delete: 'border-red-500/50 bg-red-50/50 dark:bg-red-900/20', toggle: 'border-amber-500/50 bg-amber-50/50 dark:bg-amber-900/20', } const badgeColorMap: Record = { upload: 'bg-green-500 text-white', update: 'bg-slate-600 text-white dark:bg-slate-500', delete: 'bg-red-500 text-white', toggle: 'bg-amber-500 text-white', } const cardTone = cardColorMap[log.type] || (theme === 'dark' ? 'border-slate-700 bg-slate-800/50' : 'border-slate-200 bg-slate-50') const badgeTone = badgeColorMap[log.type] || 'bg-slate-500 text-white' return (
{log.action} {new Date(log.timestamp).toLocaleString('zh-CN')}

素材:{log.materialName || '未命名素材'}

{(log as any).actionDetail && (
{Array.isArray((log as any).actionDetail) ? ( (log as any).actionDetail.map((detail: string, idx: number) => (

{detail}

)) ) : (

{(log as any).actionDetail}

)}
)}

操作人:{log.username} ({log.userEmail})

) }) )}
) } const OVERVIEW_CARDS: Array<{ label: string accentClass: string getValue: (data: DashboardData) => number href?: string onClick?: () => void }> = [ { label: '总用户数', accentClass: 'bg-slate-400 dark:bg-slate-500', getValue: (data) => data.overview.totalUsers, href: '/admin?tab=users', }, { label: '活跃用户', accentClass: 'bg-emerald-500 dark:bg-emerald-400', getValue: (data) => data.overview.activeUsers, href: '/admin?tab=users', }, { label: '总素材数', accentClass: 'bg-slate-500 dark:bg-slate-400', getValue: (data) => data.overview.totalMaterials, href: '/', }, { label: '本月新用户', accentClass: 'bg-amber-500 dark:bg-amber-400', getValue: (data) => data.overview.newUsersThisMonth, href: '/admin?tab=users', }, ]