2026-02-12 16:29:19 +08:00
|
|
|
|
import React, { useState, useEffect, useCallback, useMemo } from 'react';
|
2026-02-10 14:17:26 +08:00
|
|
|
|
import { useDebounce } from '../hooks/useDebounce';
|
2026-02-10 11:04:01 +08:00
|
|
|
|
import {
|
|
|
|
|
|
Table,
|
|
|
|
|
|
Button,
|
|
|
|
|
|
Modal,
|
|
|
|
|
|
Form,
|
|
|
|
|
|
Input,
|
|
|
|
|
|
Select,
|
|
|
|
|
|
InputNumber,
|
|
|
|
|
|
message,
|
|
|
|
|
|
Card,
|
|
|
|
|
|
Space,
|
|
|
|
|
|
Popconfirm,
|
2026-03-05 09:57:44 +08:00
|
|
|
|
Popover,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
Upload,
|
|
|
|
|
|
Progress,
|
|
|
|
|
|
Checkbox,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
Row,
|
|
|
|
|
|
Col,
|
|
|
|
|
|
Badge,
|
|
|
|
|
|
Tag,
|
|
|
|
|
|
Tooltip,
|
|
|
|
|
|
Empty,
|
|
|
|
|
|
Skeleton,
|
|
|
|
|
|
Alert,
|
|
|
|
|
|
Typography,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
} from 'antd';
|
|
|
|
|
|
import {
|
|
|
|
|
|
PlusOutlined,
|
|
|
|
|
|
EditOutlined,
|
|
|
|
|
|
DeleteOutlined,
|
|
|
|
|
|
SearchOutlined,
|
|
|
|
|
|
ExportOutlined,
|
|
|
|
|
|
ImportOutlined,
|
|
|
|
|
|
UploadOutlined,
|
|
|
|
|
|
FileExcelOutlined,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
ShoppingOutlined,
|
|
|
|
|
|
FilterOutlined,
|
|
|
|
|
|
ClearOutlined,
|
|
|
|
|
|
ReloadOutlined,
|
|
|
|
|
|
CheckCircleOutlined,
|
|
|
|
|
|
ExclamationCircleOutlined,
|
|
|
|
|
|
ArrowUpOutlined,
|
|
|
|
|
|
ArrowDownOutlined,
|
2026-03-10 11:10:04 +08:00
|
|
|
|
ScanOutlined,
|
|
|
|
|
|
BarcodeOutlined,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
} from '@ant-design/icons';
|
2025-12-24 16:44:27 +08:00
|
|
|
|
import axios from 'axios';
|
2026-02-12 16:29:19 +08:00
|
|
|
|
import { motion, AnimatePresence } from 'framer-motion';
|
2026-03-10 15:49:02 +08:00
|
|
|
|
import { designTokens } from '../config/theme';
|
2026-03-12 12:40:12 +08:00
|
|
|
|
import CloseButton from '../components/CloseButton';
|
2026-03-05 10:41:49 +08:00
|
|
|
|
import {
|
|
|
|
|
|
inputStyles,
|
|
|
|
|
|
selectStyles,
|
|
|
|
|
|
inputNumberStyles,
|
|
|
|
|
|
textAreaStyles,
|
|
|
|
|
|
filterInputStyles,
|
|
|
|
|
|
inputPlaceholders,
|
|
|
|
|
|
inputValidationRules,
|
|
|
|
|
|
} from '../styles/deviceManagementStyles';
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
|
|
|
|
|
const { Option } = Select;
|
2026-02-12 16:29:19 +08:00
|
|
|
|
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],
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
};
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
|
|
|
|
|
function ConsumableManagement() {
|
|
|
|
|
|
const [consumables, setConsumables] = 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,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
showTotal: total => `共 ${total} 条记录`,
|
2025-12-24 16:44:27 +08:00
|
|
|
|
});
|
|
|
|
|
|
const [keyword, setKeyword] = useState('');
|
2026-02-10 14:17:26 +08:00
|
|
|
|
const debouncedKeyword = useDebounce(keyword, 300);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
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);
|
2025-12-29 12:08:05 +08:00
|
|
|
|
const [importProgress, setImportProgress] = useState(0);
|
|
|
|
|
|
const [importPhase, setImportPhase] = useState('');
|
|
|
|
|
|
const [importResult, setImportResult] = useState(null);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const [stockModalVisible, setStockModalVisible] = useState(false);
|
|
|
|
|
|
const [stockRecord, setStockRecord] = useState(null);
|
|
|
|
|
|
const [stockType, setStockType] = useState('in');
|
|
|
|
|
|
const [stockForm] = Form.useForm();
|
2026-01-30 14:01:23 +08:00
|
|
|
|
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
const [snList, setSnList] = useState([]);
|
|
|
|
|
|
const [snInputVisible, setSnInputVisible] = useState(false);
|
|
|
|
|
|
const [snInputValue, setSnInputValue] = useState('');
|
|
|
|
|
|
const [selectedSnList, setSelectedSnList] = useState([]);
|
|
|
|
|
|
const [snSearchKeyword, setSnSearchKeyword] = useState('');
|
2026-03-10 11:10:04 +08:00
|
|
|
|
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);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const fetchConsumables = useCallback(
|
|
|
|
|
|
async (page = 1, pageSize = 10) => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
setLoading(true);
|
|
|
|
|
|
const response = await axios.get('/api/consumables', {
|
2026-02-10 14:17:26 +08:00
|
|
|
|
params: { page, pageSize, keyword: debouncedKeyword, category, status },
|
2026-02-10 11:04:01 +08:00
|
|
|
|
});
|
2026-02-12 16:29:19 +08:00
|
|
|
|
const data = response.data.consumables || [];
|
|
|
|
|
|
setConsumables(data);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total }));
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error('获取耗材列表失败');
|
|
|
|
|
|
console.error('获取耗材列表失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setLoading(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-02-10 14:17:26 +08:00
|
|
|
|
[debouncedKeyword, category, status]
|
2026-02-10 11:04:01 +08:00
|
|
|
|
);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2025-12-26 11:03:47 +08:00
|
|
|
|
const fetchCategories = useCallback(async () => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const response = await axios.get('/api/consumable-categories/list');
|
2026-02-12 16:29:19 +08:00
|
|
|
|
setCategories(response.data || []);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
console.error('获取分类列表失败:', error);
|
|
|
|
|
|
}
|
2025-12-26 11:03:47 +08:00
|
|
|
|
}, []);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
|
fetchConsumables();
|
|
|
|
|
|
fetchCategories();
|
2025-12-26 11:03:47 +08:00
|
|
|
|
}, [fetchConsumables, fetchCategories]);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const showModal = useCallback(
|
|
|
|
|
|
(consumable = null) => {
|
|
|
|
|
|
setEditingConsumable(consumable);
|
|
|
|
|
|
if (consumable) {
|
|
|
|
|
|
const isUnlimited =
|
|
|
|
|
|
consumable.maxStock === 0 ||
|
|
|
|
|
|
consumable.maxStock === null ||
|
|
|
|
|
|
consumable.maxStock === undefined;
|
|
|
|
|
|
setMaxStockUnlimited(isUnlimited);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSnList(consumable.snList || []);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
...consumable,
|
|
|
|
|
|
maxStock: isUnlimited ? undefined : consumable.maxStock,
|
|
|
|
|
|
});
|
|
|
|
|
|
} else {
|
|
|
|
|
|
setMaxStockUnlimited(true);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSnList([]);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
form.resetFields();
|
|
|
|
|
|
form.setFieldsValue({
|
|
|
|
|
|
unit: '个',
|
|
|
|
|
|
currentStock: 0,
|
|
|
|
|
|
minStock: 0,
|
|
|
|
|
|
status: 'active',
|
|
|
|
|
|
unitPrice: 0,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
setModalVisible(true);
|
|
|
|
|
|
},
|
|
|
|
|
|
[form]
|
|
|
|
|
|
);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2025-12-26 11:03:47 +08:00
|
|
|
|
const handleCancel = useCallback(() => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
setModalVisible(false);
|
|
|
|
|
|
setEditingConsumable(null);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSnList([]);
|
|
|
|
|
|
setSnInputVisible(false);
|
|
|
|
|
|
setSnInputValue('');
|
2025-12-26 11:03:47 +08:00
|
|
|
|
}, []);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const handleSubmit = useCallback(
|
|
|
|
|
|
async values => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
const submitData = {
|
|
|
|
|
|
...values,
|
|
|
|
|
|
maxStock: maxStockUnlimited ? 0 : values.maxStock,
|
|
|
|
|
|
unitPrice: values.unitPrice || 0,
|
2026-03-05 09:57:44 +08:00
|
|
|
|
snList: snList,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
};
|
|
|
|
|
|
if (editingConsumable) {
|
|
|
|
|
|
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.success({
|
|
|
|
|
|
content: '耗材更新成功',
|
|
|
|
|
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
|
|
|
|
|
});
|
2026-02-10 11:04:01 +08:00
|
|
|
|
} else {
|
|
|
|
|
|
await axios.post('/api/consumables', {
|
|
|
|
|
|
...submitData,
|
|
|
|
|
|
consumableId: `CON${Date.now()}`,
|
|
|
|
|
|
});
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.success({
|
|
|
|
|
|
content: '耗材创建成功',
|
|
|
|
|
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
|
|
|
|
|
});
|
2026-02-10 11:04:01 +08:00
|
|
|
|
}
|
|
|
|
|
|
setModalVisible(false);
|
|
|
|
|
|
fetchConsumables();
|
|
|
|
|
|
setEditingConsumable(null);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSnList([]);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
|
|
|
|
|
console.error('提交失败:', error);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
}
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
2026-03-05 09:57:44 +08:00
|
|
|
|
[editingConsumable, fetchConsumables, maxStockUnlimited, snList]
|
2026-02-10 11:04:01 +08:00
|
|
|
|
);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const handleDelete = useCallback(
|
|
|
|
|
|
async consumableId => {
|
|
|
|
|
|
try {
|
|
|
|
|
|
await axios.delete(`/api/consumables/${consumableId}`);
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.success({
|
|
|
|
|
|
content: '删除成功',
|
|
|
|
|
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
|
|
|
|
|
});
|
2026-02-10 11:04:01 +08:00
|
|
|
|
fetchConsumables();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error('删除失败');
|
|
|
|
|
|
console.error('删除失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
},
|
|
|
|
|
|
[fetchConsumables]
|
|
|
|
|
|
);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const handleSearch = useCallback(value => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
setKeyword(value);
|
2025-12-26 11:03:47 +08:00
|
|
|
|
}, []);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-12 16:29:19 +08:00
|
|
|
|
const handleReset = () => {
|
|
|
|
|
|
setKeyword('');
|
|
|
|
|
|
setCategory('all');
|
|
|
|
|
|
setStatus('all');
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const exportToCSV = (data, filename) => {
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const headers = [
|
|
|
|
|
|
'耗材ID',
|
|
|
|
|
|
'名称',
|
|
|
|
|
|
'分类',
|
|
|
|
|
|
'单位',
|
|
|
|
|
|
'当前库存',
|
|
|
|
|
|
'最小库存',
|
|
|
|
|
|
'最大库存',
|
|
|
|
|
|
'单价',
|
|
|
|
|
|
'供应商',
|
|
|
|
|
|
'存放位置',
|
|
|
|
|
|
'状态',
|
|
|
|
|
|
];
|
|
|
|
|
|
const keys = [
|
|
|
|
|
|
'consumableId',
|
|
|
|
|
|
'name',
|
|
|
|
|
|
'category',
|
|
|
|
|
|
'unit',
|
|
|
|
|
|
'currentStock',
|
|
|
|
|
|
'minStock',
|
|
|
|
|
|
'maxStock',
|
|
|
|
|
|
'unitPrice',
|
|
|
|
|
|
'supplier',
|
|
|
|
|
|
'location',
|
|
|
|
|
|
'status',
|
|
|
|
|
|
];
|
|
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const csvContent = [
|
|
|
|
|
|
headers.join(','),
|
2026-02-10 11:04:01 +08:00
|
|
|
|
...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(',')
|
|
|
|
|
|
),
|
2025-12-24 16:44:27 +08:00
|
|
|
|
].join('\n');
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
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 response = await axios.get('/api/consumables', {
|
2026-02-10 11:04:01 +08:00
|
|
|
|
params: { keyword, category, status, pageSize: 1000 },
|
2025-12-24 16:44:27 +08:00
|
|
|
|
});
|
|
|
|
|
|
const consumables = response.data.consumables;
|
|
|
|
|
|
exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`);
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.success({
|
|
|
|
|
|
content: '导出成功',
|
|
|
|
|
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
|
|
|
|
|
});
|
2025-12-24 16:44:27 +08:00
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error('导出失败');
|
|
|
|
|
|
console.error('导出失败:', error);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const parseCSV = text => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const lines = text.trim().split('\n');
|
|
|
|
|
|
if (lines.length < 2) return [];
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, ''));
|
|
|
|
|
|
const data = [];
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
for (let i = 1; i < lines.length; i++) {
|
|
|
|
|
|
const line = lines[i].trim();
|
|
|
|
|
|
if (!line) continue;
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
let values = [];
|
|
|
|
|
|
let inQuotes = false;
|
|
|
|
|
|
let current = '';
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
for (let j = 0; j < line.length; j++) {
|
|
|
|
|
|
const char = line[j];
|
|
|
|
|
|
if (char === '"') {
|
|
|
|
|
|
inQuotes = !inQuotes;
|
|
|
|
|
|
} else if (char === ',' && !inQuotes) {
|
|
|
|
|
|
values.push(current.trim().replace(/^"|"$/g, ''));
|
|
|
|
|
|
current = '';
|
|
|
|
|
|
} else {
|
|
|
|
|
|
current += char;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
values.push(current.trim().replace(/^"|"$/g, ''));
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const row = {};
|
|
|
|
|
|
headers.forEach((header, idx) => {
|
|
|
|
|
|
row[header] = values[idx] || '';
|
|
|
|
|
|
});
|
|
|
|
|
|
data.push(row);
|
|
|
|
|
|
}
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
return data;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const handleFileChange = info => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const file = info.fileList[info.fileList.length - 1];
|
|
|
|
|
|
if (file && file.originFileObj) {
|
|
|
|
|
|
const reader = new FileReader();
|
2026-02-10 11:04:01 +08:00
|
|
|
|
reader.onload = e => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const text = e.target.result;
|
|
|
|
|
|
const parsedData = parseCSV(text);
|
|
|
|
|
|
setImportPreview(parsedData.slice(0, 10));
|
|
|
|
|
|
setImportFile(file.originFileObj);
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.readAsText(file.originFileObj);
|
|
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const showImportModal = () => {
|
|
|
|
|
|
setImportPreview([]);
|
|
|
|
|
|
setImportFile(null);
|
|
|
|
|
|
setImportModalVisible(true);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleImportCancel = () => {
|
|
|
|
|
|
setImportModalVisible(false);
|
|
|
|
|
|
setImportPreview([]);
|
|
|
|
|
|
setImportFile(null);
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setImportProgress(0);
|
|
|
|
|
|
setImportPhase('');
|
|
|
|
|
|
setImportResult(null);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const handleImport = async () => {
|
|
|
|
|
|
if (!importFile) {
|
|
|
|
|
|
message.warning('请先选择文件');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
setImporting(true);
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setImportProgress(0);
|
|
|
|
|
|
setImportPhase('正在读取文件...');
|
|
|
|
|
|
setImportResult(null);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
try {
|
|
|
|
|
|
const reader = new FileReader();
|
2026-02-10 11:04:01 +08:00
|
|
|
|
reader.onload = async e => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const text = e.target.result;
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setImportProgress(10);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setImportProgress(20);
|
|
|
|
|
|
setImportPhase('正在解析CSV数据...');
|
|
|
|
|
|
}, 100);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const items = parseCSV(text);
|
2025-12-29 12:08:05 +08:00
|
|
|
|
const totalItems = items.length;
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setImportProgress(30);
|
|
|
|
|
|
setImportPhase(`共解析 ${totalItems} 条记录,准备提交...`);
|
|
|
|
|
|
}, 200);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setImportProgress(40);
|
|
|
|
|
|
setImportPhase('正在连接服务器...');
|
|
|
|
|
|
}, 300);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const response = await axios.post('/api/consumables/import', { items });
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setImportProgress(60);
|
|
|
|
|
|
setImportPhase('正在处理服务器响应...');
|
|
|
|
|
|
}, 100);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setImportProgress(80);
|
|
|
|
|
|
setImportPhase('正在更新本地数据...');
|
|
|
|
|
|
}, 200);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
const results = response.data.results;
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setTimeout(() => {
|
|
|
|
|
|
setImportResult(results);
|
|
|
|
|
|
setImportProgress(100);
|
|
|
|
|
|
setImportPhase('导入完成');
|
|
|
|
|
|
setImporting(false);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-29 12:08:05 +08:00
|
|
|
|
if (results.failed > 0) {
|
|
|
|
|
|
message.warning(`导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`);
|
|
|
|
|
|
} else {
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.success({
|
|
|
|
|
|
content: response.data.message || `成功导入 ${results.success} 条记录`,
|
|
|
|
|
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
|
|
|
|
|
});
|
2025-12-29 12:08:05 +08:00
|
|
|
|
}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
fetchConsumables();
|
2025-12-29 12:08:05 +08:00
|
|
|
|
}, 300);
|
|
|
|
|
|
};
|
|
|
|
|
|
reader.onerror = () => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
setImporting(false);
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setImportProgress(0);
|
|
|
|
|
|
setImportPhase('文件读取失败');
|
|
|
|
|
|
message.error('文件读取失败');
|
2025-12-24 16:44:27 +08:00
|
|
|
|
};
|
|
|
|
|
|
reader.readAsText(importFile);
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
setImporting(false);
|
2025-12-29 12:08:05 +08:00
|
|
|
|
setImportProgress(0);
|
|
|
|
|
|
setImportPhase('导入失败');
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.error('导入失败,请检查网络连接或服务器状态');
|
2025-12-29 12:08:05 +08:00
|
|
|
|
console.error('导入耗材失败:', error);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
const downloadTemplate = () => {
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const template =
|
|
|
|
|
|
'耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active';
|
2025-12-24 16:44:27 +08:00
|
|
|
|
const blob = new Blob([template], { type: 'text/csv;charset=utf-8;' });
|
|
|
|
|
|
const url = window.URL.createObjectURL(blob);
|
|
|
|
|
|
const link = document.createElement('a');
|
|
|
|
|
|
link.href = url;
|
|
|
|
|
|
link.download = '耗材导入模板.csv';
|
|
|
|
|
|
document.body.appendChild(link);
|
|
|
|
|
|
link.click();
|
|
|
|
|
|
document.body.removeChild(link);
|
|
|
|
|
|
window.URL.revokeObjectURL(url);
|
|
|
|
|
|
};
|
|
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const showStockModal = useCallback(
|
|
|
|
|
|
(record, type) => {
|
|
|
|
|
|
setStockRecord(record);
|
|
|
|
|
|
setStockType(type);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSelectedSnList([]);
|
|
|
|
|
|
setSnSearchKeyword('');
|
2026-02-10 11:04:01 +08:00
|
|
|
|
stockForm.setFieldsValue({
|
|
|
|
|
|
consumableId: record.consumableId,
|
|
|
|
|
|
consumableName: record.name,
|
|
|
|
|
|
quantity: 1,
|
|
|
|
|
|
reason: '',
|
|
|
|
|
|
notes: '',
|
|
|
|
|
|
});
|
|
|
|
|
|
setStockModalVisible(true);
|
|
|
|
|
|
},
|
|
|
|
|
|
[stockForm]
|
|
|
|
|
|
);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2025-12-26 11:03:47 +08:00
|
|
|
|
const handleStockCancel = useCallback(() => {
|
2025-12-24 16:44:27 +08:00
|
|
|
|
setStockModalVisible(false);
|
|
|
|
|
|
setStockRecord(null);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSelectedSnList([]);
|
|
|
|
|
|
setSnSearchKeyword('');
|
2025-12-26 11:03:47 +08:00
|
|
|
|
}, []);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const handleStockSubmit = useCallback(
|
|
|
|
|
|
async values => {
|
|
|
|
|
|
try {
|
2026-02-12 16:29:19 +08:00
|
|
|
|
await axios.post('/api/consumables/quick-inout', {
|
2026-02-10 11:04:01 +08:00
|
|
|
|
consumableId: stockRecord.consumableId,
|
|
|
|
|
|
type: stockType,
|
|
|
|
|
|
quantity: values.quantity,
|
|
|
|
|
|
operator: values.operator || '系统管理员',
|
|
|
|
|
|
reason: values.reason,
|
|
|
|
|
|
notes: values.notes,
|
2026-03-05 09:57:44 +08:00
|
|
|
|
snList: stockType === 'out' ? selectedSnList : values.snList || [],
|
2026-02-10 11:04:01 +08:00
|
|
|
|
});
|
2026-02-12 16:29:19 +08:00
|
|
|
|
message.success({
|
|
|
|
|
|
content: `${stockType === 'in' ? '入库' : '出库'}操作成功`,
|
|
|
|
|
|
icon: <CheckCircleOutlined style={{ color: designTokens.colors.success.main }} />,
|
|
|
|
|
|
});
|
2026-02-10 11:04:01 +08:00
|
|
|
|
setStockModalVisible(false);
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSelectedSnList([]);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
fetchConsumables();
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error(
|
|
|
|
|
|
error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`
|
2025-12-24 16:44:27 +08:00
|
|
|
|
);
|
2026-02-10 11:04:01 +08:00
|
|
|
|
console.error('操作失败:', error);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
}
|
|
|
|
|
|
},
|
2026-03-05 09:57:44 +08:00
|
|
|
|
[stockRecord, stockType, fetchConsumables, selectedSnList]
|
2026-02-10 11:04:01 +08:00
|
|
|
|
);
|
|
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
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 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 {
|
|
|
|
|
|
if (!snList.includes(code)) {
|
|
|
|
|
|
setSnList(prev => [...prev, code]);
|
|
|
|
|
|
const currentStock = form.getFieldValue('currentStock') || 0;
|
|
|
|
|
|
form.setFieldsValue({ currentStock: currentStock + 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) {
|
|
|
|
|
|
handleScanCancel();
|
|
|
|
|
|
showStockModal(res.data.consumable, 'out');
|
|
|
|
|
|
setSelectedSnList([code]);
|
|
|
|
|
|
stockForm.setFieldsValue({ quantity: 1 });
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.warning('未找到该SN对应的耗材');
|
|
|
|
|
|
setScanValue('');
|
|
|
|
|
|
scanInputRef.current?.focus();
|
|
|
|
|
|
}
|
|
|
|
|
|
} catch (error) {
|
|
|
|
|
|
message.error('查询SN失败');
|
|
|
|
|
|
console.error('查询SN失败:', error);
|
|
|
|
|
|
} finally {
|
|
|
|
|
|
setScanChecking(false);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
}, [scanMode, scanValue, scannedSnList, handleScanCancel, showModal, form, showStockModal, stockForm]);
|
|
|
|
|
|
|
|
|
|
|
|
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]);
|
|
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
const columns = useMemo(
|
|
|
|
|
|
() => [
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '名称',
|
|
|
|
|
|
dataIndex: 'name',
|
|
|
|
|
|
key: 'name',
|
|
|
|
|
|
width: 150,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: text => <span style={{ fontWeight: 500, color: designTokens.colors.neutral[800] }}>{text}</span>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '分类',
|
|
|
|
|
|
dataIndex: 'category',
|
|
|
|
|
|
key: 'category',
|
|
|
|
|
|
width: 120,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: text => <Tag color="blue" style={{ borderRadius: '4px' }}>{text}</Tag>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '单位',
|
|
|
|
|
|
dataIndex: 'unit',
|
|
|
|
|
|
key: 'unit',
|
|
|
|
|
|
width: 80,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: text => <Text type="secondary">{text}</Text>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
2026-03-05 09:57:44 +08:00
|
|
|
|
{
|
2026-03-13 15:13:58 +08:00
|
|
|
|
title: 'SN 数量',
|
2026-03-05 09:57:44 +08:00
|
|
|
|
key: 'snCount',
|
|
|
|
|
|
width: 120,
|
|
|
|
|
|
render: (_, record) => {
|
2026-03-13 15:13:58 +08:00
|
|
|
|
const snList = Array.isArray(record.snList) ? record.snList : [];
|
2026-03-05 09:57:44 +08:00
|
|
|
|
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}
|
2026-03-13 15:13:58 +08:00
|
|
|
|
title={`SN 列表 (${snCount}个)`}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
trigger="click"
|
|
|
|
|
|
placement="right"
|
|
|
|
|
|
>
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
color="purple"
|
|
|
|
|
|
style={{ borderRadius: '4px', cursor: 'pointer' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{snCount}/{stock} 🔍
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
</Popover>
|
|
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
2026-02-10 11:04:01 +08:00
|
|
|
|
{
|
|
|
|
|
|
title: '当前库存',
|
|
|
|
|
|
dataIndex: 'currentStock',
|
|
|
|
|
|
key: 'currentStock',
|
|
|
|
|
|
width: 100,
|
|
|
|
|
|
render: (value, record) => {
|
|
|
|
|
|
const isLow = value <= record.minStock;
|
|
|
|
|
|
return (
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Badge
|
|
|
|
|
|
status={isLow ? 'error' : 'success'}
|
|
|
|
|
|
text={
|
|
|
|
|
|
<span style={{
|
|
|
|
|
|
color: isLow ? designTokens.colors.error.main : designTokens.colors.success.main,
|
|
|
|
|
|
fontWeight: 600
|
|
|
|
|
|
}}>
|
|
|
|
|
|
{value}
|
|
|
|
|
|
</span>
|
|
|
|
|
|
}
|
|
|
|
|
|
/>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
);
|
|
|
|
|
|
},
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '最小库存',
|
|
|
|
|
|
dataIndex: 'minStock',
|
|
|
|
|
|
key: 'minStock',
|
|
|
|
|
|
width: 100,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: text => <Text type="secondary">{text}</Text>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '最大库存',
|
|
|
|
|
|
dataIndex: 'maxStock',
|
|
|
|
|
|
key: 'maxStock',
|
|
|
|
|
|
width: 100,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: value => (
|
|
|
|
|
|
<Text type="secondary">
|
|
|
|
|
|
{value === 0 || value === null || value === undefined ? '无限制' : value}
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
),
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
2026-02-12 16:29:19 +08:00
|
|
|
|
title: '单价',
|
2026-02-10 11:04:01 +08:00
|
|
|
|
dataIndex: 'unitPrice',
|
|
|
|
|
|
key: 'unitPrice',
|
|
|
|
|
|
width: 100,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: value => (
|
|
|
|
|
|
<Tag color="orange" style={{ borderRadius: '4px' }}>
|
|
|
|
|
|
¥{parseFloat(value || 0).toFixed(2)}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
),
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '供应商',
|
|
|
|
|
|
dataIndex: 'supplier',
|
|
|
|
|
|
key: 'supplier',
|
|
|
|
|
|
width: 150,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: value => value || <Text type="secondary">-</Text>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '位置',
|
|
|
|
|
|
dataIndex: 'location',
|
|
|
|
|
|
key: 'location',
|
|
|
|
|
|
width: 120,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: value => value || <Text type="secondary">-</Text>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '描述',
|
|
|
|
|
|
dataIndex: 'description',
|
|
|
|
|
|
key: 'description',
|
|
|
|
|
|
width: 200,
|
|
|
|
|
|
ellipsis: true,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
render: value => value || <Text type="secondary">-</Text>,
|
2026-02-10 11:04:01 +08:00
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '状态',
|
|
|
|
|
|
dataIndex: 'status',
|
|
|
|
|
|
key: 'status',
|
|
|
|
|
|
width: 100,
|
|
|
|
|
|
render: value => (
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Tag
|
|
|
|
|
|
color={value === 'active' ? 'success' : 'error'}
|
|
|
|
|
|
style={{ borderRadius: '4px' }}
|
|
|
|
|
|
icon={value === 'active' ? <CheckCircleOutlined /> : <ExclamationCircleOutlined />}
|
|
|
|
|
|
>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
{value === 'active' ? '启用' : '停用'}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
</Tag>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
{
|
|
|
|
|
|
title: '操作',
|
|
|
|
|
|
key: 'action',
|
|
|
|
|
|
width: 200,
|
2026-02-12 16:29:19 +08:00
|
|
|
|
fixed: 'right',
|
2026-02-10 11:04:01 +08:00
|
|
|
|
render: (_, record) => (
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Space size="small">
|
|
|
|
|
|
<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>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
),
|
|
|
|
|
|
},
|
|
|
|
|
|
],
|
|
|
|
|
|
[showModal, showStockModal, handleDelete]
|
|
|
|
|
|
);
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
|
|
|
|
|
const previewColumns = [
|
|
|
|
|
|
{ title: '名称', dataIndex: '名称', key: 'name', width: 120 },
|
|
|
|
|
|
{ title: '分类', dataIndex: '分类', key: 'category', width: 100 },
|
|
|
|
|
|
{ title: '单位', dataIndex: '单位', key: 'unit', width: 80 },
|
|
|
|
|
|
{ title: '当前库存', dataIndex: '当前库存', key: 'currentStock', width: 90 },
|
2026-02-10 11:04:01 +08:00
|
|
|
|
{ title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 },
|
2025-12-24 16:44:27 +08:00
|
|
|
|
];
|
|
|
|
|
|
|
|
|
|
|
|
return (
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<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}>
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<div style={filterInputStyles.container}>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
搜索
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Input
|
2026-03-05 10:41:49 +08:00
|
|
|
|
placeholder={inputPlaceholders.searchConsumable}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
value={keyword}
|
|
|
|
|
|
onChange={e => handleSearch(e.target.value)}
|
|
|
|
|
|
onPressEnter={() => fetchConsumables()}
|
|
|
|
|
|
prefix={<SearchOutlined style={{ color: designTokens.colors.neutral[400] }} />}
|
|
|
|
|
|
allowClear
|
2026-03-05 10:41:49 +08:00
|
|
|
|
style={filterInputStyles.input}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
|
|
|
|
|
|
<Col xs={24} sm={12} md={6} lg={5}>
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<div style={filterInputStyles.container}>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
分类
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Select
|
2026-03-05 10:41:49 +08:00
|
|
|
|
placeholder={inputPlaceholders.category}
|
|
|
|
|
|
style={filterInputStyles.select}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
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}>
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<div style={filterInputStyles.container}>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
状态
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
placeholder="选择状态"
|
2026-03-05 10:41:49 +08:00
|
|
|
|
style={filterInputStyles.select}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
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,
|
2026-03-05 10:41:49 +08:00
|
|
|
|
height: '40px',
|
2026-02-12 16:29:19 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
筛选
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Tooltip title="重置筛选条件">
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<ClearOutlined />}
|
|
|
|
|
|
onClick={handleReset}
|
2026-03-05 10:41:49 +08:00
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm, height: '40px' }}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
/>
|
|
|
|
|
|
</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>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<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>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
icon={<ImportOutlined />}
|
|
|
|
|
|
onClick={showImportModal}
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
|
|
|
|
>
|
|
|
|
|
|
批量导入
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<ExportOutlined />}
|
|
|
|
|
|
onClick={handleExport}
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
|
|
|
|
>
|
|
|
|
|
|
导出
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</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}
|
|
|
|
|
|
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>
|
|
|
|
|
|
)}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Card>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
</motion.div>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
|
2026-02-12 16:29:19 +08:00
|
|
|
|
{/* 添加/编辑耗材弹窗 */}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Modal
|
2026-02-12 16:29:19 +08:00
|
|
|
|
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>
|
|
|
|
|
|
}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
open={modalVisible}
|
2026-03-12 12:40:12 +08:00
|
|
|
|
closeIcon={<CloseButton />}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
onCancel={handleCancel}
|
|
|
|
|
|
footer={null}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
width={900}
|
|
|
|
|
|
style={{ top: 20 }}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
>
|
2026-03-10 14:34:39 +08:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
maxHeight: '65vh',
|
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
|
padding: '24px',
|
|
|
|
|
|
paddingRight: '16px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
className="custom-scroll-container"
|
|
|
|
|
|
>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<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}
|
2026-02-10 11:04:01 +08:00
|
|
|
|
>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{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>
|
|
|
|
|
|
|
|
|
|
|
|
{/* 库存管理 */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
background: 'linear-gradient(135deg, #f0fdf4 0%, #dcfce7 100%)',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.lg,
|
|
|
|
|
|
padding: '20px',
|
|
|
|
|
|
marginBottom: '20px',
|
|
|
|
|
|
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>
|
|
|
|
|
|
库存管理
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: '12px', marginLeft: 'auto' }}>
|
|
|
|
|
|
设置库存预警和上限
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Row gutter={16}>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="currentStock"
|
|
|
|
|
|
label="当前库存"
|
|
|
|
|
|
rules={[inputValidationRules.required('请输入当前库存')]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<InputNumber
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
style={{ ...inputNumberStyles.base, width: '100%' }}
|
|
|
|
|
|
placeholder={inputPlaceholders.stock}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="minStock"
|
|
|
|
|
|
label="最小库存(预警线)"
|
|
|
|
|
|
rules={[inputValidationRules.required('请输入最小库存')]}
|
|
|
|
|
|
>
|
|
|
|
|
|
<InputNumber
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
style={{ ...inputNumberStyles.base, width: '100%' }}
|
|
|
|
|
|
placeholder={inputPlaceholders.minStock}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
<Col span={8}>
|
|
|
|
|
|
<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>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
{!maxStockUnlimited && (
|
|
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="maxStock"
|
|
|
|
|
|
noStyle
|
2026-03-05 10:41:49 +08:00
|
|
|
|
rules={[inputValidationRules.required('请输入最大库存')]}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<InputNumber
|
|
|
|
|
|
min={0}
|
|
|
|
|
|
style={{ ...inputNumberStyles.base, width: '100%' }}
|
|
|
|
|
|
placeholder={inputPlaceholders.maxStock}
|
|
|
|
|
|
/>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
)}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Col>
|
|
|
|
|
|
</Row>
|
|
|
|
|
|
</div>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{/* 价格与状态 */}
|
|
|
|
|
|
<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>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{/* 其他信息 */}
|
|
|
|
|
|
<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>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{/* SN序列号管理 */}
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
background: 'linear-gradient(135deg, #fff1f2 0%, #ffe4e6 100%)',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.lg,
|
|
|
|
|
|
padding: '20px',
|
|
|
|
|
|
marginBottom: '20px',
|
|
|
|
|
|
border: '1px solid #fda4af',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'space-between',
|
|
|
|
|
|
marginBottom: '16px',
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
gap: '8px',
|
|
|
|
|
|
fontSize: '15px',
|
|
|
|
|
|
fontWeight: '600',
|
|
|
|
|
|
color: designTokens.colors.neutral[700],
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
width: '24px',
|
|
|
|
|
|
height: '24px',
|
|
|
|
|
|
borderRadius: '50%',
|
|
|
|
|
|
background: designTokens.colors.error.gradient,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
fontSize: '12px',
|
|
|
|
|
|
}}>5</div>
|
|
|
|
|
|
SN序列号管理
|
|
|
|
|
|
<Tag color="red" style={{ marginLeft: '8px', borderRadius: '12px' }}>
|
|
|
|
|
|
已录入 {snList.length} 个
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
</div>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
<Space>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<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>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
icon={<PlusOutlined />}
|
|
|
|
|
|
onClick={() => setSnInputVisible(!snInputVisible)}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
批量添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
danger
|
2026-03-10 11:10:04 +08:00
|
|
|
|
icon={<DeleteOutlined />}
|
|
|
|
|
|
onClick={() => setSnList([])}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
disabled={snList.length === 0}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
>
|
|
|
|
|
|
清空全部
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{/* 批量添加输入区 */}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
{snInputVisible && (
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, height: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1, height: 'auto' }}
|
|
|
|
|
|
exit={{ opacity: 0, height: 0 }}
|
|
|
|
|
|
style={{ marginBottom: '12px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
message="批量添加提示"
|
|
|
|
|
|
description="每行输入一个SN序列号,系统会自动过滤重复项"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: '12px', borderRadius: designTokens.borderRadius.md }}
|
|
|
|
|
|
/>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
<TextArea
|
2026-03-10 11:10:04 +08:00
|
|
|
|
rows={4}
|
|
|
|
|
|
placeholder="输入SN序列号,每行一个 例如: SN001 SN002 SN003"
|
2026-03-05 09:57:44 +08:00
|
|
|
|
value={snInputValue}
|
|
|
|
|
|
onChange={e => setSnInputValue(e.target.value)}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
style={{ ...textAreaStyles.base, marginBottom: '8px' }}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
/>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<Space>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
const newSns = snInputValue
|
|
|
|
|
|
.split('\n')
|
|
|
|
|
|
.map(s => s.trim())
|
|
|
|
|
|
.filter(s => s && !snList.includes(s));
|
|
|
|
|
|
if (newSns.length > 0) {
|
|
|
|
|
|
setSnList([...snList, ...newSns]);
|
|
|
|
|
|
setSnInputValue('');
|
|
|
|
|
|
message.success(`成功添加 ${newSns.length} 个SN`);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
message.warning('没有新的SN可添加(可能已存在或为空)');
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: designTokens.colors.success.gradient,
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
确认添加
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
onClick={() => {
|
2026-03-05 09:57:44 +08:00
|
|
|
|
setSnInputValue('');
|
2026-03-10 11:10:04 +08:00
|
|
|
|
setSnInputVisible(false);
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
|
|
|
|
>
|
|
|
|
|
|
取消
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</motion.div>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
)}
|
|
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{/* SN列表展示 */}
|
|
|
|
|
|
{snList.length > 0 ? (
|
2026-03-05 09:57:44 +08:00
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
2026-03-10 11:10:04 +08:00
|
|
|
|
maxHeight: '200px',
|
2026-03-05 09:57:44 +08:00
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
2026-03-10 11:10:04 +08:00
|
|
|
|
borderRadius: designTokens.borderRadius.md,
|
|
|
|
|
|
padding: '12px',
|
|
|
|
|
|
background: '#fff',
|
2026-03-05 09:57:44 +08:00
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<AnimatePresence>
|
|
|
|
|
|
{snList.map((sn, index) => (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
key={sn}
|
|
|
|
|
|
initial={{ opacity: 0, scale: 0.8 }}
|
|
|
|
|
|
animate={{ opacity: 1, scale: 1 }}
|
|
|
|
|
|
exit={{ opacity: 0, scale: 0.8 }}
|
|
|
|
|
|
transition={{ duration: 0.2 }}
|
|
|
|
|
|
style={{ display: 'inline-block', marginBottom: '8px', marginRight: '8px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
closable
|
|
|
|
|
|
onClose={() => setSnList(snList.filter((_, i) => i !== index))}
|
|
|
|
|
|
color="blue"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
padding: '4px 8px',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
fontSize: '13px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sn}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</AnimatePresence>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div style={{
|
|
|
|
|
|
textAlign: 'center',
|
|
|
|
|
|
padding: '24px',
|
|
|
|
|
|
color: designTokens.colors.neutral[400],
|
|
|
|
|
|
background: '#fff',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.md,
|
|
|
|
|
|
border: `1px dashed ${designTokens.colors.neutral[300]}`,
|
|
|
|
|
|
}}>
|
|
|
|
|
|
<BarcodeOutlined style={{ fontSize: '32px', marginBottom: '8px', display: 'block' }} />
|
|
|
|
|
|
<div>暂无SN序列号</div>
|
|
|
|
|
|
<div style={{ fontSize: '12px', marginTop: '4px' }}>点击上方按钮添加</div>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
</div>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
|
2026-03-10 11:10:04 +08:00
|
|
|
|
{/* 操作按钮 */}
|
|
|
|
|
|
<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>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form>
|
2026-03-10 14:34:39 +08:00
|
|
|
|
</div>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Modal>
|
|
|
|
|
|
|
2026-02-12 16:29:19 +08:00
|
|
|
|
{/* 导入耗材弹窗 */}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Modal
|
2026-02-12 16:29:19 +08:00
|
|
|
|
title={
|
|
|
|
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '12px' }}>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '36px',
|
|
|
|
|
|
height: '36px',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.md,
|
|
|
|
|
|
background: designTokens.colors.info.main,
|
|
|
|
|
|
display: 'flex',
|
|
|
|
|
|
alignItems: 'center',
|
|
|
|
|
|
justifyContent: 'center',
|
|
|
|
|
|
color: '#fff',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<ImportOutlined />
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<span style={{ fontSize: '18px', fontWeight: 600 }}>批量导入耗材</span>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
open={importModalVisible}
|
2026-03-12 12:40:12 +08:00
|
|
|
|
closeIcon={<CloseButton />}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
onCancel={handleImportCancel}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={700}
|
|
|
|
|
|
>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<div style={{ padding: '16px 0' }}>
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
message="请下载模板文件,按要求填写数据后上传导入"
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: '16px', borderRadius: designTokens.borderRadius.md }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<Card
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: designTokens.colors.neutral[50],
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.md,
|
|
|
|
|
|
marginBottom: '16px',
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Space>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
icon={<FileExcelOutlined />}
|
|
|
|
|
|
onClick={downloadTemplate}
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
|
|
|
|
>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
下载模板
|
|
|
|
|
|
</Button>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Text type="secondary" style={{ fontSize: '13px' }}>
|
|
|
|
|
|
请下载模板后填写数据再导入
|
|
|
|
|
|
</Text>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Space>
|
|
|
|
|
|
</Card>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Upload
|
|
|
|
|
|
accept=".csv"
|
|
|
|
|
|
maxCount={1}
|
|
|
|
|
|
beforeUpload={() => false}
|
|
|
|
|
|
onChange={handleFileChange}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Button
|
|
|
|
|
|
icon={<UploadOutlined />}
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
|
|
|
|
>
|
|
|
|
|
|
选择CSV文件
|
|
|
|
|
|
</Button>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Upload>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2025-12-24 16:44:27 +08:00
|
|
|
|
{importPreview.length > 0 && (
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0, y: 10 }}
|
|
|
|
|
|
animate={{ opacity: 1, y: 0 }}
|
|
|
|
|
|
transition={{ duration: 0.3 }}
|
|
|
|
|
|
style={{ marginTop: '24px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ marginBottom: '8px', fontWeight: 500, color: designTokens.colors.neutral[700] }}>
|
|
|
|
|
|
数据预览(前10条)
|
|
|
|
|
|
</div>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Table
|
|
|
|
|
|
columns={previewColumns}
|
|
|
|
|
|
dataSource={importPreview}
|
|
|
|
|
|
rowKey={(record, index) => index}
|
|
|
|
|
|
pagination={false}
|
|
|
|
|
|
size="small"
|
|
|
|
|
|
scroll={{ x: 500 }}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.md }}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
/>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
</motion.div>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
)}
|
2026-02-10 11:04:01 +08:00
|
|
|
|
|
2026-02-12 16:29:19 +08:00
|
|
|
|
{importing && (
|
|
|
|
|
|
<motion.div
|
|
|
|
|
|
initial={{ opacity: 0 }}
|
|
|
|
|
|
animate={{ opacity: 1 }}
|
|
|
|
|
|
style={{ marginTop: '24px', padding: '24px', textAlign: 'center' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
<Progress
|
|
|
|
|
|
percent={importProgress}
|
|
|
|
|
|
status={importProgress === 100 ? 'success' : 'active'}
|
|
|
|
|
|
strokeColor={{
|
|
|
|
|
|
'0%': designTokens.colors.primary.main,
|
|
|
|
|
|
'100%': designTokens.colors.success.main,
|
|
|
|
|
|
}}
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.sm }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
<div style={{ marginTop: '16px', color: designTokens.colors.neutral[600] }}>
|
|
|
|
|
|
{importPhase}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</motion.div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ textAlign: 'right', marginTop: '24px' }}>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Space>
|
|
|
|
|
|
<Button onClick={handleImportCancel}>取消</Button>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
onClick={handleImport}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
loading={importing}
|
|
|
|
|
|
disabled={!importFile}
|
2026-02-12 16:29:19 +08:00
|
|
|
|
style={{
|
|
|
|
|
|
background: designTokens.colors.primary.gradient,
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
}}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
>
|
|
|
|
|
|
开始导入
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</div>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
</div>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Modal>
|
|
|
|
|
|
|
2026-02-12 16:29:19 +08:00
|
|
|
|
{/* 入库/出库弹窗 */}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Modal
|
2026-02-12 16:29:19 +08:00
|
|
|
|
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>
|
|
|
|
|
|
}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
open={stockModalVisible}
|
2026-03-12 12:40:12 +08:00
|
|
|
|
closeIcon={<CloseButton />}
|
2025-12-24 16:44:27 +08:00
|
|
|
|
onCancel={handleStockCancel}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={500}
|
|
|
|
|
|
>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Form form={stockForm} layout="vertical" onFinish={handleStockSubmit} style={{ marginTop: '16px' }}>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Form.Item name="consumableId" label="耗材ID">
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<Input disabled style={{ ...inputStyles.form, ...inputStyles.disabled }} />
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="consumableName" label="耗材名称">
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<Input disabled style={{ ...inputStyles.form, ...inputStyles.disabled }} />
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form.Item>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
|
|
|
|
|
|
{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)}
|
2026-03-05 10:41:49 +08:00
|
|
|
|
style={{ ...inputStyles.search, marginBottom: '8px' }}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
/>
|
|
|
|
|
|
<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'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{(() => {
|
2026-03-13 15:13:58 +08:00
|
|
|
|
const snListArray = Array.isArray(stockRecord.snList) ? stockRecord.snList : [];
|
|
|
|
|
|
const filteredSnList = snListArray.filter(sn =>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
sn.toLowerCase().includes(snSearchKeyword.toLowerCase())
|
|
|
|
|
|
);
|
|
|
|
|
|
if (filteredSnList.length === 0) {
|
2026-03-13 15:13:58 +08:00
|
|
|
|
return <Text type="secondary" style={{ display: 'block', textAlign: 'center', padding: '16px 0' }}>无匹配的 SN</Text>;
|
2026-03-05 09:57:44 +08:00
|
|
|
|
}
|
|
|
|
|
|
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"
|
2026-03-05 10:41:49 +08:00
|
|
|
|
style={selectStyles.base}
|
|
|
|
|
|
placeholder={inputPlaceholders.snList}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
tokenSeparators={[',', '\n']}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
2026-02-10 11:04:01 +08:00
|
|
|
|
<Form.Item
|
|
|
|
|
|
name="quantity"
|
|
|
|
|
|
label="数量"
|
2026-03-05 10:41:49 +08:00
|
|
|
|
rules={[inputValidationRules.required('请输入数量')]}
|
2026-02-10 11:04:01 +08:00
|
|
|
|
>
|
2026-03-05 09:57:44 +08:00
|
|
|
|
<InputNumber
|
|
|
|
|
|
min={1}
|
|
|
|
|
|
max={stockType === 'out' && stockRecord?.snList?.length > 0 ? stockRecord.snList.length : undefined}
|
2026-03-05 10:41:49 +08:00
|
|
|
|
style={inputNumberStyles.base}
|
2026-03-05 09:57:44 +08:00
|
|
|
|
placeholder="请输入数量"
|
|
|
|
|
|
/>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="operator" label="操作人">
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<Input placeholder={inputPlaceholders.operator} style={inputStyles.form} />
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form.Item>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Form.Item name="reason" label={stockType === 'in' ? '入库原因' : '出库原因'}>
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<Input placeholder={stockType === 'in' ? '如: 采购入库' : '如: 部门领用、报损出库'} style={inputStyles.form} />
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item name="notes" label="备注">
|
2026-03-05 10:41:49 +08:00
|
|
|
|
<TextArea rows={2} placeholder={inputPlaceholders.notes} style={textAreaStyles.base} />
|
2025-12-24 16:44:27 +08:00
|
|
|
|
</Form.Item>
|
|
|
|
|
|
<Form.Item>
|
|
|
|
|
|
<Space>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
htmlType="submit"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
background: stockType === 'in' ? designTokens.colors.success.gradient : designTokens.colors.error.gradient,
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-02-10 11:04:01 +08:00
|
|
|
|
{stockType === 'in' ? '确认入库' : '确认出库'}
|
|
|
|
|
|
</Button>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
<Button onClick={handleStockCancel}>取消</Button>
|
|
|
|
|
|
</Space>
|
|
|
|
|
|
</Form.Item>
|
|
|
|
|
|
</Form>
|
|
|
|
|
|
</Modal>
|
2026-03-10 11:10:04 +08:00
|
|
|
|
|
|
|
|
|
|
{/* 扫码弹窗 */}
|
|
|
|
|
|
<Modal
|
|
|
|
|
|
title={scanMode === 'add' ? '扫码添加SN' : scanMode === 'in' ? '扫码入库' : '扫码出库'}
|
|
|
|
|
|
open={scanModalVisible}
|
2026-03-12 12:40:12 +08:00
|
|
|
|
closeIcon={<CloseButton />}
|
2026-03-10 11:10:04 +08:00
|
|
|
|
onCancel={handleScanCancel}
|
|
|
|
|
|
footer={null}
|
|
|
|
|
|
width={500}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div style={{ padding: '16px 0' }}>
|
|
|
|
|
|
<Alert
|
|
|
|
|
|
message={
|
|
|
|
|
|
scanMode === 'add'
|
|
|
|
|
|
? '扫描SN序列号,自动添加到表单中'
|
|
|
|
|
|
: '请使用扫码枪扫描SN序列号'
|
|
|
|
|
|
}
|
|
|
|
|
|
type="info"
|
|
|
|
|
|
showIcon
|
|
|
|
|
|
style={{ marginBottom: '16px', borderRadius: designTokens.borderRadius.md }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
|
|
|
|
<Input
|
|
|
|
|
|
ref={scanInputRef}
|
|
|
|
|
|
value={scanValue}
|
|
|
|
|
|
onChange={e => setScanValue(e.target.value)}
|
|
|
|
|
|
onKeyDown={handleScanKeyDown}
|
|
|
|
|
|
placeholder={scanMode === 'add' ? '扫描SN后自动添加...' : '扫描SN后自动识别...'}
|
|
|
|
|
|
prefix={<BarcodeOutlined style={{ color: designTokens.colors.neutral[400] }} />}
|
|
|
|
|
|
size="large"
|
|
|
|
|
|
autoFocus
|
|
|
|
|
|
disabled={scanChecking}
|
|
|
|
|
|
style={{ borderRadius: designTokens.borderRadius.md }}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
{scanMode === 'add' && snList.length > 0 && (
|
|
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
|
|
|
|
<div style={{ marginBottom: '8px', fontWeight: 500, color: designTokens.colors.neutral[700] }}>
|
|
|
|
|
|
已添加SN列表 ({snList.length}个)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
maxHeight: '150px',
|
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
padding: '8px'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
2026-03-13 15:13:58 +08:00
|
|
|
|
{(Array.isArray(snList) ? snList : []).map((sn, index) => (
|
2026-03-10 11:10:04 +08:00
|
|
|
|
<Tag
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
closable
|
|
|
|
|
|
onClose={() => setSnList(prev => prev.filter((_, i) => i !== index))}
|
|
|
|
|
|
color="blue"
|
|
|
|
|
|
style={{ marginBottom: '4px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sn}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
{scanMode === 'in' && scannedSnList.length > 0 && (
|
|
|
|
|
|
<div style={{ marginBottom: '16px' }}>
|
|
|
|
|
|
<div style={{ marginBottom: '8px', fontWeight: 500, color: designTokens.colors.neutral[700] }}>
|
|
|
|
|
|
已扫描SN列表 ({scannedSnList.length}个)
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<div
|
|
|
|
|
|
style={{
|
|
|
|
|
|
maxHeight: '150px',
|
|
|
|
|
|
overflowY: 'auto',
|
|
|
|
|
|
border: `1px solid ${designTokens.colors.neutral[200]}`,
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
padding: '8px'
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{scannedSnList.map((sn, index) => (
|
|
|
|
|
|
<Tag
|
|
|
|
|
|
key={index}
|
|
|
|
|
|
closable
|
|
|
|
|
|
onClose={() => setScannedSnList(prev => prev.filter((_, i) => i !== index))}
|
|
|
|
|
|
color="blue"
|
|
|
|
|
|
style={{ marginBottom: '4px' }}
|
|
|
|
|
|
>
|
|
|
|
|
|
{sn}
|
|
|
|
|
|
</Tag>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<div style={{ marginTop: '16px' }}>
|
|
|
|
|
|
<div style={{ marginBottom: '8px', fontWeight: 500, color: designTokens.colors.neutral[700] }}>
|
|
|
|
|
|
选择入库耗材
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<Select
|
|
|
|
|
|
placeholder="选择要入库的耗材"
|
|
|
|
|
|
style={{ width: '100%' }}
|
|
|
|
|
|
showSearch
|
|
|
|
|
|
optionFilterProp="children"
|
|
|
|
|
|
filterOption={(input, option) =>
|
|
|
|
|
|
option.children.toLowerCase().indexOf(input.toLowerCase()) >= 0
|
|
|
|
|
|
}
|
|
|
|
|
|
value={selectedScanInConsumable}
|
|
|
|
|
|
onChange={(value) => setSelectedScanInConsumable(value)}
|
|
|
|
|
|
>
|
|
|
|
|
|
{consumables.map(item => (
|
|
|
|
|
|
<Option key={item.consumableId} value={item.consumableId}>
|
|
|
|
|
|
{item.name} ({item.category}) - 库存: {item.currentStock}
|
|
|
|
|
|
</Option>
|
|
|
|
|
|
))}
|
|
|
|
|
|
</Select>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
|
|
<Button
|
|
|
|
|
|
type="primary"
|
|
|
|
|
|
style={{
|
|
|
|
|
|
width: '100%',
|
|
|
|
|
|
marginTop: '16px',
|
|
|
|
|
|
background: designTokens.colors.success.gradient,
|
|
|
|
|
|
border: 'none',
|
|
|
|
|
|
borderRadius: designTokens.borderRadius.sm,
|
|
|
|
|
|
}}
|
|
|
|
|
|
onClick={() => {
|
|
|
|
|
|
if (!selectedScanInConsumable) {
|
|
|
|
|
|
message.warning('请选择要入库的耗材');
|
|
|
|
|
|
return;
|
|
|
|
|
|
}
|
|
|
|
|
|
handleScanInSubmit(selectedScanInConsumable);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
确认入库 {scannedSnList.length} 个SN
|
|
|
|
|
|
</Button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
|
|
|
|
|
|
<Text type="secondary" style={{ fontSize: '12px' }}>
|
|
|
|
|
|
💡 提示:也可手动输入条码后按回车键确认
|
|
|
|
|
|
</Text>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</Modal>
|
2026-02-12 16:29:19 +08:00
|
|
|
|
</motion.div>
|
2025-12-24 16:44:27 +08:00
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2025-12-26 11:03:47 +08:00
|
|
|
|
export default React.memo(ConsumableManagement);
|