1036 lines
43 KiB
TypeScript
1036 lines
43 KiB
TypeScript
/**
|
||
* 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<Array<{ time: string; level: string; message: string }>>([])
|
||
const [materialLogs, setMaterialLogs] = useState<Array<{
|
||
id: string
|
||
type: string
|
||
action: string
|
||
materialId: string
|
||
materialName: string
|
||
username: string
|
||
userEmail: string
|
||
timestamp: string
|
||
actionDetail?: string | string[]
|
||
}>>([])
|
||
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 <LoadingState message="加载系统监控数据..." />
|
||
}
|
||
|
||
if (error) {
|
||
return <ErrorState message={error} onRetry={() => refresh()} />
|
||
}
|
||
|
||
if (!data) {
|
||
return <EmptyState message="暂无数据" />
|
||
}
|
||
|
||
|
||
return (
|
||
<div className="space-y-8">
|
||
<section
|
||
className={cn(
|
||
'rounded-lg border mb-6 flex-shrink-0',
|
||
theme === 'dark'
|
||
? 'border-slate-700 bg-slate-900/80 text-white'
|
||
: 'border-slate-200 bg-white text-slate-900',
|
||
'p-5',
|
||
)}
|
||
>
|
||
<div className="flex flex-col gap-4 lg:flex-row lg:items-center lg:justify-between">
|
||
<div className="space-y-1.5">
|
||
<h2 className={cn('text-xl font-semibold', textTitle)}>系统监控概览</h2>
|
||
<p className={cn('text-sm', 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>
|
||
{currentIsAdmin && (
|
||
<>
|
||
<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={() => setShowExportModal(true)}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
导出素材CSV
|
||
</button>
|
||
<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={async () => {
|
||
setShowLogsModal(true)
|
||
await fetchMaterialLogs()
|
||
}}
|
||
>
|
||
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
查看日志
|
||
</button>
|
||
</>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-6">
|
||
{OVERVIEW_CARDS.map((card) => {
|
||
const CardContent = (
|
||
<div className="p-5">
|
||
<div className="flex items-center gap-4">
|
||
<span className={cn('h-10 w-1 rounded-full', card.accentClass)} aria-hidden="true" />
|
||
<dl className="min-w-0 flex-1">
|
||
<dt className={cn('text-sm font-medium truncate', textLabel)}>{card.label}</dt>
|
||
<dd className={cn('text-2xl font-semibold mt-1', textValue)}>{card.getValue(data)}</dd>
|
||
</dl>
|
||
</div>
|
||
</div>
|
||
)
|
||
|
||
if (card.href) {
|
||
return (
|
||
<div
|
||
key={card.label}
|
||
className={cn(
|
||
styles.cardStyles.base,
|
||
'overflow-hidden cursor-pointer transition-colors',
|
||
theme === 'dark' ? 'hover:border-slate-500 hover:bg-slate-800/80' : 'hover:border-slate-300 hover:bg-slate-50'
|
||
)}
|
||
onClick={() => router.push(card.href!)}
|
||
title={`点击查看${card.label}详情`}
|
||
>
|
||
{CardContent}
|
||
</div>
|
||
)
|
||
}
|
||
|
||
return (
|
||
<div key={card.label} className={cn(styles.cardStyles.base, 'overflow-hidden')}>
|
||
{CardContent}
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
|
||
<div className={cn(styles.cardStyles.base, 'p-6')}>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h3 className={cn('text-lg font-semibold', textTitle)}>本月新素材(每日上传)</h3>
|
||
<span className={cn('text-sm', textDesc)}>累计 {currentMonthDailyData.reduce((sum, item) => sum + item.count, 0)}</span>
|
||
</div>
|
||
<div className="h-64">
|
||
{currentMonthDailyData.length ? (
|
||
<div className="h-full flex items-end gap-3 overflow-x-auto pb-4">
|
||
{currentMonthDailyData.map((item) => {
|
||
const barHeight = Math.max(8, (item.count / currentMonthDailyMax) * 100)
|
||
return (
|
||
<div key={item.date} className="flex flex-col items-center min-w-[40px]">
|
||
<span className={cn('text-xs font-semibold mb-2', textValue)}>{item.count}</span>
|
||
<div className="w-7 h-44 rounded-full bg-slate-200 dark:bg-slate-700 flex items-end overflow-hidden">
|
||
<div
|
||
className="w-full bg-indigo-600 dark:bg-indigo-500 transition-all"
|
||
style={{ height: `${barHeight}%` }}
|
||
/>
|
||
</div>
|
||
<span className={cn('text-[11px] mt-2 text-center', textRow)}>{formatDayLabel(item.date)}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className={cn('h-full flex items-center justify-center text-sm', textDesc)}>本月暂未上传素材</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
<div className={cn(styles.cardStyles.base, 'p-6')}>
|
||
<div className="mb-4 flex items-center justify-between">
|
||
<h3 className={cn('text-lg font-semibold', textTitle)}>素材上传趋势(最近12个月)</h3>
|
||
{monthlyUploads.length > 0 && (
|
||
<div className={cn('text-sm font-medium', textValue)}>
|
||
最近月份上传:
|
||
<span className="ml-1 text-purple-600 dark:text-purple-400">
|
||
{monthlyUploads[monthlyUploads.length - 1]?.count || 0}
|
||
</span>
|
||
</div>
|
||
)}
|
||
</div>
|
||
<div className="h-64">
|
||
{monthlyUploads.length ? (
|
||
<div className="h-full flex items-end gap-4 overflow-x-auto pb-4">
|
||
{monthlyUploads.map((item) => {
|
||
const barHeight = Math.max(8, (item.count / materialUploadMonthlyMax) * 100)
|
||
return (
|
||
<div key={item.date} className="flex flex-col items-center min-w-[48px]">
|
||
<span className={cn('text-xs font-semibold mb-2', textValue)}>{item.count}</span>
|
||
<div className="w-8 h-44 rounded-full bg-slate-200 dark:bg-slate-700 flex items-end overflow-hidden">
|
||
<div
|
||
className="w-full bg-purple-600 dark:bg-purple-500 transition-all"
|
||
style={{ height: `${barHeight}%` }}
|
||
/>
|
||
</div>
|
||
<span className={cn('text-[11px] mt-2 text-center', textRow)}>{item.date}</span>
|
||
</div>
|
||
)
|
||
})}
|
||
</div>
|
||
) : (
|
||
<div className={cn('h-full flex items-center justify-center text-sm', textDesc)}>暂无趋势数据</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="grid grid-cols-1 lg:grid-cols-3 gap-8">
|
||
<div className={cn(styles.cardStyles.base)}>
|
||
<h3 className={cn('text-lg font-semibold mb-4', textTitle)}>架构分布(一级架构)</h3>
|
||
<div className="space-y-2 h-48 overflow-y-auto pr-1">
|
||
{(() => {
|
||
// 只显示一级部门(没有"-"分隔符的部门)
|
||
if (!data.distribution.departments.length) {
|
||
return <p className={cn('text-sm', textDesc)}>暂无架构分布数据</p>
|
||
}
|
||
|
||
const deptMap = new Map<string, number>()
|
||
|
||
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) => (
|
||
<div key={item.department} className="flex justify-between items-center py-1.5">
|
||
<span className={cn('text-sm truncate flex-1', textRow)} title={item.department}>
|
||
{item.department}
|
||
</span>
|
||
<span className={cn('text-sm font-semibold ml-2', 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">
|
||
<div className="flex justify-between items-center">
|
||
<span className={cn('text-sm', textRow)}>站点名称</span>
|
||
<span className={cn('text-sm font-semibold', textValue)}>{data.systemInfo.siteName || '未设置'}</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className={cn('text-sm', textRow)}>注册功能</span>
|
||
<span className={cn('text-sm font-medium', data.systemInfo.registrationEnabled ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400')}>
|
||
{data.systemInfo.registrationEnabled ? '开启' : '关闭'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className={cn('text-sm', textRow)}>用户激活</span>
|
||
<span className={cn('text-sm font-medium', data.systemInfo.userActivationRequired ? 'text-green-600 dark:text-green-400' : 'text-slate-500 dark:text-slate-400')}>
|
||
{data.systemInfo.userActivationRequired ? '需要' : '关闭'}
|
||
</span>
|
||
</div>
|
||
<div className="flex justify-between items-center">
|
||
<span className={cn('text-sm', textRow)}>数据库状态</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={cn('text-sm', textRow)}>运行时间</span>
|
||
<span className={cn('text-sm font-semibold', textValue)}>
|
||
{Math.floor(data.status.uptime / 3600)} 小时 {Math.floor((data.status.uptime % 3600) / 60)} 分钟
|
||
</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 && data.recentActivity.length > 0 ? (
|
||
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>
|
||
))
|
||
) : (
|
||
<tr>
|
||
<td colSpan={3} className={cn('px-6 py-8 text-center text-sm', textDesc)}>
|
||
暂无最近活动
|
||
</td>
|
||
</tr>
|
||
)}
|
||
</tbody>
|
||
</table>
|
||
</div>
|
||
</div>
|
||
|
||
{/* 导出素材CSV弹窗 */}
|
||
<Modal
|
||
isOpen={showExportModal}
|
||
onClose={() => setShowExportModal(false)}
|
||
title="导出素材数据"
|
||
size="medium"
|
||
footer={
|
||
<div className="flex justify-end gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowExportModal(false)}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200'
|
||
)}
|
||
>
|
||
取消
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleExportMaterials}
|
||
className={cn(styles.buttonStyles.primary)}
|
||
>
|
||
导出
|
||
</button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-4">
|
||
<p className={cn('text-sm', textDesc)}>请选择要导出的字段:</p>
|
||
<div className="space-y-3">
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportOptions.includeBasicInfo}
|
||
onChange={(e) => setExportOptions({ ...exportOptions, includeBasicInfo: e.target.checked })}
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span className={cn('text-sm', textValue)}>基本信息(ID、产品型号、品牌、系列、SKU、材质)</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportOptions.includeStats}
|
||
onChange={(e) => setExportOptions({ ...exportOptions, includeStats: e.target.checked })}
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span className={cn('text-sm', textValue)}>统计数据(下载次数、点赞次数、是否精品)</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportOptions.includeUserInfo}
|
||
onChange={(e) => setExportOptions({ ...exportOptions, includeUserInfo: e.target.checked })}
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span className={cn('text-sm', textValue)}>用户信息(上传者、作者名称)</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportOptions.includeDates}
|
||
onChange={(e) => setExportOptions({ ...exportOptions, includeDates: e.target.checked })}
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span className={cn('text-sm', textValue)}>时间信息(创建时间、更新时间)</span>
|
||
</label>
|
||
<label className="flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="checkbox"
|
||
checked={exportOptions.includeFiles}
|
||
onChange={(e) => setExportOptions({ ...exportOptions, includeFiles: e.target.checked })}
|
||
className="w-4 h-4 rounded border-slate-300 dark:border-slate-600"
|
||
/>
|
||
<span className={cn('text-sm', textValue)}>文件信息(ZIP文件名、文件大小)</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
</Modal>
|
||
|
||
{/* 素材操作日志弹窗 */}
|
||
<Modal
|
||
isOpen={showLogsModal}
|
||
onClose={() => setShowLogsModal(false)}
|
||
title="素材操作日志"
|
||
size="large"
|
||
footer={
|
||
<div className="flex justify-between items-center gap-3">
|
||
<button
|
||
type="button"
|
||
onClick={fetchMaterialLogs}
|
||
disabled={loadingMaterialLogs}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
|
||
loadingMaterialLogs && 'opacity-50 cursor-not-allowed'
|
||
)}
|
||
>
|
||
{loadingMaterialLogs ? '刷新中...' : '刷新'}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={handleExportMaterialLogs}
|
||
disabled={materialLogs.length === 0}
|
||
className={cn(
|
||
styles.buttonStyles.secondary,
|
||
'bg-white dark:bg-slate-800 border-slate-300 dark:border-slate-600 text-slate-700 dark:text-slate-200',
|
||
materialLogs.length === 0 && 'opacity-50 cursor-not-allowed'
|
||
)}
|
||
>
|
||
<svg className="w-4 h-4 mr-2 inline" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 10v6m0 0l-3-3m3 3l3-3m2 8H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
|
||
</svg>
|
||
导出CSV
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() => setShowLogsModal(false)}
|
||
className={cn(styles.buttonStyles.secondary)}
|
||
>
|
||
关闭
|
||
</button>
|
||
</div>
|
||
}
|
||
>
|
||
<div className="space-y-2 max-h-96 overflow-y-auto">
|
||
{loadingMaterialLogs ? (
|
||
<div className={cn('text-center py-8', textDesc)}>
|
||
<span className="inline-block h-4 w-4 rounded-full border-2 border-slate-300 border-t-indigo-500 animate-spin mr-2" />
|
||
加载中...
|
||
</div>
|
||
) : materialLogs.length === 0 ? (
|
||
<p className={cn('text-sm text-center py-8', textDesc)}>暂无操作日志</p>
|
||
) : (
|
||
materialLogs.map((log) => {
|
||
const cardColorMap: Record<string, string> = {
|
||
upload: 'border-green-500/50 bg-green-50/50 dark:bg-green-900/20',
|
||
update: 'border-blue-500/50 bg-blue-50/50 dark:bg-blue-900/20',
|
||
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<string, string> = {
|
||
upload: 'bg-green-500 text-white',
|
||
update: 'bg-blue-500 text-white',
|
||
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 (
|
||
<div
|
||
key={log.id}
|
||
className={cn(
|
||
'p-3 rounded-lg border text-sm',
|
||
cardTone
|
||
)}
|
||
>
|
||
<div className="flex items-start justify-between gap-2">
|
||
<div className="flex-1">
|
||
<div className="flex items-center gap-2 mb-1">
|
||
<span className={cn('text-xs font-semibold px-2 py-0.5 rounded', badgeTone)}>
|
||
{log.action}
|
||
</span>
|
||
<span className={cn('text-xs font-mono', textDesc)}>
|
||
{new Date(log.timestamp).toLocaleString('zh-CN')}
|
||
</span>
|
||
</div>
|
||
<p className={cn('font-medium', textValue)}>
|
||
素材:{log.materialName || '未命名素材'}
|
||
</p>
|
||
{(log as any).actionDetail && (
|
||
<div className={cn('text-xs mt-2 space-y-1', textDesc)}>
|
||
{Array.isArray((log as any).actionDetail) ? (
|
||
(log as any).actionDetail.map((detail: string, idx: number) => (
|
||
<p key={idx} className="leading-relaxed">
|
||
{detail}
|
||
</p>
|
||
))
|
||
) : (
|
||
<p className="leading-relaxed">{(log as any).actionDetail}</p>
|
||
)}
|
||
</div>
|
||
)}
|
||
<p className={cn('text-xs mt-1', textDesc)}>
|
||
操作人:{log.username} ({log.userEmail})
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
)
|
||
})
|
||
)}
|
||
</div>
|
||
</Modal>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
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-indigo-500 dark:bg-indigo-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',
|
||
},
|
||
]
|