gongxiangp2/components/admin/DataStatisticsTab.tsx

1525 lines
60 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 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<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> = {
// 对齐首页 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 (
<div className="card-apple relative overflow-hidden p-5 pl-6">
{/* 左侧色条 */}
<div className={`absolute left-0 top-0 bottom-0 w-1 ${accentBarClass}`} />
<div className="flex items-start justify-between gap-3">
<div className="min-w-0">
<div className="text-sm text-gray-500 dark:text-gray-400 truncate">{title}</div>
<div className="text-3xl font-bold text-gray-900 dark:text-white mt-2 tracking-tight tabular-nums">{value}</div>
</div>
<div className={`shrink-0 inline-flex items-center justify-center h-10 w-10 rounded-apple ${badgeClass}`}>
<svg className="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8">
<path strokeLinecap="round" strokeLinejoin="round" d={iconPath} />
</svg>
</div>
</div>
</div>
)
}
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<HTMLDivElement | null>(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 (
<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-apple border border-gray-200 bg-white shadow-apple-lg dark:border-gray-700 dark:bg-gray-900">
<div className="flex items-center justify-between px-3 py-2.5 border-b border-gray-100 dark:border-gray-800">
<button
type="button"
onClick={() => setVisibleMonth((previous) => subMonths(previous, 1))}
className="h-7 w-7 inline-flex items-center justify-center rounded-apple text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="上个月"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M15 19l-7-7 7-7" /></svg>
</button>
<div className="text-sm font-semibold 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 inline-flex items-center justify-center rounded-apple text-gray-500 hover:text-gray-900 hover:bg-gray-100 dark:text-gray-400 dark:hover:text-gray-100 dark:hover:bg-gray-800 transition-colors"
aria-label="下个月"
>
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
</button>
</div>
<div className="grid grid-cols-7 gap-1 px-3 pt-2.5 text-[11px] text-gray-400 dark:text-gray-500 font-medium">
{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-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 (
<button
key={day.toISOString()}
type="button"
disabled={disabled}
onClick={() => selectDate(day)}
className={`h-8 rounded-apple border text-sm font-medium transition-colors ${
dayClass
} ${
!inCurrentMonth ? 'opacity-40' : ''
} ${
disabled
? 'cursor-not-allowed opacity-35'
: 'hover:border-sky-300 hover:bg-sky-50 dark:hover:border-sky-700 dark:hover:bg-sky-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 transition-colors"
>
清空
</button>
<button
type="button"
onClick={setToday}
className="text-xs font-medium text-sky-600 hover:text-sky-700 dark:text-sky-400 dark:hover:text-sky-300 transition-colors"
>
今天
</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-sky-300 ring-offset-1 dark:ring-sky-600'
: 'ring-2 ring-teal-300 ring-offset-1 dark:ring-teal-600'
return (
<div className="overflow-x-auto pb-2">
<div className="w-max min-w-full h-64 flex items-end gap-2 px-1">
{items.map((item) => {
const targetHeight = Math.max((item.value / maxValue) * 92, 4)
const isActive = activeKey === item.key
return (
<button
key={item.key}
type="button"
onClick={() => onSelect(item.key)}
className="group shrink-0 w-20 text-center rounded-apple transition-colors hover:bg-gray-50 dark:hover:bg-gray-800/60"
>
<div className="h-[160px] flex items-end justify-center overflow-visible px-1">
<div
className="relative w-8 transition-all duration-500 ease-out"
style={{ height: animated ? `${targetHeight}%` : '0%' }}
>
{/* 数值标签:改用深色文字 + 柔和背景,白底卡片上清晰可见 */}
<div className="absolute left-1/2 -translate-x-1/2 -top-6 text-xs font-semibold text-gray-900 dark:text-gray-100 whitespace-nowrap">
{item.value}
</div>
<div
className={`h-full w-8 rounded-t-md shadow-sm transition-all ${barColorClass} ${
isActive ? activeBarClass : 'group-hover:brightness-110'
}`}
/>
</div>
</div>
<div className="mt-1.5 px-1 min-h-[2.5rem] flex items-start justify-center">
<div
className="text-xs font-medium text-gray-700 dark:text-gray-200 leading-4 whitespace-normal break-words text-center line-clamp-2"
title={item.label}
>
{item.label}
</div>
</div>
<div className="mt-0.5 leading-tight">
<div className="text-[11px] text-gray-500 dark:text-gray-400">
{item.percentage.toFixed(1)}%
</div>
{item.subText && (
<div className="text-[11px] text-gray-400 dark:text-gray-500 whitespace-nowrap mt-0.5" title={item.subText}>
{item.subText}
</div>
)}
</div>
</button>
)
})}
</div>
<div className="mt-3 flex items-center justify-end gap-1 text-[11px] text-gray-400 dark:text-gray-500">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M9 5l7 7-7 7" /></svg>
点击柱子查看人员占比
</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)
// 趋势区视图切换:'chart' 折线图(默认)| 'table' 表格
const [trendView, setTrendView] = useState<'chart' | 'table'>('chart')
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-gradient-to-r from-gray-50 to-white dark:from-gray-900 dark:to-gray-800 flex items-start justify-between gap-4 flex-wrap">
<div>
<h2 className="text-xl font-semibold text-gray-900 dark:text-white tracking-tight">数据统计看板</h2>
<p className="text-sm text-gray-500 dark:text-gray-400 mt-1">
统一按上方时间范围统计;点击分布项可直接跳转到下方人员明细。
</p>
<div className="mt-3 flex flex-wrap gap-2 text-xs">
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-apple bg-white/80 border border-gray-200 text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200">
<svg className="h-3 w-3 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M6.75 3v2.25M17.25 3v2.25M3 18.75V7.5a2.25 2.25 0 012.25-2.25h13.5A2.25 2.25 0 0121 7.5v11.25m-18 0A2.25 2.25 0 005.25 21h13.5A2.25 2.25 0 0021 18.75m-18 0V11.25A2.25 2.25 0 015.25 9h13.5A2.25 2.25 0 0121 11.25v7.5" /></svg>
{startDate || '不限'} ~ {endDate || '不限'}
</span>
<span className="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-apple bg-white/80 border border-gray-200 text-gray-700 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-200">
<svg className="h-3 w-3 text-gray-400" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="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 0z" /></svg>
{statsData?.selectedDepartmentName || '全部部门'}
</span>
</div>
{!canSelectDepartment && (departmentScopeName || statsData?.selectedDepartmentName) && (
<p className="text-xs text-sky-600 dark:text-sky-400 mt-2 inline-flex items-center gap-1">
<svg className="h-3 w-3" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M11.25 11.25l.041-.02a.75.75 0 011.063.852l-.708 2.836a.75.75 0 001.063.853l.041-.021M21 12a9 9 0 11-18 0 9 9 0 0118 0zm-9-3.75h.008v.008H12V8.25z" /></svg>
当前为 {departmentScopeName || statsData?.selectedDepartmentName} 部门范围
</p>
)}
</div>
<button
type="button"
onClick={handleExportCsv}
className="shrink-0 btn-primary text-sm"
>
导出当前筛选 CSV
</button>
</div>
<div className="p-6 space-y-6">
<div className="rounded-apple border border-gray-200 dark:border-gray-700 bg-gray-50/50 dark:bg-gray-900/40 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.5 py-1.5 text-xs font-medium rounded-apple border transition-all ${
dateFilterMode === preset.key
? 'border-gray-900 bg-gray-900 text-white shadow-sm dark:bg-gray-100 dark:text-gray-900 dark:border-gray-100'
: 'border-gray-200 dark:border-gray-700 text-gray-600 dark:text-gray-300 bg-white dark:bg-gray-800 hover:border-gray-300 hover:bg-gray-50 dark:hover:bg-gray-700'
}`}
>
{preset.label}
</button>
))}
{dateFilterMode === 'custom' && (
<span className="px-2.5 py-1 text-xs rounded-apple border border-sky-300 bg-sky-50 text-sky-700 dark:border-sky-700 dark:bg-sky-950/40 dark:text-sky-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-apple border border-gray-200 bg-white text-gray-600 dark:bg-gray-800 dark:border-gray-700 dark:text-gray-300 px-3 py-2 text-sm inline-flex items-center gap-1.5">
<svg className="h-3.5 w-3.5 text-gray-400 shrink-0" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><path strokeLinecap="round" strokeLinejoin="round" d="M16.5 10.5V6.75a4.5 4.5 0 10-9 0v3.75m-.75 11.25h10.5a2.25 2.25 0 002.25-2.25v-6.75a2.25 2.25 0 00-2.25-2.25H6.75a2.25 2.25 0 00-2.25 2.25v6.75a2.25 2.25 0 002.25 2.25z" /></svg>
已按部门范围自动过滤
</div>
</div>
)}
<div>
<button
type="button"
onClick={handleApplyFilters}
className="btn-secondary w-full text-sm"
>
刷新数据
</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="rounded-apple border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40">
<div className="flex items-center gap-2">
<span className="inline-block h-4 w-1 rounded-full bg-sky-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">部门上传分布(核心)</h3>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-3">
统计范围:{statsData?.departmentDistribution.label || '-'}
</p>
</div>
<div className="p-5">
<HorizontalBarChart
items={departmentBars}
emptyText="当前条件下暂无部门分布数据"
activeKey={drilldown?.source === 'department' ? drilldown.key : ''}
onSelect={handleDepartmentBarSelect}
accent="blue"
/>
</div>
</div>
<div className="rounded-apple border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40">
<div className="flex items-center gap-2">
<span className="inline-block h-4 w-1 rounded-full bg-teal-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">素材类型上传分布(核心)</h3>
</div>
<p className="text-xs text-gray-500 dark:text-gray-400 mt-1.5 ml-3">
统计范围:{statsData?.materialTypeDistribution.label || '-'}
</p>
</div>
<div className="p-5">
<HorizontalBarChart
items={materialTypeBars}
emptyText="当前条件下暂无素材类型分布数据"
activeKey={drilldown?.source === 'materialType' ? drilldown.key : ''}
onSelect={handleMaterialTypeBarSelect}
accent="emerald"
/>
</div>
</div>
</div>
<div ref={drilldownSectionRef} className="scroll-mt-24 rounded-apple border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="inline-block h-4 w-1 rounded-full bg-violet-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
{drilldown?.title || '人员素材占比(点击上方柱子查看)'}
</h3>
</div>
{drilldown && (
<span className="text-xs text-gray-500 dark:text-gray-400">
合计 {drilldown.totalUploads} 条
</span>
)}
</div>
<div className="p-5">
{drilldownUsers.length ? (
<div className="max-h-[360px] overflow-y-auto space-y-2.5 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-apple border border-gray-100 dark:border-gray-800 bg-gray-50/50 dark:bg-gray-800/30 px-4 py-2.5 transition-colors hover:border-gray-200 dark:hover:border-gray-700"
>
<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-500 dark:text-gray-400 whitespace-nowrap tabular-nums">
{user.uploadCount} 条 ({percentage.toFixed(1)}%)
</div>
</div>
<div className="mt-2 h-1.5 rounded-full bg-gray-100 dark:bg-gray-700 overflow-hidden">
<div
className="h-full rounded-full bg-gradient-to-r from-violet-400 to-violet-600 transition-all duration-500"
style={{ width: `${width}%` }}
/>
</div>
<div className="mt-1.5 text-[11px] text-gray-400 dark:text-gray-500">{user.departmentName}</div>
</div>
)
})}
</div>
) : (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">
请选择上方柱子查看对应人员占比
</div>
)}
</div>
</div>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
<div className="rounded-apple border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40 flex items-center justify-between gap-3 flex-wrap">
<div className="flex items-center gap-2">
<span className="inline-block h-4 w-1 rounded-full bg-sky-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
每日趋势{statsData?.dailyUploads.length ? `(共 ${statsData.dailyUploads.length} 天)` : ''}
</h3>
</div>
<div className="inline-flex rounded-apple border border-gray-200 dark:border-gray-700 overflow-hidden text-xs">
<button
type="button"
onClick={() => setTrendView('chart')}
className={`px-2.5 py-1 font-medium transition-colors ${trendView === 'chart' ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
图表
</button>
<button
type="button"
onClick={() => setTrendView('table')}
className={`px-2.5 py-1 font-medium transition-colors ${trendView === 'table' ? 'bg-gray-900 text-white dark:bg-gray-100 dark:text-gray-900' : 'text-gray-600 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800'}`}
>
表格
</button>
</div>
</div>
{trendView === 'chart' ? (
<div className="p-4">
<TrendLineChart
data={statsData?.dailyUploads || []}
granularity="day"
accent="sky"
startDate={startDate}
endDate={endDate}
/>
</div>
) : (
<div className="max-h-[320px] overflow-y-auto">
{statsData?.dailyUploads.length ? (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50/80 dark:bg-gray-800/60 backdrop-blur z-10">
<tr className="border-b border-gray-100 dark:border-gray-800">
<th className="px-5 py-2.5 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">日期</th>
<th className="px-5 py-2.5 text-right font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">上传数</th>
</tr>
</thead>
<tbody>
{statsData.dailyUploads.map((item) => (
<tr key={item.bucket} className="border-b border-gray-50 dark:border-gray-800/60 transition-colors hover:bg-gray-50/60 dark:hover:bg-gray-800/30">
<td className="px-5 py-2.5 text-gray-700 dark:text-gray-300 tabular-nums">{item.bucket}</td>
<td className="px-5 py-2.5 text-right font-semibold text-gray-900 dark:text-white tabular-nums">{item.count}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">暂无每日趋势数据</div>
)}
</div>
)}
</div>
<div className="rounded-apple border border-gray-200 dark:border-gray-700 bg-white dark:bg-gray-900 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-200 dark:border-gray-700 bg-gray-50/60 dark:bg-gray-800/40">
<div className="flex items-center gap-2">
<span className="inline-block h-4 w-1 rounded-full bg-teal-500" />
<h3 className="text-sm font-semibold text-gray-900 dark:text-white">
月度趋势{statsData?.monthlyUploads.length ? `(共 ${statsData.monthlyUploads.length} 个月)` : ''}
</h3>
</div>
</div>
{trendView === 'chart' ? (
<div className="p-4">
<TrendLineChart
data={statsData?.monthlyUploads || []}
granularity="month"
accent="teal"
startDate={startDate}
endDate={endDate}
/>
</div>
) : (
<div className="max-h-[320px] overflow-y-auto">
{statsData?.monthlyUploads.length ? (
<table className="w-full text-sm">
<thead className="sticky top-0 bg-gray-50/80 dark:bg-gray-800/60 backdrop-blur z-10">
<tr className="border-b border-gray-100 dark:border-gray-800">
<th className="px-5 py-2.5 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">月份</th>
<th className="px-5 py-2.5 text-right font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">上传数</th>
</tr>
</thead>
<tbody>
{statsData.monthlyUploads.map((item) => (
<tr key={item.bucket} className="border-b border-gray-50 dark:border-gray-800/60 transition-colors hover:bg-gray-50/60 dark:hover:bg-gray-800/30">
<td className="px-5 py-2.5 text-gray-700 dark:text-gray-300 tabular-nums">{item.bucket}</td>
<td className="px-5 py-2.5 text-right font-semibold text-gray-900 dark:text-white tabular-nums">{item.count}</td>
</tr>
))}
</tbody>
</table>
) : (
<div className="py-16 text-center text-sm text-gray-400 dark:text-gray-500">暂无月度趋势数据</div>
)}
</div>
)}
</div>
</div>
</div>
</div>
{canViewLogs && (
<div className="card-apple overflow-hidden">
<div className="px-6 py-5 border-b border-gray-200 dark:border-gray-700 bg-gradient-to-r from-gray-50 to-white dark:from-gray-900 dark:to-gray-800">
<h2 className="text-lg font-semibold text-gray-900 dark:text-white tracking-tight">素材操作日志</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.5">操作类型</label>
<select
id="stats-log-action"
value={logAction}
onChange={(event) => {
setLogAction(event.target.value)
setLogPage(1)
}}
className="input-apple py-2 px-3 text-sm"
>
{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.5">关键词</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="input-apple py-2 px-3 text-sm"
/>
</div>
<div className="flex items-end gap-2">
<button
type="button"
onClick={() => {
setLogPage(1)
setLogKeyword(logKeywordInput.trim())
}}
className="btn-primary flex-1 text-sm"
>
查询
</button>
<button
type="button"
onClick={() => {
setLogKeywordInput('')
setLogKeyword('')
setLogAction('ALL')
setLogPage(1)
}}
className="btn-secondary flex-1 text-sm"
>
重置
</button>
</div>
</div>
<div className="rounded-apple border border-gray-200 dark:border-gray-700 overflow-x-auto">
<table className="w-full min-w-[860px] text-sm">
<thead className="bg-gray-50/80 dark:bg-gray-800/60">
<tr>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">时间</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">操作</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">素材</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">操作人</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">部门</th>
<th className="px-4 py-3 text-left font-medium text-gray-500 dark:text-gray-400 text-xs uppercase tracking-wide">变更详情</th>
</tr>
</thead>
<tbody>
{logsLoading ? (
<tr>
<td colSpan={6} className="px-4 py-12 text-center text-gray-400 dark:text-gray-500">加载日志中...</td>
</tr>
) : logsData?.logs.length ? (
logsData.logs.map((log) => (
<tr key={log.id} className="border-t border-gray-100 dark:border-gray-800 transition-colors hover:bg-gray-50/60 dark:hover:bg-gray-800/30">
<td className="px-4 py-3 text-gray-600 dark:text-gray-300 whitespace-nowrap tabular-nums">
{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 mt-0.5">{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-12 text-center text-gray-400 dark:text-gray-500">暂无日志数据</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">
共 <span className="font-semibold text-gray-900 dark:text-white tabular-nums">{logsData?.total || 0}</span> 条,当前第 {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.5 py-1.5 text-sm font-medium border border-gray-200 dark:border-gray-700 rounded-apple text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
上一页
</button>
<button
type="button"
disabled={!canGoNext}
onClick={() => setLogPage((prev) => prev + 1)}
className="px-3.5 py-1.5 text-sm font-medium border border-gray-200 dark:border-gray-700 rounded-apple text-gray-700 dark:text-gray-300 hover:bg-gray-50 dark:hover:bg-gray-800 disabled:opacity-40 disabled:cursor-not-allowed transition-colors"
>
下一页
</button>
</div>
</div>
</div>
</div>
)}
</div>
)
}