1525 lines
60 KiB
TypeScript
1525 lines
60 KiB
TypeScript
'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>
|
||
)
|
||
}
|