'use client' import { useCallback, useEffect, useMemo, useRef, useState } from 'react' import TrendLineChart from './TrendLineChart' import { addDays, endOfMonth, format, isAfter, isBefore, isSameDay, isSameMonth, isValid, isWithinInterval, parse, startOfMonth, startOfWeek, subDays, subMonths, } from 'date-fns' type Granularity = 'month' | 'day' type DatePresetKey = 'today' | 'last7' | 'last30' | 'thisMonth' | 'lastMonth' | 'all' type DateFilterMode = DatePresetKey | 'custom' interface MaterialUserUploadStat { userId: string username: string realName: string | null departmentName: string departmentKey: string uploadCount: number percentage?: number } interface MaterialDepartmentUploadStat { departmentKey: string departmentId: string | null departmentName: string uploadCount: number userCount: number percentage: number users?: MaterialUserUploadStat[] } interface MaterialTrendPoint { bucket: string count: number } interface MaterialTypeUploadStat { materialType: string uploadCount: number percentage: number } interface MaterialTypeDistributionStat { materialType: string uploadCount: number percentage: number users: MaterialUserUploadStat[] } interface MaterialDistributionSection { granularity: Granularity period: string label: string totalUploads: number items: TItem[] } interface MaterialUploadStatsData { filters: { startDate: string | null endDate: string | null departmentKey: string | null } summary: { totalUploads: number totalDepartments: number totalUsers: number } departments: MaterialDepartmentUploadStat[] selectedDepartmentName: string selectedUsers: MaterialUserUploadStat[] dailyUploads: MaterialTrendPoint[] monthlyUploads: MaterialTrendPoint[] materialTypeStats: MaterialTypeUploadStat[] departmentDistribution: MaterialDistributionSection materialTypeDistribution: MaterialDistributionSection } interface OperationLogItem { id: string source: 'material_log' | 'download_log' action: string timestamp: string user: { id: string username: string realName: string | null departmentName: string } material: { id: string productModel: string materialType: string } changes: Record | null } interface OperationLogFieldDiff { oldValue?: unknown newValue?: unknown } interface OperationLogResponse { logs: OperationLogItem[] page: number pageSize: number total: number totalPages: number } interface DataStatisticsTabProps { showToast: (message: string, type?: 'success' | 'error') => void canViewLogs?: boolean canSelectDepartment?: boolean departmentScopeName?: string | null } interface StatsQueryState { startDate: string endDate: string departmentKey: string } interface DrilldownState { source: 'department' | 'materialType' key: string title: string totalUploads: number users: MaterialUserUploadStat[] } interface HorizontalBarDatum { key: string label: string value: number percentage: number subText?: string } const DATE_FORMAT = 'yyyy-MM-dd' const DATE_PRESETS: Array<{ key: DatePresetKey; label: string }> = [ { key: 'today', label: '今天' }, { key: 'last7', label: '近7天' }, { key: 'last30', label: '近30天' }, { key: 'thisMonth', label: '本月' }, { key: 'lastMonth', label: '上月' }, { key: 'all', label: '全部' }, ] const ACTION_OPTIONS = [ { value: 'ALL', label: '全部操作' }, { value: 'UPLOAD', label: '上传' }, { value: 'UPDATE', label: '修改' }, { value: 'DELETE', label: '删除' }, { value: 'DOWNLOAD', label: '下载' }, { value: 'RESTORE', label: '恢复' }, ] const ACTION_COLOR_MAP: Record = { UPLOAD: 'bg-green-100 text-green-700 dark:bg-green-900/40 dark:text-green-300', UPDATE: 'bg-blue-100 text-blue-700 dark:bg-blue-900/40 dark:text-blue-300', DELETE: 'bg-red-100 text-red-700 dark:bg-red-900/40 dark:text-red-300', DOWNLOAD: 'bg-amber-100 text-amber-700 dark:bg-amber-900/40 dark:text-amber-300', RESTORE: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-300', } const BAR_SOLID_CLASS: Record<'blue' | 'emerald', string> = { // 对齐首页 Apple 基线:克制、有层次的蓝/青体系,柱体用渐变提升质感 blue: 'bg-gradient-to-b from-sky-400 to-sky-600', emerald: 'bg-gradient-to-b from-teal-400 to-teal-600', } const WEEKDAY_LABELS = ['一', '二', '三', '四', '五', '六', '日'] function formatLogAction(action: string) { const matched = ACTION_OPTIONS.find((item) => item.value === action) return matched?.label || action } function formatValue(value: unknown): string { if (value === null || value === undefined || value === '') { return '空' } if (typeof value === 'string') { return value.length > 24 ? `${value.slice(0, 24)}...` : value } if (typeof value === 'number' || typeof value === 'boolean') { return String(value) } const serialized = JSON.stringify(value) return serialized.length > 24 ? `${serialized.slice(0, 24)}...` : serialized } function isOperationLogFieldDiff(value: unknown): value is OperationLogFieldDiff { if (!value || typeof value !== 'object') { return false } return 'oldValue' in value || 'newValue' in value } function formatChangeSummary(log: OperationLogItem): string { if (log.action === 'DOWNLOAD') { return '下载素材文件' } if (!log.changes || typeof log.changes !== 'object') { return '-' } const changeEntries = Object.entries(log.changes) if (changeEntries.length === 0) { return '-' } const items = changeEntries.slice(0, 2).map(([field, value]) => { if (isOperationLogFieldDiff(value)) { return `${field}: ${formatValue(value.oldValue)} -> ${formatValue(value.newValue)}` } return `${field}: ${formatValue(value)}` }) return changeEntries.length > 2 ? `${items.join(';')} 等` : items.join(';') } function parseFileNameFromHeader(contentDisposition: string | null): string { if (!contentDisposition) { return `data-stats-${Date.now()}.csv` } const match = contentDisposition.match(/filename="?([^";]+)"?/i) if (!match?.[1]) { return `data-stats-${Date.now()}.csv` } try { return decodeURIComponent(match[1]) } catch { return match[1] } } function normalizeDateInputValue(value: string): string { return value.trim().replace(/[./]/g, '-') } function isValidDateInputValue(value: string): boolean { if (!value) { return true } const parsed = parse(value, DATE_FORMAT, new Date()) return isValid(parsed) && format(parsed, DATE_FORMAT) === value } function parseDateInputValue(value: string | null | undefined): Date | null { const normalizedValue = normalizeDateInputValue(value || '') if (!normalizedValue || !isValidDateInputValue(normalizedValue)) { return null } return parse(normalizedValue, DATE_FORMAT, new Date()) } function getPresetRange(preset: DatePresetKey): { startDate: string; endDate: string } { const today = new Date() if (preset === 'all') { return { startDate: '', endDate: '' } } if (preset === 'today') { const value = format(today, DATE_FORMAT) return { startDate: value, endDate: value } } if (preset === 'last7') { return { startDate: format(subDays(today, 6), DATE_FORMAT), endDate: format(today, DATE_FORMAT), } } if (preset === 'last30') { return { startDate: format(subDays(today, 29), DATE_FORMAT), endDate: format(today, DATE_FORMAT), } } if (preset === 'thisMonth') { return { startDate: format(startOfMonth(today), DATE_FORMAT), endDate: format(today, DATE_FORMAT), } } const lastMonthDate = subMonths(today, 1) return { startDate: format(startOfMonth(lastMonthDate), DATE_FORMAT), endDate: format(endOfMonth(lastMonthDate), DATE_FORMAT), } } function getUserDisplayName(user: MaterialUserUploadStat): string { if (user.realName) { return `${user.realName} (${user.username})` } return user.username } function toDepartmentDrilldown(item: MaterialDepartmentUploadStat): DrilldownState { return { source: 'department', key: item.departmentKey, title: `${item.departmentName} - 人员素材占比`, totalUploads: item.uploadCount, users: (item.users || []).slice().sort((a, b) => b.uploadCount - a.uploadCount), } } function toMaterialTypeDrilldown(item: MaterialTypeDistributionStat): DrilldownState { return { source: 'materialType', key: item.materialType, title: `${item.materialType} - 人员素材占比`, totalUploads: item.uploadCount, users: item.users.slice().sort((a, b) => b.uploadCount - a.uploadCount), } } function SummaryCard({ title, value, tone, }: { title: string value: number | string tone: 'blue' | 'emerald' | 'amber' }) { // 对齐首页 Apple 基线:白底卡片 + 左侧色条 + 图标徽章,层次更清晰 const accentBarClass = tone === 'blue' ? 'bg-sky-500' : tone === 'emerald' ? 'bg-teal-500' : 'bg-amber-500' const badgeClass = tone === 'blue' ? 'bg-sky-50 text-sky-600 dark:bg-sky-950/50 dark:text-sky-400' : tone === 'emerald' ? 'bg-teal-50 text-teal-600 dark:bg-teal-950/50 dark:text-teal-400' : 'bg-amber-50 text-amber-600 dark:bg-amber-950/50 dark:text-amber-400' // 图标:上传(蓝)/部门(青)/用户(琥珀) const iconPath = tone === 'blue' ? 'M3 16.5v2.25A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75V16.5M16.5 12L12 7.5m0 0L7.5 12M12 7.5v9' : tone === 'emerald' ? 'M3.75 21h16.5M4.5 3h15M5.25 3v18m13.5-18v18M9 6.75h1.5m-1.5 3h1.5m-1.5 3h1.5m3-6H15m-1.5 3H15m-1.5 3H15M9 21v-3.375c0-.621.504-1.125 1.125-1.125h3.75c.621 0 1.125.504 1.125 1.125V21' : 'M15 19.128a9.38 9.38 0 002.625.372 9.337 9.337 0 004.121-.952 4.125 4.125 0 00-7.533-2.493M15 19.128v-.003c0-1.113-.285-2.16-.786-3.07M15 19.128v.106A12.318 12.318 0 018.624 21c-2.331 0-4.512-.645-6.374-1.766l-.001-.109a6.375 6.375 0 0111.964-3.07M12 6.375a3.375 3.375 0 11-6.75 0 3.375 3.375 0 016.75 0zm8.25 2.25a2.625 2.625 0 11-5.25 0 2.625 2.625 0 015.25 0z' return (
{/* 左侧色条 */}
{title}
{value}
) } export function CalendarDateInput({ id, label, value, min, max, rangeStart, rangeEnd, onChange, }: { id: string label: string value: string min?: string max?: string rangeStart?: string rangeEnd?: string onChange: (value: string) => void }) { const containerRef = useRef(null) const selectedDate = useMemo(() => parseDateInputValue(value), [value]) const minDate = parseDateInputValue(min) const maxDate = parseDateInputValue(max) const rangeStartDate = parseDateInputValue(rangeStart) const rangeEndDate = parseDateInputValue(rangeEnd) const [isOpen, setIsOpen] = useState(false) const [visibleMonth, setVisibleMonth] = useState(() => startOfMonth(selectedDate || new Date())) useEffect(() => { if (!selectedDate) return const nextMonth = startOfMonth(selectedDate) setVisibleMonth((currentMonth) => (isSameMonth(currentMonth, nextMonth) ? currentMonth : nextMonth)) }, [selectedDate]) useEffect(() => { if (!isOpen) { return } const handleClickOutside = (event: MouseEvent) => { const containerElement = containerRef.current if (!containerElement) { return } if (!containerElement.contains(event.target as Node)) { setIsOpen(false) } } document.addEventListener('mousedown', handleClickOutside) return () => document.removeEventListener('mousedown', handleClickOutside) }, [isOpen]) const dayCells = useMemo(() => { const calendarStart = startOfWeek(startOfMonth(visibleMonth), { weekStartsOn: 1 }) return Array.from({ length: 42 }, (_, index) => addDays(calendarStart, index)) }, [visibleMonth]) const rangeBounds = useMemo(() => { if (!rangeStartDate || !rangeEndDate) { return null } return isAfter(rangeStartDate, rangeEndDate) ? { start: rangeEndDate, end: rangeStartDate } : { start: rangeStartDate, end: rangeEndDate } }, [rangeStartDate, rangeEndDate]) const isDayDisabled = (day: Date) => { if (minDate && isBefore(day, minDate)) { return true } if (maxDate && isAfter(day, maxDate)) { return true } return false } const selectDate = (day: Date) => { if (isDayDisabled(day)) { return } onChange(format(day, DATE_FORMAT)) setIsOpen(false) } const setToday = () => { selectDate(new Date()) } const clearDate = () => { onChange('') setIsOpen(false) } return (
setIsOpen(true)} className="input-apple py-2 px-3 pr-10 text-sm cursor-pointer" />
{isOpen && (
{format(visibleMonth, 'yyyy年MM月')}
{WEEKDAY_LABELS.map((day) => (
{day}
))}
{dayCells.map((day) => { const disabled = isDayDisabled(day) const inCurrentMonth = isSameMonth(day, visibleMonth) const isSelected = selectedDate ? isSameDay(day, selectedDate) : false const isRangeStart = rangeStartDate ? isSameDay(day, rangeStartDate) : false const isRangeEnd = rangeEndDate ? isSameDay(day, rangeEndDate) : false const isInRange = rangeBounds ? isWithinInterval(day, rangeBounds) : false const dayClass = isSelected ? 'bg-gray-900 text-white border-gray-900 dark:bg-gray-100 dark:text-gray-900 dark:border-gray-100' : isRangeStart || isRangeEnd ? 'bg-sky-100 text-sky-700 border-sky-200 dark:bg-sky-900/60 dark:text-sky-100 dark:border-sky-800' : isInRange ? 'bg-sky-50 text-sky-700 border-sky-100 dark:bg-sky-900/35 dark:text-sky-200 dark:border-sky-900/50' : 'bg-white text-gray-700 border-transparent dark:bg-gray-900 dark:text-gray-200' return ( ) })}
)}
) } function HorizontalBarChart({ items, emptyText, activeKey, onSelect, accent, }: { items: HorizontalBarDatum[] emptyText: string activeKey?: string onSelect: (key: string) => void accent: 'blue' | 'emerald' }) { const [animated, setAnimated] = useState(false) useEffect(() => { setAnimated(false) const frameId = requestAnimationFrame(() => setAnimated(true)) return () => cancelAnimationFrame(frameId) }, [items]) if (!items.length) { return
{emptyText}
} const maxValue = Math.max(...items.map((item) => item.value), 1) const barColorClass = BAR_SOLID_CLASS[accent] const activeBarClass = accent === 'blue' ? 'ring-2 ring-sky-300 ring-offset-1 dark:ring-sky-600' : 'ring-2 ring-teal-300 ring-offset-1 dark:ring-teal-600' return (
{items.map((item) => { const targetHeight = Math.max((item.value / maxValue) * 92, 4) const isActive = activeKey === item.key return ( ) })}
点击柱子查看人员占比
) } export default function DataStatisticsTab({ showToast, canViewLogs = false, canSelectDepartment = true, departmentScopeName = null, }: DataStatisticsTabProps) { const today = new Date() const initialStartDate = format(startOfMonth(today), DATE_FORMAT) const initialEndDate = format(today, DATE_FORMAT) const [startDate, setStartDate] = useState(initialStartDate) const [endDate, setEndDate] = useState(initialEndDate) const [draftStartDate, setDraftStartDate] = useState(initialStartDate) const [draftEndDate, setDraftEndDate] = useState(initialEndDate) const [departmentKey, setDepartmentKey] = useState('') const [statsLoading, setStatsLoading] = useState(false) const [statsData, setStatsData] = useState(null) const [drilldown, setDrilldown] = useState(null) const [dateFilterMode, setDateFilterMode] = useState('thisMonth') const [logAction, setLogAction] = useState('ALL') const [logKeywordInput, setLogKeywordInput] = useState('') const [logKeyword, setLogKeyword] = useState('') const [logPage, setLogPage] = useState(1) const [logsLoading, setLogsLoading] = useState(false) const [logsData, setLogsData] = useState(null) // 趋势区视图切换:'chart' 折线图(默认)| 'table' 表格 const [trendView, setTrendView] = useState<'chart' | 'table'>('chart') const hasInitializedStatsRef = useRef(false) const drilldownSectionRef = useRef(null) const scrollToDrilldownSection = useCallback(() => { requestAnimationFrame(() => { drilldownSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }) }) }, []) const fetchStats = useCallback(async (overrides: Partial = {}) => { const query: StatsQueryState = { startDate, endDate, departmentKey, ...overrides, } const normalizedStartDate = normalizeDateInputValue(query.startDate) const normalizedEndDate = normalizeDateInputValue(query.endDate) if (!isValidDateInputValue(normalizedStartDate)) { showToast('开始日期格式不正确,请输入 YYYY-MM-DD', 'error') return } if (!isValidDateInputValue(normalizedEndDate)) { showToast('结束日期格式不正确,请输入 YYYY-MM-DD', 'error') return } if (normalizedStartDate && normalizedEndDate) { const parsedStartDate = parse(normalizedStartDate, DATE_FORMAT, new Date()) const parsedEndDate = parse(normalizedEndDate, DATE_FORMAT, new Date()) if (isAfter(parsedStartDate, parsedEndDate)) { showToast('开始日期不能晚于结束日期', 'error') return } } setStatsLoading(true) try { const params = new URLSearchParams() if (normalizedStartDate) params.append('startDate', normalizedStartDate) if (normalizedEndDate) params.append('endDate', normalizedEndDate) if (canSelectDepartment && query.departmentKey) params.append('departmentKey', query.departmentKey) const response = await fetch(`/api/admin/data-stats?${params.toString()}`) if (!response.ok) { const error = await response.json() throw new Error(error?.error || '获取数据统计失败') } const payload = await response.json() const nextData: MaterialUploadStatsData = payload.data setStatsData(nextData) setDrilldown((previous) => { if (previous?.source === 'department') { const matched = nextData.departmentDistribution.items.find((item) => item.departmentKey === previous.key) if (matched) return toDepartmentDrilldown(matched) } if (previous?.source === 'materialType') { const matched = nextData.materialTypeDistribution.items.find((item) => item.materialType === previous.key) if (matched) return toMaterialTypeDrilldown(matched) } if (nextData.departmentDistribution.items.length) { return toDepartmentDrilldown(nextData.departmentDistribution.items[0]) } if (nextData.materialTypeDistribution.items.length) { return toMaterialTypeDrilldown(nextData.materialTypeDistribution.items[0]) } return null }) } catch (error) { console.error('[DataStatisticsTab] fetchStats error:', error) showToast(error instanceof Error ? error.message : '获取数据统计失败', 'error') } finally { setStatsLoading(false) } }, [ canSelectDepartment, departmentKey, endDate, showToast, startDate, ]) const fetchLogs = useCallback(async () => { if (!canViewLogs) { return } setLogsLoading(true) try { const params = new URLSearchParams({ action: logAction, page: String(logPage), pageSize: '20', }) if (startDate) params.append('startDate', startDate) if (endDate) params.append('endDate', endDate) if (logKeyword) params.append('keyword', logKeyword) const response = await fetch(`/api/admin/material-operation-logs?${params.toString()}`) if (!response.ok) { const error = await response.json() throw new Error(error?.error || '获取日志失败') } const payload = await response.json() setLogsData(payload.data) } catch (error) { console.error('[DataStatisticsTab] fetchLogs error:', error) showToast(error instanceof Error ? error.message : '获取日志失败', 'error') } finally { setLogsLoading(false) } }, [canViewLogs, endDate, logAction, logKeyword, logPage, showToast, startDate]) useEffect(() => { if (hasInitializedStatsRef.current) { return } hasInitializedStatsRef.current = true fetchStats() }, [fetchStats]) useEffect(() => { fetchLogs() }, [fetchLogs]) const handleApplyFilters = async () => { const normalizedStartDate = normalizeDateInputValue(draftStartDate) const normalizedEndDate = normalizeDateInputValue(draftEndDate) if (!isValidDateInputValue(normalizedStartDate)) { showToast('开始日期格式不正确,请输入 YYYY-MM-DD', 'error') return } if (!isValidDateInputValue(normalizedEndDate)) { showToast('结束日期格式不正确,请输入 YYYY-MM-DD', 'error') return } if (normalizedStartDate && normalizedEndDate) { const parsedStartDate = parse(normalizedStartDate, DATE_FORMAT, new Date()) const parsedEndDate = parse(normalizedEndDate, DATE_FORMAT, new Date()) if (isAfter(parsedStartDate, parsedEndDate)) { showToast('开始日期不能晚于结束日期', 'error') return } } setStartDate(normalizedStartDate) setEndDate(normalizedEndDate) setDraftStartDate(normalizedStartDate) setDraftEndDate(normalizedEndDate) setDateFilterMode('custom') setLogPage(1) await fetchStats({ startDate: normalizedStartDate, endDate: normalizedEndDate, }) } const handleApplyPreset = async (preset: DatePresetKey) => { const range = getPresetRange(preset) setStartDate(range.startDate) setEndDate(range.endDate) setDraftStartDate(range.startDate) setDraftEndDate(range.endDate) setDateFilterMode(preset) setLogPage(1) await fetchStats({ startDate: range.startDate, endDate: range.endDate, }) } const handleExportCsv = async () => { try { const params = new URLSearchParams() if (startDate) params.append('startDate', startDate) if (endDate) params.append('endDate', endDate) if (canSelectDepartment && departmentKey) params.append('departmentKey', departmentKey) const response = await fetch(`/api/admin/data-stats/export?${params.toString()}`) if (!response.ok) { const error = await response.json() throw new Error(error?.error || '导出失败') } const blob = await response.blob() const downloadUrl = URL.createObjectURL(blob) const fileName = parseFileNameFromHeader(response.headers.get('content-disposition')) const link = document.createElement('a') link.href = downloadUrl link.download = fileName document.body.appendChild(link) link.click() document.body.removeChild(link) URL.revokeObjectURL(downloadUrl) showToast('CSV 导出成功') } catch (error) { console.error('[DataStatisticsTab] export error:', error) showToast(error instanceof Error ? error.message : 'CSV 导出失败', 'error') } } const departmentBars = useMemo(() => { if (!statsData) { return [] as HorizontalBarDatum[] } return statsData.departmentDistribution.items.map((item) => ({ key: item.departmentKey, label: item.departmentName, value: item.uploadCount, percentage: item.percentage, subText: `${item.userCount} 人参与`, })) }, [statsData]) const materialTypeBars = useMemo(() => { if (!statsData) { return [] as HorizontalBarDatum[] } return statsData.materialTypeDistribution.items.map((item) => ({ key: item.materialType, label: item.materialType, value: item.uploadCount, percentage: item.percentage, subText: `${item.users.length} 人参与`, })) }, [statsData]) const handleDepartmentBarSelect = (key: string) => { const selected = statsData?.departmentDistribution.items.find((item) => item.departmentKey === key) if (!selected) { return } setDrilldown(toDepartmentDrilldown(selected)) scrollToDrilldownSection() } const handleMaterialTypeBarSelect = (key: string) => { const selected = statsData?.materialTypeDistribution.items.find((item) => item.materialType === key) if (!selected) { return } setDrilldown(toMaterialTypeDrilldown(selected)) scrollToDrilldownSection() } const drilldownUsers = useMemo(() => { if (!drilldown) { return [] as MaterialUserUploadStat[] } return drilldown.users.map((user) => ({ ...user, percentage: drilldown.totalUploads > 0 ? Number(((user.uploadCount / drilldown.totalUploads) * 100).toFixed(2)) : 0, })) }, [drilldown]) const totalPages = logsData?.totalPages || 1 const canGoPrev = logPage > 1 const canGoNext = logPage < totalPages return (

数据统计看板

统一按上方时间范围统计;点击分布项可直接跳转到下方人员明细。

{startDate || '不限'} ~ {endDate || '不限'} {statsData?.selectedDepartmentName || '全部部门'}
{!canSelectDepartment && (departmentScopeName || statsData?.selectedDepartmentName) && (

当前为 {departmentScopeName || statsData?.selectedDepartmentName} 部门范围

)}
{DATE_PRESETS.map((preset) => ( ))} {dateFilterMode === 'custom' && ( 自定义日期 )}
{canSelectDepartment ? (
) : (
已按部门范围自动过滤
)}

部门上传分布(核心)

统计范围:{statsData?.departmentDistribution.label || '-'}

素材类型上传分布(核心)

统计范围:{statsData?.materialTypeDistribution.label || '-'}

{drilldown?.title || '人员素材占比(点击上方柱子查看)'}

{drilldown && ( 合计 {drilldown.totalUploads} 条 )}
{drilldownUsers.length ? (
{drilldownUsers.map((user) => { const percentage = user.percentage || 0 const width = Math.max(percentage, 3) return (
{getUserDisplayName(user)}
{user.uploadCount} 条 ({percentage.toFixed(1)}%)
{user.departmentName}
) })}
) : (
请选择上方柱子查看对应人员占比
)}

每日趋势{statsData?.dailyUploads.length ? `(共 ${statsData.dailyUploads.length} 天)` : ''}

{trendView === 'chart' ? (
) : (
{statsData?.dailyUploads.length ? ( {statsData.dailyUploads.map((item) => ( ))}
日期 上传数
{item.bucket} {item.count}
) : (
暂无每日趋势数据
)}
)}

月度趋势{statsData?.monthlyUploads.length ? `(共 ${statsData.monthlyUploads.length} 个月)` : ''}

{trendView === 'chart' ? (
) : (
{statsData?.monthlyUploads.length ? ( {statsData.monthlyUploads.map((item) => ( ))}
月份 上传数
{item.bucket} {item.count}
) : (
暂无月度趋势数据
)}
)}
{canViewLogs && (

素材操作日志

记录上传、修改(含字段前后值)、下载、删除等关键操作

setLogKeywordInput(event.target.value)} onKeyDown={(event) => { if (event.key === 'Enter') { setLogPage(1) setLogKeyword(logKeywordInput.trim()) } }} placeholder="可按素材型号、类型、用户名搜索" className="input-apple py-2 px-3 text-sm" />
{logsLoading ? ( ) : logsData?.logs.length ? ( logsData.logs.map((log) => ( )) ) : ( )}
时间 操作 素材 操作人 部门 变更详情
加载日志中...
{new Date(log.timestamp).toLocaleString('zh-CN')} {formatLogAction(log.action)}
{log.material.productModel}
{log.material.materialType}
{log.user.realName || log.user.username} {log.user.departmentName} {formatChangeSummary(log)}
暂无日志数据
共 {logsData?.total || 0} 条,当前第 {logsData?.page || 1} / {logsData?.totalPages || 1} 页
)}
) }