gongxiangp2/components/admin/DataStatisticsTab.tsx
2026-03-06 14:14:50 +08:00

1436 lines
52 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.

'use client'
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
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<TItem> {
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<MaterialDepartmentUploadStat>
materialTypeDistribution: MaterialDistributionSection<MaterialTypeDistributionStat>
}
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<string, unknown> | 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<string, string> = {
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> = {
blue: 'bg-blue-500',
emerald: 'bg-emerald-500',
}
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'
}) {
const toneClass = tone === 'blue'
? 'bg-blue-50/60 border-blue-200 dark:bg-blue-950/30 dark:border-blue-900'
: tone === 'emerald'
? 'bg-emerald-50/60 border-emerald-200 dark:bg-emerald-950/30 dark:border-emerald-900'
: 'bg-amber-50/60 border-amber-200 dark:bg-amber-950/30 dark:border-amber-900'
return (
<div className={`rounded-lg border p-4 ${toneClass}`}>
<div className="text-sm text-gray-600 dark:text-gray-400">{title}</div>
<div className="text-2xl font-bold text-gray-900 dark:text-white mt-1">{value}</div>
</div>
)
}
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<HTMLDivElement | null>(null)
const selectedDate = parseDateInputValue(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) {
setVisibleMonth(startOfMonth(selectedDate))
}
}, [value, 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 (
<div ref={containerRef} className="relative">
<label htmlFor={id} className="block text-sm text-gray-600 dark:text-gray-400 mb-1">{label}</label>
<div className="relative">
<input
id={id}
type="text"
readOnly
value={value}
placeholder="YYYY-MM-DD"
onClick={() => setIsOpen(true)}
className="input-apple py-2 px-3 pr-10 text-sm cursor-pointer"
/>
<button
type="button"
onClick={() => setIsOpen((previous) => !previous)}
className="absolute inset-y-0 right-0 w-9 inline-flex items-center justify-center text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
aria-label={`打开${label}日历`}
title={`打开${label}日历`}
>
<svg viewBox="0 0 24 24" className="h-4 w-4" fill="none" stroke="currentColor" strokeWidth="2">
<rect x="3" y="4" width="18" height="18" rx="2" ry="2" />
<line x1="16" y1="2" x2="16" y2="6" />
<line x1="8" y1="2" x2="8" y2="6" />
<line x1="3" y1="10" x2="21" y2="10" />
</svg>
</button>
</div>
{isOpen && (
<div className="absolute z-30 mt-2 w-[280px] rounded-lg border border-gray-200 bg-white shadow-lg dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setVisibleMonth((previous) => subMonths(previous, 1))}
className="h-7 w-7 rounded-md border border-gray-200 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label="上个月"
>
←
</button>
<div className="text-sm font-medium text-gray-900 dark:text-white">
{format(visibleMonth, 'yyyy年MM月')}
</div>
<button
type="button"
onClick={() => setVisibleMonth((previous) => addDays(endOfMonth(previous), 1))}
className="h-7 w-7 rounded-md border border-gray-200 text-gray-600 hover:bg-gray-100 dark:border-gray-700 dark:text-gray-300 dark:hover:bg-gray-800"
aria-label="下个月"
>
→
</button>
</div>
<div className="grid grid-cols-7 gap-1 px-3 pt-2 text-[11px] text-gray-500 dark:text-gray-400">
{WEEKDAY_LABELS.map((day) => (
<div key={day} className="h-6 flex items-center justify-center">{day}</div>
))}
</div>
<div className="grid grid-cols-7 gap-1 px-3 pb-2">
{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-blue-600 text-white border-blue-600 dark:bg-blue-500 dark:border-blue-500'
: isRangeStart || isRangeEnd
? 'bg-blue-100 text-blue-700 border-blue-200 dark:bg-blue-900/60 dark:text-blue-100 dark:border-blue-800'
: isInRange
? 'bg-blue-50 text-blue-700 border-blue-100 dark:bg-blue-900/35 dark:text-blue-200 dark:border-blue-900/50'
: 'bg-white text-gray-800 border-transparent dark:bg-gray-900 dark:text-gray-200'
return (
<button
key={day.toISOString()}
type="button"
disabled={disabled}
onClick={() => selectDate(day)}
className={`h-8 rounded-md border text-sm transition-colors ${
dayClass
} ${
!inCurrentMonth ? 'opacity-45' : ''
} ${
disabled
? 'cursor-not-allowed opacity-35'
: 'hover:border-blue-300 hover:bg-blue-50 dark:hover:border-blue-700 dark:hover:bg-blue-900/25'
}`}
>
{format(day, 'd')}
</button>
)
})}
</div>
<div className="flex items-center justify-between px-3 py-2 border-t border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={clearDate}
className="text-xs text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200"
>
清空
</button>
<button
type="button"
onClick={setToday}
className="text-xs text-blue-600 hover:text-blue-700 dark:text-blue-300 dark:hover:text-blue-200"
>
今天
</button>
</div>
</div>
)}
</div>
)
}
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 <div className="py-16 text-center text-sm text-gray-500 dark:text-gray-400">{emptyText}</div>
}
const maxValue = Math.max(...items.map((item) => item.value), 1)
const barColorClass = BAR_SOLID_CLASS[accent]
const activeBarClass = accent === 'blue'
? 'ring-2 ring-blue-300 dark:ring-blue-700'
: 'ring-2 ring-emerald-300 dark:ring-emerald-700'
return (
<div className="overflow-x-auto pb-2">
<div className="w-max min-w-full h-60 flex items-end gap-0.5 px-0.5">
{items.map((item) => {
const targetHeight = Math.max((item.value / maxValue) * 88, 6)
const isActive = activeKey === item.key
return (
<button
key={item.key}
type="button"
onClick={() => onSelect(item.key)}
className="group shrink-0 w-[66px] text-center"
>
<div className="h-[136px] flex items-end justify-center overflow-visible">
<div
className="relative w-4 transition-all duration-500 ease-out"
style={{ height: animated ? `${targetHeight}%` : '0%' }}
>
<div className="absolute left-1/2 -translate-x-1/2 -top-5 text-[11px] font-semibold text-white drop-shadow-[0_1px_1px_rgba(0,0,0,0.7)] whitespace-nowrap">
{item.value}
</div>
<div
className={`h-full w-4 rounded-t ${barColorClass} ${
isActive ? activeBarClass : 'group-hover:opacity-85'
}`}
/>
</div>
</div>
<div className="mt-0.5 px-1 h-7 flex items-start justify-center">
<div
className="text-xs font-medium text-gray-100 dark:text-gray-100 leading-4 whitespace-normal break-words text-center"
title={item.label}
>
{item.label}
</div>
</div>
<div className="mt-0 leading-tight">
<div className="text-[11px] text-gray-200 dark:text-gray-300">
{item.percentage.toFixed(1)}%
</div>
{item.subText && (
<div className="text-[11px] text-gray-300 dark:text-gray-400 whitespace-nowrap" title={item.subText}>
{item.subText}
</div>
)}
</div>
</button>
)
})}
</div>
<div className="mt-2 text-[11px] text-gray-500 dark:text-gray-400 text-right">
点击柱子查看人员占比
</div>
</div>
)
}
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<MaterialUploadStatsData | null>(null)
const [drilldown, setDrilldown] = useState<DrilldownState | null>(null)
const [dateFilterMode, setDateFilterMode] = useState<DateFilterMode>('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<OperationLogResponse | null>(null)
const hasInitializedStatsRef = useRef(false)
const drilldownSectionRef = useRef<HTMLDivElement | null>(null)
const scrollToDrilldownSection = useCallback(() => {
requestAnimationFrame(() => {
drilldownSectionRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' })
})
}, [])
const fetchStats = useCallback(async (overrides: Partial<StatsQueryState> = {}) => {
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 (
<div className="space-y-6">
<div className="card-apple overflow-hidden">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-700 bg-gray-50 dark:bg-gray-900 flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white">数据统计看板</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
统一按上方时间范围统计;点击分布项可直接跳转到下方人员明细。
</p>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="px-2.5 py-1 rounded-md bg-white/80 border border-gray-200 text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200">
时间范围:{startDate || '不限'} ~ {endDate || '不限'}
</span>
<span className="px-2.5 py-1 rounded-md bg-white/80 border border-gray-200 text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200">
部门筛选:{statsData?.selectedDepartmentName || '全部部门'}
</span>
</div>
{!canSelectDepartment && (departmentScopeName || statsData?.selectedDepartmentName) && (
<p className="text-xs text-blue-600 dark:text-blue-300 mt-2">
当前为 {departmentScopeName || statsData?.selectedDepartmentName} 部门范围
</p>
)}
</div>
<button
type="button"
onClick={handleExportCsv}
className="shrink-0 px-4 py-2.5 text-sm font-medium bg-emerald-600 hover:bg-emerald-700 text-white rounded-lg transition-colors"
>
导出当前筛选 CSV
</button>
</div>
<div className="p-6 space-y-6">
<div className="rounded-xl border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 p-5 space-y-4">
<div className="flex flex-wrap items-center gap-2">
{DATE_PRESETS.map((preset) => (
<button
key={preset.key}
type="button"
onClick={() => handleApplyPreset(preset.key)}
className={`px-3 py-1.5 text-xs font-medium rounded-lg border transition-colors ${
dateFilterMode === preset.key
? 'border-gray-900 bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900 dark:border-gray-100'
: 'border-gray-300 dark:border-gray-600 text-gray-700 dark:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-700'
}`}
>
{preset.label}
</button>
))}
{dateFilterMode === 'custom' && (
<span className="px-2 py-1 text-xs rounded-md border border-gray-300 text-gray-600 dark:border-gray-600 dark:text-gray-300">
自定义日期
</span>
)}
</div>
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-3 items-end">
<CalendarDateInput
id="stats-start-date"
label="开始日期"
value={draftStartDate}
max={draftEndDate || undefined}
rangeStart={draftStartDate}
rangeEnd={draftEndDate}
onChange={setDraftStartDate}
/>
<CalendarDateInput
id="stats-end-date"
label="结束日期"
value={draftEndDate}
min={draftStartDate || undefined}
rangeStart={draftStartDate}
rangeEnd={draftEndDate}
onChange={setDraftEndDate}
/>
{canSelectDepartment ? (
<div>
<label htmlFor="stats-department-filter" className="block text-sm text-gray-600 dark:text-gray-400 mb-1">部门</label>
<select
id="stats-department-filter"
value={departmentKey}
onChange={async (event) => {
const nextDepartmentKey = event.target.value
setDepartmentKey(nextDepartmentKey)
await fetchStats({ departmentKey: nextDepartmentKey })
}}
className="input-apple py-2 px-3 text-sm"
>
<option value="">全部部门</option>
{statsData?.departments.map((department) => (
<option key={department.departmentKey} value={department.departmentKey}>
{department.departmentName}
</option>
))}
</select>
</div>
) : (
<div className="md:col-span-2 xl:col-span-1 flex items-end">
<div className="w-full rounded-lg border border-gray-300 bg-gray-50 text-gray-700 dark:bg-gray-800 dark:border-gray-600 dark:text-gray-300 px-3 py-2 text-sm">
当前已按部门范围自动过滤,可直接看绩效分布
</div>
</div>
)}
<div>
<button
type="button"
onClick={handleApplyFilters}
className="w-full px-4 py-2.5 text-sm font-medium border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
刷新数据
</button>
</div>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<SummaryCard
title="总上传数"
value={statsLoading ? '...' : statsData?.summary.totalUploads || 0}
tone="blue"
/>
<SummaryCard
title="有上传的部门数"
value={statsLoading ? '...' : statsData?.summary.totalDepartments || 0}
tone="emerald"
/>
<SummaryCard
title="上传用户数"
value={statsLoading ? '...' : statsData?.summary.totalUsers || 0}
tone="amber"
/>
</div>
<div className="grid grid-cols-1 xl:grid-cols-2 gap-6">
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/80 dark:bg-gray-800/50">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">部门上传分布(核心)</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
统计范围:{statsData?.departmentDistribution.label || '-'}
</p>
</div>
</div>
<div className="p-4">
<HorizontalBarChart
items={departmentBars}
emptyText="当前条件下暂无部门分布数据"
activeKey={drilldown?.source === 'department' ? drilldown.key : ''}
onSelect={handleDepartmentBarSelect}
accent="blue"
/>
</div>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/80 dark:bg-gray-800/50">
<div>
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">素材类型上传分布(核心)</h3>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1">
统计范围:{statsData?.materialTypeDistribution.label || '-'}
</p>
</div>
</div>
<div className="p-4">
<HorizontalBarChart
items={materialTypeBars}
emptyText="当前条件下暂无素材类型分布数据"
activeKey={drilldown?.source === 'materialType' ? drilldown.key : ''}
onSelect={handleMaterialTypeBarSelect}
accent="emerald"
/>
</div>
</div>
</div>
<div ref={drilldownSectionRef} className="scroll-mt-24 border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/80 dark:bg-gray-800/50 flex items-center justify-between gap-3 flex-wrap">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{drilldown?.title || '人员素材占比(点击上方柱子查看)'}
</h3>
{drilldown && (
<span className="text-xs text-gray-500 dark:text-gray-400">
合计 {drilldown.totalUploads} 条
</span>
)}
</div>
<div className="p-4">
{drilldownUsers.length ? (
<div className="max-h-[360px] overflow-y-auto space-y-2 pr-1">
{drilldownUsers.map((user) => {
const percentage = user.percentage || 0
const width = Math.max(percentage, 3)
return (
<div
key={`${drilldown?.source || 'unknown'}-${drilldown?.key || 'unknown'}-${user.userId}`}
className="rounded-lg border border-gray-200 dark:border-gray-700 px-3 py-2"
>
<div className="flex items-center justify-between gap-3">
<div className="font-medium text-sm text-gray-900 dark:text-white truncate">{getUserDisplayName(user)}</div>
<div className="text-xs text-gray-600 dark:text-gray-300 whitespace-nowrap">
{user.uploadCount} 条 ({percentage.toFixed(2)}%)
</div>
</div>
<div className="mt-1.5 h-2 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-violet-500"
style={{ width: `${width}%` }}
/>
</div>
<div className="mt-1 text-[11px] text-gray-500 dark:text-gray-400">部门:{user.departmentName}</div>
</div>
)
})}
</div>
) : (
<div className="py-16 text-center text-sm text-gray-500 dark:text-gray-400">
请选择上方柱子查看对应人员占比
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/80 dark:bg-gray-800/50">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">每日趋势(近31天)</h3>
</div>
<div className="max-h-[320px] overflow-y-auto">
{statsData?.dailyUploads.length ? (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white dark:bg-gray-900">
<tr className="border-b border-gray-100 dark:border-gray-800">
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">日期</th>
<th className="px-4 py-2 text-right font-medium text-gray-600 dark:text-gray-300">上传数</th>
</tr>
</thead>
<tbody>
{statsData.dailyUploads.slice(-31).map((item) => (
<tr key={item.bucket} className="border-b border-gray-50 dark:border-gray-800/80">
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{item.bucket}</td>
<td className="px-4 py-2 text-right font-semibold text-gray-900 dark:text-white">{item.count}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-16 text-center text-sm text-gray-500 dark:text-gray-400">暂无每日趋势数据</div>
)}
</div>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-hidden">
<div className="px-4 py-3 border-b border-gray-200 dark:border-gray-700 bg-gray-50/80 dark:bg-gray-800/50">
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">月度趋势</h3>
</div>
<div className="max-h-[320px] overflow-y-auto">
{statsData?.monthlyUploads.length ? (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-white dark:bg-gray-900">
<tr className="border-b border-gray-100 dark:border-gray-800">
<th className="px-4 py-2 text-left font-medium text-gray-600 dark:text-gray-300">月份</th>
<th className="px-4 py-2 text-right font-medium text-gray-600 dark:text-gray-300">上传数</th>
</tr>
</thead>
<tbody>
{statsData.monthlyUploads.map((item) => (
<tr key={item.bucket} className="border-b border-gray-50 dark:border-gray-800/80">
<td className="px-4 py-2 text-gray-700 dark:text-gray-300">{item.bucket}</td>
<td className="px-4 py-2 text-right font-semibold text-gray-900 dark:text-white">{item.count}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-16 text-center text-sm text-gray-500 dark:text-gray-400">暂无月度趋势数据</div>
)}
</div>
</div>
</div>
</div>
</div>
{canViewLogs && (
<div className="card-apple overflow-hidden">
<div className="px-6 py-4 border-b border-gray-200 dark:border-gray-700">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white">素材操作日志</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
记录上传、修改(含字段前后值)、下载、删除等关键操作
</p>
</div>
<div className="p-6 space-y-4">
<div className="grid grid-cols-1 md:grid-cols-4 gap-4">
<div>
<label htmlFor="stats-log-action" className="block text-sm text-gray-600 dark:text-gray-400 mb-1">操作类型</label>
<select
id="stats-log-action"
value={logAction}
onChange={(event) => {
setLogAction(event.target.value)
setLogPage(1)
}}
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
>
{ACTION_OPTIONS.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</select>
</div>
<div className="md:col-span-2">
<label htmlFor="stats-log-keyword" className="block text-sm text-gray-600 dark:text-gray-400 mb-1">关键词</label>
<input
id="stats-log-keyword"
type="text"
value={logKeywordInput}
onChange={(event) => setLogKeywordInput(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') {
setLogPage(1)
setLogKeyword(logKeywordInput.trim())
}
}}
placeholder="可按素材型号、类型、用户名搜索"
className="w-full px-3 py-2 border border-gray-300 dark:border-gray-600 rounded-lg dark:bg-gray-800 dark:text-white"
/>
</div>
<div className="flex items-end gap-2">
<button
type="button"
onClick={() => {
setLogPage(1)
setLogKeyword(logKeywordInput.trim())
}}
className="flex-1 px-4 py-2 text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white rounded-lg transition-colors"
>
查询
</button>
<button
type="button"
onClick={() => {
setLogKeywordInput('')
setLogKeyword('')
setLogAction('ALL')
setLogPage(1)
}}
className="flex-1 px-4 py-2 text-sm font-medium border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
>
重置
</button>
</div>
</div>
<div className="border border-gray-200 dark:border-gray-700 rounded-xl overflow-x-auto">
<table className="w-full min-w-[860px] text-sm">
<thead className="bg-gray-50 dark:bg-gray-800/60">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">时间</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">操作</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">素材</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">操作人</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">部门</th>
<th className="px-4 py-3 text-left font-medium text-gray-600 dark:text-gray-300">变更详情</th>
</tr>
</thead>
<tbody>
{logsLoading ? (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-gray-500 dark:text-gray-400">加载日志中...</td>
</tr>
) : logsData?.logs.length ? (
logsData.logs.map((log) => (
<tr key={log.id} className="border-t border-gray-100 dark:border-gray-800">
<td className="px-4 py-3 text-gray-600 dark:text-gray-300 whitespace-nowrap">
{new Date(log.timestamp).toLocaleString('zh-CN')}
</td>
<td className="px-4 py-3 whitespace-nowrap">
<span
className={`inline-flex px-2 py-1 rounded-full text-xs font-semibold ${
ACTION_COLOR_MAP[log.action] || 'bg-gray-100 text-gray-700 dark:bg-gray-800 dark:text-gray-300'
}`}
>
{formatLogAction(log.action)}
</span>
</td>
<td className="px-4 py-3 text-gray-900 dark:text-white">
<div className="font-medium">{log.material.productModel}</div>
<div className="text-xs text-gray-500 dark:text-gray-400">{log.material.materialType}</div>
</td>
<td className="px-4 py-3 text-gray-700 dark:text-gray-300">{log.user.realName || log.user.username}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-400">{log.user.departmentName}</td>
<td className="px-4 py-3 text-gray-600 dark:text-gray-300">{formatChangeSummary(log)}</td>
</tr>
))
) : (
<tr>
<td colSpan={6} className="px-4 py-10 text-center text-gray-500 dark:text-gray-400">暂无日志数据</td>
</tr>
)}
</tbody>
</table>
</div>
<div className="flex items-center justify-between gap-3 text-sm">
<div className="text-gray-500 dark:text-gray-400">
共 {logsData?.total || 0} 条,当前第 {logsData?.page || 1} / {logsData?.totalPages || 1} 页
</div>
<div className="flex items-center gap-2">
<button
type="button"
disabled={!canGoPrev}
onClick={() => setLogPage((prev) => Math.max(1, prev - 1))}
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
>
上一页
</button>
<button
type="button"
disabled={!canGoNext}
onClick={() => setLogPage((prev) => prev + 1)}
className="px-3 py-1.5 border border-gray-300 dark:border-gray-600 rounded-lg disabled:opacity-40 disabled:cursor-not-allowed"
>
下一页
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}