1436 lines
52 KiB
TypeScript
1436 lines
52 KiB
TypeScript
'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>
|
||
)
|
||
}
|