Files
yunrui_asset/frontend/src/pages/ConsumableStatistics.jsx
T
zhang1106 a620575d92 feat(设备管理): 添加机房筛选功能并优化UI样式
refactor(耗材统计): 重构筛选卡片布局和统计卡片样式

style(3D可视化): 优化机柜信息卡片样式和交互体验
2026-03-19 14:38:50 +08:00

1399 lines
38 KiB
React

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) => (
<Space>
<Avatar
size={34}
style={{
background: `linear-gradient(135deg, ${designTokens.colors.warning.main}, #fb923c)`,
fontSize: '14px',
}}
>
<WarningOutlined />
</Avatar>
<div>
<div style={{ fontWeight: 600, color: designTokens.colors.text.primary, fontSize: '14px' }}>
{text}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
{record.specification || '-'}
</div>
</div>
</Space>
),
},
{
title: '当前库存',
dataIndex: 'currentStock',
key: 'currentStock',
align: 'center',
width: 100,
render: (currentStock, record) => (
<Text strong style={{ fontSize: '15px', color: designTokens.colors.error.main }}>
{currentStock} {record.unit}
</Text>
),
},
{
title: '安全库存',
dataIndex: 'minStock',
key: 'minStock',
align: 'center',
width: 100,
render: (minStock, record) => (
<Text type="secondary">{minStock} {record.unit}</Text>
),
},
{
title: '充足率',
key: 'rate',
align: 'center',
width: 140,
render: (_, record) => {
const minStock = record.minStock || 0;
const currentStock = record.currentStock || 0;
if (minStock <= 0) {
return <Text type="secondary" style={{ fontSize: '12px' }}>未设置</Text>;
}
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 (
<ProgressBar>
<div className="progress-wrapper">
<Progress
percent={rate}
size="small"
strokeColor={color}
showInfo={false}
/>
<span className="progress-text" style={{ color }}>{rate}%</span>
</div>
</ProgressBar>
);
},
},
];
const recentColumns = [
{
title: '类型',
dataIndex: 'type',
key: 'type',
width: 80,
render: (type) => (
<Tag
icon={type === 'in' ? <ArrowDownOutlined /> : <ArrowUpOutlined />}
color={type === 'in' ? 'success' : 'processing'}
style={{ borderRadius: 12, fontWeight: 500, border: 'none' }}
>
{type === 'in' ? '入库' : '出库'}
</Tag>
),
},
{
title: '耗材',
dataIndex: 'consumableName',
key: 'consumableName',
width: 200,
render: (text, record) => (
<Space>
<Avatar
size={30}
style={{
background: record.category ? getCategoryColor(record.category) : designTokens.colors.info.main,
fontSize: '12px',
}}
>
{record.category?.charAt(0) || '耗'}
</Avatar>
<div>
<div style={{ fontWeight: 600, fontSize: '14px', color: designTokens.colors.text.primary }}>
{text || '-'}
</div>
{record.category && (
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
{record.category}
</div>
)}
</div>
</Space>
),
},
{
title: '数量',
dataIndex: 'quantity',
key: 'quantity',
align: 'center',
width: 90,
render: (quantity, record) => (
<Text
strong
style={{
fontSize: '15px',
color: record.type === 'in' ? designTokens.colors.success.main : designTokens.colors.error.main,
}}
>
{record.type === 'in' ? '+' : '-'}{quantity} {record.unit || '个'}
</Text>
),
},
{
title: '操作人',
dataIndex: 'operator',
key: 'operator',
width: 100,
render: (operator) => (
<Space size={6}>
<Avatar size={24} style={{ background: designTokens.colors.info.main, fontSize: '12px' }}>
{operator?.charAt(0) || '?'}
</Avatar>
<Text style={{ fontSize: '13px' }}>{operator || '-'}</Text>
</Space>
),
},
{
title: '时间',
dataIndex: 'createdAt',
key: 'createdAt',
width: 140,
render: (date) => (
<Text type="secondary" style={{ fontSize: '12px' }}>
{dayjs(date).format('MM-DD HH:mm')}
</Text>
),
},
];
const netQuantity = useMemo(() => {
return (stats?.inQuantity || 0) - (stats?.outQuantity || 0);
}, [stats]);
return (
<PageContainer>
<PageHeader variants={itemVariants} initial="hidden" animate="visible">
<TitleSection>
<motion.div
className="icon-wrapper"
whileHover={{ scale: 1.05, rotate: 3 }}
transition={{ type: 'spring', stiffness: 400 }}
>
<BarChartOutlined />
</motion.div>
<div className="title-content">
<h1>耗材统计</h1>
<div className="subtitle">实时监控耗材库存状态与流转情况</div>
</div>
</TitleSection>
<Space>
<Button
icon={<ExportOutlined />}
onClick={handleExport}
style={{
height: 38,
borderRadius: 10,
borderColor: designTokens.colors.border,
}}
>
导出报表
</Button>
<Button
type="primary"
icon={<ReloadOutlined />}
onClick={handleRefresh}
loading={loading}
style={{
height: 38,
borderRadius: 10,
background: designTokens.colors.primary.gradient,
border: 'none',
}}
>
刷新
</Button>
</Space>
</PageHeader>
<QuickFilterBar variants={containerVariants} initial="hidden" animate="visible">
{quickFilters.map(filter => (
<QuickFilterBtn
key={filter.key}
$active={quickFilter === filter.key}
onClick={() => handleQuickFilter(filter.key)}
>
{filter.label}
</QuickFilterBtn>
))}
</QuickFilterBar>
<FilterCard variants={itemVariants}>
<FilterRow>
<FilterGroup>
<FilterItem>
<span className="filter-label">时间范围</span>
<RangePicker
value={dateRange}
onChange={(dates) => {
setDateRange(dates);
setQuickFilter(null);
}}
style={{ ...datePickerStyles.range, width: 320 }}
allowClear={false}
/>
</FilterItem>
<FilterItem>
<span className="filter-label">耗材类别</span>
<Select
value={categoryFilter}
onChange={setCategoryFilter}
style={{ ...selectStyles.base, width: 180 }}
showSearch
placeholder="选择类别"
optionFilterProp="children"
>
<Option value="all">全部类别</Option>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>{cat.name}</Option>
))}
</Select>
</FilterItem>
</FilterGroup>
<FilterActions>
<Button
type="primary"
onClick={loadStatistics}
loading={loading}
icon={<ThunderboltOutlined />}
style={{
height: 40,
borderRadius: 10,
background: designTokens.colors.primary.gradient,
border: 'none',
padding: '0 24px',
fontWeight: 500,
}}
>
应用筛选
</Button>
<Button
onClick={() => {
setQuickFilter('30days');
setDateRange([dayjs().subtract(30, 'days'), dayjs()]);
setCategoryFilter('all');
}}
icon={<ReloadOutlined />}
style={{
height: 40,
borderRadius: 10,
borderColor: designTokens.colors.border,
}}
>
重置
</Button>
</FilterActions>
</FilterRow>
</FilterCard>
<StatsGrid variants={containerVariants} initial="hidden" animate="visible">
<StatsCard
variants={itemVariants}
$accent={designTokens.colors.primary.gradient}
$iconBg="rgba(99, 102, 241, 0.1)"
$iconColor={designTokens.colors.primary.main}
$iconShadow="rgba(99, 102, 241, 0.2)"
$borderColor={designTokens.colors.primary.main}
whileHover={{ y: -6 }}
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><DatabaseOutlined /></div>
<div className="card-trend neutral">
<AppstoreOutlined /> 总览
</div>
</div>
<div className="card-body">
<div className="card-value">{summary?.total || 0}</div>
<div className="card-label">耗材种类</div>
</div>
</div>
</StatsCard>
<StatsCard
variants={itemVariants}
$accent={designTokens.colors.warning.gradient}
$iconBg="rgba(245, 158, 11, 0.1)"
$iconColor={designTokens.colors.warning.main}
$iconShadow="rgba(245, 158, 11, 0.2)"
$borderColor={designTokens.colors.warning.main}
whileHover={{ y: -6 }}
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><WarningOutlined /></div>
{summary?.lowStock > 0 && (
<div className="card-trend down">
<ExclamationCircleOutlined /> 需关注
</div>
)}
</div>
<div className="card-body">
<div className="card-value" style={{ color: designTokens.colors.warning.main }}>
{summary?.lowStock || 0}
</div>
<div className="card-label">库存预警</div>
</div>
</div>
</StatsCard>
<StatsCard
variants={itemVariants}
$accent={designTokens.colors.success.gradient}
$iconBg="rgba(16, 185, 129, 0.1)"
$iconColor={designTokens.colors.success.main}
$iconShadow="rgba(16, 185, 129, 0.2)"
$borderColor={designTokens.colors.success.main}
whileHover={{ y: -6 }}
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><ArrowDownOutlined /></div>
<div className="card-trend up">
<RiseOutlined /> 入库
</div>
</div>
<div className="card-body">
<div className="card-value" style={{ color: designTokens.colors.success.main }}>
{stats?.inQuantity || 0}
</div>
<div className="card-label">期间入库</div>
</div>
</div>
</StatsCard>
<StatsCard
variants={itemVariants}
$accent={designTokens.colors.secondary.gradient}
$iconBg="rgba(236, 72, 153, 0.1)"
$iconColor={designTokens.colors.secondary.main}
$iconShadow="rgba(236, 72, 153, 0.2)"
$borderColor={designTokens.colors.secondary.main}
whileHover={{ y: -6 }}
>
<div className="card-content">
<div className="card-header">
<div className="card-icon"><ArrowUpOutlined /></div>
<div className="card-trend neutral">
<FallOutlined /> 出库
</div>
</div>
<div className="card-body">
<div className="card-value" style={{ color: designTokens.colors.secondary.main }}>
{stats?.outQuantity || 0}
</div>
<div className="card-label">期间出库</div>
</div>
</div>
</StatsCard>
</StatsGrid>
<BentoGrid variants={containerVariants} initial="hidden" animate="visible">
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.info.gradient}>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><ShoppingCartOutlined /></div>
<span className="header-title">出入库统计</span>
</div>
<div className="header-extra">
{dateRange[0]?.format('MM/DD')} - {dateRange[1]?.format('MM/DD')}
</div>
</div>
<div className="card-body">
<InOutComparison>
<ComparisonItem
$bg="rgba(16, 185, 129, 0.04)"
$color={designTokens.colors.success.main}
>
<div className="item-icon"><ArrowDownOutlined /></div>
<div className="item-value">{stats?.inCount || 0}</div>
<div className="item-label">入库次数</div>
<div className="item-sub"> <strong>{stats?.inQuantity || 0}</strong> </div>
</ComparisonItem>
<ComparisonItem
$bg="rgba(99, 102, 241, 0.04)"
$color={designTokens.colors.primary.main}
>
<div className="item-icon"><ArrowUpOutlined /></div>
<div className="item-value">{stats?.outCount || 0}</div>
<div className="item-label">出库次数</div>
<div className="item-sub"> <strong>{stats?.outQuantity || 0}</strong> </div>
</ComparisonItem>
</InOutComparison>
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 }}
style={{
marginTop: 20,
padding: '18px 24px',
background: `linear-gradient(135deg, ${netQuantity >= 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 ? (
<>
<RiseOutlined style={{ color: designTokens.colors.success.main, fontSize: 20 }} />
<Text style={{ color: designTokens.colors.success.main, fontWeight: 600, fontSize: 15 }}>
净入库 +{netQuantity}
</Text>
</>
) : (
<>
<FallOutlined style={{ color: designTokens.colors.error.main, fontSize: 20 }} />
<Text style={{ color: designTokens.colors.error.main, fontWeight: 600, fontSize: 15 }}>
净出库 {Math.abs(netQuantity)}
</Text>
</>
)}
</motion.div>
</div>
</BentoCard>
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.secondary.gradient}>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><PieChartOutlined /></div>
<span className="header-title">类别分布</span>
</div>
</div>
<div className="card-body">
{summary?.byCategory?.length > 0 ? (
<CategoryGrid>
{summary.byCategory.slice(0, 8).map((item, index) => (
<CategoryCard
key={item.category}
$color={getCategoryColor(item.category)}
initial={{ opacity: 0, scale: 0.9 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ delay: index * 0.05 }}
>
<div className="category-badge">
<span className="dot" />
{item.category}
</div>
<div className="category-count">{item.count}</div>
<div className="category-label">种耗材</div>
<div className="category-stock">
库存: <strong>{item.totalQuantity || 0}</strong>
</div>
</CategoryCard>
))}
</CategoryGrid>
) : (
<EmptyState>
<PieChartOutlined className="empty-icon" />
<div className="empty-text">暂无类别数据</div>
<div className="empty-subtext">添加耗材后将自动统计</div>
</EmptyState>
)}
</div>
</BentoCard>
</BentoGrid>
<BentoGrid variants={containerVariants} initial="hidden" animate="visible">
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.warning.gradient}>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><ExclamationCircleOutlined /></div>
<span className="header-title">库存预警</span>
</div>
{lowStockItems.length > 0 ? (
<Badge
count={lowStockItems.length}
style={{ backgroundColor: designTokens.colors.warning.main }}
/>
) : (
<span style={{
fontSize: 12,
color: designTokens.colors.success.main,
fontWeight: 500,
padding: '4px 10px',
background: 'rgba(16, 185, 129, 0.1)',
borderRadius: 8
}}>
全部充足
</span>
)}
</div>
<div className="card-body" style={{ padding: 0 }}>
<StyledTable
columns={lowStockColumns}
dataSource={lowStockItems}
rowKey="consumableId"
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
locale={{
emptyText: (
<EmptyState>
<BoxPlotOutlined className="empty-icon" style={{ color: designTokens.colors.success.main }} />
<div className="empty-text">库存充足</div>
<div className="empty-subtext">所有耗材均在安全范围内</div>
</EmptyState>
),
}}
/>
</div>
</BentoCard>
<BentoCard variants={itemVariants} $col="span 6" $iconBg={designTokens.colors.success.gradient}>
<div className="card-header">
<div className="header-left">
<div className="header-icon"><HistoryOutlined /></div>
<span className="header-title">最近记录</span>
</div>
<div className="header-extra">最近10条</div>
</div>
<div className="card-body" style={{ padding: 0 }}>
<StyledTable
columns={recentColumns}
dataSource={stats?.recentRecords || []}
rowKey="recordId"
pagination={false}
size="small"
scroll={{ x: 'max-content' }}
locale={{
emptyText: (
<EmptyState>
<HistoryOutlined className="empty-icon" />
<div className="empty-text">暂无记录</div>
<div className="empty-subtext">出入库操作后将显示</div>
</EmptyState>
),
}}
/>
</div>
</BentoCard>
</BentoGrid>
</PageContainer>
);
};
export default ConsumableStatistics;