flnxi-Wu/app/admin/_components/MonitorTab.tsx

1036 lines
43 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

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, 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',
},
]