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) => (
+
+ }
+ onClick={() => {
+ setSelectedConsumable(record);
+ setTimelineModalVisible(true);
+ }}
+ style={{ color: designTokens.colors.info.main }}
+ />
+