feat: 添加耗材操作日志修改记录功能; feat: 最大库存支持无限制选项; fix: 修复Ant Design Card组件bodyStyle废弃警告和字体预加载警告
This commit is contained in:
@@ -33,8 +33,9 @@ const Consumable = sequelize.define('Consumable', {
|
|||||||
},
|
},
|
||||||
maxStock: {
|
maxStock: {
|
||||||
type: DataTypes.INTEGER,
|
type: DataTypes.INTEGER,
|
||||||
allowNull: false,
|
allowNull: true,
|
||||||
defaultValue: 100
|
defaultValue: null,
|
||||||
|
comment: '最大库存,null表示无限制'
|
||||||
},
|
},
|
||||||
unitPrice: {
|
unitPrice: {
|
||||||
type: DataTypes.DECIMAL(10, 2),
|
type: DataTypes.DECIMAL(10, 2),
|
||||||
|
|||||||
@@ -53,6 +53,31 @@ const ConsumableLog = sequelize.define('ConsumableLog', {
|
|||||||
relatedId: {
|
relatedId: {
|
||||||
type: DataTypes.STRING,
|
type: DataTypes.STRING,
|
||||||
comment: '关联ID(如订单号、盘点ID等)'
|
comment: '关联ID(如订单号、盘点ID等)'
|
||||||
|
},
|
||||||
|
isEditable: {
|
||||||
|
type: DataTypes.BOOLEAN,
|
||||||
|
defaultValue: true,
|
||||||
|
comment: '是否可编辑(创建、导入的记录可编辑,系统生成的出入库记录不可编辑)'
|
||||||
|
},
|
||||||
|
originalLogId: {
|
||||||
|
type: DataTypes.INTEGER,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '原始日志ID(用于追踪修改历史链)'
|
||||||
|
},
|
||||||
|
modifiedBy: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '修改人'
|
||||||
|
},
|
||||||
|
modifiedAt: {
|
||||||
|
type: DataTypes.DATE,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '修改时间'
|
||||||
|
},
|
||||||
|
modificationReason: {
|
||||||
|
type: DataTypes.STRING,
|
||||||
|
allowNull: true,
|
||||||
|
comment: '修改原因'
|
||||||
}
|
}
|
||||||
}, {
|
}, {
|
||||||
tableName: 'consumable_logs',
|
tableName: 'consumable_logs',
|
||||||
@@ -62,7 +87,9 @@ const ConsumableLog = sequelize.define('ConsumableLog', {
|
|||||||
{ fields: ['consumableId'] },
|
{ fields: ['consumableId'] },
|
||||||
{ fields: ['operationType'] },
|
{ fields: ['operationType'] },
|
||||||
{ fields: ['createdAt'] },
|
{ fields: ['createdAt'] },
|
||||||
{ fields: ['consumableId', 'createdAt'] }
|
{ fields: ['consumableId', 'createdAt'] },
|
||||||
|
{ fields: ['originalLogId'] },
|
||||||
|
{ fields: ['isEditable'] }
|
||||||
]
|
]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -314,6 +314,7 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
notes
|
notes
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
|
// 系统生成的出入库记录不可编辑
|
||||||
await ConsumableLog.create({
|
await ConsumableLog.create({
|
||||||
consumableId,
|
consumableId,
|
||||||
consumableName: consumable.name,
|
consumableName: consumable.name,
|
||||||
@@ -323,7 +324,8 @@ router.post('/quick-inout', async (req, res) => {
|
|||||||
currentStock: newStock,
|
currentStock: newStock,
|
||||||
operator,
|
operator,
|
||||||
reason,
|
reason,
|
||||||
notes
|
notes,
|
||||||
|
isEditable: false
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@@ -407,6 +409,7 @@ router.post('/inout', async (req, res) => {
|
|||||||
notes
|
notes
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
|
// 系统生成的出入库记录不可编辑
|
||||||
await ConsumableLog.create({
|
await ConsumableLog.create({
|
||||||
consumableId,
|
consumableId,
|
||||||
consumableName: consumable.name,
|
consumableName: consumable.name,
|
||||||
@@ -416,7 +419,8 @@ router.post('/inout', async (req, res) => {
|
|||||||
currentStock: newStock,
|
currentStock: newStock,
|
||||||
operator,
|
operator,
|
||||||
reason,
|
reason,
|
||||||
notes
|
notes,
|
||||||
|
isEditable: false
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@@ -499,6 +503,7 @@ router.post('/adjust', async (req, res) => {
|
|||||||
|
|
||||||
const changeQuantity = newStock - previousStock;
|
const changeQuantity = newStock - previousStock;
|
||||||
|
|
||||||
|
// 系统生成的调整记录不可编辑
|
||||||
await ConsumableLog.create({
|
await ConsumableLog.create({
|
||||||
consumableId,
|
consumableId,
|
||||||
consumableName: consumable.name,
|
consumableName: consumable.name,
|
||||||
@@ -508,7 +513,8 @@ router.post('/adjust', async (req, res) => {
|
|||||||
currentStock: newStock,
|
currentStock: newStock,
|
||||||
operator,
|
operator,
|
||||||
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
reason: reason || (adjustType === 'set' ? '库存调整为 ' + newStock : reason),
|
||||||
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes
|
notes: adjustType === 'set' ? `库存从 ${previousStock} 调整为 ${newStock}` : notes,
|
||||||
|
isEditable: false
|
||||||
}, { transaction });
|
}, { transaction });
|
||||||
|
|
||||||
await transaction.commit();
|
await transaction.commit();
|
||||||
@@ -780,4 +786,79 @@ router.delete('/:id', async (req, res) => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 修改日志记录
|
||||||
|
router.put('/logs/:id', async (req, res) => {
|
||||||
|
const transaction = await sequelize.transaction();
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
const { reason, notes, operator, modificationReason } = req.body;
|
||||||
|
|
||||||
|
const log = await ConsumableLog.findByPk(id, { transaction });
|
||||||
|
if (!log) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(404).json({ error: '日志记录不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查是否可编辑
|
||||||
|
if (!log.isEditable) {
|
||||||
|
await transaction.rollback();
|
||||||
|
return res.status(403).json({ error: '该记录为系统自动生成,不可修改' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 保存原始日志ID(用于追踪修改历史)
|
||||||
|
const originalLogId = log.originalLogId || log.id;
|
||||||
|
|
||||||
|
// 更新当前记录,并标记为已修改
|
||||||
|
await log.update({
|
||||||
|
reason: reason !== undefined ? reason : log.reason,
|
||||||
|
notes: notes !== undefined ? notes : log.notes,
|
||||||
|
modifiedBy: operator || '系统',
|
||||||
|
modifiedAt: new Date(),
|
||||||
|
modificationReason: modificationReason || '用户修改'
|
||||||
|
}, { transaction });
|
||||||
|
|
||||||
|
await transaction.commit();
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
message: '日志修改成功',
|
||||||
|
log: await ConsumableLog.findByPk(id)
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
await transaction.rollback();
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// 获取日志修改历史
|
||||||
|
router.get('/logs/:id/history', async (req, res) => {
|
||||||
|
try {
|
||||||
|
const { id } = req.params;
|
||||||
|
|
||||||
|
const log = await ConsumableLog.findByPk(id);
|
||||||
|
if (!log) {
|
||||||
|
return res.status(404).json({ error: '日志记录不存在' });
|
||||||
|
}
|
||||||
|
|
||||||
|
// 查询该日志的所有修改历史(包括原始记录)
|
||||||
|
const originalLogId = log.originalLogId || log.id;
|
||||||
|
|
||||||
|
const history = await ConsumableLog.findAll({
|
||||||
|
where: {
|
||||||
|
[Op.or]: [
|
||||||
|
{ id: originalLogId },
|
||||||
|
{ originalLogId: originalLogId }
|
||||||
|
]
|
||||||
|
},
|
||||||
|
order: [['createdAt', 'ASC']]
|
||||||
|
});
|
||||||
|
|
||||||
|
res.json({
|
||||||
|
current: log,
|
||||||
|
history: history
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
res.status(500).json({ error: error.message });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
module.exports = router;
|
module.exports = router;
|
||||||
|
|||||||
@@ -0,0 +1,118 @@
|
|||||||
|
/**
|
||||||
|
* 耗材操作日志表结构迁移脚本
|
||||||
|
* 添加修改记录相关字段
|
||||||
|
*/
|
||||||
|
|
||||||
|
const { sequelize } = require('../db');
|
||||||
|
|
||||||
|
async function migrate() {
|
||||||
|
try {
|
||||||
|
console.log('开始迁移耗材操作日志表...');
|
||||||
|
|
||||||
|
// 检查并添加 isEditable 字段
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE consumable_logs ADD COLUMN isEditable BOOLEAN DEFAULT 1
|
||||||
|
`);
|
||||||
|
console.log('✓ 添加 isEditable 字段成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ isEditable 字段已存在');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并添加 originalLogId 字段
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE consumable_logs ADD COLUMN originalLogId INTEGER
|
||||||
|
`);
|
||||||
|
console.log('✓ 添加 originalLogId 字段成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ originalLogId 字段已存在');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并添加 modifiedBy 字段
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE consumable_logs ADD COLUMN modifiedBy VARCHAR(255)
|
||||||
|
`);
|
||||||
|
console.log('✓ 添加 modifiedBy 字段成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ modifiedBy 字段已存在');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并添加 modifiedAt 字段
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE consumable_logs ADD COLUMN modifiedAt DATETIME
|
||||||
|
`);
|
||||||
|
console.log('✓ 添加 modifiedAt 字段成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ modifiedAt 字段已存在');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查并添加 modificationReason 字段
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
ALTER TABLE consumable_logs ADD COLUMN modificationReason VARCHAR(255)
|
||||||
|
`);
|
||||||
|
console.log('✓ 添加 modificationReason 字段成功');
|
||||||
|
} catch (err) {
|
||||||
|
if (err.message.includes('duplicate column name')) {
|
||||||
|
console.log('✓ modificationReason 字段已存在');
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 创建索引
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consumable_logs_original_log_id ON consumable_logs(originalLogId)
|
||||||
|
`);
|
||||||
|
console.log('✓ 创建 originalLogId 索引成功');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('! originalLogId 索引创建失败:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await sequelize.query(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_consumable_logs_is_editable ON consumable_logs(isEditable)
|
||||||
|
`);
|
||||||
|
console.log('✓ 创建 isEditable 索引成功');
|
||||||
|
} catch (err) {
|
||||||
|
console.log('! isEditable 索引创建失败:', err.message);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 更新现有记录:将出入库、调整等系统生成的记录标记为不可编辑
|
||||||
|
const [result] = await sequelize.query(`
|
||||||
|
UPDATE consumable_logs
|
||||||
|
SET isEditable = 0
|
||||||
|
WHERE operationType IN ('in', 'out', 'adjust')
|
||||||
|
AND (isEditable IS NULL OR isEditable = 1)
|
||||||
|
`);
|
||||||
|
console.log(`✓ 更新 ${result.changes || 0} 条系统生成记录为不可编辑状态`);
|
||||||
|
|
||||||
|
console.log('\n迁移完成!');
|
||||||
|
process.exit(0);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('迁移失败:', error);
|
||||||
|
process.exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
migrate();
|
||||||
@@ -4,8 +4,6 @@
|
|||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
<title>IDC设备管理系统</title>
|
<title>IDC设备管理系统</title>
|
||||||
<!-- 预加载字体,确保本地资源可用 -->
|
|
||||||
<link rel="preload" href="/fonts/Inter-Regular.woff2" as="font" type="font/woff2" crossorigin>
|
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="root"></div>
|
<div id="root"></div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import React, { useState, useEffect, useRef } from 'react';
|
import React, { useState, useEffect, useRef } from 'react';
|
||||||
import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown } from 'antd';
|
import { Table, Card, Space, Select, DatePicker, Input, Tag, Button, message, Modal, Upload, Radio, Dropdown, Form, Tooltip, Timeline } from 'antd';
|
||||||
import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined } from '@ant-design/icons';
|
import { HistoryOutlined, SearchOutlined, FileTextOutlined, DownloadOutlined, UploadOutlined, FileExcelOutlined, FileOutlined, DownOutlined, EditOutlined, EyeOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
import dayjs from 'dayjs';
|
import dayjs from 'dayjs';
|
||||||
import * as XLSX from 'xlsx';
|
import * as XLSX from 'xlsx';
|
||||||
@@ -20,6 +20,13 @@ function ConsumableLogs() {
|
|||||||
const [importModalVisible, setImportModalVisible] = useState(false);
|
const [importModalVisible, setImportModalVisible] = useState(false);
|
||||||
const [importType, setImportType] = useState('excel');
|
const [importType, setImportType] = useState('excel');
|
||||||
const [importing, setImporting] = useState(false);
|
const [importing, setImporting] = useState(false);
|
||||||
|
const [editModalVisible, setEditModalVisible] = useState(false);
|
||||||
|
const [historyModalVisible, setHistoryModalVisible] = useState(false);
|
||||||
|
const [currentLog, setCurrentLog] = useState(null);
|
||||||
|
const [logHistory, setLogHistory] = useState([]);
|
||||||
|
const [editLoading, setEditLoading] = useState(false);
|
||||||
|
const [historyLoading, setHistoryLoading] = useState(false);
|
||||||
|
const [form] = Form.useForm();
|
||||||
const fileInputRef = useRef(null);
|
const fileInputRef = useRef(null);
|
||||||
|
|
||||||
const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => {
|
const fetchLogs = async (page = 1, pageSize = 10, currentFilters = filters) => {
|
||||||
@@ -147,6 +154,34 @@ function ConsumableLogs() {
|
|||||||
width: 200,
|
width: 200,
|
||||||
render: (value) => value || '-',
|
render: (value) => value || '-',
|
||||||
ellipsis: true
|
ellipsis: true
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: '操作',
|
||||||
|
key: 'action',
|
||||||
|
width: 120,
|
||||||
|
fixed: 'right',
|
||||||
|
render: (_, record) => (
|
||||||
|
<Space>
|
||||||
|
{record.isEditable && (
|
||||||
|
<Tooltip title="编辑">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EditOutlined />}
|
||||||
|
onClick={() => handleEdit(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
)}
|
||||||
|
<Tooltip title="查看历史">
|
||||||
|
<Button
|
||||||
|
type="link"
|
||||||
|
size="small"
|
||||||
|
icon={<EyeOutlined />}
|
||||||
|
onClick={() => handleViewHistory(record)}
|
||||||
|
/>
|
||||||
|
</Tooltip>
|
||||||
|
</Space>
|
||||||
|
)
|
||||||
}
|
}
|
||||||
];
|
];
|
||||||
|
|
||||||
@@ -323,6 +358,57 @@ function ConsumableLogs() {
|
|||||||
message.success('模板下载成功');
|
message.success('模板下载成功');
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// 编辑日志
|
||||||
|
const handleEdit = (record) => {
|
||||||
|
setCurrentLog(record);
|
||||||
|
form.setFieldsValue({
|
||||||
|
reason: record.reason,
|
||||||
|
notes: record.notes,
|
||||||
|
modificationReason: ''
|
||||||
|
});
|
||||||
|
setEditModalVisible(true);
|
||||||
|
};
|
||||||
|
|
||||||
|
// 提交编辑
|
||||||
|
const handleEditSubmit = async (values) => {
|
||||||
|
if (!currentLog) return;
|
||||||
|
|
||||||
|
setEditLoading(true);
|
||||||
|
try {
|
||||||
|
const response = await axios.put(`/api/consumables/logs/${currentLog.id}`, {
|
||||||
|
reason: values.reason,
|
||||||
|
notes: values.notes,
|
||||||
|
operator: values.operator || '管理员',
|
||||||
|
modificationReason: values.modificationReason
|
||||||
|
});
|
||||||
|
|
||||||
|
message.success('日志修改成功');
|
||||||
|
setEditModalVisible(false);
|
||||||
|
fetchLogs(pagination.current, pagination.pageSize);
|
||||||
|
} catch (error) {
|
||||||
|
message.error(error.response?.data?.error || '修改失败');
|
||||||
|
} finally {
|
||||||
|
setEditLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 查看修改历史
|
||||||
|
const handleViewHistory = async (record) => {
|
||||||
|
setCurrentLog(record);
|
||||||
|
setHistoryModalVisible(true);
|
||||||
|
setHistoryLoading(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await axios.get(`/api/consumables/logs/${record.id}/history`);
|
||||||
|
setLogHistory(response.data.history || []);
|
||||||
|
} catch (error) {
|
||||||
|
message.error('获取修改历史失败');
|
||||||
|
setLogHistory([]);
|
||||||
|
} finally {
|
||||||
|
setHistoryLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<Card
|
<Card
|
||||||
@@ -462,6 +548,105 @@ function ConsumableLogs() {
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
||||||
|
{/* 编辑日志弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="编辑日志记录"
|
||||||
|
open={editModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setEditModalVisible(false);
|
||||||
|
form.resetFields();
|
||||||
|
}}
|
||||||
|
onOk={() => form.submit()}
|
||||||
|
confirmLoading={editLoading}
|
||||||
|
width={600}
|
||||||
|
>
|
||||||
|
<Form
|
||||||
|
form={form}
|
||||||
|
layout="vertical"
|
||||||
|
onFinish={handleEditSubmit}
|
||||||
|
>
|
||||||
|
<Form.Item
|
||||||
|
label="操作原因"
|
||||||
|
name="reason"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入操作原因" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="备注"
|
||||||
|
name="notes"
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={3} placeholder="请输入备注信息" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="修改原因"
|
||||||
|
name="modificationReason"
|
||||||
|
rules={[{ required: true, message: '请输入修改原因' }]}
|
||||||
|
>
|
||||||
|
<Input.TextArea rows={2} placeholder="请输入修改原因(必填)" />
|
||||||
|
</Form.Item>
|
||||||
|
<Form.Item
|
||||||
|
label="修改人"
|
||||||
|
name="operator"
|
||||||
|
>
|
||||||
|
<Input placeholder="请输入修改人姓名" />
|
||||||
|
</Form.Item>
|
||||||
|
</Form>
|
||||||
|
</Modal>
|
||||||
|
|
||||||
|
{/* 修改历史弹窗 */}
|
||||||
|
<Modal
|
||||||
|
title="日志修改历史"
|
||||||
|
open={historyModalVisible}
|
||||||
|
onCancel={() => {
|
||||||
|
setHistoryModalVisible(false);
|
||||||
|
setLogHistory([]);
|
||||||
|
}}
|
||||||
|
footer={null}
|
||||||
|
width={700}
|
||||||
|
>
|
||||||
|
{historyLoading ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px' }}>加载中...</div>
|
||||||
|
) : logHistory.length <= 1 ? (
|
||||||
|
<div style={{ textAlign: 'center', padding: '40px', color: '#888' }}>
|
||||||
|
该记录暂无修改历史
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Timeline mode="left">
|
||||||
|
{logHistory.map((item, index) => (
|
||||||
|
<Timeline.Item
|
||||||
|
key={item.id}
|
||||||
|
color={index === logHistory.length - 1 ? 'green' : 'blue'}
|
||||||
|
label={dayjs(item.createdAt).format('YYYY-MM-DD HH:mm:ss')}
|
||||||
|
>
|
||||||
|
<div style={{ marginBottom: 8 }}>
|
||||||
|
<Tag color={getOperationTag(item.operationType).props.color}>
|
||||||
|
{getOperationTag(item.operationType).props.children}
|
||||||
|
</Tag>
|
||||||
|
{item.modifiedBy && (
|
||||||
|
<Tag color="orange">已修改</Tag>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div style={{ fontSize: 12, color: '#666' }}>
|
||||||
|
<p><strong>耗材:</strong> {item.consumableName} ({item.consumableId})</p>
|
||||||
|
<p><strong>操作人:</strong> {item.operator}</p>
|
||||||
|
{item.reason && <p><strong>原因:</strong> {item.reason}</p>}
|
||||||
|
{item.notes && <p><strong>备注:</strong> {item.notes}</p>}
|
||||||
|
{item.modifiedBy && (
|
||||||
|
<>
|
||||||
|
<p><strong>修改人:</strong> {item.modifiedBy}</p>
|
||||||
|
<p><strong>修改时间:</strong> {dayjs(item.modifiedAt).format('YYYY-MM-DD HH:mm:ss')}</p>
|
||||||
|
{item.modificationReason && (
|
||||||
|
<p><strong>修改原因:</strong> {item.modificationReason}</p>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Timeline.Item>
|
||||||
|
))}
|
||||||
|
</Timeline>
|
||||||
|
)}
|
||||||
|
</Modal>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
import React, { useState, useEffect, useRef, useCallback, useMemo } from 'react';
|
||||||
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable, Progress } from 'antd';
|
import { Table, Button, Modal, Form, Input, Select, InputNumber, message, Card, Space, Popconfirm, Upload, Table as AntTable, Progress, Checkbox } from 'antd';
|
||||||
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
|
import { PlusOutlined, EditOutlined, DeleteOutlined, SearchOutlined, ExportOutlined, ImportOutlined, UploadOutlined, FileExcelOutlined, InboxOutlined } from '@ant-design/icons';
|
||||||
import axios from 'axios';
|
import axios from 'axios';
|
||||||
|
|
||||||
@@ -32,6 +32,7 @@ function ConsumableManagement() {
|
|||||||
const [stockRecord, setStockRecord] = useState(null);
|
const [stockRecord, setStockRecord] = useState(null);
|
||||||
const [stockType, setStockType] = useState('in');
|
const [stockType, setStockType] = useState('in');
|
||||||
const [stockForm] = Form.useForm();
|
const [stockForm] = Form.useForm();
|
||||||
|
const [maxStockUnlimited, setMaxStockUnlimited] = useState(false);
|
||||||
|
|
||||||
const fetchConsumables = useCallback(async (page = 1, pageSize = 10) => {
|
const fetchConsumables = useCallback(async (page = 1, pageSize = 10) => {
|
||||||
try {
|
try {
|
||||||
@@ -66,8 +67,14 @@ function ConsumableManagement() {
|
|||||||
const showModal = useCallback((consumable = null) => {
|
const showModal = useCallback((consumable = null) => {
|
||||||
setEditingConsumable(consumable);
|
setEditingConsumable(consumable);
|
||||||
if (consumable) {
|
if (consumable) {
|
||||||
form.setFieldsValue(consumable);
|
const isUnlimited = consumable.maxStock === null || consumable.maxStock === undefined;
|
||||||
|
setMaxStockUnlimited(isUnlimited);
|
||||||
|
form.setFieldsValue({
|
||||||
|
...consumable,
|
||||||
|
maxStock: isUnlimited ? undefined : consumable.maxStock
|
||||||
|
});
|
||||||
} else {
|
} else {
|
||||||
|
setMaxStockUnlimited(true);
|
||||||
form.resetFields();
|
form.resetFields();
|
||||||
}
|
}
|
||||||
setModalVisible(true);
|
setModalVisible(true);
|
||||||
@@ -80,12 +87,16 @@ function ConsumableManagement() {
|
|||||||
|
|
||||||
const handleSubmit = useCallback(async (values) => {
|
const handleSubmit = useCallback(async (values) => {
|
||||||
try {
|
try {
|
||||||
|
const submitData = {
|
||||||
|
...values,
|
||||||
|
maxStock: maxStockUnlimited ? null : values.maxStock
|
||||||
|
};
|
||||||
if (editingConsumable) {
|
if (editingConsumable) {
|
||||||
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, values);
|
await axios.put(`/api/consumables/${editingConsumable.consumableId}`, submitData);
|
||||||
message.success('耗材更新成功');
|
message.success('耗材更新成功');
|
||||||
} else {
|
} else {
|
||||||
await axios.post('/api/consumables', {
|
await axios.post('/api/consumables', {
|
||||||
...values,
|
...submitData,
|
||||||
consumableId: `CON${Date.now()}`
|
consumableId: `CON${Date.now()}`
|
||||||
});
|
});
|
||||||
message.success('耗材创建成功');
|
message.success('耗材创建成功');
|
||||||
@@ -97,7 +108,7 @@ function ConsumableManagement() {
|
|||||||
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
message.error(editingConsumable ? '耗材更新失败' : '耗材创建失败');
|
||||||
console.error('提交失败:', error);
|
console.error('提交失败:', error);
|
||||||
}
|
}
|
||||||
}, [editingConsumable, fetchConsumables]);
|
}, [editingConsumable, fetchConsumables, maxStockUnlimited]);
|
||||||
|
|
||||||
const handleDelete = useCallback(async (consumableId) => {
|
const handleDelete = useCallback(async (consumableId) => {
|
||||||
try {
|
try {
|
||||||
@@ -447,7 +458,8 @@ function ConsumableManagement() {
|
|||||||
title: '最大库存',
|
title: '最大库存',
|
||||||
dataIndex: 'maxStock',
|
dataIndex: 'maxStock',
|
||||||
key: 'maxStock',
|
key: 'maxStock',
|
||||||
width: 100
|
width: 100,
|
||||||
|
render: (value) => value === null || value === undefined ? '无限制' : value
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: '单价(元)',
|
title: '单价(元)',
|
||||||
@@ -579,8 +591,25 @@ function ConsumableManagement() {
|
|||||||
<Form.Item name="minStock" label="最小库存" rules={[{ required: true, message: '请输入最小库存' }]}>
|
<Form.Item name="minStock" label="最小库存" rules={[{ required: true, message: '请输入最小库存' }]}>
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<InputNumber min={0} style={{ width: '100%' }} />
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
<Form.Item name="maxStock" label="最大库存" rules={[{ required: true, message: '请输入最大库存' }]}>
|
<Form.Item label="最大库存">
|
||||||
<InputNumber min={0} style={{ width: '100%' }} />
|
<Space direction="vertical" style={{ width: '100%' }}>
|
||||||
|
<Checkbox
|
||||||
|
checked={maxStockUnlimited}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMaxStockUnlimited(e.target.checked);
|
||||||
|
if (e.target.checked) {
|
||||||
|
form.setFieldsValue({ maxStock: undefined });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
无限制
|
||||||
|
</Checkbox>
|
||||||
|
{!maxStockUnlimited && (
|
||||||
|
<Form.Item name="maxStock" noStyle rules={[{ required: true, message: '请输入最大库存' }]}>
|
||||||
|
<InputNumber min={0} style={{ width: '100%' }} placeholder="请输入最大库存" />
|
||||||
|
</Form.Item>
|
||||||
|
)}
|
||||||
|
</Space>
|
||||||
</Form.Item>
|
</Form.Item>
|
||||||
</Space>
|
</Space>
|
||||||
<Form.Item name="unitPrice" label="单价(元)">
|
<Form.Item name="unitPrice" label="单价(元)">
|
||||||
|
|||||||
@@ -741,7 +741,7 @@ function Dashboard() {
|
|||||||
style={cardStyle}
|
style={cardStyle}
|
||||||
onMouseEnter={() => setHoveredCard(statKey)}
|
onMouseEnter={() => setHoveredCard(statKey)}
|
||||||
onMouseLeave={() => setHoveredCard(null)}
|
onMouseLeave={() => setHoveredCard(null)}
|
||||||
bodyStyle={{ padding: 'clamp(16px, 3vw, 24px)' }}
|
styles={{ body: { padding: 'clamp(16px, 3vw, 24px)' } }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '12px' }}>
|
||||||
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', gap: '8px' }}>
|
||||||
|
|||||||
@@ -173,7 +173,7 @@ const RackCard = ({ rack, onEdit, onDelete, onView, selected, onSelect }) => {
|
|||||||
}}
|
}}
|
||||||
onClick={() => onSelect(rack.rackId)}
|
onClick={() => onSelect(rack.rackId)}
|
||||||
onDoubleClick={() => onView(rack)}
|
onDoubleClick={() => onView(rack)}
|
||||||
bodyStyle={{ padding: '20px' }}
|
styles={{ body: { padding: '20px' } }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
@@ -163,7 +163,7 @@ const RoomCard = ({ room, onEdit, onDelete, onView, selected, onSelect }) => {
|
|||||||
}}
|
}}
|
||||||
onClick={() => onSelect(room.roomId)}
|
onClick={() => onSelect(room.roomId)}
|
||||||
onDoubleClick={() => onView(room)}
|
onDoubleClick={() => onView(room)}
|
||||||
bodyStyle={{ padding: '20px' }}
|
styles={{ body: { padding: '20px' } }}
|
||||||
>
|
>
|
||||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: '16px' }}>
|
||||||
<div>
|
<div>
|
||||||
|
|||||||
Reference in New Issue
Block a user