refactor(devices): 实现设备字段动态校验与配置同步
1. 新增动态设备校验Schema生成器,支持从数据库读取字段配置生成校验规则 2. 重构设备增改接口,使用动态校验替代硬编码Schema 3. 调整前端设备表单,适配字段配置并锁定核心系统字段 4. 修复前后端默认字段配置不一致问题 5. 新增字段管理页面防护,锁定核心字段的必填/可见配置
This commit is contained in:
@@ -1,10 +1,16 @@
|
||||
const logger = require('../utils/logger').module('ValidationMiddleware');
|
||||
|
||||
const validate = (schema, source = 'body') => {
|
||||
const validate = (schemaOrFn, source = 'body') => {
|
||||
return async (req, res, next) => {
|
||||
const data = source === 'query' ? req.query : req.body;
|
||||
|
||||
try {
|
||||
// 支持函数形式的schema(动态schema:异步函数或同步函数返回Joi schema)
|
||||
let schema = schemaOrFn;
|
||||
if (typeof schemaOrFn === 'function') {
|
||||
schema = await schemaOrFn();
|
||||
}
|
||||
|
||||
let value;
|
||||
|
||||
if (schema.validate && typeof schema.validate === 'function') {
|
||||
|
||||
@@ -16,13 +16,12 @@ const {
|
||||
} = require('../utils/operationLogger');
|
||||
const { validateBody, validateQuery } = require('../middleware/validation');
|
||||
const {
|
||||
createDeviceSchema,
|
||||
updateDeviceSchema,
|
||||
batchDeviceIdsSchema,
|
||||
batchStatusSchema,
|
||||
batchMoveSchema,
|
||||
queryDeviceSchema,
|
||||
} = require('../validation/deviceSchema');
|
||||
const { createDeviceSchema } = require('../validation/dynamicDeviceSchema');
|
||||
|
||||
const PREVIEW_COUNT = 20;
|
||||
|
||||
@@ -736,7 +735,7 @@ async function generateDeviceId() {
|
||||
}
|
||||
|
||||
// 创建设备
|
||||
router.post('/', validateBody(createDeviceSchema), async (req, res) => {
|
||||
router.post('/', validateBody(() => createDeviceSchema(false)), async (req, res) => {
|
||||
try {
|
||||
const deviceData = { ...req.body };
|
||||
|
||||
@@ -2285,7 +2284,7 @@ router.get('/:deviceId/tickets', async (req, res) => {
|
||||
});
|
||||
|
||||
// 更新设备
|
||||
router.put('/:deviceId', validateBody(updateDeviceSchema), async (req, res) => {
|
||||
router.put('/:deviceId', validateBody(() => createDeviceSchema(true)), async (req, res) => {
|
||||
try {
|
||||
const oldDevice = await Device.findByPk(req.params.deviceId);
|
||||
if (!oldDevice) {
|
||||
|
||||
@@ -114,8 +114,6 @@ const queryDeviceSchema = Joi.object({
|
||||
});
|
||||
|
||||
module.exports = {
|
||||
createDeviceSchema,
|
||||
updateDeviceSchema,
|
||||
batchDeviceIdsSchema,
|
||||
batchStatusSchema,
|
||||
batchMoveSchema,
|
||||
|
||||
@@ -0,0 +1,146 @@
|
||||
/**
|
||||
* 动态设备字段验证Schema生成器
|
||||
* 根据数据库DeviceField表的配置动态生成Joi验证Schema
|
||||
* 系统核心字段(name/serialNumber/position/height)强制锁定必填
|
||||
*/
|
||||
|
||||
const Joi = require('joi');
|
||||
const DeviceField = require('../models/DeviceField');
|
||||
|
||||
const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other'];
|
||||
const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault', 'idle'];
|
||||
|
||||
// 强制锁定必填的字段(不受字段管理配置影响)
|
||||
const FORCE_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height'];
|
||||
|
||||
/**
|
||||
* 根据字段配置生成单个字段的Joi校验器
|
||||
* @param {Object} field - DeviceField数据库记录
|
||||
* @param {boolean} isUpdate - 是否为更新模式
|
||||
* @returns {Joi.AnySchema} 该字段对应的Joi校验器
|
||||
*/
|
||||
function buildFieldValidator(field, isUpdate) {
|
||||
const { fieldName, required: fieldRequired } = field;
|
||||
// 系统核心字段强制必填
|
||||
const isRequired = FORCE_REQUIRED_FIELDS.includes(fieldName) || fieldRequired;
|
||||
|
||||
let validator;
|
||||
|
||||
switch (fieldName) {
|
||||
case 'name':
|
||||
validator = Joi.string().max(100).messages({
|
||||
'string.empty': '设备名称不能为空',
|
||||
'string.max': '设备名称不能超过100个字符',
|
||||
'any.required': '设备名称是必填字段',
|
||||
});
|
||||
if (isRequired && !isUpdate) validator = validator.required();
|
||||
else validator = validator.allow('', null);
|
||||
break;
|
||||
|
||||
case 'type':
|
||||
validator = Joi.string().valid(...DEVICE_TYPES).messages({
|
||||
'any.only': `设备类型必须是以下之一: ${DEVICE_TYPES.join(', ')}`,
|
||||
'any.required': '设备类型是必填字段',
|
||||
});
|
||||
if (isRequired && !isUpdate) validator = validator.required();
|
||||
break;
|
||||
|
||||
case 'serialNumber':
|
||||
validator = Joi.string().max(100).messages({
|
||||
'string.empty': '序列号不能为空',
|
||||
'string.max': '序列号不能超过100个字符',
|
||||
'any.required': '序列号是必填字段',
|
||||
});
|
||||
if (isRequired && !isUpdate) validator = validator.required();
|
||||
else validator = validator.allow('', null);
|
||||
break;
|
||||
|
||||
case 'rackId':
|
||||
validator = Joi.string().allow('', null).max(50);
|
||||
break;
|
||||
|
||||
case 'position':
|
||||
validator = Joi.number().integer().min(1).max(100).allow(null);
|
||||
break;
|
||||
|
||||
case 'height':
|
||||
validator = Joi.number().integer().min(1).max(50).allow(null);
|
||||
break;
|
||||
|
||||
case 'powerConsumption':
|
||||
validator = Joi.number().min(0).max(100000).allow(null);
|
||||
break;
|
||||
|
||||
case 'status':
|
||||
validator = Joi.string().valid(...DEVICE_STATUS).default('offline');
|
||||
break;
|
||||
|
||||
case 'model':
|
||||
validator = Joi.string().allow('', null).max(100);
|
||||
break;
|
||||
|
||||
case 'ipAddress':
|
||||
validator = Joi.string().allow('', null).max(50);
|
||||
break;
|
||||
|
||||
case 'description':
|
||||
validator = Joi.string().allow('', null).max(500);
|
||||
break;
|
||||
|
||||
case 'purchaseDate':
|
||||
case 'warrantyExpiry':
|
||||
validator = Joi.date().allow(null);
|
||||
break;
|
||||
|
||||
case 'brand':
|
||||
validator = Joi.string().allow('', null).max(100);
|
||||
break;
|
||||
|
||||
default:
|
||||
// 自定义字段走宽松校验
|
||||
validator = Joi.any().allow(null);
|
||||
}
|
||||
|
||||
return validator;
|
||||
}
|
||||
|
||||
/**
|
||||
* 动态生成设备创建/更新的Joi Schema
|
||||
* 每次请求时从DeviceField表读取最新配置
|
||||
* @param {boolean} isUpdate - 是否为更新模式
|
||||
* @returns {Promise<Joi.ObjectSchema>} 动态生成的Joi Schema
|
||||
*/
|
||||
async function createDeviceSchema(isUpdate = false) {
|
||||
// 查询所有字段配置
|
||||
const fieldConfigs = await DeviceField.findAll({
|
||||
order: [['order', 'ASC']],
|
||||
});
|
||||
|
||||
const schemaMap = {};
|
||||
|
||||
// 根据字段配置动态生成每个字段的校验器
|
||||
fieldConfigs.forEach(field => {
|
||||
schemaMap[field.fieldName] = buildFieldValidator(field, isUpdate);
|
||||
});
|
||||
|
||||
// 补充customFields(不在DeviceField表中)
|
||||
schemaMap.customFields = Joi.object().allow(null);
|
||||
|
||||
let schema = Joi.object(schemaMap);
|
||||
|
||||
// 更新模式要求至少传一个字段
|
||||
if (isUpdate) {
|
||||
schema = schema.min(1).messages({
|
||||
'object.min': '至少需要提供一个字段进行更新',
|
||||
});
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDeviceSchema,
|
||||
DEVICE_TYPES,
|
||||
DEVICE_STATUS,
|
||||
FORCE_REQUIRED_FIELDS,
|
||||
};
|
||||
@@ -0,0 +1,418 @@
|
||||
# 设备字段必填规则与可见性同步设计文档
|
||||
|
||||
> 版本:1.0.0 | 更新日期:2026-06-12
|
||||
|
||||
---
|
||||
|
||||
## 1. 问题概述
|
||||
|
||||
### 1.1 现状
|
||||
|
||||
设备字段管理页面(`/api/deviceFields`)允许用户配置每个字段的:
|
||||
- **是否必填**(`required`)
|
||||
- **是否可见**(`visible`)
|
||||
|
||||
但在设备管理页面添加/编辑设备时,这些配置**完全不生效**。具体表现为:
|
||||
|
||||
| 层面 | 当前行为 | 预期行为 |
|
||||
|------|---------|---------|
|
||||
| **后端 Joi Schema** | 硬编码写死,不查询数据库 | 从 `DeviceField` 表动态读取 `required` 配置 |
|
||||
| **前端表单渲染** | `rackId/position/height` 跳过字段配置,硬编码必填 | 跟随字段配置,但对强依赖字段做防护 |
|
||||
| **前端默认常量** | `powerConsumption` 与后端不一致 | 与后端 `initDeviceFields.js` 保持同步 |
|
||||
|
||||
### 1.2 影响范围
|
||||
|
||||
- 用户在字段管理页面修改配置后感到困惑(修改不生效)
|
||||
- 无法灵活控制不同场景下的业务校验规则
|
||||
- `powerConsumption` 等字段的前后端默认值不一致
|
||||
|
||||
---
|
||||
|
||||
## 2. 字段强依赖分析
|
||||
|
||||
### 2.1 系统字段依赖矩阵
|
||||
|
||||
| 字段名 | 字段标识 | 系统字段 | 下游依赖场景 | 是否可改必填 | 是否可改可见 |
|
||||
|--------|---------|---------|-------------|------------|------------|
|
||||
| 设备名称 | `name` | 是 | 操作日志、工单关联、拓扑图标签、导出文件名 | **否**(强制必填) | **否**(强制可见) |
|
||||
| 设备类型 | `type` | 是 | 搜索筛选、统计分类、3D可视化类型渲染 | 是 | 是(但筛选功能受限) |
|
||||
| 序列号 | `serialNumber` | 是 | **设备唯一标识**:端口关联(按SN)、盘点扫码匹配、空闲设备恢复、工单关联、导入去重、搜索定位 | **否**(强制必填) | **否**(强制可见) |
|
||||
| 所在机柜 | `rackId` | 是 | 3D可视化定位、机柜容量统计、U位冲突检测、机柜功率计算 | 是 | 是 |
|
||||
| 位置(U) | `position` | 是 | 3D可视化定位、U位冲突检测、机柜容量计算 | **否**(强制必填) | 是 |
|
||||
| 高度(U) | `height` | 是 | 3D可视化渲染、U位占用计算 | **否**(强制必填) | 是 |
|
||||
| 状态 | `status` | 是 | 搜索筛选、统计卡片、3D可视化颜色 | 是 | 是(但筛选功能受限) |
|
||||
| 设备型号 | `model` | 是 | 搜索筛选 | 是 | 是 |
|
||||
| 功率(W) | `powerConsumption` | 否 | 机柜功率统计、机房功率监控 | 是 | 是 |
|
||||
| IP地址 | `ipAddress` | 否 | 搜索筛选、端口关联 | 是 | 是 |
|
||||
| 购买日期 | `purchaseDate` | 否 | 无强依赖 | 是 | 是 |
|
||||
| 保修到期 | `warrantyExpiry` | 否 | 无强依赖 | 是 | 是 |
|
||||
| 描述 | `description` | 否 | 无强依赖 | 是 | 是 |
|
||||
| 品牌 | `brand` | 否 | 无强依赖 | 是 | 是 |
|
||||
|
||||
### 2.2 强依赖字段判定标准
|
||||
|
||||
满足以下任一条件的字段,标记为**强制锁定字段**:
|
||||
|
||||
1. **唯一标识性**:作为设备在系统中的唯一标识,被其他模块硬引用(如 `serialNumber`)
|
||||
2. **显示必要性**:无此字段设备无法在其他模块中被识别(如 `name`)
|
||||
3. **物理定位性**:无此字段设备无法在3D/平面图中定位(如 `position`、`height`)
|
||||
|
||||
---
|
||||
|
||||
## 3. 修复方案
|
||||
|
||||
### 3.1 总体架构
|
||||
|
||||
```
|
||||
+------------------+ +------------------+ +------------------+
|
||||
| 字段管理页面 | ----> | DeviceField表 | <---- | 启动初始化 |
|
||||
| (FieldConfig) | PUT | (数据库) | | (initDeviceFields)|
|
||||
+------------------+ +------------------+ +------------------+
|
||||
|
|
||||
+--------------+--------------+
|
||||
| | |
|
||||
v v v
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| 设备添加 | | 设备导入 | | 设备编辑 |
|
||||
| 前端表单 | | 后端导入 | | 后端接口 |
|
||||
| (Device | | 验证 | | (Joi |
|
||||
| FormModal)| | (已正常) | | 动态Schema)|
|
||||
+-----------+ +-----------+ +-----------+
|
||||
| |
|
||||
| 读取field.required | 动态查询DeviceField
|
||||
v v
|
||||
表单Item rules Joi Schema校验
|
||||
(强制锁定字段额外防护) (强制锁定字段额外防护)
|
||||
```
|
||||
|
||||
### 3.2 方案一:后端 Joi Schema 动态化(核心)
|
||||
|
||||
#### 3.2.1 新增文件
|
||||
|
||||
**`backend/validation/dynamicDeviceSchema.js`**
|
||||
|
||||
```javascript
|
||||
const Joi = require('joi');
|
||||
const DeviceField = require('../models/DeviceField');
|
||||
|
||||
const DEVICE_TYPES = ['server', 'switch', 'router', 'storage', 'other'];
|
||||
const DEVICE_STATUS = ['running', 'maintenance', 'offline', 'fault', 'idle'];
|
||||
|
||||
// 强制锁定字段列表(不受字段管理配置影响)
|
||||
const FORCE_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height'];
|
||||
|
||||
/**
|
||||
* 动态生建设备创建Joi Schema
|
||||
* 从DeviceField表读取字段配置,结合强制锁定字段规则
|
||||
* @param {boolean} isUpdate - 是否为更新模式
|
||||
* @returns {Promise<Joi.ObjectSchema>}
|
||||
*/
|
||||
async function createDeviceSchema(isUpdate = false) {
|
||||
const fieldConfigs = await DeviceField.findAll({
|
||||
order: [['order', 'ASC']],
|
||||
});
|
||||
|
||||
const schemaMap = {};
|
||||
|
||||
fieldConfigs.forEach(field => {
|
||||
let validator;
|
||||
|
||||
// 判断是否强制必填
|
||||
const isRequired = FORCE_REQUIRED_FIELDS.includes(field.fieldName) || field.required;
|
||||
|
||||
switch (field.fieldName) {
|
||||
case 'name':
|
||||
validator = Joi.string().max(100);
|
||||
if (isRequired) validator = validator.required();
|
||||
else validator = validator.allow('', null);
|
||||
break;
|
||||
case 'type':
|
||||
validator = Joi.string().valid(...DEVICE_TYPES);
|
||||
if (isRequired) validator = validator.required();
|
||||
break;
|
||||
case 'serialNumber':
|
||||
validator = Joi.string().max(100);
|
||||
if (isRequired) validator = validator.required();
|
||||
else validator = validator.allow('', null);
|
||||
break;
|
||||
case 'rackId':
|
||||
validator = Joi.string().allow('', null).max(50);
|
||||
break;
|
||||
case 'position':
|
||||
validator = Joi.number().integer().min(1).max(100).allow(null);
|
||||
break;
|
||||
case 'height':
|
||||
validator = Joi.number().integer().min(1).max(50).allow(null);
|
||||
break;
|
||||
case 'powerConsumption':
|
||||
validator = Joi.number().min(0).max(100000).allow(null);
|
||||
break;
|
||||
case 'status':
|
||||
validator = Joi.string().valid(...DEVICE_STATUS).default('offline');
|
||||
break;
|
||||
case 'model':
|
||||
case 'ipAddress':
|
||||
case 'description':
|
||||
validator = Joi.string().allow('', null).max(100);
|
||||
break;
|
||||
case 'purchaseDate':
|
||||
case 'warrantyExpiry':
|
||||
validator = Joi.date().allow(null);
|
||||
break;
|
||||
default:
|
||||
// 自定义字段走宽松校验
|
||||
validator = Joi.any().allow(null);
|
||||
}
|
||||
|
||||
// 仅在创建模式下(非更新模式)且字段非强制锁定、且数据库配置为required时使用required()
|
||||
// 更新模式下避免object.min(1)策略与动态required冲突
|
||||
schemaMap[field.fieldName] = validator;
|
||||
});
|
||||
|
||||
// 补充未在DeviceField表中的字段
|
||||
schemaMap.customFields = Joi.object().allow(null);
|
||||
|
||||
let schema = Joi.object(schemaMap);
|
||||
|
||||
if (isUpdate) {
|
||||
schema = schema.min(1).messages({
|
||||
'object.min': '至少需要提供一个字段进行更新',
|
||||
});
|
||||
}
|
||||
|
||||
return schema;
|
||||
}
|
||||
|
||||
module.exports = {
|
||||
createDeviceSchema,
|
||||
DEVICE_TYPES,
|
||||
DEVICE_STATUS,
|
||||
FORCE_REQUIRED_FIELDS,
|
||||
};
|
||||
```
|
||||
|
||||
#### 3.2.2 修改文件
|
||||
|
||||
**`backend/routes/devices.js`**
|
||||
|
||||
- 移除对静态 `createDeviceSchema`、`updateDeviceSchema` 的引用
|
||||
- `router.post('/')` 中请求到来时调用 `createDeviceSchema(false)` 生成动态 schema 进行验证
|
||||
- `router.put('/:deviceId')` 中调用 `createDeviceSchema(true)` 生成动态 schema
|
||||
|
||||
```javascript
|
||||
// 替换:
|
||||
// const { createDeviceSchema, updateDeviceSchema } = require('../validation/deviceSchema');
|
||||
// 为:
|
||||
const { createDeviceSchema } = require('../validation/dynamicDeviceSchema');
|
||||
```
|
||||
|
||||
**`backend/validation/deviceSchema.js`**
|
||||
|
||||
- 移除 `createDeviceSchema`、`updateDeviceSchema` 导出
|
||||
- 保留 `batchDeviceIdsSchema`、`batchStatusSchema`、`batchMoveSchema`、`queryDeviceSchema`(这些与字段配置无关)
|
||||
- `DEVICE_TYPES`、`DEVICE_STATUS` 枚举可保留供其他模块引用
|
||||
|
||||
### 3.3 方案二:前端表单完整适配字段配置
|
||||
|
||||
#### 3.3.1 修改文件
|
||||
|
||||
**`frontend/src/components/device/DeviceFormModal.jsx`**
|
||||
|
||||
**修改点 A** — 取消 `rackId/position/height` 的排除(第272~278行):
|
||||
|
||||
```javascript
|
||||
// 修改前:排除rackId/position/height
|
||||
const filteredFields = deviceFields.filter(
|
||||
field =>
|
||||
field.fieldName !== 'deviceId' &&
|
||||
field.fieldName !== 'rackId' && // 移除
|
||||
field.fieldName !== 'position' && // 移除
|
||||
field.fieldName !== 'height' // 移除
|
||||
);
|
||||
|
||||
// 修改后:只排除deviceId
|
||||
const filteredFields = deviceFields.filter(
|
||||
field => field.fieldName !== 'deviceId'
|
||||
);
|
||||
```
|
||||
|
||||
**修改点 B** — "设备位置选择"区块(第326~429行)的 rules 改为动态:
|
||||
|
||||
```javascript
|
||||
// 修改前:硬编码required: true
|
||||
<Form.Item
|
||||
name="rackId"
|
||||
rules={[{ required: true, message: '请选择机柜' }]}
|
||||
>...
|
||||
|
||||
// 修改后:读取字段配置
|
||||
const rackField = deviceFields.find(f => f.fieldName === 'rackId');
|
||||
// ... 使用 rackField?.required 决定是否需要 required 校验
|
||||
// 强制锁定字段:position/height 即使字段配置为非必填,仍强制必填
|
||||
const posField = deviceFields.find(f => f.fieldName === 'position');
|
||||
const isPositionRequired = true; // 强制锁定
|
||||
<Form.Item
|
||||
name="position"
|
||||
rules={isPositionRequired ? [{ required: true, message: '请输入U位' }] : []}
|
||||
>...
|
||||
```
|
||||
|
||||
**修改点 C** — 位置信息如果设为非必填,提交时给警告:
|
||||
|
||||
```javascript
|
||||
const handleSubmit = values => {
|
||||
if (!values.position || !values.height) {
|
||||
Modal.warning({
|
||||
title: '位置信息不完整',
|
||||
content: '位置(U位)或高度信息为空,设备将无法在3D视图中准确定位,建议填写完整。',
|
||||
});
|
||||
}
|
||||
// ... 继续提交
|
||||
};
|
||||
```
|
||||
|
||||
### 3.4 方案三:字段管理页面防护
|
||||
|
||||
#### 3.4.1 修改文件
|
||||
|
||||
**`frontend/src/pages/FieldConfig.jsx`**(或对应字段管理页面组件)
|
||||
|
||||
对强制锁定字段的"必填"和"可见"开关做禁用处理:
|
||||
|
||||
| 字段 | 必填开关 | 可见开关 |
|
||||
|------|---------|---------|
|
||||
| `name` | 禁用,显示"系统核心字段" | 禁用,显示"系统核心字段" |
|
||||
| `serialNumber` | 禁用,显示"系统核心字段" | 禁用,显示"系统核心字段" |
|
||||
| `position` | 禁用,显示"3D定位依赖" | 启用 |
|
||||
| `height` | 禁用,显示"3D定位依赖" | 启用 |
|
||||
|
||||
```jsx
|
||||
// 伪代码逻辑
|
||||
const isLockedRequired = ['name', 'serialNumber', 'position', 'height'].includes(field.fieldName);
|
||||
const isLockedVisible = ['name', 'serialNumber'].includes(field.fieldName);
|
||||
|
||||
<Form.Item label="必填">
|
||||
<Switch
|
||||
checked={field.required}
|
||||
disabled={isLockedRequired}
|
||||
title={isLockedRequired ? '系统核心字段,不可关闭必填' : ''}
|
||||
/>
|
||||
</Form.Item>
|
||||
```
|
||||
|
||||
### 3.5 方案四:同步默认值
|
||||
|
||||
#### 3.5.1 修改文件
|
||||
|
||||
**`frontend/src/constants/deviceManagementConstants.js`**
|
||||
|
||||
| 字段 | 修改前 | 修改后 |
|
||||
|------|-------|-------|
|
||||
| `deviceId` 的 `required` | `true` | `false` |
|
||||
| `powerConsumption` 的 `required` | `false` | `true` |
|
||||
|
||||
#### 3.5.2 修改文件
|
||||
|
||||
**`backend/validation/deviceSchema.js`**
|
||||
|
||||
- 移除 `createDeviceSchema`、`updateDeviceSchema` 的 `module.exports`
|
||||
- 保留 `batchDeviceIdsSchema`、`batchStatusSchema`、`batchMoveSchema`、`queryDeviceSchema`
|
||||
|
||||
---
|
||||
|
||||
## 4. 数据流对比
|
||||
|
||||
### 4.1 修复前数据流
|
||||
|
||||
```
|
||||
字段管理页面修改 required=true → DeviceField表 更新
|
||||
|
|
||||
┌─────────────────────┘
|
||||
▼ (无读取)
|
||||
后端 Joi Schema (硬编码) ← 忽略数据库配置,直接拦截请求
|
||||
前端 DeviceFormModal ← 只读部分字段配置,position等硬编码
|
||||
```
|
||||
|
||||
### 4.2 修复后数据流
|
||||
|
||||
```
|
||||
字段管理页面修改 required=true/false → DeviceField表 更新
|
||||
|
|
||||
┌─────────────────────────┴─────────────┐
|
||||
▼ ▼
|
||||
设备添加接口 (POST /api/devices) 设备添加弹窗 (DeviceFormModal)
|
||||
│ │
|
||||
▼ ▼
|
||||
createDeviceSchema(false) GET /api/deviceFields
|
||||
│ │
|
||||
▼ ▼
|
||||
动态查询DeviceField表 读取 field.required
|
||||
强制锁定字段 = required() 强制锁定字段覆盖为必填
|
||||
其他字段跟随数据库配置 其他字段跟随配置
|
||||
│ │
|
||||
▼ ▼
|
||||
Joi校验 → 通过 → Device.create() Form.Item rules 动态生成
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 风险与注意事项
|
||||
|
||||
### 5.1 兼容性
|
||||
|
||||
- 本方案为**新功能**(让字段管理配置真正生效),不涉及向后兼容性破坏
|
||||
- 已有数据库中的 `DeviceField` 配置保持不变
|
||||
- 已有 `Device` 表中的数据不受影响
|
||||
|
||||
### 5.2 边界情况
|
||||
|
||||
| 场景 | 处理策略 |
|
||||
|------|---------|
|
||||
| DeviceField 表为空(首次部署) | 动态 schema 降级为最小验证集 |
|
||||
| 用户在字段管理修改后立即添加设备 | 实时查询 DeviceField 表,无需缓存 |
|
||||
| 高并发场景 | 每次创建设备都查表,建议后续可加内存缓存+过期机制 |
|
||||
| 自定义字段(非预定义字段) | 走 `Joi.any().allow(null)` 不限制 |
|
||||
|
||||
### 5.3 性能考量
|
||||
|
||||
每次创建/更新设备时多一次 `DeviceField.findAll()` 查询。由于:
|
||||
- `DeviceField` 表数据量很小(<50 条)
|
||||
- 设备创建/更新频率远低于查询频率
|
||||
- 无复杂关联查询
|
||||
|
||||
该额外查询对性能影响可以忽略不计。
|
||||
|
||||
---
|
||||
|
||||
## 6. 实施计划
|
||||
|
||||
| 步骤 | 文件 | 工作量估算 | 说明 |
|
||||
|------|------|-----------|------|
|
||||
| 1 | 新建 `dynamicDeviceSchema.js` | ~60 行 | 核心动态 schema 生成逻辑 |
|
||||
| 2 | 修改 `devices.js` | ~10 行 | 替换静态 schema 引用 |
|
||||
| 3 | 修改 `deviceSchema.js` | ~5 行 | 移除已迁移的导出 |
|
||||
| 4 | 修改 `DeviceFormModal.jsx` | ~30 行 | 适配字段配置 |
|
||||
| 5 | 修改 `FieldConfig.jsx` | ~20 行 | 锁定字段防护 |
|
||||
| 6 | 修改 `deviceManagementConstants.js` | ~2 行 | 同步默认值 |
|
||||
|
||||
---
|
||||
|
||||
## 7. 附录
|
||||
|
||||
### 7.1 相关文件清单
|
||||
|
||||
| 文件路径 | 操作类型 |
|
||||
|----------|---------|
|
||||
| `backend/validation/dynamicDeviceSchema.js` | **新增** |
|
||||
| `backend/routes/devices.js` | 修改 |
|
||||
| `backend/validation/deviceSchema.js` | 修改 |
|
||||
| `frontend/src/components/device/DeviceFormModal.jsx` | 修改 |
|
||||
| `frontend/src/pages/FieldConfig.jsx` | 修改 |
|
||||
| `frontend/src/constants/deviceManagementConstants.js` | 修改 |
|
||||
|
||||
### 7.2 参考
|
||||
|
||||
- 后端导入功能的必填验证逻辑(`backend/routes/devices.js` 第1191~1215行)已正确查询 `DeviceField` 表,本设计参考其实现模式
|
||||
- 设备模型定义:`backend/models/Device.js`
|
||||
- 设备字段模型定义:`backend/models/DeviceField.js`
|
||||
- 默认字段初始化:`backend/initDeviceFields.js`
|
||||
@@ -269,14 +269,22 @@ const DeviceFormModal = ({
|
||||
}
|
||||
};
|
||||
|
||||
// 强制锁定必填的字段(不受字段管理配置影响)
|
||||
const FORCE_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height'];
|
||||
|
||||
const filteredFields = deviceFields.filter(
|
||||
field =>
|
||||
field.fieldName !== 'deviceId' &&
|
||||
field.fieldName !== 'rackId' &&
|
||||
field.fieldName !== 'position' &&
|
||||
field.fieldName !== 'height'
|
||||
field => field.fieldName !== 'deviceId'
|
||||
);
|
||||
|
||||
// 获取关键字段的配置(用于设备位置区块)
|
||||
const rackFieldConfig = deviceFields.find(f => f.fieldName === 'rackId');
|
||||
const positionFieldConfig = deviceFields.find(f => f.fieldName === 'position');
|
||||
const heightFieldConfig = deviceFields.find(f => f.fieldName === 'height');
|
||||
|
||||
// 动态判断是否必填(强制锁定字段 > 字段配置)
|
||||
const isPositionRequired = FORCE_REQUIRED_FIELDS.includes('position') || positionFieldConfig?.required;
|
||||
const isHeightRequired = FORCE_REQUIRED_FIELDS.includes('height') || heightFieldConfig?.required;
|
||||
|
||||
const formItems = [];
|
||||
filteredFields.forEach(field => {
|
||||
if (field.fieldName === 'serialNumber') {
|
||||
@@ -356,10 +364,10 @@ const DeviceFormModal = ({
|
||||
label={
|
||||
<span>
|
||||
机柜
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
{rackFieldConfig?.required && <span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>}
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请选择机柜' }]}
|
||||
rules={rackFieldConfig?.required ? [{ required: true, message: '请选择机柜' }] : []}
|
||||
style={{ marginBottom: '0' }}
|
||||
>
|
||||
<Select
|
||||
@@ -389,10 +397,10 @@ const DeviceFormModal = ({
|
||||
label={
|
||||
<span>
|
||||
安装位置 (U位)
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
{isPositionRequired && <span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>}
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入U位' }]}
|
||||
rules={isPositionRequired ? [{ required: true, message: '请输入U位' }] : []}
|
||||
style={{ marginBottom: '0' }}
|
||||
>
|
||||
<InputNumber
|
||||
@@ -410,10 +418,10 @@ const DeviceFormModal = ({
|
||||
label={
|
||||
<span>
|
||||
设备高度 (U)
|
||||
<span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>
|
||||
{isHeightRequired && <span style={{ color: '#ff4d4f', marginLeft: '4px' }}>*</span>}
|
||||
</span>
|
||||
}
|
||||
rules={[{ required: true, message: '请输入设备高度' }]}
|
||||
rules={isHeightRequired ? [{ required: true, message: '请输入设备高度' }] : []}
|
||||
initialValue={1}
|
||||
style={{ marginBottom: '0' }}
|
||||
>
|
||||
|
||||
@@ -30,7 +30,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldName: 'deviceId',
|
||||
displayName: '设备ID',
|
||||
fieldType: 'text',
|
||||
required: true,
|
||||
required: false,
|
||||
visible: true,
|
||||
editable: false,
|
||||
},
|
||||
@@ -101,7 +101,7 @@ export const DEFAULT_DEVICE_FIELDS = [
|
||||
fieldName: 'powerConsumption',
|
||||
displayName: '功率(W)',
|
||||
fieldType: 'number',
|
||||
required: false,
|
||||
required: true,
|
||||
visible: true,
|
||||
editable: true,
|
||||
},
|
||||
|
||||
@@ -338,6 +338,11 @@ const FIELD_TYPE_OPTIONS = [
|
||||
{ value: 'textarea', label: '多行文本' },
|
||||
];
|
||||
|
||||
// 强制锁定必填的系统核心字段(不受字段管理配置影响)
|
||||
const FORCE_LOCKED_REQUIRED_FIELDS = ['name', 'serialNumber', 'position', 'height'];
|
||||
// 强制锁定可见的系统核心字段(在其他模块强引用,不可关闭可见)
|
||||
const FORCE_LOCKED_VISIBLE_FIELDS = ['name', 'serialNumber'];
|
||||
|
||||
function DeviceFieldManagement() {
|
||||
const [fields, setFields] = useState([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -668,8 +673,17 @@ function DeviceFieldManagement() {
|
||||
label={<span style={formLabelStyle}>必填</span>}
|
||||
valuePropName="checked"
|
||||
style={formItemFlexStyle}
|
||||
tooltip={
|
||||
editingField && FORCE_LOCKED_REQUIRED_FIELDS.includes(editingField.fieldName)
|
||||
? '系统核心字段,不可关闭必填'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Switch />
|
||||
<Switch
|
||||
disabled={
|
||||
editingField && FORCE_LOCKED_REQUIRED_FIELDS.includes(editingField.fieldName)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item
|
||||
@@ -677,8 +691,18 @@ function DeviceFieldManagement() {
|
||||
label={<span style={formLabelStyle}>可见</span>}
|
||||
valuePropName="checked"
|
||||
style={formItemFlexStyle}
|
||||
tooltip={
|
||||
editingField && FORCE_LOCKED_VISIBLE_FIELDS.includes(editingField.fieldName)
|
||||
? '系统核心字段,在其他模块中被引用,不可关闭可见'
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<Switch defaultChecked />
|
||||
<Switch
|
||||
defaultChecked
|
||||
disabled={
|
||||
editingField && FORCE_LOCKED_VISIBLE_FIELDS.includes(editingField.fieldName)
|
||||
}
|
||||
/>
|
||||
</Form.Item>
|
||||
</div>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user