diff --git a/apps/web-antd/src/api/iot/subsystem/index.ts b/apps/web-antd/src/api/iot/subsystem/index.ts new file mode 100644 index 000000000..3148de52f --- /dev/null +++ b/apps/web-antd/src/api/iot/subsystem/index.ts @@ -0,0 +1,136 @@ +import type { PageParam, PageResult } from '@vben/request'; + +import { requestClient } from '#/api/request'; + +export namespace IotSubsystemApi { + /** 子系统 */ + export interface Subsystem { + id?: number; // 子系统编号 + name: string; // 子系统名称 + code: string; // 子系统编码(小写+下划线,如 security / energy / clean) + description?: string; // 子系统描述 + icon?: string; // 子系统图标 URL 或 iconify 名称 + status: number; // 状态(0=禁用 1=启用) + sort?: number; // 排序 + projectId?: number; // 所属项目 ID(预留字段,当前版本不启用) + deviceCount?: number; // 设备数量(来自 /iot/subsystem/device-count 聚合,非实时 count) + createTime?: Date; // 创建时间 + updateTime?: Date; // 更新时间 + } + + /** 子系统分页查询参数 */ + export interface SubsystemPageParam extends PageParam { + keyword?: string; // 关键字(名称/编码模糊搜索) + status?: number; // 状态过滤 + } + + /** 子系统精简信息(下拉列表) */ + export interface SubsystemSimple { + id: number; + name: string; + code: string; + } + + /** 设备统计 map 中单条数据 */ + export interface SubsystemDeviceCount { + subsystemId: number; + total: number; + online: number; + alarm: number; + } + + /** 批量绑定子系统请求体 */ + export interface BatchBindSubsystemReqVO { + deviceIds: number[]; // 设备编号列表(每批 ≤ 100) + subsystemId: number; // 目标子系统 ID + } + + /** 单设备绑定/解绑子系统请求体 */ + export interface BindSubsystemReqVO { + deviceId: number; + subsystemId: null | number; // null = 解绑(移除归属) + } +} + +// ─── 子系统 CRUD ──────────────────────────────────────────────────────────── + +/** 创建子系统 */ +export function createSubsystem(data: IotSubsystemApi.Subsystem) { + return requestClient.post('/iot/subsystem/create', data); +} + +/** 更新子系统 */ +export function updateSubsystem(data: IotSubsystemApi.Subsystem) { + return requestClient.put('/iot/subsystem/update', data); +} + +/** + * 删除子系统 + * 后端会校验子系统下是否仍有设备,有则返回业务异常(前端 deleteCount 检查后禁用按钮) + */ +export function deleteSubsystem(id: number) { + return requestClient.delete(`/iot/subsystem/delete?id=${id}`); +} + +/** 获取子系统详情 */ +export function getSubsystem(id: number) { + return requestClient.get( + `/iot/subsystem/get?id=${id}`, + ); +} + +/** 子系统分页查询 */ +export function getSubsystemPage(params: IotSubsystemApi.SubsystemPageParam) { + return requestClient.get>( + '/iot/subsystem/page', + { params }, + ); +} + +/** + * 子系统精简列表(下拉) + * 注意:⚠️ [评审 A7] 此接口已加权限,403 由请求拦截器统一处理 + */ +export function getSubsystemSimpleList() { + return requestClient.get( + '/iot/subsystem/simple-list', + ); +} + +// ─── 设备统计(B10 需提供)──────────────────────────────────────────────────── + +/** + * 各子系统设备统计(Redis 聚合) + * ⚠️ [评审 A6] 统计数据来自 Redis 缓存,不做实时 count + * B10 需要实现:GET /iot/subsystem/device-count + * 返回格式:{ [subsystemId]: { total, online, alarm } } + */ +export function getSubsystemDeviceCount() { + return requestClient.get< + Record + >('/iot/subsystem/device-count'); +} + +// ─── 设备归属操作(B11 需提供)─────────────────────────────────────────────── + +/** + * 批量绑定设备到子系统 + * ⚠️ 每批 ≤ 100 台,调用方需自行分批 + * B11 需要实现:PUT /iot/device/batchBindSubsystem + */ +export function batchBindSubsystem( + data: IotSubsystemApi.BatchBindSubsystemReqVO, +) { + return requestClient.put('/iot/device/batchBindSubsystem', data); +} + +/** + * 单设备解绑子系统(移除归属) + * B11 需要实现:PUT /iot/device/bindSubsystem(subsystemId=null 表示解绑) + */ +export function unbindDeviceSubsystem(deviceId: number) { + return requestClient.put('/iot/device/bindSubsystem', { + deviceId, + subsystemId: null, + } satisfies IotSubsystemApi.BindSubsystemReqVO); +} 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 6edb73d69..203a45fb3 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,53 @@ } }, "iot": { + "subsystem": { + "title": "Subsystem", + "listTitle": "Subsystem List", + "name": "Name", + "namePlaceholder": "Enter subsystem name", + "nameRequired": "Name is required", + "nameTooLong": "Name must not exceed 128 characters", + "code": "Code", + "codePlaceholder": "e.g. security, clean", + "codeRequired": "Code is required", + "codeTooLong": "Code must not exceed 64 characters", + "codeFormat": "Code must start with a lowercase letter and contain only lowercase letters, digits, or underscores", + "codeHelp": "Format: starts with a letter, allows digits and underscores, e.g. clean_01", + "status": "Status", + "statusEnabled": "Enabled", + "statusDisabled": "Disabled", + "statusPlaceholder": "Select status", + "icon": "Icon", + "iconPlaceholder": "Icon URL or iconify name", + "description": "Description", + "descriptionPlaceholder": "Enter description", + "deviceCount": "Devices", + "searchPlaceholder": "Search name / code", + "viewDevices": "View Devices", + "deleteDisabledTip": "Cannot delete: subsystem has {0} device(s)", + "deviceListTitle": "Subsystem Devices", + "deviceName": "Device Name", + "serialNumber": "Serial Number", + "deviceState": "Online State", + "deviceOnline": "Online", + "deviceOffline": "Offline", + "deviceSearchPlaceholder": "Search device name", + "statTotal": "Total Devices", + "statOnline": "Online", + "statAlarm": "Alarming", + "statsNotice": "Stats are provided by Redis cache and may have a short delay (⚠️ A6)", + "batchBind": "Batch Bind Devices", + "batchBindHint": "Max 100 devices per batch", + "bindSuccess": "{0} device(s) bound successfully", + "selectDeviceTip": "Please select devices first", + "unbind": "Remove", + "unbindConfirm": "Remove device \"{0}\" from this subsystem?", + "unbinding": "Removing device...", + "unbindSuccess": "Device removed", + "transferCandidate": "Candidate Devices", + "transferSelected": "Selected Devices" + }, "dag": { "toolbar": { "zoomIn": "Zoom In", @@ -120,6 +167,86 @@ "desc": "Send notification (SMS/Email/Webhook)" } } + }, + "alarm": { + "state": { + "archived": "Archived", + "activeUnacked": "Active·Unacked", + "activeAcked": "Active·Acked", + "clearedUnacked": "Cleared·Unacked", + "clearedAcked": "Cleared·Acked", + "unknown": "Unknown" + }, + "severity": { + "critical": "Critical", + "major": "Major", + "minor": "Minor", + "warning": "Warning", + "info": "Info", + "unknown": "Unknown" + }, + "list": { + "title": "Alarm Records" + }, + "field": { + "alarmName": "Alarm Name", + "severity": "Severity", + "state": "State", + "deviceName": "Device", + "productName": "Product", + "triggerCount": "Trigger Count", + "startTs": "First Triggered", + "endTs": "Last Triggered", + "clearTs": "Cleared At", + "ackTs": "Acked At" + }, + "filter": { + "all": "All", + "ackState": "Ack State", + "clearState": "Clear State", + "archived": "Archive", + "unacked": "Unacked", + "acked": "Acked", + "active": "Active", + "cleared": "Cleared", + "excludeArchived": "Exclude Archived", + "allRecords": "All Records", + "onlyArchived": "Archived Only" + }, + "op": { + "ack": "Ack", + "unack": "Unack", + "clear": "Clear", + "archive": "Archive", + "noOps": "No Operations", + "confirmTitle": "Confirm", + "ackConfirm": "Acknowledge this alarm?", + "unackConfirm": "Unacknowledge this alarm?", + "clearConfirm": "Clear this alarm?", + "archiveConfirm": "Archive this alarm? No further operations after archiving.", + "batchAck": "Batch Ack", + "batchClear": "Batch Clear", + "batchArchive": "Batch Archive", + "batchAckConfirm": "Acknowledge {count} selected alarm(s)?", + "batchClearConfirm": "Clear {count} selected alarm(s)?", + "batchArchiveConfirm": "Archive {count} selected alarm(s)? No further operations after archiving.", + "selectFirst": "Please select records first", + "ackSuccess": "Acknowledged", + "unackSuccess": "Unacknowledged", + "clearSuccess": "Cleared", + "archiveSuccess": "Archived" + }, + "detail": { + "title": "Alarm Detail", + "basicInfo": "Basic Info", + "propagationPath": "Propagation Path", + "details": "Alarm Details", + "remark": "Process Remark", + "remarkPlaceholder": "Enter remark", + "remarkSaved": "Remark saved", + "history": "History", + "noHistory": "No history records" + } } } } 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 a14307127..b3774e0d4 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,53 @@ } }, "iot": { + "subsystem": { + "title": "子系统", + "listTitle": "子系统列表", + "name": "子系统名称", + "namePlaceholder": "请输入子系统名称", + "nameRequired": "子系统名称不能为空", + "nameTooLong": "子系统名称长度不能超过 128 个字符", + "code": "子系统编码", + "codePlaceholder": "请输入编码,如 security、clean", + "codeRequired": "子系统编码不能为空", + "codeTooLong": "子系统编码长度不能超过 64 个字符", + "codeFormat": "编码格式错误:仅允许小写字母/数字/下划线,且以字母开头", + "codeHelp": "格式:小写字母开头,允许数字和下划线,如 clean_01", + "status": "状态", + "statusEnabled": "启用", + "statusDisabled": "禁用", + "statusPlaceholder": "请选择状态", + "icon": "图标", + "iconPlaceholder": "图标 URL 或 iconify 名称", + "description": "描述", + "descriptionPlaceholder": "请输入子系统描述", + "deviceCount": "设备数", + "searchPlaceholder": "搜索名称 / 编码", + "viewDevices": "查看设备", + "deleteDisabledTip": "子系统下有 {0} 台设备,无法删除", + "deviceListTitle": "子系统设备列表", + "deviceName": "设备名称", + "serialNumber": "序列号", + "deviceState": "在线状态", + "deviceOnline": "在线", + "deviceOffline": "离线", + "deviceSearchPlaceholder": "搜索设备名称", + "statTotal": "设备总数", + "statOnline": "在线设备", + "statAlarm": "告警设备", + "statsNotice": "统计数据由 Redis 缓存提供,可能有短暂延迟(⚠️ A6)", + "batchBind": "批量绑定设备", + "batchBindHint": "每批最多支持 100 台设备同时绑定", + "bindSuccess": "已成功绑定 {0} 台设备", + "selectDeviceTip": "请先选择要绑定的设备", + "unbind": "移除", + "unbindConfirm": "确认将设备「{0}」从当前子系统移除?", + "unbinding": "正在移除设备...", + "unbindSuccess": "设备已移除", + "transferCandidate": "候选设备", + "transferSelected": "已选设备" + }, "dag": { "toolbar": { "zoomIn": "放大", @@ -120,6 +167,86 @@ "desc": "发送消息通知(短信/邮件/webhook)" } } + }, + "alarm": { + "state": { + "archived": "已归档", + "activeUnacked": "活跃·未确认", + "activeAcked": "活跃·已确认", + "clearedUnacked": "已清除·未确认", + "clearedAcked": "已清除·已确认", + "unknown": "未知" + }, + "severity": { + "critical": "紧急", + "major": "重要", + "minor": "次要", + "warning": "警告", + "info": "信息", + "unknown": "未知" + }, + "list": { + "title": "告警记录" + }, + "field": { + "alarmName": "告警名称", + "severity": "严重度", + "state": "状态", + "deviceName": "设备", + "productName": "产品", + "triggerCount": "触发次数", + "startTs": "首次触发", + "endTs": "最近触发", + "clearTs": "清除时间", + "ackTs": "确认时间" + }, + "filter": { + "all": "全部", + "ackState": "确认状态", + "clearState": "清除状态", + "archived": "归档", + "unacked": "未确认", + "acked": "已确认", + "active": "活跃", + "cleared": "已清除", + "excludeArchived": "排除归档", + "allRecords": "全部记录", + "onlyArchived": "只看归档" + }, + "op": { + "ack": "确认", + "unack": "撤销确认", + "clear": "清除", + "archive": "归档", + "noOps": "无可操作", + "confirmTitle": "操作确认", + "ackConfirm": "确认该告警?", + "unackConfirm": "撤销确认该告警?", + "clearConfirm": "清除该告警?", + "archiveConfirm": "归档该告警?归档后不可操作。", + "batchAck": "批量确认", + "batchClear": "批量清除", + "batchArchive": "批量归档", + "batchAckConfirm": "确认选中的 {count} 条告警?", + "batchClearConfirm": "清除选中的 {count} 条告警?", + "batchArchiveConfirm": "归档选中的 {count} 条告警?归档后不可操作。", + "selectFirst": "请先选择记录", + "ackSuccess": "确认成功", + "unackSuccess": "撤销确认成功", + "clearSuccess": "清除成功", + "archiveSuccess": "归档成功" + }, + "detail": { + "title": "告警详情", + "basicInfo": "基本信息", + "propagationPath": "传播路径", + "details": "告警详情", + "remark": "处理备注", + "remarkPlaceholder": "请输入处理备注", + "remarkSaved": "备注已保存", + "history": "历史变化", + "noHistory": "暂无历史记录" + } } } } diff --git a/apps/web-antd/src/router/routes/modules/iot.ts b/apps/web-antd/src/router/routes/modules/iot.ts index 2bc6d32b2..497d1c60d 100644 --- a/apps/web-antd/src/router/routes/modules/iot.ts +++ b/apps/web-antd/src/router/routes/modules/iot.ts @@ -39,8 +39,40 @@ const routes: RouteRecordRaw[] = [ component: () => import('#/views/iot/ota/modules/firmware-detail/index.vue'), }, + // F9: 子系统设备详情(子路由,归属子系统菜单高亮) + { + path: 'subsystem/:id/devices', + name: 'IoTSubsystemDevices', + meta: { + title: '子系统设备', + activePath: '/iot/subsystem', + }, + component: () => import('#/views/iot/subsystem/devices.vue'), + }, ], }, + // F9: 子系统管理菜单(顶级菜单项) + { + path: '/iot/subsystem', + name: 'IoTSubsystem', + meta: { + title: '子系统管理', + icon: 'lucide:server-cog', + keepAlive: true, + }, + component: () => import('#/views/iot/subsystem/list.vue'), + }, + // F10: 告警记录(v2 正交三态) + { + path: '/iot/alarm/record', + name: 'IoTAlarmRecord', + meta: { + title: '告警记录', + icon: 'lucide:bell-ring', + keepAlive: true, + }, + component: () => import('#/views/iot/alarm/record/list.vue'), + }, ]; export default routes; diff --git a/apps/web-antd/src/views/iot/alarm/record/__tests__/AlarmStateTag.spec.ts b/apps/web-antd/src/views/iot/alarm/record/__tests__/AlarmStateTag.spec.ts new file mode 100644 index 000000000..61c268309 --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/__tests__/AlarmStateTag.spec.ts @@ -0,0 +1,96 @@ +import { describe, expect, it } from 'vitest'; + +import { getSeverityInfoByNum, getStateLabel } from '../alarm-state'; + +// ── getSeverityInfoByNum ────────────────────────────────────────────────────── + +// ── getStateLabel — 正交 5 态完整性测试(评审 C1)───────────────────────────── + +describe('getStateLabel — 5 种组合状态(评审 C1)', () => { + it('archived=1 → 已归档 (default)', () => { + // 不管 ack / clear 值如何,archived 优先 + const result = getStateLabel(0, 0, 1); + expect(result.labelKey).toBe('iot.alarm.state.archived'); + expect(result.color).toBe('default'); + }); + + it('clear=0 ack=0 → 活跃·未确认 (red)', () => { + const result = getStateLabel(0, 0, 0); + expect(result.labelKey).toBe('iot.alarm.state.activeUnacked'); + expect(result.color).toBe('red'); + }); + + it('clear=0 ack=1 → 活跃·已确认 (orange)', () => { + const result = getStateLabel(1, 0, 0); + expect(result.labelKey).toBe('iot.alarm.state.activeAcked'); + expect(result.color).toBe('orange'); + }); + + it('clear=1 ack=0 → 已清除·未确认 (gold) — 关键运维场景', () => { + const result = getStateLabel(0, 1, 0); + expect(result.labelKey).toBe('iot.alarm.state.clearedUnacked'); + // 醒目黄色(gold)区别于普通灰色,提醒运维补确认 + expect(result.color).toBe('gold'); + }); + + it('clear=1 ack=1 → 已清除·已确认 (green)', () => { + const result = getStateLabel(1, 1, 0); + expect(result.labelKey).toBe('iot.alarm.state.clearedAcked'); + expect(result.color).toBe('green'); + }); + + it('archived=1 优先级高于 clear/ack 状态', () => { + // 即使 clear=1 ack=1,只要 archived=1 就返回已归档 + const result = getStateLabel(1, 1, 1); + expect(result.labelKey).toBe('iot.alarm.state.archived'); + expect(result.color).toBe('default'); + }); + + it('5 种状态颜色均不同(C1 正交性)', () => { + const archived = getStateLabel(0, 0, 1); + const activeUnacked = getStateLabel(0, 0, 0); + const activeAcked = getStateLabel(1, 0, 0); + const clearedUnacked = getStateLabel(0, 1, 0); + const clearedAcked = getStateLabel(1, 1, 0); + + const colors = [ + archived.color, + activeUnacked.color, + activeAcked.color, + clearedUnacked.color, + clearedAcked.color, + ]; + + // 至少 4 种不同颜色(archived 和 unknown 都用 default,其余 4 种各异) + const uniqueColors = new Set(colors); + expect(uniqueColors.size).toBeGreaterThanOrEqual(4); + }); +}); + +describe('getSeverityInfoByNum — 严重度颜色映射', () => { + it('1 → CRITICAL (red)', () => { + const info = getSeverityInfoByNum(1); + expect(info.color).toBe('red'); + expect(info.labelKey).toBe('iot.alarm.severity.critical'); + }); + + it('2 → MAJOR (orange)', () => { + const info = getSeverityInfoByNum(2); + expect(info.color).toBe('orange'); + }); + + it('3 → MINOR (gold)', () => { + const info = getSeverityInfoByNum(3); + expect(info.color).toBe('gold'); + }); + + it('4 → WARNING (blue)', () => { + const info = getSeverityInfoByNum(4); + expect(info.color).toBe('blue'); + }); + + it('5 → INFO (default)', () => { + const info = getSeverityInfoByNum(5); + expect(info.color).toBe('default'); + }); +}); diff --git a/apps/web-antd/src/views/iot/alarm/record/alarm-state.ts b/apps/web-antd/src/views/iot/alarm/record/alarm-state.ts new file mode 100644 index 000000000..337497518 --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/alarm-state.ts @@ -0,0 +1,85 @@ +import type { AckState, AlarmSeverity, ArchivedState, ClearState } from './api'; + +// ── State Label ─────────────────────────────────────────────────────────────── + +export interface AlarmStateInfo { + /** i18n key — 对应 zh-CN/en-US page.json 中的 iot.alarm.state.xxx */ + labelKey: string; + /** Ant Design Tag color */ + color: string; +} + +/** + * 正交三态组合 → 标签 + 颜色(评审 C1) + * + * 5 种组合: + * archived=1 → 已归档(灰) + * clear=0 ack=0 → 活跃·未确认(红) + * clear=0 ack=1 → 活跃·已确认(橙) + * clear=1 ack=0 → 已清除·未确认(黄) ← 关键运维场景 + * clear=1 ack=1 → 已清除·已确认(绿) + */ +export function getStateLabel( + ack: AckState, + clear: ClearState, + archived: ArchivedState, +): AlarmStateInfo { + if (archived === 1) { + return { labelKey: 'iot.alarm.state.archived', color: 'default' }; + } + if (clear === 0 && ack === 0) { + return { labelKey: 'iot.alarm.state.activeUnacked', color: 'red' }; + } + if (clear === 0 && ack === 1) { + return { labelKey: 'iot.alarm.state.activeAcked', color: 'orange' }; + } + if (clear === 1 && ack === 0) { + return { labelKey: 'iot.alarm.state.clearedUnacked', color: 'gold' }; + } + if (clear === 1 && ack === 1) { + return { labelKey: 'iot.alarm.state.clearedAcked', color: 'green' }; + } + return { labelKey: 'iot.alarm.state.unknown', color: 'default' }; +} + +// ── Severity Color ──────────────────────────────────────────────────────────── + +export interface SeverityInfo { + labelKey: string; + color: string; +} + +/** + * 严重度颜色映射(评审 Known Pitfalls) + * CRITICAL 红 / MAJOR 橙 / MINOR 黄 / WARNING 蓝 / INFO 灰 + */ +export function getSeverityInfo( + severityLabel: AlarmSeverity | string, +): SeverityInfo { + const map: Record = { + CRITICAL: { labelKey: 'iot.alarm.severity.critical', color: 'red' }, + MAJOR: { labelKey: 'iot.alarm.severity.major', color: 'orange' }, + MINOR: { labelKey: 'iot.alarm.severity.minor', color: 'gold' }, + WARNING: { labelKey: 'iot.alarm.severity.warning', color: 'blue' }, + INFO: { labelKey: 'iot.alarm.severity.info', color: 'default' }, + }; + return ( + map[severityLabel] ?? { + labelKey: 'iot.alarm.severity.unknown', + color: 'default', + } + ); +} + +/** 通过 severity number (1-5) 获取严重度信息 */ +export function getSeverityInfoByNum(severity: number): SeverityInfo { + const labels: AlarmSeverity[] = [ + 'CRITICAL', + 'MAJOR', + 'MINOR', + 'WARNING', + 'INFO', + ]; + const label = labels[severity - 1] ?? 'INFO'; + return getSeverityInfo(label); +} diff --git a/apps/web-antd/src/views/iot/alarm/record/api.ts b/apps/web-antd/src/views/iot/alarm/record/api.ts new file mode 100644 index 000000000..7de39275f --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/api.ts @@ -0,0 +1,149 @@ +import type { PageParam, PageResult } from '@vben/request'; + +import { requestClient } from '#/api/request'; + +// ── Enums ───────────────────────────────────────────────────────────────────── + +/** 告警确认状态 (0=未确认, 1=已确认) */ +export type AckState = 0 | 1; + +/** 告警清除状态 (0=活跃, 1=已清除) */ +export type ClearState = 0 | 1; + +/** 告警归档状态 (0=未归档, 1=已归档) */ +export type ArchivedState = 0 | 1; + +/** 告警严重度 */ +export type AlarmSeverity = 'CRITICAL' | 'INFO' | 'MAJOR' | 'MINOR' | 'WARNING'; + +// ── VO Types ────────────────────────────────────────────────────────────────── + +/** 告警传播路径条目 */ +export interface AlarmPropagationItem { + type: string; + id: number; + name: string; +} + +/** 告警记录 VO(与 B12 AlarmRecordVO 对齐) */ +export interface AlarmRecordVO { + id: number; + recordKey: string; + alarmConfigId: number; + alarmName: string; + /** 1=CRITICAL 2=MAJOR 3=MINOR 4=WARNING 5=INFO */ + severity: 1 | 2 | 3 | 4 | 5; + severityLabel: AlarmSeverity; + /** 确认状态 0=未确认 1=已确认 */ + ackState: AckState; + /** 清除状态 0=活跃 1=已清除 */ + clearState: ClearState; + /** 归档 0=未归档 1=已归档 */ + archived: ArchivedState; + deviceId: number; + deviceName?: string; + productId?: number; + productName?: string; + subsystemId?: number; + subsystemName?: string; + startTs: string; + endTs?: string; + clearTs?: string; + ackTs?: string; + triggerCount: number; + propagatedTo?: AlarmPropagationItem[]; + processRemark?: string; + details?: Record; + createTime: string; + updateTime?: string; +} + +/** 告警历史条目 VO(B13) */ +export interface AlarmHistoryVO { + ts: string; + alarmRecordId: number; + alarmConfigId: number; + severity: number; + state: string; + triggerData?: string; + details?: string; + operator: string; + remark?: string; +} + +/** 分页查询参数 */ +export interface AlarmRecordPageParam extends PageParam { + alarmConfigId?: number; + severity?: number; + ackState?: '' | AckState; + clearState?: '' | ClearState; + archived?: '' | ArchivedState; + deviceId?: number; + productId?: number; + subsystemId?: number; + startTs?: string[]; +} + +// ── API Functions ───────────────────────────────────────────────────────────── + +/** 查询告警记录分页 */ +export function getAlarmRecordPage(params: AlarmRecordPageParam) { + return requestClient.get>( + '/iot/alarm-record/page', + { + params, + }, + ); +} + +/** 查询告警记录详情 */ +export function getAlarmRecord(id: number) { + return requestClient.get(`/iot/alarm-record/get?id=${id}`); +} + +/** 确认告警 */ +export function ackAlarmRecord(id: number, remark?: string) { + return requestClient.put('/iot/alarm-record/ack', { id, remark }); +} + +/** 撤销确认告警 */ +export function unackAlarmRecord(id: number, remark?: string) { + return requestClient.put('/iot/alarm-record/unack', { id, remark }); +} + +/** 清除告警 */ +export function clearAlarmRecord(id: number, remark?: string) { + return requestClient.put('/iot/alarm-record/clear', { id, remark }); +} + +/** 归档告警 */ +export function archiveAlarmRecord(id: number) { + return requestClient.put('/iot/alarm-record/archive', { id }); +} + +/** 批量确认告警 */ +export function batchAckAlarmRecord(ids: number[], remark?: string) { + return requestClient.put('/iot/alarm-record/batch-ack', { ids, remark }); +} + +/** 批量清除告警 */ +export function batchClearAlarmRecord(ids: number[], remark?: string) { + return requestClient.put('/iot/alarm-record/batch-clear', { ids, remark }); +} + +/** 批量归档告警 */ +export function batchArchiveAlarmRecord(ids: number[]) { + return requestClient.put('/iot/alarm-record/batch-archive', { ids }); +} + +/** 查询告警历史(时序库,B13) */ +export function getAlarmHistory(alarmRecordId: number) { + return requestClient.get( + `/iot/alarm-record/history?alarmRecordId=${alarmRecordId}`, + ); +} + +/** 更新告警处理备注 */ +export function updateAlarmRemark(id: number, remark: string) { + return requestClient.put('/iot/alarm-record/remark', { id, remark }); +} diff --git a/apps/web-antd/src/views/iot/alarm/record/components/AlarmOperations.vue b/apps/web-antd/src/views/iot/alarm/record/components/AlarmOperations.vue new file mode 100644 index 000000000..2198fa3c7 --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/components/AlarmOperations.vue @@ -0,0 +1,103 @@ + + + diff --git a/apps/web-antd/src/views/iot/alarm/record/components/AlarmStateTag.vue b/apps/web-antd/src/views/iot/alarm/record/components/AlarmStateTag.vue new file mode 100644 index 000000000..93ef945af --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/components/AlarmStateTag.vue @@ -0,0 +1,27 @@ + + + diff --git a/apps/web-antd/src/views/iot/alarm/record/detail.vue b/apps/web-antd/src/views/iot/alarm/record/detail.vue new file mode 100644 index 000000000..552646f15 --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/detail.vue @@ -0,0 +1,251 @@ + + + diff --git a/apps/web-antd/src/views/iot/alarm/record/list.vue b/apps/web-antd/src/views/iot/alarm/record/list.vue new file mode 100644 index 000000000..dcee6e568 --- /dev/null +++ b/apps/web-antd/src/views/iot/alarm/record/list.vue @@ -0,0 +1,363 @@ + + + diff --git a/apps/web-antd/src/views/iot/subsystem/__tests__/subsystem-utils.test.ts b/apps/web-antd/src/views/iot/subsystem/__tests__/subsystem-utils.test.ts new file mode 100644 index 000000000..01708dbe7 --- /dev/null +++ b/apps/web-antd/src/views/iot/subsystem/__tests__/subsystem-utils.test.ts @@ -0,0 +1,127 @@ +import { describe, expect, it } from 'vitest'; + +/** + * 子系统 code 格式校验工具函数测试 + * ⚠️ [Known Pitfall] code 格式:snake_case 正则 ^[a-z][a-z0-9_]*$ + */ + +/** 复用 form.vue 中相同的正则 */ +const CODE_REGEX = /^[a-z][a-z0-9_]*$/; + +function isValidCode(code: string): boolean { + return CODE_REGEX.test(code); +} + +describe('iotSubsystem code format validation', () => { + // 合法编码 + it('accepts simple lowercase word', () => { + expect(isValidCode('clean')).toBe(true); + }); + + it('accepts snake_case code', () => { + expect(isValidCode('clean_system')).toBe(true); + }); + + it('accepts code with digits at non-first position', () => { + expect(isValidCode('zone1')).toBe(true); + }); + + it('accepts code like security_v2', () => { + expect(isValidCode('security_v2')).toBe(true); + }); + + it('accepts single letter code', () => { + expect(isValidCode('a')).toBe(true); + }); + + // 非法编码 + it('rejects code starting with digit', () => { + expect(isValidCode('1clean')).toBe(false); + }); + + it('rejects code starting with underscore', () => { + expect(isValidCode('_clean')).toBe(false); + }); + + it('rejects code with uppercase letters', () => { + expect(isValidCode('Clean')).toBe(false); + expect(isValidCode('CLEAN')).toBe(false); + }); + + it('rejects code with hyphens', () => { + expect(isValidCode('clean-system')).toBe(false); + }); + + it('rejects code with spaces', () => { + expect(isValidCode('clean system')).toBe(false); + }); + + it('rejects empty string', () => { + expect(isValidCode('')).toBe(false); + }); + + it('rejects code with special characters', () => { + expect(isValidCode('clean@01')).toBe(false); + }); +}); + +/** + * 批量绑定分批逻辑测试 + * ⚠️ [Known Pitfall] 支持每批 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('batchBind split logic', () => { + it('returns single batch for 50 devices', () => { + 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('returns single batch for exactly 100 devices', () => { + 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('splits 150 devices into 2 batches (100 + 50)', () => { + const ids = Array.from({ length: 150 }, (_, i) => i + 1); + const batches = splitIntoBatches(ids, BATCH_SIZE); + expect(batches).toHaveLength(2); + expect(batches[0]).toHaveLength(100); + expect(batches[1]).toHaveLength(50); + }); + + it('splits 250 devices into 3 batches', () => { + 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('returns empty array for empty input', () => { + const batches = splitIntoBatches([], BATCH_SIZE); + expect(batches).toHaveLength(0); + }); + + it('preserves all device ids across batches', () => { + 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/subsystem/devices.vue b/apps/web-antd/src/views/iot/subsystem/devices.vue new file mode 100644 index 000000000..4339c405e --- /dev/null +++ b/apps/web-antd/src/views/iot/subsystem/devices.vue @@ -0,0 +1,365 @@ + + + diff --git a/apps/web-antd/src/views/iot/subsystem/form.vue b/apps/web-antd/src/views/iot/subsystem/form.vue new file mode 100644 index 000000000..928bda05d --- /dev/null +++ b/apps/web-antd/src/views/iot/subsystem/form.vue @@ -0,0 +1,152 @@ + + + diff --git a/apps/web-antd/src/views/iot/subsystem/list.vue b/apps/web-antd/src/views/iot/subsystem/list.vue new file mode 100644 index 000000000..f7d9c7e87 --- /dev/null +++ b/apps/web-antd/src/views/iot/subsystem/list.vue @@ -0,0 +1,303 @@ + + +