From 887e51eaaa257b7545e7a83caf968c134cbef823 Mon Sep 17 00:00:00 2001 From: lzh Date: Thu, 23 Apr 2026 23:02:01 +0800 Subject: [PATCH] =?UTF-8?q?[F11]=20=E8=AE=BE=E5=A4=87=E6=9F=A5=E8=AF=A2?= =?UTF-8?q?=E9=A1=B5=20subsystemId=20=E7=AD=9B=E9=80=89=20+=20=E6=9C=AA?= =?UTF-8?q?=E5=BD=92=E5=B1=9E=E6=A0=87=E7=AD=BE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - apps/web-antd/src/api/iot/device/device/index.ts - Device 接口加 subsystemId/subsystemName - 新增 BindSubsystemReqVO + bindDeviceSubsystem (PUT /iot/device/bindSubsystem) - apps/web-antd/src/views/iot/device/device/index.vue - 筛选器:子系统下拉 + 未归属哨兵 -1 (→ queryParams.unassigned=true) - 单设备/批量绑定弹窗,批量 100 台/批 - 行操作 + TableAction 增加"绑定子系统"按钮 (auth: iot:device:update) - apps/web-antd/src/views/iot/device/device/data.ts - useGridColumns 追加"所属子系统"列 (slot: subsystem) - apps/web-antd/src/views/iot/device/device/modules/card-view.vue - 卡片视图加子系统信息行 + 未归属红标签 - Props 透传 subsystems / searchParams.subsystemId / unassigned - apps/web-antd/src/views/iot/device/device/__tests__/device-subsystem-filter.spec.ts - 14 用例: 筛选参数转换 / 未归属标签条件 / 批量分批逻辑 - locales/langs/{zh-CN,en-US}/page.json: iot.device.filter.subsystem.* 12 键同步 - Known Pitfalls 落地: 评审 A2 NULL 醒目红标签 / 哨兵 -1 → unassigned=true / 批量 100 台分批 / iot:device:update 权限 / simple-list 加载静默降级 后端 B11 API 补充契约: - GET /iot/device/page?unassigned=true (subsystemId IS NULL 过滤) - PUT /iot/device/bindSubsystem { deviceId, subsystemId: number | null } - 分页响应 Device 含 subsystemId?: number | null + subsystemName?: string Co-Authored-By: Claude Opus 4.7 (1M context) Co-Authored-By: Claude Sonnet 4.6 --- .../src/api/iot/device/device/index.ts | 20 ++ .../src/locales/langs/en-US/page.json | 18 ++ .../src/locales/langs/zh-CN/page.json | 18 ++ .../__tests__/device-subsystem-filter.spec.ts | 154 +++++++++++++ .../src/views/iot/device/device/data.ts | 6 + .../src/views/iot/device/device/index.vue | 215 +++++++++++++++++- .../iot/device/device/modules/card-view.vue | 26 +++ 7 files changed, 456 insertions(+), 1 deletion(-) create mode 100644 apps/web-antd/src/views/iot/device/device/__tests__/device-subsystem-filter.spec.ts 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 @@