import React, { useState, useEffect, useMemo } from 'react'; import { Row, Col, Table, Tag, Progress, DatePicker, Select, Button, Empty, Tooltip, Badge, Typography, Space, Avatar, Spin, Dropdown, Menu, Statistic, } from 'antd'; import { PieChartOutlined, BarChartOutlined, WarningOutlined, HistoryOutlined, ReloadOutlined, CalendarOutlined, AppstoreOutlined, ArrowUpOutlined, ArrowDownOutlined, BoxPlotOutlined, ExclamationCircleOutlined, ShoppingCartOutlined, DatabaseOutlined, ExportOutlined, FallOutlined, RiseOutlined, MinusOutlined, ThunderboltOutlined, FileExcelOutlined, MoreOutlined, EyeOutlined, } from '@ant-design/icons'; import { motion, AnimatePresence } from 'framer-motion'; import styled from 'styled-components'; import dayjs from 'dayjs'; import api from '../api'; import { consumableRecordAPI, consumableCategoryAPI, consumableAPI } from '../api/cache'; import { message } from 'antd'; import { selectStyles, datePickerStyles, } from '../styles/deviceManagementStyles'; import { designTokens } from '../config/theme'; const { Title, Text } = Typography; const { RangePicker } = DatePicker; const { Option } = Select; const containerVariants = { hidden: { opacity: 0 }, visible: { opacity: 1, transition: { staggerChildren: 0.06, delayChildren: 0.1 }, }, }; const itemVariants = { hidden: { opacity: 0, y: 16 }, visible: { opacity: 1, y: 0, transition: { type: 'spring', stiffness: 120, damping: 18 }, }, }; const PageContainer = styled.div` padding: 24px; background: ${designTokens.colors.background.main}; min-height: 100vh; @media (max-width: 768px) { padding: 16px; } `; const PageHeader = styled(motion.div)` margin-bottom: 24px; display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 16px; `; const TitleSection = styled.div` display: flex; align-items: center; gap: 16px; .icon-wrapper { width: 52px; height: 52px; background: ${designTokens.colors.primary.gradient}; border-radius: 16px; display: flex; align-items: center; justify-content: center; box-shadow: 0 8px 24px rgba(99, 102, 241, 0.25); .anticon { font-size: 26px; color: white; } } .title-content { h1 { margin: 0; font-size: 26px; font-weight: 700; color: ${designTokens.colors.text.primary}; } .subtitle { color: ${designTokens.colors.text.secondary}; font-size: 14px; margin-top: 2px; } } `; const QuickFilterBar = styled(motion.div)` display: flex; gap: 8px; margin-bottom: 20px; flex-wrap: wrap; `; const QuickFilterBtn = styled(Button)` border-radius: 20px; height: 32px; padding: 0 16px; font-size: 13px; font-weight: 500; border: 1px solid ${props => props.$active ? designTokens.colors.primary.main : designTokens.colors.border}; background: ${props => props.$active ? designTokens.colors.primary.main : 'transparent'}; color: ${props => props.$active ? 'white' : designTokens.colors.text.secondary}; &:hover { border-color: ${designTokens.colors.primary.main}; color: ${props => props.$active ? 'white' : designTokens.colors.primary.main}; background: ${props => props.$active ? designTokens.colors.primary.main : 'rgba(99, 102, 241, 0.05)'}; } `; const FilterCard = styled(motion.div)` background: ${designTokens.colors.background.card}; padding: 24px; border-radius: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); margin-bottom: 24px; border: 1px solid ${designTokens.colors.border}; @media (max-width: 768px) { padding: 16px; } `; const FilterRow = styled.div` display: flex; align-items: center; gap: 24px; flex-wrap: wrap; @media (max-width: 768px) { flex-direction: column; align-items: stretch; gap: 16px; } `; const FilterGroup = styled.div` display: flex; align-items: center; gap: 12px; @media (max-width: 768px) { flex-direction: column; align-items: flex-start; gap: 8px; } `; const FilterItem = styled.div` display: flex; flex-direction: column; gap: 8px; .filter-label { font-size: 13px; font-weight: 600; color: ${designTokens.colors.text.primary}; display: flex; align-items: center; gap: 6px; &::before { content: ''; width: 3px; height: 12px; background: ${designTokens.colors.primary.main}; border-radius: 2px; } } `; const FilterActions = styled.div` display: flex; align-items: center; gap: 12px; margin-left: auto; @media (max-width: 768px) { margin-left: 0; width: 100%; } `; const StatsGrid = styled(motion.div)` display: grid; grid-template-columns: repeat(4, 1fr); gap: 16px; margin-bottom: 24px; @media (max-width: 1200px) { grid-template-columns: repeat(2, 1fr); } @media (max-width: 576px) { grid-template-columns: 1fr; } `; const StatsCard = styled(motion.div)` background: ${designTokens.colors.background.card}; border-radius: 16px; padding: 20px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); border: 1px solid ${designTokens.colors.border}; position: relative; overflow: hidden; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); &:hover { transform: translateY(-6px); box-shadow: 0 12px 32px rgba(0, 0, 0, 0.1); border-color: ${props => props.$borderColor || designTokens.colors.primary.main}40; } &::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 4px; background: ${props => props.$accent || designTokens.colors.primary.gradient}; } .card-content { display: flex; flex-direction: column; gap: 12px; } .card-header { display: flex; align-items: center; justify-content: space-between; } .card-icon { width: 48px; height: 48px; border-radius: 14px; display: flex; align-items: center; justify-content: center; font-size: 24px; background: ${props => props.$iconBg || 'rgba(99, 102, 241, 0.1)'}; color: ${props => props.$iconColor || designTokens.colors.primary.main}; box-shadow: 0 4px 12px ${props => props.$iconShadow || 'rgba(99, 102, 241, 0.2)'}; } .card-trend { display: flex; align-items: center; gap: 4px; font-size: 12px; font-weight: 600; padding: 4px 10px; border-radius: 20px; &.up { color: ${designTokens.colors.success.main}; background: rgba(16, 185, 129, 0.1); } &.down { color: ${designTokens.colors.error.main}; background: rgba(239, 68, 68, 0.1); } &.neutral { color: ${designTokens.colors.text.secondary}; background: rgba(107, 114, 128, 0.1); } } .card-body { display: flex; flex-direction: column; gap: 4px; } .card-value { font-size: 32px; font-weight: 700; color: ${designTokens.colors.text.primary}; line-height: 1; } .card-label { font-size: 13px; color: ${designTokens.colors.text.secondary}; font-weight: 500; } `; const BentoGrid = styled(motion.div)` display: grid; grid-template-columns: repeat(12, 1fr); grid-template-rows: auto; gap: 20px; margin-bottom: 24px; @media (max-width: 1200px) { grid-template-columns: repeat(6, 1fr); } @media (max-width: 768px) { grid-template-columns: 1fr; } `; const BentoCard = styled(motion.div)` background: ${designTokens.colors.background.card}; border-radius: 16px; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); border: 1px solid ${designTokens.colors.border}; overflow: hidden; grid-column: ${props => props.$col || 'span 6'}; transition: all 0.3s ease; &:hover { box-shadow: 0 8px 24px rgba(0, 0, 0, 0.06); } .card-header { padding: 18px 24px; border-bottom: 1px solid ${designTokens.colors.border}; display: flex; align-items: center; justify-content: space-between; background: ${designTokens.colors.background.main}; .header-left { display: flex; align-items: center; gap: 12px; .header-icon { width: 40px; height: 40px; border-radius: 12px; display: flex; align-items: center; justify-content: center; font-size: 20px; background: ${props => props.$iconBg || designTokens.colors.primary.gradient}; color: white; box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1); } .header-title { font-size: 15px; font-weight: 600; color: ${designTokens.colors.text.primary}; } } .header-extra { color: ${designTokens.colors.text.secondary}; font-size: 12px; font-weight: 500; padding: 4px 10px; background: ${designTokens.colors.background.card}; border-radius: 8px; border: 1px solid ${designTokens.colors.border}; } } .card-body { padding: 0; } `; const InOutComparison = styled.div` display: grid; grid-template-columns: 1fr 1fr; gap: 16px; @media (max-width: 576px) { grid-template-columns: 1fr; } `; const ComparisonItem = styled.div` padding: 20px; border-radius: 14px; background: ${props => props.$bg || 'transparent'}; text-align: center; transition: all 0.3s ease; border: 1px solid ${props => props.$color}20; &:hover { background: ${props => props.$bg || 'transparent'}; border-color: ${props => props.$color}40; transform: translateY(-2px); } .item-icon { width: 56px; height: 56px; margin: 0 auto 14px; border-radius: 16px; display: flex; align-items: center; justify-content: center; font-size: 26px; background: ${props => props.$color}15; color: ${props => props.$color}; } .item-value { font-size: 34px; font-weight: 700; color: ${props => props.$color}; margin-bottom: 4px; line-height: 1; } .item-label { font-size: 14px; color: ${designTokens.colors.text.secondary}; margin-bottom: 10px; font-weight: 500; } .item-sub { font-size: 13px; color: ${designTokens.colors.text.secondary}; display: flex; align-items: center; justify-content: center; gap: 4px; strong { color: ${props => props.$color}; font-weight: 600; } } `; const CategoryGrid = styled.div` display: grid; grid-template-columns: repeat(auto-fill, minmax(150px, 1fr)); gap: 12px; `; const CategoryCard = styled(motion.div)` padding: 16px; border-radius: 12px; background: ${designTokens.colors.background.card}; border: 1px solid ${designTokens.colors.border}; text-align: center; cursor: pointer; transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1); position: relative; overflow: hidden; &::before { content: ''; position: absolute; top: 0; left: 0; right: 0; height: 3px; background: ${props => props.$color}; opacity: 0; transition: opacity 0.3s ease; } &:hover { transform: translateY(-4px); box-shadow: 0 8px 24px ${props => props.$color}20; border-color: ${props => props.$color}40; &::before { opacity: 1; } } .category-badge { display: inline-flex; align-items: center; gap: 6px; padding: 5px 12px; border-radius: 16px; font-size: 12px; font-weight: 600; margin-bottom: 12px; background: ${props => props.$color}15; color: ${props => props.$color}; text-transform: uppercase; letter-spacing: 0.5px; .dot { width: 6px; height: 6px; border-radius: 50%; background: currentColor; } } .category-count { font-size: 32px; font-weight: 700; color: ${designTokens.colors.text.primary}; margin-bottom: 4px; line-height: 1; } .category-label { font-size: 12px; color: ${designTokens.colors.text.secondary}; margin-bottom: 10px; font-weight: 500; } .category-stock { font-size: 12px; color: ${designTokens.colors.text.secondary}; padding-top: 8px; border-top: 1px solid ${designTokens.colors.border}40; strong { color: ${designTokens.colors.text.primary}; font-weight: 600; } } `; const StyledTable = styled(Table)` .ant-table { background: transparent; font-size: 13px; } .ant-table-thead > tr > th { background: linear-gradient(135deg, #fafbfc 0%, #f5f7fa 100%); font-weight: 600; font-size: 12px; color: ${designTokens.colors.text.secondary}; border-bottom: 1px solid ${designTokens.colors.border}; padding: 12px 16px; text-transform: uppercase; letter-spacing: 0.5px; } .ant-table-tbody > tr > td { padding: 14px 16px; border-bottom: 1px solid ${designTokens.colors.border}40; } .ant-table-tbody > tr:hover > td { background: rgba(99, 102, 241, 0.03); } .ant-table-wrapper { border-radius: 0 0 16px 16px; } `; const ProgressBar = styled.div` .progress-wrapper { display: flex; align-items: center; gap: 10px; .ant-progress { flex: 1; margin: 0; } .progress-text { font-size: 13px; font-weight: 600; min-width: 42px; text-align: right; } } `; const EmptyState = styled.div` text-align: center; padding: 48px 24px; color: ${designTokens.colors.text.secondary}; .empty-icon { font-size: 56px; margin-bottom: 16px; opacity: 0.4; } .empty-text { font-size: 15px; margin-bottom: 6px; font-weight: 500; } .empty-subtext { font-size: 13px; opacity: 0.7; } `; const LoadingOverlay = styled.div` position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(255, 255, 255, 0.8); display: flex; align-items: center; justify-content: center; border-radius: 16px; z-index: 10; `; const ConsumableStatistics = () => { const [loading, setLoading] = useState(true); const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [], }); const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [], }); const [lowStockItems, setLowStockItems] = useState([]); const [categories, setCategories] = useState([]); const [dateRange, setDateRange] = useState([dayjs().subtract(30, 'days'), dayjs()]); const [categoryFilter, setCategoryFilter] = useState('all'); const [quickFilter, setQuickFilter] = useState('30days'); const quickFilters = [ { key: 'today', label: '今日', days: 0 }, { key: '7days', label: '近7天', days: 7 }, { key: '30days', label: '近30天', days: 30 }, { key: '90days', label: '近90天', days: 90 }, ]; const loadCategories = async () => { try { const response = await consumableCategoryAPI.getList(); console.log('[分类] 返回数据:', response); setCategories(response || []); } catch (error) { console.error('加载分类列表失败:', error); } }; const loadStatistics = async () => { try { setLoading(true); const params = { startDate: dateRange[0]?.format('YYYY-MM-DD'), endDate: dateRange[1]?.format('YYYY-MM-DD'), category: categoryFilter !== 'all' ? categoryFilter : undefined, }; const statsResponse = await consumableRecordAPI.statistics(params); console.log('[统计] 返回:', statsResponse); console.log('[统计] 最近记录:', statsResponse?.recentRecords); setStats({ inCount: statsResponse?.inCount || 0, outCount: statsResponse?.outCount || 0, inQuantity: statsResponse?.inQuantity || 0, outQuantity: statsResponse?.outQuantity || 0, recentRecords: statsResponse?.recentRecords || [], }); const summaryResponse = await consumableAPI.getStatistics(); console.log('[汇总] 返回:', summaryResponse); setSummary({ total: summaryResponse?.total || 0, lowStock: summaryResponse?.lowStock || 0, totalValue: summaryResponse?.totalValue || 0, byCategory: summaryResponse?.byCategory || [], }); } catch (error) { const errorMsg = error?.message || error || '未知错误'; message.error('加载统计数据失败: ' + errorMsg); console.error('加载统计数据失败:', error); setStats({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] }); setSummary({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] }); } finally { setLoading(false); } }; const loadLowStockItems = async () => { try { const response = await consumableAPI.getLowStock(); console.log('[低库存] 返回:', response); setLowStockItems(response || []); } catch (error) { console.error('加载低库存预警失败:', error?.message || error); setLowStockItems([]); } }; useEffect(() => { loadCategories(); loadStatistics(); loadLowStockItems(); }, []); const handleQuickFilter = (key) => { setQuickFilter(key); const filter = quickFilters.find(f => f.key === key); if (filter) { if (filter.days === 0) { setDateRange([dayjs().startOf('day'), dayjs()]); } else { setDateRange([dayjs().subtract(filter.days, 'days'), dayjs()]); } } }; const handleRefresh = () => { loadStatistics(); loadLowStockItems(); message.success('数据已刷新'); }; const handleExport = () => { message.info('导出功能开发中...'); }; const getCategoryColor = (category) => { const predefinedColors = [ '#6366f1', '#10b981', '#f59e0b', '#ec4899', '#8b5cf6', '#06b6d4', '#f97316', '#14b8a6', '#ef4444', '#3b82f6' ]; const colorMap = { '网络设备': '#6366f1', '线缆': '#10b981', '配件': '#f59e0b', '工具': '#ec4899', '其他': '#6b7280', }; if (colorMap[category]) return colorMap[category]; let hash = 0; for (let i = 0; i < category.length; i++) { hash = category.charCodeAt(i) + ((hash << 5) - hash); } return predefinedColors[Math.abs(hash) % predefinedColors.length]; }; const lowStockColumns = [ { title: '耗材名称', dataIndex: 'name', key: 'name', render: (text, record) => ( {text} {record.specification || '-'} ), }, { title: '当前库存', dataIndex: 'currentStock', key: 'currentStock', align: 'center', width: 100, render: (currentStock, record) => ( {currentStock} {record.unit} ), }, { title: '安全库存', dataIndex: 'minStock', key: 'minStock', align: 'center', width: 100, render: (minStock, record) => ( {minStock} {record.unit} ), }, { title: '充足率', key: 'rate', align: 'center', width: 140, render: (_, record) => { const minStock = record.minStock || 0; const currentStock = record.currentStock || 0; if (minStock <= 0) { return 未设置; } const rate = Math.min(100, Math.round((currentStock / minStock) * 100)); const color = rate < 50 ? designTokens.colors.error.main : rate < 100 ? designTokens.colors.warning.main : designTokens.colors.success.main; return ( {rate}% ); }, }, ]; const recentColumns = [ { title: '类型', dataIndex: 'type', key: 'type', width: 80, render: (type) => ( : } color={type === 'in' ? 'success' : 'processing'} style={{ borderRadius: 12, fontWeight: 500, border: 'none' }} > {type === 'in' ? '入库' : '出库'} ), }, { title: '耗材', dataIndex: 'consumableName', key: 'consumableName', width: 200, render: (text, record) => ( {record.category?.charAt(0) || '耗'} {text || '-'} {record.category && ( {record.category} )} ), }, { title: '数量', dataIndex: 'quantity', key: 'quantity', align: 'center', width: 90, render: (quantity, record) => ( {record.type === 'in' ? '+' : '-'}{quantity} {record.unit || '个'} ), }, { title: '操作人', dataIndex: 'operator', key: 'operator', width: 100, render: (operator) => ( {operator?.charAt(0) || '?'} {operator || '-'} ), }, { title: '时间', dataIndex: 'createdAt', key: 'createdAt', width: 140, render: (date) => ( {dayjs(date).format('MM-DD HH:mm')} ), }, ]; const netQuantity = useMemo(() => { return (stats?.inQuantity || 0) - (stats?.outQuantity || 0); }, [stats]); return ( 耗材统计 实时监控耗材库存状态与流转情况 } onClick={handleExport} style={{ height: 38, borderRadius: 10, borderColor: designTokens.colors.border, }} > 导出报表 } onClick={handleRefresh} loading={loading} style={{ height: 38, borderRadius: 10, background: designTokens.colors.primary.gradient, border: 'none', }} > 刷新 {quickFilters.map(filter => ( handleQuickFilter(filter.key)} > {filter.label} ))} 时间范围 { setDateRange(dates); setQuickFilter(null); }} style={{ ...datePickerStyles.range, width: 320 }} allowClear={false} /> 耗材类别 全部类别 {categories.map(cat => ( {cat.name} ))} } style={{ height: 40, borderRadius: 10, background: designTokens.colors.primary.gradient, border: 'none', padding: '0 24px', fontWeight: 500, }} > 应用筛选 { setQuickFilter('30days'); setDateRange([dayjs().subtract(30, 'days'), dayjs()]); setCategoryFilter('all'); }} icon={} style={{ height: 40, borderRadius: 10, borderColor: designTokens.colors.border, }} > 重置 总览 {summary?.total || 0} 耗材种类 {summary?.lowStock > 0 && ( 需关注 )} {summary?.lowStock || 0} 库存预警 入库 {stats?.inQuantity || 0} 期间入库 出库 {stats?.outQuantity || 0} 期间出库 出入库统计 {dateRange[0]?.format('MM/DD')} - {dateRange[1]?.format('MM/DD')} {stats?.inCount || 0} 入库次数 共 {stats?.inQuantity || 0} 件 {stats?.outCount || 0} 出库次数 共 {stats?.outQuantity || 0} 件 = 0 ? 'rgba(16, 185, 129, 0.08)' : 'rgba(239, 68, 68, 0.08)'} 0%, ${netQuantity >= 0 ? 'rgba(5, 150, 105, 0.04)' : 'rgba(185, 28, 28, 0.04)'} 100%)`, borderRadius: 14, display: 'flex', alignItems: 'center', justifyContent: 'center', gap: 12, border: `1px solid ${netQuantity >= 0 ? 'rgba(16, 185, 129, 0.2)' : 'rgba(239, 68, 68, 0.2)'}`, }} > {netQuantity >= 0 ? ( <> 净入库 +{netQuantity} 件 > ) : ( <> 净出库 {Math.abs(netQuantity)} 件 > )} 类别分布 {summary?.byCategory?.length > 0 ? ( {summary.byCategory.slice(0, 8).map((item, index) => ( {item.category} {item.count} 种耗材 库存: {item.totalQuantity || 0} ))} ) : ( 暂无类别数据 添加耗材后将自动统计 )} 库存预警 {lowStockItems.length > 0 ? ( ) : ( 全部充足 )} 库存充足 所有耗材均在安全范围内 ), }} /> 最近记录 最近10条 暂无记录 出入库操作后将显示 ), }} /> ); }; export default ConsumableStatistics;