diff --git a/apps/web-antd/src/api/iot/device/device/index.ts b/apps/web-antd/src/api/iot/device/device/index.ts index ca2a17ada..06038e532 100644 --- a/apps/web-antd/src/api/iot/device/device/index.ts +++ b/apps/web-antd/src/api/iot/device/device/index.ts @@ -25,6 +25,17 @@ export namespace IotDeviceApi { latitude?: number; // 设备位置的纬度 longitude?: number; // 设备位置的经度 createTime?: Date; // 创建时间 + /** 所属子系统编号(null = 未归属,⚠️ A2 存量设备可能为 NULL) */ + subsystemId?: null | number; + /** 所属子系统名称(后端 join,便于列表直接展示) */ + subsystemName?: string; + } + + /** 绑定子系统请求体(单设备) */ + export interface BindSubsystemReqVO { + deviceId: number; + /** null 表示解绑(移除归属) */ + subsystemId: null | number; } /** 设备更新分组 Request VO */ @@ -232,3 +243,12 @@ export function getUnboundSubDevicePage(params: PageParam) { { params }, ); } + +/** + * 单设备绑定/解绑子系统 + * ⚠️ [F11] B11 需要实现:PUT /iot/device/bindSubsystem + * subsystemId=null 表示解绑(移除归属) + */ +export function bindDeviceSubsystem(data: IotDeviceApi.BindSubsystemReqVO) { + return requestClient.put('/iot/device/bindSubsystem', data); +} diff --git a/apps/web-antd/src/locales/langs/en-US/page.json b/apps/web-antd/src/locales/langs/en-US/page.json index 203a45fb3..e0079da86 100644 --- a/apps/web-antd/src/locales/langs/en-US/page.json +++ b/apps/web-antd/src/locales/langs/en-US/page.json @@ -41,6 +41,24 @@ } }, "iot": { + "device": { + "filter": { + "subsystem": { + "placeholder": "All Subsystems", + "unassigned": "Unassigned", + "bind": "Bind Subsystem", + "bindTitle": "Bind Subsystem", + "batchBind": "Batch Bind Subsystem", + "batchBindTitle": "Batch Bind Subsystem", + "batchBindSuccess": "{0} device(s) bound successfully", + "bindSuccess": "Bound successfully", + "selectRequired": "Please select a subsystem first", + "selectDeviceTip": "Please select devices first", + "binding": "Binding...", + "batchBindHint": "{0} device(s) selected, max 100 per batch" + } + } + }, "subsystem": { "title": "Subsystem", "listTitle": "Subsystem List", diff --git a/apps/web-antd/src/locales/langs/zh-CN/page.json b/apps/web-antd/src/locales/langs/zh-CN/page.json index b3774e0d4..042c0446e 100644 --- a/apps/web-antd/src/locales/langs/zh-CN/page.json +++ b/apps/web-antd/src/locales/langs/zh-CN/page.json @@ -41,6 +41,24 @@ } }, "iot": { + "device": { + "filter": { + "subsystem": { + "placeholder": "全部子系统", + "unassigned": "未归属", + "bind": "绑定子系统", + "bindTitle": "绑定子系统", + "batchBind": "批量绑定子系统", + "batchBindTitle": "批量绑定子系统", + "batchBindSuccess": "已成功绑定 {0} 台设备", + "bindSuccess": "绑定成功", + "selectRequired": "请先选择子系统", + "selectDeviceTip": "请先选择要绑定的设备", + "binding": "正在绑定...", + "batchBindHint": "已选 {0} 台设备,每批最多 100 台" + } + } + }, "subsystem": { "title": "子系统", "listTitle": "子系统列表", diff --git a/apps/web-antd/src/views/iot/device/device/__tests__/device-subsystem-filter.spec.ts b/apps/web-antd/src/views/iot/device/device/__tests__/device-subsystem-filter.spec.ts new file mode 100644 index 000000000..aa262df6f --- /dev/null +++ b/apps/web-antd/src/views/iot/device/device/__tests__/device-subsystem-filter.spec.ts @@ -0,0 +1,154 @@ +import { describe, expect, it } from 'vitest'; + +/** + * F11 — 设备查询页 subsystemId 筛选 + "未归属"标签 + * + * 测试覆盖: + * 1. 前端哨兵值 -1 转换为 { unassigned: true } 后端参数 + * 2. 正常子系统 ID 透传为 { subsystemId: X } + * 3. undefined(全部)不传任何子系统相关参数 + * 4. 批量绑定分批逻辑(每批 ≤ 100 台,⚠️ B11 限制) + * 5. 未归属标签渲染条件(subsystemId == null/undefined → 红标签) + */ + +// ─── 1. 子系统筛选参数转换 ───────────────────────────────────────────────────── + +const UNASSIGNED_SENTINEL = -1; + +interface SubsystemQueryParam { + subsystemId?: number; + unassigned?: boolean; +} + +/** + * 将前端筛选 Select 的值转换为后端查询参数 + * -1 → { unassigned: true } + * 正常 ID → { subsystemId: id } + * undefined → {}(全部) + */ +function buildSubsystemQueryParam( + filterValue: number | undefined, +): SubsystemQueryParam { + if (filterValue === UNASSIGNED_SENTINEL) { + return { unassigned: true }; + } + if (filterValue !== undefined) { + return { subsystemId: filterValue }; + } + return {}; +} + +describe('buildSubsystemQueryParam — 筛选参数转换', () => { + it('哨兵值 -1 → unassigned=true(未归属筛选)', () => { + const result = buildSubsystemQueryParam(-1); + expect(result).toEqual({ unassigned: true }); + expect(result.subsystemId).toBeUndefined(); + }); + + it('正常子系统 ID → subsystemId 透传', () => { + const result = buildSubsystemQueryParam(42); + expect(result).toEqual({ subsystemId: 42 }); + expect(result.unassigned).toBeUndefined(); + }); + + it('undefined(全部)→ 空对象,不传任何子系统参数', () => { + const result = buildSubsystemQueryParam(undefined); + expect(result).toEqual({}); + expect(result.subsystemId).toBeUndefined(); + expect(result.unassigned).toBeUndefined(); + }); + + it('subsystemId=0 不应被视为未归属(防御性测试)', () => { + // 0 是合法的数字 ID,不是哨兵值 + const result = buildSubsystemQueryParam(0); + expect(result).toEqual({ subsystemId: 0 }); + expect(result.unassigned).toBeUndefined(); + }); +}); + +// ─── 2. 未归属标签渲染条件(⚠️ A2:存量 NULL 醒目展示)──────────────────────── + +/** + * 判断设备是否未归属(subsystemId 为 null 或 undefined) + * 对应模板中 v-if="row.subsystemId == null" 的判断逻辑 + * 注意:模板中使用宽松 == null(同时处理 null/undefined), + * 此处用严格判断等价替代。 + */ +function isUnassigned(subsystemId: null | number | undefined): boolean { + return subsystemId === null || subsystemId === undefined; +} + +describe('isUnassigned — 未归属标签渲染条件(⚠️ A2)', () => { + it('subsystemId=null → 未归属(存量 NULL 设备)', () => { + expect(isUnassigned(null)).toBe(true); + }); + + it('subsystemId=undefined → 未归属(字段缺失时)', () => { + expect(isUnassigned(undefined)).toBe(true); + }); + + it('subsystemId=1 → 已归属,不展示红标签', () => { + expect(isUnassigned(1)).toBe(false); + }); + + it('subsystemId=0 → 已归属(0 是合法 ID)', () => { + expect(isUnassigned(0)).toBe(false); + }); +}); + +// ─── 3. 批量绑定分批逻辑(⚠️ B11 限制 100 台/批)────────────────────────────── + +const BATCH_SIZE = 100; + +function splitIntoBatches(items: T[], batchSize: number): T[][] { + const batches: T[][] = []; + for (let i = 0; i < items.length; i += batchSize) { + batches.push(items.slice(i, i + batchSize)); + } + return batches; +} + +describe('splitIntoBatches — 批量绑定分批(⚠️ B11 每批 ≤ 100 台)', () => { + it('50 台 → 1 批', () => { + const ids = Array.from({ length: 50 }, (_, i) => i + 1); + const batches = splitIntoBatches(ids, BATCH_SIZE); + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(50); + }); + + it('恰好 100 台 → 1 批', () => { + const ids = Array.from({ length: 100 }, (_, i) => i + 1); + const batches = splitIntoBatches(ids, BATCH_SIZE); + expect(batches).toHaveLength(1); + expect(batches[0]).toHaveLength(100); + }); + + it('101 台 → 2 批(100 + 1)', () => { + const ids = Array.from({ length: 101 }, (_, i) => i + 1); + const batches = splitIntoBatches(ids, BATCH_SIZE); + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(100); + expect(batches[1]).toHaveLength(1); + }); + + it('250 台 → 3 批(100 + 100 + 50)', () => { + const ids = Array.from({ length: 250 }, (_, i) => i + 1); + const batches = splitIntoBatches(ids, BATCH_SIZE); + expect(batches).toHaveLength(3); + expect(batches[0]).toHaveLength(100); + expect(batches[1]).toHaveLength(100); + expect(batches[2]).toHaveLength(50); + }); + + it('空数组 → 0 批', () => { + expect(splitIntoBatches([], BATCH_SIZE)).toHaveLength(0); + }); + + it('分批后所有 ID 不丢失', () => { + const ids = Array.from({ length: 220 }, (_, i) => i + 1); + const batches = splitIntoBatches(ids, BATCH_SIZE); + const flattened = batches.flat(); + expect(flattened).toHaveLength(220); + expect(flattened).toEqual(ids); + }); +}); diff --git a/apps/web-antd/src/views/iot/device/device/data.ts b/apps/web-antd/src/views/iot/device/device/data.ts index c53c0e207..8878d5dc3 100644 --- a/apps/web-antd/src/views/iot/device/device/data.ts +++ b/apps/web-antd/src/views/iot/device/device/data.ts @@ -310,6 +310,12 @@ export function useGridColumns(): VxeTableGridOptions['columns'] { minWidth: 150, slots: { default: 'groups' }, }, + { + field: 'subsystemId', + title: '所属子系统', + minWidth: 140, + slots: { default: 'subsystem' }, + }, { field: 'state', title: '设备状态', diff --git a/apps/web-antd/src/views/iot/device/device/index.vue b/apps/web-antd/src/views/iot/device/device/index.vue index 9b1d53e58..f7e30b556 100644 --- a/apps/web-antd/src/views/iot/device/device/index.vue +++ b/apps/web-antd/src/views/iot/device/device/index.vue @@ -1,9 +1,11 @@