From 568f9c7db862661237e8418330c5327c70013f21 Mon Sep 17 00:00:00 2001 From: zhang1106 <849185023@qq.com> Date: Wed, 24 Dec 2025 16:44:27 +0800 Subject: [PATCH] =?UTF-8?q?feat(consumables):=20=E6=B7=BB=E5=8A=A0?= =?UTF-8?q?=E8=80=97=E6=9D=90=E7=AE=A1=E7=90=86=E5=8A=9F=E8=83=BD=E6=A8=A1?= =?UTF-8?q?=E5=9D=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 添加耗材管理相关功能,包括: 1. 后端模型、路由和控制器 2. 前端页面和组件 3. 耗材分类管理 4. 耗材出入库记录 5. 耗材统计和日志功能 6. 集成到现有系统菜单 --- backend/db.js | 5 +- backend/migrateAddLocationColumn.js | 35 + backend/models/Consumable.js | 63 ++ backend/models/ConsumableCategory.js | 35 + backend/models/ConsumableLog.js | 68 ++ backend/models/ConsumableRecord.js | 64 ++ backend/package-lock.json | 7 + backend/package.json | 1 + backend/routes/consumableCategories.js | 133 ++++ backend/routes/consumableRecords.js | 188 ++++++ backend/routes/consumables.js | 680 ++++++++++++++++++++ backend/server.js | 6 + backend/test_inout.ps1 | 13 + frontend/package-lock.json | 1 + frontend/package.json | 1 + frontend/src/App.jsx | 144 +++-- frontend/src/pages/CategoryManagement.jsx | 215 +++++++ frontend/src/pages/ConsumableLogs.jsx | 469 ++++++++++++++ frontend/src/pages/ConsumableManagement.jsx | 611 ++++++++++++++++++ frontend/src/pages/ConsumableStatistics.jsx | 289 +++++++++ package-lock.json | 13 + 21 files changed, 2990 insertions(+), 51 deletions(-) create mode 100644 backend/migrateAddLocationColumn.js create mode 100644 backend/models/Consumable.js create mode 100644 backend/models/ConsumableCategory.js create mode 100644 backend/models/ConsumableLog.js create mode 100644 backend/models/ConsumableRecord.js create mode 100644 backend/routes/consumableCategories.js create mode 100644 backend/routes/consumableRecords.js create mode 100644 backend/routes/consumables.js create mode 100644 backend/test_inout.ps1 create mode 100644 frontend/src/pages/CategoryManagement.jsx create mode 100644 frontend/src/pages/ConsumableLogs.jsx create mode 100644 frontend/src/pages/ConsumableManagement.jsx create mode 100644 frontend/src/pages/ConsumableStatistics.jsx create mode 100644 package-lock.json diff --git a/backend/db.js b/backend/db.js index 4c5c10c..ce1549a 100644 --- a/backend/db.js +++ b/backend/db.js @@ -24,7 +24,10 @@ if (DB_TYPE === 'mysql') { sequelize = new Sequelize({ dialect: 'sqlite', storage: process.env.DB_PATH || './idc_management.db', - logging: process.env.NODE_ENV === 'development' ? console.log : false + logging: process.env.NODE_ENV === 'development' ? console.log : false, + dialectOptions: { + charset: 'utf8mb4' + } }); } diff --git a/backend/migrateAddLocationColumn.js b/backend/migrateAddLocationColumn.js new file mode 100644 index 0000000..b6072e6 --- /dev/null +++ b/backend/migrateAddLocationColumn.js @@ -0,0 +1,35 @@ +const sqlite3 = require('sqlite3').verbose(); +const path = require('path'); + +const dbPath = path.join(__dirname, 'idc_management.db'); + +const db = new sqlite3.Database(dbPath, (err) => { + if (err) { + console.error('无法打开数据库:', err); + process.exit(1); + } +}); + +console.log('开始修复数据库表结构...'); + +db.serialize(() => { + db.run(`ALTER TABLE consumables ADD COLUMN location TEXT;`, function(err) { + if (err) { + if (err.message.includes('duplicate column name: location')) { + console.log('location 列已存在,无需添加'); + } else { + console.error('添加 location 列失败:', err.message); + } + } else { + console.log('成功添加 location 列到 consumables 表'); + } + + db.close((closeErr) => { + if (closeErr) { + console.error('关闭数据库失败:', closeErr); + } else { + console.log('数据库修复完成'); + } + }); + }); +}); diff --git a/backend/models/Consumable.js b/backend/models/Consumable.js new file mode 100644 index 0000000..ca69bdf --- /dev/null +++ b/backend/models/Consumable.js @@ -0,0 +1,63 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const Consumable = sequelize.define('Consumable', { + consumableId: { + type: DataTypes.STRING, + primaryKey: true, + allowNull: false, + unique: true + }, + name: { + type: DataTypes.STRING, + allowNull: false + }, + category: { + type: DataTypes.STRING, + allowNull: false + }, + unit: { + type: DataTypes.STRING, + allowNull: false, + defaultValue: '个' + }, + currentStock: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 0 + }, + minStock: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 10 + }, + maxStock: { + type: DataTypes.INTEGER, + allowNull: false, + defaultValue: 100 + }, + unitPrice: { + type: DataTypes.DECIMAL(10, 2), + allowNull: false, + defaultValue: 0 + }, + supplier: { + type: DataTypes.STRING + }, + location: { + type: DataTypes.STRING, + comment: '存放位置' + }, + description: { + type: DataTypes.TEXT + }, + status: { + type: DataTypes.STRING, + defaultValue: 'active' + } +}, { + tableName: 'consumables', + timestamps: true +}); + +module.exports = Consumable; diff --git a/backend/models/ConsumableCategory.js b/backend/models/ConsumableCategory.js new file mode 100644 index 0000000..cde8d61 --- /dev/null +++ b/backend/models/ConsumableCategory.js @@ -0,0 +1,35 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); + +const ConsumableCategory = sequelize.define('ConsumableCategory', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + name: { + type: DataTypes.STRING, + allowNull: false, + unique: true, + comment: '分类名称' + }, + description: { + type: DataTypes.STRING, + comment: '分类描述' + }, + sortOrder: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '排序顺序' + }, + status: { + type: DataTypes.STRING, + defaultValue: 'active', + comment: '状态: active-启用, inactive-停用' + } +}, { + tableName: 'consumable_categories', + timestamps: true +}); + +module.exports = ConsumableCategory; diff --git a/backend/models/ConsumableLog.js b/backend/models/ConsumableLog.js new file mode 100644 index 0000000..7e49d9b --- /dev/null +++ b/backend/models/ConsumableLog.js @@ -0,0 +1,68 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); +const Consumable = require('./Consumable'); + +const ConsumableLog = sequelize.define('ConsumableLog', { + id: { + type: DataTypes.INTEGER, + primaryKey: true, + autoIncrement: true + }, + consumableId: { + type: DataTypes.STRING, + allowNull: false, + comment: '耗材ID' + }, + consumableName: { + type: DataTypes.STRING, + allowNull: false, + comment: '耗材名称' + }, + operationType: { + type: DataTypes.ENUM('in', 'out', 'create', 'update', 'delete', 'adjust', 'import'), + allowNull: false, + comment: '操作类型' + }, + quantity: { + type: DataTypes.INTEGER, + defaultValue: 0, + comment: '变动数量(入库为正,出库为负)' + }, + previousStock: { + type: DataTypes.INTEGER, + allowNull: false, + comment: '操作前库存' + }, + currentStock: { + type: DataTypes.INTEGER, + allowNull: false, + comment: '操作后库存' + }, + operator: { + type: DataTypes.STRING, + comment: '操作人' + }, + reason: { + type: DataTypes.STRING, + comment: '操作原因' + }, + notes: { + type: DataTypes.TEXT, + comment: '备注' + }, + relatedId: { + type: DataTypes.STRING, + comment: '关联ID(如订单号、盘点ID等)' + } +}, { + tableName: 'consumable_logs', + timestamps: true, + comment: '耗材操作日志表' +}); + +ConsumableLog.belongsTo(Consumable, { + foreignKey: 'consumableId', + onDelete: 'CASCADE' +}); + +module.exports = ConsumableLog; diff --git a/backend/models/ConsumableRecord.js b/backend/models/ConsumableRecord.js new file mode 100644 index 0000000..d485b89 --- /dev/null +++ b/backend/models/ConsumableRecord.js @@ -0,0 +1,64 @@ +const { DataTypes } = require('sequelize'); +const { sequelize } = require('../db'); +const Consumable = require('./Consumable'); + +const ConsumableRecord = sequelize.define('ConsumableRecord', { + recordId: { + type: DataTypes.UUID, + primaryKey: true, + defaultValue: DataTypes.UUIDV4, + allowNull: false, + unique: true + }, + consumableId: { + type: DataTypes.STRING, + allowNull: false, + references: { + model: Consumable, + key: 'consumableId' + } + }, + type: { + type: DataTypes.ENUM('in', 'out'), + allowNull: false + }, + quantity: { + type: DataTypes.INTEGER, + allowNull: false + }, + previousStock: { + type: DataTypes.INTEGER, + allowNull: false + }, + currentStock: { + type: DataTypes.INTEGER, + allowNull: false + }, + operator: { + type: DataTypes.STRING, + allowNull: false + }, + reason: { + type: DataTypes.STRING + }, + recipient: { + type: DataTypes.STRING + }, + notes: { + type: DataTypes.TEXT + } +}, { + tableName: 'consumable_records', + timestamps: true +}); + +ConsumableRecord.belongsTo(Consumable, { + foreignKey: 'consumableId', + onDelete: 'CASCADE' +}); +Consumable.hasMany(ConsumableRecord, { + foreignKey: 'consumableId', + onDelete: 'CASCADE' +}); + +module.exports = ConsumableRecord; diff --git a/backend/package-lock.json b/backend/package-lock.json index ab35e91..5086c94 100644 --- a/backend/package-lock.json +++ b/backend/package-lock.json @@ -11,6 +11,7 @@ "cors": "^2.8.5", "csv-parser": "^3.2.0", "csv-writer": "^1.6.0", + "dayjs": "^1.11.19", "dotenv": "^17.2.3", "express": "^4.18.2", "express-fileupload": "^1.5.2", @@ -645,6 +646,12 @@ "integrity": "sha512-NOx7YDFWEsM/fTRAJjRpPp8t+MKRVvniAg9wQlUKx20MFrPs73WLJhFf5iteqrxNYnsy924K3Iroh3yNHeYd2g==", "license": "MIT" }, + "node_modules/dayjs": { + "version": "1.11.19", + "resolved": "https://registry.npmjs.org/dayjs/-/dayjs-1.11.19.tgz", + "integrity": "sha512-t5EcLVS6QPBNqM2z8fakk/NKel+Xzshgt8FFKAn+qwlD1pzZWxh0nVCrvFK7ZDb6XucZeF9z8C7CBWTRIVApAw==", + "license": "MIT" + }, "node_modules/debug": { "version": "2.6.9", "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz", diff --git a/backend/package.json b/backend/package.json index 89641ba..229b185 100644 --- a/backend/package.json +++ b/backend/package.json @@ -10,6 +10,7 @@ "cors": "^2.8.5", "csv-parser": "^3.2.0", "csv-writer": "^1.6.0", + "dayjs": "^1.11.19", "dotenv": "^17.2.3", "express": "^4.18.2", "express-fileupload": "^1.5.2", diff --git a/backend/routes/consumableCategories.js b/backend/routes/consumableCategories.js new file mode 100644 index 0000000..cb717c7 --- /dev/null +++ b/backend/routes/consumableCategories.js @@ -0,0 +1,133 @@ +const express = require('express'); +const router = express.Router(); +const { Op } = require('sequelize'); +const ConsumableCategory = require('../models/ConsumableCategory'); + +router.get('/', async (req, res) => { + try { + const { keyword, status, page = 1, pageSize = 10 } = req.query; + const offset = (page - 1) * pageSize; + + const where = {}; + + if (keyword) { + where[Op.or] = [ + { name: { [Op.like]: `%${keyword}%` } }, + { description: { [Op.like]: `%${keyword}%` } } + ]; + } + + if (status && status !== 'all') { + where.status = status; + } + + const { count, rows } = await ConsumableCategory.findAndCountAll({ + where, + order: [['sortOrder', 'ASC'], ['id', 'DESC']], + offset, + limit: parseInt(pageSize) + }); + + res.json({ + categories: rows, + total: count, + currentPage: parseInt(page), + pageSize: parseInt(pageSize), + totalPages: Math.ceil(count / pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/list', async (req, res) => { + try { + const categories = await ConsumableCategory.findAll({ + where: { status: 'active' }, + order: [['sortOrder', 'ASC'], ['name', 'ASC']] + }); + res.json(categories); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/:id', async (req, res) => { + try { + const category = await ConsumableCategory.findByPk(req.params.id); + if (!category) { + return res.status(404).json({ error: '分类不存在' }); + } + res.json(category); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/', async (req, res) => { + try { + const { name, description, sortOrder, status } = req.body; + + const existing = await ConsumableCategory.findOne({ where: { name } }); + if (existing) { + return res.status(400).json({ error: '分类名称已存在' }); + } + + const category = await ConsumableCategory.create({ + name, + description, + sortOrder: sortOrder || 0, + status: status || 'active' + }); + + res.status(201).json(category); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/:id', async (req, res) => { + try { + const { name, description, sortOrder, status } = req.body; + const category = await ConsumableCategory.findByPk(req.params.id); + + if (!category) { + return res.status(404).json({ error: '分类不存在' }); + } + + if (name && name !== category.name) { + const existing = await ConsumableCategory.findOne({ where: { name } }); + if (existing) { + return res.status(400).json({ error: '分类名称已存在' }); + } + } + + await category.update({ + name: name || category.name, + description: description !== undefined ? description : category.description, + sortOrder: sortOrder !== undefined ? sortOrder : category.sortOrder, + status: status || category.status + }); + + res.json(category); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.delete('/:id', async (req, res) => { + try { + const category = await ConsumableCategory.findByPk(req.params.id); + + if (!category) { + return res.status(404).json({ error: '分类不存在' }); + } + + await category.destroy(); + res.json({ message: '删除成功' }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/routes/consumableRecords.js b/backend/routes/consumableRecords.js new file mode 100644 index 0000000..77e68a8 --- /dev/null +++ b/backend/routes/consumableRecords.js @@ -0,0 +1,188 @@ +const express = require('express'); +const router = express.Router(); +const { Op } = require('sequelize'); +const { sequelize } = require('../db'); +const Consumable = require('../models/Consumable'); +const ConsumableRecord = require('../models/ConsumableRecord'); +const ConsumableLog = require('../models/ConsumableLog'); + +router.get('/', async (req, res) => { + try { + const { consumableId, type, startDate, endDate, page = 1, pageSize = 10 } = req.query; + const offset = (page - 1) * pageSize; + + const where = {}; + + if (consumableId) { + where.consumableId = consumableId; + } + + if (type && type !== 'all') { + where.type = type; + } + + if (startDate && endDate) { + where.createdAt = { + [Op.between]: [new Date(startDate), new Date(endDate)] + }; + } else if (startDate) { + where.createdAt = { [Op.gte]: new Date(startDate) }; + } else if (endDate) { + where.createdAt = { [Op.lte]: new Date(endDate) }; + } + + const { count, rows } = await ConsumableRecord.findAndCountAll({ + where, + include: [ + { model: Consumable, attributes: ['name', 'category', 'unit'] } + ], + offset, + limit: parseInt(pageSize), + order: [['createdAt', 'DESC']] + }); + + res.json({ + total: count, + records: rows, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/', async (req, res) => { + const transaction = await sequelize.transaction(); + + try { + const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body; + + const consumable = await Consumable.findByPk(consumableId); + if (!consumable) { + await transaction.rollback(); + return res.status(404).json({ error: '耗材不存在' }); + } + + const previousStock = consumable.currentStock; + let newStock; + + if (type === 'in') { + newStock = previousStock + quantity; + } else if (type === 'out') { + if (previousStock < quantity) { + await transaction.rollback(); + return res.status(400).json({ error: '库存不足' }); + } + newStock = previousStock - quantity; + } else { + await transaction.rollback(); + return res.status(400).json({ error: '操作类型无效' }); + } + + await consumable.update({ currentStock: newStock }, { transaction }); + + const record = await ConsumableRecord.create({ + consumableId, + type, + quantity, + previousStock, + currentStock: newStock, + operator, + reason, + recipient, + notes + }, { transaction }); + + await ConsumableLog.create({ + consumableId, + consumableName: consumable.name, + operationType: type, + quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity), + previousStock, + currentStock: newStock, + operator, + reason, + notes + }, { transaction }); + + await transaction.commit(); + + res.status(201).json({ + record, + consumable: { + previousStock, + currentStock: newStock + } + }); + } catch (error) { + await transaction.rollback(); + res.status(400).json({ error: error.message }); + } +}); + +router.get('/statistics', async (req, res) => { + try { + const { startDate, endDate } = req.query; + + const dateWhere = {}; + if (startDate && endDate) { + dateWhere.createdAt = { + [Op.between]: [new Date(startDate), new Date(endDate)] + }; + } + + const inCount = await ConsumableRecord.count({ + where: { ...dateWhere, type: 'in' } + }); + + const outCount = await ConsumableRecord.count({ + where: { ...dateWhere, type: 'out' } + }); + + const inQuantity = await ConsumableRecord.sum('quantity', { + where: { ...dateWhere, type: 'in' } + }) || 0; + + const outQuantity = await ConsumableRecord.sum('quantity', { + where: { ...dateWhere, type: 'out' } + }) || 0; + + const byType = await ConsumableRecord.findAll({ + where: dateWhere, + attributes: [ + 'type', + [sequelize.fn('SUM', sequelize.col('quantity')), 'totalQuantity'], + [sequelize.fn('COUNT', '*'), 'count'] + ], + group: ['type'] + }); + + const recentRecords = await ConsumableRecord.findAll({ + where: dateWhere, + include: [ + { model: Consumable, attributes: ['name', 'category'] } + ], + order: [['createdAt', 'DESC']], + limit: 10 + }); + + res.json({ + inCount, + outCount, + inQuantity, + outQuantity, + netQuantity: inQuantity - outQuantity, + byType: byType.map(item => ({ + type: item.type, + totalQuantity: item.dataValues.totalQuantity, + count: item.dataValues.count + })), + recentRecords + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/routes/consumables.js b/backend/routes/consumables.js new file mode 100644 index 0000000..4512a4c --- /dev/null +++ b/backend/routes/consumables.js @@ -0,0 +1,680 @@ +const express = require('express'); +const router = express.Router(); +const { Op } = require('sequelize'); +const { sequelize } = require('../db'); +const dayjs = require('dayjs'); +const Consumable = require('../models/Consumable'); +const ConsumableRecord = require('../models/ConsumableRecord'); +const ConsumableLog = require('../models/ConsumableLog'); + +router.get('/', async (req, res) => { + try { + const { keyword, category, status, page = 1, pageSize = 10 } = req.query; + const offset = (page - 1) * pageSize; + + const where = {}; + + if (keyword) { + where[Op.or] = [ + { consumableId: { [Op.like]: `%${keyword}%` } }, + { name: { [Op.like]: `%${keyword}%` } }, + { category: { [Op.like]: `%${keyword}%` } }, + { supplier: { [Op.like]: `%${keyword}%` } }, + { location: { [Op.like]: `%${keyword}%` } } + ]; + } + + if (category && category !== 'all') { + where.category = category; + } + + if (status && status !== 'all') { + where.status = status; + } + + const { count, rows } = await Consumable.findAndCountAll({ + where, + offset, + limit: parseInt(pageSize), + order: [['createdAt', 'DESC']] + }); + + res.json({ + total: count, + consumables: rows, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const consumable = await Consumable.create(req.body, { transaction }); + + await ConsumableLog.create({ + consumableId: consumable.consumableId, + consumableName: consumable.name, + operationType: 'create', + quantity: consumable.currentStock, + previousStock: 0, + currentStock: consumable.currentStock, + operator: req.body.operator || req.body.operatorName || '系统', + reason: '新建耗材', + notes: req.body.description || '' + }, { transaction }); + + await transaction.commit(); + res.status(201).json(consumable); + } catch (error) { + await transaction.rollback(); + res.status(400).json({ error: error.message }); + } +}); + +router.post('/import', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const { items, operator = '系统' } = req.body; + + if (!items || !Array.isArray(items) || items.length === 0) { + await transaction.rollback(); + return res.status(400).json({ error: '没有导入数据' }); + } + + const results = { + success: 0, + failed: 0, + errors: [] + }; + + for (let i = 0; i < items.length; i++) { + const item = items[i]; + try { + const consumableData = { + consumableId: item.耗材ID || item.consumableId || `CON${Date.now()}${i}`, + name: item.名称 || item.name, + category: item.分类 || item.category, + unit: item.单位 || item.unit || '个', + currentStock: parseInt(item.当前库存 || item.currentStock) || 0, + minStock: parseInt(item.最小库存 || item.minStock) || 10, + maxStock: parseInt(item.最大库存 || item.maxStock) || 100, + unitPrice: parseFloat(item.单价 || item.unitPrice) || 0, + supplier: item.供应商 || item.supplier || '', + location: item.存放位置 || item.location || '', + description: item.描述 || item.description || '', + status: item.状态 || item.status || 'active' + }; + + if (!consumableData.name || !consumableData.category) { + results.failed++; + results.errors.push(`第 ${i + 1} 行: 名称和分类为必填项`); + continue; + } + + const consumable = await Consumable.create(consumableData, { transaction }); + + await ConsumableLog.create({ + consumableId: consumable.consumableId, + consumableName: consumable.name, + operationType: 'import', + quantity: consumable.currentStock, + previousStock: 0, + currentStock: consumable.currentStock, + operator, + reason: '批量导入', + notes: '' + }, { transaction }); + + results.success++; + } catch (error) { + results.failed++; + results.errors.push(`第 ${i + 1} 行: ${error.message}`); + } + } + + await transaction.commit(); + res.json({ + message: `导入完成,成功 ${results.success} 条,失败 ${results.failed} 条`, + results + }); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/categories/list', async (req, res) => { + try { + const categories = await Consumable.findAll({ + attributes: ['category'], + group: ['category'] + }); + const categoryList = categories.map(item => item.category).filter(Boolean); + res.json(categoryList); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/low-stock', async (req, res) => { + try { + const consumables = await Consumable.findAll({ + where: { + [Op.and]: [ + sequelize.where(sequelize.col('currentStock'), { + [Op.lte]: sequelize.col('minStock') + }) + ] + } + }); + res.json(consumables); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/statistics/summary', async (req, res) => { + try { + const total = await Consumable.count(); + const lowStock = await Consumable.count({ + where: sequelize.where(sequelize.col('currentStock'), { + [Op.lte]: sequelize.col('minStock') + }) + }); + + const consumables = await Consumable.findAll({ + attributes: ['currentStock', 'unitPrice'] + }); + + const totalValue = consumables.reduce((sum, item) => { + return sum + (parseFloat(item.currentStock) || 0) * (parseFloat(item.unitPrice) || 0); + }, 0); + + const byCategory = await Consumable.findAll({ + attributes: ['category', [sequelize.fn('COUNT', '*'), 'count']], + group: ['category'] + }); + + res.json({ + total, + lowStock, + totalValue: totalValue.toFixed(2), + byCategory: byCategory.map(item => ({ + category: item.category, + count: item.dataValues.count + })) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/inout/records', async (req, res) => { + try { + const { page = 1, pageSize = 10 } = req.query; + const offset = (page - 1) * pageSize; + + const { count, rows } = await ConsumableRecord.findAndCountAll({ + offset, + limit: parseInt(pageSize), + order: [['createdAt', 'DESC']], + include: [{ + model: Consumable, + as: 'Consumable', + attributes: ['consumableId', 'name', 'category'] + }] + }); + + res.json({ + total: count, + records: rows, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/quick-inout', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const { consumableId, type, quantity, operator, reason, notes } = req.body; + + const consumable = await Consumable.findByPk(consumableId, { transaction }); + if (!consumable) { + await transaction.rollback(); + return res.status(404).json({ error: '耗材不存在' }); + } + + const previousStock = parseFloat(consumable.currentStock); + let newStock; + + if (type === 'in') { + newStock = previousStock + parseFloat(quantity); + } else if (type === 'out') { + newStock = previousStock - parseFloat(quantity); + if (newStock < 0) { + await transaction.rollback(); + return res.status(400).json({ error: '库存不足' }); + } + } else { + await transaction.rollback(); + return res.status(400).json({ error: '操作类型无效' }); + } + + await consumable.update({ currentStock: newStock }, { transaction }); + + const record = await ConsumableRecord.create({ + consumableId, + type, + quantity, + previousStock, + currentStock: newStock, + operator, + reason, + notes + }, { transaction }); + + await ConsumableLog.create({ + consumableId, + consumableName: consumable.name, + operationType: type, + quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity), + previousStock, + currentStock: newStock, + operator, + reason, + notes + }, { transaction }); + + await transaction.commit(); + + res.json({ + message: '操作成功', + record, + consumable: await consumable.reload() + }); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +router.post('/inout', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const { consumableId, type, quantity, operator, reason, recipient, notes } = req.body; + + const consumable = await Consumable.findByPk(consumableId, { transaction }); + if (!consumable) { + await transaction.rollback(); + return res.status(404).json({ error: '耗材不存在' }); + } + + const previousStock = parseFloat(consumable.currentStock); + let newStock; + + if (type === 'in') { + newStock = previousStock + parseFloat(quantity); + } else { + newStock = previousStock - parseFloat(quantity); + if (newStock < 0) { + await transaction.rollback(); + return res.status(400).json({ error: '库存不足' }); + } + } + + await consumable.update({ currentStock: newStock }, { transaction }); + + const record = await ConsumableRecord.create({ + consumableId, + type, + quantity, + previousStock, + currentStock: newStock, + operator, + reason, + recipient, + notes + }, { transaction }); + + await ConsumableLog.create({ + consumableId, + consumableName: consumable.name, + operationType: type, + quantity: type === 'in' ? parseFloat(quantity) : -parseFloat(quantity), + previousStock, + currentStock: newStock, + operator, + reason, + notes + }, { transaction }); + + await transaction.commit(); + + res.json({ + message: '操作成功', + record, + consumable: await consumable.reload() + }); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +router.post('/adjust', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const { consumableId, adjustType, quantity, operator, reason, notes } = req.body; + + const consumable = await Consumable.findByPk(consumableId, { transaction }); + if (!consumable) { + await transaction.rollback(); + return res.status(404).json({ error: '耗材不存在' }); + } + + const previousStock = parseFloat(consumable.currentStock); + let newStock; + + if (adjustType === 'add') { + newStock = previousStock + parseFloat(quantity); + } else if (adjustType === 'subtract') { + newStock = previousStock - parseFloat(quantity); + if (newStock < 0) { + await transaction.rollback(); + return res.status(400).json({ error: '调整后库存不能为负' }); + } + } else if (adjustType === 'set') { + newStock = parseFloat(quantity); + } else { + await transaction.rollback(); + return res.status(400).json({ error: '调整类型无效' }); + } + + await consumable.update({ currentStock: newStock }, { transaction }); + + const changeQuantity = newStock - previousStock; + + await ConsumableLog.create({ + consumableId, + consumableName: consumable.name, + operationType: 'adjust', + quantity: changeQuantity, + previousStock, + currentStock: newStock, + operator, + reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason), + notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes + }, { transaction }); + + await transaction.commit(); + + res.json({ + message: '调整成功', + consumable: await consumable.reload() + }); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/logs', async (req, res) => { + try { + const { consumableId, operationType, startDate, endDate, page = 1, pageSize = 20 } = req.query; + const offset = (page - 1) * pageSize; + + const where = {}; + + if (consumableId) { + where.consumableId = consumableId; + } + + if (operationType && operationType !== 'all') { + where.operationType = operationType; + } + + if (startDate && endDate) { + where.createdAt = { + [Op.between]: [new Date(startDate), new Date(endDate)] + }; + } else if (startDate) { + where.createdAt = { [Op.gte]: new Date(startDate) }; + } else if (endDate) { + where.createdAt = { [Op.lte]: new Date(endDate) }; + } + + const { count, rows } = await ConsumableLog.findAndCountAll({ + where, + offset, + limit: parseInt(pageSize), + order: [['createdAt', 'DESC']] + }); + + res.json({ + total: count, + logs: rows, + page: parseInt(page), + pageSize: parseInt(pageSize) + }); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.get('/logs/export', async (req, res) => { + try { + const { consumableId, operationType, startDate, endDate } = req.query; + + const where = {}; + + if (consumableId) { + where.consumableId = consumableId; + } + + if (operationType && operationType !== 'all') { + where.operationType = operationType; + } + + if (startDate && endDate) { + where.createdAt = { + [Op.between]: [new Date(startDate), new Date(endDate)] + }; + } else if (startDate) { + where.createdAt = { [Op.gte]: new Date(startDate) }; + } else if (endDate) { + where.createdAt = { [Op.lte]: new Date(endDate) }; + } + + const logs = await ConsumableLog.findAll({ + where, + order: [['createdAt', 'DESC']] + }); + + const csvHeader = 'ID,耗材ID,耗材名称,操作类型,变动数量,操作前库存,操作后库存,操作人,原因,备注,创建时间,更新时间\n'; + const csvRows = logs.map(log => { + const operationTypeMap = { + 'in': '入库', + 'out': '出库', + 'create': '创建', + 'update': '更新', + 'delete': '删除', + 'adjust': '调整', + 'import': '导入' + }; + return [ + log.id, + log.consumableId, + log.consumableName, + operationTypeMap[log.operationType] || log.operationType, + log.quantity, + log.previousStock, + log.currentStock, + log.operator, + log.reason || '', + log.notes || '', + dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'), + dayjs(log.updatedAt).format('YYYY-MM-DD HH:mm:ss') + ].map(v => `"${String(v).replace(/"/g, '""')}"`).join(','); + }).join('\n'); + + const csv = csvHeader + csvRows; + + res.setHeader('Content-Type', 'text/csv;charset=utf-8'); + res.setHeader('Content-Disposition', `attachment; filename=consumable_logs_${dayjs().format('YYYYMMDD_HHmmss')}.csv`); + res.send(csv); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.post('/logs/import', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const { logs: logItems, operator = '系统导入' } = req.body; + + if (!logItems || !Array.isArray(logItems) || logItems.length === 0) { + await transaction.rollback(); + return res.status(400).json({ error: '没有导入数据' }); + } + + const results = { + success: 0, + failed: 0, + errors: [] + }; + + const operationTypeMap = { + '入库': 'in', + '出库': 'out', + '创建': 'create', + '更新': 'update', + '删除': 'delete', + '调整': 'adjust', + '导入': 'import' + }; + + for (let i = 0; i < logItems.length; i++) { + const item = logItems[i]; + try { + const consumableId = item.耗材ID || item.consumableId || item['consumableId']; + const consumableName = item.耗材名称 || item.consumableName || item['consumableName']; + const operationType = operationTypeMap[item.操作类型 || item.operationType] || item.operationType || item['operationType']; + + if (!consumableId || !operationType) { + results.failed++; + results.errors.push(`第 ${i + 1} 行: 缺少耗材ID或操作类型`); + continue; + } + + await ConsumableLog.create({ + consumableId, + consumableName: consumableName || '', + operationType, + quantity: parseFloat(item.变动数量 || item.quantity || item['quantity']) || 0, + previousStock: parseFloat(item.操作前库存 || item.previousStock || item['previousStock']) || 0, + currentStock: parseFloat(item.操作后库存 || item.currentStock || item['currentStock']) || 0, + operator: item.操作人 || item.operator || operator, + reason: item.原因 || item.reason || '', + notes: item.备注 || item.notes || '' + }, { transaction }); + + results.success++; + } catch (err) { + results.failed++; + results.errors.push(`第 ${i + 1} 行: ${err.message}`); + } + } + + await transaction.commit(); + res.json(results); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +router.get('/:id', async (req, res) => { + try { + const consumable = await Consumable.findByPk(req.params.id); + if (!consumable) { + return res.status(404).json({ error: '耗材不存在' }); + } + res.json(consumable); + } catch (error) { + res.status(500).json({ error: error.message }); + } +}); + +router.put('/:id', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const consumable = await Consumable.findByPk(req.params.id, { transaction }); + if (!consumable) { + await transaction.rollback(); + return res.status(404).json({ error: '耗材不存在' }); + } + + const oldData = consumable.toJSON(); + await consumable.update(req.body, { transaction }); + + await ConsumableLog.create({ + consumableId: consumable.consumableId, + consumableName: consumable.name, + operationType: 'update', + quantity: 0, + previousStock: consumable.currentStock, + currentStock: consumable.currentStock, + operator: req.body.operator || req.body.operatorName || '系统', + reason: '信息更新', + notes: `更新字段: ${Object.keys(req.body).join(', ')}` + }, { transaction }); + + await transaction.commit(); + res.json(consumable); + } catch (error) { + await transaction.rollback(); + res.status(400).json({ error: error.message }); + } +}); + +router.delete('/:id', async (req, res) => { + const transaction = await sequelize.transaction(); + try { + const consumable = await Consumable.findByPk(req.params.id, { transaction }); + if (!consumable) { + await transaction.rollback(); + return res.status(404).json({ error: '耗材不存在' }); + } + + const consumableId = consumable.consumableId; + const consumableName = consumable.name; + const currentStock = consumable.currentStock; + + await ConsumableRecord.destroy({ + where: { consumableId }, + transaction + }); + + await ConsumableLog.destroy({ + where: { consumableId }, + transaction + }); + + await consumable.destroy({ transaction }); + + await transaction.commit(); + res.json({ message: '删除成功' }); + } catch (error) { + await transaction.rollback(); + res.status(500).json({ error: error.message }); + } +}); + +module.exports = router; diff --git a/backend/server.js b/backend/server.js index c1498f8..06565d0 100644 --- a/backend/server.js +++ b/backend/server.js @@ -39,6 +39,9 @@ const rackRoutes = require('./routes/racks'); const roomRoutes = require('./routes/rooms'); const deviceFieldRoutes = require('./routes/deviceFields'); const backgroundRoutes = require('./routes/background'); +const consumableRoutes = require('./routes/consumables'); +const consumableRecordRoutes = require('./routes/consumableRecords'); +const consumableCategoryRoutes = require('./routes/consumableCategories'); // 使用路由 app.use('/api/devices', deviceRoutes); @@ -46,6 +49,9 @@ app.use('/api/racks', rackRoutes); app.use('/api/rooms', roomRoutes); app.use('/api/deviceFields', deviceFieldRoutes); app.use('/api/background', backgroundRoutes); +app.use('/api/consumables', consumableRoutes); +app.use('/api/consumable-records', consumableRecordRoutes); +app.use('/api/consumable-categories', consumableCategoryRoutes); // 静态文件服务 app.use('/uploads', express.static('uploads')); diff --git a/backend/test_inout.ps1 b/backend/test_inout.ps1 new file mode 100644 index 0000000..f46d88e --- /dev/null +++ b/backend/test_inout.ps1 @@ -0,0 +1,13 @@ +$postData = @{ + consumableId = "CON1766493042245" + type = "in" + quantity = 5 + operator = "测试管理员" + reason = "测试入库" + notes = "测试日志记录功能" +} | ConvertTo-Json + +$response = Invoke-RestMethod -Uri "http://localhost:8000/api/consumables/quick-inout" -Method Post -Body $postData -ContentType "application/json" + +Write-Host "入库API响应:" +$response | ConvertTo-Json -Depth 5 diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 8c7edcd..7711a52 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -11,6 +11,7 @@ "@ant-design/icons": "^6.1.0", "antd": "^5.8.6", "axios": "^1.5.0", + "dayjs": "^1.11.19", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", diff --git a/frontend/package.json b/frontend/package.json index 5df4e02..bbce1b6 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -12,6 +12,7 @@ "@ant-design/icons": "^6.1.0", "antd": "^5.8.6", "axios": "^1.5.0", + "dayjs": "^1.11.19", "react": "^18.2.0", "react-dom": "^18.2.0", "react-router-dom": "^6.15.0", diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 05280a9..f2c00f0 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -1,14 +1,17 @@ import React, { useState } from 'react'; import { Layout, Menu, theme, Button } from 'antd'; import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom'; -import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined } from '@ant-design/icons'; +import { BarChartOutlined, DatabaseOutlined, CloudServerOutlined, MenuUnfoldOutlined, MenuFoldOutlined, EyeOutlined, BuildOutlined, HomeOutlined, ShoppingCartOutlined, InboxOutlined, ImportOutlined, FileTextOutlined } from '@ant-design/icons'; import Dashboard from './pages/Dashboard'; import DeviceManagement from './pages/DeviceManagement'; import RackManagement from './pages/RackManagement'; import RoomManagement from './pages/RoomManagement'; import DeviceFieldManagement from './pages/DeviceFieldManagement'; import RackVisualization from './pages/RackVisualization'; - +import ConsumableManagement from './pages/ConsumableManagement'; +import ConsumableStatistics from './pages/ConsumableStatistics'; +import ConsumableLogs from './pages/ConsumableLogs'; +import CategoryManagement from './pages/CategoryManagement'; const { Content, Sider } = Layout; @@ -22,7 +25,7 @@ function App() { - {/* 自定义收起/展开按钮 */}
- , - label: 仪表盘, - }, - { - key: '2', - icon: , - label: 设备管理, - }, - { - key: '3', - icon: , - label: 机柜管理, - }, - { - key: '4', - icon: , - label: 机房管理, - }, - { - key: '5', - icon: , - label: 字段管理, - }, - { - key: '6', - icon: , - label: 机柜可视化, - }, - ]} - /> - + , + label: 仪表盘, + }, + { + key: 'room-management', + icon: , + label: '机房管理', + children: [ + { + key: 'rooms', + icon: , + label: 机房管理, + }, + { + key: 'racks', + icon: , + label: 机柜管理, + }, + { + key: 'visualization', + icon: , + label: 机柜可视化, + }, + ], + }, + { + key: 'asset-management', + icon: , + label: '资产管理', + children: [ + { + key: 'devices', + icon: , + label: 设备管理, + }, + { + key: 'fields', + icon: , + label: 字段管理, + }, + ], + }, + { + key: 'consumables-management', + icon: , + label: '耗材管理', + children: [ + { + key: 'consumables-stats', + icon: , + label: 耗材统计, + }, + { + key: 'consumables', + icon: , + label: 耗材列表, + }, + { + key: 'consumables-categories', + icon: , + label: 分类管理, + }, + { + key: 'consumables-logs', + icon: , + label: 操作日志, + }, + ], + }, + ]} + /> + } /> } /> } /> + } /> + } /> + } /> + } /> diff --git a/frontend/src/pages/CategoryManagement.jsx b/frontend/src/pages/CategoryManagement.jsx new file mode 100644 index 0000000..c2378dc --- /dev/null +++ b/frontend/src/pages/CategoryManagement.jsx @@ -0,0 +1,215 @@ +import React, { useState, useEffect } from 'react'; +import { Table, Button, Modal, Form, Input, InputNumber, Select, message, Card, Space, Popconfirm, Tag } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined } from '@ant-design/icons'; +import axios from 'axios'; + +const { Option } = Select; + +function CategoryManagement() { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [modalVisible, setModalVisible] = useState(false); + const [editingCategory, setEditingCategory] = useState(null); + const [form] = Form.useForm(); + const [pagination, setPagination] = useState({ + current: 1, + pageSize: 10, + total: 0, + showTotal: (total) => `共 ${total} 条记录` + }); + const [keyword, setKeyword] = useState(''); + const [status, setStatus] = useState('all'); + + const fetchCategories = async (page = 1, pageSize = 10) => { + try { + setLoading(true); + const response = await axios.get('/api/consumable-categories', { + params: { page, pageSize, keyword, status } + }); + setCategories(response.data.categories); + setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total })); + } catch (error) { + message.error('获取分类列表失败'); + console.error('获取分类列表失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchCategories(); + }, [keyword, status]); + + const showModal = (category = null) => { + setEditingCategory(category); + if (category) { + form.setFieldsValue(category); + } else { + form.resetFields(); + form.setFieldsValue({ status: 'active', sortOrder: 0 }); + } + setModalVisible(true); + }; + + const handleCancel = () => { + setModalVisible(false); + setEditingCategory(null); + }; + + const handleSubmit = async (values) => { + try { + if (editingCategory) { + await axios.put(`/api/consumable-categories/${editingCategory.id}`, values); + message.success('分类更新成功'); + } else { + await axios.post('/api/consumable-categories', values); + message.success('分类创建成功'); + } + setModalVisible(false); + fetchCategories(); + setEditingCategory(null); + } catch (error) { + message.error(error.response?.data?.error || (editingCategory ? '分类更新失败' : '分类创建失败')); + console.error('提交失败:', error); + } + }; + + const handleDelete = async (id) => { + try { + await axios.delete(`/api/consumable-categories/${id}`); + message.success('删除成功'); + fetchCategories(); + } catch (error) { + message.error(error.response?.data?.error || '删除失败'); + console.error('删除失败:', error); + } + }; + + const columns = [ + { + title: 'ID', + dataIndex: 'id', + key: 'id', + width: 80 + }, + { + title: '分类名称', + dataIndex: 'name', + key: 'name', + width: 150 + }, + { + title: '描述', + dataIndex: 'description', + key: 'description', + width: 200, + render: (value) => value || '-' + }, + { + title: '排序', + dataIndex: 'sortOrder', + key: 'sortOrder', + width: 80 + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (value) => ( + + {value === 'active' ? '启用' : '停用'} + + ) + }, + { + title: '创建时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (value) => value ? new Date(value).toLocaleString('zh-CN') : '-' + }, + { + title: '操作', + key: 'action', + width: 150, + render: (_, record) => ( + + + handleDelete(record.id)}> + + + + ) + } + ]; + + return ( +
+ } onClick={() => showModal()}>添加分类 + }> + + + setKeyword(value)} + allowClear + /> + + + + + + fetchCategories(pagination.current, pagination.pageSize)} + scroll={{ x: 1000 }} + /> + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + ); +} + +export default CategoryManagement; diff --git a/frontend/src/pages/ConsumableLogs.jsx b/frontend/src/pages/ConsumableLogs.jsx new file mode 100644 index 0000000..2ef2ca4 --- /dev/null +++ b/frontend/src/pages/ConsumableLogs.jsx @@ -0,0 +1,469 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown } from 'antd'; +import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined } from '@ant-design/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; +import * as XLSX from 'xlsx'; + +const { RangePicker } = DatePicker; +const { Option } = Select; + +function ConsumableLogs() { + const [logs, setLogs] = useState([]); + const [loading, setLoading] = useState(true); + const [pagination, setPagination] = useState({ current: 1, pageSize: 10, total: 0 }); + const [filters, setFilters] = useState({ + operationType: 'all', + consumableId: '', + dateRange: null + }); + const [importModalVisible, setImportModalVisible] = useState(false); + const [importType, setImportType] = useState('excel'); + const [importing, setImporting] = useState(false); + const fileInputRef = useRef(null); + + const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => { + try { + setLoading(true); + const params = { page, pageSize }; + + if (currentFilters.operationType !== 'all') { + params.operationType = currentFilters.operationType; + } + if (currentFilters.consumableId) { + params.consumableId = currentFilters.consumableId; + } + if (currentFilters.dateRange) { + params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD'); + params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD'); + } + + const response = await axios.get('/api/consumables/logs', { params }); + setLogs(response.data.logs); + setPagination(prev => ({ ...prev, current: page, total: response.data.total })); + } catch (error) { + message.error('获取操作日志失败'); + console.error('获取操作日志失败:', error); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchLogs(1, pagination.pageSize, filters); + }, [filters.operationType, filters.consumableId, filters.dateRange]); + + const handleFilterChange = (key, value) => { + setFilters(prev => ({ ...prev, [key]: value })); + fetchLogs(1, pagination.pageSize); + }; + + const getOperationTag = (type) => { + const config = { + in: { color: 'green', text: '入库' }, + out: { color: 'red', text: '出库' }, + create: { color: 'blue', text: '创建' }, + update: { color: 'orange', text: '更新' }, + delete: { color: 'magenta', text: '删除' }, + adjust: { color: 'purple', text: '调整' }, + import: { color: 'cyan', text: '导入' } + }; + const { color, text } = config[type] || { color: 'default', text: type }; + return {text}; + }; + + const columns = [ + { + title: '时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + sorter: (a, b) => new Date(a.createdAt) - new Date(b.createdAt), + render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss') + }, + { + title: '耗材ID', + dataIndex: 'consumableId', + key: 'consumableId', + width: 150, + render: (value) => {value} + }, + { + title: '耗材名称', + dataIndex: 'consumableName', + key: 'consumableName', + width: 150 + }, + { + title: '操作类型', + dataIndex: 'operationType', + key: 'operationType', + width: 100, + render: (type) => getOperationTag(type) + }, + { + title: '变动数量', + dataIndex: 'quantity', + key: 'quantity', + width: 100, + render: (value, record) => ( + 0 ? '#52c41a' : value < 0 ? '#ff4d4f' : '#888', + fontWeight: 'bold' + }}> + {value > 0 ? '+' : ''}{value} + + ) + }, + { + title: '操作前库存', + dataIndex: 'previousStock', + key: 'previousStock', + width: 100 + }, + { + title: '操作后库存', + dataIndex: 'currentStock', + key: 'currentStock', + width: 100 + }, + { + title: '操作人', + dataIndex: 'operator', + key: 'operator', + width: 120 + }, + { + title: '原因', + dataIndex: 'reason', + key: 'reason', + width: 150, + render: (value) => value || '-' + }, + { + title: '备注', + dataIndex: 'notes', + key: 'notes', + width: 200, + render: (value) => value || '-', + ellipsis: true + } + ]; + + const handleExport = async (currentFilters = filters) => { + try { + const params = {}; + if (currentFilters.operationType !== 'all') { + params.operationType = currentFilters.operationType; + } + if (currentFilters.consumableId) { + params.consumableId = currentFilters.consumableId; + } + if (currentFilters.dateRange) { + params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD'); + params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD'); + } + + const response = await axios.get('/api/consumables/logs/export', { + params, + responseType: 'blob' + }); + + const blob = new Blob([response.data], { type: 'text/csv;charset=utf-8;' }); + const link = document.createElement('a'); + link.href = window.URL.createObjectURL(blob); + link.download = `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.csv`; + link.click(); + + message.success('导出成功'); + } catch (error) { + message.error('导出失败'); + console.error('导出失败:', error); + } + }; + + const handleExportExcel = async (currentFilters = filters) => { + try { + const params = {}; + if (currentFilters.operationType !== 'all') { + params.operationType = currentFilters.operationType; + } + if (currentFilters.consumableId) { + params.consumableId = currentFilters.consumableId; + } + if (currentFilters.dateRange) { + params.startDate = currentFilters.dateRange[0].format('YYYY-MM-DD'); + params.endDate = currentFilters.dateRange[1].format('YYYY-MM-DD'); + } + + const response = await axios.get('/api/consumables/logs', { + params: { ...params, page: 1, pageSize: 10000 } + }); + + const exportData = response.data.logs.map(log => ({ + '时间': dayjs(log.createdAt).format('YYYY-MM-DD HH:mm:ss'), + '耗材ID': log.consumableId, + '耗材名称': log.consumableName, + '操作类型': getOperationTypeText(log.operationType), + '变动数量': log.quantity, + '操作前库存': log.previousStock, + '操作后库存': log.currentStock, + '操作人': log.operator, + '原因': log.reason || '', + '备注': log.notes || '' + })); + + const ws = XLSX.utils.json_to_sheet(exportData); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, '操作日志'); + XLSX.writeFile(wb, `耗材操作日志_${dayjs().format('YYYYMMDD_HHmmss')}.xlsx`); + + message.success('导出Excel成功'); + } catch (error) { + message.error('导出Excel失败'); + console.error('导出Excel失败:', error); + } + }; + + const getOperationTypeText = (type) => { + const map = { + 'in': '入库', + 'out': '出库', + 'create': '创建', + 'update': '更新', + 'delete': '删除', + 'adjust': '调整', + 'import': '导入' + }; + return map[type] || type; + }; + + const handleImport = async (file) => { + setImporting(true); + try { + const reader = new FileReader(); + reader.onload = async (e) => { + try { + let logItems = []; + + if (importType === 'excel') { + const workbook = XLSX.read(e.target.result, { type: 'array' }); + const sheetName = workbook.SheetNames[0]; + const worksheet = workbook.Sheets[sheetName]; + logItems = XLSX.utils.sheet_to_json(worksheet); + } else { + const text = e.target.result; + const lines = text.split('\n').filter(line => line.trim()); + const headers = lines[0].split(',').map(h => h.replace(/"/g, '')); + + for (let i = 1; i < lines.length; i++) { + const values = lines[i].split(',').map(v => v.replace(/"/g, '')); + const item = {}; + headers.forEach((h, idx) => { + item[h] = values[idx]; + }); + logItems.push(item); + } + } + + const response = await axios.post('/api/consumables/logs/import', { + logs: logItems, + operator: '前端导入' + }); + + if (response.data.success > 0) { + message.success(`成功导入 ${response.data.success} 条记录`); + } + if (response.data.failed > 0) { + message.warning(`导入失败 ${response.data.failed} 条`); + response.data.errors.forEach(err => console.error(err)); + } + + setImportModalVisible(false); + fetchLogs(1, pagination.pageSize); + } catch (err) { + message.error('解析文件失败: ' + err.message); + } finally { + setImporting(false); + } + }; + + if (importType === 'excel') { + reader.readAsArrayBuffer(file); + } else { + reader.readAsText(file); + } + } catch (error) { + message.error('导入失败'); + setImporting(false); + } + return false; + }; + + const downloadTemplate = () => { + const template = [ + { + '耗材ID': 'CON123456', + '耗材名称': '示例耗材', + '操作类型': '入库', + '变动数量': 10, + '操作前库存': 100, + '操作后库存': 110, + '操作人': '管理员', + '原因': '示例原因', + '备注': '示例备注' + } + ]; + + const ws = XLSX.utils.json_to_sheet(template); + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, '日志模板'); + XLSX.writeFile(wb, '耗材操作日志导入模板.xlsx'); + + message.success('模板下载成功'); + }; + + return ( +
+ + + 耗材操作日志 + + } + > + + + handleFilterChange('consumableId', value)} + prefix={} + /> + + handleFilterChange('dateRange', dates)} + placeholder={['开始日期', '结束日期']} + /> + + , + label: '导出CSV', + onClick: () => handleExport(filters) + }, + { + key: 'excel', + icon: , + label: '导出Excel', + onClick: () => handleExportExcel(filters) + } + ] + }} + > + + + + + + +
`共 ${total} 条记录`, + showSizeChanger: true, + showQuickJumper: true + }} + onChange={(pagination) => fetchLogs(pagination.current, pagination.pageSize)} + scroll={{ x: 1500 }} + /> + + + { + setImportModalVisible(false); + setImportType('excel'); + }} + footer={null} + width={500} + > +
+ setImportType(e.target.value)} + style={{ marginBottom: 16 }} + > + Excel文件 + CSV文件 + +
+ +
+ + (建议先下载模板填写) +
+ + + + + +
+

注意事项:

+
    +
  • 支持 Excel (.xlsx, .xls) 或 CSV 格式
  • +
  • 文件列名必须包含:耗材ID、操作类型
  • +
  • 操作类型支持:入库、出库、创建、更新、删除、调整、导入
  • +
  • 系统将根据筛选条件过滤需要导出的数据
  • +
+
+
+ + ); +} + +export default ConsumableLogs; diff --git a/frontend/src/pages/ConsumableManagement.jsx b/frontend/src/pages/ConsumableManagement.jsx new file mode 100644 index 0000000..91c3c0e --- /dev/null +++ b/frontend/src/pages/ConsumableManagement.jsx @@ -0,0 +1,611 @@ +import React, { useState, useEffect, useRef } from 'react'; +import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable } from 'antd'; +import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons'; +import axios from 'axios'; + +const { Option } = Select; + +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, + showTotal: (total) => `共 ${total} 条记录` + }); + const [keyword, setKeyword] = useState(''); + const [category, setCategory] = useState('all'); + const [status, setStatus] = useState('all'); + const [importModalVisible, setImportModalVisible] = useState(false); + const [importPreview, setImportPreview] = useState([]); + const [importFile, setImportFile] = useState(null); + const [importing, setImporting] = useState(false); + const importFormRef = useRef(null); + const [stockModalVisible, setStockModalVisible] = useState(false); + const [stockRecord, setStockRecord] = useState(null); + const [stockType, setStockType] = useState('in'); + const [stockForm] = Form.useForm(); + + const fetchConsumables = async (page = 1, pageSize = 10) => { + try { + setLoading(true); + const response = await axios.get('/api/consumables', { + params: { page, pageSize, keyword, category, status } + }); + setConsumables(response.data.consumables); + setPagination(prev => ({ ...prev, current: page, pageSize, total: response.data.total })); + } catch (error) { + message.error('获取耗材列表失败'); + console.error('获取耗材列表失败:', error); + } finally { + setLoading(false); + } + }; + + const fetchCategories = async () => { + try { + const response = await axios.get('/api/consumable-categories/list'); + setCategories(response.data); + } catch (error) { + console.error('获取分类列表失败:', error); + } + }; + + useEffect(() => { + fetchConsumables(); + fetchCategories(); + }, [keyword, category, status]); + + const showModal = (consumable = null) => { + setEditingConsumable(consumable); + if (consumable) { + form.setFieldsValue(consumable); + } else { + form.resetFields(); + } + setModalVisible(true); + }; + + const handleCancel = () => { + setModalVisible(false); + setEditingConsumable(null); + }; + + const handleSubmit = async (values) => { + try { + if (editingConsumable) { + await axios.put(`/api/consumables/${editingConsumable.consumableId}`, values); + message.success('耗材更新成功'); + } else { + await axios.post('/api/consumables', { + ...values, + consumableId: `CON${Date.now()}` + }); + message.success('耗材创建成功'); + } + setModalVisible(false); + fetchConsumables(); + setEditingConsumable(null); + } catch (error) { + message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败'); + console.error('提交失败:', error); + } + }; + + const handleDelete = async (consumableId) => { + try { + await axios.delete(`/api/consumables/${consumableId}`); + message.success('删除成功'); + fetchConsumables(); + } catch (error) { + message.error('删除失败'); + console.error('删除失败:', error); + } + }; + + const handleSearch = (value) => { + setKeyword(value); + }; + + const exportToCSV = (data, filename) => { + const headers = ['耗材ID', '名称', '分类', '单位', '当前库存', '最小库存', '最大库存', '单价', '供应商', '存放位置', '状态']; + const keys = ['consumableId', 'name', 'category', 'unit', 'currentStock', 'minStock', 'maxStock', 'unitPrice', 'supplier', 'location', 'status']; + + const csvContent = [ + headers.join(','), + ...data.map(row => keys.map(key => { + let value = row[key]; + if (key === 'unitPrice') value = `¥${parseFloat(value || 0).toFixed(2)}`; + if (key === 'status') value = value === 'active' ? '启用' : '停用'; + if (value === null || value === undefined) value = ''; + const str = String(value); + if (str.includes(',') || str.includes('"') || str.includes('\n')) { + return `"${str.replace(/"/g, '""')}"`; + } + return str; + }).join(',')) + ].join('\n'); + + const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' }); + const url = window.URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = url; + link.download = filename; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + window.URL.revokeObjectURL(url); + }; + + const handleExport = async () => { + try { + const response = await axios.get('/api/consumables', { + params: { keyword, category, status, pageSize: 1000 } + }); + const consumables = response.data.consumables; + exportToCSV(consumables, `consumables_${new Date().toISOString().split('T')[0]}.csv`); + message.success('导出成功'); + } catch (error) { + message.error('导出失败'); + console.error('导出失败:', error); + } + }; + + const parseCSV = (text) => { + const lines = text.trim().split('\n'); + if (lines.length < 2) return []; + + const headers = lines[0].split(',').map(h => h.trim().replace(/^"|"$/g, '')); + const data = []; + + for (let i = 1; i < lines.length; i++) { + const line = lines[i].trim(); + if (!line) continue; + + let values = []; + let inQuotes = false; + let current = ''; + + 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, '')); + + const row = {}; + headers.forEach((header, idx) => { + row[header] = values[idx] || ''; + }); + data.push(row); + } + + return data; + }; + + const handleFileChange = (info) => { + const file = info.fileList[info.fileList.length - 1]; + if (file && file.originFileObj) { + const reader = new FileReader(); + reader.onload = (e) => { + 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); + }; + + const handleImport = async () => { + if (!importFile) { + message.warning('请先选择文件'); + return; + } + + setImporting(true); + try { + const reader = new FileReader(); + reader.onload = async (e) => { + const text = e.target.result; + const items = parseCSV(text); + + const response = await axios.post('/api/consumables/import', { items }); + message.success(response.data.message); + + if (response.data.results.failed > 0) { + response.data.results.errors.forEach(err => console.error(err)); + } + + setImportModalVisible(false); + setImportPreview([]); + setImportFile(null); + fetchConsumables(); + setImporting(false); + }; + reader.readAsText(importFile); + } catch (error) { + message.error('导入失败'); + console.error('导入失败:', error); + setImporting(false); + } + }; + + const downloadTemplate = () => { + const template = '耗材ID,名称,分类,单位,当前库存,最小库存,最大库存,单价,供应商,存放位置,描述,状态\n,测试耗材,办公用品,个,100,10,500,5.00,XX公司,A柜-01层,测试数据,active'; + 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); + }; + + const showStockModal = (record, type) => { + setStockRecord(record); + setStockType(type); + stockForm.setFieldsValue({ + consumableId: record.consumableId, + consumableName: record.name, + quantity: 1, + reason: '', + notes: '' + }); + setStockModalVisible(true); + }; + + const handleStockCancel = () => { + setStockModalVisible(false); + setStockRecord(null); + }; + + const handleStockSubmit = async (values) => { + try { + const response = await axios.post('/api/consumables/quick-inout', { + consumableId: stockRecord.consumableId, + type: stockType, + quantity: values.quantity, + operator: values.operator || '系统管理员', + reason: values.reason, + notes: values.notes + }); + message.success(`${stockType === 'in' ? '入库' : '出库'}操作成功`); + setStockModalVisible(false); + fetchConsumables(); + } catch (error) { + message.error(error.response?.data?.error || `${stockType === 'in' ? '入库' : '出库'}操作失败`); + console.error('操作失败:', error); + } + }; + + const columns = [ + { + title: '耗材ID', + dataIndex: 'consumableId', + key: 'consumableId', + width: 150 + }, + { + title: '名称', + dataIndex: 'name', + key: 'name', + width: 150 + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 120 + }, + { + title: '单位', + dataIndex: 'unit', + key: 'unit', + width: 80 + }, + { + title: '当前库存', + dataIndex: 'currentStock', + key: 'currentStock', + width: 100, + render: (value, record) => { + const isLow = value <= record.minStock; + return ( + + {value} + + ); + } + }, + { + title: '最小库存', + dataIndex: 'minStock', + key: 'minStock', + width: 100 + }, + { + title: '最大库存', + dataIndex: 'maxStock', + key: 'maxStock', + width: 100 + }, + { + title: '单价(元)', + dataIndex: 'unitPrice', + key: 'unitPrice', + width: 100, + render: (value) => `¥${parseFloat(value || 0).toFixed(2)}` + }, + { + title: '供应商', + dataIndex: 'supplier', + key: 'supplier', + width: 150, + render: (value) => value || '-' + }, + { + title: '位置', + dataIndex: 'location', + key: 'location', + width: 120, + render: (value) => value || '-' + }, + { + title: '状态', + dataIndex: 'status', + key: 'status', + width: 100, + render: (value) => ( + + {value === 'active' ? '启用' : '停用'} + + ) + }, + { + title: '操作', + key: 'action', + width: 200, + render: (_, record) => ( + + + + + handleDelete(record.consumableId)}> + + + + ) + } + ]; + + 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 }, + { title: '单价', dataIndex: '单价', key: 'unitPrice', width: 80 } + ]; + + return ( +
+ + + + + + }> + + + + + + + + +
fetchConsumables(pagination.current, pagination.pageSize)} + scroll={{ x: 1300 }} + /> + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + + + + + 请下载模板后填写数据再导入 + + + + false} + onChange={handleFileChange} + > + + + + {importPreview.length > 0 && ( +
+
预览 (前10条):
+
index} + pagination={false} + size="small" + scroll={{ x: 500 }} + /> + + )} + +
+ + + + +
+ + + + +
+ + + + + + + + + + + + + + + + + + + + + + + + + +
+ + ); +} + +export default ConsumableManagement; diff --git a/frontend/src/pages/ConsumableStatistics.jsx b/frontend/src/pages/ConsumableStatistics.jsx new file mode 100644 index 0000000..12d0772 --- /dev/null +++ b/frontend/src/pages/ConsumableStatistics.jsx @@ -0,0 +1,289 @@ +import React, { useState, useEffect } from 'react'; +import { Card, Row, Col, Statistic, Table, Tag, DatePicker, Space, Select, Progress, message } from 'antd'; +import { InboxOutlined, ExportOutlined, WarningOutlined, DollarOutlined, ShoppingCartOutlined, ExclamationCircleOutlined } from '@ant-design/icons'; +import axios from 'axios'; +import dayjs from 'dayjs'; + +const { RangePicker } = DatePicker; + +function ConsumableStatistics() { + const [summary, setSummary] = useState({ total: 0, lowStock: 0, totalValue: 0, byCategory: [] }); + const [lowStockItems, setLowStockItems] = useState([]); + const [stats, setStats] = useState({ inCount: 0, outCount: 0, inQuantity: 0, outQuantity: 0, recentRecords: [] }); + const [loading, setLoading] = useState(true); + const [dateRange, setDateRange] = useState([]); + + const fetchSummary = async () => { + try { + const response = await axios.get('/api/consumables/statistics/summary'); + setSummary(response.data); + } catch (error) { + message.error('获取统计信息失败'); + } + }; + + const fetchLowStock = async () => { + try { + const response = await axios.get('/api/consumables/low-stock'); + setLowStockItems(response.data); + } catch (error) { + message.error('获取低库存信息失败'); + } + }; + + const fetchInOutStats = async () => { + try { + const params = {}; + if (dateRange && dateRange.length === 2) { + params.startDate = dateRange[0].toISOString(); + params.endDate = dateRange[1].toISOString(); + } + const response = await axios.get('/api/consumable-records/statistics', { params }); + setStats(response.data); + } catch (error) { + message.error('获取出入库统计失败'); + } + }; + + useEffect(() => { + fetchSummary(); + fetchLowStock(); + fetchInOutStats(); + }, []); + + useEffect(() => { + fetchInOutStats(); + }, [dateRange]); + + const lowStockColumns = [ + { + title: '耗材名称', + dataIndex: 'name', + key: 'name', + width: 150 + }, + { + title: '分类', + dataIndex: 'category', + key: 'category', + width: 120 + }, + { + title: '当前库存', + dataIndex: 'currentStock', + key: 'currentStock', + width: 100, + render: (value) => ( + {value} + ) + }, + { + title: '最小库存', + dataIndex: 'minStock', + key: 'minStock', + width: 100 + }, + { + title: '单位', + dataIndex: 'unit', + key: 'unit', + width: 80 + }, + { + title: '库存充足率', + key: 'rate', + width: 150, + render: (_, record) => { + const rate = Math.min(100, Math.round((record.currentStock / record.maxStock) * 100)); + const status = rate < 30 ? 'exception' : rate < 60 ? 'active' : 'success'; + return ; + } + }, + { + title: '供应商', + dataIndex: 'supplier', + key: 'supplier', + width: 150, + render: (value) => value || '-' + } + ]; + + const recentColumns = [ + { + title: '时间', + dataIndex: 'createdAt', + key: 'createdAt', + width: 180, + render: (date) => dayjs(date).format('YYYY-MM-DD HH:mm:ss') + }, + { + title: '耗材名称', + dataIndex: ['Consumable', 'name'], + key: 'consumableName', + width: 150 + }, + { + title: '类型', + dataIndex: 'type', + key: 'type', + width: 100, + render: (type) => ( + + {type === 'in' ? '入库' : '出库'} + + ) + }, + { + title: '数量', + dataIndex: 'quantity', + key: 'quantity', + width: 100, + render: (value, record) => ( + + {record.type === 'in' ? '+' : '-'}{value} + + ) + }, + { + title: '操作人', + dataIndex: 'operator', + key: 'operator', + width: 120 + }, + { + title: '原因', + dataIndex: 'reason', + key: 'reason', + width: 150, + render: (value) => value || '-' + } + ]; + + return ( +
+ +
+ + } + /> + + + + + 0 ? '#ff4d4f' : '#52c41a' }} />} + valueStyle={{ color: summary.lowStock > 0 ? '#ff4d4f' : '#52c41a' }} + /> + + + + + } + precision={2} + /> + + + + + = 0 ? '#52c41a' : '#ff4d4f' }} />} + valueStyle={{ color: stats.inQuantity - stats.outQuantity >= 0 ? '#52c41a' : '#ff4d4f' }} + /> + + + + + + + + }> + + + + } + /> +
+ +{stats.inQuantity} +
+
+ + + + } + /> +
+ -{stats.outQuantity} +
+
+ + + + + + + + {summary.byCategory?.map(item => ( + + + + ))} + + + + + + + + 低库存预警} + extra={{lowStockItems.length}项} + > +
+ + + + +
+ + + + + ); +} + +export default ConsumableStatistics; diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..ac96f27 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,13 @@ +{ + "name": "idc-device-management", + "version": "1.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "idc-device-management", + "version": "1.0.0", + "license": "MIT" + } + } +}