From 618633bc6a402fc94fc95a7be353cd8ae649e57b Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Fri, 10 Apr 2026 12:02:06 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E8=80=97=E6=9D=90):=20=E5=A2=9E=E5=BC=BA?= =?UTF-8?q?=E8=80=97=E6=9D=90=E6=97=A5=E5=BF=97=E5=90=8D=E7=A7=B0=E5=90=8C?= =?UTF-8?q?=E6=AD=A5=E5=92=8C=E5=AF=BC=E5=85=A5=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/models/ConsumableLog.js | 5 + backend/routes/consumables.js | 254 ++- backend/routes/tickets.js | 1 + backend/scripts/migrate-all.js | 17 + frontend/src/pages/ConsumableLogs.jsx | 62 +- frontend/src/pages/ConsumableManagement.jsx | 1539 +++++++++++++------ 6 files changed, 1330 insertions(+), 548 deletions(-) diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js index cd39157..3f83d57 100644 --- a/backend/models/ConsumableLog.js +++ b/backend/models/ConsumableLog.js @@ -126,6 +126,11 @@ const ConsumableLog = sequelize.define( allowNull: true, comment: '机房名称', }, + lastNameSyncAt: { + type: DataTypes.DATE, + allowNull: true, + comment: '名称最后同步时间(名称变更时更新)', + }, }, { tableName: 'consumable_logs', diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js index 1538af0..5aa5c2e 100644 --- a/backend/routes/consumables.js +++ b/backend/routes/consumables.js @@ -71,15 +71,22 @@ const MAX_EXPORT_SIZE = 50000; router.get('/export', async (req, res) => { try { - const { keyword, category, status } = req.query; + const { + keyword, + category, + status, + stockStatus, // 新增:warning/normal/all + ids, // 新增:耗材ID列表,逗号分隔 + fields, // 新增:要导出的字段列表,逗号分隔 + } = req.query; + // 构建查询条件 const where = {}; if (keyword) { where[Op.or] = [ - { consumableId: { [Op.like]: `%${keyword}%` } }, { name: { [Op.like]: `%${keyword}%` } }, - { category: { [Op.like]: `%${keyword}%` } }, + { consumableId: { [Op.like]: `%${keyword}%` } }, { supplier: { [Op.like]: `%${keyword}%` } }, { location: { [Op.like]: `%${keyword}%` } }, ]; @@ -93,14 +100,70 @@ router.get('/export', async (req, res) => { where.status = status; } + // 支持导出选中项 + if (ids) { + const idList = ids.split(',').map(id => id.trim()).filter(Boolean); + if (idList.length > 0) { + where.consumableId = { [Op.in]: idList }; + } + } + + // 支持导出预警库存 + if (stockStatus === 'warning') { + where[Op.and] = [ + { + [Op.or]: [ + { currentStock: { [Op.lte]: sequelize.col('minStock') } }, + { + [Op.and]: [ + { maxStock: { [Op.gt]: 0 } }, + { currentStock: { [Op.gte]: sequelize.col('maxStock') } }, + ], + }, + ], + }, + ]; + } else if (stockStatus === 'normal') { + where[Op.and] = [ + { currentStock: { [Op.gt]: sequelize.col('minStock') } }, + { + [Op.or]: [ + { maxStock: { [Op.eq]: 0 } }, + { currentStock: { [Op.lt]: sequelize.col('maxStock') } }, + ], + }, + ]; + } + const consumables = await Consumable.findAll({ where, limit: MAX_EXPORT_SIZE, order: [['createdAt', 'DESC']], }); - const result = consumables.map(item => { - const data = item.toJSON(); + // 处理导出字段 + let exportData = consumables; + if (fields) { + const fieldList = fields.split(',').map(f => f.trim()).filter(Boolean); + if (fieldList.length > 0) { + exportData = consumables.map(c => { + const obj = {}; + fieldList.forEach(field => { + if (c[field] !== undefined) { + obj[field] = c[field]; + } + }); + // 始终保留名称 + if (!obj.name && c.name) { + obj.name = c.name; + } + return obj; + }); + } + } + + const result = exportData.map(item => { + const data = item.toJSON ? item.toJSON() : item; if (!Array.isArray(data.snList)) { data.snList = []; } @@ -112,6 +175,7 @@ router.get('/export', async (req, res) => { total: result.length, }); } catch (error) { + console.error('导出失败:', error); res.status(500).json({ error: error.message }); } }); @@ -294,7 +358,7 @@ router.post('/create-with-inbound', async (req, res) => { router.post('/import', async (req, res) => { const transaction = await sequelize.transaction(); try { - const { items, operator = '系统', mode = 'create' } = req.body; + const { items, operator = '系统', mode = 'create', stockMode = 'basic' } = req.body; if (!items || !Array.isArray(items) || items.length === 0) { await transaction.rollback(); @@ -327,7 +391,7 @@ router.post('/import', async (req, res) => { } let snList = []; - if (item.SN序列号 || item.snList) { + if (stockMode !== 'basic' && (item.SN序列号 || item.snList)) { const snStr = item.SN序列号 || item.snList; if (typeof snStr === 'string') { snList = snStr @@ -344,8 +408,10 @@ router.post('/import', async (req, res) => { name, category, unit: item.单位 || item.unit || '个', - currentStock: - snList.length > 0 ? snList.length : parseInt(item.当前库存 || item.currentStock) || 0, + // 根据 stockMode 决定库存处理方式 + currentStock: stockMode === 'basic' + ? 0 // basic模式:强制为0 + : (snList.length > 0 ? snList.length : parseInt(item.当前库存 || item.currentStock) || 0), minStock: parseInt(item.最小库存 || item.minStock) || 10, maxStock: parseInt(item.最大库存 || item.maxStock) || 0, unitPrice: parseFloat(item.单价 || item.unitPrice) || 0, @@ -353,7 +419,7 @@ router.post('/import', async (req, res) => { location: item.存放位置 || item.location || '', description: item.描述 || item.description || '', status: item.状态 || item.status || 'active', - snList, + snList: stockMode === 'basic' ? [] : snList, // basic模式:强制为空数组 }; let existingConsumable = null; @@ -400,17 +466,113 @@ router.post('/import', async (req, res) => { }); } + // 如果是 inbound 模式且是新创建的耗材,执行入库操作 + if (stockMode === 'inbound' && !existingConsumable) { + // 新建模式下的入库 + const inboundQuantity = parseInt(item.入库数量 || item.inboundQuantity) || consumable.currentStock; + const inboundSnList = consumable.snList; // 使用处理后的SN列表 + + // 更新耗材库存 + await consumable.update({ + currentStock: inboundQuantity, + snList: inboundSnList, + version: sequelize.literal('version + 1'), + }, { transaction }); + + // 创建入库记录 + await ConsumableRecord.create({ + consumableId: consumable.consumableId, + type: 'in', + quantity: inboundQuantity, + previousStock: 0, + currentStock: inboundQuantity, + operator: item.操作人 || operator, + reason: item.原因 || '批量导入入库', + notes: '', + snList: inboundSnList, + }, { transaction }); + + // 创建入库日志 + await ConsumableLog.create({ + consumableId: consumable.consumableId, + consumableName: consumable.name, + operationType: 'in', + quantity: inboundQuantity, + previousStock: 0, + currentStock: inboundQuantity, + operator: item.操作人 || operator, + reason: item.原因 || '批量导入入库', + notes: '', + snList: inboundSnList, + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location, + }, + }, { transaction }); + } + + // 对于 update 模式下的 inbound,计算库存差异 + if (stockMode === 'inbound' && existingConsumable && mode === 'update') { + const inboundQuantity = parseInt(item.入库数量 || item.inboundQuantity) || 0; + if (inboundQuantity > 0) { + const prevStock = existingConsumable.currentStock; + const newStock = prevStock + inboundQuantity; + const inboundSnList = consumable.snList || []; + + await consumable.update({ + currentStock: newStock, + snList: inboundSnList, + version: sequelize.literal('version + 1'), + }, { transaction }); + + await ConsumableRecord.create({ + consumableId: consumable.consumableId, + type: 'in', + quantity: inboundQuantity, + previousStock: prevStock, + currentStock: newStock, + operator: item.操作人 || operator, + reason: item.原因 || '批量导入入库', + notes: '', + snList: inboundSnList, + }, { transaction }); + + await ConsumableLog.create({ + consumableId: consumable.consumableId, + consumableName: consumable.name, + operationType: 'in', + quantity: inboundQuantity, + previousStock: prevStock, + currentStock: newStock, + operator: item.操作人 || operator, + reason: item.原因 || '批量导入入库', + notes: '', + snList: inboundSnList, + consumableSnapshot: { + category: consumable.category, + unit: consumable.unit, + unitPrice: consumable.unitPrice, + supplier: consumable.supplier, + location: consumable.location, + }, + }, { transaction }); + } + } + await ConsumableLog.create( { consumableId: consumable.consumableId, consumableName: consumable.name, - operationType, - quantity: consumable.currentStock, - previousStock, - currentStock: consumable.currentStock, + operationType: stockMode === 'inbound' && !existingConsumable ? 'import' : 'import_update', + quantity: stockMode === 'basic' ? 0 : consumable.currentStock, + previousStock: stockMode === 'basic' ? 0 : previousStock, + currentStock: stockMode === 'basic' ? 0 : consumable.currentStock, operator, reason: '批量导入', - notes: existingConsumable ? '更新现有耗材' : '', + notes: existingConsumable ? '更新现有耗材' : (stockMode === 'inbound' ? '导入并入库' : ''), consumableSnapshot: { category: consumable.category, unit: consumable.unit, @@ -444,19 +606,18 @@ router.post('/import', async (req, res) => { router.get('/by-sn/:sn', async (req, res) => { try { const sn = req.params.sn; - // 使用数据库 LIKE 查询替代全表扫描 + + // 获取所有有 snList 的耗材 const consumables = await Consumable.findAll({ - where: { - snList: { - [Op.like]: `%${sn}%`, - }, - }, + attributes: ['consumableId', 'name', 'category', 'currentStock', 'unit', 'snList', 'location', 'status'], }); + // 精确匹配 SN(JSON 数组中的元素) const consumable = consumables.find(c => { const snList = Array.isArray(c.snList) ? c.snList : []; return snList.includes(sn); }); + const result = consumable ? consumable.toJSON() : null; if (result && !Array.isArray(result.snList)) { result.snList = []; @@ -1077,9 +1238,30 @@ router.get('/logs', async (req, res) => { order: [['createdAt', 'DESC']], }); + const consumableIds = [...new Set(rows.map(log => log.consumableId).filter(Boolean))]; + const consumables = await Consumable.findAll({ + where: { consumableId: { [Op.in]: consumableIds } }, + attributes: ['consumableId', 'name', 'status'], + }); + const consumableMap = Object.fromEntries(consumables.map(c => [c.consumableId, c])); + + const logsWithCurrentName = rows.map(log => { + const logData = log.toJSON(); + const relatedConsumable = consumableMap[log.consumableId]; + logData.currentConsumableName = relatedConsumable ? relatedConsumable.name : logData.consumableName; + if (relatedConsumable) { + logData.consumable = { + consumableId: relatedConsumable.consumableId, + name: relatedConsumable.name, + status: relatedConsumable.status, + }; + } + return logData; + }); + res.json({ total: count, - logs: rows, + logs: logsWithCurrentName, page: parseInt(page), pageSize: parseInt(pageSize), }); @@ -1117,8 +1299,15 @@ router.get('/logs/export', async (req, res) => { order: [['createdAt', 'DESC']], }); + const consumableIds = [...new Set(logs.map(log => log.consumableId).filter(Boolean))]; + const consumables = await Consumable.findAll({ + where: { consumableId: { [Op.in]: consumableIds } }, + attributes: ['consumableId', 'name', 'status'], + }); + const consumableMap = Object.fromEntries(consumables.map(c => [c.consumableId, c])); + const csvHeader = - 'ID,耗材ID,耗材名称,操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,耗材状态,分类,单位,单价,创建时间,更新时间\n'; + 'ID,耗材ID,耗材名称(历史),耗材名称(当前),操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,耗材状态,分类,单位,单价,创建时间,更新时间\n'; const csvRows = logs .map(log => { const operationTypeMap = { @@ -1131,10 +1320,13 @@ router.get('/logs/export', async (req, res) => { import: '导入', }; const snapshot = log.consumableSnapshot || {}; + const relatedConsumable = consumableMap[log.consumableId]; + const currentConsumableName = relatedConsumable ? relatedConsumable.name : log.consumableName; return [ log.id, log.consumableId, log.consumableName, + currentConsumableName, operationTypeMap[log.operationType] || log.operationType, log.quantity, log.previousStock, @@ -1392,12 +1584,26 @@ router.put('/:id', async (req, res) => { } const oldData = consumable.toJSON(); + const oldName = oldData.name; const updateData = { ...req.body }; - // 禁止通过编辑接口修改库存和SN列表 delete updateData.currentStock; delete updateData.snList; await consumable.update(updateData, { transaction }); + const newName = consumable.name; + if (oldName !== newName) { + await ConsumableLog.update( + { + consumableName: newName, + lastNameSyncAt: new Date(), + }, + { + where: { consumableId: consumable.consumableId }, + transaction, + } + ); + } + await ConsumableLog.create( { consumableId: consumable.consumableId, diff --git a/backend/routes/tickets.js b/backend/routes/tickets.js index a27abd7..fd0eb5d 100644 --- a/backend/routes/tickets.js +++ b/backend/routes/tickets.js @@ -2,6 +2,7 @@ const express = require('express'); const router = express.Router(); const { Op } = require('sequelize'); const { v4: uuidv4 } = require('uuid'); +const { generateId } = require('../utils/idGenerator'); const { Ticket, TicketOperationRecord } = require('../models/ticketIndex'); const Device = require('../models/Device'); const User = require('../models/User'); diff --git a/backend/scripts/migrate-all.js b/backend/scripts/migrate-all.js index 938d2c9..3d8abf1 100644 --- a/backend/scripts/migrate-all.js +++ b/backend/scripts/migrate-all.js @@ -113,6 +113,11 @@ const migrations = [ '为 consumable_logs 表添加 deviceId、deviceName、rackId、rackName、roomId、roomName 字段', migrate: migrateConsumableLogDeviceAssociation, }, + { + name: '耗材日志名称同步', + description: '为 consumable_logs 表添加 lastNameSyncAt 字段,支持名称同步', + migrate: migrateConsumableLogNameSync, + }, ]; async function runMigrations() { @@ -845,6 +850,18 @@ async function migrateConsumableLogDeviceAssociation() { console.log(' 耗材日志设备关联迁移完成'); } +async function migrateConsumableLogNameSync() { + const tableName = 'consumable_logs'; + + if (!(await tableExists(tableName))) { + console.log(` ${tableName} 表不存在,跳过`); + return; + } + + await addColumnIfNotExists(tableName, 'lastNameSyncAt', 'DATETIME'); + console.log(' 耗材日志名称同步迁移完成'); +} + // 执行迁移 runMigrations().catch(error => { console.error('迁移执行失败:', error); diff --git a/frontend/src/pages/ConsumableLogs.jsx b/frontend/src/pages/ConsumableLogs.jsx index 0ee9026..d2dbf2c 100644 --- a/frontend/src/pages/ConsumableLogs.jsx +++ b/frontend/src/pages/ConsumableLogs.jsx @@ -163,31 +163,46 @@ function ConsumableLogs() { title: '耗材名称', dataIndex: 'consumableName', key: 'consumableName', - width: 180, - render: (value, record) => ( - { + const currentName = record.currentConsumableName || value; + const nameChanged = currentName !== value && value; + return ( + -
分类: {record.consumableSnapshot.category || '-'}
-
单位: {record.consumableSnapshot.unit || '-'}
-
单价: {record.consumableSnapshot.unitPrice || '-'}
-
供应商: {record.consumableSnapshot.supplier || '-'}
-
位置: {record.consumableSnapshot.location || '-'}
+ {record.consumableSnapshot && ( +
+
分类: {record.consumableSnapshot.category || '-'}
+
单位: {record.consumableSnapshot.unit || '-'}
+
单价: {record.consumableSnapshot.unitPrice || '-'}
+
供应商: {record.consumableSnapshot.supplier || '-'}
+
位置: {record.consumableSnapshot.location || '-'}
+
+ )} + {nameChanged && ( +
+ 曾用名: {value} +
+ )} - ) : null - } - > - - {value} - {record.isConsumableDeleted && ( - - 已删除 - - )} - -
- ), + } + > + + {currentName} + {record.isConsumableDeleted ? ( + + 已删除 + + ) : nameChanged ? ( + + 已更名 + + ) : null} + +
+ ); + }, }, { title: '操作类型', @@ -417,6 +432,7 @@ function ConsumableLogs() { 时间: dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'), 耗材ID: log.consumableId, 耗材名称: log.consumableName, + 当前名称: log.currentConsumableName || log.consumableName, 操作类型: getOperationTypeText(log.operationType), 变动数量: log.quantity, 操作前库存: log.previousStock, diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx index 1ddac7b..66821ce 100644 --- a/frontend/src/pages/ConsumableManagement.jsx +++ b/frontend/src/pages/ConsumableManagement.jsx @@ -31,6 +31,8 @@ import { Avatar, Statistic, Timeline, + Dropdown, + Menu, } from 'antd'; import { PlusOutlined, @@ -59,6 +61,10 @@ import { DesktopOutlined, HistoryOutlined, EyeOutlined, + CloseCircleOutlined, + DownOutlined, + CheckSquareOutlined, + SettingOutlined, } from '@ant-design/icons'; import axios from 'axios'; import * as XLSX from 'xlsx'; @@ -131,6 +137,7 @@ function ConsumableManagement() { 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); @@ -185,6 +192,30 @@ function ConsumableManagement() { 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 () => { @@ -429,15 +460,34 @@ function ConsumableManagement() { const handleExport = async () => { try { - const response = await axios.get('/api/consumables/export', { - params: { keyword, category, status }, - }); + // 根据导出模式准备参数 + 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: , }); + setExportModalVisible(false); } catch (error) { message.error('导出失败'); console.error('导出失败:', error); @@ -727,6 +777,7 @@ function ConsumableManagement() { const response = await axios.post('/api/consumables/import', { items: importPreview, mode: importMode, + stockMode: importStockMode, }); setImportProgress(70); @@ -758,26 +809,33 @@ function ConsumableManagement() { } }; - const downloadTemplate = () => { - const template = [ - { - 耗材ID: 'CON001', - 名称: '示例耗材-网络模块', - 分类: '光模块', - 单位: '个', - 当前库存: 100, - 最小库存: 10, - 最大库存: 500, - 单价: 5.0, - 供应商: 'XX公司', - 存放位置: 'A柜-01层', - 描述: '测试数据', - SN序列号: 'SN001,SN002,SN003', - 状态: 'active', - }, - ]; + const downloadTemplate = (stockMode = 'basic') => { + // 根据 stockMode 生成不同的模板数据 + const basicTemplate = { + 耗材ID: 'CON001', + 名称: '示例耗材-网络模块', + 分类: '光模块', + 单位: '个', + 最小库存: 10, + 最大库存: 500, + 单价: 5.0, + 供应商: 'XX公司', + 存放位置: 'A柜-01层', + 描述: '测试数据', + 状态: 'active', + }; - const fieldDescription = [ + const inboundTemplate = { + ...basicTemplate, + 入库数量: 100, + 操作人: '系统', + 原因: '批量导入入库', + SN序列号: 'SN001,SN002,SN003', + }; + + const templateData = stockMode === 'basic' ? basicTemplate : inboundTemplate; + + const basicFieldDescription = [ { 字段名: '耗材ID', 系统字段: 'consumableId', @@ -806,13 +864,6 @@ function ConsumableManagement() { 说明: '计量单位,如"个"、"根"、"箱",默认"个"', 示例: '个', }, - { - 字段名: '当前库存', - 系统字段: 'currentStock', - 必填: '否', - 说明: '当前库存数量,数字类型', - 示例: '100', - }, { 字段名: '最小库存', 系统字段: 'minStock', @@ -855,13 +906,6 @@ function ConsumableManagement() { 说明: '耗材的详细描述或备注', 示例: '这是一条测试数据', }, - { - 字段名: 'SN序列号', - 系统字段: 'snList', - 必填: '否', - 说明: '多个SN用逗号、分号或换行分隔,如"SN001,SN002"或"SN001\\nSN002"', - 示例: 'SN001,SN002,SN003', - }, { 字段名: '状态', 系统字段: 'status', @@ -871,6 +915,41 @@ function ConsumableManagement() { }, ]; + 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); @@ -1789,14 +1868,66 @@ function ConsumableManagement() { > 批量导入 - + + @@ -1866,6 +1997,10 @@ function ConsumableManagement() { dataSource={consumables} rowKey="consumableId" loading={loading} + rowSelection={{ + selectedRowKeys, + onChange: setSelectedRowKeys, + }} pagination={{ ...pagination, showSizeChanger: true, @@ -2783,259 +2918,6 @@ function ConsumableManagement() { exit={{ opacity: 0, y: -20 }} transition={{ duration: 0.3 }} > - {/* 模板下载区域 */} -
-
-
-
- - - 下载导入模板 - -
- - 先下载标准模板,填写数据后再上传,支持 .xlsx、.xls、.csv 格式 - -
- -
- - - - {/* 字段说明 */} -
-
-
- -
- - 模板字段说明 - - - 必填 - - - 可选 - -
- -
- {[ - { - field: '耗材ID', - desc: '留空自动生成;填写后可识别并更新现有耗材', - required: false, - icon: '🔑', - }, - { field: '名称', desc: '耗材名称,必填项', required: true, icon: '📝' }, - { - field: '分类', - desc: '耗材分类,如"光模块"或"光纤跳线",必填', - required: true, - icon: '📂', - }, - { - field: '单位', - desc: '计量单位,如"个"、"根"、"箱",默认"个"', - required: false, - icon: '📏', - }, - { - field: '当前库存', - desc: '当前库存数量,数字类型', - 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: 'SN序列号', - desc: '多个SN用逗号分隔,如"SN001,SN002"', - required: false, - icon: '🏷️', - }, - { - field: '状态', - desc: '"active"启用,"inactive"停用,默认启用', - required: false, - icon: '✅', - }, - ].map((item, idx) => ( -
-
- {item.icon} -
-
-
- - {item.field} - - {item.required && ( - - 必填 - - )} -
- - {item.desc} - -
-
- ))} -
-
-
- {/* 拖拽上传区域 */} -
+
点击或拖拽文件到此处上传 - - 支持 Excel (.xlsx/.xls)、CSV (.csv) 格式,文件大小不超过 10MB + + 支持 .xlsx/.xls/.csv 格式 -
- {['.xlsx', '.xls', '.csv'].map(type => ( - - {type} - - ))} -
+ + {/* 模板下载与配置区域 */} + + + 模板下载与配置 +
+ } + style={{ + borderRadius: '16px', + marginBottom: '20px', + border: `1px solid ${designTokens.colors.neutral[200]}`, + }} + bodyStyle={{ padding: '24px' }} + > + {/* 库存处理方式选择 */} +
+
+
+ + 库存处理方式 + + + {importStockMode === 'basic' ? '仅基础信息' : '导入并入库'} + +
+ + {importStockMode === 'basic' + ? '模板仅包含基础信息字段,不含库存和SN' + : '模板包含基础信息及入库相关字段(入库数量、SN等)'} + +
+ +
+ + + + {/* 库存处理单选选项 */} + setImportStockMode(e.target.value)} + > + + + +
+ 仅导入基础信息 +
+
+ 库存初始化为 0 +
+
+
+ + +
+ 导入并入库 +
+
+ 执行入库操作 +
+
+
+
+
+ + + {/* 字段说明 */} + + + 模板字段说明 + 必填 + 可选 + + } + style={{ + borderRadius: '16px', + border: `1px solid ${designTokens.colors.neutral[200]}`, + }} + bodyStyle={{ padding: '20px' }} + > +
+ {[ + { + 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) => ( +
+
+ {item.icon} +
+
+
+ + {item.field} + + {item.required && ( + + 必填 + + )} +
+ + {item.desc} + +
+
+ ))} +
+
)} @@ -3279,6 +3435,7 @@ function ConsumableManagement() { + {/* 数据预览表格 */} + {/* 字段选择弹窗 */} + setExportModalVisible(false)} + onOk={() => { + setExportModalVisible(false); + handleExport(); + }} + width={500} + > +
+ + + {exportableFields.map(field => ( + + {field.label} {field.required && 必选} + + ))} + + +
+
+ {/* 入库/出库弹窗 */} - {/* 扫码弹窗 */} + {/* 扫码弹窗 - 支持入库/出库/添加SN三种模式 */} +
+ {scanMode === 'in' ? : + scanMode === 'out' ? : + } +
+
+ + {scanMode === 'in' ? '扫码入库' : scanMode === 'out' ? '扫码出库' : '扫码添加SN'} + +
+ {scanMode === 'in' ? '扫描 SN 序列号快速入库' : + scanMode === 'out' ? '扫描 SN 序列号快速出库' : + '扫描 SN 序列号添加到表单'} +
+
+ + } open={scanModalVisible} closeIcon={} onCancel={handleScanCancel} footer={null} - width={500} + width={560} + bodyStyle={{ padding: '0 24px 24px' }} + style={{ top: 60 }} > -
- + {/* 统计卡片区 - 根据模式显示不同内容 */} +
+ {scanMode === 'out' ? ( + /* 出库模式:显示待出库列表 */ +
+
+ {pendingOutItems.length} +
+
+ 待出库耗材项 +
+ {pendingOutItems.length > 0 && ( +
+ + 共 {pendingOutItems.reduce((sum, item) => sum + item.quantity, 0)} 件耗材 + +
+ )} +
+ ) : ( + /* 入库/添加SN模式:显示已扫描数量 */ + + +
+
+ {(scanMode === 'in' ? scannedSnList : snList).length} +
+
+ 已扫描SN +
+
+ + +
+
+ {allConsumablesForScan.length} +
+
+ {scanMode === 'in' ? '可入库耗材' : '可选耗材'} +
+
+ +
+ )} +
-
+ {/* 扫码动画输入区 */} +
+
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 && ( +
+ )} setScanValue(e.target.value)} onKeyDown={handleScanKeyDown} - placeholder={scanMode === 'add' ? '扫描SN后自动添加...' : '扫描SN后自动识别...'} - prefix={} + placeholder={scanMode === 'in' + ? "对准扫码枪或输入 SN 后按 Enter 入库" + : scanMode === 'out' + ? "对准扫码枪或输入 SN 后按 Enter 出库" + : "对准扫码枪或输入 SN 后按 Enter 添加"} + prefix={ +
0 + ? `${designTokens.colors.success.main}20` + : `${designTokens.colors.primary.main}20`, + display: 'flex', + alignItems: 'center', + justifyContent: 'center', + transition: 'all 0.3s ease', + }} + > + 0 + ? designTokens.colors.success.main + : designTokens.colors.primary.main, + }} + /> +
+ } + suffix={ + scanChecking ? ( +
+
+ 识别中 +
+ ) : scanValue && ( +
+ +
- {scanMode === 'add' && snList.length > 0 && ( -
-
- 已添加SN列表 ({snList.length}个) -
-
- {(Array.isArray(snList) ? snList : []).map((sn, index) => ( - { - const newSnList = snList.filter((_, i) => i !== index); - setSnList(newSnList); - }} - color="blue" - style={{ marginBottom: '4px' }} - > - {sn} - - ))} -
+ {/* 入库/添加SN模式:耗材选择区 */} + {scanMode !== 'out' && ( +
+
+ {scanMode === 'in' ? '选择入库耗材' : '选择关联耗材'} + {selectedScanInConsumable && ( + + 已选择 + + )}
- )} + +
+ )} + + {/* 入库/添加SN模式:SN列表网格 */} + {scanMode !== 'out' && (scanMode === 'in' ? scannedSnList : snList).length > 0 && ( + +
+ + {scanMode === 'in' ? '已扫描待入库' : '已添加 SN'} + + +
-
- {scannedSnList.map((sn, index) => ( - setScannedSnList(prev => prev.filter((_, i) => i !== index))} - color="blue" - style={{ marginBottom: '4px' }} - > - {sn} - + 清空 + +
+
+ + {(scanMode === 'in' ? scannedSnList : snList).map((sn, index) => ( + + + 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', + }} + > + {sn} + + + + ))} -
+ +
+ + )} -
-
0 && ( + +
+ + 待出库耗材 + + + +
+
+ {pendingOutItems.map(item => ( + removeFromPendingOut(item.consumable.consumableId)} + color="red" style={{ + display: 'flex', + justifyContent: 'space-between', + padding: '10px 14px', + borderRadius: designTokens.borderRadius.sm, marginBottom: '8px', - fontWeight: 500, - color: designTokens.colors.neutral[700], + fontSize: '13px', }} > - 选择入库耗材 -
- -
+ + {item.consumable.name} + + × {item.quantity} + + {item.snList.length > 0 && ( + + ({item.snList.length} SN) + + )} + + + + ))} +
+ + )} - + + {/* 入库/添加SN模式按钮 */} + {scanMode !== 'out' && ( + -
+ } + }} + 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', + }} + > + + {scanMode === 'in' ? : } + {scanMode === 'in' ? '确认入库' : '确认添加'} + {(scanMode === 'in' ? scannedSnList : snList).length > 0 && `(${(scanMode === 'in' ? scannedSnList : snList).length})`} + + )} + {/* 出库模式按钮 */} {scanMode === 'out' && pendingOutItems.length > 0 && ( -
-
- 待出库列表 ({pendingOutItems.length} 项) -
-
- {pendingOutItems.map(item => ( - removeFromPendingOut(item.consumable.consumableId)} - color="red" - style={{ marginBottom: '4px', marginRight: '8px' }} - > - {item.consumable.name} × {item.quantity} - {item.snList.length > 0 && ` (${item.snList.length} SN)`} - - ))} -
- -
+ + )} +
+ {/* 底部提示 */} +
- 💡 提示:也可手动输入条码后按回车键确认 + + {scanMode === 'in' ? '支持扫码枪自动识别,也可手动输入 SN 后按 Enter 确认' : + scanMode === 'out' ? '扫描耗材 SN 进行出库操作' : + '扫描 SN 序列号自动添加到表单中'}