From 7c27e6e100071b2b7862ef15a8c43596bf83bdb7 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Thu, 9 Apr 2026 16:18:36 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=80=97=E6=9D=90=E7=AE=A1=E7=90=86):=20?= =?UTF-8?q?=E6=B7=BB=E5=8A=A0=E8=80=97=E6=9D=90=E6=93=8D=E4=BD=9C=E8=AE=B0?= =?UTF-8?q?=E5=BD=95=E6=97=B6=E9=97=B4=E7=BA=BF=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 新增耗材操作记录时间线组件,展示耗材的入库、出库、调整等操作历史 包含操作类型、数量变化、操作人等信息,并支持分页加载更多记录 --- .../components/ConsumableTimelineModal.jsx | 497 ++++++++++++++++++ frontend/src/pages/ConsumableManagement.jsx | 27 +- 2 files changed, 523 insertions(+), 1 deletion(-) create mode 100644 frontend/src/components/ConsumableTimelineModal.jsx diff --git a/frontend/src/components/ConsumableTimelineModal.jsx b/frontend/src/components/ConsumableTimelineModal.jsx new file mode 100644 index 0000000..b3bce27 --- /dev/null +++ b/frontend/src/components/ConsumableTimelineModal.jsx @@ -0,0 +1,497 @@ +import React, { useState, useEffect, useCallback } from 'react'; +import { + Modal, + Timeline, + Tag, + Space, + Typography, + Card, + Row, + Col, + Statistic, + Spin, + Empty, + Tooltip, + Badge, +} from 'antd'; +import { + ClockCircleOutlined, + InboxOutlined, + ExportOutlined, + PlusOutlined, + EditOutlined, + DeleteOutlined, + SwapOutlined, + UploadOutlined, + UserOutlined, + EnvironmentOutlined, + BarcodeOutlined, +} from '@ant-design/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import relativeTime from 'dayjs/plugin/relativeTime'; +import CloseButton from './CloseButton'; +import { designTokens } from '../config/theme'; + +dayjs.extend(relativeTime); + +const { Text, Title } = Typography; + +const getOperationConfig = type => { + const config = { + in: { + color: designTokens.colors.success.main, + bg: designTokens.colors.success.bg, + icon: , + label: '入库', + prefix: '+', + }, + out: { + color: designTokens.colors.error.main, + bg: designTokens.colors.error.bg, + icon: , + label: '出库', + prefix: '', + }, + create: { + color: designTokens.colors.primary.main, + bg: designTokens.colors.primary.bg, + icon: , + label: '创建', + prefix: '', + }, + update: { + color: designTokens.colors.warning.main, + bg: designTokens.colors.warning.bg, + icon: , + label: '更新', + prefix: '', + }, + delete: { + color: designTokens.colors.error.main, + bg: designTokens.colors.error.bg, + icon: , + label: '删除', + prefix: '', + }, + adjust: { + color: designTokens.colors.purple.main, + bg: '#f5f3ff', + icon: , + label: '调整', + prefix: '', + }, + import: { + color: designTokens.colors.info.main, + bg: designTokens.colors.info.bg, + icon: , + label: '导入', + prefix: '', + }, + }; + return config[type] || { + color: designTokens.colors.neutral[500], + bg: designTokens.colors.neutral[100], + icon: , + label: type, + prefix: '', + }; +}; + +const TimelineItem = ({ log, isFirst, isLast }) => { + const config = getOperationConfig(log.operationType); + const isPositive = log.quantity > 0; + + return ( + + + + + + {config.icon} {config.label} + + + {dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss')} + + + + ({dayjs(log.createdAt).fromNow()}) + + + + + + + + +
+ + 变动数量 + + + {config.prefix} + {log.quantity} + +
+ + +
+ + 库存变化 + + + {log.previousStock} + + + {log.currentStock} + + +
+ + +
+ + 操作人 + + + + {log.operator || '系统'} + +
+ +
+ + {(log.reason || log.notes) && ( +
+ {log.reason && ( +
+ + 原因: + + {log.reason} +
+ )} + {log.notes && ( +
+ + 备注: + + {log.notes} +
+ )} +
+ )} + + {log.snList && log.snList.length > 0 && ( +
+ + + SN序列号 ({log.snList.length}个) + + + {log.snList.slice(0, 5).map((sn, idx) => ( + + {sn} + + ))} + {log.snList.length > 5 && ( + ... + )} + +
+ )} +
+ ); +}; + +const ConsumableTimelineModal = ({ visible, consumable, onClose }) => { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(false); + const [pagination, setPagination] = useState({ current: 1, pageSize: 20, total: 0 }); + const [stats, setStats] = useState({ totalIn: 0, totalOut: 0, netChange: 0 }); + + const fetchLogs = useCallback(async (page = 1) => { + if (!consumable?.consumableId) return; + + setLoading(true); + try { + const response = await axios.get('/api/consumables/logs', { + params: { + consumableId: consumable.consumableId, + page, + pageSize: pagination.pageSize, + }, + }); + + const fetchedLogs = (response.data.logs || []).filter( + log => log.operationType !== 'update' + ); + setLogs(page === 1 ? fetchedLogs : [...logs, ...fetchedLogs]); + setPagination(prev => ({ + ...prev, + current: page, + total: response.data.total - (response.data.logs || []).filter(log => log.operationType === 'update').length, + })); + + const totalIn = fetchedLogs + .filter(log => log.operationType === 'in') + .reduce((sum, log) => sum + Math.abs(log.quantity), 0); + const totalOut = fetchedLogs + .filter(log => log.operationType === 'out') + .reduce((sum, log) => sum + Math.abs(log.quantity), 0); + setStats({ + totalIn, + totalOut, + netChange: totalIn - totalOut, + }); + } catch (error) { + console.error('获取耗材操作记录失败:', error); + } finally { + setLoading(false); + } + }, [consumable, pagination.pageSize, logs]); + + useEffect(() => { + if (visible && consumable?.consumableId) { + setLogs([]); + setPagination(prev => ({ ...prev, current: 1 })); + fetchLogs(1); + } + }, [visible, consumable]); + + const handleLoadMore = () => { + if (!loading && logs.length < pagination.total) { + fetchLogs(pagination.current + 1); + } + }; + + if (!consumable) return null; + + return ( + } + footer={null} + width={900} + title={ + + + 耗材操作记录 + + {consumable.name} + + + ({consumable.consumableId}) + + + } + style={{ top: 20 }} + bodyStyle={{ maxHeight: 'calc(100vh - 200px)', overflowY: 'auto', padding: '16px 24px' }} + > + + + + + + 总入库 + + } + value={stats.totalIn} + valueStyle={{ color: designTokens.colors.success.main, fontSize: '24px' }} + suffix={consumable.unit || '个'} + /> + + + + + 总出库 + + } + value={stats.totalOut} + valueStyle={{ color: designTokens.colors.error.main, fontSize: '24px' }} + suffix={consumable.unit || '个'} + /> + + + + + 净变化 + + } + value={stats.netChange} + valueStyle={{ + color: stats.netChange >= 0 ? designTokens.colors.success.main : designTokens.colors.error.main, + fontSize: '24px', + }} + prefix={stats.netChange >= 0 ? '+' : ''} + suffix={consumable.unit || '个'} + /> + + + + + 当前库存 + + } + value={consumable.currentStock || 0} + valueStyle={{ color: designTokens.colors.info.main, fontSize: '24px' }} + suffix={consumable.unit || '个'} + /> + + + + +
+ + + + 共 {pagination.total} 条操作记录 + + + + 入库 + 出库 + 创建 + 调整 + +
+ + {loading && logs.length === 0 ? ( +
+ + + 加载中... + +
+ ) : logs.length === 0 ? ( + + 暂无操作记录 + + } + image={Empty.PRESENTED_IMAGE_SIMPLE} + style={{ padding: '40px' }} + /> + ) : ( + <> + ({ + key: log.id || index, + color: getOperationConfig(log.operationType).color, + dot: ( +
+ {getOperationConfig(log.operationType).icon} +
+ ), + children: ( + + ), + }))} + /> + + {logs.length < pagination.total && ( +
+ + {loading ? '加载中...' : `加载更多 (${logs.length}/${pagination.total})`} + +
+ )} + + )} +
+ ); +}; + +export default ConsumableTimelineModal; diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx index 9bec2aa..3242ba3 100644 --- a/frontend/src/pages/ConsumableManagement.jsx +++ b/frontend/src/pages/ConsumableManagement.jsx @@ -30,6 +30,7 @@ import { AutoComplete, Avatar, Statistic, + Timeline, } from 'antd'; import { PlusOutlined, @@ -56,12 +57,14 @@ import { InfoCircleOutlined, QrcodeOutlined, DesktopOutlined, + HistoryOutlined, } from '@ant-design/icons'; import axios from 'axios'; import * as XLSX from 'xlsx'; import { motion, AnimatePresence } from 'framer-motion'; import { designTokens } from '../config/theme'; import CloseButton from '../components/CloseButton'; +import ConsumableTimelineModal from '../components/ConsumableTimelineModal'; import { inputStyles, selectStyles, @@ -172,6 +175,8 @@ function ConsumableManagement() { const [showFieldMapping, setShowFieldMapping] = useState(false); const [pollingInterval, setPollingInterval] = useState(null); const importProgressRef = React.useRef(null); + const [timelineModalVisible, setTimelineModalVisible] = useState(false); + const [selectedConsumable, setSelectedConsumable] = useState(null); // 获取全部耗材(用于扫码入库下拉框,不受分页限制) const fetchAllConsumablesForScan = useCallback(async () => { @@ -1482,10 +1487,21 @@ function ConsumableManagement() { { title: '操作', key: 'action', - width: 200, + width: 240, fixed: 'right', render: (_, record) => ( + +