[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:
lzh
2026-04-23 22:39:47 +08:00
parent 8613641d1d
commit ba459aa1d7
15 changed files with 2443 additions and 0 deletions

View 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/bindSubsystemsubsystemId=null 表示解绑)
*/
export function unbindDeviceSubsystem(deviceId: number) {
return requestClient.put<boolean>('/iot/device/bindSubsystem', {
deviceId,
subsystemId: null,
} satisfies IotSubsystemApi.BindSubsystemReqVO);
}

View File

@@ -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"
}
}
}
}

View File

@@ -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": "暂无历史记录"
}
}
}
}

View File

@@ -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;

View File

@@ -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');
});
});

View 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);
}

View 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;
}
/** 告警历史条目 VOB13 */
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 });
}

View File

@@ -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>

View File

@@ -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>

View 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>

View 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>

View File

@@ -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);
});
});

View 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>

View 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>

View 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>