Files
yunrui_asset/frontend/src/pages/ConsumableManagement.jsx
T
zhang1106 5450d5f05a refactor(ConsumableManagement): 移除多余的导出菜单项
简化导出菜单,删除不常用的筛选结果和预警库存导出选项
2026-04-10 12:33:01 +08:00

5131 lines
189 KiB
React
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import React, { useState, useEffect, useCallback, useMemo } from 'react';
import { useDebounce, useDebouncedCallback } from '../hooks/useDebounce';
import {
Table,
Button,
Modal,
Form,
Input,
Select,
InputNumber,
message,
Card,
Space,
Popconfirm,
Popover,
Upload,
Progress,
Checkbox,
Radio,
Row,
Col,
Badge,
Tag,
Tooltip,
Empty,
Skeleton,
Alert,
Typography,
Divider,
AutoComplete,
Avatar,
Statistic,
Timeline,
Dropdown,
Menu,
} from 'antd';
import {
PlusOutlined,
EditOutlined,
DeleteOutlined,
SearchOutlined,
ExportOutlined,
ImportOutlined,
UploadOutlined,
FileExcelOutlined,
FileTextOutlined,
ShoppingOutlined,
FilterOutlined,
ClearOutlined,
ReloadOutlined,
CheckCircleOutlined,
ExclamationCircleOutlined,
ArrowUpOutlined,
ArrowDownOutlined,
ScanOutlined,
BarcodeOutlined,
DownloadOutlined,
WarningOutlined,
InfoCircleOutlined,
QrcodeOutlined,
DesktopOutlined,
HistoryOutlined,
EyeOutlined,
CloseCircleOutlined,
DownOutlined,
CheckSquareOutlined,
SettingOutlined,
} 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,
inputNumberStyles,
textAreaStyles,
filterInputStyles,
inputPlaceholders,
inputValidationRules,
} from '../styles/deviceManagementStyles';
const { Option } = Select;
const { Text, Title } = Typography;
const { TextArea } = Input;
// 动画配置
const animations = {
container: {
hidden: { opacity: 0 },
visible: {
opacity: 1,
transition: {
staggerChildren: 0.05,
delayChildren: 0.1,
},
},
},
item: {
hidden: { opacity: 0, y: 20 },
visible: {
opacity: 1,
y: 0,
transition: {
duration: 0.3,
ease: [0.25, 0.46, 0.45, 0.94],
},
},
},
};
function ConsumableManagement() {
const [consumables, setConsumables] = useState([]);
const [allConsumablesForScan, setAllConsumablesForScan] = useState([]);
const [loading, setLoading] = useState(true);
const [modalVisible, setModalVisible] = useState(false);
const [editingConsumable, setEditingConsumable] = useState(null);
const [form] = Form.useForm();
const [categories, setCategories] = useState([]);
const [pagination, setPagination] = useState({
current: 1,
pageSize: 10,
total: 0,
showTotal: total => `共 ${total} 条记录`,
});
const [keyword, setKeyword] = useState('');
const debouncedKeyword = useDebounce(keyword, 300);
const [category, setCategory] = useState('all');
const [status, setStatus] = useState('all');
const [importModalVisible, setImportModalVisible] = useState(false);
const [importPreview, setImportPreview] = useState([]);
const [importFile, setImportFile] = useState(null);
const [importing, setImporting] = useState(false);
const [importProgress, setImportProgress] = useState(0);
const [importPhase, setImportPhase] = useState('');
const [importResult, setImportResult] = useState(null);
const [importMode, setImportMode] = useState('create');
const [importStockMode, setImportStockMode] = useState('basic');
const [importValidationErrors, setImportValidationErrors] = useState([]);
const [importStep, setImportStep] = useState('upload');
const [stockModalVisible, setStockModalVisible] = useState(false);
const [stockRecord, setStockRecord] = useState(null);
const [stockType, setStockType] = useState('in');
const [stockForm] = Form.useForm();
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
const [snList, setSnList] = useState([]);
const [snInputVisible, setSnInputVisible] = useState(false);
const [snInputValue, setSnInputValue] = useState('');
const [selectedSnList, setSelectedSnList] = useState([]);
const [snSearchKeyword, setSnSearchKeyword] = useState('');
const [createWithInbound, setCreateWithInbound] = useState(false);
const [inboundData, setInboundData] = useState({
quantity: 1,
operator: '',
reason: '',
});
const [inboundSnList, setInboundSnList] = useState([]);
const [scanModalVisible, setScanModalVisible] = useState(false);
const [scanMode, setScanMode] = useState('add');
const [scanValue, setScanValue] = useState('');
const [scanChecking, setScanChecking] = useState(false);
const [scannedSnList, setScannedSnList] = useState([]);
const [selectedScanInConsumable, setSelectedScanInConsumable] = useState(null);
const scanInputRef = React.useRef(null);
const [quickOutModalVisible, setQuickOutModalVisible] = useState(false);
const [quickOutConsumable, setQuickOutConsumable] = useState(null);
const [quickOutDevice, setQuickOutDevice] = useState(null);
const [quickOutDeviceSearch, setQuickOutDeviceSearch] = useState('');
const [quickOutDeviceList, setQuickOutDeviceList] = useState([]);
const [quickOutDeviceLoading, setQuickOutDeviceLoading] = useState(false);
const [quickOutQuantity, setQuickOutQuantity] = useState(1);
const [quickOutReason, setQuickOutReason] = useState('');
const [quickOutSnList, setQuickOutSnList] = useState([]);
const [quickOutSubmitting, setQuickOutSubmitting] = useState(false);
const [pendingOutItems, setPendingOutItems] = useState([]);
const [batchOutModalVisible, setBatchOutModalVisible] = useState(false);
const [batchOutDevice, setBatchOutDevice] = useState(null);
const [batchOutDeviceSearch, setBatchOutDeviceSearch] = useState('');
const [batchOutDeviceList, setBatchOutDeviceList] = useState([]);
const [batchOutDeviceLoading, setBatchOutDeviceLoading] = useState(false);
const [batchOutReason, setBatchOutReason] = useState('');
const [batchOutSubmitting, setBatchOutSubmitting] = useState(false);
const [importJobId, setImportJobId] = useState(null);
const [importJobStatus, setImportJobStatus] = useState(null);
const [fieldMappings, setFieldMappings] = useState({});
const [availableFields, setAvailableFields] = useState([]);
const [detectedHeaders, setDetectedHeaders] = useState([]);
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 [exportModalVisible, setExportModalVisible] = useState(false);
const [exportMode, setExportMode] = useState('all'); // all/filtered/selected/warning
const [exportFields, setExportFields] = useState([
'consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock',
'unitPrice', 'supplier', 'location', 'description', 'status', 'snList'
]);
const [selectedRowKeys, setSelectedRowKeys] = useState([]);
// 导出字段定义
const exportableFields = [
{ key: 'consumableId', label: '耗材ID' },
{ key: 'name', label: '名称', required: true },
{ key: 'category', label: '分类' },
{ key: 'unit', label: '单位' },
{ key: 'currentStock', label: '当前库存' },
{ key: 'minStock', label: '最小库存' },
{ key: 'maxStock', label: '最大库存' },
{ key: 'unitPrice', label: '单价' },
{ key: 'supplier', label: '供应商' },
{ key: 'location', label: '存放位置' },
{ key: 'description', label: '描述' },
{ key: 'status', label: '状态' },
{ key: 'snList', label: 'SN序列号' },
];
// 获取全部耗材(用于扫码入库下拉框,不受分页限制)
const fetchAllConsumablesForScan = useCallback(async () => {
try {
const response = await axios.get('/api/consumables', {
params: { page: 1, pageSize: 9999 },
});
setAllConsumablesForScan(response.data.consumables || []);
} catch (error) {
console.error('获取全部耗材失败:', error);
}
}, []);
const fetchConsumables = useCallback(
async (page = 1, pageSize = 10) => {
try {
setLoading(true);
const response = await axios.get('/api/consumables', {
params: { page, pageSize, keyword: debouncedKeyword, category, status },
});
const data = response.data.consumables || [];
setConsumables(data);
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
// 同步刷新扫码入库用的全量列表
fetchAllConsumablesForScan();
} catch (error) {
message.error('获取耗材列表失败');
console.error('获取耗材列表失败:', error);
} finally {
setLoading(false);
}
},
[debouncedKeyword, category, status, fetchAllConsumablesForScan]
);
const fetchCategories = useCallback(async () => {
try {
const response = await axios.get('/api/consumable-categories/list');
setCategories(response.data || []);
} catch (error) {
console.error('获取分类列表失败:', error);
}
}, []);
useEffect(() => {
fetchConsumables();
fetchCategories();
}, [fetchConsumables, fetchCategories]);
const showModal = useCallback(
(consumable = null) => {
setEditingConsumable(consumable);
if (consumable) {
const isUnlimited =
consumable.maxStock === 0 ||
consumable.maxStock === null ||
consumable.maxStock === undefined;
setMaxStockUnlimited(isUnlimited);
setSnList(consumable.snList || []);
form.setFieldsValue({
...consumable,
maxStock: isUnlimited ? undefined : consumable.maxStock,
});
} else {
setMaxStockUnlimited(true);
setSnList([]);
setCreateWithInbound(false);
setInboundData({ quantity: 1, operator: '', reason: '' });
setInboundSnList([]);
form.resetFields();
form.setFieldsValue({
unit: '个',
minStock: 0,
status: 'active',
unitPrice: 0,
});
}
setModalVisible(true);
},
[form]
);
const handleCancel = useCallback(() => {
setModalVisible(false);
setEditingConsumable(null);
setSnList([]);
setSnInputVisible(false);
setSnInputValue('');
setCreateWithInbound(false);
setInboundData({ quantity: 1, operator: '', reason: '' });
}, []);
const handleSubmit = useCallback(
async values => {
try {
const submitData = {
...values,
maxStock: maxStockUnlimited ? 0 : values.maxStock,
unitPrice: values.unitPrice || 0,
};
if (editingConsumable) {
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
message.success({
content: '耗材更新成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} else {
if (createWithInbound) {
// 同时入库模式,调用创建并入库接口
await axios.post('/api/consumables/create-with-inbound', {
...submitData,
consumableId: `CON${Date.now()}`,
inboundQuantity: inboundData.quantity,
inboundOperator: inboundData.operator,
inboundReason: inboundData.reason,
inboundSnList: inboundSnList,
});
message.success({
content: '耗材创建成功并已入库',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} else {
// 普通创建模式
await axios.post('/api/consumables', {
...submitData,
consumableId: `CON${Date.now()}`,
});
message.success({
content: '耗材创建成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
}
}
setModalVisible(false);
fetchConsumables();
setEditingConsumable(null);
setSnList([]);
setCreateWithInbound(false);
setInboundData({ quantity: 1, operator: '', reason: '' });
setInboundSnList([]);
} catch (error) {
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
console.error('提交失败:', error);
}
},
[editingConsumable, fetchConsumables, maxStockUnlimited, createWithInbound, inboundData, inboundSnList]
);
const handleDelete = useCallback(
async consumableId => {
Modal.confirm({
title: '确认删除',
content: '确定要删除这个耗材吗?此操作不可恢复!',
okText: '删除',
okType: 'danger',
cancelText: '取消',
onOk: async () => {
try {
await axios.delete(`/api/consumables/${consumableId}`);
message.success({
content: '删除成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
fetchConsumables();
} catch (error) {
message.error('删除失败');
console.error('删除失败:', error);
}
},
});
},
[fetchConsumables]
);
const handleSearch = useCallback(value => {
setKeyword(value);
}, []);
const handleReset = () => {
setKeyword('');
setCategory('all');
setStatus('all');
};
const exportToCSV = (data, filename) => {
const headers = [
'耗材ID',
'名称',
'分类',
'单位',
'当前库存',
'最小库存',
'最大库存',
'单价',
'供应商',
'存放位置',
'状态',
];
const keys = [
'consumableId',
'name',
'category',
'unit',
'currentStock',
'minStock',
'maxStock',
'unitPrice',
'supplier',
'location',
'status',
];
const csvContent = [
headers.join(','),
...data.map(row =>
keys
.map(key => {
let value = row[key];
if (key === 'unitPrice') value = ${parseFloat(value || 0).toFixed(2)}`;
if (key === 'status') value = value === 'active' ? '启用' : '停用';
if (value === null || value === undefined) value = '';
const str = String(value);
if (str.includes(',') || str.includes('"') || str.includes('\n')) {
return `"${str.replace(/"/g, '""')}"`;
}
return str;
})
.join(',')
),
].join('\n');
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
};
const handleExport = async () => {
try {
// 根据导出模式准备参数
const params = { fields: exportFields.join(',') };
if (exportMode === 'all') {
// 导出全部,不加筛选条件
} else if (exportMode === 'filtered') {
// 导出筛选结果
params.keyword = keyword;
params.category = category;
params.status = status;
} else if (exportMode === 'selected') {
// 导出选中项
params.ids = selectedRowKeys.join(',');
} else if (exportMode === 'warning') {
// 导出预警库存
params.stockStatus = 'warning';
params.keyword = keyword;
params.category = category;
}
const response = await axios.get('/api/consumables/export', { params });
const consumables = response.data.consumables;
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
message.success({
content: '导出成功',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setExportModalVisible(false);
} catch (error) {
message.error('导出失败');
console.error('导出失败:', error);
}
};
const parseFile = file => {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = e => {
try {
const data = new Uint8Array(e.target.result);
const workbook = XLSX.read(data, { type: 'array' });
const firstSheet = workbook.Sheets[workbook.SheetNames[0]];
const jsonData = XLSX.utils.sheet_to_json(firstSheet, { header: 1 });
if (jsonData.length < 2) {
resolve({ data: [], headers: [] });
return;
}
const headers = jsonData[0].map(h => String(h || '').trim());
const result = [];
for (let i = 1; i < jsonData.length; i++) {
const row = jsonData[i];
const obj = {};
headers.forEach((header, idx) => {
obj[header] = row[idx] !== undefined ? String(row[idx] || '').trim() : '';
});
if (Object.values(obj).some(v => v)) {
result.push(obj);
}
}
resolve({ data: result, headers });
} catch (error) {
reject(error);
}
};
reader.onerror = () => reject(new Error('文件读取失败'));
reader.readAsArrayBuffer(file);
});
};
const detectFieldMappings = (headers, availableFields) => {
const mappings = {};
const normalizedAvailableFields = availableFields.map(f => ({
source: f.source,
target: f.target,
normalizedSource: f.source.toLowerCase(),
normalizedTarget: f.target.toLowerCase(),
}));
headers.forEach(header => {
const normalizedHeader = header.toLowerCase().replace(/[_\s]/g, '');
const match = normalizedAvailableFields.find(
f =>
f.normalizedSource.replace(/[_\s]/g, '') === normalizedHeader ||
f.normalizedTarget.replace(/[_\s]/g, '') === normalizedHeader ||
f.source === header
);
if (match) {
mappings[header] = match.target;
}
});
return mappings;
};
const validateImportData = (data, validCategories) => {
const errors = [];
const validCategoryNames = validCategories.map(c => c.name);
data.forEach((item, index) => {
const rowNum = index + 1;
const name = item['名称'] || item.name;
const category = item['分类'] || item.category;
if (!name) {
errors.push({ row: rowNum, field: '名称', message: '名称为必填项' });
}
if (!category) {
errors.push({ row: rowNum, field: '分类', message: '分类为必填项' });
} else if (validCategoryNames.length > 0 && !validCategoryNames.includes(category)) {
errors.push({
row: rowNum,
field: '分类',
message: `分类"${category}"不存在,请使用系统已有的分类`,
});
}
});
return errors;
};
const fetchFieldMappings = async () => {
try {
const response = await axios.get('/api/consumables/field-mappings');
if (response.data && response.data.aliases) {
setAvailableFields(response.data.aliases);
}
} catch (error) {
console.error('获取字段映射信息失败:', error);
}
};
const pollImportProgress = async jobId => {
try {
const response = await axios.get(`/api/consumables/progress/${jobId}`);
const progress = response.data;
setImportJobStatus(progress);
setImportProgress(progress.progressPercent || 0);
if (progress.status === 'completed') {
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
setImportPhase('导入完成');
fetchImportResult(jobId);
} else if (progress.status === 'failed') {
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
setImportPhase('导入失败');
setImporting(false);
message.error(progress.error || '导入失败');
} else if (progress.status === 'cancelled') {
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
setImportPhase('导入已取消');
setImporting(false);
}
} catch (error) {
console.error('获取导入进度失败:', error);
}
};
const fetchImportResult = async jobId => {
try {
const response = await axios.get(`/api/consumables/result/${jobId}`);
if (response.data && response.data.result) {
setImportResult(response.data.result);
setImportStep('result');
}
setImporting(false);
fetchConsumables();
} catch (error) {
console.error('获取导入结果失败:', error);
setImporting(false);
}
};
const handleCancelImport = async () => {
if (!importJobId) return;
try {
await axios.post(`/api/consumables/cancel/${importJobId}`);
message.info('正在取消导入任务...');
} catch (error) {
console.error('取消导入失败:', error);
message.error('取消导入失败');
}
};
const handleFileChange = async info => {
const file = info.fileList[info.fileList.length - 1];
if (file && file.originFileObj) {
try {
setImporting(true);
setImportPhase('正在解析文件...');
const { data: parsedData, headers: detectedHeadersList } = await parseFile(file.originFileObj);
if (parsedData.length === 0) {
message.warning('文件中没有有效数据');
setImporting(false);
return;
}
setDetectedHeaders(detectedHeadersList || []);
if (availableFields.length > 0 && detectedHeadersList.length > 0) {
const autoMappings = detectFieldMappings(detectedHeadersList, availableFields);
setFieldMappings(autoMappings);
}
const validationErrors = validateImportData(parsedData, categories);
setImportValidationErrors(validationErrors);
setImportPreview(parsedData);
setImportFile(file.originFileObj);
setImportStep('preview');
if (validationErrors.length > 0) {
message.warning(`数据校验发现 ${validationErrors.length} 个问题,请检查预览`);
} else {
message.success(`成功解析 ${parsedData.length} 条数据`);
}
} catch (error) {
message.error('文件解析失败,请确保文件格式正确');
console.error('文件解析失败:', error);
} finally {
setImporting(false);
setImportPhase('');
}
}
};
const showImportModal = () => {
setImportPreview([]);
setImportFile(null);
setImportModalVisible(true);
setImportStep('upload');
setImportMode('create');
setImportValidationErrors([]);
setImportResult(null);
setFieldMappings({});
setDetectedHeaders([]);
setImportJobId(null);
setImportJobStatus(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
if (availableFields.length === 0) {
fetchFieldMappings();
}
};
const handleImportCancel = () => {
setImportModalVisible(false);
setImportPreview([]);
setImportFile(null);
setImportProgress(0);
setImportPhase('');
setImportResult(null);
setImportStep('upload');
setImportValidationErrors([]);
setFieldMappings({});
setDetectedHeaders([]);
setImportJobId(null);
setImportJobStatus(null);
if (pollingInterval) {
clearInterval(pollingInterval);
setPollingInterval(null);
}
};
const handleImport = async () => {
if (!importFile || importPreview.length === 0) {
message.warning('请先选择并解析文件');
return;
}
setImporting(true);
setImportProgress(0);
setImportPhase('准备导入数据...');
setImportResult(null);
setImportStep('importing');
const useBackgroundMode = importPreview.length > 500;
try {
if (useBackgroundMode) {
setImportPhase('正在创建后台任务...');
const response = await axios.post('/api/consumables/background', {
items: importPreview,
mode: importMode,
fieldMapping: fieldMappings,
});
const { jobId } = response.data;
setImportJobId(jobId);
setImportProgress(5);
setImportPhase('后台任务已创建,正在导入...');
const interval = setInterval(() => {
pollImportProgress(jobId);
}, 1000);
setPollingInterval(interval);
} else {
setImportProgress(30);
setImportPhase('正在提交到服务器...');
const response = await axios.post('/api/consumables/import', {
items: importPreview,
mode: importMode,
stockMode: importStockMode,
});
setImportProgress(70);
setImportPhase('处理导入结果...');
const results = response.data.results;
setImportResult(results);
setImportStep('result');
setImportProgress(100);
setImportPhase('导入完成');
if (results.failed > 0) {
message.warning(response.data.message);
} else {
message.success({
content: response.data.message,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
}
fetchConsumables();
setImporting(false);
}
} catch (error) {
message.error('导入失败,请检查网络连接或服务器状态');
console.error('导入耗材失败:', error);
setImporting(false);
setImportStep('preview');
}
};
const downloadTemplate = (stockMode = 'basic') => {
// 根据 stockMode 生成不同的模板数据
const basicTemplate = {
耗材ID: 'CON001',
名称: '示例耗材-网络模块',
分类: '光模块',
单位: '个',
最小库存: 10,
最大库存: 500,
单价: 5.0,
供应商: 'XX公司',
存放位置: 'A柜-01层',
描述: '测试数据',
状态: 'active',
};
const inboundTemplate = {
...basicTemplate,
入库数量: 100,
操作人: '系统',
原因: '批量导入入库',
SN序列号: 'SN001,SN002,SN003',
};
const templateData = stockMode === 'basic' ? basicTemplate : inboundTemplate;
const basicFieldDescription = [
{
字段名: '耗材ID',
系统字段: 'consumableId',
必填: '否',
说明: '耗材唯一标识符,留空自动生成;填写后可识别并更新现有耗材',
示例: 'CON001',
},
{
字段名: '名称',
系统字段: 'name',
必填: '是',
说明: '耗材名称',
示例: '光纤跳线',
},
{
字段名: '分类',
系统字段: 'category',
必填: '是',
说明: '耗材分类,如"光模块"或"光纤跳线",需先在系统中创建该分类',
示例: '光模块',
},
{
字段名: '单位',
系统字段: 'unit',
必填: '否',
说明: '计量单位,如"个"、"根"、"箱",默认"个"',
示例: '个',
},
{
字段名: '最小库存',
系统字段: 'minStock',
必填: '否',
说明: '安全库存阈值,低于此值会触发预警',
示例: '10',
},
{
字段名: '最大库存',
系统字段: 'maxStock',
必填: '否',
说明: '最大库存限制,0表示无限制',
示例: '500',
},
{
字段名: '单价',
系统字段: 'unitPrice',
必填: '否',
说明: '耗材单价,数字类型',
示例: '5.00',
},
{
字段名: '供应商',
系统字段: 'supplier',
必填: '否',
说明: '耗材供应商名称',
示例: 'XX科技有限公司',
},
{
字段名: '存放位置',
系统字段: 'location',
必填: '否',
说明: '仓库内存放位置,如"A柜-01层"',
示例: 'A柜-01层',
},
{
字段名: '描述',
系统字段: 'description',
必填: '否',
说明: '耗材的详细描述或备注',
示例: '这是一条测试数据',
},
{
字段名: '状态',
系统字段: 'status',
必填: '否',
说明: '"active"启用,"inactive"停用,默认启用',
示例: 'active',
},
];
const inboundFieldDescription = [
...basicFieldDescription,
{
字段名: '入库数量',
系统字段: 'quantity',
必填: '是',
说明: '入库操作的数量,数字类型',
示例: '100',
},
{
字段名: '操作人',
系统字段: 'operator',
必填: '否',
说明: '入库操作人,默认"系统"',
示例: '系统',
},
{
字段名: '原因',
系统字段: 'reason',
必填: '否',
说明: '入库原因,默认"批量导入入库"',
示例: '批量导入入库',
},
{
字段名: 'SN序列号',
系统字段: 'snList',
必填: '否',
说明: '多个SN用逗号、分号或换行分隔,如"SN001,SN002"或"SN001\\nSN002"',
示例: 'SN001,SN002,SN003',
},
];
const fieldDescription = stockMode === 'basic' ? basicFieldDescription : inboundFieldDescription;
const template = [templateData];
const wb = XLSX.utils.book_new();
const ws1 = XLSX.utils.json_to_sheet(template);
XLSX.utils.book_append_sheet(wb, ws1, '耗材导入模板');
const ws2 = XLSX.utils.json_to_sheet(fieldDescription);
XLSX.utils.book_append_sheet(wb, ws2, '字段说明');
const ws3 = XLSX.utils.json_to_sheet([{ 注意: '请删除示例数据后填写您的实际数据' }]);
XLSX.utils.book_append_sheet(wb, ws3, '使用说明');
XLSX.writeFile(wb, '耗材导入模板.xlsx');
message.success({
content: '模板下载成功(包含3个工作表)',
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
};
const downloadFailedRecords = () => {
if (!importResult || !importResult.details) return;
const failedRecords = importResult.details
.filter(d => d.status === 'failed')
.map(d => {
const original = importPreview[d.row - 1] || {};
return {
行号: d.row,
错误原因: d.error,
...original,
};
});
if (failedRecords.length === 0) {
message.info('没有失败记录');
return;
}
const ws = XLSX.utils.json_to_sheet(failedRecords);
const wb = XLSX.utils.book_new();
XLSX.utils.book_append_sheet(wb, ws, '失败记录');
XLSX.writeFile(wb, '耗材导入失败记录.xlsx');
message.success('失败记录下载成功');
};
const showStockModal = useCallback(
(record, type) => {
setStockRecord(record);
setStockType(type);
setSelectedSnList([]);
setSnSearchKeyword('');
stockForm.setFieldsValue({
consumableId: record.consumableId,
consumableName: record.name,
quantity: 1,
reason: '',
notes: '',
});
setStockModalVisible(true);
},
[stockForm]
);
const handleStockCancel = useCallback(() => {
setStockModalVisible(false);
setStockRecord(null);
setSelectedSnList([]);
setSnSearchKeyword('');
}, []);
const handleStockSubmit = useCallback(
async values => {
try {
await axios.post('/api/consumables/quick-inout', {
consumableId: stockRecord.consumableId,
type: stockType,
quantity: values.quantity,
operator: values.operator || '系统管理员',
reason: values.reason,
notes: values.notes,
snList: stockType === 'out' ? selectedSnList : values.snList || [],
});
message.success({
content: `${stockType === 'in' ? '入库' : '出库'}操作成功`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setStockModalVisible(false);
setSelectedSnList([]);
fetchConsumables();
} catch (error) {
message.error(
error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`
);
console.error('操作失败:', error);
}
},
[stockRecord, stockType, fetchConsumables, selectedSnList]
);
const showScanModal = useCallback(mode => {
setScanMode(mode);
setScanValue('');
setScannedSnList([]);
setSelectedScanInConsumable(null);
setScanModalVisible(true);
setTimeout(() => {
scanInputRef.current?.focus();
}, 100);
}, []);
const handleScanCancel = useCallback(() => {
setScanModalVisible(false);
setScanValue('');
setScannedSnList([]);
setSelectedScanInConsumable(null);
setScanChecking(false);
}, []);
const addToPendingOut = (consumable, snList = []) => {
const existingIndex = pendingOutItems.findIndex(
item => item.consumable.consumableId === consumable.consumableId
);
if (existingIndex >= 0) {
const updated = [...pendingOutItems];
const existing = updated[existingIndex];
const newSnList = [...new Set([...existing.snList, ...snList])];
updated[existingIndex] = {
...existing,
quantity: newSnList.length > 0 ? newSnList.length : existing.quantity + 1,
snList: newSnList,
};
setPendingOutItems(updated);
} else {
setPendingOutItems([
...pendingOutItems,
{
consumable,
quantity: snList.length > 0 ? snList.length : 1,
snList,
},
]);
}
};
const removeFromPendingOut = consumableId => {
setPendingOutItems(pendingOutItems.filter(item => item.consumable.consumableId !== consumableId));
};
const handleScanKeyDown = useCallback(
async e => {
if (e.key === 'Enter' && scanValue.trim()) {
e.preventDefault();
const code = scanValue.trim();
if (scanMode === 'add') {
setScanChecking(true);
try {
const res = await axios.get(`/api/consumables/by-sn/${encodeURIComponent(code)}`);
if (res.data.found) {
const existingConsumable = res.data.consumable;
Modal.confirm({
title: 'SN已存在',
content: (
<div>
<p>
该SN已关联耗材<strong>{existingConsumable.name}</strong>
</p>
<p>分类{existingConsumable.category}</p>
<p>
当前库存{existingConsumable.currentStock} {existingConsumable.unit}
</p>
<p style={{ marginTop: 12, color: '#1890ff' }}>是否为该耗材入库</p>
</div>
),
okText: '入库',
cancelText: '关闭',
onOk: () => {
handleScanCancel();
setModalVisible(false);
showStockModal(existingConsumable, 'in');
setSelectedSnList([code]);
stockForm.setFieldsValue({ quantity: 1 });
},
onCancel: () => {
setScanValue('');
scanInputRef.current?.focus();
},
});
} else {
// 区分添加到哪个列表
const targetList = createWithInbound ? inboundSnList : snList;
const setTargetList = createWithInbound ? setInboundSnList : setSnList;
if (!targetList.includes(code)) {
setTargetList(prev => [...prev, code]);
// 如果是同时入库模式,自动同步入库数量
if (createWithInbound) {
setInboundData(prev => ({ ...prev, quantity: targetList.length + 1 }));
}
message.success(`已添加SN: ${code}`);
} else {
message.warning(`SN已在列表中: ${code}`);
}
setScanValue('');
scanInputRef.current?.focus();
}
} catch (error) {
message.error('查询SN失败');
console.error('查询SN失败:', error);
} finally {
setScanChecking(false);
}
} else if (scanMode === 'in') {
if (!scannedSnList.includes(code)) {
setScannedSnList(prev => [...prev, code]);
message.success(`已添加SN: ${code}`);
} else {
message.warning(`SN已存在: ${code}`);
}
setScanValue('');
scanInputRef.current?.focus();
} else if (scanMode === 'out') {
setScanChecking(true);
try {
const res = await axios.get(`/api/consumables/by-sn/${encodeURIComponent(code)}`);
if (res.data.found) {
const consumable = res.data.consumable;
const existingItem = pendingOutItems.find(
item => item.consumable.consumableId === consumable.consumableId
);
if (existingItem) {
if (!existingItem.snList.includes(code)) {
const updated = [...pendingOutItems];
const index = updated.findIndex(
item => item.consumable.consumableId === consumable.consumableId
);
updated[index] = {
...updated[index],
quantity: updated[index].quantity + 1,
snList: [...updated[index].snList, code],
};
setPendingOutItems(updated);
message.success({
content: `已添加 ${consumable.name} (SN: ${code}) 到出库列表`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} else {
message.warning(`SN已在列表中: ${code}`);
}
} else {
addToPendingOut(consumable, [code]);
message.success({
content: `已添加 ${consumable.name} (SN: ${code}) 到出库列表`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
}
setScanValue('');
scanInputRef.current?.focus();
} else {
message.warning('未找到该SN对应的耗材');
setScanValue('');
scanInputRef.current?.focus();
}
} catch (error) {
message.error('查询SN失败');
console.error('查询SN失败:', error);
} finally {
setScanChecking(false);
}
}
}
},
[
scanMode,
scanValue,
scannedSnList,
snList,
setSnList,
createWithInbound,
inboundSnList,
setInboundSnList,
setInboundData,
handleScanCancel,
showModal,
form,
showStockModal,
stockForm,
pendingOutItems,
addToPendingOut,
]
);
const handleScanInSubmit = useCallback(
async consumableId => {
if (scannedSnList.length === 0) {
message.warning('请先扫描SN序列号');
return;
}
try {
await axios.post('/api/consumables/quick-inout', {
consumableId,
type: 'in',
quantity: scannedSnList.length,
operator: '系统管理员',
reason: '扫码入库',
snList: scannedSnList,
});
message.success({
content: `成功入库 ${scannedSnList.length} 个SN`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
handleScanCancel();
fetchConsumables();
} catch (error) {
message.error(error.response?.data?.error || '入库操作失败');
console.error('入库失败:', error);
}
},
[scannedSnList, handleScanCancel, fetchConsumables]
);
const searchDevices = useCallback(async keyword => {
if (!keyword || keyword.length < 1) {
setQuickOutDeviceList([]);
return;
}
setQuickOutDeviceLoading(true);
try {
const response = await axios.get('/api/consumables/devices/search', {
params: { keyword, limit: 20 },
});
setQuickOutDeviceList(response.data.devices || []);
} catch (error) {
console.error('搜索设备失败:', error);
setQuickOutDeviceList([]);
} finally {
setQuickOutDeviceLoading(false);
}
}, []);
const debouncedDeviceSearch = useDebouncedCallback(searchDevices, 300);
const handleQuickOutDeviceSearch = value => {
setQuickOutDeviceSearch(value);
debouncedDeviceSearch(value);
};
const searchBatchDevices = useCallback(async keyword => {
if (!keyword || keyword.length < 1) {
setBatchOutDeviceList([]);
return;
}
setBatchOutDeviceLoading(true);
try {
const response = await axios.get('/api/consumables/devices/search', {
params: { keyword, limit: 20 },
});
setBatchOutDeviceList(response.data.devices || []);
} catch (error) {
console.error('搜索设备失败:', error);
setBatchOutDeviceList([]);
} finally {
setBatchOutDeviceLoading(false);
}
}, []);
const debouncedBatchDeviceSearch = useDebouncedCallback(searchBatchDevices, 300);
const handleBatchOutDeviceSearch = value => {
setBatchOutDeviceSearch(value);
debouncedBatchDeviceSearch(value);
};
const openQuickOutModal = (consumable, sn = null) => {
setQuickOutConsumable(consumable);
setQuickOutDevice(null);
setQuickOutDeviceSearch('');
setQuickOutDeviceList([]);
setQuickOutQuantity(1);
setQuickOutReason('');
setQuickOutSnList(sn ? [sn] : []);
setQuickOutModalVisible(true);
};
const handleQuickOutSubmit = async () => {
if (!quickOutConsumable) {
message.warning('请选择耗材');
return;
}
if (quickOutQuantity < 1) {
message.warning('出库数量必须大于0');
return;
}
if (quickOutQuantity > quickOutConsumable.currentStock) {
message.warning('出库数量不能超过当前库存');
return;
}
setQuickOutSubmitting(true);
try {
const response = await axios.post('/api/consumables/quick-inout', {
consumableId: quickOutConsumable.consumableId,
type: 'out',
quantity: quickOutQuantity,
operator: '系统管理员',
reason: quickOutReason,
notes: quickOutDevice ? `出库至设备: ${quickOutDevice.name}` : '',
snList: quickOutSnList,
deviceId: quickOutDevice?.deviceId || null,
});
message.success({
content: `成功出库 ${quickOutQuantity}${quickOutDevice ? `至设备 ${quickOutDevice.name}` : ''}`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
setQuickOutModalVisible(false);
fetchConsumables();
} catch (error) {
message.error(error.response?.data?.error || '出库操作失败');
console.error('出库失败:', error);
} finally {
setQuickOutSubmitting(false);
}
};
const openBatchOutModal = () => {
if (pendingOutItems.length === 0) {
message.warning('没有待出库的耗材');
return;
}
setBatchOutDevice(null);
setBatchOutDeviceSearch('');
setBatchOutDeviceList([]);
setBatchOutReason('');
setBatchOutModalVisible(true);
};
const handleBatchOutSubmit = async () => {
if (pendingOutItems.length === 0) {
message.warning('没有待出库的耗材');
return;
}
setBatchOutSubmitting(true);
let successCount = 0;
let failCount = 0;
const errors = [];
for (const item of pendingOutItems) {
try {
await axios.post('/api/consumables/quick-inout', {
consumableId: item.consumable.consumableId,
type: 'out',
quantity: item.quantity,
operator: '系统管理员',
reason: batchOutReason || '批量出库',
notes: batchOutDevice ? `出库至设备: ${batchOutDevice.name}` : '',
snList: item.snList,
deviceId: batchOutDevice?.deviceId || null,
});
successCount++;
} catch (error) {
failCount++;
errors.push(`${item.consumable.name}: ${error.response?.data?.error || error.message}`);
}
}
setBatchOutSubmitting(false);
setBatchOutModalVisible(false);
setPendingOutItems([]);
setBatchOutDevice(null);
setBatchOutDeviceSearch('');
setBatchOutReason('');
if (failCount === 0) {
message.success({
content: `成功出库 ${successCount}${batchOutDevice ? `至设备 ${batchOutDevice.name}` : ''}`,
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
});
} else {
message.warning({
content: `出库完成: 成功 ${successCount} 项, 失败 ${failCount} 项`,
icon: <ExclamationCircleOutlined style={{ color: designTokens.colors.warning.main }} />,
});
if (errors.length > 0) {
console.error('出库失败详情:', errors);
}
}
fetchConsumables();
};
const columns = useMemo(
() => [
{
title: '名称',
dataIndex: 'name',
key: 'name',
width: 150,
render: text => (
<span style={{ fontWeight: 500, color: designTokens.colors.neutral[800] }}>{text}</span>
),
},
{
title: '分类',
dataIndex: 'category',
key: 'category',
width: 120,
render: text => (
<Tag color="blue" style={{ borderRadius: '4px' }}>
{text}
</Tag>
),
},
{
title: '单位',
dataIndex: 'unit',
key: 'unit',
width: 80,
render: text => <Text type="secondary">{text}</Text>,
},
{
title: 'SN 数量',
key: 'snCount',
width: 120,
render: (_, record) => {
const snList = Array.isArray(record.snList) ? record.snList : [];
const snCount = snList.length;
const stock = record.currentStock || 0;
if (snCount === 0) {
return (
<Tag color="default" style={{ borderRadius: '4px' }}>
0/{stock}
</Tag>
);
}
const snContent = (
<div style={{ maxHeight: '200px', overflowY: 'auto', maxWidth: '300px' }}>
{snList.map((sn, index) => (
<Tag key={index} style={{ marginBottom: '4px', marginRight: '4px' }}>
{sn}
</Tag>
))}
</div>
);
return (
<Popover
content={snContent}
title={`SN 列表 (${snCount}个)`}
trigger="click"
placement="right"
>
<Tag color="purple" style={{ borderRadius: '4px', cursor: 'pointer' }}>
{snCount}/{stock} 🔍
</Tag>
</Popover>
);
},
},
{
title: '当前库存',
dataIndex: 'currentStock',
key: 'currentStock',
width: 100,
render: (value, record) => {
const isLow = value <= record.minStock;
return (
<Badge
status={isLow ? 'error' : 'success'}
text={
<span
style={{
color: isLow
? designTokens.colors.error.main
: designTokens.colors.success.main,
fontWeight: 600,
}}
>
{value}
</span>
}
/>
);
},
},
{
title: '最小库存',
dataIndex: 'minStock',
key: 'minStock',
width: 100,
render: text => <Text type="secondary">{text}</Text>,
},
{
title: '最大库存',
dataIndex: 'maxStock',
key: 'maxStock',
width: 100,
render: value => (
<Text type="secondary">
{value === 0 || value === null || value === undefined ? '无限制' : value}
</Text>
),
},
{
title: '单价',
dataIndex: 'unitPrice',
key: 'unitPrice',
width: 100,
render: value => (
<Tag color="orange" style={{ borderRadius: '4px' }}>
¥{parseFloat(value || 0).toFixed(2)}
</Tag>
),
},
{
title: '供应商',
dataIndex: 'supplier',
key: 'supplier',
width: 150,
render: value => value || <Text type="secondary">-</Text>,
},
{
title: '位置',
dataIndex: 'location',
key: 'location',
width: 120,
render: value => value || <Text type="secondary">-</Text>,
},
{
title: '描述',
dataIndex: 'description',
key: 'description',
width: 200,
ellipsis: true,
render: value => value || <Text type="secondary">-</Text>,
},
{
title: '状态',
dataIndex: 'status',
key: 'status',
width: 100,
render: value => (
<Tag
color={value === 'active' ? 'success' : 'error'}
style={{ borderRadius: '4px' }}
icon={value === 'active' ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
>
{value === 'active' ? '启用' : '停用'}
</Tag>
),
},
{
title: '操作',
key: 'action',
width: 240,
fixed: 'right',
render: (_, record) => (
<Space size="small">
<Tooltip title="查看记录">
<Button
type="text"
icon={<HistoryOutlined />}
onClick={() => {
setSelectedConsumable(record);
setTimelineModalVisible(true);
}}
style={{ color: designTokens.colors.info.main }}
/>
</Tooltip>
<Tooltip title="编辑">
<Button
type="text"
icon={<EditOutlined />}
onClick={() => showModal(record)}
style={{ color: designTokens.colors.primary.main }}
/>
</Tooltip>
<Tooltip title="入库">
<Button
type="text"
icon={<ArrowDownOutlined />}
onClick={() => showStockModal(record, 'in')}
style={{ color: designTokens.colors.success.main }}
/>
</Tooltip>
<Tooltip title="出库">
<Button
type="text"
icon={<ArrowUpOutlined />}
onClick={() => showStockModal(record, 'out')}
style={{ color: designTokens.colors.error.main }}
/>
</Tooltip>
<Tooltip title="删除">
<Popconfirm
title="确定删除该耗材?"
onConfirm={() => handleDelete(record.consumableId)}
okText="确定"
cancelText="取消"
>
<Button type="text" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Tooltip>
</Space>
),
},
],
[showModal, showStockModal, handleDelete]
);
const previewColumns = [
{ title: '行号', key: 'row', width: 60, render: (_, __, index) => index + 1 },
{ title: '耗材ID', dataIndex: '耗材ID', key: 'consumableId', width: 120 },
{ title: '名称', dataIndex: '名称', key: 'name', width: 150 },
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
{ title: '单位', dataIndex: '单位', key: 'unit', width: 70 },
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
{ title: '供应商', dataIndex: '供应商', key: 'supplier', width: 120 },
{ title: 'SN序列号', dataIndex: 'SN序列号', key: 'snList', width: 150, ellipsis: true },
];
return (
<motion.div
variants={animations.container}
initial="hidden"
animate="visible"
style={{
padding: '24px',
background: designTokens.colors.neutral[50],
minHeight: '100vh',
}}
>
{/* 页面标题 */}
<motion.div variants={animations.item} style={{ marginBottom: '24px' }}>
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', marginBottom: '8px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: designTokens.borderRadius.md,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '20px',
}}
>
<ShoppingOutlined />
</div>
<div>
<Title level={4} style={{ margin: 0, color: designTokens.colors.neutral[800] }}>
耗材管理
</Title>
<Text type="secondary" style={{ fontSize: '13px' }}>
管理机房耗材的库存入库和出库
</Text>
</div>
</div>
</motion.div>
{/* 主内容区 */}
<motion.div variants={animations.item}>
<Card
style={{
borderRadius: designTokens.borderRadius.lg,
boxShadow: designTokens.shadows.md,
border: 'none',
}}
bodyStyle={{ padding: '24px' }}
>
{/* 过滤器区域 */}
<div style={{ marginBottom: '24px' }}>
<Card
style={{
background: designTokens.colors.neutral[50],
borderRadius: designTokens.borderRadius.md,
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: '16px' }}
>
<Row gutter={[16, 16]} align="middle">
<Col xs={24} sm={12} md={8} lg={8}>
<div style={filterInputStyles.container}>搜索</div>
<Input
placeholder={inputPlaceholders.searchConsumable}
value={keyword}
onChange={e => handleSearch(e.target.value)}
onPressEnter={() => fetchConsumables()}
prefix={<SearchOutlined style={{ color: designTokens.colors.neutral[400] }} />}
allowClear
style={filterInputStyles.input}
/>
</Col>
<Col xs={24} sm={12} md={6} lg={5}>
<div style={filterInputStyles.container}>分类</div>
<Select
placeholder={inputPlaceholders.category}
style={filterInputStyles.select}
value={category}
onChange={setCategory}
allowClear
>
<Option value="all">所有分类</Option>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>
{cat.name}
</Option>
))}
</Select>
</Col>
<Col xs={24} sm={12} md={6} lg={5}>
<div style={filterInputStyles.container}>状态</div>
<Select
placeholder="选择状态"
style={filterInputStyles.select}
value={status}
onChange={setStatus}
>
<Option value="all">所有状态</Option>
<Option value="active">启用</Option>
<Option value="inactive">停用</Option>
</Select>
</Col>
<Col xs={24} sm={12} md={4} lg={6}>
<div style={{ marginBottom: '6px', fontSize: '13px', color: 'transparent' }}>
操作
</div>
<Space>
<Button
type="primary"
icon={<FilterOutlined />}
onClick={() => fetchConsumables()}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
height: '40px',
}}
>
筛选
</Button>
<Tooltip title="重置筛选条件">
<Button
icon={<ClearOutlined />}
onClick={handleReset}
style={{ borderRadius: designTokens.borderRadius.sm, height: '40px' }}
/>
</Tooltip>
</Space>
</Col>
</Row>
</Card>
</div>
{/* 操作按钮区域 */}
<div
style={{
marginBottom: '20px',
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
}}
>
<Space size="middle">
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => showModal()}
size="large"
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
boxShadow: designTokens.shadows.md,
}}
>
添加耗材
</Button>
<Button
icon={<ArrowDownOutlined />}
onClick={() => showScanModal('in')}
size="large"
style={{
borderRadius: designTokens.borderRadius.sm,
color: designTokens.colors.success.main,
borderColor: designTokens.colors.success.main,
}}
>
扫码入库
</Button>
<Button
icon={<ArrowUpOutlined />}
onClick={() => showScanModal('out')}
size="large"
style={{
borderRadius: designTokens.borderRadius.sm,
color: designTokens.colors.error.main,
borderColor: designTokens.colors.error.main,
}}
>
扫码出库
</Button>
<Button
icon={<ImportOutlined />}
onClick={showImportModal}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
批量导入
</Button>
<Dropdown
overlay={
<Menu
onClick={({ key }) => {
if (key === 'fields') {
setExportModalVisible(true);
} else {
setExportMode(key);
// 直接导出
setTimeout(() => {
const params = { fields: exportFields.join(',') };
if (key === 'filtered') {
params.keyword = keyword;
params.category = category;
params.status = status;
} else if (key === 'selected') {
params.ids = selectedRowKeys.join(',');
} else if (key === 'warning') {
params.stockStatus = 'warning';
params.keyword = keyword;
params.category = category;
}
axios.get('/api/consumables/export', { params })
.then(res => {
exportToCSV(res.data.consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
message.success('导出成功');
})
.catch(() => message.error('导出失败'));
}, 0);
}
}}
>
<Menu.Item key="all">
<FileExcelOutlined /> 导出全部
</Menu.Item>
<Menu.Item key="selected" disabled={selectedRowKeys.length === 0}>
<CheckSquareOutlined /> 导出选中项 ({selectedRowKeys.length})
</Menu.Item>
<Menu.Divider />
<Menu.Item key="fields">
<SettingOutlined /> 字段选择...
</Menu.Item>
</Menu>
}
trigger={['click']}
>
<Button
icon={<ExportOutlined />}
size="large"
style={{ borderRadius: designTokens.borderRadius.sm }}
>
导出 <DownOutlined />
</Button>
</Dropdown>
</Space>
<Space>
<Tooltip title="刷新数据">
<Button
icon={<ReloadOutlined />}
onClick={() => fetchConsumables()}
loading={loading}
style={{ borderRadius: designTokens.borderRadius.sm }}
/>
</Tooltip>
</Space>
</div>
{/* 数据表格 */}
{loading ? (
<div style={{ padding: '40px' }}>
<Skeleton active paragraph={{ rows: 6 }} />
</div>
) : consumables.length === 0 ? (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
>
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description={
<div style={{ textAlign: 'center' }}>
<div
style={{
fontSize: '16px',
color: designTokens.colors.neutral[600],
marginBottom: '8px',
}}
>
暂无耗材数据
</div>
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[400] }}>
点击"添加耗材"按钮创建第一个耗材
</div>
</div>
}
>
<Button
type="primary"
icon={<PlusOutlined />}
onClick={() => showModal()}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
添加耗材
</Button>
</Empty>
</motion.div>
) : (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.3 }}
>
<Table
columns={columns}
dataSource={consumables}
rowKey="consumableId"
loading={loading}
rowSelection={{
selectedRowKeys,
onChange: setSelectedRowKeys,
}}
pagination={{
...pagination,
showSizeChanger: true,
pageSizeOptions: ['10', '20', '50', '100'],
}}
onChange={pagination => fetchConsumables(pagination.current, pagination.pageSize)}
scroll={{ x: 1400 }}
style={{
borderRadius: designTokens.borderRadius.md,
overflow: 'hidden',
}}
/>
</motion.div>
)}
</Card>
</motion.div>
{/* 添加/编辑耗材弹窗 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.md,
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
<ShoppingOutlined />
</div>
<span style={{ fontSize: '18px', fontWeight: 600 }}>
{editingConsumable ? '编辑耗材' : '添加耗材'}
</span>
</div>
}
open={modalVisible}
closeIcon={<CloseButton />}
onCancel={handleCancel}
footer={null}
width={900}
style={{ top: 20 }}
>
<div
style={{
maxHeight: '65vh',
overflowY: 'auto',
padding: '24px',
paddingRight: '16px',
}}
className="custom-scroll-container"
>
<Form form={form} layout="vertical" onFinish={handleSubmit}>
{/* 基本信息 */}
<div
style={{
background: 'linear-gradient(135deg, #f0f9ff 0%, #e0f2fe 100%)',
borderRadius: designTokens.borderRadius.lg,
padding: '20px',
marginBottom: '20px',
border: '1px solid #bae6fd',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.neutral[700],
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px',
}}
>
1
</div>
基本信息
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="name"
label="名称"
rules={[inputValidationRules.required('请输入名称')]}
>
<Input
placeholder={inputPlaceholders.consumableName}
style={inputStyles.form}
prefix={
<ShoppingOutlined style={{ color: designTokens.colors.neutral[400] }} />
}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="category"
label="分类"
rules={[inputValidationRules.required('请选择分类')]}
>
<Select
placeholder={inputPlaceholders.category}
allowClear
style={selectStyles.base}
>
{categories.map(cat => (
<Option key={cat.id} value={cat.name}>
{cat.name}
</Option>
))}
</Select>
</Form.Item>
</Col>
</Row>
<Row gutter={16}>
<Col span={12}>
<Form.Item
name="unit"
label="单位"
rules={[inputValidationRules.required('请输入单位')]}
initialValue="个"
>
<Input placeholder={inputPlaceholders.unit} style={inputStyles.form} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="description" label="描述">
<Input placeholder={inputPlaceholders.description} style={inputStyles.form} />
</Form.Item>
</Col>
</Row>
</div>
{/* 库存预警设置 */}
{!editingConsumable && (
<div
style={{
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
borderRadius: designTokens.borderRadius.lg,
padding: '20px',
marginBottom: '16px',
border: '1px solid #86efac',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.neutral[700],
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: designTokens.colors.success.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px',
}}
>
2
</div>
库存预警
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="最小库存(预警线)"
name="minStock"
rules={[inputValidationRules.required('请输入最小库存')]}
>
<InputNumber
min={0}
style={{ ...inputNumberStyles.base, width: '100%' }}
placeholder={inputPlaceholders.minStock}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="最大库存">
<div style={{ marginBottom: '8px' }}>
<Checkbox
checked={maxStockUnlimited}
onChange={e => {
setMaxStockUnlimited(e.target.checked);
if (e.target.checked) {
form.setFieldsValue({ maxStock: undefined });
}
}}
>
无限制
</Checkbox>
</div>
{!maxStockUnlimited && (
<Form.Item
name="maxStock"
noStyle
rules={[inputValidationRules.required('请输入最大库存')]}
>
<InputNumber
min={0}
style={{ ...inputNumberStyles.base, width: '100%' }}
placeholder={inputPlaceholders.maxStock}
/>
</Form.Item>
)}
</Form.Item>
</Col>
</Row>
</div>
)}
{/* 初始入库(添加模式专用) */}
{!editingConsumable && (
<div
style={{
background: createWithInbound
? 'linear-gradient(135deg, #eff6ff 0%, #dbeafe 100%)'
: '#fafafa',
borderRadius: designTokens.borderRadius.lg,
padding: '16px 20px',
marginBottom: '20px',
border: `1px solid ${createWithInbound ? '#93c5fd' : '#e5e7eb'}`,
transition: 'all 0.3s ease',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<Checkbox
checked={createWithInbound}
onChange={e => setCreateWithInbound(e.target.checked)}
style={{ marginTop: '4px' }}
/>
<div style={{ flex: 1 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: createWithInbound ? '12px' : '0',
}}
>
<Text strong style={{ fontSize: '15px', color: designTokens.colors.neutral[700] }}>
同时进行初始入库
</Text>
<Tag color={createWithInbound ? 'blue' : 'default'} style={{ borderRadius: '8px' }}>
推荐
</Tag>
</div>
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: createWithInbound ? '16px' : '0' }}>
创建耗材后立即录入初始库存数量生成入库记录
</Text>
{createWithInbound && (
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
>
<div
style={{
padding: '16px',
background: '#fff',
borderRadius: designTokens.borderRadius.md,
border: '1px solid #e5e7eb',
}}
>
<Row gutter={16}>
<Col span={8}>
<Form.Item
label="入库数量"
rules={[inputValidationRules.required('请输入入库数量')]}
>
<InputNumber
min={1}
value={inboundData.quantity}
onChange={value =>
setInboundData({ ...inboundData, quantity: value || 0 })
}
style={{ ...inputNumberStyles.base, width: '100%' }}
placeholder="输入数量"
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="操作人">
<Input
value={inboundData.operator}
onChange={e =>
setInboundData({ ...inboundData, operator: e.target.value })
}
placeholder="输入操作人"
style={inputStyles.form}
/>
</Form.Item>
</Col>
<Col span={8}>
<Form.Item label="原因">
<Input
value={inboundData.reason}
onChange={e =>
setInboundData({ ...inboundData, reason: e.target.value })
}
placeholder="输入入库原因"
style={inputStyles.form}
/>
</Form.Item>
</Col>
</Row>
{/* SN序列号管理 */}
<div
style={{
marginTop: '16px',
paddingTop: '16px',
borderTop: '1px dashed #e5e7eb',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
marginBottom: '12px',
}}
>
<Text strong style={{ fontSize: '14px', color: designTokens.colors.neutral[700] }}>
SN序列号可选
</Text>
<Space>
<Button
type="primary"
size="small"
icon={<ScanOutlined />}
onClick={() => {
setScanMode('add');
setScanValue('');
setScanModalVisible(true);
setTimeout(() => scanInputRef.current?.focus(), 100);
}}
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
扫码添加
</Button>
<Button
size="small"
icon={<PlusOutlined />}
onClick={() => {
const newSn = window.prompt('请输入SN序列号:');
if (newSn && newSn.trim() && !inboundSnList.includes(newSn.trim())) {
const newList = [...inboundSnList, newSn.trim()];
setInboundSnList(newList);
setInboundData(prev => ({ ...prev, quantity: newList.length }));
}
}}
style={{ borderRadius: designTokens.borderRadius.sm }}
>
手动添加
</Button>
</Space>
</div>
{inboundSnList.length > 0 ? (
<div
style={{
maxHeight: '120px',
overflowY: 'auto',
border: `1px solid ${designTokens.colors.neutral[200]}`,
borderRadius: designTokens.borderRadius.sm,
padding: '8px',
}}
>
{inboundSnList.map((sn, index) => (
<Tag
key={index}
closable
onClose={() => {
const newList = inboundSnList.filter((_, i) => i !== index);
setInboundSnList(newList);
setInboundData(prev => ({ ...prev, quantity: newList.length }));
}}
color="blue"
style={{ marginBottom: '4px', marginRight: '4px' }}
>
{sn}
</Tag>
))}
</div>
) : (
<Text type="secondary" style={{ fontSize: '12px' }}>
暂未添加SN序列号
</Text>
)}
</div>
</div>
</motion.div>
)}
</div>
</div>
</div>
)}
{/* 编辑模式下的库存预警设置 */}
{editingConsumable && (
<div
style={{
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
borderRadius: designTokens.borderRadius.lg,
padding: '20px',
marginBottom: '16px',
border: '1px solid #86efac',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.neutral[700],
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: designTokens.colors.success.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px',
}}
>
2
</div>
库存预警
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item
label="最小库存(预警线)"
name="minStock"
rules={[inputValidationRules.required('请输入最小库存')]}
>
<InputNumber
min={0}
style={{ ...inputNumberStyles.base, width: '100%' }}
placeholder={inputPlaceholders.minStock}
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item label="最大库存">
<div style={{ marginBottom: '8px' }}>
<Checkbox
checked={maxStockUnlimited}
onChange={e => {
setMaxStockUnlimited(e.target.checked);
if (e.target.checked) {
form.setFieldsValue({ maxStock: undefined });
}
}}
>
无限制
</Checkbox>
</div>
{!maxStockUnlimited && (
<Form.Item
name="maxStock"
noStyle
rules={[inputValidationRules.required('请输入最大库存')]}
>
<InputNumber
min={0}
style={{ ...inputNumberStyles.base, width: '100%' }}
placeholder={inputPlaceholders.maxStock}
/>
</Form.Item>
)}
</Form.Item>
</Col>
</Row>
</div>
)}
{/* 编辑模式下的当前库存信息展示 */}
{editingConsumable && (
<div
style={{
background: '#fafafa',
borderRadius: designTokens.borderRadius.lg,
padding: '16px 20px',
marginBottom: '20px',
border: '1px solid #e5e7eb',
}}
>
<div style={{ display: 'flex', alignItems: 'flex-start', gap: '12px' }}>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #64748b 0%, #475569 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px',
flexShrink: 0,
}}
>
<EyeOutlined style={{ fontSize: '12px' }} />
</div>
<div style={{ flex: 1 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '12px',
}}
>
<Text strong style={{ fontSize: '15px', color: designTokens.colors.neutral[700] }}>
当前库存信息
</Text>
<Tag color="default" style={{ borderRadius: '8px' }}>
只读
</Tag>
</div>
<Text type="secondary" style={{ fontSize: '13px', display: 'block', marginBottom: '12px' }}>
如需调整库存请使用调整库存功能
</Text>
<div
style={{
display: 'grid',
gridTemplateColumns: 'repeat(3, 1fr)',
gap: '16px',
padding: '16px',
background: '#fff',
borderRadius: designTokens.borderRadius.md,
border: '1px solid #e5e7eb',
}}
>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: designTokens.colors.primary.main }}>
{editingConsumable.currentStock || 0}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
当前库存 ({editingConsumable.unit})
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{ fontSize: '24px', fontWeight: 'bold', color: designTokens.colors.success.main }}>
{editingConsumable.snList?.length || 0}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
已录入SN
</div>
</div>
<div style={{ textAlign: 'center' }}>
<div style={{
fontSize: '24px',
fontWeight: 'bold',
color: (editingConsumable.currentStock || 0) <= (editingConsumable.minStock || 0)
? designTokens.colors.error.main
: designTokens.colors.neutral[700]
}}>
{editingConsumable.currentStock <= editingConsumable.minStock ? '⚠️ 低' : '✓ 正常'}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
库存状态
</div>
</div>
</div>
</div>
</div>
</div>
)}
{/* 价格与状态 */}
<div
style={{
background: 'linear-gradient(135deg, #fffbeb 0%, #fef3c7 100%)',
borderRadius: designTokens.borderRadius.lg,
padding: '20px',
marginBottom: '20px',
border: '1px solid #fcd34d',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.neutral[700],
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: designTokens.colors.warning.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px',
}}
>
3
</div>
价格与状态
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="unitPrice" label="单价(元)">
<InputNumber
min={0}
step={0.01}
precision={2}
style={{ ...inputNumberStyles.base, width: '100%' }}
placeholder={inputPlaceholders.unitPrice}
prefix="¥"
/>
</Form.Item>
</Col>
<Col span={12}>
<Form.Item
name="status"
label="状态"
rules={[inputValidationRules.required('请选择状态')]}
>
<Select placeholder="请选择状态" style={selectStyles.base}>
<Option value="active">
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<CheckCircleOutlined
style={{ color: designTokens.colors.success.main }}
/>
启用
</span>
</Option>
<Option value="inactive">
<span style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
<ExclamationCircleOutlined
style={{ color: designTokens.colors.error.main }}
/>
停用
</span>
</Option>
</Select>
</Form.Item>
</Col>
</Row>
</div>
{/* 其他信息 */}
<div
style={{
background: 'linear-gradient(135deg, #faf5ff 0%, #f3e8ff 100%)',
borderRadius: designTokens.borderRadius.lg,
padding: '20px',
marginBottom: '20px',
border: '1px solid #d8b4fe',
}}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '16px',
fontSize: '15px',
fontWeight: '600',
color: designTokens.colors.neutral[700],
}}
>
<div
style={{
width: '24px',
height: '24px',
borderRadius: '50%',
background: 'linear-gradient(135deg, #a855f7 0%, #7c3aed 100%)',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
fontSize: '12px',
}}
>
4
</div>
其他信息
</div>
<Row gutter={16}>
<Col span={12}>
<Form.Item name="supplier" label="供应商">
<Input placeholder={inputPlaceholders.supplier} style={inputStyles.form} />
</Form.Item>
</Col>
<Col span={12}>
<Form.Item name="location" label="存放位置">
<Input placeholder={inputPlaceholders.location} style={inputStyles.form} />
</Form.Item>
</Col>
</Row>
</div>
{/* 操作按钮 */}
<div
style={{
display: 'flex',
justifyContent: 'flex-end',
gap: '12px',
paddingTop: '16px',
borderTop: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
<Button
size="large"
onClick={handleCancel}
style={{
borderRadius: designTokens.borderRadius.sm,
minWidth: '100px',
}}
>
取消
</Button>
<Button
type="primary"
size="large"
htmlType="submit"
style={{
background: designTokens.colors.primary.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
minWidth: '120px',
boxShadow: designTokens.shadows.md,
}}
>
{editingConsumable ? '更新耗材' : '创建耗材'}
</Button>
</div>
</Form>
</div>
</Modal>
{/* 导入耗材弹窗 - 全新UI/UX设计 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px', padding: '4px 0' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: '12px',
background: `linear-gradient(135deg, ${designTokens.colors.info.main} 0%, ${designTokens.colors.primary.main} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: `0 4px 12px ${designTokens.colors.info.main}40`,
}}
>
<ImportOutlined style={{ fontSize: '20px' }} />
</div>
<div>
<span
style={{
fontSize: '18px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
}}
>
批量导入耗材
</span>
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[500],
marginTop: '2px',
}}
>
支持 Excel/CSV 格式批量导入
</div>
</div>
</div>
}
open={importModalVisible}
closeIcon={<CloseButton />}
onCancel={handleImportCancel}
footer={null}
width={920}
bodyStyle={{ padding: '24px' }}
style={{ top: 40 }}
>
{/* 步骤指示器 */}
<div
style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
marginBottom: '32px',
padding: '0 40px',
}}
>
{[
{ key: 'upload', label: '上传文件', icon: UploadOutlined },
{ key: 'preview', label: '预览确认', icon: FileTextOutlined },
{ key: 'result', label: '完成', icon: CheckCircleOutlined },
].map((step, index) => {
const isActive = importStep === step.key;
const isPast = ['upload', 'preview', 'result'].indexOf(importStep) > index;
const StepIcon = step.icon;
return (
<React.Fragment key={step.key}>
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center' }}>
<div
style={{
width: '44px',
height: '44px',
borderRadius: '50%',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background:
isActive || isPast
? `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.success.main} 100%)`
: designTokens.colors.neutral[100],
color: isActive || isPast ? '#fff' : designTokens.colors.neutral[400],
boxShadow: isActive
? `0 4px 16px ${designTokens.colors.primary.main}50`
: 'none',
transition: 'all 0.3s ease',
transform: isActive ? 'scale(1.1)' : 'scale(1)',
}}
>
<StepIcon style={{ fontSize: '18px' }} />
</div>
<span
style={{
marginTop: '8px',
fontSize: '13px',
fontWeight: isActive ? 600 : 400,
color: isActive
? designTokens.colors.primary.main
: designTokens.colors.neutral[500],
}}
>
{step.label}
</span>
</div>
{index < 2 && (
<div
style={{
width: '80px',
height: '2px',
background: isPast
? designTokens.colors.success.main
: designTokens.colors.neutral[200],
margin: '0 12px',
marginBottom: '28px',
borderRadius: '1px',
transition: 'background 0.3s ease',
}}
/>
)}
</React.Fragment>
);
})}
</div>
<div style={{ minHeight: '400px' }}>
{/* 步骤1: 上传文件 */}
{importStep === 'upload' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{/* 拖拽上传区域 */}
<Upload.Dragger
accept=".xlsx,.xls,.csv"
maxCount={1}
beforeUpload={() => false}
onChange={handleFileChange}
showUploadList={false}
style={{
borderRadius: '12px',
overflow: 'hidden',
marginBottom: '20px',
}}
>
<div style={{ padding: '24px' }}>
<div
style={{
width: '48px',
height: '48px',
borderRadius: '50%',
background: `linear-gradient(135deg, ${designTokens.colors.primary.main}10 0%, ${designTokens.colors.info.main}20 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 12px',
}}
>
<UploadOutlined
style={{ fontSize: '22px', color: designTokens.colors.primary.main }}
/>
</div>
<Text
style={{
fontSize: '14px',
fontWeight: 500,
display: 'block',
marginBottom: '4px',
color: designTokens.colors.neutral[800],
}}
>
点击或拖拽文件到此处上传
</Text>
<Text type="secondary" style={{ fontSize: '12px' }}>
支持 .xlsx/.xls/.csv 格式
</Text>
</div>
</Upload.Dragger>
{/* 模板下载与配置区域 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<FileExcelOutlined style={{ fontSize: '18px', color: designTokens.colors.success.main }} />
<span style={{ fontSize: '15px', fontWeight: 600 }}>模板下载与配置</span>
</div>
}
style={{
borderRadius: '16px',
marginBottom: '20px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: '24px' }}
>
{/* 库存处理方式选择 */}
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'flex-start',
flexWrap: 'wrap',
gap: '16px',
marginBottom: '20px',
}}
>
<div>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
marginBottom: '4px',
}}
>
<span style={{ fontSize: '14px', fontWeight: 600, color: designTokens.colors.neutral[700] }}>
库存处理方式
</span>
<Tag color="blue" style={{ borderRadius: '6px', fontSize: '11px' }}>
{importStockMode === 'basic' ? '仅基础信息' : '导入并入库'}
</Tag>
</div>
<Text type="secondary" style={{ fontSize: '13px' }}>
{importStockMode === 'basic'
? '模板仅包含基础信息字段,不含库存和SN'
: '模板包含基础信息及入库相关字段(入库数量、SN等)'}
</Text>
</div>
<Button
type="primary"
icon={<DownloadOutlined />}
onClick={() => downloadTemplate(importStockMode)}
size="middle"
style={{
background: `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, ${designTokens.colors.primary.main} 100%)`,
border: 'none',
borderRadius: '10px',
height: '40px',
}}
>
下载模板
</Button>
</div>
<Divider style={{ margin: '0 0 20px 0' }} />
{/* 库存处理单选选项 */}
<Radio.Group
value={importStockMode}
onChange={e => setImportStockMode(e.target.value)}
>
<Space style={{ justifyContent: 'center' }} size={16}>
<Radio value="basic">
<Card
size="small"
style={{
width: '280px',
borderRadius: '10px',
border:
importStockMode === 'basic'
? `2px solid ${designTokens.colors.success.main}`
: `1px solid ${designTokens.colors.neutral[200]}`,
background:
importStockMode === 'basic'
? `${designTokens.colors.success.main}08`
: '#fff',
transition: 'all 0.2s ease',
}}
bodyStyle={{ padding: '14px 20px' }}
>
<div style={{ fontWeight: 600, fontSize: '14px', color: importStockMode === 'basic' ? designTokens.colors.success.main : designTokens.colors.neutral[700], marginBottom: '6px' }}>
仅导入基础信息
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
库存初始化为 0
</div>
</Card>
</Radio>
<Radio value="inbound">
<Card
size="small"
style={{
width: '280px',
borderRadius: '10px',
border:
importStockMode === 'inbound'
? `2px solid ${designTokens.colors.primary.main}`
: `1px solid ${designTokens.colors.neutral[200]}`,
background:
importStockMode === 'inbound'
? `${designTokens.colors.primary.main}08`
: '#fff',
transition: 'all 0.2s ease',
}}
bodyStyle={{ padding: '14px 20px' }}
>
<div style={{ fontWeight: 600, fontSize: '14px', color: importStockMode === 'inbound' ? designTokens.colors.primary.main : designTokens.colors.neutral[700], marginBottom: '6px' }}>
导入并入库
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
执行入库操作
</div>
</Card>
</Radio>
</Space>
</Radio.Group>
</Card>
{/* 字段说明 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
<InfoCircleOutlined style={{ fontSize: '16px', color: designTokens.colors.info.main }} />
<span style={{ fontSize: '14px', fontWeight: 600 }}>模板字段说明</span>
<Tag color="error" style={{ borderRadius: '4px', fontSize: '10px' }}>必填</Tag>
<Tag color="default" style={{ borderRadius: '4px', fontSize: '10px' }}>可选</Tag>
</div>
}
style={{
borderRadius: '16px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: '20px' }}
>
<div
style={{ display: 'grid', gridTemplateColumns: 'repeat(3, 1fr)', gap: '12px' }}
>
{[
{
field: '耗材 ID',
desc: '留空自动生成;填写后可识别并更新现有耗材',
required: false,
icon: '🔑',
},
{ field: '名称', desc: '耗材名称,必填项', required: true, icon: '📝' },
{
field: '分类',
desc: '耗材分类,如"光模块"或"光纤跳线",必填',
required: true,
icon: '📂',
},
{
field: '单位',
desc: '计量单位,如"个"、"根"、"箱",默认"个"',
required: false,
icon: '📏',
},
...(importStockMode === 'inbound'
? [
{
field: '入库数量',
desc: '入库的耗材数量,数字类型',
required: true,
icon: '📦',
},
{
field: '操作人',
desc: '执行入库操作的人员,默认"系统"',
required: false,
icon: '👤',
},
{
field: '原因',
desc: '入库原因,默认"批量导入入库"',
required: false,
icon: '📝',
},
{
field: 'SN 序列号',
desc: '多个 SN 用逗号分隔,如"SN001,SN002"',
required: false,
icon: '🏷️',
},
]
: [
{
field: '最小库存',
desc: '安全库存阈值,低于此值会触发预警',
required: false,
icon: '⚠️',
},
{
field: '最大库存',
desc: '最大库存限制,0 表示无限制',
required: false,
icon: '📈',
},
]),
{ field: '单价', desc: '耗材单价,数字类型', required: false, icon: '💰' },
{ field: '供应商', desc: '耗材供应商名称', required: false, icon: '🏭' },
{
field: '存放位置',
desc: '仓库内存放位置,如"A 柜 -01 层"',
required: false,
icon: '📍',
},
{ field: '描述', desc: '耗材的详细描述或备注', required: false, icon: '📄' },
{
field: '状态',
desc: '"active"启用,"inactive"停用,默认启用',
required: false,
icon: '✅',
},
].map((item, idx) => (
<div
key={idx}
style={{
display: 'flex',
gap: '10px',
padding: '10px 12px',
background: designTokens.colors.neutral[50],
borderRadius: '8px',
border: `1px solid ${designTokens.colors.neutral[100]}`,
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '6px',
background: item.required
? `${designTokens.colors.error.main}15`
: `${designTokens.colors.primary.main}10`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<span style={{ fontSize: '14px' }}>{item.icon}</span>
</div>
<div style={{ flex: 1, minWidth: 0 }}>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '6px',
marginBottom: '4px',
}}
>
<span
style={{
fontSize: '13px',
fontWeight: 600,
color: designTokens.colors.neutral[800],
}}
>
{item.field}
</span>
{item.required && (
<span
style={{
fontSize: '10px',
padding: '1px 6px',
borderRadius: '3px',
background: `${designTokens.colors.error.main}15`,
color: designTokens.colors.error.main,
fontWeight: 500,
}}
>
必填
</span>
)}
</div>
<Text
type="secondary"
style={{ fontSize: '11px', lineHeight: 1.4, display: 'block' }}
>
{item.desc}
</Text>
</div>
</div>
))}
</div>
</Card>
</motion.div>
)}
{/* 步骤2: 预览确认 */}
{importStep === 'preview' && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={{ duration: 0.3 }}
>
{/* 数据统计卡片 */}
<div
style={{
display: 'flex',
gap: '16px',
marginBottom: '20px',
}}
>
<div
style={{
flex: 1,
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.info.main} 100%)`,
borderRadius: '12px',
padding: '16px 20px',
color: '#fff',
}}
>
<div style={{ fontSize: '28px', fontWeight: 700 }}>{importPreview.length}</div>
<div style={{ fontSize: '13px', opacity: 0.9 }}>待导入记录</div>
</div>
<div
style={{
flex: 1,
background:
importValidationErrors.length > 0
? `linear-gradient(135deg, ${designTokens.colors.warning.main} 0%, ${designTokens.colors.error.main} 100%)`
: `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, #52c41a 100%)`,
borderRadius: '12px',
padding: '16px 20px',
color: '#fff',
}}
>
<div style={{ fontSize: '28px', fontWeight: 700 }}>
{importValidationErrors.length}
</div>
<div style={{ fontSize: '13px', opacity: 0.9 }}>数据问题</div>
</div>
</div>
{/* 错误提示 */}
{importValidationErrors.length > 0 && (
<Alert
message="数据校验发现问题"
description={
<div style={{ maxHeight: '80px', overflowY: 'auto', marginTop: '8px' }}>
{importValidationErrors.slice(0, 5).map((err, idx) => (
<div key={idx} style={{ fontSize: '13px', marginBottom: '4px' }}>
<Tag color="warning" style={{ marginRight: '8px' }}>
{err.row}
</Tag>
<Text type="secondary">
{err.field && `[${err.field}]`} {err.message}
</Text>
</div>
))}
{importValidationErrors.length > 5 && (
<Text type="secondary" style={{ fontSize: '12px' }}>
...还有 {importValidationErrors.length - 5} 个问题
</Text>
)}
</div>
}
type="warning"
showIcon
icon={<WarningOutlined />}
style={{
marginBottom: '20px',
borderRadius: '12px',
border: 'none',
background: `${designTokens.colors.warning.main}15`,
}}
/>
)}
{/* 导入模式选择 */}
<Card
style={{
borderRadius: '12px',
marginBottom: '20px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: '16px 20px' }}
>
<div
style={{
display: 'flex',
alignItems: 'center',
gap: '12px',
marginBottom: '16px',
}}
>
<div
style={{
width: '32px',
height: '32px',
borderRadius: '8px',
background: `${designTokens.colors.primary.main}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
}}
>
<FileTextOutlined style={{ color: designTokens.colors.primary.main }} />
</div>
<span style={{ fontSize: '14px', fontWeight: 600 }}>导入模式</span>
</div>
<Radio.Group
value={importMode}
onChange={e => setImportMode(e.target.value)}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }} size={12}>
<Radio value="create" style={{ width: '100%' }}>
<Card
size="small"
style={{
marginLeft: '8px',
width: 'calc(100% - 8px)',
borderRadius: '10px',
border:
importMode === 'create'
? `2px solid ${designTokens.colors.primary.main}`
: `1px solid ${designTokens.colors.neutral[200]}`,
background:
importMode === 'create'
? `${designTokens.colors.primary.main}08`
: '#fff',
}}
bodyStyle={{ padding: '12px 16px' }}
>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>仅新增模式</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
跳过已存在的耗材根据耗材ID判断),仅创建新耗材
</div>
</Card>
</Radio>
<Radio value="update" style={{ width: '100%' }}>
<Card
size="small"
style={{
marginLeft: '8px',
width: 'calc(100% - 8px)',
borderRadius: '10px',
border:
importMode === 'update'
? `2px solid ${designTokens.colors.primary.main}`
: `1px solid ${designTokens.colors.neutral[200]}`,
background:
importMode === 'update'
? `${designTokens.colors.primary.main}08`
: '#fff',
}}
bodyStyle={{ padding: '12px 16px' }}
>
<div style={{ fontWeight: 600, marginBottom: '4px' }}>更新模式</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
如果耗材ID已存在则更新现有记录不存在则创建新耗材
</div>
</Card>
</Radio>
</Space>
</Radio.Group>
</Card>
{/* 数据预览表格 */}
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<span style={{ fontSize: '14px', fontWeight: 600 }}>数据预览</span>
<Tag color="blue" style={{ marginLeft: '8px', borderRadius: '6px' }}>
{importPreview.length}
</Tag>
</div>
}
extra={
<Button
size="small"
icon={<ReloadOutlined />}
onClick={() => setImportStep('upload')}
style={{ borderRadius: '8px' }}
>
重新选择
</Button>
}
style={{
borderRadius: '12px',
border: `1px solid ${designTokens.colors.neutral[200]}`,
}}
bodyStyle={{ padding: 0 }}
>
<Table
columns={previewColumns}
dataSource={importPreview}
rowKey={(record, index) => index}
pagination={{ pageSize: 5, showSizeChanger: false }}
size="small"
scroll={{ x: 900 }}
style={{ borderRadius: '12px' }}
/>
</Card>
</motion.div>
)}
{/* 导入中状态 */}
{importStep === 'importing' && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
textAlign: 'center',
padding: '48px 24px',
}}
>
<div
style={{
width: '100px',
height: '100px',
borderRadius: '50%',
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.info.main} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 24px',
boxShadow: `0 8px 24px ${designTokens.colors.primary.main}40`,
animation: 'pulse 2s infinite',
}}
>
<ImportOutlined style={{ fontSize: '48px', color: '#fff' }} />
</div>
<Title level={4} style={{ margin: 0, marginBottom: '8px', fontSize: '20px' }}>
{importJobStatus?.status === 'processing' ? '正在导入中...' : '准备导入...'}
</Title>
<Text type="secondary" style={{ fontSize: '14px', display: 'block', marginBottom: '24px' }}>
{importPhase}
</Text>
{importJobStatus && (
<div style={{ marginBottom: '24px' }}>
<Row gutter={16} style={{ maxWidth: '400px', margin: '0 auto' }}>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center', borderRadius: '12px' }} bodyStyle={{ padding: '12px' }}>
<div style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.success.main }}>
{importJobStatus.successCount || 0}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>成功</div>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center', borderRadius: '12px' }} bodyStyle={{ padding: '12px' }}>
<div style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.warning.main }}>
{importJobStatus.skippedCount || 0}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>跳过</div>
</Card>
</Col>
<Col span={8}>
<Card size="small" style={{ textAlign: 'center', borderRadius: '12px' }} bodyStyle={{ padding: '12px' }}>
<div style={{ fontSize: '24px', fontWeight: 700, color: designTokens.colors.error.main }}>
{importJobStatus.failedCount || 0}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>失败</div>
</Card>
</Col>
</Row>
<div style={{ marginTop: '16px', color: designTokens.colors.neutral[500], fontSize: '13px' }}>
已处理 {importJobStatus.processedItems || 0} / {importJobStatus.totalItems || 0}
</div>
</div>
)}
<Progress
percent={importProgress}
status={importProgress === 100 ? 'success' : 'active'}
strokeColor={{
'0%': designTokens.colors.primary.main,
'100%': designTokens.colors.success.main,
}}
style={{ width: '280px', margin: '0 auto 24px' }}
/>
<Space>
<Button
danger
onClick={handleCancelImport}
disabled={!importJobId || !importJobStatus?.canCancel}
style={{ borderRadius: '10px' }}
>
取消导入
</Button>
<Button
onClick={handleImportCancel}
style={{ borderRadius: '10px' }}
>
关闭
</Button>
</Space>
<style>{`
@keyframes pulse {
0% { transform: scale(1); }
50% { transform: scale(1.05); }
100% { transform: scale(1); }
}
`}</style>
</motion.div>
)}
{/* 步骤3: 完成 */}
{importStep === 'result' && importResult && (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.4, ease: 'easeOut' }}
>
{/* 结果状态 */}
<div
style={{
textAlign: 'center',
padding: '32px 0 28px',
}}
>
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{ delay: 0.1, type: 'spring', stiffness: 200 }}
style={{
width: '88px',
height: '88px',
borderRadius: '50%',
background:
importResult.failed === 0
? `linear-gradient(135deg, ${designTokens.colors.success.main} 0%, #52c41a 100%)`
: `linear-gradient(135deg, ${designTokens.colors.warning.main} 0%, ${designTokens.colors.error.main} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
margin: '0 auto 20px',
boxShadow:
importResult.failed === 0
? `0 8px 24px ${designTokens.colors.success.main}50`
: `0 8px 24px ${designTokens.colors.warning.main}50`,
}}
>
{importResult.failed === 0 ? (
<CheckCircleOutlined style={{ fontSize: '44px', color: '#fff' }} />
) : (
<WarningOutlined style={{ fontSize: '44px', color: '#fff' }} />
)}
</motion.div>
<Title level={4} style={{ margin: 0, marginBottom: '8px', fontSize: '20px' }}>
{importResult.failed === 0 ? '导入成功!' : '导入完成(部分失败)'}
</Title>
<Text type="secondary" style={{ fontSize: '14px' }}>
{importResult.success > 0 && `成功新增 ${importResult.success} 条,`}
{importResult.updated > 0 && `更新 ${importResult.updated} 条,`}
{importResult.skipped > 0 && `跳过 ${importResult.skipped} 条,`}
{importResult.failed > 0 && `失败 ${importResult.failed} 条`}
</Text>
</div>
{/* 统计卡片 */}
<Row gutter={[12, 12]} style={{ marginBottom: '24px' }}>
{[
{
label: '新增成功',
value: importResult.success,
color: designTokens.colors.success.main,
bg: `${designTokens.colors.success.main}15`,
},
{
label: '更新成功',
value: importResult.updated,
color: designTokens.colors.primary.main,
bg: `${designTokens.colors.primary.main}15`,
},
{
label: '跳过',
value: importResult.skipped,
color: designTokens.colors.neutral[500],
bg: designTokens.colors.neutral[100],
},
{
label: '失败',
value: importResult.failed,
color: designTokens.colors.error.main,
bg: `${designTokens.colors.error.main}15`,
},
].map((stat, idx) => (
<Col span={6} key={idx}>
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.2 + idx * 0.1 }}
>
<Card
size="small"
style={{
textAlign: 'center',
borderRadius: '12px',
border: 'none',
background: stat.bg,
}}
bodyStyle={{ padding: '16px 8px' }}
>
<div
style={{
fontSize: '28px',
fontWeight: 700,
color: stat.color,
lineHeight: 1.2,
}}
>
{stat.value}
</div>
<div
style={{
fontSize: '12px',
color: designTokens.colors.neutral[600],
marginTop: '4px',
}}
>
{stat.label}
</div>
</Card>
</motion.div>
</Col>
))}
</Row>
{/* 失败记录 */}
{importResult.failed > 0 && (
<motion.div
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.5 }}
>
<Card
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
<WarningOutlined style={{ color: designTokens.colors.error.main }} />
<span style={{ fontSize: '14px', fontWeight: 600 }}>失败记录</span>
<Tag color="error" style={{ marginLeft: '8px', borderRadius: '6px' }}>
{importResult.failed}
</Tag>
</div>
}
extra={
<Button
size="small"
icon={<DownloadOutlined />}
onClick={downloadFailedRecords}
style={{ borderRadius: '8px' }}
>
导出失败记录
</Button>
}
style={{
borderRadius: '12px',
border: `1px solid ${designTokens.colors.error.main}30`,
}}
bodyStyle={{ padding: 0 }}
>
<div style={{ maxHeight: '180px', overflowY: 'auto' }}>
{importResult.details
.filter(d => d.status === 'failed')
.map((detail, idx) => (
<div
key={idx}
style={{
padding: '12px 20px',
borderBottom:
idx <
importResult.details.filter(d => d.status === 'failed').length - 1
? `1px solid ${designTokens.colors.neutral[100]}`
: 'none',
display: 'flex',
alignItems: 'center',
gap: '12px',
}}
>
<div
style={{
width: '28px',
height: '28px',
borderRadius: '50%',
background: `${designTokens.colors.error.main}15`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
}}
>
<span
style={{
fontSize: '12px',
fontWeight: 600,
color: designTokens.colors.error.main,
}}
>
{detail.row}
</span>
</div>
<Text type="secondary" style={{ fontSize: '13px' }}>
{detail.error}
</Text>
</div>
))}
</div>
</Card>
</motion.div>
)}
</motion.div>
)}
{/* 导入中状态 */}
{importing && (
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
bottom: 0,
background: 'rgba(255,255,255,0.95)',
backdropFilter: 'blur(4px)',
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
borderRadius: '16px',
zIndex: 10,
}}
>
<div
style={{
width: '80px',
height: '80px',
borderRadius: '50%',
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.info.main} 100%)`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
marginBottom: '24px',
boxShadow: `0 8px 24px ${designTokens.colors.primary.main}40`,
}}
>
<ImportOutlined style={{ fontSize: '36px', color: '#fff' }} />
</div>
<Text
style={{
fontSize: '16px',
fontWeight: 600,
marginBottom: '16px',
color: designTokens.colors.neutral[800],
}}
>
正在导入数据...
</Text>
<Progress
percent={importProgress}
status={importProgress === 100 ? 'success' : 'active'}
strokeColor={{
'0%': designTokens.colors.primary.main,
'100%': designTokens.colors.success.main,
}}
style={{ width: '280px', marginBottom: '8px' }}
/>
<Text type="secondary" style={{ fontSize: '13px' }}>
{importPhase}
</Text>
</motion.div>
)}
</div>
{/* 底部按钮 */}
{!importing && importStep !== 'result' && (
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
paddingTop: '20px',
borderTop: `1px solid ${designTokens.colors.neutral[100]}`,
marginTop: '24px',
}}
>
<Button
onClick={handleImportCancel}
style={{
borderRadius: '10px',
height: '40px',
paddingInline: '20px',
}}
>
取消
</Button>
{importStep === 'preview' && (
<Button
type="primary"
onClick={handleImport}
disabled={importValidationErrors.length > 0 && importMode === 'create'}
style={{
background: `linear-gradient(135deg, ${designTokens.colors.primary.main} 0%, ${designTokens.colors.success.main} 100%)`,
border: 'none',
borderRadius: '10px',
height: '40px',
paddingInline: '32px',
boxShadow: `0 4px 12px ${designTokens.colors.primary.main}40`,
}}
>
开始导入
</Button>
)}
</div>
)}
</Modal>
{/* 字段选择弹窗 */}
<Modal
title="选择导出字段"
open={exportModalVisible}
onCancel={() => setExportModalVisible(false)}
onOk={() => {
setExportModalVisible(false);
handleExport();
}}
width={500}
>
<div style={{ maxHeight: '400px', overflowY: 'auto' }}>
<Checkbox.Group
value={exportFields}
onChange={setExportFields}
style={{ width: '100%' }}
>
<Space direction="vertical" style={{ width: '100%' }}>
{exportableFields.map(field => (
<Checkbox
key={field.key}
value={field.key}
disabled={field.required}
style={{ width: '100%', padding: '8px', borderBottom: '1px solid #f0f0f0' }}
>
{field.label} {field.required && <Tag color="red" style={{ marginLeft: 8 }}>必选</Tag>}
</Checkbox>
))}
</Space>
</Checkbox.Group>
</div>
</Modal>
{/* 入库/出库弹窗 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.md,
background:
stockType === 'in'
? designTokens.colors.success.gradient
: designTokens.colors.error.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
}}
>
{stockType === 'in' ? <ArrowDownOutlined /> : <ArrowUpOutlined />}
</div>
<span style={{ fontSize: '18px', fontWeight: 600 }}>
{stockType === 'in' ? '耗材入库' : '耗材出库'}
</span>
</div>
}
open={stockModalVisible}
closeIcon={<CloseButton />}
onCancel={handleStockCancel}
footer={null}
width={500}
>
<Form
form={stockForm}
layout="vertical"
onFinish={handleStockSubmit}
style={{ marginTop: '16px' }}
>
<Form.Item name="consumableId" label="耗材ID">
<Input disabled style={{ ...inputStyles.form, ...inputStyles.disabled }} />
</Form.Item>
<Form.Item name="consumableName" label="耗材名称">
<Input disabled style={{ ...inputStyles.form, ...inputStyles.disabled }} />
</Form.Item>
{stockType === 'out' && stockRecord?.snList && stockRecord.snList.length > 0 && (
<Form.Item
label={
<span>
选择SN序列号
<Text type="secondary" style={{ fontSize: '12px', marginLeft: '8px' }}>
(已选 {selectedSnList.length} )
</Text>
</span>
}
>
<Space direction="vertical" style={{ width: '100%' }} size="small">
<Input
placeholder="搜索SN序列号..."
prefix={<SearchOutlined />}
allowClear
value={snSearchKeyword}
onChange={e => setSnSearchKeyword(e.target.value)}
style={{ ...inputStyles.search, marginBottom: '8px' }}
/>
<Space size="small" style={{ marginBottom: '8px' }}>
<Button
size="small"
type="link"
onClick={() => {
const filtered = stockRecord.snList.filter(sn =>
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
);
setSelectedSnList([...new Set([...selectedSnList, ...filtered])]);
stockForm.setFieldsValue({
quantity: [...new Set([...selectedSnList, ...filtered])].length,
});
}}
>
全选过滤结果
</Button>
<Button
size="small"
type="link"
onClick={() => {
setSelectedSnList([]);
stockForm.setFieldsValue({ quantity: 1 });
}}
>
清空选择
</Button>
</Space>
<div
style={{
maxHeight: '150px',
overflowY: 'auto',
border: `1px solid ${designTokens.colors.neutral[200]}`,
borderRadius: designTokens.borderRadius.sm,
padding: '8px',
}}
>
{(() => {
const snListArray = Array.isArray(stockRecord.snList) ? stockRecord.snList : [];
const filteredSnList = snListArray.filter(sn =>
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
);
if (filteredSnList.length === 0) {
return (
<Text
type="secondary"
style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}
>
无匹配的 SN
</Text>
);
}
return filteredSnList.map((sn, index) => (
<Tag.CheckableTag
key={index}
checked={selectedSnList.includes(sn)}
onChange={checked => {
let newSelected;
if (checked) {
newSelected = [...selectedSnList, sn];
} else {
newSelected = selectedSnList.filter(s => s !== sn);
}
setSelectedSnList(newSelected);
stockForm.setFieldsValue({ quantity: newSelected.length });
}}
style={{ marginBottom: '4px' }}
>
{sn}
</Tag.CheckableTag>
));
})()}
</div>
<Text type="secondary" style={{ fontSize: '12px' }}>
{snSearchKeyword
? `过滤结果: ${stockRecord.snList.filter(sn => sn.toLowerCase().includes(snSearchKeyword.toLowerCase())).length} 个SN`
: `共 ${stockRecord.snList.length} 个SN`}
点击SN进行选择
</Text>
</Space>
</Form.Item>
)}
{stockType === 'in' && (
<Form.Item name="snList" label="入库SN序列号(可选)">
<Select
mode="tags"
style={selectStyles.base}
placeholder={inputPlaceholders.snList}
tokenSeparators={[',', '\n']}
/>
</Form.Item>
)}
<Form.Item
name="quantity"
label="数量"
rules={[inputValidationRules.required('请输入数量')]}
>
<InputNumber
min={1}
max={
stockType === 'out' && stockRecord?.snList?.length > 0
? stockRecord.snList.length
: undefined
}
style={inputNumberStyles.base}
placeholder="请输入数量"
/>
</Form.Item>
<Form.Item name="operator" label="操作人">
<Input placeholder={inputPlaceholders.operator} style={inputStyles.form} />
</Form.Item>
<Form.Item name="reason" label={stockType === 'in' ? '入库原因' : '出库原因'}>
<Input
placeholder={stockType === 'in' ? '如: 采购入库' : '如: 部门领用、报损出库'}
style={inputStyles.form}
/>
</Form.Item>
<Form.Item name="notes" label="备注">
<TextArea rows={2} placeholder={inputPlaceholders.notes} style={textAreaStyles.base} />
</Form.Item>
<Form.Item>
<Space>
<Button
type="primary"
htmlType="submit"
style={{
background:
stockType === 'in'
? designTokens.colors.success.gradient
: designTokens.colors.error.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.sm,
}}
>
{stockType === 'in' ? '确认入库' : '确认出库'}
</Button>
<Button onClick={handleStockCancel}>取消</Button>
</Space>
</Form.Item>
</Form>
</Modal>
{/* 扫码弹窗 - 支持入库/出库/添加SN三种模式 */}
<Modal
title={
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
<div
style={{
width: '40px',
height: '40px',
borderRadius: designTokens.borderRadius.md,
background: scanMode === 'in'
? designTokens.colors.success.gradient
: scanMode === 'out'
? designTokens.colors.error.gradient
: designTokens.colors.primary.gradient,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
color: '#fff',
boxShadow: scanMode === 'in'
? `0 4px 12px ${designTokens.colors.success.main}40`
: scanMode === 'out'
? `0 4px 12px ${designTokens.colors.error.main}40`
: `0 4px 12px ${designTokens.colors.primary.main}40`,
}}
>
{scanMode === 'in' ? <ArrowDownOutlined style={{ fontSize: '20px' }} /> :
scanMode === 'out' ? <ArrowUpOutlined style={{ fontSize: '20px' }} /> :
<BarcodeOutlined style={{ fontSize: '20px' }} />}
</div>
<div>
<span style={{ fontSize: '18px', fontWeight: 600, color: designTokens.colors.text.primary }}>
{scanMode === 'in' ? '扫码入库' : scanMode === 'out' ? '扫码出库' : '扫码添加SN'}
</span>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '2px' }}>
{scanMode === 'in' ? '扫描 SN 序列号快速入库' :
scanMode === 'out' ? '扫描 SN 序列号快速出库' :
'扫描 SN 序列号添加到表单'}
</div>
</div>
</div>
}
open={scanModalVisible}
closeIcon={<CloseButton />}
onCancel={handleScanCancel}
footer={null}
width={560}
bodyStyle={{ padding: '0 24px 24px' }}
style={{ top: 60 }}
>
{/* 统计卡片区 - 根据模式显示不同内容 */}
<div style={{ marginBottom: '24px', marginTop: '-8px' }}>
{scanMode === 'out' ? (
/* 出库模式:显示待出库列表 */
<div
style={{
background: `linear-gradient(135deg, ${designTokens.colors.error.main}10 0%, ${designTokens.colors.error.main}05 100%)`,
border: `1px solid ${designTokens.colors.error.main}30`,
borderRadius: designTokens.borderRadius.md,
padding: '20px',
textAlign: 'center',
}}
>
<div style={{ fontSize: '32px', fontWeight: 700, color: designTokens.colors.error.main, lineHeight: 1 }}>
{pendingOutItems.length}
</div>
<div style={{ fontSize: '13px', color: designTokens.colors.neutral[600], marginTop: '6px' }}>
待出库耗材项
</div>
{pendingOutItems.length > 0 && (
<div style={{ marginTop: '12px', paddingTop: '12px', borderTop: `1px dashed ${designTokens.colors.error.main}30` }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
{pendingOutItems.reduce((sum, item) => sum + item.quantity, 0)} 件耗材
</Text>
</div>
)}
</div>
) : (
/* 入库/添加SN模式:显示已扫描数量 */
<Row gutter={12}>
<Col span={12}>
<div
style={{
background: `linear-gradient(135deg, ${designTokens.colors.success.main}15 0%, ${designTokens.colors.success.main}05 100%)`,
border: `1px solid ${designTokens.colors.success.main}30`,
borderRadius: designTokens.borderRadius.md,
padding: '16px',
textAlign: 'center',
}}
>
<div style={{ fontSize: '28px', fontWeight: 700, color: designTokens.colors.success.main, lineHeight: 1 }}>
{(scanMode === 'in' ? scannedSnList : snList).length}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
已扫描SN
</div>
</div>
</Col>
<Col span={12}>
<div
style={{
background: `linear-gradient(135deg, ${designTokens.colors.primary.main}15 0%, ${designTokens.colors.primary.main}05 100%)`,
border: `1px solid ${designTokens.colors.primary.main}30`,
borderRadius: designTokens.borderRadius.md,
padding: '16px',
textAlign: 'center',
}}
>
<div style={{ fontSize: '28px', fontWeight: 700, color: designTokens.colors.primary.main, lineHeight: 1 }}>
{allConsumablesForScan.length}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500], marginTop: '4px' }}>
{scanMode === 'in' ? '可入库耗材' : '可选耗材'}
</div>
</div>
</Col>
</Row>
)}
</div>
{/* 扫码动画输入区 */}
<div style={{ marginBottom: '20px' }}>
<div
style={{
position: 'relative',
borderRadius: designTokens.borderRadius.lg,
overflow: 'hidden',
background: scanChecking
? `linear-gradient(135deg, ${designTokens.colors.warning.main}08 0%, ${designTokens.colors.warning.main}05 100%)`
: (scanMode === 'in' ? scannedSnList : snList).length > 0
? `linear-gradient(135deg, ${designTokens.colors.success.main}08 0%, ${designTokens.colors.success.main}05 100%)`
: `linear-gradient(135deg, ${designTokens.colors.primary.main}08 0%, ${designTokens.colors.primary.main}05 100%)`,
border: `2px solid ${
scanChecking
? designTokens.colors.warning.main
: (scanMode === 'in' ? scannedSnList : snList).length > 0
? designTokens.colors.success.main
: designTokens.colors.primary.main
}40`,
transition: 'all 0.3s ease',
}}
>
{scanChecking && (
<div
style={{
position: 'absolute',
top: 0,
left: 0,
right: 0,
height: '3px',
background: designTokens.colors.warning.gradient,
animation: 'scanLine 1s ease-in-out infinite',
zIndex: 1,
}}
/>
)}
<Input
ref={scanInputRef}
value={scanValue}
onChange={e => setScanValue(e.target.value)}
onKeyDown={handleScanKeyDown}
placeholder={scanMode === 'in'
? "对准扫码枪或输入 SN 后按 Enter 入库"
: scanMode === 'out'
? "对准扫码枪或输入 SN 后按 Enter 出库"
: "对准扫码枪或输入 SN 后按 Enter 添加"}
prefix={
<div
style={{
width: '36px',
height: '36px',
borderRadius: designTokens.borderRadius.sm,
background: scanChecking
? `${designTokens.colors.warning.main}20`
: (scanMode === 'in' ? scannedSnList : snList).length > 0
? `${designTokens.colors.success.main}20`
: `${designTokens.colors.primary.main}20`,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
transition: 'all 0.3s ease',
}}
>
<BarcodeOutlined
style={{
fontSize: '18px',
color: scanChecking
? designTokens.colors.warning.main
: (scanMode === 'in' ? scannedSnList : snList).length > 0
? designTokens.colors.success.main
: designTokens.colors.primary.main,
}}
/>
</div>
}
suffix={
scanChecking ? (
<div style={{ display: 'flex', alignItems: 'center', gap: '4px' }}>
<div
style={{
width: '8px',
height: '8px',
borderRadius: '50%',
background: designTokens.colors.warning.main,
animation: 'pulse 1s ease-in-out infinite',
}}
/>
<Text type="secondary" style={{ fontSize: '12px' }}>识别中</Text>
</div>
) : scanValue && (
<Button
type="text"
size="small"
icon={<CloseCircleOutlined />}
onClick={() => setScanValue('')}
style={{ color: designTokens.colors.neutral[400] }}
/>
)
}
size="large"
autoFocus
disabled={scanChecking}
style={{
border: 'none',
borderRadius: designTokens.borderRadius.lg,
background: 'transparent',
fontSize: '16px',
}}
/>
</div>
<style>{`
@keyframes scanLine {
0%, 100% { transform: translateX(-100%); opacity: 0; }
50% { transform: translateX(100%); opacity: 1; }
}
@keyframes pulse {
0%, 100% { transform: scale(1); opacity: 1; }
50% { transform: scale(1.5); opacity: 0.5; }
}
`}</style>
</div>
{/* 入库/添加SN模式:耗材选择区 */}
{scanMode !== 'out' && (
<div style={{ marginBottom: '20px' }}>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.neutral[600],
marginBottom: '8px',
display: 'flex',
alignItems: 'center',
gap: '6px',
}}
>
<span>{scanMode === 'in' ? '选择入库耗材' : '选择关联耗材'}</span>
{selectedScanInConsumable && (
<Tag color="success" style={{ marginLeft: 'auto', borderRadius: '20px' }}>
已选择
</Tag>
)}
</div>
<Select
placeholder={scanMode === 'in' ? "扫描 SN 后自动匹配,或手动选择耗材" : "选择要将 SN 关联到的耗材"}
style={{ width: '100%' }}
showSearch
optionFilterProp="children"
value={selectedScanInConsumable}
onChange={value => setSelectedScanInConsumable(value)}
filterOption={(input, option) => {
const children = option?.children || '';
return typeof children === 'string' && children.toLowerCase().includes(input.toLowerCase());
}}
optionRender={option => {
const label = option.label || option.data?.label || '';
const parts = label ? label.split(' ') : [];
const name = parts[0] || '';
const rest = parts.slice(1).join(' ') || '';
return (
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500, marginBottom: '2px' }}>{name}</div>
<div style={{ fontSize: '12px', color: designTokens.colors.neutral[500] }}>
{rest}
</div>
</div>
);
}}
notFoundContent={
<Empty
image={Empty.PRESENTED_IMAGE_SIMPLE}
description="无可选耗材"
style={{ padding: '20px 0' }}
/>
}
>
{allConsumablesForScan.map(item => (
<Option
key={item.consumableId}
value={item.consumableId}
>
{`${item.name} ${item.category} - 库存:${item.currentStock}${item.unit}`}
</Option>
))}
</Select>
</div>
)}
{/* 入库/添加SN模式:SN列表网格 */}
{scanMode !== 'out' && (scanMode === 'in' ? scannedSnList : snList).length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
style={{ marginBottom: '20px' }}
>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.neutral[600],
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space>
<span>{scanMode === 'in' ? '已扫描待入库' : '已添加 SN'}</span>
<Badge count={(scanMode === 'in' ? scannedSnList : snList).length} style={{ backgroundColor: designTokens.colors.success.main }} />
</Space>
<Button
type="link"
size="small"
icon={<DeleteOutlined />}
onClick={() => scanMode === 'in' ? setScannedSnList([]) : setSnList([])}
style={{ color: designTokens.colors.error.main, padding: 0 }}
>
清空
</Button>
</div>
<div
style={{
maxHeight: '180px',
overflowY: 'auto',
background: designTokens.colors.neutral[50],
borderRadius: designTokens.borderRadius.md,
padding: '12px',
}}
>
<Row gutter={[8, 8]}>
{(scanMode === 'in' ? scannedSnList : snList).map((sn, index) => (
<Col key={index} span={12}>
<motion.div
initial={{ opacity: 0, scale: 0.8 }}
animate={{ opacity: 1, scale: 1 }}
transition={{ duration: 0.2, delay: index * 0.05 }}
>
<Tag
closable
onClose={() => scanMode === 'in'
? setScannedSnList(prev => prev.filter((_, i) => i !== index))
: setSnList(prev => prev.filter((_, i) => i !== index))}
color="blue"
style={{
width: '100%',
justifyContent: 'space-between',
padding: '8px 12px',
borderRadius: designTokens.borderRadius.sm,
fontFamily: 'monospace',
}}
>
<span style={{ overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>{sn}</span>
<CheckCircleOutlined style={{ color: designTokens.colors.success.main, marginLeft: '8px' }} />
</Tag>
</motion.div>
</Col>
))}
</Row>
</div>
</motion.div>
)}
{/* 出库模式:待出库列表 */}
{scanMode === 'out' && pendingOutItems.length > 0 && (
<motion.div
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.3 }}
style={{ marginBottom: '20px' }}
>
<div
style={{
fontSize: '13px',
fontWeight: 500,
color: designTokens.colors.neutral[600],
marginBottom: '12px',
display: 'flex',
alignItems: 'center',
justifyContent: 'space-between',
}}
>
<Space>
<span>待出库耗材</span>
<Badge count={pendingOutItems.length} style={{ backgroundColor: designTokens.colors.error.main }} />
</Space>
<Button
type="link"
size="small"
icon={<DeleteOutlined />}
onClick={() => setPendingOutItems([])}
style={{ color: designTokens.colors.error.main, padding: 0 }}
>
清空
</Button>
</div>
<div
style={{
maxHeight: '200px',
overflowY: 'auto',
background: designTokens.colors.neutral[50],
borderRadius: designTokens.borderRadius.md,
padding: '12px',
}}
>
{pendingOutItems.map(item => (
<Tag
key={item.consumable.consumableId}
closable
onClose={() => removeFromPendingOut(item.consumable.consumableId)}
color="red"
style={{
display: 'flex',
justifyContent: 'space-between',
padding: '10px 14px',
borderRadius: designTokens.borderRadius.sm,
marginBottom: '8px',
fontSize: '13px',
}}
>
<span>
<strong>{item.consumable.name}</strong>
<Text type="secondary" style={{ marginLeft: '8px', fontSize: '12px' }}>
× {item.quantity}
</Text>
{item.snList.length > 0 && (
<Text type="secondary" style={{ marginLeft: '8px', fontSize: '11px' }}>
({item.snList.length} SN)
</Text>
)}
</span>
<CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />
</Tag>
))}
</div>
</motion.div>
)}
{/* 操作按钮区 */}
<div
style={{
display: 'flex',
gap: '12px',
paddingTop: '8px',
borderTop: `1px solid ${designTokens.colors.neutral[200]}`,
}}
>
<Button
block
size="large"
onClick={handleScanCancel}
style={{
borderRadius: designTokens.borderRadius.md,
height: '48px',
fontSize: '15px',
}}
>
取消
</Button>
{/* 入库/添加SN模式按钮 */}
{scanMode !== 'out' && (
<Button
type="primary"
block
size="large"
disabled={(scanMode === 'in' ? scannedSnList : snList).length === 0 || !selectedScanInConsumable}
onClick={() => {
if (!selectedScanInConsumable) {
message.warning('请选择要关联的耗材');
return;
}
if (scanMode === 'in') {
handleScanInSubmit(selectedScanInConsumable);
}
}}
style={{
background: (scanMode === 'in' ? scannedSnList : snList).length > 0 && selectedScanInConsumable
? scanMode === 'in'
? designTokens.colors.success.gradient
: designTokens.colors.primary.gradient
: undefined,
border: 'none',
borderRadius: designTokens.borderRadius.md,
height: '48px',
fontSize: '15px',
boxShadow: (scanMode === 'in' ? scannedSnList : snList).length > 0 && selectedScanInConsumable
? scanMode === 'in'
? `0 4px 12px ${designTokens.colors.success.main}40`
: `0 4px 12px ${designTokens.colors.primary.main}40`
: 'none',
}}
>
<Space>
{scanMode === 'in' ? <ArrowDownOutlined /> : <PlusOutlined />}
{scanMode === 'in' ? '确认入库' : '确认添加'}
{(scanMode === 'in' ? scannedSnList : snList).length > 0 && `(${(scanMode === 'in' ? scannedSnList : snList).length})`}
</Space>
</Button>
)}
{/* 出库模式按钮 */}
{scanMode === 'out' && pendingOutItems.length > 0 && (
<Button
type="primary"
block
size="large"
onClick={() => {
setScanModalVisible(false);
openBatchOutModal();
}}
style={{
background: designTokens.colors.error.gradient,
border: 'none',
borderRadius: designTokens.borderRadius.md,
height: '48px',
fontSize: '15px',
boxShadow: `0 4px 12px ${designTokens.colors.error.main}40`,
}}
>
<Space>
<ArrowUpOutlined />
批量出库确认 ({pendingOutItems.length} )
</Space>
</Button>
)}
</div>
{/* 底部提示 */}
<div style={{ textAlign: 'center', marginTop: '16px' }}>
<Text type="secondary" style={{ fontSize: '12px' }}>
<InfoCircleOutlined style={{ marginRight: '4px' }} />
{scanMode === 'in' ? '支持扫码枪自动识别,也可手动输入 SN 后按 Enter 确认' :
scanMode === 'out' ? '扫描耗材 SN 进行出库操作' :
'扫描 SN 序列号自动添加到表单中'}
</Text>
</div>
</Modal>
{/* 扫码快速出库到设备弹窗 */}
<Modal
title={
<Space>
<QrcodeOutlined style={{ color: designTokens.colors.primary.main }} />
<span>扫码出库</span>
</Space>
}
open={quickOutModalVisible}
closeIcon={<CloseButton />}
onCancel={() => setQuickOutModalVisible(false)}
footer={null}
width={520}
destroyOnClose
>
{quickOutConsumable && (
<div style={{ padding: '16px 0' }}>
<Card
size="small"
style={{
marginBottom: '16px',
borderRadius: '12px',
border: `1px solid ${designTokens.colors.primary.main}30`,
background: `linear-gradient(135deg, ${designTokens.colors.primary.main}08 0%, ${designTokens.colors.info.main}05 100%)`,
}}
bodyStyle={{ padding: '12px 16px' }}
>
<Row gutter={16} align="middle">
<Col flex="none">
<Avatar
size={48}
style={{
background: designTokens.colors.primary.gradient,
fontSize: '20px',
}}
>
{quickOutConsumable.category?.charAt(0) || '耗'}
</Avatar>
</Col>
<Col flex="auto">
<div style={{ fontWeight: 600, fontSize: '15px', color: designTokens.colors.text.primary }}>
{quickOutConsumable.name}
</div>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
{quickOutConsumable.category} · 库存: {quickOutConsumable.currentStock} {quickOutConsumable.unit}
</div>
{quickOutConsumable.location && (
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
📍 {quickOutConsumable.location}
</div>
)}
</Col>
</Row>
</Card>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
出库数量
</label>
<InputNumber
min={1}
max={quickOutConsumable.currentStock}
value={quickOutQuantity}
onChange={setQuickOutQuantity}
style={{ width: '100%', borderRadius: '8px' }}
size="large"
/>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
目标设备可选
</label>
<AutoComplete
value={quickOutDeviceSearch}
options={quickOutDeviceList.map(d => ({
value: d.name,
label: (
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>{d.name}</div>
<div style={{ fontSize: '11px', color: '#888' }}>
{d.type} · {d.location ? `${d.location.rackName || ''} ${d.location.roomName || ''}` : '未绑定机柜'}
</div>
</div>
),
}))}
onSearch={handleQuickOutDeviceSearch}
onSelect={(value, option) => {
const device = quickOutDeviceList.find(d => d.name === value);
setQuickOutDevice(device);
setQuickOutDeviceSearch(device?.name || '');
}}
onChange={value => {
setQuickOutDeviceSearch(value);
if (!value) {
setQuickOutDevice(null);
}
}}
placeholder="搜索设备名称/ID/序列号"
style={{ width: '100%' }}
loading={quickOutDeviceLoading}
/>
{quickOutDevice && (
<div
style={{
marginTop: '8px',
padding: '8px 12px',
borderRadius: '8px',
background: designTokens.colors.success.main + '15',
border: `1px solid ${designTokens.colors.success.main}30`,
}}
>
<Space>
<CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />
<span style={{ color: designTokens.colors.success.main, fontWeight: 500 }}>
已选择: {quickOutDevice.name}
</span>
</Space>
{quickOutDevice.location && (
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
📍 {quickOutDevice.location.roomName} · {quickOutDevice.location.rackName}
</div>
)}
</div>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
出库原因
</label>
<Input.TextArea
value={quickOutReason}
onChange={e => setQuickOutReason(e.target.value)}
placeholder="请输入出库原因"
autoSize={{ minRows: 2, maxRows: 4 }}
style={{ borderRadius: '8px' }}
/>
</div>
{quickOutSnList.length > 0 && (
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
SN序列号 ({quickOutSnList.length})
</label>
<div
style={{
maxHeight: '100px',
overflowY: 'auto',
border: `1px solid ${designTokens.colors.neutral[200]}`,
borderRadius: '8px',
padding: '8px',
}}
>
{quickOutSnList.map((sn, index) => (
<Tag key={index} color="purple" style={{ marginBottom: '4px' }}>
{sn}
</Tag>
))}
</div>
</div>
)}
<div
style={{
padding: '12px',
borderRadius: '8px',
background: designTokens.colors.neutral[50],
marginBottom: '16px',
}}
>
<Row gutter={16}>
<Col span={8}>
<Statistic
title="出库数量"
value={quickOutQuantity}
valueStyle={{ fontSize: '20px', color: designTokens.colors.error.main }}
/>
</Col>
<Col span={8}>
<Statistic
title="操作前库存"
value={quickOutConsumable.currentStock}
valueStyle={{ fontSize: '20px' }}
/>
</Col>
<Col span={8}>
<Statistic
title="操作后库存"
value={quickOutConsumable.currentStock - quickOutQuantity}
valueStyle={{ fontSize: '20px', color: designTokens.colors.success.main }}
/>
</Col>
</Row>
</div>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setQuickOutModalVisible(false)} style={{ borderRadius: '8px' }}>
取消
</Button>
<Button
type="primary"
loading={quickOutSubmitting}
onClick={handleQuickOutSubmit}
disabled={quickOutQuantity < 1 || quickOutQuantity > quickOutConsumable.currentStock}
style={{
borderRadius: '8px',
background: designTokens.colors.primary.gradient,
border: 'none',
}}
>
确认出库
</Button>
</Space>
</div>
)}
</Modal>
{/* 批量出库确认弹窗 */}
<Modal
title={
<Space>
<QrcodeOutlined style={{ color: designTokens.colors.primary.main }} />
<span>批量出库确认 ({pendingOutItems.length} )</span>
</Space>
}
open={batchOutModalVisible}
closeIcon={<CloseButton />}
onCancel={() => setBatchOutModalVisible(false)}
footer={null}
width={600}
destroyOnClose
>
<div style={{ padding: '16px 0' }}>
<Alert
message="请确认出库信息"
description="扫描耗材SN后,需要确认目标设备和出库原因才能完成出库"
type="info"
showIcon
style={{ marginBottom: '16px', borderRadius: '8px' }}
/>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
待出库耗材 ({pendingOutItems.length} )
</label>
<div
style={{
maxHeight: '200px',
overflowY: 'auto',
border: `1px solid ${designTokens.colors.neutral[200]}`,
borderRadius: '8px',
padding: '8px',
}}
>
{pendingOutItems.map((item, index) => (
<Card
key={item.consumable.consumableId}
size="small"
style={{
marginBottom: index < pendingOutItems.length - 1 ? '8px' : 0,
borderRadius: '8px',
}}
bodyStyle={{ padding: '8px 12px' }}
>
<Row align="middle" gutter={12}>
<Col flex="auto">
<div style={{ fontWeight: 500 }}>{item.consumable.name}</div>
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary }}>
{item.consumable.category} · 库存: {item.consumable.currentStock}
{item.snList.length > 0 && ` · SN: ${item.snList.length}个`}
</div>
{item.snList.length > 0 && (
<div style={{ marginTop: '4px' }}>
{item.snList.slice(0, 5).map((sn, i) => (
<Tag key={i} color="purple" style={{ marginBottom: '2px' }}>
{sn}
</Tag>
))}
{item.snList.length > 5 && (
<Tag color="default">+{item.snList.length - 5} 更多</Tag>
)}
</div>
)}
</Col>
<Col flex="none">
<Tag color="blue" style={{ borderRadius: '6px' }}>
× {item.quantity}
</Tag>
</Col>
<Col flex="none">
<Button
type="text"
danger
size="small"
icon={<DeleteOutlined />}
onClick={() => removeFromPendingOut(item.consumable.consumableId)}
/>
</Col>
</Row>
</Card>
))}
</div>
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
目标设备可选
</label>
<AutoComplete
value={batchOutDeviceSearch}
options={batchOutDeviceList.map(d => ({
value: d.name,
label: (
<div style={{ padding: '4px 0' }}>
<div style={{ fontWeight: 500 }}>{d.name}</div>
<div style={{ fontSize: '11px', color: '#888' }}>
{d.type} · {d.location ? `${d.location.rackName || ''} ${d.location.roomName || ''}` : '未绑定机柜'}
</div>
</div>
),
}))}
onSearch={handleBatchOutDeviceSearch}
onSelect={(value, option) => {
const device = batchOutDeviceList.find(d => d.name === value);
setBatchOutDevice(device);
setBatchOutDeviceSearch(device?.name || '');
}}
onChange={value => {
setBatchOutDeviceSearch(value);
if (!value) {
setBatchOutDevice(null);
}
}}
placeholder="搜索设备名称/ID/序列号"
style={{ width: '100%' }}
loading={batchOutDeviceLoading}
/>
{batchOutDevice && (
<div
style={{
marginTop: '8px',
padding: '8px 12px',
borderRadius: '8px',
background: designTokens.colors.success.main + '15',
border: `1px solid ${designTokens.colors.success.main}30`,
}}
>
<Space>
<CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />
<span style={{ color: designTokens.colors.success.main, fontWeight: 500 }}>
已选择: {batchOutDevice.name}
</span>
</Space>
{batchOutDevice.location && (
<div style={{ fontSize: '12px', color: designTokens.colors.text.secondary, marginTop: '4px' }}>
📍 {batchOutDevice.location.roomName} · {batchOutDevice.location.rackName}
</div>
)}
</div>
)}
</div>
<div style={{ marginBottom: '16px' }}>
<label style={{ fontWeight: 500, marginBottom: '8px', display: 'block', color: designTokens.colors.text.primary }}>
出库原因
</label>
<Input.TextArea
value={batchOutReason}
onChange={e => setBatchOutReason(e.target.value)}
placeholder="请输入出库原因"
autoSize={{ minRows: 2, maxRows: 4 }}
style={{ borderRadius: '8px' }}
/>
</div>
<div
style={{
padding: '12px',
borderRadius: '8px',
background: designTokens.colors.neutral[50],
marginBottom: '16px',
}}
>
<Row gutter={16}>
<Col span={8}>
<Statistic
title="耗材种类"
value={pendingOutItems.length}
valueStyle={{ fontSize: '20px' }}
/>
</Col>
<Col span={8}>
<Statistic
title="总数量"
value={pendingOutItems.reduce((sum, item) => sum + item.quantity, 0)}
valueStyle={{ fontSize: '20px', color: designTokens.colors.error.main }}
/>
</Col>
<Col span={8}>
<Statistic
title="SN数量"
value={pendingOutItems.reduce((sum, item) => sum + item.snList.length, 0)}
valueStyle={{ fontSize: '20px', color: designTokens.colors.primary.main }}
/>
</Col>
</Row>
</div>
<Space style={{ width: '100%', justifyContent: 'flex-end' }}>
<Button onClick={() => setBatchOutModalVisible(false)} style={{ borderRadius: '8px' }}>
取消
</Button>
<Button
type="primary"
loading={batchOutSubmitting}
onClick={handleBatchOutSubmit}
disabled={pendingOutItems.length === 0}
style={{
borderRadius: '8px',
background: designTokens.colors.primary.gradient,
border: 'none',
}}
>
确认出库 ({pendingOutItems.length} )
</Button>
</Space>
</div>
</Modal>
<ConsumableTimelineModal
visible={timelineModalVisible}
consumable={selectedConsumable}
onClose={() => {
setTimelineModalVisible(false);
setSelectedConsumable(null);
}}
/>
</motion.div>
);
}
export default React.memo(ConsumableManagement);