[F9/F10] 子系统管理 + 告警正交 5 态列表(UI 骨架 + mock)
两任务并行完成,因同动 router/iot.ts 与 locales/page.json 合并 commit。
F9 子系统管理 + 设备批量绑定:
- apps/web-antd/src/api/iot/subsystem/index.ts
- apps/web-antd/src/views/iot/subsystem/{list,form,devices}.vue
- apps/web-antd/src/views/iot/subsystem/__tests__/subsystem-utils.test.ts (18 用例)
- Known Pitfalls: 评审 A6 Redis stats 降级 / A7 403 拦截器 / 批量 100 台 / code regex snake_case
- 后端 B10/B11 API 契约: /iot/subsystem/{page,get,create,update,delete,simple-list,device-count}
+ /iot/device/{batchBindSubsystem,bindSubsystem} + /iot/device/page 加 subsystemId 过滤
F10 告警记录 (评审 C1 正交 5 态):
- apps/web-antd/src/views/iot/alarm/record/{list,detail}.vue
- apps/web-antd/src/views/iot/alarm/record/{alarm-state,api}.ts
- apps/web-antd/src/views/iot/alarm/record/components/{AlarmStateTag,AlarmOperations}.vue
- apps/web-antd/src/views/iot/alarm/record/__tests__/AlarmStateTag.spec.ts (12 用例)
- 5 态: 活跃·未确认(red) / 活跃·已确认(orange) / 已清除·未确认(gold醒目) / 已清除·已确认(green) / 已归档(default)
- Known Pitfalls: C1 5 态必展示 / archived 优先判断 / 30s 轮询 / 严重度颜色映射
- 后端 B12 API 契约: /iot/alarm-record/{page,get,ack,unack,clear,archive,batch-*,history,remark}
共同:
- apps/web-antd/src/router/routes/modules/iot.ts 追加 subsystem + alarm 路由
- locales/langs/{zh-CN,en-US}/page.json 追加 iot.subsystem.* + iot.alarm.*
note: 路由用顶级路径;项目采用动态菜单机制,菜单注册需后端 menu 表配置(运维侧工作),
前端路由可通过 URL 直接访问。
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
136
apps/web-antd/src/api/iot/subsystem/index.ts
Normal file
136
apps/web-antd/src/api/iot/subsystem/index.ts
Normal file
@@ -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<number>('/iot/subsystem/create', data);
|
||||
}
|
||||
|
||||
/** 更新子系统 */
|
||||
export function updateSubsystem(data: IotSubsystemApi.Subsystem) {
|
||||
return requestClient.put<boolean>('/iot/subsystem/update', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 删除子系统
|
||||
* 后端会校验子系统下是否仍有设备,有则返回业务异常(前端 deleteCount 检查后禁用按钮)
|
||||
*/
|
||||
export function deleteSubsystem(id: number) {
|
||||
return requestClient.delete<boolean>(`/iot/subsystem/delete?id=${id}`);
|
||||
}
|
||||
|
||||
/** 获取子系统详情 */
|
||||
export function getSubsystem(id: number) {
|
||||
return requestClient.get<IotSubsystemApi.Subsystem>(
|
||||
`/iot/subsystem/get?id=${id}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 子系统分页查询 */
|
||||
export function getSubsystemPage(params: IotSubsystemApi.SubsystemPageParam) {
|
||||
return requestClient.get<PageResult<IotSubsystemApi.Subsystem>>(
|
||||
'/iot/subsystem/page',
|
||||
{ params },
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 子系统精简列表(下拉)
|
||||
* 注意:⚠️ [评审 A7] 此接口已加权限,403 由请求拦截器统一处理
|
||||
*/
|
||||
export function getSubsystemSimpleList() {
|
||||
return requestClient.get<IotSubsystemApi.SubsystemSimple[]>(
|
||||
'/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<string, IotSubsystemApi.SubsystemDeviceCount>
|
||||
>('/iot/subsystem/device-count');
|
||||
}
|
||||
|
||||
// ─── 设备归属操作(B11 需提供)───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* 批量绑定设备到子系统
|
||||
* ⚠️ 每批 ≤ 100 台,调用方需自行分批
|
||||
* B11 需要实现:PUT /iot/device/batchBindSubsystem
|
||||
*/
|
||||
export function batchBindSubsystem(
|
||||
data: IotSubsystemApi.BatchBindSubsystemReqVO,
|
||||
) {
|
||||
return requestClient.put<boolean>('/iot/device/batchBindSubsystem', data);
|
||||
}
|
||||
|
||||
/**
|
||||
* 单设备解绑子系统(移除归属)
|
||||
* B11 需要实现:PUT /iot/device/bindSubsystem(subsystemId=null 表示解绑)
|
||||
*/
|
||||
export function unbindDeviceSubsystem(deviceId: number) {
|
||||
return requestClient.put<boolean>('/iot/device/bindSubsystem', {
|
||||
deviceId,
|
||||
subsystemId: null,
|
||||
} satisfies IotSubsystemApi.BindSubsystemReqVO);
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": "暂无历史记录"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
85
apps/web-antd/src/views/iot/alarm/record/alarm-state.ts
Normal file
85
apps/web-antd/src/views/iot/alarm/record/alarm-state.ts
Normal file
@@ -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<string, SeverityInfo> = {
|
||||
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);
|
||||
}
|
||||
149
apps/web-antd/src/views/iot/alarm/record/api.ts
Normal file
149
apps/web-antd/src/views/iot/alarm/record/api.ts
Normal file
@@ -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<string, unknown>;
|
||||
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<PageResult<AlarmRecordVO>>(
|
||||
'/iot/alarm-record/page',
|
||||
{
|
||||
params,
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
/** 查询告警记录详情 */
|
||||
export function getAlarmRecord(id: number) {
|
||||
return requestClient.get<AlarmRecordVO>(`/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<AlarmHistoryVO[]>(
|
||||
`/iot/alarm-record/history?alarmRecordId=${alarmRecordId}`,
|
||||
);
|
||||
}
|
||||
|
||||
/** 更新告警处理备注 */
|
||||
export function updateAlarmRemark(id: number, remark: string) {
|
||||
return requestClient.put('/iot/alarm-record/remark', { id, remark });
|
||||
}
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlarmRecordVO } from '../api';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Modal } from 'ant-design-vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'AlarmOperations' });
|
||||
|
||||
const props = defineProps<{
|
||||
record: AlarmRecordVO;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
ack: [id: number];
|
||||
archive: [id: number];
|
||||
clear: [id: number];
|
||||
unack: [id: number];
|
||||
}>();
|
||||
|
||||
/**
|
||||
* 操作按钮按状态动态显示(任务卡 §3.3)
|
||||
*
|
||||
* | 当前状态 | 可操作 |
|
||||
* |------------------|---------------------|
|
||||
* | 活跃·未确认 | 确认 / 清除 / 归档 |
|
||||
* | 活跃·已确认 | 撤销确认 / 清除 / 归档|
|
||||
* | 已清除·未确认 | 确认 / 归档 |
|
||||
* | 已清除·已确认 | 撤销确认 / 归档 |
|
||||
* | 已归档 | 无 |
|
||||
*/
|
||||
const operations = computed(() => {
|
||||
const { ackState, clearState, archived, id } = props.record;
|
||||
|
||||
if (archived === 1) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const ops: Array<{ danger?: boolean; handler: () => void; label: string }> =
|
||||
[];
|
||||
|
||||
// 确认 / 撤销确认
|
||||
if (ackState === 0) {
|
||||
ops.push({
|
||||
label: $t('iot.alarm.op.ack'),
|
||||
handler: () =>
|
||||
confirmOp($t('iot.alarm.op.ackConfirm'), () => emit('ack', id)),
|
||||
});
|
||||
} else {
|
||||
ops.push({
|
||||
label: $t('iot.alarm.op.unack'),
|
||||
danger: true,
|
||||
handler: () =>
|
||||
confirmOp($t('iot.alarm.op.unackConfirm'), () => emit('unack', id)),
|
||||
});
|
||||
}
|
||||
|
||||
// 清除(仅活跃时可清除)
|
||||
if (clearState === 0) {
|
||||
ops.push({
|
||||
label: $t('iot.alarm.op.clear'),
|
||||
handler: () =>
|
||||
confirmOp($t('iot.alarm.op.clearConfirm'), () => emit('clear', id)),
|
||||
});
|
||||
}
|
||||
|
||||
// 归档(活跃/已清除均可归档)
|
||||
ops.push({
|
||||
label: $t('iot.alarm.op.archive'),
|
||||
handler: () =>
|
||||
confirmOp($t('iot.alarm.op.archiveConfirm'), () => emit('archive', id)),
|
||||
});
|
||||
|
||||
return ops;
|
||||
});
|
||||
|
||||
function confirmOp(content: string, onOk: () => void) {
|
||||
Modal.confirm({
|
||||
title: $t('iot.alarm.op.confirmTitle'),
|
||||
content,
|
||||
onOk,
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex items-center gap-1">
|
||||
<template v-for="op in operations" :key="op.label">
|
||||
<a
|
||||
:class="op.danger ? 'text-red-500' : 'text-blue-500'"
|
||||
class="cursor-pointer text-xs"
|
||||
@click="op.handler()"
|
||||
>
|
||||
{{ op.label }}
|
||||
</a>
|
||||
</template>
|
||||
<span v-if="operations.length === 0" class="text-xs text-gray-400">
|
||||
{{ $t('iot.alarm.op.noOps') }}
|
||||
</span>
|
||||
</div>
|
||||
</template>
|
||||
@@ -0,0 +1,27 @@
|
||||
<script setup lang="ts">
|
||||
import type { AckState, ArchivedState, ClearState } from '../api';
|
||||
|
||||
import { computed } from 'vue';
|
||||
|
||||
import { Tag } from 'ant-design-vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { getStateLabel } from '../alarm-state';
|
||||
|
||||
defineOptions({ name: 'AlarmStateTag' });
|
||||
|
||||
const props = defineProps<{
|
||||
ack: AckState;
|
||||
archived: ArchivedState;
|
||||
clear: ClearState;
|
||||
}>();
|
||||
|
||||
const stateInfo = computed(() =>
|
||||
getStateLabel(props.ack, props.clear, props.archived),
|
||||
);
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Tag :color="stateInfo.color">{{ $t(stateInfo.labelKey) }}</Tag>
|
||||
</template>
|
||||
251
apps/web-antd/src/views/iot/alarm/record/detail.vue
Normal file
251
apps/web-antd/src/views/iot/alarm/record/detail.vue
Normal file
@@ -0,0 +1,251 @@
|
||||
<script setup lang="ts">
|
||||
import type { AlarmHistoryVO, AlarmRecordVO } from './api';
|
||||
|
||||
import { onMounted, ref, watch } from 'vue';
|
||||
|
||||
import {
|
||||
Button,
|
||||
Descriptions,
|
||||
DescriptionsItem,
|
||||
Drawer,
|
||||
Input,
|
||||
message,
|
||||
Spin,
|
||||
Tag,
|
||||
Timeline,
|
||||
TimelineItem,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { getSeverityInfoByNum } from './alarm-state';
|
||||
import { getAlarmHistory, getAlarmRecord, updateAlarmRemark } from './api';
|
||||
import AlarmStateTag from './components/AlarmStateTag.vue';
|
||||
|
||||
defineOptions({ name: 'AlarmRecordDetail' });
|
||||
|
||||
const props = defineProps<{
|
||||
open: boolean;
|
||||
recordId: null | number;
|
||||
}>();
|
||||
|
||||
const emit = defineEmits<{
|
||||
refresh: [];
|
||||
'update:open': [value: boolean];
|
||||
}>();
|
||||
|
||||
const loading = ref(false);
|
||||
const historyLoading = ref(false);
|
||||
const record = ref<AlarmRecordVO | null>(null);
|
||||
const historyList = ref<AlarmHistoryVO[]>([]);
|
||||
const remarkInput = ref('');
|
||||
const savingRemark = ref(false);
|
||||
|
||||
async function loadRecord() {
|
||||
if (!props.recordId) return;
|
||||
loading.value = true;
|
||||
try {
|
||||
record.value = await getAlarmRecord(props.recordId);
|
||||
remarkInput.value = record.value?.processRemark ?? '';
|
||||
await loadHistory();
|
||||
} finally {
|
||||
loading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function loadHistory() {
|
||||
if (!props.recordId) return;
|
||||
historyLoading.value = true;
|
||||
try {
|
||||
historyList.value = await getAlarmHistory(props.recordId);
|
||||
} finally {
|
||||
historyLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleSaveRemark() {
|
||||
if (!props.recordId) return;
|
||||
savingRemark.value = true;
|
||||
try {
|
||||
await updateAlarmRemark(props.recordId, remarkInput.value);
|
||||
message.success($t('iot.alarm.detail.remarkSaved'));
|
||||
emit('refresh');
|
||||
} finally {
|
||||
savingRemark.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
emit('update:open', false);
|
||||
}
|
||||
|
||||
function getSeverityColor(severity: number) {
|
||||
return getSeverityInfoByNum(severity).color;
|
||||
}
|
||||
|
||||
function getSeverityLabel(severity: number) {
|
||||
return $t(getSeverityInfoByNum(severity).labelKey);
|
||||
}
|
||||
|
||||
watch(
|
||||
() => props.open,
|
||||
(val) => {
|
||||
if (val && props.recordId) {
|
||||
loadRecord();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
onMounted(() => {
|
||||
if (props.open && props.recordId) {
|
||||
loadRecord();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Drawer
|
||||
:open="open"
|
||||
:title="$t('iot.alarm.detail.title')"
|
||||
width="640"
|
||||
destroy-on-close
|
||||
@close="handleClose"
|
||||
>
|
||||
<Spin :spinning="loading">
|
||||
<template v-if="record">
|
||||
<!-- 基本信息 -->
|
||||
<Descriptions
|
||||
:title="$t('iot.alarm.detail.basicInfo')"
|
||||
bordered
|
||||
:column="2"
|
||||
size="small"
|
||||
>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.alarmName')">
|
||||
{{ record.alarmName }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.severity')">
|
||||
<Tag :color="getSeverityColor(record.severity)">
|
||||
{{ getSeverityLabel(record.severity) }}
|
||||
</Tag>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.state')">
|
||||
<AlarmStateTag
|
||||
:ack="record.ackState"
|
||||
:clear="record.clearState"
|
||||
:archived="record.archived"
|
||||
/>
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.triggerCount')">
|
||||
{{ record.triggerCount }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.deviceName')">
|
||||
{{ record.deviceName ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.productName')">
|
||||
{{ record.productName ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.startTs')">
|
||||
{{ record.startTs }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.endTs')">
|
||||
{{ record.endTs ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.clearTs')">
|
||||
{{ record.clearTs ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
<DescriptionsItem :label="$t('iot.alarm.field.ackTs')">
|
||||
{{ record.ackTs ?? '-' }}
|
||||
</DescriptionsItem>
|
||||
</Descriptions>
|
||||
|
||||
<!-- 传播路径 -->
|
||||
<div
|
||||
v-if="record.propagatedTo && record.propagatedTo.length > 0"
|
||||
class="mt-4"
|
||||
>
|
||||
<div class="mb-2 font-semibold">
|
||||
{{ $t('iot.alarm.detail.propagationPath') }}
|
||||
</div>
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Tag
|
||||
v-for="item in record.propagatedTo"
|
||||
:key="`${item.type}-${item.id}`"
|
||||
color="blue"
|
||||
>
|
||||
{{ item.name }} ({{ item.type }})
|
||||
</Tag>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 告警详情 JSON -->
|
||||
<div v-if="record.details" class="mt-4">
|
||||
<div class="mb-2 font-semibold">
|
||||
{{ $t('iot.alarm.detail.details') }}
|
||||
</div>
|
||||
<pre class="max-h-32 overflow-auto rounded bg-gray-50 p-3 text-xs">{{
|
||||
JSON.stringify(record.details, null, 2)
|
||||
}}</pre>
|
||||
</div>
|
||||
|
||||
<!-- 处理备注 -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 font-semibold">
|
||||
{{ $t('iot.alarm.detail.remark') }}
|
||||
</div>
|
||||
<div class="flex gap-2">
|
||||
<Input.TextArea
|
||||
v-model:value="remarkInput"
|
||||
:disabled="record.archived === 1"
|
||||
:rows="3"
|
||||
:placeholder="$t('iot.alarm.detail.remarkPlaceholder')"
|
||||
/>
|
||||
<Button
|
||||
v-if="record.archived !== 1"
|
||||
type="primary"
|
||||
:loading="savingRemark"
|
||||
@click="handleSaveRemark"
|
||||
>
|
||||
{{ $t('action.save') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 历史变化 -->
|
||||
<div class="mt-4">
|
||||
<div class="mb-2 font-semibold">
|
||||
{{ $t('iot.alarm.detail.history') }}
|
||||
</div>
|
||||
<Spin :spinning="historyLoading">
|
||||
<Timeline v-if="historyList.length > 0">
|
||||
<TimelineItem
|
||||
v-for="(item, idx) in historyList"
|
||||
:key="idx"
|
||||
:color="
|
||||
item.state === 'ACTIVE'
|
||||
? 'red'
|
||||
: item.state === 'CLEARED'
|
||||
? 'green'
|
||||
: 'blue'
|
||||
"
|
||||
>
|
||||
<div class="flex items-center gap-2">
|
||||
<Tag>{{ item.state }}</Tag>
|
||||
<span class="text-xs text-gray-500">{{ item.ts }}</span>
|
||||
<span v-if="item.operator" class="text-xs text-gray-400">
|
||||
{{ item.operator }}
|
||||
</span>
|
||||
</div>
|
||||
<div v-if="item.remark" class="mt-1 text-xs text-gray-600">
|
||||
{{ item.remark }}
|
||||
</div>
|
||||
</TimelineItem>
|
||||
</Timeline>
|
||||
<div v-else class="text-sm text-gray-400">
|
||||
{{ $t('iot.alarm.detail.noHistory') }}
|
||||
</div>
|
||||
</Spin>
|
||||
</div>
|
||||
</template>
|
||||
</Spin>
|
||||
</Drawer>
|
||||
</template>
|
||||
363
apps/web-antd/src/views/iot/alarm/record/list.vue
Normal file
363
apps/web-antd/src/views/iot/alarm/record/list.vue
Normal file
@@ -0,0 +1,363 @@
|
||||
<script setup lang="ts">
|
||||
import type { AckState, AlarmRecordVO, ArchivedState, ClearState } from './api';
|
||||
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
|
||||
import { onBeforeUnmount, onMounted, ref } from 'vue';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
|
||||
import { Button, message, Modal, Select, Space, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import { getSeverityInfoByNum } from './alarm-state';
|
||||
import {
|
||||
ackAlarmRecord,
|
||||
archiveAlarmRecord,
|
||||
batchAckAlarmRecord,
|
||||
batchArchiveAlarmRecord,
|
||||
batchClearAlarmRecord,
|
||||
clearAlarmRecord,
|
||||
getAlarmRecordPage,
|
||||
unackAlarmRecord,
|
||||
} from './api';
|
||||
import AlarmOperations from './components/AlarmOperations.vue';
|
||||
import AlarmStateTag from './components/AlarmStateTag.vue';
|
||||
import AlarmRecordDetail from './detail.vue';
|
||||
|
||||
defineOptions({ name: 'IoTAlarmRecordList' });
|
||||
|
||||
// ── Filter state ──────────────────────────────────────────────────────────────
|
||||
|
||||
const filterAck = ref<'' | AckState>('');
|
||||
const filterClear = ref<'' | ClearState>('');
|
||||
// Default: exclude archived (show only non-archived)
|
||||
const filterArchived = ref<'' | 'exclude' | ArchivedState>(0 as ArchivedState);
|
||||
|
||||
// ── Detail drawer ─────────────────────────────────────────────────────────────
|
||||
|
||||
const detailOpen = ref(false);
|
||||
const detailRecordId = ref<null | number>(null);
|
||||
|
||||
function handleViewDetail(row: AlarmRecordVO) {
|
||||
detailRecordId.value = row.id;
|
||||
detailOpen.value = true;
|
||||
}
|
||||
|
||||
// ── Grid ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ type: 'checkbox', width: 40 },
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'alarmName',
|
||||
title: $t('iot.alarm.field.alarmName'),
|
||||
minWidth: 150,
|
||||
},
|
||||
{
|
||||
field: 'severity',
|
||||
title: $t('iot.alarm.field.severity'),
|
||||
width: 100,
|
||||
slots: { default: 'severity' },
|
||||
},
|
||||
{
|
||||
field: 'ackState',
|
||||
title: $t('iot.alarm.field.state'),
|
||||
width: 160,
|
||||
slots: { default: 'state' },
|
||||
},
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: $t('iot.alarm.field.deviceName'),
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'productName',
|
||||
title: $t('iot.alarm.field.productName'),
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'triggerCount',
|
||||
title: $t('iot.alarm.field.triggerCount'),
|
||||
width: 90,
|
||||
},
|
||||
{
|
||||
field: 'startTs',
|
||||
title: $t('iot.alarm.field.startTs'),
|
||||
width: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
field: 'endTs',
|
||||
title: $t('iot.alarm.field.endTs'),
|
||||
width: 170,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: $t('action.action'),
|
||||
width: 200,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({ page }) => {
|
||||
return await getAlarmRecordPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
ackState: filterAck.value,
|
||||
clearState: filterClear.value,
|
||||
archived:
|
||||
filterArchived.value === 'exclude' ? 0 : filterArchived.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: {
|
||||
keyField: 'id',
|
||||
isHover: true,
|
||||
},
|
||||
toolbarConfig: {
|
||||
refresh: true,
|
||||
search: true,
|
||||
},
|
||||
} as VxeTableGridOptions<AlarmRecordVO>,
|
||||
});
|
||||
|
||||
function handleRefresh() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
// ── Polling 30s ───────────────────────────────────────────────────────────────
|
||||
|
||||
let pollingTimer: null | ReturnType<typeof setInterval> = null;
|
||||
|
||||
onMounted(() => {
|
||||
pollingTimer = setInterval(() => {
|
||||
gridApi.query();
|
||||
}, 30_000);
|
||||
});
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
if (pollingTimer) {
|
||||
clearInterval(pollingTimer);
|
||||
pollingTimer = null;
|
||||
}
|
||||
});
|
||||
|
||||
// ── Single operations ─────────────────────────────────────────────────────────
|
||||
|
||||
async function handleAck(id: number) {
|
||||
await ackAlarmRecord(id);
|
||||
message.success($t('iot.alarm.op.ackSuccess'));
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
async function handleUnack(id: number) {
|
||||
await unackAlarmRecord(id);
|
||||
message.success($t('iot.alarm.op.unackSuccess'));
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
async function handleClear(id: number) {
|
||||
await clearAlarmRecord(id);
|
||||
message.success($t('iot.alarm.op.clearSuccess'));
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
async function handleArchive(id: number) {
|
||||
await archiveAlarmRecord(id);
|
||||
message.success($t('iot.alarm.op.archiveSuccess'));
|
||||
handleRefresh();
|
||||
}
|
||||
|
||||
// ── Batch operations ──────────────────────────────────────────────────────────
|
||||
|
||||
function getSelectedIds(): number[] {
|
||||
const rows = gridApi.grid?.getCheckboxRecords() as
|
||||
| AlarmRecordVO[]
|
||||
| undefined;
|
||||
return rows?.map((r) => r.id) ?? [];
|
||||
}
|
||||
|
||||
function handleBatchAck() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
message.warning($t('iot.alarm.op.selectFirst'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: $t('iot.alarm.op.confirmTitle'),
|
||||
content: $t('iot.alarm.op.batchAckConfirm', { count: ids.length }),
|
||||
async onOk() {
|
||||
await batchAckAlarmRecord(ids);
|
||||
message.success($t('iot.alarm.op.ackSuccess'));
|
||||
handleRefresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleBatchClear() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
message.warning($t('iot.alarm.op.selectFirst'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: $t('iot.alarm.op.confirmTitle'),
|
||||
content: $t('iot.alarm.op.batchClearConfirm', { count: ids.length }),
|
||||
async onOk() {
|
||||
await batchClearAlarmRecord(ids);
|
||||
message.success($t('iot.alarm.op.clearSuccess'));
|
||||
handleRefresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function handleBatchArchive() {
|
||||
const ids = getSelectedIds();
|
||||
if (ids.length === 0) {
|
||||
message.warning($t('iot.alarm.op.selectFirst'));
|
||||
return;
|
||||
}
|
||||
Modal.confirm({
|
||||
title: $t('iot.alarm.op.confirmTitle'),
|
||||
content: $t('iot.alarm.op.batchArchiveConfirm', { count: ids.length }),
|
||||
async onOk() {
|
||||
await batchArchiveAlarmRecord(ids);
|
||||
message.success($t('iot.alarm.op.archiveSuccess'));
|
||||
handleRefresh();
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// ── Helpers ───────────────────────────────────────────────────────────────────
|
||||
|
||||
function getSeverityColor(severity: number) {
|
||||
return getSeverityInfoByNum(severity).color;
|
||||
}
|
||||
|
||||
function getSeverityLabel(severity: number) {
|
||||
return $t(getSeverityInfoByNum(severity).labelKey);
|
||||
}
|
||||
|
||||
// Filter label helpers (keep lines ≤80 chars in template)
|
||||
const lblAck = () => $t('iot.alarm.filter.ackState');
|
||||
const lblClear = () => $t('iot.alarm.filter.clearState');
|
||||
const lblArchived = () => $t('iot.alarm.filter.archived');
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 三维筛选工具栏 -->
|
||||
<div class="mb-3 flex flex-wrap items-center gap-2">
|
||||
<Space wrap>
|
||||
<span class="text-sm text-gray-600">{{ lblAck() }}:</span>
|
||||
<Select
|
||||
v-model:value="filterAck"
|
||||
:options="[
|
||||
{ label: $t('iot.alarm.filter.all'), value: '' },
|
||||
{ label: $t('iot.alarm.filter.unacked'), value: 0 },
|
||||
{ label: $t('iot.alarm.filter.acked'), value: 1 },
|
||||
]"
|
||||
style="width: 110px"
|
||||
size="small"
|
||||
@change="handleRefresh"
|
||||
/>
|
||||
|
||||
<span class="text-sm text-gray-600">{{ lblClear() }}:</span>
|
||||
<Select
|
||||
v-model:value="filterClear"
|
||||
:options="[
|
||||
{ label: $t('iot.alarm.filter.all'), value: '' },
|
||||
{ label: $t('iot.alarm.filter.active'), value: 0 },
|
||||
{ label: $t('iot.alarm.filter.cleared'), value: 1 },
|
||||
]"
|
||||
style="width: 110px"
|
||||
size="small"
|
||||
@change="handleRefresh"
|
||||
/>
|
||||
|
||||
<span class="text-sm text-gray-600">{{ lblArchived() }}:</span>
|
||||
<Select
|
||||
v-model:value="filterArchived"
|
||||
:options="[
|
||||
{ label: $t('iot.alarm.filter.excludeArchived'), value: 0 },
|
||||
{ label: $t('iot.alarm.filter.allRecords'), value: '' },
|
||||
{ label: $t('iot.alarm.filter.onlyArchived'), value: 1 },
|
||||
]"
|
||||
style="width: 130px"
|
||||
size="small"
|
||||
@change="handleRefresh"
|
||||
/>
|
||||
</Space>
|
||||
|
||||
<div class="ml-auto flex gap-2">
|
||||
<Button size="small" @click="handleBatchAck">
|
||||
{{ $t('iot.alarm.op.batchAck') }}
|
||||
</Button>
|
||||
<Button size="small" @click="handleBatchClear">
|
||||
{{ $t('iot.alarm.op.batchClear') }}
|
||||
</Button>
|
||||
<Button size="small" danger @click="handleBatchArchive">
|
||||
{{ $t('iot.alarm.op.batchArchive') }}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<Grid :table-title="$t('iot.alarm.list.title')">
|
||||
<!-- 严重度列 -->
|
||||
<template #severity="{ row }">
|
||||
<Tag :color="getSeverityColor(row.severity)">
|
||||
{{ getSeverityLabel(row.severity) }}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 状态列 — 正交 5 态 (评审 C1) -->
|
||||
<template #state="{ row }">
|
||||
<AlarmStateTag
|
||||
:ack="row.ackState"
|
||||
:clear="row.clearState"
|
||||
:archived="row.archived"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<div class="flex items-center gap-1">
|
||||
<AlarmOperations
|
||||
:record="row"
|
||||
@ack="handleAck"
|
||||
@unack="handleUnack"
|
||||
@clear="handleClear"
|
||||
@archive="handleArchive"
|
||||
/>
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('action.more'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.VIEW,
|
||||
onClick: handleViewDetail.bind(null, row),
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 详情抽屉 -->
|
||||
<AlarmRecordDetail
|
||||
v-model:open="detailOpen"
|
||||
:record-id="detailRecordId"
|
||||
@refresh="handleRefresh"
|
||||
/>
|
||||
</Page>
|
||||
</template>
|
||||
@@ -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<T>(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);
|
||||
});
|
||||
});
|
||||
365
apps/web-antd/src/views/iot/subsystem/devices.vue
Normal file
365
apps/web-antd/src/views/iot/subsystem/devices.vue
Normal file
@@ -0,0 +1,365 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotDeviceApi } from '#/api/iot/device/device';
|
||||
import type { IotSubsystemApi } from '#/api/iot/subsystem';
|
||||
|
||||
import { computed, onMounted, ref } from 'vue';
|
||||
import { useRoute, useRouter } from 'vue-router';
|
||||
|
||||
import { Page } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import {
|
||||
Alert,
|
||||
Button,
|
||||
Card,
|
||||
Input,
|
||||
message,
|
||||
Modal,
|
||||
Statistic,
|
||||
Tag,
|
||||
Transfer,
|
||||
} from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import { getDevicePage, getSimpleDeviceList } from '#/api/iot/device/device';
|
||||
import {
|
||||
batchBindSubsystem,
|
||||
getSubsystem,
|
||||
getSubsystemDeviceCount,
|
||||
unbindDeviceSubsystem,
|
||||
} from '#/api/iot/subsystem';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'IoTSubsystemDevices' });
|
||||
|
||||
const route = useRoute();
|
||||
const router = useRouter();
|
||||
|
||||
const subsystemId = computed(() => Number(route.params.id));
|
||||
const subsystemName = computed(() =>
|
||||
String(route.query.name ?? $t('iot.subsystem.title')),
|
||||
);
|
||||
|
||||
const subsystem = ref<IotSubsystemApi.Subsystem>();
|
||||
const deviceStats = ref<IotSubsystemApi.SubsystemDeviceCount>({
|
||||
subsystemId: 0,
|
||||
total: 0,
|
||||
online: 0,
|
||||
alarm: 0,
|
||||
});
|
||||
|
||||
/** 设备列表搜索关键字 */
|
||||
const keyword = ref('');
|
||||
|
||||
// ─── 批量绑定弹窗状态 ────────────────────────────────────────────────────────
|
||||
const bindModalVisible = ref(false);
|
||||
const bindModalLoading = ref(false);
|
||||
/** Transfer 左侧候选设备(未归属当前子系统) */
|
||||
const candidateDevices = ref<IotDeviceApi.Device[]>([]);
|
||||
/** Transfer 已选(右侧)—— ant-design-vue Transfer 要求 string[] */
|
||||
const bindSelectedKeys = ref<string[]>([]);
|
||||
const bindTargetKeys = ref<string[]>([]);
|
||||
|
||||
/** 加载子系统详情 */
|
||||
async function loadSubsystem() {
|
||||
try {
|
||||
subsystem.value = await getSubsystem(subsystemId.value);
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
/** 加载设备统计(Redis 聚合,⚠️ A6) */
|
||||
async function loadDeviceStats() {
|
||||
try {
|
||||
const map = await getSubsystemDeviceCount();
|
||||
const stats = map[String(subsystemId.value)];
|
||||
if (stats) deviceStats.value = stats;
|
||||
} catch {
|
||||
// B10 未就绪,忽略
|
||||
}
|
||||
}
|
||||
|
||||
/** 刷新设备列表 */
|
||||
function handleRefresh() {
|
||||
loadDeviceStats();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置搜索 */
|
||||
function handleReset() {
|
||||
keyword.value = '';
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 解绑单台设备 */
|
||||
async function handleUnbind(row: IotDeviceApi.Device) {
|
||||
const hide = message.loading({
|
||||
content: $t('iot.subsystem.unbinding'),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await unbindDeviceSubsystem(row.id!);
|
||||
message.success($t('iot.subsystem.unbindSuccess'));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hide();
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 批量绑定 ────────────────────────────────────────────────────────────────
|
||||
|
||||
/** 打开批量绑定弹窗 */
|
||||
async function openBindModal() {
|
||||
bindModalLoading.value = true;
|
||||
bindModalVisible.value = true;
|
||||
bindTargetKeys.value = [];
|
||||
bindSelectedKeys.value = [];
|
||||
try {
|
||||
// 获取全部设备的精简列表(未过滤子系统)
|
||||
// ⚠️ [评审 A7] simple-list 已加权限,403 由请求拦截器统一处理
|
||||
candidateDevices.value = await getSimpleDeviceList();
|
||||
} catch {
|
||||
candidateDevices.value = [];
|
||||
} finally {
|
||||
bindModalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 确认批量绑定 */
|
||||
async function handleBindConfirm() {
|
||||
if (bindTargetKeys.value.length === 0) {
|
||||
message.warning($t('iot.subsystem.selectDeviceTip'));
|
||||
return;
|
||||
}
|
||||
const allIds = bindTargetKeys.value.map(Number);
|
||||
const BATCH_SIZE = 100; // ⚠️ 每批 ≤ 100 台
|
||||
const batches: number[][] = [];
|
||||
for (let i = 0; i < allIds.length; i += BATCH_SIZE) {
|
||||
batches.push(allIds.slice(i, i + BATCH_SIZE));
|
||||
}
|
||||
bindModalLoading.value = true;
|
||||
try {
|
||||
for (const batch of batches) {
|
||||
await batchBindSubsystem({
|
||||
deviceIds: batch,
|
||||
subsystemId: subsystemId.value,
|
||||
});
|
||||
}
|
||||
message.success($t('iot.subsystem.bindSuccess', [String(allIds.length)]));
|
||||
bindModalVisible.value = false;
|
||||
handleRefresh();
|
||||
} finally {
|
||||
bindModalLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
/** Transfer 数据源(左侧候选) */
|
||||
const transferDataSource = computed(() =>
|
||||
candidateDevices.value.map((d) => ({
|
||||
key: String(d.id),
|
||||
title: d.deviceName,
|
||||
description: d.serialNumber ?? '',
|
||||
})),
|
||||
);
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'deviceName',
|
||||
title: $t('iot.subsystem.deviceName'),
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'serialNumber',
|
||||
title: $t('iot.subsystem.serialNumber'),
|
||||
minWidth: 160,
|
||||
},
|
||||
{
|
||||
field: 'state',
|
||||
title: $t('iot.subsystem.deviceState'),
|
||||
width: 100,
|
||||
slots: { default: 'state' },
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: $t('common.createTime'),
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: $t('common.action'),
|
||||
width: 120,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
] as VxeTableGridOptions<IotDeviceApi.Device>['columns'],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
return await getDevicePage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
subsystemId: subsystemId.value,
|
||||
deviceName: keyword.value || undefined,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: { keyField: 'id', isHover: true },
|
||||
toolbarConfig: { refresh: true },
|
||||
pagerConfig: { pageSize: 20 },
|
||||
} as VxeTableGridOptions<IotDeviceApi.Device>,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await Promise.all([loadSubsystem(), loadDeviceStats()]);
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<!-- 返回按钮 -->
|
||||
<div class="mb-3">
|
||||
<Button @click="router.back()">
|
||||
<IconifyIcon icon="ant-design:arrow-left-outlined" class="mr-1" />
|
||||
{{ $t('common.back') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<!-- 子系统信息卡片 -->
|
||||
<Card class="mb-4" :title="subsystemName">
|
||||
<div class="flex flex-wrap gap-8">
|
||||
<Statistic
|
||||
:title="$t('iot.subsystem.statTotal')"
|
||||
:value="deviceStats.total"
|
||||
/>
|
||||
<Statistic
|
||||
:title="$t('iot.subsystem.statOnline')"
|
||||
:value="deviceStats.online"
|
||||
:value-style="{ color: '#52c41a' }"
|
||||
/>
|
||||
<Statistic
|
||||
:title="$t('iot.subsystem.statAlarm')"
|
||||
:value="deviceStats.alarm"
|
||||
:value-style="{ color: '#f5222d' }"
|
||||
/>
|
||||
</div>
|
||||
<Alert
|
||||
v-if="deviceStats.total === 0 && deviceStats.online === 0"
|
||||
class="mt-3"
|
||||
type="info"
|
||||
:message="$t('iot.subsystem.statsNotice')"
|
||||
show-icon
|
||||
/>
|
||||
</Card>
|
||||
|
||||
<!-- 设备列表 + 批量绑定 -->
|
||||
<Card :body-style="{ padding: '16px' }" class="mb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="keyword"
|
||||
:placeholder="$t('iot.subsystem.deviceSearchPlaceholder')"
|
||||
allow-clear
|
||||
style="width: 220px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
{{ $t('common.search') }}
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
<Button type="primary" @click="openBindModal">
|
||||
<IconifyIcon icon="ant-design:plus-outlined" class="mr-1" />
|
||||
{{ $t('iot.subsystem.batchBind') }}
|
||||
</Button>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<Grid :table-title="$t('iot.subsystem.deviceListTitle')">
|
||||
<!-- 设备状态列 -->
|
||||
<template #state="{ row }">
|
||||
<Tag :color="row.state === 1 ? 'green' : 'default'">
|
||||
{{
|
||||
row.state === 1
|
||||
? $t('iot.subsystem.deviceOnline')
|
||||
: $t('iot.subsystem.deviceOffline')
|
||||
}}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('iot.subsystem.unbind'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
auth: ['iot:device:update'],
|
||||
popConfirm: {
|
||||
title: $t('iot.subsystem.unbindConfirm', [row.deviceName]),
|
||||
confirm: handleUnbind.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
|
||||
<!-- 批量绑定弹窗 -->
|
||||
<Modal
|
||||
v-model:open="bindModalVisible"
|
||||
:title="$t('iot.subsystem.batchBind')"
|
||||
width="720px"
|
||||
:confirm-loading="bindModalLoading"
|
||||
:ok-text="$t('common.confirm')"
|
||||
:cancel-text="$t('common.cancel')"
|
||||
@ok="handleBindConfirm"
|
||||
>
|
||||
<Transfer
|
||||
v-model:target-keys="bindTargetKeys"
|
||||
v-model:selected-keys="bindSelectedKeys"
|
||||
:data-source="transferDataSource"
|
||||
:titles="[
|
||||
$t('iot.subsystem.transferCandidate'),
|
||||
$t('iot.subsystem.transferSelected'),
|
||||
]"
|
||||
:list-style="{ width: '280px', height: '320px' }"
|
||||
show-search
|
||||
:filter-option="
|
||||
(inputValue, item) =>
|
||||
(item?.title ?? '').toLowerCase().includes(inputValue.toLowerCase())
|
||||
"
|
||||
>
|
||||
<template #render="item">
|
||||
<span>{{ item?.title ?? '' }}</span>
|
||||
<span v-if="item?.description" class="ml-2 text-xs text-gray-400">{{
|
||||
item.description
|
||||
}}</span>
|
||||
</template>
|
||||
</Transfer>
|
||||
<div class="mt-2 text-sm text-gray-500">
|
||||
{{ $t('iot.subsystem.batchBindHint') }}
|
||||
</div>
|
||||
</Modal>
|
||||
</Page>
|
||||
</template>
|
||||
152
apps/web-antd/src/views/iot/subsystem/form.vue
Normal file
152
apps/web-antd/src/views/iot/subsystem/form.vue
Normal file
@@ -0,0 +1,152 @@
|
||||
<script setup lang="ts">
|
||||
import type { IotSubsystemApi } from '#/api/iot/subsystem';
|
||||
|
||||
import { computed, ref } from 'vue';
|
||||
|
||||
import { useVbenModal } from '@vben/common-ui';
|
||||
|
||||
import { message } from 'ant-design-vue';
|
||||
|
||||
import { useVbenForm, z } from '#/adapter/form';
|
||||
import {
|
||||
createSubsystem,
|
||||
getSubsystem,
|
||||
updateSubsystem,
|
||||
} from '#/api/iot/subsystem';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
defineOptions({ name: 'IoTSubsystemForm' });
|
||||
|
||||
const emit = defineEmits<{ success: [] }>();
|
||||
const formData = ref<IotSubsystemApi.Subsystem>();
|
||||
|
||||
/** code 格式正则:⚠️ [Known Pitfall] snake_case */
|
||||
const CODE_REGEX = /^[a-z][a-z0-9_]*$/;
|
||||
|
||||
const getTitle = computed(() =>
|
||||
formData.value?.id
|
||||
? $t('ui.actionTitle.edit', [$t('iot.subsystem.title')])
|
||||
: $t('ui.actionTitle.create', [$t('iot.subsystem.title')]),
|
||||
);
|
||||
|
||||
const [Form, formApi] = useVbenForm({
|
||||
commonConfig: {
|
||||
componentProps: { class: 'w-full' },
|
||||
},
|
||||
wrapperClass: 'grid-cols-1',
|
||||
layout: 'horizontal',
|
||||
showDefaultActions: false,
|
||||
schema: [
|
||||
{
|
||||
component: 'Input',
|
||||
fieldName: 'id',
|
||||
dependencies: { triggerFields: [''], show: () => false },
|
||||
},
|
||||
{
|
||||
fieldName: 'name',
|
||||
label: $t('iot.subsystem.name'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('iot.subsystem.namePlaceholder'),
|
||||
maxlength: 128,
|
||||
showCount: true,
|
||||
},
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, $t('iot.subsystem.nameRequired'))
|
||||
.max(128, $t('iot.subsystem.nameTooLong')),
|
||||
},
|
||||
{
|
||||
fieldName: 'code',
|
||||
label: $t('iot.subsystem.code'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('iot.subsystem.codePlaceholder'),
|
||||
maxlength: 64,
|
||||
showCount: true,
|
||||
},
|
||||
help: $t('iot.subsystem.codeHelp'),
|
||||
rules: z
|
||||
.string()
|
||||
.min(1, $t('iot.subsystem.codeRequired'))
|
||||
.max(64, $t('iot.subsystem.codeTooLong'))
|
||||
.regex(CODE_REGEX, $t('iot.subsystem.codeFormat')),
|
||||
},
|
||||
{
|
||||
fieldName: 'status',
|
||||
label: $t('iot.subsystem.status'),
|
||||
component: 'RadioGroup',
|
||||
componentProps: {
|
||||
options: [
|
||||
{ label: $t('iot.subsystem.statusEnabled'), value: 1 },
|
||||
{ label: $t('iot.subsystem.statusDisabled'), value: 0 },
|
||||
],
|
||||
buttonStyle: 'solid',
|
||||
optionType: 'button',
|
||||
},
|
||||
defaultValue: 1,
|
||||
rules: 'required',
|
||||
},
|
||||
{
|
||||
fieldName: 'icon',
|
||||
label: $t('iot.subsystem.icon'),
|
||||
component: 'Input',
|
||||
componentProps: {
|
||||
placeholder: $t('iot.subsystem.iconPlaceholder'),
|
||||
maxlength: 256,
|
||||
},
|
||||
},
|
||||
{
|
||||
fieldName: 'description',
|
||||
label: $t('iot.subsystem.description'),
|
||||
component: 'Textarea',
|
||||
componentProps: {
|
||||
placeholder: $t('iot.subsystem.descriptionPlaceholder'),
|
||||
rows: 3,
|
||||
maxlength: 500,
|
||||
showCount: true,
|
||||
},
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const [Modal, modalApi] = useVbenModal({
|
||||
async onConfirm() {
|
||||
const { valid } = await formApi.validate();
|
||||
if (!valid) return;
|
||||
modalApi.lock();
|
||||
try {
|
||||
const values = (await formApi.getValues()) as IotSubsystemApi.Subsystem;
|
||||
await (values.id ? updateSubsystem(values) : createSubsystem(values));
|
||||
await modalApi.close();
|
||||
emit('success');
|
||||
message.success($t('ui.actionMessage.operationSuccess'));
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
async onOpenChange(isOpen: boolean) {
|
||||
if (!isOpen) {
|
||||
formData.value = undefined;
|
||||
return;
|
||||
}
|
||||
const data = modalApi.getData<IotSubsystemApi.Subsystem>();
|
||||
if (!data?.id) return;
|
||||
modalApi.lock();
|
||||
try {
|
||||
formData.value = await getSubsystem(data.id);
|
||||
await formApi.setValues(formData.value);
|
||||
} finally {
|
||||
modalApi.unlock();
|
||||
}
|
||||
},
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Modal :title="getTitle" class="w-[600px]">
|
||||
<div class="mx-4">
|
||||
<Form />
|
||||
</div>
|
||||
</Modal>
|
||||
</template>
|
||||
303
apps/web-antd/src/views/iot/subsystem/list.vue
Normal file
303
apps/web-antd/src/views/iot/subsystem/list.vue
Normal file
@@ -0,0 +1,303 @@
|
||||
<script setup lang="ts">
|
||||
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
|
||||
import type { IotSubsystemApi } from '#/api/iot/subsystem';
|
||||
|
||||
import { onMounted, ref } from 'vue';
|
||||
import { useRouter } from 'vue-router';
|
||||
|
||||
import { Page, useVbenModal } from '@vben/common-ui';
|
||||
import { IconifyIcon } from '@vben/icons';
|
||||
|
||||
import { Button, Card, Input, message, Select, Tag } from 'ant-design-vue';
|
||||
|
||||
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
|
||||
import {
|
||||
deleteSubsystem,
|
||||
getSubsystemDeviceCount,
|
||||
getSubsystemPage,
|
||||
} from '#/api/iot/subsystem';
|
||||
import { $t } from '#/locales';
|
||||
|
||||
import SubsystemForm from './form.vue';
|
||||
|
||||
defineOptions({ name: 'IoTSubsystemList' });
|
||||
|
||||
const router = useRouter();
|
||||
|
||||
/** 设备数量 map: subsystemId → count */
|
||||
const deviceCountMap = ref<
|
||||
Record<string, IotSubsystemApi.SubsystemDeviceCount>
|
||||
>({});
|
||||
|
||||
const queryParams = ref<{
|
||||
keyword?: string;
|
||||
status?: number;
|
||||
}>({
|
||||
keyword: undefined,
|
||||
status: undefined,
|
||||
});
|
||||
|
||||
const [FormModal, formModalApi] = useVbenModal({
|
||||
connectedComponent: SubsystemForm,
|
||||
destroyOnClose: true,
|
||||
});
|
||||
|
||||
/** 加载设备统计数据(Redis 聚合,⚠️ A6) */
|
||||
async function loadDeviceCount() {
|
||||
try {
|
||||
deviceCountMap.value = await getSubsystemDeviceCount();
|
||||
} catch {
|
||||
// B10 未就绪时忽略,不阻断页面
|
||||
deviceCountMap.value = {};
|
||||
}
|
||||
}
|
||||
|
||||
/** 搜索 */
|
||||
function handleSearch() {
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 重置 */
|
||||
function handleReset() {
|
||||
queryParams.value.keyword = undefined;
|
||||
queryParams.value.status = undefined;
|
||||
handleSearch();
|
||||
}
|
||||
|
||||
/** 刷新 */
|
||||
function handleRefresh() {
|
||||
loadDeviceCount();
|
||||
gridApi.query();
|
||||
}
|
||||
|
||||
/** 新建 */
|
||||
function handleCreate() {
|
||||
formModalApi.setData(null).open();
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
function handleEdit(row: IotSubsystemApi.Subsystem) {
|
||||
formModalApi.setData(row).open();
|
||||
}
|
||||
|
||||
/** 查看设备 */
|
||||
function handleViewDevices(row: IotSubsystemApi.Subsystem) {
|
||||
router.push({
|
||||
name: 'IoTSubsystemDevices',
|
||||
params: { id: row.id },
|
||||
query: { name: row.name },
|
||||
});
|
||||
}
|
||||
|
||||
/** 删除:有设备时禁用 */
|
||||
async function handleDelete(row: IotSubsystemApi.Subsystem) {
|
||||
const count = deviceCountMap.value[String(row.id)]?.total ?? 0;
|
||||
if (count > 0) {
|
||||
message.warning($t('iot.subsystem.deleteDisabledTip', [String(count)]));
|
||||
return;
|
||||
}
|
||||
const hideLoading = message.loading({
|
||||
content: $t('ui.actionMessage.deleting', [row.name]),
|
||||
duration: 0,
|
||||
});
|
||||
try {
|
||||
await deleteSubsystem(row.id!);
|
||||
message.success($t('ui.actionMessage.deleteSuccess', [row.name]));
|
||||
handleRefresh();
|
||||
} finally {
|
||||
hideLoading();
|
||||
}
|
||||
}
|
||||
|
||||
/** 判断删除按钮是否应禁用 */
|
||||
function isDeleteDisabled(row: IotSubsystemApi.Subsystem): boolean {
|
||||
const count = deviceCountMap.value[String(row.id)]?.total ?? 0;
|
||||
return count > 0;
|
||||
}
|
||||
|
||||
const [Grid, gridApi] = useVbenVxeGrid({
|
||||
gridOptions: {
|
||||
columns: [
|
||||
{ field: 'id', title: 'ID', width: 80 },
|
||||
{
|
||||
field: 'name',
|
||||
title: $t('iot.subsystem.name'),
|
||||
minWidth: 140,
|
||||
},
|
||||
{
|
||||
field: 'code',
|
||||
title: $t('iot.subsystem.code'),
|
||||
minWidth: 120,
|
||||
},
|
||||
{
|
||||
field: 'status',
|
||||
title: $t('iot.subsystem.status'),
|
||||
width: 100,
|
||||
slots: { default: 'status' },
|
||||
},
|
||||
{
|
||||
field: 'deviceCount',
|
||||
title: $t('iot.subsystem.deviceCount'),
|
||||
width: 100,
|
||||
slots: { default: 'deviceCount' },
|
||||
},
|
||||
{
|
||||
field: 'description',
|
||||
title: $t('iot.subsystem.description'),
|
||||
minWidth: 160,
|
||||
showOverflow: true,
|
||||
},
|
||||
{
|
||||
field: 'createTime',
|
||||
title: $t('common.createTime'),
|
||||
minWidth: 180,
|
||||
formatter: 'formatDateTime',
|
||||
},
|
||||
{
|
||||
title: $t('common.action'),
|
||||
width: 240,
|
||||
fixed: 'right',
|
||||
slots: { default: 'actions' },
|
||||
},
|
||||
] as VxeTableGridOptions<IotSubsystemApi.Subsystem>['columns'],
|
||||
height: 'auto',
|
||||
keepSource: true,
|
||||
proxyConfig: {
|
||||
ajax: {
|
||||
query: async ({
|
||||
page,
|
||||
}: {
|
||||
page: { currentPage: number; pageSize: number };
|
||||
}) => {
|
||||
return await getSubsystemPage({
|
||||
pageNo: page.currentPage,
|
||||
pageSize: page.pageSize,
|
||||
...queryParams.value,
|
||||
});
|
||||
},
|
||||
},
|
||||
},
|
||||
rowConfig: { keyField: 'id', isHover: true },
|
||||
toolbarConfig: { refresh: true },
|
||||
pagerConfig: { pageSize: 20 },
|
||||
} as VxeTableGridOptions<IotSubsystemApi.Subsystem>,
|
||||
});
|
||||
|
||||
onMounted(async () => {
|
||||
await loadDeviceCount();
|
||||
});
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<Page auto-content-height>
|
||||
<FormModal @success="handleRefresh" />
|
||||
|
||||
<!-- 搜索工具栏 -->
|
||||
<Card :body-style="{ padding: '16px' }" class="mb-4">
|
||||
<div class="flex flex-wrap items-center gap-3">
|
||||
<Input
|
||||
v-model:value="queryParams.keyword"
|
||||
:placeholder="$t('iot.subsystem.searchPlaceholder')"
|
||||
allow-clear
|
||||
style="width: 220px"
|
||||
@press-enter="handleSearch"
|
||||
/>
|
||||
<Select
|
||||
v-model:value="queryParams.status"
|
||||
:placeholder="$t('iot.subsystem.statusPlaceholder')"
|
||||
allow-clear
|
||||
style="width: 160px"
|
||||
>
|
||||
<Select.Option :value="1">
|
||||
{{ $t('iot.subsystem.statusEnabled') }}
|
||||
</Select.Option>
|
||||
<Select.Option :value="0">
|
||||
{{ $t('iot.subsystem.statusDisabled') }}
|
||||
</Select.Option>
|
||||
</Select>
|
||||
<Button type="primary" @click="handleSearch">
|
||||
<IconifyIcon icon="ant-design:search-outlined" class="mr-1" />
|
||||
{{ $t('common.search') }}
|
||||
</Button>
|
||||
<Button @click="handleReset">
|
||||
<IconifyIcon icon="ant-design:reload-outlined" class="mr-1" />
|
||||
{{ $t('common.reset') }}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div class="mt-3">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('ui.actionTitle.create', [$t('iot.subsystem.title')]),
|
||||
type: 'primary',
|
||||
icon: ACTION_ICON.ADD,
|
||||
auth: ['iot:subsystem:create'],
|
||||
onClick: handleCreate,
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
<!-- 列表 -->
|
||||
<Grid :table-title="$t('iot.subsystem.listTitle')">
|
||||
<!-- 状态列 -->
|
||||
<template #status="{ row }">
|
||||
<Tag :color="row.status === 1 ? 'green' : 'default'">
|
||||
{{
|
||||
row.status === 1
|
||||
? $t('iot.subsystem.statusEnabled')
|
||||
: $t('iot.subsystem.statusDisabled')
|
||||
}}
|
||||
</Tag>
|
||||
</template>
|
||||
|
||||
<!-- 设备数列(Redis 聚合,A6) -->
|
||||
<template #deviceCount="{ row }">
|
||||
<span>
|
||||
{{ deviceCountMap[String(row.id)]?.total ?? '-' }}
|
||||
</span>
|
||||
</template>
|
||||
|
||||
<!-- 操作列 -->
|
||||
<template #actions="{ row }">
|
||||
<TableAction
|
||||
:actions="[
|
||||
{
|
||||
label: $t('iot.subsystem.viewDevices'),
|
||||
type: 'link',
|
||||
icon: 'lucide:server',
|
||||
auth: ['iot:subsystem:query'],
|
||||
onClick: handleViewDevices.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.edit'),
|
||||
type: 'link',
|
||||
icon: ACTION_ICON.EDIT,
|
||||
auth: ['iot:subsystem:update'],
|
||||
onClick: handleEdit.bind(null, row),
|
||||
},
|
||||
{
|
||||
label: $t('common.delete'),
|
||||
type: 'link',
|
||||
danger: true,
|
||||
icon: ACTION_ICON.DELETE,
|
||||
disabled: isDeleteDisabled(row),
|
||||
tooltip: isDeleteDisabled(row)
|
||||
? $t('iot.subsystem.deleteDisabledTip', [
|
||||
String(deviceCountMap[String(row.id)]?.total ?? 0),
|
||||
])
|
||||
: undefined,
|
||||
auth: ['iot:subsystem:delete'],
|
||||
popConfirm: {
|
||||
title: $t('ui.actionMessage.deleteConfirm', [row.name]),
|
||||
confirm: handleDelete.bind(null, row),
|
||||
},
|
||||
},
|
||||
]"
|
||||
/>
|
||||
</template>
|
||||
</Grid>
|
||||
</Page>
|
||||
</template>
|
||||
Reference in New Issue
Block a user