feat: 设备中心运维/分发/库存全量完善 + 菜单与公共组件细节调整
- 运维(ops): 告警/工单/初始化/版本/监测详情抽屉与弹框全量完善,新增 versionList 子组件与 versionSet 弹框,废弃 config 模块 - 分发(distribute): 删除死信模块,记录下沉为配置子页,新增上报模块 - 库存(stock): 入库详情抽屉新增,出库详情/编辑完善 - 第三方认证: 高德/虹眼新增编辑流程完善 - 公共: FilterBar/Sidebar/MenuTreeNode/nav 细节调整 - 截图: SIM 卡导出任务/列表页截图归档 Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
74a5419028
commit
68ea2a1167
@@ -86,7 +86,7 @@ const dictionaryList: AdminRecord[] = [
|
||||
]
|
||||
|
||||
const tenantList: AdminRecord[] = [
|
||||
{ id: 'tenant-001', name: '长庆能源总部食堂', code: 'cq-energy', expireTime: '2027-06-30', status: true, createTime: '2026-05-08 09:18:00' },
|
||||
{ id: 'tenant-001', name: '国庆能源总部食堂', code: 'cq-energy', expireTime: '2027-06-30', status: true, createTime: '2026-05-08 09:18:00' },
|
||||
{ id: 'tenant-002', name: '第一采油厂', code: 'oilfield-01', expireTime: '2027-05-31', status: true, createTime: '2026-05-09 10:22:00' },
|
||||
{ id: 'tenant-003', name: '炼化分公司', code: 'refining', expireTime: '2026-12-31', status: true, createTime: '2026-05-10 11:35:00' },
|
||||
{ id: 'tenant-004', name: '健康示范园区', code: 'health-park', expireTime: '2026-10-31', status: false, createTime: '2026-05-11 14:06:00' },
|
||||
|
||||
@@ -36,6 +36,8 @@ interface DeviceItem {
|
||||
siteId: string
|
||||
siteName: string
|
||||
deployStatus: DeployStatus
|
||||
/** 软件包名(初始化设置时写入,与 softwareVersion 同时产生) */
|
||||
packageName: string
|
||||
softwareVersion: string
|
||||
onlineTime: string
|
||||
deployTime: string
|
||||
@@ -126,7 +128,7 @@ const CATEGORY_META: Record<CategoryTab, CategoryMeta[]> = {
|
||||
|
||||
/** 客户下拉数据源(与 customer/archive mock 对齐,精简为 id+name) */
|
||||
const customerOptionList: CustomerOption[] = [
|
||||
{ id: 'cust-001', name: '长庆能源总部食堂' },
|
||||
{ id: 'cust-001', name: '国庆能源总部食堂' },
|
||||
{ id: 'cust-002', name: '第一采油厂' },
|
||||
{ id: 'cust-003', name: '炼化分公司' },
|
||||
{ id: 'cust-004', name: '健康示范园区' },
|
||||
@@ -136,7 +138,7 @@ const customerOptionList: CustomerOption[] = [
|
||||
|
||||
/** 场所下拉数据源(与 customer/site mock 对齐,精简为 id+name+customerId) */
|
||||
const siteOptionList: SiteOption[] = [
|
||||
{ id: 'site-001', name: '长庆总部一食堂', customerId: 'cust-001' },
|
||||
{ id: 'site-001', name: '国庆总部一食堂', customerId: 'cust-001' },
|
||||
{ id: 'site-002', name: '采油一厂食堂', customerId: 'cust-002' },
|
||||
{ id: 'site-003', name: '炼化员工餐厅', customerId: 'cust-003' },
|
||||
{ id: 'site-004', name: '健康园区体检站', customerId: 'cust-004' },
|
||||
@@ -177,7 +179,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-1', deviceCode: 'CATERING-PM-0001', name: '一号吐盘机', serialNumber: 'SN-PM-0001',
|
||||
deviceCategory: '吐盘机', category: 'catering',
|
||||
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.2.0',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V1.2.0',
|
||||
onlineTime: '2026-05-12 10:00:00', deployTime: '2026-05-20 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '商合', model: 'SH-TP-200',
|
||||
@@ -186,7 +188,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-2', deviceCode: 'CATERING-CB-0002', name: '智能货柜-B02', serialNumber: 'SN-CB-0002',
|
||||
deviceCategory: '智能货柜', category: 'catering',
|
||||
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-006', siteName: '东区食堂',
|
||||
deployStatus: 'deployed', softwareVersion: 'V2.0.1',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V2.0.1',
|
||||
onlineTime: '2026-05-13 11:00:00', deployTime: '2026-05-22 10:00:00',
|
||||
arcLicense: 'ARC-CAB-2026-0002', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '华为', model: 'HW-CAB-100L',
|
||||
@@ -195,7 +197,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-3', deviceCode: 'CATERING-NS-0003', name: '', serialNumber: 'SN-NS-0003',
|
||||
deviceCategory: '营养秤', category: 'catering',
|
||||
customerId: '', customerName: '', siteId: '', siteName: '',
|
||||
deployStatus: 'in_stock', softwareVersion: 'V1.0.5',
|
||||
deployStatus: 'in_stock', packageName: '智养餐饮', softwareVersion: 'V1.0.5',
|
||||
onlineTime: '2026-05-14 09:30:00', deployTime: '',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-NS-5KG',
|
||||
@@ -204,7 +206,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-4', deviceCode: 'CATERING-CO-0004', name: '结算台-04', serialNumber: 'SN-CO-0004',
|
||||
deviceCategory: '结算台', category: 'catering',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.3.0',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V1.3.0',
|
||||
onlineTime: '2026-05-15 14:00:00', deployTime: '2026-05-25 14:00:00',
|
||||
arcLicense: 'ARC-CK-2026-0004', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '华为', model: 'HW-CK-21IN',
|
||||
@@ -213,7 +215,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-5', deviceCode: 'CATERING-RS-0005', name: '配比秤-05', serialNumber: 'SN-RS-0005',
|
||||
deviceCategory: '配比秤', category: 'catering',
|
||||
customerId: 'cust-002', customerName: '第一采油厂', siteId: 'site-002', siteName: '采油一厂食堂',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.1.0',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V1.1.0',
|
||||
onlineTime: '2026-05-16 09:00:00', deployTime: '2026-05-24 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-RS-15KG',
|
||||
@@ -222,7 +224,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-6', deviceCode: 'CATERING-IS-0006', name: '入库秤-06', serialNumber: 'SN-IS-0006',
|
||||
deviceCategory: '入库秤', category: 'catering',
|
||||
customerId: 'cust-003', customerName: '炼化分公司', siteId: 'site-003', siteName: '炼化员工餐厅',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.0.8',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V1.0.8',
|
||||
onlineTime: '2026-05-17 10:30:00', deployTime: '2026-05-26 10:30:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-IS-30KG',
|
||||
@@ -230,8 +232,8 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
{
|
||||
id: 'dev-c-7', deviceCode: 'CATERING-BS-0007', name: '带鱼屏-A1', serialNumber: 'SN-BS-0007',
|
||||
deviceCategory: '带鱼屏', category: 'catering',
|
||||
customerId: 'cust-001', customerName: '长庆能源总部食堂', siteId: 'site-001', siteName: '长庆总部一食堂',
|
||||
deployStatus: 'deployed', softwareVersion: 'V2.3.0',
|
||||
customerId: 'cust-001', customerName: '国庆能源总部食堂', siteId: 'site-001', siteName: '国庆总部一食堂',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V2.3.0',
|
||||
onlineTime: '2026-05-18 14:30:00', deployTime: '2026-05-28 14:30:00',
|
||||
arcLicense: 'ARC-BS-2026-0007', simIccid: '',
|
||||
isIot: false, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '商合', model: 'SH-BS-25IN',
|
||||
@@ -240,7 +242,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-8', deviceCode: 'CATERING-BF-0008', name: '波菲炉-08', serialNumber: 'SN-BF-0008',
|
||||
deviceCategory: '波菲炉', category: 'catering',
|
||||
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.0.0',
|
||||
deployStatus: 'deployed', packageName: '智养餐饮', softwareVersion: 'V1.0.0',
|
||||
onlineTime: '2026-05-19 11:00:00', deployTime: '2026-05-29 11:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: false, dataTransit: false, dataReport: false, networkMode: 'ethernet', manufacturer: '商合', model: 'SH-BF-4GN',
|
||||
@@ -249,7 +251,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-9', deviceCode: 'CATERING-CB-0009', name: '', serialNumber: 'SN-CB-0009',
|
||||
deviceCategory: '智能货柜', category: 'catering',
|
||||
customerId: '', customerName: '', siteId: '', siteName: '',
|
||||
deployStatus: 'in_stock', softwareVersion: 'V2.0.1',
|
||||
deployStatus: 'in_stock', packageName: '', softwareVersion: '',
|
||||
onlineTime: '2026-06-02 09:00:00', deployTime: '',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '华为', model: 'HW-CAB-100L',
|
||||
@@ -258,7 +260,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-c-10', deviceCode: 'CATERING-PM-0010', name: '', serialNumber: 'SN-PM-0010',
|
||||
deviceCategory: '吐盘机', category: 'catering',
|
||||
customerId: '', customerName: '', siteId: '', siteName: '',
|
||||
deployStatus: 'in_stock', softwareVersion: 'V1.2.0',
|
||||
deployStatus: 'in_stock', packageName: '', softwareVersion: '',
|
||||
onlineTime: '2026-06-05 15:00:00', deployTime: '',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: false, dataReport: false, networkMode: 'wifi', manufacturer: '商合', model: 'SH-TP-200',
|
||||
@@ -269,7 +271,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-b-1', deviceCode: 'BODY-AIO-0001', name: '体检一体机-01', serialNumber: 'SN-AIO-0001',
|
||||
deviceCategory: '一体机', category: 'body',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
|
||||
deployStatus: 'deployed', softwareVersion: 'V3.1.0',
|
||||
deployStatus: 'deployed', packageName: '体重测量', softwareVersion: 'V3.1.0',
|
||||
onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-18 09:00:00',
|
||||
arcLicense: 'ARC-AIO-2026-0001', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '罗杰夫', model: 'LJF-AIO-V3',
|
||||
@@ -278,7 +280,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-b-2', deviceCode: 'BODY-BT-0002', name: '蓝牙秤-02', serialNumber: 'SN-BT-0002',
|
||||
deviceCategory: '蓝牙秤', category: 'body',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-008', siteName: '体检站2',
|
||||
deployStatus: 'deployed', softwareVersion: 'V2.4.0',
|
||||
deployStatus: 'deployed', packageName: '体重测量', softwareVersion: 'V2.4.0',
|
||||
onlineTime: '2026-05-11 10:00:00', deployTime: '2026-05-19 10:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-BT-180KG',
|
||||
@@ -287,7 +289,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-b-3', deviceCode: 'BODY-FAT-0003', name: '', serialNumber: 'SN-FAT-0003',
|
||||
deviceCategory: '体脂仪', category: 'body',
|
||||
customerId: '', customerName: '', siteId: '', siteName: '',
|
||||
deployStatus: 'in_stock', softwareVersion: 'V1.8.0',
|
||||
deployStatus: 'in_stock', packageName: '体重测量', softwareVersion: 'V1.8.0',
|
||||
onlineTime: '2026-05-12 11:00:00', deployTime: '',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'HW-FAT-BH',
|
||||
@@ -298,7 +300,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-w-1', deviceCode: 'WEAR-HW5-0001', name: '华为手表5-老人甲', serialNumber: 'SN-HW5-0001',
|
||||
deviceCategory: '华为-watch5', category: 'wearable',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
|
||||
deployStatus: 'deployed', softwareVersion: 'V5.2.0',
|
||||
deployStatus: 'deployed', packageName: '智能穿戴', softwareVersion: 'V5.2.0',
|
||||
onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'Watch-5',
|
||||
@@ -307,7 +309,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-w-2', deviceCode: 'WEAR-HW4-0002', name: '华为手表4-老人乙', serialNumber: 'SN-HW4-0002',
|
||||
deviceCategory: '华为-watch4', category: 'wearable',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-008', siteName: '体检站2',
|
||||
deployStatus: 'deployed', softwareVersion: 'V4.1.0',
|
||||
deployStatus: 'deployed', packageName: '智能穿戴', softwareVersion: 'V4.1.0',
|
||||
onlineTime: '2026-05-10 09:00:00', deployTime: '2026-05-16 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '华为', model: 'Watch-4',
|
||||
@@ -316,7 +318,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-w-3', deviceCode: 'WEAR-LJF-0003', name: '罗杰夫-丙', serialNumber: 'SN-LJF-0003',
|
||||
deviceCategory: '罗杰夫-LJF01', category: 'wearable',
|
||||
customerId: 'cust-005', customerName: '示范餐饮集团', siteId: 'site-005', siteName: '总部食堂',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.0.3',
|
||||
deployStatus: 'deployed', packageName: '智能穿戴', softwareVersion: 'V1.0.3',
|
||||
onlineTime: '2026-05-11 09:00:00', deployTime: '2026-05-17 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '罗杰夫', model: 'LJF-01',
|
||||
@@ -327,7 +329,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-f-1', deviceCode: 'FIT-TM-0001', name: '跑步机-01', serialNumber: 'SN-TM-0001',
|
||||
deviceCategory: '跑步机', category: 'fitness',
|
||||
customerId: 'cust-006', customerName: '健身中心B', siteId: 'site-009', siteName: '健身房1',
|
||||
deployStatus: 'deployed', softwareVersion: 'V2.0.0',
|
||||
deployStatus: 'deployed', packageName: '健身管理', softwareVersion: 'V2.0.0',
|
||||
onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-TM-Pro',
|
||||
@@ -336,7 +338,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-f-2', deviceCode: 'FIT-EL-0002', name: '椭圆机-02', serialNumber: 'SN-EL-0002',
|
||||
deviceCategory: '椭圆机', category: 'fitness',
|
||||
customerId: 'cust-006', customerName: '健身中心B', siteId: 'site-009', siteName: '健身房1',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.5.0',
|
||||
deployStatus: 'deployed', packageName: '健身管理', softwareVersion: 'V1.5.0',
|
||||
onlineTime: '2026-05-09 09:00:00', deployTime: '2026-05-15 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-EL-X1',
|
||||
@@ -345,7 +347,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-f-3', deviceCode: 'FIT-BK-0003', name: '', serialNumber: 'SN-BK-0003',
|
||||
deviceCategory: '健身车', category: 'fitness',
|
||||
customerId: '', customerName: '', siteId: '', siteName: '',
|
||||
deployStatus: 'in_stock', softwareVersion: 'V1.2.0',
|
||||
deployStatus: 'in_stock', packageName: '健身管理', softwareVersion: 'V1.2.0',
|
||||
onlineTime: '2026-05-10 09:00:00', deployTime: '',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '毅雄', model: 'YX-BK-M3',
|
||||
@@ -356,7 +358,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-m-1', deviceCode: 'MON-SG-0001', name: '血糖仪-01', serialNumber: 'SN-SG-0001',
|
||||
deviceCategory: '血糖监测仪', category: 'monitor',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-007', siteName: '体检站1',
|
||||
deployStatus: 'deployed', softwareVersion: 'V1.4.0',
|
||||
deployStatus: 'deployed', packageName: '健康监测', softwareVersion: 'V1.4.0',
|
||||
onlineTime: '2026-05-07 09:00:00', deployTime: '2026-05-13 09:00:00',
|
||||
arcLicense: '', simIccid: '8986011224567890',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'sim', manufacturer: '鱼跃', model: 'YY-BG-530',
|
||||
@@ -365,7 +367,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-m-2', deviceCode: 'MON-BP-0002', name: '高血压治疗仪-02', serialNumber: 'SN-BP-0002',
|
||||
deviceCategory: '高血压治疗仪', category: 'monitor',
|
||||
customerId: 'cust-004', customerName: '健康示范园区', siteId: 'site-008', siteName: '体检站2',
|
||||
deployStatus: 'deployed', softwareVersion: 'V2.1.0',
|
||||
deployStatus: 'deployed', packageName: '健康监测', softwareVersion: 'V2.1.0',
|
||||
onlineTime: '2026-05-08 09:00:00', deployTime: '2026-05-14 09:00:00',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'bluetooth', manufacturer: '鱼跃', model: 'YY-BP-680',
|
||||
@@ -374,7 +376,7 @@ const dataset: Record<CategoryTab, DeviceItem[]> = {
|
||||
id: 'dev-m-3', deviceCode: 'MON-AIO-0003', name: '健康一体机-03', serialNumber: 'SN-AIO-0003',
|
||||
deviceCategory: '智能健康一体机', category: 'monitor',
|
||||
customerId: '', customerName: '', siteId: '', siteName: '',
|
||||
deployStatus: 'in_stock', softwareVersion: 'V3.0.0',
|
||||
deployStatus: 'in_stock', packageName: '健康监测', softwareVersion: 'V3.0.0',
|
||||
onlineTime: '2026-05-09 09:00:00', deployTime: '',
|
||||
arcLicense: '', simIccid: '',
|
||||
isIot: true, dataTransit: true, dataReport: true, networkMode: 'wifi', manufacturer: '罗杰夫', model: 'LJF-HM-Z1',
|
||||
@@ -446,6 +448,9 @@ const handleAdd = (ctx: MockContext): ApiResponse<null> => {
|
||||
const categoryTab = resolveCategoryTab(payload.deviceCategory)
|
||||
const base: DeviceItem = {
|
||||
...payload,
|
||||
/** 新增设备未经初始化设置,packageName / softwareVersion 由后续初始化写入 */
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
customerName,
|
||||
siteName,
|
||||
deployStatus,
|
||||
@@ -587,6 +592,7 @@ export const pushDevicesFromInbound = (record: InboundRecordInput): number => {
|
||||
siteId: '',
|
||||
siteName: '',
|
||||
deployStatus: 'in_stock',
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
onlineTime: now,
|
||||
deployTime: '',
|
||||
|
||||
@@ -12,9 +12,15 @@ interface PageParams {
|
||||
categoryId?: string
|
||||
warehouseAreaId?: string
|
||||
customerId?: string
|
||||
deviceCode?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface OutboundDetailItem {
|
||||
deviceCode: string
|
||||
serialNumber: string
|
||||
}
|
||||
|
||||
interface OutboundRecord {
|
||||
id: string
|
||||
outboundCode: string
|
||||
@@ -34,6 +40,8 @@ interface OutboundRecord {
|
||||
warehouseAreaId?: string
|
||||
warehouseAreaName?: string
|
||||
remark?: string
|
||||
/** 设备明细(每行一台出库设备) */
|
||||
items?: OutboundDetailItem[]
|
||||
}
|
||||
|
||||
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
|
||||
@@ -91,7 +99,7 @@ const WAREHOUSE_AREA_NAME: Record<string, string> = {
|
||||
|
||||
/** 客户 ID → 名称映射(与 manage/statistics mock 同源) */
|
||||
const CUSTOMER_NAME: Record<string, string> = {
|
||||
'cust-001': '长庆能源总部食堂',
|
||||
'cust-001': '国庆能源总部食堂',
|
||||
'cust-002': '第一采油厂',
|
||||
'cust-003': '炼化分公司',
|
||||
'cust-004': '健康示范园区',
|
||||
@@ -105,14 +113,38 @@ const resolveWarehouseAreaName = (id?: string): string => (id ? WAREHOUSE_AREA_N
|
||||
const resolveCustomerName = (id?: string): string => (id ? CUSTOMER_NAME[id] ?? '' : '')
|
||||
|
||||
const outboundList: OutboundRecord[] = [
|
||||
{ id: 'oub-001', outboundCode: 'OUB-20260603-001', categoryId: 'cat-catering-cabinet', categoryName: '智能货柜', quantity: 2, recipient: '王师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-03 10:00:00', signedTime: '2026-06-03 14:30:00', status: 'signed', warehouseAreaId: 'wh-floor1-large-a-2', warehouseAreaName: '1楼大型A号货架-2号储位' },
|
||||
{ id: 'oub-002', outboundCode: 'OUB-20260608-002', categoryId: 'cat-wearable-hw5', categoryName: '华为-watch5', quantity: 5, recipient: '李主任', targetSite: '健康站01', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '李四', outboundTime: '2026-06-08 15:30:00', signedTime: '2026-06-09 09:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位' },
|
||||
{ id: 'oub-003', outboundCode: 'OUB-20260612-003', categoryId: 'cat-monitor-all-in-one', categoryName: '智能健康一体机', quantity: 1, recipient: '赵工', targetSite: '东区体检站', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '王五', outboundTime: '2026-06-12 09:15:00', status: 'completed', warehouseAreaId: 'wh-floor1-large-b-1', warehouseAreaName: '1楼大型B号货架-1号储位' },
|
||||
{ id: 'oub-004', outboundCode: 'OUB-20260616-004', categoryId: 'cat-catering-inbound-scale', categoryName: '入库秤', quantity: 1, recipient: '孙师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '赵六', outboundTime: '2026-06-16 14:00:00', signedTime: '2026-06-16 18:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
|
||||
{ id: 'oub-005', outboundCode: 'OUB-20260620-005', categoryId: 'cat-fitness-bike', categoryName: '健身车', quantity: 2, recipient: '周教练', targetSite: '总部健身房', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'transfer', operator: '孙七', outboundTime: '2026-06-20 11:45:00', cancelledTime: '2026-06-20 16:00:00', status: 'cancelled', remark: '客户取消订单', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
|
||||
{ id: 'oub-006', outboundCode: 'OUB-20260623-006', categoryId: 'cat-catering-band-screen', categoryName: '带鱼屏', quantity: 1, recipient: '陈师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-23 16:20:00', status: 'pending', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位' },
|
||||
{ id: 'oub-007', outboundCode: 'OUB-20260626-007', categoryId: 'cat-body-bt-scale', categoryName: '蓝牙秤', quantity: 3, recipient: '吴主任', targetSite: '健康站01', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '李四', outboundTime: '2026-06-26 10:30:00', signedTime: '2026-06-27 09:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位' },
|
||||
{ id: 'oub-008', outboundCode: 'OUB-20260628-008', categoryId: 'cat-body-fat', categoryName: '体脂仪', quantity: 1, recipient: '郑医生', targetSite: '东区体检站', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '王五', outboundTime: '2026-06-28 13:10:00', status: 'completed', warehouseAreaId: 'wh-floor2-check-1', warehouseAreaName: '2楼待检区-临时储位' },
|
||||
{ id: 'oub-001', outboundCode: 'OUB-20260603-001', categoryId: 'cat-catering-cabinet', categoryName: '智能货柜', quantity: 2, recipient: '王师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-03 10:00:00', signedTime: '2026-06-03 14:30:00', status: 'signed', warehouseAreaId: 'wh-floor1-large-a-2', warehouseAreaName: '1楼大型A号货架-2号储位', items: [
|
||||
{ deviceCode: 'CAB-2026-001', serialNumber: 'SN-CAB-001' },
|
||||
{ deviceCode: 'CAB-2026-002', serialNumber: 'SN-CAB-002' },
|
||||
] },
|
||||
{ id: 'oub-002', outboundCode: 'OUB-20260608-002', categoryId: 'cat-wearable-hw5', categoryName: '华为-watch5', quantity: 5, recipient: '李主任', targetSite: '健康站01', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '李四', outboundTime: '2026-06-08 15:30:00', signedTime: '2026-06-09 09:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-small-shelf-1', warehouseAreaName: '1楼小型备品货架-1号储位', items: [
|
||||
{ deviceCode: 'HW5-2026-001', serialNumber: 'SN-HW5-001' },
|
||||
{ deviceCode: 'HW5-2026-002', serialNumber: 'SN-HW5-002' },
|
||||
{ deviceCode: 'HW5-2026-003', serialNumber: 'SN-HW5-003' },
|
||||
{ deviceCode: 'HW5-2026-004', serialNumber: 'SN-HW5-004' },
|
||||
{ deviceCode: 'HW5-2026-005', serialNumber: 'SN-HW5-005' },
|
||||
] },
|
||||
{ id: 'oub-003', outboundCode: 'OUB-20260612-003', categoryId: 'cat-monitor-all-in-one', categoryName: '智能健康一体机', quantity: 1, recipient: '赵工', targetSite: '东区体检站', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '王五', outboundTime: '2026-06-12 09:15:00', status: 'completed', warehouseAreaId: 'wh-floor1-large-b-1', warehouseAreaName: '1楼大型B号货架-1号储位', items: [
|
||||
{ deviceCode: 'AIO-2026-001', serialNumber: 'SN-AIO-001' },
|
||||
] },
|
||||
{ id: 'oub-004', outboundCode: 'OUB-20260616-004', categoryId: 'cat-catering-inbound-scale', categoryName: '入库秤', quantity: 1, recipient: '孙师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '赵六', outboundTime: '2026-06-16 14:00:00', signedTime: '2026-06-16 18:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位', items: [
|
||||
{ deviceCode: 'IBS-2026-001', serialNumber: 'SN-IBS-001' },
|
||||
] },
|
||||
{ id: 'oub-005', outboundCode: 'OUB-20260620-005', categoryId: 'cat-fitness-bike', categoryName: '健身车', quantity: 2, recipient: '周教练', targetSite: '总部健身房', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'transfer', operator: '孙七', outboundTime: '2026-06-20 11:45:00', cancelledTime: '2026-06-20 16:00:00', status: 'cancelled', remark: '客户取消订单', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位', items: [
|
||||
{ deviceCode: 'BIKE-2026-001', serialNumber: 'SN-BIKE-001' },
|
||||
{ deviceCode: 'BIKE-2026-002', serialNumber: 'SN-BIKE-002' },
|
||||
] },
|
||||
{ id: 'oub-006', outboundCode: 'OUB-20260623-006', categoryId: 'cat-catering-band-screen', categoryName: '带鱼屏', quantity: 1, recipient: '陈师傅', targetSite: '总部食堂', customerId: 'cust-005', customerName: '示范餐饮集团', outboundType: 'install', operator: '张三', outboundTime: '2026-06-23 16:20:00', status: 'pending', warehouseAreaId: 'wh-floor1-large-a-1', warehouseAreaName: '1楼大型A号货架-1号储位', items: [
|
||||
{ deviceCode: 'BFS-2026-001', serialNumber: 'SN-BFS-001' },
|
||||
] },
|
||||
{ id: 'oub-007', outboundCode: 'OUB-20260626-007', categoryId: 'cat-body-bt-scale', categoryName: '蓝牙秤', quantity: 3, recipient: '吴主任', targetSite: '健康站01', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '李四', outboundTime: '2026-06-26 10:30:00', signedTime: '2026-06-27 09:00:00', status: 'signed', warehouseAreaId: 'wh-floor1-small-shelf-2', warehouseAreaName: '1楼小型备品货架-2号储位', items: [
|
||||
{ deviceCode: 'BTS-2026-001', serialNumber: 'SN-BTS-001' },
|
||||
{ deviceCode: 'BTS-2026-002', serialNumber: 'SN-BTS-002' },
|
||||
{ deviceCode: 'BTS-2026-003', serialNumber: 'SN-BTS-003' },
|
||||
] },
|
||||
{ id: 'oub-008', outboundCode: 'OUB-20260628-008', categoryId: 'cat-body-fat', categoryName: '体脂仪', quantity: 1, recipient: '郑医生', targetSite: '东区体检站', customerId: 'cust-007', customerName: '健康机构A', outboundType: 'install', operator: '王五', outboundTime: '2026-06-28 13:10:00', status: 'completed', warehouseAreaId: 'wh-floor2-check-1', warehouseAreaName: '2楼待检区-临时储位', items: [
|
||||
{ deviceCode: 'BFT-2026-001', serialNumber: 'SN-BFT-001' },
|
||||
] },
|
||||
]
|
||||
|
||||
const listPage = (ctx: MockContext): ApiResponse<OutboundRecord[]> => {
|
||||
@@ -124,6 +156,11 @@ const listPage = (ctx: MockContext): ApiResponse<OutboundRecord[]> => {
|
||||
if (params.warehouseAreaId && item.warehouseAreaId !== params.warehouseAreaId) return false
|
||||
if (params.customerId && item.customerId !== params.customerId) return false
|
||||
if (params.keyword && !includes(item.outboundCode, params.keyword)) return false
|
||||
// 设备编码:按出库明细 items[].deviceCode 模糊匹配,空明细跳过
|
||||
if (params.deviceCode) {
|
||||
const matched = (item.items ?? []).some((entry) => includes(entry.deviceCode, params.deviceCode))
|
||||
if (!matched) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
if (params.column === 'outboundTime' && params.order) {
|
||||
@@ -155,16 +192,32 @@ const genOutboundCode = (): string => {
|
||||
return `OUB-${ymd}-${seq}`
|
||||
}
|
||||
|
||||
/** 解析 items 数组参数 */
|
||||
const parseItems = (raw: unknown): OutboundDetailItem[] => {
|
||||
if (!Array.isArray(raw)) return []
|
||||
return raw
|
||||
.map((entry) => {
|
||||
if (!entry || typeof entry !== 'object') return null
|
||||
const obj = entry as { deviceCode?: unknown; serialNumber?: unknown }
|
||||
return {
|
||||
deviceCode: String(obj.deviceCode ?? '').trim(),
|
||||
serialNumber: String(obj.serialNumber ?? '').trim(),
|
||||
}
|
||||
})
|
||||
.filter((item): item is OutboundDetailItem => item !== null && item.deviceCode.length > 0)
|
||||
}
|
||||
|
||||
const addRecord = (params: PageParams): ApiResponse<null> => {
|
||||
const categoryId = String(params.categoryId ?? '')
|
||||
const customerId = String(params.customerId ?? '')
|
||||
const warehouseAreaId = params.warehouseAreaId ? String(params.warehouseAreaId) : undefined
|
||||
const items = parseItems(params.items)
|
||||
const record: OutboundRecord = {
|
||||
id: `oub-${Date.now().toString(36)}`,
|
||||
outboundCode: String(params.outboundCode ?? genOutboundCode()),
|
||||
categoryId,
|
||||
categoryName: resolveCategoryName(categoryId),
|
||||
quantity: Number(params.quantity ?? 1),
|
||||
quantity: items.length || Number(params.quantity ?? 1),
|
||||
recipient: String(params.recipient ?? ''),
|
||||
targetSite: String(params.targetSite ?? ''),
|
||||
customerId,
|
||||
@@ -176,6 +229,7 @@ const addRecord = (params: PageParams): ApiResponse<null> => {
|
||||
warehouseAreaId,
|
||||
warehouseAreaName: resolveWarehouseAreaName(warehouseAreaId),
|
||||
remark: params.remark ? String(params.remark) : undefined,
|
||||
items,
|
||||
}
|
||||
outboundList.unshift(record)
|
||||
return ok('新增成功')
|
||||
@@ -189,7 +243,6 @@ const updateRecord = (params: PageParams): ApiResponse<null> => {
|
||||
record.categoryId = String(params.categoryId)
|
||||
record.categoryName = resolveCategoryName(record.categoryId)
|
||||
}
|
||||
if (params.quantity !== undefined) record.quantity = Number(params.quantity)
|
||||
if (params.recipient !== undefined) record.recipient = String(params.recipient)
|
||||
if (params.targetSite !== undefined) record.targetSite = String(params.targetSite)
|
||||
if (params.customerId !== undefined) {
|
||||
@@ -202,6 +255,10 @@ const updateRecord = (params: PageParams): ApiResponse<null> => {
|
||||
record.warehouseAreaName = resolveWarehouseAreaName(record.warehouseAreaId)
|
||||
}
|
||||
if (params.remark !== undefined) record.remark = params.remark ? String(params.remark) : undefined
|
||||
if (params.items !== undefined) {
|
||||
record.items = parseItems(params.items)
|
||||
record.quantity = record.items.length || record.quantity
|
||||
}
|
||||
return ok('更新成功')
|
||||
}
|
||||
|
||||
|
||||
@@ -101,7 +101,7 @@ const CATEGORY_LEAF_NAME: Record<string, string> = {
|
||||
|
||||
/** 客户下拉数据源(与 manage mock 同源,保持 id+name 一致) */
|
||||
const customerOptionList: CustomerOption[] = [
|
||||
{ id: 'cust-001', name: '长庆能源总部食堂' },
|
||||
{ id: 'cust-001', name: '国庆能源总部食堂' },
|
||||
{ id: 'cust-002', name: '第一采油厂' },
|
||||
{ id: 'cust-003', name: '炼化分公司' },
|
||||
{ id: 'cust-004', name: '健康示范园区' },
|
||||
@@ -112,7 +112,7 @@ const customerOptionList: CustomerOption[] = [
|
||||
|
||||
/** 场所下拉数据源(与 manage mock 同源,按 customerId 联动) */
|
||||
const siteOptionList: SiteOption[] = [
|
||||
{ id: 'site-001', name: '长庆总部一食堂', customerId: 'cust-001' },
|
||||
{ id: 'site-001', name: '国庆总部一食堂', customerId: 'cust-001' },
|
||||
{ id: 'site-002', name: '采油一厂食堂', customerId: 'cust-002' },
|
||||
{ id: 'site-003', name: '炼化员工餐厅', customerId: 'cust-003' },
|
||||
{ id: 'site-004', name: '健康园区体检站', customerId: 'cust-004' },
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { registerMocks } from '@axios/mockBus'
|
||||
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 设备运维 / 工单管理 mock
|
||||
* - 支持分页查询、详情(含处理记录时间线)、新增、派单、处理、删除
|
||||
* - 状态流转:pending → assigned → processing → closed
|
||||
*/
|
||||
|
||||
interface PageParams {
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
@@ -15,6 +21,14 @@ interface PageParams {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
interface ProcessLog {
|
||||
id: string
|
||||
action: 'create' | 'dispatch' | 'start' | 'progress' | 'close'
|
||||
operator: string
|
||||
operateTime: string
|
||||
remark: string
|
||||
}
|
||||
|
||||
interface WorkOrderRecord {
|
||||
id: string
|
||||
orderCode: string
|
||||
@@ -24,20 +38,26 @@ interface WorkOrderRecord {
|
||||
customerName: string
|
||||
source: 'customer' | 'inspection' | 'self-check' | 'after-sale'
|
||||
faultLevel: 'low' | 'medium' | 'high' | 'critical'
|
||||
faultType: 'hardware' | 'software' | 'network' | 'mechanical' | 'electrical' | 'other'
|
||||
faultDesc: string
|
||||
images: string[]
|
||||
status: 'pending' | 'assigned' | 'processing' | 'closed'
|
||||
assignee?: string
|
||||
reportTime: string
|
||||
slaDeadline: string
|
||||
slaOverdue: boolean
|
||||
processLogs: ProcessLog[]
|
||||
}
|
||||
|
||||
const getParams = (params: RequestParameter): PageParams => (params ?? {}) as PageParams
|
||||
|
||||
const includes = (value: unknown, keyword: unknown): boolean => {
|
||||
if (!keyword) return true
|
||||
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
|
||||
}
|
||||
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
|
||||
const paginate = <T extends WorkOrderRecord>(list: T[], params: PageParams): ApiResponse<T[]> => {
|
||||
const pageNum = Number(params.pageNum ?? 1)
|
||||
const pageSize = Number(params.pageSize ?? 30)
|
||||
@@ -45,15 +65,117 @@ const paginate = <T extends WorkOrderRecord>(list: T[], params: PageParams): Api
|
||||
return { code: '00000', msg: '查询成功', data: list.slice(start, start + pageSize), total: list.length }
|
||||
}
|
||||
|
||||
const createProcessLogs = (recordId: string): Record<string, ProcessLog[]> => ({
|
||||
'wo-001': [
|
||||
{ id: 'pl-001-1', action: 'create', operator: '张客户', operateTime: '2026-06-20 09:15:00', remark: '客户报障:柜门无法开启,疑似锁具故障' },
|
||||
{ id: 'pl-001-2', action: 'dispatch', operator: '系统', operateTime: '2026-06-20 09:25:00', remark: '派单给 李工程师' },
|
||||
{ id: 'pl-001-3', action: 'start', operator: '李工程师', operateTime: '2026-06-20 10:30:00', remark: '已到达现场,开始排查' },
|
||||
{ id: 'pl-001-4', action: 'progress', operator: '李工程师', operateTime: '2026-06-20 14:20:00', remark: '更换锁具主板,正在测试' },
|
||||
{ id: 'pl-001-5', action: 'close', operator: '李工程师', operateTime: '2026-06-20 16:45:00', remark: '维修完成,柜门开关正常' },
|
||||
],
|
||||
'wo-002': [
|
||||
{ id: 'pl-002-1', action: 'create', operator: '系统', operateTime: '2026-06-22 14:30:00', remark: '巡检异常:心率采集异常,间歇性无数据' },
|
||||
{ id: 'pl-002-2', action: 'dispatch', operator: '系统', operateTime: '2026-06-22 15:00:00', remark: '派单给 王工程师' },
|
||||
{ id: 'pl-002-3', action: 'start', operator: '王工程师', operateTime: '2026-06-22 16:10:00', remark: '开始排查传感器' },
|
||||
{ id: 'pl-002-4', action: 'progress', operator: '王工程师', operateTime: '2026-06-23 09:20:00', remark: '已更换心率传感器,正在测试中' },
|
||||
],
|
||||
'wo-003': [
|
||||
{ id: 'pl-003-1', action: 'create', operator: '李经理', operateTime: '2026-06-24 10:00:00', remark: '设备完全无响应,无法开机' },
|
||||
],
|
||||
'wo-004': [
|
||||
{ id: 'pl-004-1', action: 'create', operator: '系统', operateTime: '2026-06-25 16:20:00', remark: '自检异常:称重数据偏差,校准无效' },
|
||||
{ id: 'pl-004-2', action: 'dispatch', operator: '系统', operateTime: '2026-06-25 16:30:00', remark: '派单给 赵工程师' },
|
||||
],
|
||||
'wo-005': [
|
||||
{ id: 'pl-005-1', action: 'create', operator: '客服', operateTime: '2026-06-26 11:45:00', remark: '售后转单:用户反馈界面字体显示异常' },
|
||||
{ id: 'pl-005-2', action: 'dispatch', operator: '系统', operateTime: '2026-06-26 12:00:00', remark: '派单给 孙工程师' },
|
||||
{ id: 'pl-005-3', action: 'start', operator: '孙工程师', operateTime: '2026-06-26 13:30:00', remark: '远程连接,开始排查' },
|
||||
{ id: 'pl-005-4', action: 'progress', operator: '孙工程师', operateTime: '2026-06-26 15:00:00', remark: '更新固件到 V2.1.0' },
|
||||
{ id: 'pl-005-5', action: 'close', operator: '孙工程师', operateTime: '2026-06-27 09:00:00', remark: '字体显示恢复正常,客户确认' },
|
||||
],
|
||||
'wo-006': [
|
||||
{ id: 'pl-006-1', action: 'create', operator: '张主管', operateTime: '2026-06-27 09:00:00', remark: '客户报障:阻力调节失灵,无法增减阻力' },
|
||||
{ id: 'pl-006-2', action: 'dispatch', operator: '系统', operateTime: '2026-06-27 09:15:00', remark: '派单给 李工程师' },
|
||||
{ id: 'pl-006-3', action: 'start', operator: '李工程师', operateTime: '2026-06-27 10:00:00', remark: '到达现场,开始排查' },
|
||||
{ id: 'pl-006-4', action: 'progress', operator: '李工程师', operateTime: '2026-06-27 14:00:00', remark: '阻力电机已更换,测试通过' },
|
||||
],
|
||||
'wo-007': [
|
||||
{ id: 'pl-007-1', action: 'create', operator: '巡检系统', operateTime: '2026-06-28 15:30:00', remark: '巡检异常:屏幕局部亮点,不影响显示' },
|
||||
],
|
||||
'wo-008': [
|
||||
{ id: 'pl-008-1', action: 'create', operator: '王客户', operateTime: '2026-06-29 13:00:00', remark: '客户报障:蓝牙连接频繁断开' },
|
||||
{ id: 'pl-008-2', action: 'dispatch', operator: '系统', operateTime: '2026-06-29 13:15:00', remark: '派单给 王工程师' },
|
||||
{ id: 'pl-008-3', action: 'start', operator: '王工程师', operateTime: '2026-06-29 14:00:00', remark: '开始排查蓝牙模块' },
|
||||
{ id: 'pl-008-4', action: 'progress', operator: '王工程师', operateTime: '2026-06-29 16:30:00', remark: '更换蓝牙天线,信号强度正常' },
|
||||
{ id: 'pl-008-5', action: 'close', operator: '王工程师', operateTime: '2026-06-30 09:00:00', remark: '蓝牙连接稳定,已交付客户' },
|
||||
],
|
||||
})
|
||||
|
||||
const workOrderList: WorkOrderRecord[] = [
|
||||
{ id: 'wo-001', orderCode: 'WO-20260620-001', deviceCode: 'CAB-A1-0001', deviceName: '总部食堂货柜', siteName: '总部食堂', customerName: '智养餐饮集团', source: 'customer', faultLevel: 'high', faultDesc: '柜门无法开启,疑似锁具故障', status: 'closed', assignee: '李工程师', reportTime: '2026-06-20 09:15:00', slaDeadline: '2026-06-20 18:00:00', slaOverdue: false },
|
||||
{ id: 'wo-002', orderCode: 'WO-20260622-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', siteName: '健康站01', customerName: '健康管理机构', source: 'inspection', faultLevel: 'medium', faultDesc: '心率采集异常,间歇性无数据', status: 'processing', assignee: '王工程师', reportTime: '2026-06-22 14:30:00', slaDeadline: '2026-06-23 18:00:00', slaOverdue: false },
|
||||
{ id: 'wo-003', orderCode: 'WO-20260624-003', deviceCode: 'CHK-M1-0001', deviceName: '体检站一体机', siteName: '东区体检站', customerName: '健康管理机构', source: 'customer', faultLevel: 'critical', faultDesc: '设备完全无响应,无法开机', status: 'pending', reportTime: '2026-06-24 10:00:00', slaDeadline: '2026-06-24 22:00:00', slaOverdue: true },
|
||||
{ id: 'wo-004', orderCode: 'WO-20260625-004', deviceCode: 'SCL-X1-0001', deviceName: '总部入库秤', siteName: '总部食堂', customerName: '智养餐饮集团', source: 'self-check', faultLevel: 'medium', faultDesc: '称重数据偏差,校准无效', status: 'assigned', assignee: '赵工程师', reportTime: '2026-06-25 16:20:00', slaDeadline: '2026-06-26 18:00:00', slaOverdue: false },
|
||||
{ id: 'wo-005', orderCode: 'WO-20260626-005', deviceCode: 'FAT-INBODY-0001', deviceName: '体检站体脂秤', siteName: '东区体检站', customerName: '健康管理机构', source: 'after-sale', faultLevel: 'low', faultDesc: '用户反馈界面字体显示异常', status: 'closed', assignee: '孙工程师', reportTime: '2026-06-26 11:45:00', slaDeadline: '2026-06-28 18:00:00', slaOverdue: false },
|
||||
{ id: 'wo-006', orderCode: 'WO-20260627-006', deviceCode: 'FIT-S1-0001', deviceName: '健身房顿米车', siteName: '总部健身房', customerName: '智养餐饮集团', source: 'customer', faultLevel: 'high', faultDesc: '阻力调节失灵,无法增减阻力', status: 'processing', assignee: '李工程师', reportTime: '2026-06-27 09:00:00', slaDeadline: '2026-06-27 22:00:00', slaOverdue: true },
|
||||
{ id: 'wo-007', orderCode: 'WO-20260628-007', deviceCode: 'SCR-P1-0001', deviceName: '总部大屏', siteName: '总部食堂', customerName: '智养餐饮集团', source: 'inspection', faultLevel: 'low', faultDesc: '屏幕局部亮点,不影响显示', status: 'pending', reportTime: '2026-06-28 15:30:00', slaDeadline: '2026-07-01 18:00:00', slaOverdue: false },
|
||||
{ id: 'wo-008', orderCode: 'WO-20260629-008', deviceCode: 'SCL-W-0001', deviceName: '健康站体重秤', siteName: '健康站01', customerName: '健康管理机构', source: 'customer', faultLevel: 'medium', faultDesc: '蓝牙连接频繁断开', status: 'closed', assignee: '王工程师', reportTime: '2026-06-29 13:00:00', slaDeadline: '2026-06-30 18:00:00', slaOverdue: false },
|
||||
{
|
||||
id: 'wo-001', orderCode: 'WO-20260620-001', deviceCode: 'CAB-A1-0001', deviceName: '总部食堂货柜',
|
||||
siteName: '总部食堂', customerName: '智养餐饮集团', source: 'customer', faultLevel: 'high',
|
||||
faultType: 'mechanical', faultDesc: '柜门无法开启,疑似锁具故障', images: [],
|
||||
status: 'closed', assignee: '李工程师', reportTime: '2026-06-20 09:15:00',
|
||||
slaDeadline: '2026-06-20 18:00:00', slaOverdue: false,
|
||||
processLogs: createProcessLogs('wo-001')['wo-001'],
|
||||
},
|
||||
{
|
||||
id: 'wo-002', orderCode: 'WO-20260622-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表',
|
||||
siteName: '健康站01', customerName: '健康管理机构', source: 'inspection', faultLevel: 'medium',
|
||||
faultType: 'hardware', faultDesc: '心率采集异常,间歇性无数据', images: [],
|
||||
status: 'processing', assignee: '王工程师', reportTime: '2026-06-22 14:30:00',
|
||||
slaDeadline: '2026-06-23 18:00:00', slaOverdue: false,
|
||||
processLogs: createProcessLogs('wo-002')['wo-002'],
|
||||
},
|
||||
{
|
||||
id: 'wo-003', orderCode: 'WO-20260624-003', deviceCode: 'CHK-M1-0001', deviceName: '体检站一体机',
|
||||
siteName: '东区体检站', customerName: '健康管理机构', source: 'customer', faultLevel: 'critical',
|
||||
faultType: 'electrical', faultDesc: '设备完全无响应,无法开机', images: [],
|
||||
status: 'pending', reportTime: '2026-06-24 10:00:00',
|
||||
slaDeadline: '2026-06-24 22:00:00', slaOverdue: true,
|
||||
processLogs: createProcessLogs('wo-003')['wo-003'],
|
||||
},
|
||||
{
|
||||
id: 'wo-004', orderCode: 'WO-20260625-004', deviceCode: 'SCL-X1-0001', deviceName: '总部入库秤',
|
||||
siteName: '总部食堂', customerName: '智养餐饮集团', source: 'self-check', faultLevel: 'medium',
|
||||
faultType: 'hardware', faultDesc: '称重数据偏差,校准无效', images: [],
|
||||
status: 'assigned', assignee: '赵工程师', reportTime: '2026-06-25 16:20:00',
|
||||
slaDeadline: '2026-06-26 18:00:00', slaOverdue: false,
|
||||
processLogs: createProcessLogs('wo-004')['wo-004'],
|
||||
},
|
||||
{
|
||||
id: 'wo-005', orderCode: 'WO-20260626-005', deviceCode: 'FAT-INBODY-0001', deviceName: '体检站体脂秤',
|
||||
siteName: '东区体检站', customerName: '健康管理机构', source: 'after-sale', faultLevel: 'low',
|
||||
faultType: 'software', faultDesc: '用户反馈界面字体显示异常', images: [],
|
||||
status: 'closed', assignee: '孙工程师', reportTime: '2026-06-26 11:45:00',
|
||||
slaDeadline: '2026-06-28 18:00:00', slaOverdue: false,
|
||||
processLogs: createProcessLogs('wo-005')['wo-005'],
|
||||
},
|
||||
{
|
||||
id: 'wo-006', orderCode: 'WO-20260627-006', deviceCode: 'FIT-S1-0001', deviceName: '健身房顿米车',
|
||||
siteName: '总部健身房', customerName: '智养餐饮集团', source: 'customer', faultLevel: 'high',
|
||||
faultType: 'mechanical', faultDesc: '阻力调节失灵,无法增减阻力', images: [],
|
||||
status: 'processing', assignee: '李工程师', reportTime: '2026-06-27 09:00:00',
|
||||
slaDeadline: '2026-06-27 22:00:00', slaOverdue: true,
|
||||
processLogs: createProcessLogs('wo-006')['wo-006'],
|
||||
},
|
||||
{
|
||||
id: 'wo-007', orderCode: 'WO-20260628-007', deviceCode: 'SCR-P1-0001', deviceName: '总部大屏',
|
||||
siteName: '总部食堂', customerName: '智养餐饮集团', source: 'inspection', faultLevel: 'low',
|
||||
faultType: 'hardware', faultDesc: '屏幕局部亮点,不影响显示', images: [],
|
||||
status: 'pending', reportTime: '2026-06-28 15:30:00',
|
||||
slaDeadline: '2026-07-01 18:00:00', slaOverdue: false,
|
||||
processLogs: createProcessLogs('wo-007')['wo-007'],
|
||||
},
|
||||
{
|
||||
id: 'wo-008', orderCode: 'WO-20260629-008', deviceCode: 'SCL-W-0001', deviceName: '健康站体重秤',
|
||||
siteName: '健康站01', customerName: '健康管理机构', source: 'customer', faultLevel: 'medium',
|
||||
faultType: 'hardware', faultDesc: '蓝牙连接频繁断开', images: [],
|
||||
status: 'closed', assignee: '王工程师', reportTime: '2026-06-29 13:00:00',
|
||||
slaDeadline: '2026-06-30 18:00:00', slaOverdue: false,
|
||||
processLogs: createProcessLogs('wo-008')['wo-008'],
|
||||
},
|
||||
]
|
||||
|
||||
const listPage = (ctx: MockContext): ApiResponse<WorkOrderRecord[]> => {
|
||||
@@ -73,19 +195,96 @@ const listPage = (ctx: MockContext): ApiResponse<WorkOrderRecord[]> => {
|
||||
return paginate(filtered, params)
|
||||
}
|
||||
|
||||
const handleTransition = (params: PageParams): ApiResponse<null> => {
|
||||
const infoDetail = (ctx: MockContext): ApiResponse<WorkOrderRecord> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const item = workOrderList.find((o) => o.id === id)
|
||||
if (!item) return ok('工单不存在')
|
||||
const toStatus = params.toStatus as WorkOrderRecord['status']
|
||||
if (!toStatus) return ok('目标状态不能为空')
|
||||
item.status = toStatus
|
||||
if (toStatus === 'closed') {
|
||||
item.slaOverdue = false
|
||||
} else if (params.assignee) {
|
||||
item.assignee = params.assignee
|
||||
return ok('查询成功', item)
|
||||
}
|
||||
|
||||
const addWorkOrder = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const now = new Date()
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
const orderCode = `WO-${now.getFullYear()}${pad(now.getMonth() + 1)}${pad(now.getDate())}-${String(workOrderList.length + 1).padStart(3, '0')}`
|
||||
const createTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
const record: WorkOrderRecord = {
|
||||
id: `wo-${Date.now().toString(36)}`,
|
||||
orderCode,
|
||||
deviceCode: String(params.deviceCode ?? ''),
|
||||
deviceName: String(params.deviceName ?? ''),
|
||||
siteName: String(params.siteName ?? ''),
|
||||
customerName: String(params.customerName ?? ''),
|
||||
source: (params.source as WorkOrderRecord['source']) ?? 'customer',
|
||||
faultLevel: (params.faultLevel as WorkOrderRecord['faultLevel']) ?? 'low',
|
||||
faultType: (params.faultType as WorkOrderRecord['faultType']) ?? 'other',
|
||||
faultDesc: String(params.faultDesc ?? ''),
|
||||
images: Array.isArray(params.images) ? params.images.map(String) : [],
|
||||
status: 'pending',
|
||||
reportTime: createTime,
|
||||
slaDeadline: String(params.slaDeadline ?? ''),
|
||||
slaOverdue: false,
|
||||
processLogs: [
|
||||
{ id: `pl-${Date.now()}-0`, action: 'create', operator: '当前用户', operateTime: createTime, remark: String(params.faultDesc ?? '') },
|
||||
],
|
||||
}
|
||||
return ok('工单状态已更新')
|
||||
workOrderList.unshift(record)
|
||||
return ok('新增成功')
|
||||
}
|
||||
|
||||
const dispatchWorkOrder = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const item = workOrderList.find((o) => o.id === id)
|
||||
if (!item) return ok('工单不存在')
|
||||
const assignee = String(params.assignee ?? '')
|
||||
const now = new Date()
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
const operateTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
item.assignee = assignee
|
||||
item.status = 'assigned'
|
||||
item.processLogs.push({
|
||||
id: `pl-${Date.now()}`,
|
||||
action: 'dispatch',
|
||||
operator: '当前用户',
|
||||
operateTime,
|
||||
remark: `派单给 ${assignee}${params.remark ? ',备注:' + String(params.remark) : ''}`,
|
||||
})
|
||||
return ok('派单成功')
|
||||
}
|
||||
|
||||
const processWorkOrder = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const item = workOrderList.find((o) => o.id === id)
|
||||
if (!item) return ok('工单不存在')
|
||||
const remark = String(params.remark ?? '')
|
||||
const now = new Date()
|
||||
const pad = (n: number): string => String(n).padStart(2, '0')
|
||||
const operateTime = `${now.getFullYear()}-${pad(now.getMonth() + 1)}-${pad(now.getDate())} ${pad(now.getHours())}:${pad(now.getMinutes())}:${pad(now.getSeconds())}`
|
||||
const fromStatus = item.status
|
||||
if (fromStatus === 'pending' || fromStatus === 'assigned') {
|
||||
item.status = 'processing'
|
||||
item.processLogs.push({
|
||||
id: `pl-${Date.now()}`,
|
||||
action: 'start',
|
||||
operator: '当前用户',
|
||||
operateTime,
|
||||
remark,
|
||||
})
|
||||
} else if (fromStatus === 'processing') {
|
||||
item.status = 'closed'
|
||||
item.slaOverdue = false
|
||||
item.processLogs.push({
|
||||
id: `pl-${Date.now()}`,
|
||||
action: 'close',
|
||||
operator: '当前用户',
|
||||
operateTime,
|
||||
remark,
|
||||
})
|
||||
}
|
||||
return ok('处理成功')
|
||||
}
|
||||
|
||||
const deleteById = (params: PageParams): ApiResponse<null> => {
|
||||
@@ -97,6 +296,9 @@ const deleteById = (params: PageParams): ApiResponse<null> => {
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/page', handler: listPage },
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/transition', handler: (ctx) => handleTransition(getParams(ctx.params)) },
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/info', handler: infoDetail },
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/add', handler: addWorkOrder },
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/dispatch', handler: dispatchWorkOrder },
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/process', handler: processWorkOrder },
|
||||
{ method: 'POST', url: '/admin/device/repair/work-order/delete', handler: (ctx) => deleteById(getParams(ctx.params)) },
|
||||
])
|
||||
|
||||
@@ -63,7 +63,7 @@ const paginate = <T extends ArchiveRecord>(list: T[], params: ArchiveParams): Ap
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
|
||||
const archiveList: ArchiveRecord[] = [
|
||||
{ id: 'cust-001', code: 'CQ-001', name: '长庆能源总部食堂', type: 'enterprise', contactName: '王经理', contactPhone: '13900010001', email: 'wang@cq-energy.com', address: '西安市未央区凤城八路 1 号', expireTime: '2027-06-30', appCount: 5, siteCount: 3, status: true, remark: '集团总部客户', createTime: '2026-04-08 09:18:30', dispatchUrl: 'https://dispatch.cq-energy.com/v1/data', authAccount: 'cq_dispatch', authSecret: 'Cq#2026$Dispatch', authApiUrl: 'https://auth.cq-energy.com/v1/token' },
|
||||
{ id: 'cust-001', code: 'CQ-001', name: '国庆能源总部食堂', type: 'enterprise', contactName: '王经理', contactPhone: '13900010001', email: 'wang@cq-energy.com', address: '西安市未央区凤城八路 1 号', expireTime: '2027-06-30', appCount: 5, siteCount: 3, status: true, remark: '集团总部客户', createTime: '2026-04-08 09:18:30', dispatchUrl: 'https://dispatch.cq-energy.com/v1/data', authAccount: 'cq_dispatch', authSecret: 'Cq#2026$Dispatch', authApiUrl: 'https://auth.cq-energy.com/v1/token' },
|
||||
{ id: 'cust-002', code: 'OILF-01', name: '第一采油厂', type: 'enterprise', contactName: '刘主管', contactPhone: '13900010002', email: 'liu@oilfield-01.com', address: '延安市宝塔区河庄坪', expireTime: '2027-05-31', appCount: 4, siteCount: 2, status: true, createTime: '2026-04-14 10:24:48', dispatchUrl: 'https://dispatch.oilfield-01.com/v1/data', authAccount: 'oilf_dispatch', authSecret: 'Oilf#2026$Key', authApiUrl: 'https://auth.oilfield-01.com/v1/token' },
|
||||
{ id: 'cust-003', code: 'REF-01', name: '炼化分公司', type: 'enterprise', contactName: '陈主任', contactPhone: '13900010003', email: 'chen@refining.com', address: '兰州市西固区福利路 88 号', expireTime: '2026-12-31', appCount: 3, siteCount: 2, status: true, createTime: '2026-04-16 14:08:11', dispatchUrl: 'https://dispatch.refining.com/v1/data', authAccount: 'ref_dispatch', authSecret: 'Ref#2026$Secret', authApiUrl: 'https://auth.refining.com/v1/token' },
|
||||
{ id: 'cust-004', code: 'HP-001', name: '健康示范园区', type: 'gov', contactName: '张医生', contactPhone: '13900010004', email: 'zhang@health-park.gov', address: '成都市高新区天府三街 199 号', expireTime: '2026-10-31', appCount: 6, siteCount: 2, status: false, remark: '园区示范项目', createTime: '2026-04-18 16:45:09', dispatchUrl: 'https://dispatch.health-park.gov/v1/data', authAccount: 'hp_dispatch', authSecret: 'Hp#2026$Park', authApiUrl: 'https://auth.health-park.gov/v1/token' },
|
||||
|
||||
@@ -70,7 +70,7 @@ const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ co
|
||||
|
||||
const siteList: SiteRecord[] = [
|
||||
{
|
||||
id: 'site-001', name: '长庆总部一食堂', code: 'CQ-CTN-01', customerId: 'cust-001', customerName: '长庆能源总部食堂',
|
||||
id: 'site-001', name: '国庆总部一食堂', code: 'CQ-CTN-01', customerId: 'cust-001', customerName: '国庆能源总部食堂',
|
||||
department: '总部后勤', address: '西安市未央区凤城八路 1 号', contactName: '王经理', contactPhone: '13900010001',
|
||||
siteType: 'canteen', customFlag: 'A01A02A01',
|
||||
hasFrontendServer: true, frontendServerStatus: 'online', lastHeartbeatTime: '2026-07-08 09:18:30',
|
||||
@@ -101,7 +101,7 @@ const siteList: SiteRecord[] = [
|
||||
deviceCount: 4, status: true, createTime: '2026-05-18 16:45:09',
|
||||
},
|
||||
{
|
||||
id: 'site-005', name: '总部体重测量点 A', code: 'CQ-MSR-A', customerId: 'cust-001', customerName: '长庆能源总部食堂',
|
||||
id: 'site-005', name: '总部体重测量点 A', code: 'CQ-MSR-A', customerId: 'cust-001', customerName: '国庆能源总部食堂',
|
||||
department: '总部后勤', address: '西安市未央区凤城八路 1 号 二楼', contactName: '李护士', contactPhone: '13900010005',
|
||||
siteType: 'measure-point', customFlag: 'A01B02A02',
|
||||
hasFrontendServer: false,
|
||||
@@ -157,7 +157,7 @@ const listSite = (ctx: MockContext): ApiResponse<SiteRecord[]> => {
|
||||
|
||||
/** 客户档案字典(与 archive.ts mock 同源,用于 customerId → customerName 反查) */
|
||||
const customerDict: Record<string, string> = {
|
||||
'cust-001': '长庆能源总部食堂',
|
||||
'cust-001': '国庆能源总部食堂',
|
||||
'cust-002': '第一采油厂',
|
||||
'cust-003': '炼化分公司',
|
||||
'cust-004': '健康示范园区',
|
||||
|
||||
@@ -3,53 +3,52 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 分发配置 mock 数据
|
||||
* - 下游系统管理(CRUD + 启用/禁用)
|
||||
* - 订阅映射(CRUD + 启用/禁用 + 下游下拉选项)
|
||||
* - 单列表 CRUD(无 Tab)
|
||||
* - 字段:下游系统名称 / 系统标识 / 所属客户 / 数据分发地址 / 分发模式 / 鉴权开关 /
|
||||
* 鉴权账号 / 鉴权秘钥 / 鉴权接口地址 / 状态 / 覆盖设备(设备分类多选) /
|
||||
* 最大重试次数 / 最新通讯时间 / 备注
|
||||
* - 客户下拉复用客户档案列表接口(/admin/device/customer/archive/page,由 customer/archive.ts 注册)
|
||||
* - 设备分类树复用 /admin/device/category/tree(由 asset/category.ts 注册)
|
||||
*/
|
||||
|
||||
interface DownstreamItem {
|
||||
interface ConfigItem {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
callbackUrl: string
|
||||
authType: 'none' | 'apikey' | 'hmac' | 'token'
|
||||
authKey: string
|
||||
dataTypes: string[]
|
||||
retryStrategy: 'exponential' | 'fixed' | 'none'
|
||||
dispatchMode: 'realtime' | 'polling'
|
||||
authEnabled: boolean
|
||||
authAccount?: string
|
||||
authSecret?: string
|
||||
authApiUrl?: string
|
||||
status: 'enabled' | 'disabled'
|
||||
deviceCategoryIds: string[]
|
||||
deviceCategoryNames: string[]
|
||||
maxRetry: number
|
||||
status: 'enabled' | 'disabled'
|
||||
lastCommunicateTime?: string
|
||||
remark?: string
|
||||
createdAt: string
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
interface SubscribeItem {
|
||||
id: string
|
||||
downstreamId: string
|
||||
downstreamName: string
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
dataType: string
|
||||
filterRule: string
|
||||
status: 'enabled' | 'disabled'
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
interface BaseParams {
|
||||
interface ConfigParams {
|
||||
id?: string
|
||||
name?: string
|
||||
authType?: string
|
||||
retryStrategy?: string
|
||||
status?: string
|
||||
downstreamId?: string
|
||||
deviceCode?: string
|
||||
dataType?: string
|
||||
code?: string
|
||||
customerId?: string
|
||||
callbackUrl?: string
|
||||
authKey?: string
|
||||
dataTypes?: string[]
|
||||
dispatchMode?: string
|
||||
authEnabled?: boolean
|
||||
authAccount?: string
|
||||
authSecret?: string
|
||||
authApiUrl?: string
|
||||
status?: string
|
||||
deviceCategoryIds?: string[]
|
||||
maxRetry?: number
|
||||
remark?: string
|
||||
filterRule?: string
|
||||
lastCommunicateTimeStart?: string
|
||||
lastCommunicateTimeEnd?: string
|
||||
column?: string
|
||||
order?: 'ascend' | 'descend' | null
|
||||
pageNum?: number
|
||||
@@ -57,269 +56,216 @@ interface BaseParams {
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const getP = (params: RequestParameter): BaseParams => (params ?? {}) as BaseParams
|
||||
const getP = (params: RequestParameter): ConfigParams => (params ?? {}) as ConfigParams
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
|
||||
const downstreamList: DownstreamItem[] = [
|
||||
/** 设备分类 ID → 名称映射(与 asset/category mock 真实树节点保持一致) */
|
||||
const categoryDict: Record<string, string> = {
|
||||
'cat-body-bt-scale': '蓝牙秤',
|
||||
'cat-body-fat': '体脂仪',
|
||||
'cat-body-all-in-one': '一体机',
|
||||
'cat-wearable-hw5': '华为-watch5',
|
||||
'cat-wearable-hw4': '华为-watch4',
|
||||
'cat-wearable-ljf01': '罗杰夫-LJF01',
|
||||
'cat-fitness-bike': '健身车',
|
||||
'cat-fitness-treadmill': '跑步机',
|
||||
'cat-fitness-elliptical': '椭圆机',
|
||||
'cat-monitor-sugar': '血糖监测仪',
|
||||
'cat-monitor-pressure': '高血压治疗仪',
|
||||
'cat-monitor-all-in-one': '智能健康一体机',
|
||||
}
|
||||
|
||||
const categoryName = (id: string): string => categoryDict[id] ?? id
|
||||
|
||||
const configList: ConfigItem[] = [
|
||||
{
|
||||
id: 'DS-001', name: '体重管理系统', code: 'WEIGHT-MGMT',
|
||||
customerId: 'cust-001', customerName: '国庆能源总部食堂',
|
||||
callbackUrl: 'https://weight.example.com/api/v1/iot/callback',
|
||||
authType: 'apikey', authKey: 'ak_xxx_weight_dev',
|
||||
dataTypes: ['weight', 'bodyFat'], retryStrategy: 'exponential', maxRetry: 5,
|
||||
status: 'enabled', remark: '总部 + 园区体重数据', createdAt: '2026-06-12 10:30:00',
|
||||
dispatchMode: 'realtime', authEnabled: true,
|
||||
authAccount: 'weight_appid_001', authSecret: 'ak_xxx_weight_secret', authApiUrl: 'https://weight.example.com/api/v1/auth/token',
|
||||
status: 'enabled', deviceCategoryIds: ['cat-body-bt-scale', 'cat-body-fat'], deviceCategoryNames: ['蓝牙秤', '体脂仪'],
|
||||
maxRetry: 5, lastCommunicateTime: '2026-07-09 09:12:30', remark: '总部 + 园区体重数据',
|
||||
createTime: '2026-06-12 10:30:00',
|
||||
},
|
||||
{
|
||||
id: 'DS-002', name: '健康管理系统', code: 'HEALTH-MGMT',
|
||||
customerId: 'cust-004', customerName: '健康示范园区',
|
||||
callbackUrl: 'https://health.example.com/open/iot/receive',
|
||||
authType: 'hmac', authKey: 'hmac_xxx_health_prod',
|
||||
dataTypes: ['heartRate', 'sleep', 'stepCount', 'healthCheck'],
|
||||
retryStrategy: 'exponential', maxRetry: 5,
|
||||
status: 'enabled', remark: '个人健康数据聚合', createdAt: '2026-06-12 11:20:00',
|
||||
dispatchMode: 'realtime', authEnabled: true,
|
||||
authAccount: 'health_appid_002', authSecret: 'hmac_xxx_health_secret', authApiUrl: 'https://health.example.com/open/auth/token',
|
||||
status: 'enabled', deviceCategoryIds: ['cat-wearable-hw5', 'cat-monitor-all-in-one', 'cat-monitor-pressure'], deviceCategoryNames: ['华为-watch5', '智能健康一体机', '高血压治疗仪'],
|
||||
maxRetry: 5, lastCommunicateTime: '2026-07-09 09:15:48', remark: '个人健康数据聚合',
|
||||
createTime: '2026-06-12 11:20:00',
|
||||
},
|
||||
{
|
||||
id: 'DS-003', name: '运动管理系统', code: 'SPORT-MGMT',
|
||||
customerId: 'cust-008', customerName: '锦绣物业管理公司',
|
||||
callbackUrl: 'https://sport.example.com/api/iot/v2',
|
||||
authType: 'token', authKey: 'bearer_xxx_sport_dev',
|
||||
dataTypes: ['training'], retryStrategy: 'fixed', maxRetry: 3,
|
||||
status: 'enabled', remark: '健身房运动数据', createdAt: '2026-06-15 09:00:00',
|
||||
dispatchMode: 'polling', authEnabled: false,
|
||||
status: 'enabled', deviceCategoryIds: ['cat-fitness-bike', 'cat-wearable-hw5'], deviceCategoryNames: ['健身车', '华为-watch5'],
|
||||
maxRetry: 3, lastCommunicateTime: '2026-07-09 08:30:00', remark: '健身房运动数据(轮询 5 分钟)',
|
||||
createTime: '2026-06-15 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'DS-004', name: '营养分析系统', code: 'NUTRI-ANALYSIS',
|
||||
customerId: 'cust-005', customerName: '江南大学后勤处',
|
||||
callbackUrl: 'https://nutri.example.com/api/iot/weight',
|
||||
authType: 'apikey', authKey: 'ak_xxx_nutri_test',
|
||||
dataTypes: ['weight', 'bodyFat', 'heartRate'],
|
||||
retryStrategy: 'exponential', maxRetry: 5,
|
||||
status: 'disabled', remark: '营养摄入关联分析(停用中)', createdAt: '2026-06-20 14:00:00',
|
||||
dispatchMode: 'realtime', authEnabled: true,
|
||||
authAccount: 'nutri_appid_004', authSecret: 'ak_xxx_nutri_secret', authApiUrl: 'https://nutri.example.com/api/v1/auth/token',
|
||||
status: 'disabled', deviceCategoryIds: ['cat-body-bt-scale', 'cat-body-fat', 'cat-monitor-sugar'], deviceCategoryNames: ['蓝牙秤', '体脂仪', '血糖监测仪'],
|
||||
maxRetry: 5, lastCommunicateTime: '2026-07-08 22:10:00', remark: '营养摄入关联分析(停用中)',
|
||||
createTime: '2026-06-20 14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'DS-005', name: '应急监测平台', code: 'EMERGENCY',
|
||||
customerId: 'cust-007', customerName: '市民政局养老服务科',
|
||||
callbackUrl: 'https://emerg.example.com/api/iot/alert',
|
||||
authType: 'none', authKey: '',
|
||||
dataTypes: ['heartRate', 'healthCheck'],
|
||||
retryStrategy: 'none', maxRetry: 0,
|
||||
status: 'enabled', remark: '高风险健康事件实时推送', createdAt: '2026-06-25 16:30:00',
|
||||
dispatchMode: 'realtime', authEnabled: false,
|
||||
status: 'enabled', deviceCategoryIds: ['cat-wearable-hw5', 'cat-monitor-pressure'], deviceCategoryNames: ['华为-watch5', '高血压治疗仪'],
|
||||
maxRetry: 0, lastCommunicateTime: '2026-07-09 09:20:11', remark: '高风险健康事件实时推送',
|
||||
createTime: '2026-06-25 16:30:00',
|
||||
},
|
||||
{
|
||||
id: 'DS-006', name: '体检数据归档', code: 'HEALTH-ARCHIVE',
|
||||
customerId: 'cust-006', customerName: '市第一人民医院',
|
||||
callbackUrl: 'https://archive.example.com/api/iot/healthCheck',
|
||||
dispatchMode: 'polling', authEnabled: true,
|
||||
authAccount: 'archive_appid_006', authSecret: 'ak_xxx_archive_secret', authApiUrl: 'https://archive.example.com/api/v1/auth/token',
|
||||
status: 'enabled', deviceCategoryIds: ['cat-monitor-all-in-one'], deviceCategoryNames: ['智能健康一体机'],
|
||||
maxRetry: 3, lastCommunicateTime: '2026-07-09 06:00:00', remark: '体检数据按日归档(轮询 1 小时)',
|
||||
createTime: '2026-06-28 10:00:00',
|
||||
},
|
||||
{
|
||||
/** 真实场景:同一客户(健康示范园区)有多个分发配置,不同设备数据走不同回调地址 */
|
||||
id: 'DS-007', name: '手表心率实时上报', code: 'WATCH-HEART',
|
||||
customerId: 'cust-004', customerName: '健康示范园区',
|
||||
callbackUrl: 'https://health.example.com/open/iot/watch/heart',
|
||||
dispatchMode: 'realtime', authEnabled: true,
|
||||
authAccount: 'health_appid_007', authSecret: 'hmac_xxx_watch_secret', authApiUrl: 'https://health.example.com/open/auth/token',
|
||||
status: 'enabled', deviceCategoryIds: ['cat-wearable-hw5'], deviceCategoryNames: ['华为-watch5'],
|
||||
maxRetry: 5, lastCommunicateTime: '2026-07-09 09:25:33', remark: '园区手表心率数据专用通道',
|
||||
createTime: '2026-06-30 14:30:00',
|
||||
},
|
||||
{
|
||||
/** 同一客户的第二条配置:体脂 + 血糖双设备聚合(样例:一个配置覆盖两类设备) */
|
||||
id: 'DS-008', name: '体脂数据分析通道', code: 'BODY-FAT-ANA',
|
||||
customerId: 'cust-004', customerName: '健康示范园区',
|
||||
callbackUrl: 'https://health.example.com/open/iot/body/analysis',
|
||||
dispatchMode: 'realtime', authEnabled: true,
|
||||
authAccount: 'health_appid_008', authSecret: 'hmac_xxx_body_secret', authApiUrl: 'https://health.example.com/open/auth/token',
|
||||
status: 'enabled', deviceCategoryIds: ['cat-body-fat', 'cat-monitor-sugar'], deviceCategoryNames: ['体脂仪', '血糖监测仪'],
|
||||
maxRetry: 5, lastCommunicateTime: '2026-07-09 09:28:12', remark: '体脂 + 血糖数据合并分析通道',
|
||||
createTime: '2026-07-02 09:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
const deviceDict: Record<string, string> = {
|
||||
'WD-SCALE-0001': '总部体重秤 A',
|
||||
'WD-SCALE-0002': '分部体重秤 B',
|
||||
'WD-INBODY-0012': '园区体脂仪',
|
||||
'WD-WATCH-0042': '李四的华为手表',
|
||||
'WD-WATCH-0051': '王五的华为手表',
|
||||
'WD-BIKE-0008': '健身房 08 号健身车',
|
||||
'WD-HEALTH-0023': '体检一体机 23',
|
||||
'WD-BP-0007': '血压计 07',
|
||||
}
|
||||
|
||||
const subscribeList: SubscribeItem[] = [
|
||||
{
|
||||
id: 'SUB-001', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
deviceCode: 'WD-SCALE-0001', deviceName: '总部体重秤 A',
|
||||
dataType: 'weight', filterRule: '', status: 'enabled', createdAt: '2026-06-12 10:35:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-002', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
deviceCode: 'WD-INBODY-0012', deviceName: '园区体脂仪',
|
||||
dataType: 'bodyFat', filterRule: 'weight > 40', status: 'enabled', createdAt: '2026-06-12 10:36:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-003', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'heartRate', filterRule: '', status: 'enabled', createdAt: '2026-06-12 11:25:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-004', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'sleep', filterRule: '', status: 'enabled', createdAt: '2026-06-12 11:26:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-005', downstreamId: 'DS-003', downstreamName: '运动管理系统',
|
||||
deviceCode: 'WD-BIKE-0008', deviceName: '健身房 08 号健身车',
|
||||
dataType: 'training', filterRule: 'duration > 5', status: 'enabled', createdAt: '2026-06-15 09:05:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-006', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
deviceCode: 'WD-HEALTH-0023', deviceName: '体检一体机 23',
|
||||
dataType: 'healthCheck', filterRule: '', status: 'enabled', createdAt: '2026-06-15 09:30:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-007', downstreamId: 'DS-005', downstreamName: '应急监测平台',
|
||||
deviceCode: 'WD-BP-0007', deviceName: '血压计 07',
|
||||
dataType: 'heartRate', filterRule: 'heartRate > 120', status: 'enabled', createdAt: '2026-06-25 16:35:00',
|
||||
},
|
||||
{
|
||||
id: 'SUB-008', downstreamId: 'DS-004', downstreamName: '营养分析系统',
|
||||
deviceCode: 'WD-SCALE-0002', deviceName: '分部体重秤 B',
|
||||
dataType: 'weight', filterRule: '', status: 'disabled', createdAt: '2026-06-20 14:10:00',
|
||||
},
|
||||
]
|
||||
|
||||
// ============= 下游系统接口 =============
|
||||
|
||||
const downstreamListHandler = (ctx: MockContext): ApiResponse<DownstreamItem[]> => {
|
||||
/** 列表筛选 */
|
||||
const listConfig = (ctx: MockContext): ApiResponse<ConfigItem[]> => {
|
||||
const p = getP(ctx.params)
|
||||
const filtered = downstreamList.filter((item) => {
|
||||
const filtered = configList.filter((item) => {
|
||||
if (p.customerId && item.customerId !== p.customerId) return false
|
||||
if (p.name && !item.name.toLowerCase().includes(p.name.toLowerCase())) return false
|
||||
if (p.authType && item.authType !== p.authType) return false
|
||||
if (p.retryStrategy && item.retryStrategy !== p.retryStrategy) return false
|
||||
if (p.status && item.status !== p.status) return false
|
||||
if (p.authEnabled !== undefined && p.authEnabled !== null && item.authEnabled !== p.authEnabled) return false
|
||||
if (p.lastCommunicateTimeStart && (item.lastCommunicateTime ?? '') < p.lastCommunicateTimeStart) return false
|
||||
if (p.lastCommunicateTimeEnd && (item.lastCommunicateTime ?? '') > p.lastCommunicateTimeEnd + ' 23:59:59') return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (p.column === 'lastCommunicateTime' && p.order) {
|
||||
filtered.sort((a, b) => {
|
||||
const cmp = (a.lastCommunicateTime ?? '').localeCompare(b.lastCommunicateTime ?? '')
|
||||
return p.order === 'ascend' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
return { code: '00000', msg: '查询成功', data: filtered, total: filtered.length }
|
||||
}
|
||||
|
||||
const downstreamInfo = (ctx: MockContext): ApiResponse<DownstreamItem | null> => {
|
||||
/** 详情 */
|
||||
const infoConfig = (ctx: MockContext): ApiResponse<ConfigItem | null> => {
|
||||
const p = getP(ctx.params)
|
||||
const target = downstreamList.find((i) => i.id === p.id)
|
||||
const target = configList.find((i) => i.id === p.id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
return ok('查询成功', target)
|
||||
}
|
||||
|
||||
const downstreamAdd = (ctx: MockContext): ApiResponse<null> => {
|
||||
/** 新增 */
|
||||
const addConfig = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = 'DS-' + String(downstreamList.length + 1).padStart(3, '0')
|
||||
downstreamList.push({
|
||||
const id = 'DS-' + String(configList.length + 1).padStart(3, '0')
|
||||
const ids = (p.deviceCategoryIds ?? []) as string[]
|
||||
configList.push({
|
||||
id,
|
||||
name: String(p.name ?? ''),
|
||||
code: String(p.code ?? ''),
|
||||
customerId: String(p.customerId ?? ''),
|
||||
customerName: '',
|
||||
callbackUrl: String(p.callbackUrl ?? ''),
|
||||
authType: (p.authType as DownstreamItem['authType']) ?? 'none',
|
||||
authKey: String(p.authKey ?? ''),
|
||||
dataTypes: p.dataTypes ?? [],
|
||||
retryStrategy: (p.retryStrategy as DownstreamItem['retryStrategy']) ?? 'exponential',
|
||||
dispatchMode: (p.dispatchMode as ConfigItem['dispatchMode']) ?? 'realtime',
|
||||
authEnabled: Boolean(p.authEnabled),
|
||||
authAccount: p.authEnabled ? String(p.authAccount ?? '') : '',
|
||||
authSecret: p.authEnabled ? String(p.authSecret ?? '') : '',
|
||||
authApiUrl: p.authEnabled ? String(p.authApiUrl ?? '') : '',
|
||||
status: (p.status as ConfigItem['status']) ?? 'enabled',
|
||||
deviceCategoryIds: ids,
|
||||
deviceCategoryNames: ids.map(categoryName),
|
||||
maxRetry: Number(p.maxRetry ?? 0),
|
||||
status: (p.status as DownstreamItem['status']) ?? 'enabled',
|
||||
lastCommunicateTime: '',
|
||||
remark: p.remark ?? '',
|
||||
createdAt: '2026-07-02 10:00:00',
|
||||
createTime: '2026-07-09 10:00:00',
|
||||
})
|
||||
return ok('新增成功')
|
||||
}
|
||||
|
||||
const downstreamUpdate = (ctx: MockContext): ApiResponse<null> => {
|
||||
/** 编辑 */
|
||||
const updateConfig = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const target = downstreamList.find((i) => i.id === id)
|
||||
const target = configList.find((i) => i.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
if (p.name !== undefined) target.name = String(p.name)
|
||||
if (p.customerId !== undefined) target.customerId = String(p.customerId)
|
||||
if (p.callbackUrl !== undefined) target.callbackUrl = String(p.callbackUrl)
|
||||
if (p.authType !== undefined) target.authType = p.authType as DownstreamItem['authType']
|
||||
if (p.authKey !== undefined) target.authKey = String(p.authKey)
|
||||
if (p.dataTypes !== undefined) target.dataTypes = p.dataTypes as string[]
|
||||
if (p.retryStrategy !== undefined) target.retryStrategy = p.retryStrategy as DownstreamItem['retryStrategy']
|
||||
if (p.dispatchMode !== undefined) target.dispatchMode = p.dispatchMode as ConfigItem['dispatchMode']
|
||||
if (p.authEnabled !== undefined) {
|
||||
target.authEnabled = Boolean(p.authEnabled)
|
||||
if (!target.authEnabled) {
|
||||
target.authAccount = ''
|
||||
target.authSecret = ''
|
||||
target.authApiUrl = ''
|
||||
} else {
|
||||
target.authAccount = String(p.authAccount ?? '')
|
||||
target.authSecret = String(p.authSecret ?? '')
|
||||
target.authApiUrl = String(p.authApiUrl ?? '')
|
||||
}
|
||||
}
|
||||
if (p.status !== undefined) target.status = p.status as ConfigItem['status']
|
||||
if (p.deviceCategoryIds !== undefined) {
|
||||
const ids = p.deviceCategoryIds as string[]
|
||||
target.deviceCategoryIds = ids
|
||||
target.deviceCategoryNames = ids.map(categoryName)
|
||||
}
|
||||
if (p.maxRetry !== undefined) target.maxRetry = Number(p.maxRetry)
|
||||
if (p.status !== undefined) target.status = p.status as DownstreamItem['status']
|
||||
if (p.remark !== undefined) target.remark = p.remark
|
||||
return ok('更新成功')
|
||||
}
|
||||
|
||||
const downstreamDelete = (ctx: MockContext): ApiResponse<null> => {
|
||||
/** 切换启用状态 */
|
||||
const toggleStatusConfig = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const idx = downstreamList.findIndex((i) => i.id === id)
|
||||
if (idx < 0) return ok('记录不存在', null)
|
||||
downstreamList.splice(idx, 1)
|
||||
// 级联删除订阅
|
||||
for (let i = subscribeList.length - 1; i >= 0; i--) {
|
||||
if (subscribeList[i].downstreamId === id) subscribeList.splice(i, 1)
|
||||
}
|
||||
return ok('删除成功')
|
||||
}
|
||||
|
||||
const downstreamToggle = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const target = downstreamList.find((i) => i.id === id)
|
||||
const target = configList.find((i) => i.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
target.status = target.status === 'enabled' ? 'disabled' : 'enabled'
|
||||
return ok(target.status === 'enabled' ? '已启用' : '已禁用')
|
||||
}
|
||||
|
||||
// ============= 订阅映射接口 =============
|
||||
|
||||
const subscribeListHandler = (ctx: MockContext): ApiResponse<SubscribeItem[]> => {
|
||||
const p = getP(ctx.params)
|
||||
const filtered = subscribeList.filter((item) => {
|
||||
if (p.downstreamId && item.downstreamId !== p.downstreamId) return false
|
||||
if (p.deviceCode && !item.deviceCode.toLowerCase().includes(p.deviceCode.toLowerCase())) return false
|
||||
if (p.dataType && item.dataType !== p.dataType) return false
|
||||
if (p.status && item.status !== p.status) return false
|
||||
return true
|
||||
})
|
||||
return { code: '00000', msg: '查询成功', data: filtered, total: filtered.length }
|
||||
}
|
||||
|
||||
const subscribeOptions = (): ApiResponse<{ downstream: { label: string; value: string }[] }> => {
|
||||
return ok('查询成功', {
|
||||
downstream: downstreamList
|
||||
.filter((i) => i.status === 'enabled')
|
||||
.map((i) => ({ label: i.name, value: i.id })),
|
||||
})
|
||||
}
|
||||
|
||||
const subscribeAdd = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const ds = downstreamList.find((i) => i.id === p.downstreamId)
|
||||
if (!ds) return ok('下游系统不存在', null)
|
||||
const id = 'SUB-' + String(subscribeList.length + 1).padStart(3, '0')
|
||||
subscribeList.push({
|
||||
id,
|
||||
downstreamId: ds.id,
|
||||
downstreamName: ds.name,
|
||||
deviceCode: String(p.deviceCode ?? ''),
|
||||
deviceName: deviceDict[String(p.deviceCode ?? '')] ?? '未知设备',
|
||||
dataType: String(p.dataType ?? ''),
|
||||
filterRule: String(p.filterRule ?? ''),
|
||||
status: (p.status as SubscribeItem['status']) ?? 'enabled',
|
||||
createdAt: '2026-07-02 10:00:00',
|
||||
})
|
||||
return ok('新增成功')
|
||||
}
|
||||
|
||||
const subscribeUpdate = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const target = subscribeList.find((i) => i.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
if (p.deviceCode !== undefined) {
|
||||
target.deviceCode = String(p.deviceCode)
|
||||
target.deviceName = deviceDict[target.deviceCode] ?? '未知设备'
|
||||
}
|
||||
if (p.dataType !== undefined) target.dataType = String(p.dataType)
|
||||
if (p.filterRule !== undefined) target.filterRule = String(p.filterRule)
|
||||
if (p.status !== undefined) target.status = p.status as SubscribeItem['status']
|
||||
return ok('更新成功')
|
||||
}
|
||||
|
||||
const subscribeDelete = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const idx = subscribeList.findIndex((i) => i.id === id)
|
||||
if (idx < 0) return ok('记录不存在', null)
|
||||
subscribeList.splice(idx, 1)
|
||||
return ok('删除成功')
|
||||
}
|
||||
|
||||
const subscribeToggle = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const target = subscribeList.find((i) => i.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
target.status = target.status === 'enabled' ? 'disabled' : 'enabled'
|
||||
return ok(target.status === 'enabled' ? '已启用' : '已禁用')
|
||||
return ok(target.status === 'enabled' ? '已启用' : '已停用')
|
||||
}
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/downstream/list', handler: downstreamListHandler },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/downstream/info', handler: downstreamInfo },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/downstream/add', handler: downstreamAdd },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/downstream/update', handler: downstreamUpdate },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/downstream/delete', handler: downstreamDelete },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/downstream/toggleStatus', handler: downstreamToggle },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/list', handler: subscribeListHandler },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/options', handler: subscribeOptions },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/add', handler: subscribeAdd },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/update', handler: subscribeUpdate },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/delete', handler: subscribeDelete },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/subscribe/toggleStatus', handler: subscribeToggle },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/list', handler: listConfig },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/info', handler: infoConfig },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/add', handler: addConfig },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/update', handler: updateConfig },
|
||||
{ method: 'POST', url: '/admin/device/distribute/config/toggleStatus', handler: toggleStatusConfig },
|
||||
])
|
||||
|
||||
@@ -1,254 +0,0 @@
|
||||
import { registerMocks } from '@axios/mockBus'
|
||||
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 失败重投(死信队列) mock 数据
|
||||
* - 记录分发失败进入死信队列的样本
|
||||
* - 自动重投策略:指数退避(1min / 5min / 30min / 2h / 12h),最多 5 次后转人工
|
||||
* - 状态:pending 待重投 / retrying 重投中 / recovered 已恢复 / discarded 已丢弃
|
||||
*/
|
||||
|
||||
interface DeadletterItem {
|
||||
id: string
|
||||
originalRecordId: string
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
dataType: 'weight' | 'bodyFat' | 'heartRate' | 'sleep' | 'stepCount' | 'training' | 'healthCheck'
|
||||
downstreamId: string
|
||||
downstreamName: string
|
||||
firstFailTime: string
|
||||
lastRetryTime: string | null
|
||||
retryCount: number
|
||||
nextRetryTime: string | null
|
||||
failReason: string
|
||||
status: 'pending' | 'retrying' | 'recovered' | 'discarded'
|
||||
}
|
||||
|
||||
interface DeadletterDetail extends DeadletterItem {
|
||||
requestBody: string
|
||||
responseBody: string
|
||||
authKey: string
|
||||
strategy: string
|
||||
thresholdWarn: string
|
||||
}
|
||||
|
||||
interface DeadletterParams {
|
||||
deviceCode?: string
|
||||
dataType?: string
|
||||
downstreamId?: string
|
||||
status?: string
|
||||
column?: string
|
||||
order?: 'ascend' | 'descend' | null
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
ids?: string[]
|
||||
id?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const getParams = (params: RequestParameter): DeadletterParams => (params ?? {}) as DeadletterParams
|
||||
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
|
||||
const RETRY_STRATEGY = '指数退避:1min → 5min → 30min → 2h → 12h,最多 5 次后转人工'
|
||||
|
||||
const sampleRequestBody = (dataType: string, deviceCode: string): string =>
|
||||
JSON.stringify({
|
||||
deviceCode,
|
||||
dataType,
|
||||
timestamp: '2026-07-02T10:30:00Z',
|
||||
payload: {
|
||||
weight: dataType === 'weight' ? 65.3 : undefined,
|
||||
bodyFat: dataType === 'bodyFat' ? 18.5 : undefined,
|
||||
heartRate: dataType === 'heartRate' ? 72 : undefined,
|
||||
steps: dataType === 'stepCount' ? 8542 : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const sampleResponseBody = (status: string): string =>
|
||||
JSON.stringify({
|
||||
code: status === 'recovered' ? 200 : 500,
|
||||
message: status === 'recovered' ? 'OK' : 'Internal Server Error',
|
||||
receivedAt: '2026-07-02T10:30:01Z',
|
||||
})
|
||||
|
||||
const deadletterList: DeadletterItem[] = [
|
||||
{
|
||||
id: 'DL-001', originalRecordId: 'DPR-002', deviceCode: 'WD-INBODY-0012', deviceName: '园区体脂仪',
|
||||
dataType: 'bodyFat', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
firstFailTime: '2026-07-02 09:13:12', lastRetryTime: '2026-07-02 09:33:12',
|
||||
retryCount: 3, nextRetryTime: '2026-07-02 11:33:12',
|
||||
failReason: '下游服务 500 内部错误,已重试 3 次仍未恢复', status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'DL-002', originalRecordId: 'DPR-004', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'sleep', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
firstFailTime: '2026-07-02 09:15:20', lastRetryTime: '2026-07-02 09:20:20',
|
||||
retryCount: 1, nextRetryTime: '2026-07-02 09:45:20',
|
||||
failReason: '请求超时 408,进入指数退避重投队列', status: 'retrying',
|
||||
},
|
||||
{
|
||||
id: 'DL-003', originalRecordId: 'DPR-008', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'stepCount', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
firstFailTime: '2026-07-02 09:19:18', lastRetryTime: '2026-07-02 10:19:18',
|
||||
retryCount: 5, nextRetryTime: null,
|
||||
failReason: '下游服务不可用 503,已耗尽 5 次自动重投,待人工介入', status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'DL-004', originalRecordId: 'DPR-010', deviceCode: 'WD-SCALE-0002', deviceName: '分部体重秤 B',
|
||||
dataType: 'weight', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
firstFailTime: '2026-07-02 08:42:11', lastRetryTime: '2026-07-02 08:47:11',
|
||||
retryCount: 2, nextRetryTime: '2026-07-02 09:17:11',
|
||||
failReason: '鉴权密钥过期,下游返回 401', status: 'pending',
|
||||
},
|
||||
{
|
||||
id: 'DL-005', originalRecordId: 'DPR-012', deviceCode: 'WD-BP-0007', deviceName: '血压计 07',
|
||||
dataType: 'heartRate', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
firstFailTime: '2026-07-01 17:30:55', lastRetryTime: '2026-07-01 18:30:55',
|
||||
retryCount: 4, nextRetryTime: '2026-07-02 06:30:55',
|
||||
failReason: '下游数据库连接失败,连续 4 次重试失败', status: 'retrying',
|
||||
},
|
||||
{
|
||||
id: 'DL-006', originalRecordId: 'DPR-015', deviceCode: 'WD-BIKE-0008', deviceName: '健身房 08 号健身车',
|
||||
dataType: 'training', downstreamId: 'DS-003', downstreamName: '运动管理系统',
|
||||
firstFailTime: '2026-07-01 14:22:30', lastRetryTime: '2026-07-01 14:27:30',
|
||||
retryCount: 2, nextRetryTime: null,
|
||||
failReason: '运维确认下游已升级,重投成功', status: 'recovered',
|
||||
},
|
||||
{
|
||||
id: 'DL-007', originalRecordId: 'DPR-018', deviceCode: 'WD-HEALTH-0023', deviceName: '体检一体机 23',
|
||||
dataType: 'healthCheck', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
firstFailTime: '2026-07-01 10:08:14', lastRetryTime: '2026-07-01 22:08:14',
|
||||
retryCount: 5, nextRetryTime: null,
|
||||
failReason: '下游接口字段不兼容,已耗尽自动重投,运维永久丢弃', status: 'discarded',
|
||||
},
|
||||
{
|
||||
id: 'DL-008', originalRecordId: 'DPR-020', deviceCode: 'WD-WATCH-0051', deviceName: '王五的华为手表',
|
||||
dataType: 'sleep', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
firstFailTime: '2026-07-02 07:55:42', lastRetryTime: null,
|
||||
retryCount: 0, nextRetryTime: '2026-07-02 07:56:42',
|
||||
failReason: '下游服务瞬时不可用,等待首次重投', status: 'pending',
|
||||
},
|
||||
]
|
||||
|
||||
/** 列表筛选 + 分页 */
|
||||
const listDeadletter = (ctx: MockContext): ApiResponse<DeadletterItem[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = deadletterList.filter((item) => {
|
||||
if (params.deviceCode && !item.deviceCode.toLowerCase().includes(params.deviceCode.toLowerCase())) return false
|
||||
if (params.dataType && item.dataType !== params.dataType) return false
|
||||
if (params.downstreamId && item.downstreamId !== params.downstreamId) return false
|
||||
if (params.status && item.status !== params.status) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (params.column === 'firstFailTime' && params.order) {
|
||||
filtered.sort((a, b) => {
|
||||
const cmp = a.firstFailTime.localeCompare(b.firstFailTime)
|
||||
return params.order === 'ascend' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
const pageNum = Number(params.pageNum ?? 1)
|
||||
const pageSize = Number(params.pageSize ?? 30)
|
||||
const start = (pageNum - 1) * pageSize
|
||||
return { code: '00000', msg: '查询成功', data: filtered.slice(start, start + pageSize), total: filtered.length }
|
||||
}
|
||||
|
||||
/** 详情(含请求体/响应体 + 策略说明) */
|
||||
const infoDeadletter = (ctx: MockContext): ApiResponse<DeadletterDetail | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const target = deadletterList.find((item) => item.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
return ok('查询成功', {
|
||||
...target,
|
||||
requestBody: sampleRequestBody(target.dataType, target.deviceCode),
|
||||
responseBody: sampleResponseBody(target.status),
|
||||
authKey: 'ak_xxx_' + target.downstreamId.toLowerCase() + '_dev',
|
||||
strategy: RETRY_STRATEGY,
|
||||
thresholdWarn: '死信累积超 100 条将自动告警到「设备运维 → 告警管理」',
|
||||
})
|
||||
}
|
||||
|
||||
/** 顶部统计卡 */
|
||||
const statDeadletter = (): ApiResponse<{ pendingCount: number; retryingCount: number; recoveredCount: number; discardedCount: number }> => {
|
||||
const count = (s: string) => deadletterList.filter((i) => i.status === s).length
|
||||
return ok('查询成功', {
|
||||
pendingCount: count('pending'),
|
||||
retryingCount: count('retrying'),
|
||||
recoveredCount: count('recovered'),
|
||||
discardedCount: count('discarded'),
|
||||
})
|
||||
}
|
||||
|
||||
/** 手动重投(单条) */
|
||||
const retryDeadletter = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const target = deadletterList.find((item) => item.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
if (target.status === 'discarded') return ok('该死信已丢弃,无法重投')
|
||||
target.status = 'retrying'
|
||||
target.retryCount += 1
|
||||
target.lastRetryTime = '2026-07-02 10:00:00'
|
||||
target.failReason = '运维手动触发重投,等待下游响应'
|
||||
return ok('重投任务已提交')
|
||||
}
|
||||
|
||||
/** 批量重投 */
|
||||
const batchRetryDeadletter = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const ids = (params.ids ?? []) as string[]
|
||||
let count = 0
|
||||
ids.forEach((id) => {
|
||||
const target = deadletterList.find((item) => item.id === id)
|
||||
if (target && target.status !== 'discarded') {
|
||||
target.status = 'retrying'
|
||||
target.retryCount += 1
|
||||
target.lastRetryTime = '2026-07-02 10:00:00'
|
||||
target.failReason = '批量手动触发重投,等待下游响应'
|
||||
count += 1
|
||||
}
|
||||
})
|
||||
return ok(`已提交 ${count} 条重投任务`)
|
||||
}
|
||||
|
||||
/** 永久丢弃(单条) */
|
||||
const discardDeadletter = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const target = deadletterList.find((item) => item.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
target.status = 'discarded'
|
||||
target.nextRetryTime = null
|
||||
target.failReason = '运维永久丢弃,不再自动重投'
|
||||
return ok('已永久丢弃')
|
||||
}
|
||||
|
||||
/** 批量丢弃 */
|
||||
const batchDiscardDeadletter = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const ids = (params.ids ?? []) as string[]
|
||||
let count = 0
|
||||
ids.forEach((id) => {
|
||||
const target = deadletterList.find((item) => item.id === id)
|
||||
if (target) {
|
||||
target.status = 'discarded'
|
||||
target.nextRetryTime = null
|
||||
target.failReason = '批量永久丢弃,不再自动重投'
|
||||
count += 1
|
||||
}
|
||||
})
|
||||
return ok(`已永久丢弃 ${count} 条记录`)
|
||||
}
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/list', handler: listDeadletter },
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/info', handler: infoDeadletter },
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/stat', handler: statDeadletter },
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/retry', handler: retryDeadletter },
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/batchRetry', handler: batchRetryDeadletter },
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/discard', handler: discardDeadletter },
|
||||
{ method: 'POST', url: '/admin/device/distribute/deadletter/batchDiscard', handler: batchDiscardDeadletter },
|
||||
])
|
||||
@@ -4,8 +4,8 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
/**
|
||||
* 分发记录 mock 数据
|
||||
* - 一条上报数据 × 一个下游 = 一条分发记录
|
||||
* - 含 HTTP 状态码、响应耗时、重试次数、状态、失败原因
|
||||
* - 详情包含请求体 / 响应体
|
||||
* - 列表/统计支持按 downstreamId 过滤(从分发配置跳转携带)
|
||||
* - 详情包含请求体 / 响应体(成功)或失败报错日志(失败/重投中)
|
||||
*/
|
||||
|
||||
interface RecordItem {
|
||||
@@ -51,7 +51,7 @@ const sampleRequestBody = (dataType: string, deviceCode: string): string =>
|
||||
JSON.stringify({
|
||||
deviceCode,
|
||||
dataType,
|
||||
timestamp: '2026-07-02T10:30:00Z',
|
||||
timestamp: '2026-07-09T10:30:00Z',
|
||||
payload: {
|
||||
weight: dataType === 'weight' ? 65.3 : undefined,
|
||||
bodyFat: dataType === 'bodyFat' ? 18.5 : undefined,
|
||||
@@ -64,61 +64,61 @@ const sampleResponseBody = (httpStatus: number, status: string): string =>
|
||||
JSON.stringify({
|
||||
code: httpStatus,
|
||||
message: status === 'success' ? 'OK' : 'Internal Server Error',
|
||||
receivedAt: '2026-07-02T10:30:01Z',
|
||||
receivedAt: '2026-07-09T10:30:01Z',
|
||||
})
|
||||
|
||||
const recordList: RecordItem[] = [
|
||||
{
|
||||
id: 'DPR-001', deviceCode: 'WD-SCALE-0001', deviceName: '总部体重秤 A',
|
||||
dataType: 'weight', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
dispatchTime: '2026-07-02 09:12:30', httpStatus: 200, responseTime: 85, retryCount: 0,
|
||||
dispatchTime: '2026-07-09 09:12:30', httpStatus: 200, responseTime: 85, retryCount: 0,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'DPR-002', deviceCode: 'WD-INBODY-0012', deviceName: '园区体脂仪',
|
||||
dataType: 'bodyFat', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
dispatchTime: '2026-07-02 09:13:12', httpStatus: 500, responseTime: 320, retryCount: 3,
|
||||
dispatchTime: '2026-07-09 09:13:12', httpStatus: 500, responseTime: 320, retryCount: 3,
|
||||
status: 'failed', failureReason: '下游服务 500 内部错误,已耗尽 3 次重试',
|
||||
},
|
||||
{
|
||||
id: 'DPR-003', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'heartRate', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
dispatchTime: '2026-07-02 09:14:48', httpStatus: 200, responseTime: 102, retryCount: 0,
|
||||
dispatchTime: '2026-07-09 09:14:48', httpStatus: 200, responseTime: 102, retryCount: 0,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'DPR-004', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'sleep', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
dispatchTime: '2026-07-02 09:15:20', httpStatus: 408, responseTime: 5000, retryCount: 1,
|
||||
status: 'retrying', failureReason: '请求超时,进入重投队列',
|
||||
dispatchTime: '2026-07-09 09:15:20', httpStatus: 408, responseTime: 5000, retryCount: 1,
|
||||
status: 'retrying', failureReason: '请求超时 408,进入重投队列等待下一次回调',
|
||||
},
|
||||
{
|
||||
id: 'DPR-005', deviceCode: 'WD-BIKE-0008', deviceName: '健身房 08 号健身车',
|
||||
dataType: 'training', downstreamId: 'DS-003', downstreamName: '运动管理系统',
|
||||
dispatchTime: '2026-07-02 09:16:05', httpStatus: 200, responseTime: 145, retryCount: 0,
|
||||
dispatchTime: '2026-07-09 09:16:05', httpStatus: 200, responseTime: 145, retryCount: 0,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'DPR-006', deviceCode: 'WD-HEALTH-0023', deviceName: '体检一体机 23',
|
||||
dataType: 'healthCheck', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
dispatchTime: '2026-07-02 09:17:42', httpStatus: 200, responseTime: 220, retryCount: 0,
|
||||
dispatchTime: '2026-07-09 09:17:42', httpStatus: 200, responseTime: 220, retryCount: 0,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'DPR-007', deviceCode: 'WD-SCALE-0001', deviceName: '总部体重秤 A',
|
||||
dataType: 'weight', downstreamId: 'DS-001', downstreamName: '体重管理系统',
|
||||
dispatchTime: '2026-07-02 09:18:33', httpStatus: 200, responseTime: 78, retryCount: 0,
|
||||
dispatchTime: '2026-07-09 09:18:33', httpStatus: 200, responseTime: 78, retryCount: 0,
|
||||
status: 'success',
|
||||
},
|
||||
{
|
||||
id: 'DPR-008', deviceCode: 'WD-WATCH-0042', deviceName: '李四的华为手表',
|
||||
dataType: 'stepCount', downstreamId: 'DS-002', downstreamName: '健康管理系统',
|
||||
dispatchTime: '2026-07-02 09:19:18', httpStatus: 503, responseTime: 280, retryCount: 2,
|
||||
dispatchTime: '2026-07-09 09:19:18', httpStatus: 503, responseTime: 280, retryCount: 2,
|
||||
status: 'failed', failureReason: '下游服务不可用 503,已耗尽重试',
|
||||
},
|
||||
]
|
||||
|
||||
/** 列表筛选 + 分页 */
|
||||
/** 列表筛选 + 分页(支持 downstreamId 维度过滤) */
|
||||
const listRecord = (ctx: MockContext): ApiResponse<RecordItem[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = recordList.filter((item) => {
|
||||
@@ -150,21 +150,29 @@ const infoRecord = (ctx: MockContext): ApiResponse<DetailRecord | null> => {
|
||||
if (!target) return ok('记录不存在', null)
|
||||
return ok('查询成功', {
|
||||
...target,
|
||||
requestBody: sampleRequestBody(target.dataType, target.deviceCode),
|
||||
responseBody: sampleResponseBody(target.httpStatus, target.status),
|
||||
requestBody: target.status === 'success' ? sampleRequestBody(target.dataType, target.deviceCode) : '',
|
||||
responseBody: target.status === 'success' ? sampleResponseBody(target.httpStatus, target.status) : '',
|
||||
authKey: 'ak_xxx_' + target.downstreamId.toLowerCase() + '_dev',
|
||||
})
|
||||
}
|
||||
|
||||
/** 顶部统计卡:今日分发总数 / 成功率 / 平均响应耗时 / 失败待重投数 */
|
||||
const statRecord = (): ApiResponse<{ todayTotal: number; successRate: number; avgResponseTime: number; failedPending: number }> => {
|
||||
const total = recordList.length
|
||||
const successCount = recordList.filter((i) => i.status === 'success').length
|
||||
const failedPending = recordList.filter((i) => i.status !== 'success').length
|
||||
const avgTime = Math.round(recordList.reduce((sum, i) => sum + i.responseTime, 0) / total)
|
||||
/** 顶部统计卡(支持按 downstreamId 维度统计) */
|
||||
const statRecord = (ctx: MockContext): ApiResponse<{ todayTotal: number; successRate: number; avgResponseTime: number; failedPending: number }> => {
|
||||
const params = getParams(ctx.params)
|
||||
const scoped = params.downstreamId
|
||||
? recordList.filter((i) => i.downstreamId === params.downstreamId)
|
||||
: recordList
|
||||
if (scoped.length === 0) {
|
||||
return ok('查询成功', { todayTotal: 0, successRate: 0, avgResponseTime: 0, failedPending: 0 })
|
||||
}
|
||||
const successCount = scoped.filter((i) => i.status === 'success').length
|
||||
const failedPending = scoped.filter((i) => i.status !== 'success').length
|
||||
const avgTime = Math.round(scoped.reduce((sum, i) => sum + i.responseTime, 0) / scoped.length)
|
||||
/** todayTotal 用 mock 倍数模拟(实际场景由后端聚合今日分发总数) */
|
||||
const todayTotal = scoped.length * 160
|
||||
return ok('查询成功', {
|
||||
todayTotal: 1280,
|
||||
successRate: Math.round((successCount / total) * 1000) / 10,
|
||||
todayTotal,
|
||||
successRate: Math.round((successCount / scoped.length) * 1000) / 10,
|
||||
avgResponseTime: avgTime,
|
||||
failedPending,
|
||||
})
|
||||
|
||||
@@ -0,0 +1,310 @@
|
||||
import { registerMocks } from '@axios/mockBus'
|
||||
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 上报配置 mock 数据
|
||||
* - 单列表 CRUD(无 Tab)
|
||||
* - 字段:上报通道名称 / 通道编码 / 所属客户 / 布点场所 / 鉴权开关 / 鉴权账号 / 鉴权秘钥 /
|
||||
* 鉴权接口地址 / 状态 / MQ 通讯地址 / 服务网关地址 / 覆盖设备(设备分类多选) /
|
||||
* 备注 / 创建时间
|
||||
* - 客户下拉复用 /admin/device/customer/archive/page(由 customer/archive.ts 注册)
|
||||
* - 场所下拉复用 /admin/device/site/list(由 customer/site.ts 注册,支持 customerId 联动过滤)
|
||||
* - 设备分类树复用 /admin/device/category/tree(由 asset/category.ts 注册)
|
||||
*/
|
||||
|
||||
interface ReportItem {
|
||||
id: string
|
||||
channelName: string
|
||||
channelCode: string
|
||||
customerId: string
|
||||
customerName: string
|
||||
siteId: string
|
||||
siteName: string
|
||||
authEnabled: boolean
|
||||
authAccount?: string
|
||||
authSecret?: string
|
||||
authApiUrl?: string
|
||||
status: 'enabled' | 'disabled'
|
||||
mqAddress: string
|
||||
gatewayUrl: string
|
||||
deviceCategoryIds: string[]
|
||||
deviceCategoryNames: string[]
|
||||
remark?: string
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
interface ReportParams {
|
||||
id?: string
|
||||
channelName?: string
|
||||
channelCode?: string
|
||||
customerId?: string
|
||||
siteId?: string
|
||||
name?: string
|
||||
status?: string
|
||||
authEnabled?: boolean
|
||||
mqAddress?: string
|
||||
gatewayUrl?: string
|
||||
deviceCategoryIds?: string[]
|
||||
remark?: string
|
||||
column?: string
|
||||
order?: 'ascend' | 'descend' | null
|
||||
pageNum?: number
|
||||
pageSize?: number
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const getP = (params: RequestParameter): ReportParams => (params ?? {}) as ReportParams
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
|
||||
/** 设备分类 ID → 名称映射(与 asset/category mock 真实树节点保持一致) */
|
||||
const categoryDict: Record<string, string> = {
|
||||
'cat-body-bt-scale': '蓝牙秤',
|
||||
'cat-body-fat': '体脂仪',
|
||||
'cat-body-all-in-one': '一体机',
|
||||
'cat-wearable-hw5': '华为-watch5',
|
||||
'cat-wearable-hw4': '华为-watch4',
|
||||
'cat-wearable-ljf01': '罗杰夫-LJF01',
|
||||
'cat-fitness-bike': '健身车',
|
||||
'cat-fitness-treadmill': '跑步机',
|
||||
'cat-fitness-elliptical': '椭圆机',
|
||||
'cat-monitor-sugar': '血糖监测仪',
|
||||
'cat-monitor-pressure': '高血压治疗仪',
|
||||
'cat-monitor-all-in-one': '智能健康一体机',
|
||||
}
|
||||
|
||||
const categoryName = (id: string): string => categoryDict[id] ?? id
|
||||
|
||||
/** 客户字典(与 customer/archive mock 同源) */
|
||||
const customerDict: Record<string, string> = {
|
||||
'cust-001': '国庆能源总部食堂',
|
||||
'cust-002': '第一采油厂',
|
||||
'cust-003': '炼化分公司',
|
||||
'cust-004': '健康示范园区',
|
||||
}
|
||||
|
||||
/** 场所字典(与 customer/site mock 同源,用于 siteId → siteName 反查) */
|
||||
const siteDict: Record<string, { name: string; customerId: string }> = {
|
||||
'site-001': { name: '国庆总部一食堂', customerId: 'cust-001' },
|
||||
'site-002': { name: '采油一厂食堂', customerId: 'cust-002' },
|
||||
'site-003': { name: '炼化员工餐厅', customerId: 'cust-003' },
|
||||
'site-004': { name: '健康园区体检站', customerId: 'cust-004' },
|
||||
'site-005': { name: '总部体重测量点 A', customerId: 'cust-001' },
|
||||
'site-006': { name: '园区员工健身房', customerId: 'cust-004' },
|
||||
'site-007': { name: '采油一厂社区站', customerId: 'cust-002' },
|
||||
'site-008': { name: '炼化二食堂', customerId: 'cust-003' },
|
||||
}
|
||||
|
||||
const reportList: ReportItem[] = [
|
||||
{
|
||||
id: 'RP-001', channelName: '一厂食堂前置服务器', channelCode: 'CQ-CANTEEN-01',
|
||||
customerId: 'cust-001', customerName: '国庆能源总部食堂',
|
||||
siteId: 'site-001', siteName: '国庆总部一食堂',
|
||||
authEnabled: true,
|
||||
authAccount: 'cq_report_001', authSecret: 'ak_xxx_cq_secret', authApiUrl: 'https://auth.cq-energy.com/v1/token',
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.10.1.21:61616',
|
||||
gatewayUrl: 'https://gateway.cq-energy.com/api/v1/iot/report',
|
||||
deviceCategoryIds: ['cat-body-bt-scale', 'cat-body-fat'], deviceCategoryNames: ['蓝牙秤', '体脂仪'],
|
||||
remark: '总部一食堂体重体脂数据上报通道',
|
||||
createTime: '2026-06-10 10:30:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-002', channelName: '阿里云中转服务器', channelCode: 'ALI-RELAY-EAST',
|
||||
customerId: 'cust-004', customerName: '健康示范园区',
|
||||
siteId: 'site-004', siteName: '健康园区体检站',
|
||||
authEnabled: true,
|
||||
authAccount: 'hp_relay_002', authSecret: 'hmac_xxx_hp_secret', authApiUrl: 'https://auth.health-park.gov/v1/token',
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.30.3.31:61616',
|
||||
gatewayUrl: 'https://gateway.health-park.gov/api/v1/iot/report',
|
||||
deviceCategoryIds: ['cat-wearable-hw5', 'cat-monitor-all-in-one', 'cat-monitor-pressure'], deviceCategoryNames: ['华为-watch5', '智能健康一体机', '高血压治疗仪'],
|
||||
remark: '园区体检 + 手表数据经阿里云中转上报',
|
||||
createTime: '2026-06-12 11:20:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-003', channelName: '二厂食堂前置机', channelCode: 'OILF-CTN-02',
|
||||
customerId: 'cust-002', customerName: '第一采油厂',
|
||||
siteId: 'site-002', siteName: '采油一厂食堂',
|
||||
authEnabled: false,
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.20.2.11:61616',
|
||||
gatewayUrl: 'https://gateway.oilfield-01.com/api/v1/iot/report',
|
||||
deviceCategoryIds: ['cat-body-bt-scale'], deviceCategoryNames: ['蓝牙秤'],
|
||||
remark: '采油厂食堂体重数据上报(免鉴权通道)',
|
||||
createTime: '2026-06-15 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-004', channelName: '炼化餐厅前置服务器', channelCode: 'REF-DIN-01',
|
||||
customerId: 'cust-003', customerName: '炼化分公司',
|
||||
siteId: 'site-003', siteName: '炼化员工餐厅',
|
||||
authEnabled: true,
|
||||
authAccount: 'ref_din_004', authSecret: 'ak_xxx_ref_secret', authApiUrl: 'https://auth.refining.com/v1/token',
|
||||
status: 'disabled',
|
||||
mqAddress: 'mq://10.40.4.11:61616',
|
||||
gatewayUrl: 'https://gateway.refining.com/api/v1/iot/report',
|
||||
deviceCategoryIds: ['cat-body-fat', 'cat-monitor-sugar'], deviceCategoryNames: ['体脂仪', '血糖监测仪'],
|
||||
remark: '炼化员工餐厅体脂血糖上报(停用中)',
|
||||
createTime: '2026-06-20 14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-005', channelName: '总部测量点上报', channelCode: 'CQ-MSR-01',
|
||||
customerId: 'cust-001', customerName: '国庆能源总部食堂',
|
||||
siteId: 'site-005', siteName: '总部体重测量点 A',
|
||||
authEnabled: false,
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.10.1.31:61616',
|
||||
gatewayUrl: 'https://gateway.cq-energy.com/api/v1/iot/measure',
|
||||
deviceCategoryIds: ['cat-body-bt-scale'], deviceCategoryNames: ['蓝牙秤'],
|
||||
remark: '总部测量点 A 蓝牙秤数据上报',
|
||||
createTime: '2026-06-25 16:30:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-006', channelName: '园区健身房网关', channelCode: 'HP-GYM-01',
|
||||
customerId: 'cust-004', customerName: '健康示范园区',
|
||||
siteId: 'site-006', siteName: '园区员工健身房',
|
||||
authEnabled: true,
|
||||
authAccount: 'hp_gym_006', authSecret: 'hmac_xxx_gym_secret', authApiUrl: 'https://auth.health-park.gov/v1/token',
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.30.3.41:61616',
|
||||
gatewayUrl: 'https://gateway.health-park.gov/api/v1/iot/fitness',
|
||||
deviceCategoryIds: ['cat-fitness-bike', 'cat-fitness-treadmill', 'cat-wearable-hw5'], deviceCategoryNames: ['健身车', '跑步机', '华为-watch5'],
|
||||
remark: '健身房运动数据上报通道',
|
||||
createTime: '2026-06-28 10:00:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-007', channelName: '采油社区站前置', channelCode: 'OILF-COM-01',
|
||||
customerId: 'cust-002', customerName: '第一采油厂',
|
||||
siteId: 'site-007', siteName: '采油一厂社区站',
|
||||
authEnabled: false,
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.20.4.21:61616',
|
||||
gatewayUrl: 'https://gateway.oilfield-01.com/api/v1/iot/community',
|
||||
deviceCategoryIds: ['cat-wearable-hw5', 'cat-monitor-pressure'], deviceCategoryNames: ['华为-watch5', '高血压治疗仪'],
|
||||
remark: '社区站老人健康数据上报(免鉴权)',
|
||||
createTime: '2026-07-02 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'RP-008', channelName: '炼化二食堂网关', channelCode: 'REF-CTN-02',
|
||||
customerId: 'cust-003', customerName: '炼化分公司',
|
||||
siteId: 'site-008', siteName: '炼化二食堂',
|
||||
authEnabled: true,
|
||||
authAccount: 'ref_ctn_008', authSecret: 'ak_xxx_ref2_secret', authApiUrl: 'https://auth.refining.com/v1/token',
|
||||
status: 'enabled',
|
||||
mqAddress: 'mq://10.40.4.31:61616',
|
||||
gatewayUrl: 'https://gateway.refining.com/api/v1/iot/canteen2',
|
||||
deviceCategoryIds: ['cat-body-all-in-one'], deviceCategoryNames: ['一体机'],
|
||||
remark: '炼化二食堂一体机数据上报',
|
||||
createTime: '2026-07-05 14:30:00',
|
||||
},
|
||||
]
|
||||
|
||||
/** 列表筛选 */
|
||||
const listReport = (ctx: MockContext): ApiResponse<ReportItem[]> => {
|
||||
const p = getP(ctx.params)
|
||||
const filtered = reportList.filter((item) => {
|
||||
if (p.customerId && item.customerId !== p.customerId) return false
|
||||
if (p.siteId && item.siteId !== p.siteId) return false
|
||||
/** 兼容 BaseSearchForm.name(前端搜索字段命名为 name) */
|
||||
const kw = (p.name ?? p.channelName) as string | undefined
|
||||
if (kw && !item.channelName.toLowerCase().includes(kw.toLowerCase())) return false
|
||||
if (p.status && item.status !== p.status) return false
|
||||
if (p.authEnabled !== undefined && p.authEnabled !== null && item.authEnabled !== p.authEnabled) return false
|
||||
return true
|
||||
})
|
||||
|
||||
if (p.column === 'createTime' && p.order) {
|
||||
filtered.sort((a, b) => {
|
||||
const cmp = (a.createTime ?? '').localeCompare(b.createTime ?? '')
|
||||
return p.order === 'ascend' ? cmp : -cmp
|
||||
})
|
||||
}
|
||||
|
||||
return { code: '00000', msg: '查询成功', data: filtered, total: filtered.length }
|
||||
}
|
||||
|
||||
/** 详情 */
|
||||
const infoReport = (ctx: MockContext): ApiResponse<ReportItem | null> => {
|
||||
const p = getP(ctx.params)
|
||||
const target = reportList.find((i) => i.id === p.id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
return ok('查询成功', target)
|
||||
}
|
||||
|
||||
/** 新增 */
|
||||
const addReport = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = 'RP-' + String(reportList.length + 1).padStart(3, '0')
|
||||
const ids = (p.deviceCategoryIds ?? []) as string[]
|
||||
const customerId = String(p.customerId ?? '')
|
||||
const siteId = String(p.siteId ?? '')
|
||||
const site = siteDict[siteId]
|
||||
reportList.push({
|
||||
id,
|
||||
channelName: String(p.channelName ?? ''),
|
||||
channelCode: String(p.channelCode ?? ''),
|
||||
customerId,
|
||||
customerName: customerDict[customerId] ?? '',
|
||||
siteId,
|
||||
siteName: site?.name ?? '',
|
||||
authEnabled: Boolean(p.authEnabled),
|
||||
authAccount: p.authEnabled ? String(p.authAccount ?? '') : '',
|
||||
authSecret: p.authEnabled ? String(p.authSecret ?? '') : '',
|
||||
authApiUrl: p.authEnabled ? String(p.authApiUrl ?? '') : '',
|
||||
status: (p.status as ReportItem['status']) ?? 'enabled',
|
||||
mqAddress: String(p.mqAddress ?? ''),
|
||||
gatewayUrl: String(p.gatewayUrl ?? ''),
|
||||
deviceCategoryIds: ids,
|
||||
deviceCategoryNames: ids.map(categoryName),
|
||||
remark: p.remark ?? '',
|
||||
createTime: '2026-07-09 10:00:00',
|
||||
})
|
||||
return ok('新增成功')
|
||||
}
|
||||
|
||||
/** 编辑 */
|
||||
const updateReport = (ctx: MockContext): ApiResponse<null> => {
|
||||
const p = getP(ctx.params)
|
||||
const id = String(p.id ?? '')
|
||||
const target = reportList.find((i) => i.id === id)
|
||||
if (!target) return ok('记录不存在', null)
|
||||
if (p.channelName !== undefined) target.channelName = String(p.channelName)
|
||||
if (p.customerId !== undefined) {
|
||||
const cid = String(p.customerId)
|
||||
target.customerId = cid
|
||||
target.customerName = customerDict[cid] ?? ''
|
||||
}
|
||||
if (p.siteId !== undefined) {
|
||||
const sid = String(p.siteId)
|
||||
target.siteId = sid
|
||||
target.siteName = siteDict[sid]?.name ?? ''
|
||||
}
|
||||
if (p.authEnabled !== undefined) {
|
||||
target.authEnabled = Boolean(p.authEnabled)
|
||||
if (!target.authEnabled) {
|
||||
target.authAccount = ''
|
||||
target.authSecret = ''
|
||||
target.authApiUrl = ''
|
||||
} else {
|
||||
target.authAccount = String(p.authAccount ?? '')
|
||||
target.authSecret = String(p.authSecret ?? '')
|
||||
target.authApiUrl = String(p.authApiUrl ?? '')
|
||||
}
|
||||
}
|
||||
if (p.status !== undefined) target.status = p.status as ReportItem['status']
|
||||
if (p.mqAddress !== undefined) target.mqAddress = String(p.mqAddress)
|
||||
if (p.gatewayUrl !== undefined) target.gatewayUrl = String(p.gatewayUrl)
|
||||
if (p.deviceCategoryIds !== undefined) {
|
||||
const ids = p.deviceCategoryIds as string[]
|
||||
target.deviceCategoryIds = ids
|
||||
target.deviceCategoryNames = ids.map(categoryName)
|
||||
}
|
||||
if (p.remark !== undefined) target.remark = p.remark
|
||||
return ok('更新成功')
|
||||
}
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/distribute/report/list', handler: listReport },
|
||||
{ method: 'POST', url: '/admin/device/distribute/report/info', handler: infoReport },
|
||||
{ method: 'POST', url: '/admin/device/distribute/report/add', handler: addReport },
|
||||
{ method: 'POST', url: '/admin/device/distribute/report/update', handler: updateReport },
|
||||
])
|
||||
@@ -3,14 +3,14 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 设备运维 / 告警管理 mock
|
||||
* - 告警处置:转工单 / 忽略 / 批量处理
|
||||
* - 业务日志:全量操作流水视图
|
||||
* - 告警列表:转工单 / 忽略 / 批量处理
|
||||
* - 告警详情:基础信息 + 报错日志(代码风格多行文本)
|
||||
* - 设备分类下拉:末级分类名(与设备监测模块对齐)
|
||||
*/
|
||||
|
||||
type AlarmSource = 'runLog' | 'selfCheck' | 'inspection' | 'sla' | 'ota' | 'dataDistribute'
|
||||
type AlarmLevel = 'info' | 'warn' | 'critical'
|
||||
type AlarmStatus = 'pending' | 'processing' | 'ignored' | 'workorder'
|
||||
type BusinessLogType = 'operate' | 'system' | 'audit' | 'data'
|
||||
|
||||
interface AlarmRecord {
|
||||
id: string
|
||||
@@ -19,29 +19,29 @@ interface AlarmRecord {
|
||||
level: AlarmLevel
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
deviceCategory: string
|
||||
content: string
|
||||
triggerTime: string
|
||||
status: AlarmStatus
|
||||
handler: string | null
|
||||
}
|
||||
|
||||
interface BusinessLogRecord {
|
||||
id: string
|
||||
logType: BusinessLogType
|
||||
/** 触发规则 */
|
||||
triggerRule: string
|
||||
/** 所属租户 */
|
||||
tenantName: string
|
||||
/** 所属场所 */
|
||||
siteName: string
|
||||
module: string
|
||||
content: string
|
||||
operator: string
|
||||
operateTime: string
|
||||
/** 处理建议 */
|
||||
handleSuggestion: string
|
||||
/** 报错日志(代码风格多行文本) */
|
||||
errorLog: string
|
||||
}
|
||||
|
||||
interface AlarmParams {
|
||||
keyword?: string
|
||||
deviceCode?: string
|
||||
deviceCategory?: string
|
||||
source?: string
|
||||
level?: string
|
||||
status?: string
|
||||
logType?: string
|
||||
siteName?: string
|
||||
id?: string
|
||||
ids?: string[]
|
||||
type?: string
|
||||
@@ -50,113 +50,183 @@ interface AlarmParams {
|
||||
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
const getParams = (params: RequestParameter): AlarmParams => (params ?? {}) as AlarmParams
|
||||
const includes = (value: unknown, keyword: unknown): boolean => {
|
||||
if (!keyword) return true
|
||||
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
|
||||
}
|
||||
|
||||
const alarmList: AlarmRecord[] = [
|
||||
{
|
||||
id: 'alm-001', alarmCode: 'ALM20260703001', source: 'selfCheck', level: 'critical',
|
||||
deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)',
|
||||
deviceCode: 'CAB-SH-001', deviceName: '1号净菜柜(上海浦东)', deviceCategory: '智能货柜',
|
||||
content: '自检失败:压缩机停转 3 次/小时,疑似硬件故障',
|
||||
triggerTime: '2026-07-03 09:24:11', status: 'pending', handler: null,
|
||||
triggerRule: '压缩机停转次数 ≥ 3 次/小时(critical)',
|
||||
tenantName: '上海智养医疗科技有限公司', siteName: '上海浦东 1号净菜柜',
|
||||
handleSuggestion: '现场检查压缩机供电与控制板,必要时更换压缩机;修复后重新自检',
|
||||
errorLog: `[2026-07-03 09:24:11.243] ERROR [self-check-thread-3] c.yx.device.SelfCheckTask -
|
||||
>>> 自检失败:压缩机停转次数超阈值(当前值=3,阈值=3)
|
||||
at com.yx.device.SelfCheckTask.checkCompressor(SelfCheckTask.java:142)
|
||||
at com.yx.device.SelfCheckTask.run(SelfCheckTask.java:88)
|
||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
|
||||
at java.base/java.util.concurrent.FutureTask.run(FutureTask.java:317)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1144)
|
||||
at java.base/java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:642)
|
||||
Caused by: com.yx.device.HardwareException: 压缩机响应超时 (3000ms)
|
||||
at com.yx.device.CompressorClient.ping(CompressorClient.java:67)
|
||||
at com.yx.device.CompressorClient.getStatus(CompressorClient.java:42)
|
||||
at com.yx.device.SelfCheckTask.checkCompressor(SelfCheckTask.java:138)
|
||||
... 6 more
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-002', alarmCode: 'ALM20260703002', source: 'ota', level: 'warn',
|
||||
deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)',
|
||||
deviceCode: 'WATCH-BJ-014', deviceName: '14号健康手表(北京朝阳)', deviceCategory: '智能手表',
|
||||
content: 'OTA 升级失败:设备位于 v1.0.4,目标 v1.1.0,已回滚',
|
||||
triggerTime: '2026-07-03 10:12:48', status: 'processing', handler: '李工程师',
|
||||
triggerRule: 'OTA 任务执行结果 = fail(warn)',
|
||||
tenantName: '北京康养科技有限公司', siteName: '北京朝阳 14号健康手表',
|
||||
handleSuggestion: '检查设备网络与电量,确认目标版本号无误后重试 OTA;必要时人工升级',
|
||||
errorLog: `[2026-07-03 10:12:48.108] WARN [ota-push-worker-5] c.yx.device.ota.OtaPushService -
|
||||
>>> OTA 升级失败:设备 WATCH-BJ-014 从 v1.0.4 升级到 v1.1.0 失败,已自动回滚
|
||||
at com.yx.device.ota.OtaPushService.executeUpgrade(OtaPushService.java:215)
|
||||
at com.yx.device.ota.OtaPushService.handle(OtaPushService.java:96)
|
||||
at com.yx.device.ota.OtaScheduler$ScheduledJob.run(OtaScheduler.java:54)
|
||||
Caused by: com.yx.device.ota.FirmwareVerifyException: 固件签名校验失败:expected=sha256:8f3a...e1d2, actual=sha256:9c2b...7f4a
|
||||
at com.yx.device.ota.FirmwareVerifier.verify(FirmwareVerifier.java:78)
|
||||
at com.yx.device.ota.OtaPushService.executeUpgrade(OtaPushService.java:198)
|
||||
... 2 more
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-003', alarmCode: 'ALM20260702018', source: 'inspection', level: 'warn',
|
||||
deviceCode: 'CAB-GZ-007', deviceName: '7号净菜柜(广州天河)',
|
||||
deviceCode: 'CAB-GZ-007', deviceName: '7号净菜柜(广州天河)', deviceCategory: '智能货柜',
|
||||
content: '巡检异常:货道 5 错位,建议现场复位',
|
||||
triggerTime: '2026-07-02 16:30:22', status: 'workorder', handler: '王工程师',
|
||||
triggerRule: '货道错位传感器触发(warn)',
|
||||
tenantName: '广州智养餐饮有限公司', siteName: '广州天河 7号净菜柜',
|
||||
handleSuggestion: '现场复位货道 5 传感器,重新校准后完成巡检',
|
||||
errorLog: `[2026-07-02 16:30:22.571] WARN [inspection-task-1] c.yx.device.inspection.InspectionExecutor -
|
||||
>>> 巡检异常:货道 5 错位(laneOffset=12mm,阈值=5mm)
|
||||
at com.yx.device.inspection.InspectionExecutor.checkLane(InspectionExecutor.java:118)
|
||||
at com.yx.device.inspection.InspectionExecutor.checkAllLanes(InspectionExecutor.java:64)
|
||||
at com.yx.device.inspection.InspectionTask.run(InspectionTask.java:35)
|
||||
[2026-07-02 16:30:23.104] INFO [inspection-task-1] c.yx.device.inspection.InspectionExecutor -
|
||||
巡检任务 INS20260702001 完成,异常项=1,已自动生成巡检异常记录
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-004', alarmCode: 'ALM20260702015', source: 'sla', level: 'critical',
|
||||
deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)',
|
||||
deviceCode: 'CHECK-SZ-003', deviceName: '3号体检一体机(深圳南山)', deviceCategory: '智能健康一体机',
|
||||
content: 'SLA 超时:连续 3 次心跳未上报,已离线 25 分钟',
|
||||
triggerTime: '2026-07-02 14:05:09', status: 'ignored', handler: '赵工程师',
|
||||
triggerRule: '心跳连续缺失次数 ≥ 3(critical)',
|
||||
tenantName: '深圳南山区医疗集团', siteName: '深圳南山 3号体检一体机',
|
||||
handleSuggestion: '现场排查网络与电源;如设备损坏需走维修流程',
|
||||
errorLog: `[2026-07-02 14:05:09.821] ERROR [sla-monitor-2] c.yx.device.sla.SlaMonitor -
|
||||
>>> SLA 超时:设备 CHECK-SZ-003 已离线 25 分钟(连续 3 次心跳未上报)
|
||||
at com.yx.device.sla.SlaMonitor.checkHeartbeat(SlaMonitor.java:92)
|
||||
at com.yx.device.sla.SlaMonitor.scan(SlaMonitor.java:54)
|
||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
|
||||
[2026-07-02 13:40:09.103] WARN [sla-monitor-2] c.yx.device.sla.SlaMonitor -
|
||||
设备 CHECK-SZ-003 心跳缺失(第 1 次),最后心跳时间=2026-07-02 13:39:45
|
||||
[2026-07-02 13:50:09.105] WARN [sla-monitor-2] c.yx.device.sla.SlaMonitor -
|
||||
设备 CHECK-SZ-003 心跳缺失(第 2 次),最后心跳时间=2026-07-02 13:39:45
|
||||
[2026-07-02 14:00:09.107] WARN [sla-monitor-2] c.yx.device.sla.SlaMonitor -
|
||||
设备 CHECK-SZ-003 心跳缺失(第 3 次),最后心跳时间=2026-07-02 13:39:45
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-005', alarmCode: 'ALM20260702009', source: 'dataDistribute', level: 'warn',
|
||||
deviceCode: 'CAB-SH-002', deviceName: '2号净菜柜(上海闵行)',
|
||||
deviceCode: 'CAB-SH-002', deviceName: '2号净菜柜(上海闵行)', deviceCategory: '智能货柜',
|
||||
content: '数据分发失败:营养系统订阅通道 4 次重试失败',
|
||||
triggerTime: '2026-07-02 11:48:33', status: 'workorder', handler: '李工程师',
|
||||
triggerRule: '分发重试次数 ≥ 4(warn)',
|
||||
tenantName: '上海智养医疗科技有限公司', siteName: '上海闵行 2号净菜柜',
|
||||
handleSuggestion: '检查营养系统订阅服务可用性;必要时人工补偿数据',
|
||||
errorLog: `[2026-07-02 11:48:33.412] WARN [distribute-worker-7] c.yx.device.distribute.DataDistributeService -
|
||||
>>> 数据分发失败:目标=营养系统订阅通道,重试次数=4/4(已达上限)
|
||||
at com.yx.device.distribute.DataDistributeService.publish(DataDistributeService.java:156)
|
||||
at com.yx.device.distribute.DataDistributeService.handle(DataDistributeService.java:73)
|
||||
at com.yx.device.distribute.DataDistributeScheduler$Job.run(DataDistributeScheduler.java:42)
|
||||
Caused by: com.yx.device.distribute.SubscribeChannelException: 订阅通道连接被拒绝(connection refused)
|
||||
at com.yx.device.distribute.SubscribeClient.connect(SubscribeClient.java:88)
|
||||
at com.yx.device.distribute.SubscribeClient.publish(SubscribeClient.java:54)
|
||||
at com.yx.device.distribute.DataDistributeService.publish(DataDistributeService.java:148)
|
||||
... 2 more
|
||||
[2026-07-02 11:48:33.412] INFO [distribute-worker-7] c.yx.device.distribute.DataDistributeService -
|
||||
分发任务已转入死信队列,任务ID=DT-20260702-114833-002
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-006', alarmCode: 'ALM20260701022', source: 'runLog', level: 'info',
|
||||
deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)',
|
||||
deviceCode: 'BODYFAT-HZ-005', deviceName: '5号体脂仪(杭州西湖)', deviceCategory: '体脂仪',
|
||||
content: '运行日志:电池电量低于 15%,建议安排更换',
|
||||
triggerTime: '2026-07-01 17:20:00', status: 'ignored', handler: null,
|
||||
triggerRule: '电池电量 < 15%(info)',
|
||||
tenantName: '杭州西湖区医疗中心', siteName: '杭州西湖 5号体脂仪',
|
||||
handleSuggestion: '安排运维人员更换电池',
|
||||
errorLog: `[2026-07-01 17:20:00.000] INFO [run-log-collector-1] c.yx.device.runlog.RunLogCollector -
|
||||
>>> 运行日志:设备 BODYFAT-HZ-005 电池电量=12%(阈值=15%)
|
||||
at com.yx.device.runlog.RunLogCollector.collectBattery(RunLogCollector.java:64)
|
||||
at com.yx.device.runlog.RunLogCollector.collect(RunLogCollector.java:38)
|
||||
at com.yx.device.runlog.RunLogScheduler$ScheduledJob.run(RunLogScheduler.java:29)
|
||||
[2026-07-01 17:20:00.000] INFO [run-log-collector-1] c.yx.device.runlog.RunLogCollector -
|
||||
建议处理动作:通知运维人员更换电池
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-007', alarmCode: 'ALM20260701008', source: 'selfCheck', level: 'warn',
|
||||
deviceCode: 'CAB-BJ-003', deviceName: '3号净菜柜(北京海淀)',
|
||||
deviceCode: 'CAB-BJ-003', deviceName: '3号净菜柜(北京海淀)', deviceCategory: '智能货柜',
|
||||
content: '自检告警:温度传感器读数漂移 2.4℃,建议校准',
|
||||
triggerTime: '2026-07-01 09:15:42', status: 'workorder', handler: '王工程师',
|
||||
triggerRule: '温度传感器读数漂移 ≥ 2℃(warn)',
|
||||
tenantName: '北京海淀餐饮集团', siteName: '北京海淀 3号净菜柜',
|
||||
handleSuggestion: '现场校准温度传感器;如漂移持续需更换传感器',
|
||||
errorLog: `[2026-07-01 09:15:42.221] WARN [self-check-thread-1] c.yx.device.SelfCheckTask -
|
||||
>>> 自检告警:温度传感器读数漂移(drift=2.4℃,阈值=2.0℃)
|
||||
at com.yx.device.SelfCheckTask.checkTempSensor(SelfCheckTask.java:176)
|
||||
at com.yx.device.SelfCheckTask.run(SelfCheckTask.java:85)
|
||||
at java.base/java.util.concurrent.Executors$RunnableAdapter.call(Executors.java:572)
|
||||
[2026-07-01 09:15:42.221] INFO [self-check-thread-1] c.yx.device.SelfCheckTask -
|
||||
温度传感器读数:currentValue=-3.2℃,standardValue=-0.8℃
|
||||
`,
|
||||
},
|
||||
{
|
||||
id: 'alm-008', alarmCode: 'ALM20260630031', source: 'ota', level: 'critical',
|
||||
deviceCode: 'CHECK-SH-008', deviceName: '8号体检一体机(上海徐汇)',
|
||||
deviceCode: 'CHECK-SH-008', deviceName: '8号体检一体机(上海徐汇)', deviceCategory: '智能健康一体机',
|
||||
content: 'OTA 升级失败:固件签名校验未通过,已拦截',
|
||||
triggerTime: '2026-06-30 22:08:55', status: 'processing', handler: '赵工程师',
|
||||
triggerRule: 'OTA 固件签名校验失败(critical)',
|
||||
tenantName: '上海徐汇区医疗集团', siteName: '上海徐汇 8号体检一体机',
|
||||
handleSuggestion: '联系固件提供方核对签名公钥;确认固件完整性后重新推送',
|
||||
errorLog: `[2026-06-30 22:08:55.642] ERROR [ota-push-worker-2] c.yx.device.ota.OtaPushService -
|
||||
>>> OTA 升级失败:设备 CHECK-SH-008 固件签名校验未通过,已拦截升级
|
||||
at com.yx.device.ota.FirmwareVerifier.verify(FirmwareVerifier.java:81)
|
||||
at com.yx.device.ota.OtaPushService.executeUpgrade(OtaPushService.java:198)
|
||||
at com.yx.device.ota.OtaPushService.handle(OtaPushService.java:96)
|
||||
Caused by: java.security.SignatureException: Signature does not match
|
||||
at java.base/java.security.Signature.verify(Signature.java:610)
|
||||
at com.yx.device.ota.FirmwareVerifier.verify(FirmwareVerifier.java:75)
|
||||
... 2 more
|
||||
[2026-06-30 22:08:55.642] WARN [ota-push-worker-2] c.yx.device.ota.OtaPushService -
|
||||
已自动拦截升级任务,原版本=v1.1.0,目标版本=v1.2.0
|
||||
`,
|
||||
},
|
||||
]
|
||||
|
||||
const logList: BusinessLogRecord[] = [
|
||||
{
|
||||
id: 'log-001', logType: 'operate', siteName: '上海浦东 1号净菜柜',
|
||||
module: '工单管理', content: '创建工单 WO20260703001(基于告警 ALM20260703001)',
|
||||
operator: '李工程师', operateTime: '2026-07-03 09:30:14',
|
||||
},
|
||||
{
|
||||
id: 'log-002', logType: 'system', siteName: '全局',
|
||||
module: '设备初始化', content: '设备 CAB-SH-001 完成证书烧录,进入待出库状态',
|
||||
operator: '系统', operateTime: '2026-07-03 08:50:01',
|
||||
},
|
||||
{
|
||||
id: 'log-003', logType: 'audit', siteName: '北京朝阳 14号健康手表',
|
||||
module: '告警管理', content: '用户 [李工程师] 处理告警 ALM20260703002:标记为处理中',
|
||||
operator: '李工程师', operateTime: '2026-07-03 10:15:00',
|
||||
},
|
||||
{
|
||||
id: 'log-004', logType: 'data', siteName: '广州天河 7号净菜柜',
|
||||
module: '数据分发', content: '推送销售数据到营养系统成功(耗时 220ms)',
|
||||
operator: '系统', operateTime: '2026-07-02 18:00:11',
|
||||
},
|
||||
{
|
||||
id: 'log-005', logType: 'operate', siteName: '深圳南山 3号体检一体机',
|
||||
module: '版本管理', content: '固件版本 V3.0.0 灰度推送至 30% 设备',
|
||||
operator: '赵工程师', operateTime: '2026-07-02 15:42:30',
|
||||
},
|
||||
{
|
||||
id: 'log-006', logType: 'audit', siteName: '上海闵行 2号净菜柜',
|
||||
module: '告警管理', content: '用户 [王工程师] 转工单 ALM20260702009',
|
||||
operator: '王工程师', operateTime: '2026-07-02 14:00:55',
|
||||
},
|
||||
{
|
||||
id: 'log-007', logType: 'system', siteName: '全局',
|
||||
module: '设备监测', content: '触发巡检任务 INS20260702001(覆盖上海区域 12 台设备)',
|
||||
operator: '系统', operateTime: '2026-07-02 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'log-008', logType: 'operate', siteName: '杭州西湖 5号体脂仪',
|
||||
module: '告警管理', content: '用户 [孙工程师] 忽略告警 ALM20260701022',
|
||||
operator: '孙工程师', operateTime: '2026-07-01 17:35:20',
|
||||
},
|
||||
const CATEGORY_OPTIONS: { name: string }[] = [
|
||||
{ name: '智能货柜' },
|
||||
{ name: '智能手表' },
|
||||
{ name: '智能健康一体机' },
|
||||
{ name: '体脂仪' },
|
||||
{ name: '蓝牙秤' },
|
||||
{ name: '健身车' },
|
||||
{ name: '带鱼屏' },
|
||||
{ name: '入库秤' },
|
||||
]
|
||||
|
||||
const listAlarm = (ctx: MockContext): ApiResponse<AlarmRecord[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = alarmList.filter((item) => {
|
||||
if (params.keyword) {
|
||||
const kw = params.keyword
|
||||
if (!includes(item.alarmCode, kw) && !includes(item.deviceCode, kw) &&
|
||||
!includes(item.deviceName, kw) && !includes(item.content, kw)) return false
|
||||
}
|
||||
if (params.deviceCode && !item.deviceCode.toLowerCase().includes(params.deviceCode.toLowerCase())) return false
|
||||
if (params.deviceCategory && item.deviceCategory !== params.deviceCategory) return false
|
||||
if (params.source && item.source !== params.source) return false
|
||||
if (params.level && item.level !== params.level) return false
|
||||
if (params.status && item.status !== params.status) return false
|
||||
@@ -169,6 +239,13 @@ const statsAlarm = (): ApiResponse<{ todayNew: number; pending: number; critical
|
||||
return ok('查询成功', { todayNew: 2, pending: 3, critical: 2, weekTrend: 11 })
|
||||
}
|
||||
|
||||
const infoAlarm = (ctx: MockContext): ApiResponse<AlarmRecord | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = alarmList.find((i) => i.id === String(params.id ?? ''))
|
||||
if (!target) return ok('告警不存在')
|
||||
return ok('查询成功', target)
|
||||
}
|
||||
|
||||
const toWorkOrder = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = alarmList.find((i) => i.id === String(params.id ?? ''))
|
||||
@@ -204,25 +281,14 @@ const batchHandle = (ctx: MockContext): ApiResponse<null> => {
|
||||
return ok(type === 'workorder' ? '批量转工单成功' : '批量忽略成功')
|
||||
}
|
||||
|
||||
const listBusinessLog = (ctx: MockContext): ApiResponse<BusinessLogRecord[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = logList.filter((item) => {
|
||||
if (params.keyword) {
|
||||
const kw = params.keyword
|
||||
if (!includes(item.module, kw) && !includes(item.content, kw)) return false
|
||||
}
|
||||
if (params.logType && item.logType !== params.logType) return false
|
||||
if (params.siteName && !includes(item.siteName, params.siteName)) return false
|
||||
return true
|
||||
})
|
||||
return ok('查询成功', filtered)
|
||||
}
|
||||
const categoryOpts = (): ApiResponse<{ name: string }[]> => ok('查询成功', CATEGORY_OPTIONS)
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/list', handler: listAlarm },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/stats', handler: statsAlarm },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/info', handler: infoAlarm },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/to-work-order', handler: toWorkOrder },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/ignore', handler: ignoreAlarm },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/batch', handler: batchHandle },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/log-list', handler: listBusinessLog },
|
||||
{ method: 'POST', url: '/admin/device/ops/alarm/category/options', handler: categoryOpts },
|
||||
])
|
||||
|
||||
@@ -1,203 +0,0 @@
|
||||
import { registerMocks } from '@axios/mockBus'
|
||||
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 设备运维 / 系统配置 mock
|
||||
* - 5 大分组的全局参数(推送通道 / 限流 / 巡检自检 / SLA / OTA)
|
||||
* - 编辑配置(写历史)
|
||||
* - 变更历史列表
|
||||
*/
|
||||
|
||||
type ConfigGroup = 'push' | 'rateLimit' | 'inspect' | 'sla' | 'ota'
|
||||
type ConfigValueType = 'number' | 'string' | 'boolean' | 'json'
|
||||
|
||||
interface ConfigRecord {
|
||||
id: string
|
||||
group: ConfigGroup
|
||||
configKey: string
|
||||
configName: string
|
||||
configValue: string
|
||||
valueType: ConfigValueType
|
||||
unit?: string
|
||||
description: string
|
||||
updater: string
|
||||
updateTime: string
|
||||
}
|
||||
|
||||
interface HistoryRecord {
|
||||
id: string
|
||||
configId: string
|
||||
oldValue: string
|
||||
newValue: string
|
||||
operator: string
|
||||
operateTime: string
|
||||
reason: string
|
||||
}
|
||||
|
||||
interface ConfigParams {
|
||||
keyword?: string
|
||||
group?: string
|
||||
id?: string
|
||||
configId?: string
|
||||
configValue?: string
|
||||
reason?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
const getParams = (params: RequestParameter): ConfigParams => (params ?? {}) as ConfigParams
|
||||
const includes = (value: unknown, keyword: unknown): boolean => {
|
||||
if (!keyword) return true
|
||||
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
|
||||
}
|
||||
const now = (): string => new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
|
||||
const configList: ConfigRecord[] = [
|
||||
// ===== 推送通道 =====
|
||||
{
|
||||
id: 'cfg-push-01', group: 'push', configKey: 'push.callback.timeout',
|
||||
configName: '回调超时时间', configValue: '5', valueType: 'number', unit: '秒',
|
||||
description: 'HTTP 回调等待响应的最大时长,超过则判定为推送失败并重试',
|
||||
updater: '李工程师', updateTime: '2026-06-22 15:30:00',
|
||||
},
|
||||
{
|
||||
id: 'cfg-push-02', group: 'push', configKey: 'push.callback.retry',
|
||||
configName: '回调重试次数', configValue: '4', valueType: 'number', unit: '次',
|
||||
description: '推送失败后最大重试次数(指数退避:5s / 30s / 2min / 10min)',
|
||||
updater: '李工程师', updateTime: '2026-06-22 15:30:00',
|
||||
},
|
||||
{
|
||||
id: 'cfg-push-03', group: 'push', configKey: 'push.callback.concurrency',
|
||||
configName: '回调并发数', configValue: '20', valueType: 'number', unit: '路',
|
||||
description: '推送通道全局并发请求数,超过将排队等待',
|
||||
updater: '李工程师', updateTime: '2026-06-22 15:30:00',
|
||||
},
|
||||
// ===== 限流 / 黑白名单 =====
|
||||
{
|
||||
id: 'cfg-rl-01', group: 'rateLimit', configKey: 'rateLimit.qps',
|
||||
configName: '全局限流 QPS', configValue: '500', valueType: 'number', unit: '次/秒',
|
||||
description: '租户调用设备数据查询接口的全局 QPS 上限',
|
||||
updater: '赵工程师', updateTime: '2026-06-15 10:20:00',
|
||||
},
|
||||
{
|
||||
id: 'cfg-rl-02', group: 'rateLimit', configKey: 'rateLimit.ip.whitelist',
|
||||
configName: 'IP 白名单', configValue: '10.0.0.0/8,172.16.0.0/12', valueType: 'string',
|
||||
description: '设备数据查询接口的 IP 白名单(CIDR 逗号分隔)',
|
||||
updater: '赵工程师', updateTime: '2026-06-15 10:20:00',
|
||||
},
|
||||
// ===== 巡检 / 自检 =====
|
||||
{
|
||||
id: 'cfg-ins-01', group: 'inspect', configKey: 'inspection.defaultPeriod',
|
||||
configName: '巡检默认周期', configValue: '24', valueType: 'number', unit: '小时',
|
||||
description: '新建设备的默认巡检周期,超过未上报视为巡检异常',
|
||||
updater: '王工程师', updateTime: '2026-06-10 14:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cfg-ins-02', group: 'inspect', configKey: 'selfCheck.failThreshold',
|
||||
configName: '自检告警阈值', configValue: '2', valueType: 'number', unit: '次',
|
||||
description: '设备连续自检失败次数达到阈值即产生告警',
|
||||
updater: '王工程师', updateTime: '2026-06-10 14:00:00',
|
||||
},
|
||||
// ===== SLA =====
|
||||
{
|
||||
id: 'cfg-sla-01', group: 'sla', configKey: 'sla.urgent.responseMinutes',
|
||||
configName: '紧急工单响应时长', configValue: '30', valueType: 'number', unit: '分钟',
|
||||
description: '紧急级别工单必须在此时长内有人接单',
|
||||
updater: '孙工程师', updateTime: '2026-05-28 09:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cfg-sla-02', group: 'sla', configKey: 'sla.urgent.resolveHours',
|
||||
configName: '紧急工单处理时长', configValue: '4', valueType: 'number', unit: '小时',
|
||||
description: '紧急级别工单从接单到完成的处理时长上限',
|
||||
updater: '孙工程师', updateTime: '2026-05-28 09:00:00',
|
||||
},
|
||||
// ===== OTA =====
|
||||
{
|
||||
id: 'cfg-ota-01', group: 'ota', configKey: 'ota.defaultGrayPercent',
|
||||
configName: '默认灰度比例', configValue: '10', valueType: 'number', unit: '%',
|
||||
description: '新建版本默认的灰度推送比例,可后续逐级提升',
|
||||
updater: '李工程师', updateTime: '2026-06-25 17:00:00',
|
||||
},
|
||||
{
|
||||
id: 'cfg-ota-02', group: 'ota', configKey: 'ota.rollback.failRate',
|
||||
configName: 'OTA 自动回滚阈值', configValue: '5', valueType: 'number', unit: '%',
|
||||
description: '灰度阶段失败率超过此阈值将自动触发回滚',
|
||||
updater: '李工程师', updateTime: '2026-06-25 17:00:00',
|
||||
},
|
||||
]
|
||||
|
||||
const historyList: HistoryRecord[] = [
|
||||
{
|
||||
id: 'his-001', configId: 'cfg-push-01', oldValue: '3', newValue: '5',
|
||||
operator: '李工程师', operateTime: '2026-06-22 15:30:00',
|
||||
reason: '部分下游系统回调耗时较高,超时时间从 3 秒调整为 5 秒以降低误报',
|
||||
},
|
||||
{
|
||||
id: 'his-002', configId: 'cfg-push-02', oldValue: '3', newValue: '4',
|
||||
operator: '李工程师', operateTime: '2026-06-22 15:30:00',
|
||||
reason: '配合超时调整,将重试次数从 3 次提升到 4 次以提高送达率',
|
||||
},
|
||||
{
|
||||
id: 'his-003', configId: 'cfg-ota-01', oldValue: '5', newValue: '10',
|
||||
operator: '李工程师', operateTime: '2026-06-25 17:00:00',
|
||||
reason: 'V1.2.0 灰度验证通过,将默认灰度比例从 5% 提升至 10% 加快推广',
|
||||
},
|
||||
{
|
||||
id: 'his-004', configId: 'cfg-sla-01', oldValue: '60', newValue: '30',
|
||||
operator: '孙工程师', operateTime: '2026-05-28 09:00:00',
|
||||
reason: '紧急工单响应时长从 60 分钟缩短到 30 分钟,对齐 SLA 协议升级',
|
||||
},
|
||||
]
|
||||
|
||||
const listConfig = (ctx: MockContext): ApiResponse<ConfigRecord[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = configList.filter((item) => {
|
||||
if (params.keyword) {
|
||||
const kw = params.keyword
|
||||
if (!includes(item.configKey, kw) && !includes(item.configName, kw) && !includes(item.description, kw)) return false
|
||||
}
|
||||
if (params.group && item.group !== params.group) return false
|
||||
return true
|
||||
})
|
||||
return ok('查询成功', filtered)
|
||||
}
|
||||
|
||||
const infoConfig = (ctx: MockContext): ApiResponse<ConfigRecord | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
return ok('查询成功', configList.find((i) => i.id === params.id) ?? null)
|
||||
}
|
||||
|
||||
const updateConfig = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = configList.find((i) => i.id === String(params.id ?? ''))
|
||||
if (!target) return ok('配置不存在')
|
||||
const oldValue = target.configValue
|
||||
const newValue = String(params.configValue ?? '')
|
||||
if (oldValue === newValue) return ok('配置值未变化')
|
||||
target.configValue = newValue
|
||||
target.updater = '当前用户'
|
||||
target.updateTime = now()
|
||||
historyList.unshift({
|
||||
id: `his-${String(historyList.length + 1).padStart(3, '0')}`,
|
||||
configId: target.id,
|
||||
oldValue,
|
||||
newValue,
|
||||
operator: '当前用户',
|
||||
operateTime: now(),
|
||||
reason: String(params.reason ?? ''),
|
||||
})
|
||||
return ok('配置已更新')
|
||||
}
|
||||
|
||||
const listHistory = (ctx: MockContext): ApiResponse<HistoryRecord[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = historyList.filter((i) => i.configId === String(params.configId ?? ''))
|
||||
return ok('查询成功', filtered)
|
||||
}
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/ops/config/list', handler: listConfig },
|
||||
{ method: 'POST', url: '/admin/device/ops/config/info', handler: infoConfig },
|
||||
{ method: 'POST', url: '/admin/device/ops/config/update', handler: updateConfig },
|
||||
{ method: 'POST', url: '/admin/device/ops/config/history', handler: listHistory },
|
||||
])
|
||||
@@ -1,287 +1,378 @@
|
||||
/**
|
||||
* 设备运维 / 初始化设置 mock
|
||||
*
|
||||
* 业务定位:IOT 设备出库投用前的软件版本设置
|
||||
* - list 列表查询(设备视角)
|
||||
* - info 详情(含类别 / 数据分发 / 软件版本详情)
|
||||
* - set-version / batch-set-version 版本设置
|
||||
* - export 异步导出
|
||||
* - app/version/category/customer/site 下拉
|
||||
*/
|
||||
import { registerMocks } from '@axios/mockBus'
|
||||
import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
import type {
|
||||
AppOption,
|
||||
CustomerOption,
|
||||
DeviceCategoryOption,
|
||||
InitDetail,
|
||||
InitItem,
|
||||
SiteOption,
|
||||
VersionOption,
|
||||
} from '../../../pages/admin-portal/device-center/ops/init/types'
|
||||
|
||||
/**
|
||||
* 设备运维 / 设备初始化 mock
|
||||
* - 出库装机前的激活 / 烧录证书 / 预置场所配置 / 固件基线
|
||||
*/
|
||||
const BASE = '/admin/device/ops/init'
|
||||
|
||||
interface InitRecord {
|
||||
id: string
|
||||
taskCode: string
|
||||
deviceSn: string
|
||||
deviceName: string
|
||||
deviceCategory: 'weightScale' | 'bodyFat' | 'watch' | 'healthCheck' | 'cabinet' | 'other'
|
||||
targetSite: string
|
||||
baselineFw: string
|
||||
baselineApp: string
|
||||
activateCode: string
|
||||
status: 'pending' | 'processing' | 'success' | 'failed'
|
||||
operator: string
|
||||
initTime: string | null
|
||||
failReason?: string
|
||||
outboundOrderNo?: string
|
||||
createTime: string
|
||||
certPayload: string
|
||||
siteConfig: string
|
||||
strategy: string
|
||||
steps: { id: string; stepName: string; status: 'pending' | 'processing' | 'success' | 'failed'; costSeconds: number; finishTime: string | null; remark?: string }[]
|
||||
}
|
||||
|
||||
interface InitParams {
|
||||
type ListParams = {
|
||||
keyword?: string
|
||||
deviceSn?: string
|
||||
deviceCode?: string
|
||||
deviceCategory?: string
|
||||
status?: string
|
||||
id?: string
|
||||
[key: string]: unknown
|
||||
initStatus?: string
|
||||
deployStatus?: string
|
||||
customerId?: string
|
||||
siteId?: string
|
||||
outboundOrderNo?: string
|
||||
}
|
||||
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
const getParams = (params: RequestParameter): InitParams => (params ?? {}) as InitParams
|
||||
const includes = (value: unknown, keyword: unknown): boolean => {
|
||||
if (!keyword) return true
|
||||
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
|
||||
}
|
||||
const getParams = <T = ListParams>(params: RequestParameter): T => (params ?? {}) as T
|
||||
|
||||
const initList: InitRecord[] = [
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({
|
||||
code: '00000', msg, data,
|
||||
})
|
||||
|
||||
/** 应用名称 mock */
|
||||
const APP_DATASET: AppOption[] = [
|
||||
{ id: 'app-001', name: '智养秤终端 App', description: '体重秤/体脂仪终端 App' },
|
||||
{ id: 'app-002', name: '智能手表 App', description: '智能手表终端 App' },
|
||||
{ id: 'app-003', name: '体检一体机 App', description: '体检一体机终端 App' },
|
||||
{ id: 'app-004', name: '净菜柜固件', description: '净菜柜屏固件 App' },
|
||||
]
|
||||
|
||||
/** 版本号 mock(按应用联动) */
|
||||
const VERSION_DATASET: VersionOption[] = [
|
||||
{ id: 'ver-001', appId: 'app-001', version: 'V1.2.0', description: '体重秤/体脂仪终端 App', upgradePoints: '修复数据上报丢包问题', packageUrl: 'https://mock.example.com/pkg/app-001/v1.2.0.bin' },
|
||||
{ id: 'ver-002', appId: 'app-001', version: 'V1.3.0', description: '体重秤/体脂仪终端 App', upgradePoints: '新增蓝牙配网引导流程', packageUrl: 'https://mock.example.com/pkg/app-001/v1.3.0.bin' },
|
||||
{ id: 'ver-003', appId: 'app-002', version: 'V2.0.1', description: '智能手表终端 App', upgradePoints: '心率异常阈值调整', packageUrl: 'https://mock.example.com/pkg/app-002/v2.0.1.bin' },
|
||||
{ id: 'ver-004', appId: 'app-002', version: 'V2.1.0', description: '智能手表终端 App', upgradePoints: '新增 OTA 静默升级模式', packageUrl: 'https://mock.example.com/pkg/app-002/v2.1.0.bin' },
|
||||
{ id: 'ver-005', appId: 'app-003', version: 'V3.0.0', description: '体检一体机终端 App', upgradePoints: '新增身份识别多模态', packageUrl: 'https://mock.example.com/pkg/app-003/v3.0.0.bin' },
|
||||
{ id: 'ver-006', appId: 'app-004', version: 'V0.9.5', description: '净菜柜屏固件 App', upgradePoints: '柜门传感器识别优化', packageUrl: 'https://mock.example.com/pkg/app-004/v0.9.5.bin' },
|
||||
]
|
||||
|
||||
/** 设备类别 mock(二级分类名) */
|
||||
const CATEGORY_DATASET: DeviceCategoryOption[] = [
|
||||
{ name: '体重秤' },
|
||||
{ name: '体脂仪' },
|
||||
{ name: '智能手表' },
|
||||
{ name: '体检一体机' },
|
||||
{ name: '净菜柜' },
|
||||
]
|
||||
|
||||
/** 客户 mock */
|
||||
const CUSTOMER_DATASET: CustomerOption[] = [
|
||||
{ id: 'cus-001', name: '阳光养老院' },
|
||||
{ id: 'cus-002', name: '康乐社区卫生服务中心' },
|
||||
{ id: 'cus-003', name: '福寿康养集团' },
|
||||
]
|
||||
|
||||
/** 场所 mock(按客户联动) */
|
||||
const SITE_DATASET: SiteOption[] = [
|
||||
{ id: 'site-001', name: '阳光养老院 · 1号楼', customerId: 'cus-001' },
|
||||
{ id: 'site-002', name: '阳光养老院 · 食堂', customerId: 'cus-001' },
|
||||
{ id: 'site-003', name: '康乐社区 · 健康小屋', customerId: 'cus-002' },
|
||||
{ id: 'site-004', name: '福寿康养 · 总部', customerId: 'cus-003' },
|
||||
]
|
||||
|
||||
/** 初始化设备 mock dataset(8 条,符合原型展示上限) */
|
||||
const INIT_DATASET: InitItem[] = [
|
||||
{
|
||||
id: 'init-001', taskCode: 'INIT-20260620-001', deviceSn: 'CAB-A1-0001', deviceName: '总部食堂货柜A1',
|
||||
deviceCategory: 'cabinet', targetSite: '长庆总部一食堂', baselineFw: 'V1.2.0', baselineApp: 'V1.0.5',
|
||||
activateCode: 'ACT-20260620-A1B2C3D4', status: 'success', operator: '李工程师', initTime: '2026-06-20 14:30:00',
|
||||
outboundOrderNo: 'OUT-20260620-001', createTime: '2026-06-20 13:45:12',
|
||||
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
|
||||
siteConfig: '{"siteCode":"CQ-HQ-CANTEEN-01","mqTopic":"device.cabinet","heartbeat":30}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 12, finishTime: '2026-06-20 14:25:30' },
|
||||
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-20 14:25:34' },
|
||||
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 180, finishTime: '2026-06-20 14:28:34' },
|
||||
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 90, finishTime: '2026-06-20 14:30:04' },
|
||||
],
|
||||
id: 'dev-001',
|
||||
deviceCode: 'WT-2025-0001',
|
||||
deviceCategory: '体重秤',
|
||||
deviceName: '智养体重秤',
|
||||
serialNumber: 'SN-WT-2025-0001',
|
||||
initStatus: 'pending',
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
deployStatus: 'in_stock',
|
||||
customerId: 'cus-001',
|
||||
customerName: '阳光养老院',
|
||||
siteId: 'site-001',
|
||||
siteName: '阳光养老院 · 1号楼',
|
||||
dataTransit: true,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250701-001',
|
||||
onlineTime: '2025-07-01 09:30:00',
|
||||
deployTime: '',
|
||||
initTime: '',
|
||||
},
|
||||
{
|
||||
id: 'init-002', taskCode: 'INIT-20260622-002', deviceSn: 'WAT-W1-0001', deviceName: '健康站01手表',
|
||||
deviceCategory: 'watch', targetSite: '健康园区体检站', baselineFw: 'V2.0.1', baselineApp: 'V1.3.0',
|
||||
activateCode: 'ACT-20260622-E5F6G7H8', status: 'failed', operator: '王工程师', initTime: null,
|
||||
failReason: '固件烧录超时,设备未在 180s 内完成 OTA', outboundOrderNo: 'OUT-20260622-002', createTime: '2026-06-22 09:10:00',
|
||||
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
|
||||
siteConfig: '{"siteCode":"HP-CHKUP-01","mqTopic":"device.watch","heartbeat":60}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 10, finishTime: '2026-06-22 09:15:00' },
|
||||
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 3, finishTime: '2026-06-22 09:15:03' },
|
||||
{ id: 's3', stepName: '固件基线烧录', status: 'failed', costSeconds: 180, finishTime: null, remark: 'OTA 超时' },
|
||||
],
|
||||
id: 'dev-002',
|
||||
deviceCode: 'BF-2025-0002',
|
||||
deviceCategory: '体脂仪',
|
||||
deviceName: '智养体脂仪',
|
||||
serialNumber: 'SN-BF-2025-0002',
|
||||
initStatus: 'success',
|
||||
packageName: '智养秤终端 App',
|
||||
softwareVersion: 'V1.3.0',
|
||||
deployStatus: 'deployed',
|
||||
customerId: 'cus-001',
|
||||
customerName: '阳光养老院',
|
||||
siteId: 'site-002',
|
||||
siteName: '阳光养老院 · 食堂',
|
||||
dataTransit: true,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250701-001',
|
||||
onlineTime: '2025-07-01 10:15:00',
|
||||
deployTime: '2025-07-02 14:00:00',
|
||||
initTime: '2025-07-02 13:50:00',
|
||||
},
|
||||
{
|
||||
id: 'init-003', taskCode: 'INIT-20260624-003', deviceSn: 'CHK-M1-0001', deviceName: '体检站一体机',
|
||||
deviceCategory: 'healthCheck', targetSite: '东区体检站', baselineFw: 'V3.0.0', baselineApp: 'V2.0.0',
|
||||
activateCode: 'ACT-20260624-I9J0K1L2', status: 'processing', operator: '赵工程师', initTime: null,
|
||||
outboundOrderNo: 'OUT-20260624-003', createTime: '2026-06-24 10:00:00',
|
||||
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
|
||||
siteConfig: '{"siteCode":"HP-CHKUP-01","mqTopic":"device.healthcheck","heartbeat":120}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 11, finishTime: '2026-06-24 10:05:00' },
|
||||
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-24 10:05:04' },
|
||||
{ id: 's3', stepName: '固件基线烧录', status: 'processing', costSeconds: 90, finishTime: null, remark: '升级中…' },
|
||||
],
|
||||
id: 'dev-003',
|
||||
deviceCode: 'WT-2025-0003',
|
||||
deviceCategory: '智能手表',
|
||||
deviceName: '智养手表',
|
||||
serialNumber: 'SN-WT-2025-0003',
|
||||
initStatus: 'pending',
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
deployStatus: 'in_stock',
|
||||
customerId: 'cus-002',
|
||||
customerName: '康乐社区卫生服务中心',
|
||||
siteId: 'site-003',
|
||||
siteName: '康乐社区 · 健康小屋',
|
||||
dataTransit: false,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250703-008',
|
||||
onlineTime: '2025-07-03 11:00:00',
|
||||
deployTime: '',
|
||||
initTime: '',
|
||||
},
|
||||
{
|
||||
id: 'init-004', taskCode: 'INIT-20260625-004', deviceSn: 'FAT-INBODY-0001', deviceName: '体检站体脂秤',
|
||||
deviceCategory: 'bodyFat', targetSite: '东区体检站', baselineFw: 'V1.5.0', baselineApp: 'V1.2.0',
|
||||
activateCode: 'ACT-20260625-M3N4O5P6', status: 'success', operator: '孙工程师', initTime: '2026-06-25 16:00:00',
|
||||
createTime: '2026-06-25 15:30:00',
|
||||
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
|
||||
siteConfig: '{"siteCode":"HP-CHKUP-01","mqTopic":"device.bodyfat","heartbeat":60}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 9, finishTime: '2026-06-25 15:55:00' },
|
||||
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 3, finishTime: '2026-06-25 15:55:03' },
|
||||
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 150, finishTime: '2026-06-25 15:57:33' },
|
||||
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 80, finishTime: '2026-06-25 15:58:53' },
|
||||
],
|
||||
id: 'dev-004',
|
||||
deviceCode: 'HC-2025-0004',
|
||||
deviceCategory: '体检一体机',
|
||||
deviceName: '智养体检一体机',
|
||||
serialNumber: 'SN-HC-2025-0004',
|
||||
initStatus: 'pending',
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
deployStatus: 'in_stock',
|
||||
customerId: 'cus-003',
|
||||
customerName: '福寿康养集团',
|
||||
siteId: 'site-004',
|
||||
siteName: '福寿康养 · 总部',
|
||||
dataTransit: true,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250705-012',
|
||||
onlineTime: '2025-07-05 16:20:00',
|
||||
deployTime: '',
|
||||
initTime: '',
|
||||
},
|
||||
{
|
||||
id: 'init-005', taskCode: 'INIT-20260626-005', deviceSn: 'SCL-W-0001', deviceName: '健康站体重秤',
|
||||
deviceCategory: 'weightScale', targetSite: '健康园区体检站', baselineFw: 'V1.0.3', baselineApp: 'V1.0.0',
|
||||
activateCode: 'ACT-20260626-Q7R8S9T0', status: 'pending', operator: '—', initTime: null,
|
||||
createTime: '2026-06-26 08:30:00',
|
||||
certPayload: '—', siteConfig: '{}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [],
|
||||
id: 'dev-005',
|
||||
deviceCode: 'CB-2025-0005',
|
||||
deviceCategory: '净菜柜',
|
||||
deviceName: '智养净菜柜',
|
||||
serialNumber: 'SN-CB-2025-0005',
|
||||
initStatus: 'pending',
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
deployStatus: 'in_stock',
|
||||
customerId: 'cus-001',
|
||||
customerName: '阳光养老院',
|
||||
siteId: 'site-002',
|
||||
siteName: '阳光养老院 · 食堂',
|
||||
dataTransit: true,
|
||||
dataReport: false,
|
||||
outboundOrderNo: 'OUT-20250706-015',
|
||||
onlineTime: '2025-07-06 09:00:00',
|
||||
deployTime: '',
|
||||
initTime: '',
|
||||
},
|
||||
{
|
||||
id: 'init-006', taskCode: 'INIT-20260627-006', deviceSn: 'FIT-S1-0001', deviceName: '健身房顿米车',
|
||||
deviceCategory: 'other', targetSite: '园区员工健身房', baselineFw: 'V2.1.0', baselineApp: 'V1.0.5',
|
||||
activateCode: 'ACT-20260627-U1V2W3X4', status: 'success', operator: '李工程师', initTime: '2026-06-27 11:00:00',
|
||||
createTime: '2026-06-27 10:30:00',
|
||||
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
|
||||
siteConfig: '{"siteCode":"HP-GYM-01","mqTopic":"device.fitness","heartbeat":120}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 12, finishTime: '2026-06-27 10:50:00' },
|
||||
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-27 10:50:04' },
|
||||
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 180, finishTime: '2026-06-27 10:53:04' },
|
||||
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 90, finishTime: '2026-06-27 10:54:34' },
|
||||
],
|
||||
id: 'dev-006',
|
||||
deviceCode: 'WT-2025-0006',
|
||||
deviceCategory: '体重秤',
|
||||
deviceName: '智养体重秤',
|
||||
serialNumber: 'SN-WT-2025-0006',
|
||||
initStatus: 'success',
|
||||
packageName: '智养秤终端 App',
|
||||
softwareVersion: 'V1.2.0',
|
||||
deployStatus: 'deployed',
|
||||
customerId: 'cus-002',
|
||||
customerName: '康乐社区卫生服务中心',
|
||||
siteId: 'site-003',
|
||||
siteName: '康乐社区 · 健康小屋',
|
||||
dataTransit: true,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250707-020',
|
||||
onlineTime: '2025-07-07 10:30:00',
|
||||
deployTime: '2025-07-08 09:00:00',
|
||||
initTime: '2025-07-08 08:50:00',
|
||||
},
|
||||
{
|
||||
id: 'init-007', taskCode: 'INIT-20260628-007', deviceSn: 'WAT-W1-0002', deviceName: '健康站02手表',
|
||||
deviceCategory: 'watch', targetSite: '总部体重测量点 A', baselineFw: 'V2.0.1', baselineApp: 'V1.3.0',
|
||||
activateCode: 'ACT-20260628-Y5Z6A7B8', status: 'failed', operator: '王工程师', initTime: null,
|
||||
failReason: '证书签名校验失败,请检查激活码', createTime: '2026-06-28 14:00:00',
|
||||
certPayload: '—',
|
||||
siteConfig: '{"siteCode":"CQ-HQ-MEASURE-A","mqTopic":"device.watch"}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'failed', costSeconds: 5, finishTime: null, remark: '签名校验失败' },
|
||||
],
|
||||
id: 'dev-007',
|
||||
deviceCode: 'BF-2025-0007',
|
||||
deviceCategory: '体脂仪',
|
||||
deviceName: '智养体脂仪',
|
||||
serialNumber: 'SN-BF-2025-0007',
|
||||
initStatus: 'pending',
|
||||
packageName: '',
|
||||
softwareVersion: '',
|
||||
deployStatus: 'in_stock',
|
||||
customerId: 'cus-003',
|
||||
customerName: '福寿康养集团',
|
||||
siteId: 'site-004',
|
||||
siteName: '福寿康养 · 总部',
|
||||
dataTransit: true,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250708-025',
|
||||
onlineTime: '2025-07-08 14:00:00',
|
||||
deployTime: '',
|
||||
initTime: '',
|
||||
},
|
||||
{
|
||||
id: 'init-008', taskCode: 'INIT-20260629-008', deviceSn: 'CHK-M1-0002', deviceName: '社区站一体机',
|
||||
deviceCategory: 'healthCheck', targetSite: '采油一厂社区站', baselineFw: 'V3.0.0', baselineApp: 'V2.0.0',
|
||||
activateCode: 'ACT-20260629-C9D0E1F2', status: 'success', operator: '赵工程师', initTime: '2026-06-29 17:30:00',
|
||||
createTime: '2026-06-29 16:45:00',
|
||||
certPayload: '-----BEGIN CERTIFICATE-----\nMIIBxTCCAW8CFA==\n-----END CERTIFICATE-----',
|
||||
siteConfig: '{"siteCode":"OILF-01-COMMUNITY","mqTopic":"device.healthcheck"}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [
|
||||
{ id: 's1', stepName: '设备激活与证书烧录', status: 'success', costSeconds: 11, finishTime: '2026-06-29 17:20:00' },
|
||||
{ id: 's2', stepName: '场所配置预置', status: 'success', costSeconds: 4, finishTime: '2026-06-29 17:20:04' },
|
||||
{ id: 's3', stepName: '固件基线烧录', status: 'success', costSeconds: 160, finishTime: '2026-06-29 17:22:44' },
|
||||
{ id: 's4', stepName: 'APP 基线烧录', status: 'success', costSeconds: 85, finishTime: '2026-06-29 17:24:09' },
|
||||
],
|
||||
id: 'dev-008',
|
||||
deviceCode: 'WT-2025-0008',
|
||||
deviceCategory: '体重秤',
|
||||
deviceName: '智养体重秤',
|
||||
serialNumber: 'SN-WT-2025-0008',
|
||||
initStatus: 'success',
|
||||
packageName: '智养秤终端 App',
|
||||
softwareVersion: 'V1.3.0',
|
||||
deployStatus: 'deployed',
|
||||
customerId: 'cus-001',
|
||||
customerName: '阳光养老院',
|
||||
siteId: 'site-001',
|
||||
siteName: '阳光养老院 · 1号楼',
|
||||
dataTransit: true,
|
||||
dataReport: true,
|
||||
outboundOrderNo: 'OUT-20250709-030',
|
||||
onlineTime: '2025-07-09 08:00:00',
|
||||
deployTime: '2025-07-09 10:30:00',
|
||||
initTime: '2025-07-09 10:20:00',
|
||||
},
|
||||
]
|
||||
|
||||
const matchKeyword = (item: InitRecord, keyword?: string): boolean => {
|
||||
if (!keyword) return true
|
||||
return includes(item.taskCode, keyword) || includes(item.deviceSn, keyword) || includes(item.deviceName, keyword)
|
||||
}
|
||||
|
||||
const listInit = (ctx: MockContext): ApiResponse<InitRecord[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = initList.filter((item) => {
|
||||
if (!matchKeyword(item, params.keyword)) return false
|
||||
if (params.deviceSn && !includes(item.deviceSn, params.deviceSn)) return false
|
||||
if (params.deviceCategory && item.deviceCategory !== params.deviceCategory) return false
|
||||
if (params.status && item.status !== params.status) return false
|
||||
return true
|
||||
})
|
||||
return ok('查询成功', filtered)
|
||||
}
|
||||
|
||||
const infoInit = (ctx: MockContext): ApiResponse<InitRecord | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = initList.find((item) => item.id === params.id)
|
||||
return ok('查询成功', target ?? null)
|
||||
}
|
||||
|
||||
/** 编辑回填版(剔除 certPayload / steps 等大字段) */
|
||||
const infoForEdit = (ctx: MockContext): ApiResponse<Partial<InitRecord> | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = initList.find((item) => item.id === params.id)
|
||||
if (!target) return ok('查询成功', null)
|
||||
return ok('查询成功', {
|
||||
id: target.id,
|
||||
taskCode: target.taskCode,
|
||||
deviceSn: target.deviceSn,
|
||||
deviceName: target.deviceName,
|
||||
deviceCategory: target.deviceCategory,
|
||||
targetSite: target.targetSite,
|
||||
baselineFw: target.baselineFw,
|
||||
baselineApp: target.baselineApp,
|
||||
activateCode: target.activateCode,
|
||||
status: target.status,
|
||||
outboundOrderNo: target.outboundOrderNo,
|
||||
})
|
||||
}
|
||||
|
||||
const genTaskCode = (): string => {
|
||||
const ts = new Date()
|
||||
const ymd = `${ts.getFullYear()}${String(ts.getMonth() + 1).padStart(2, '0')}${String(ts.getDate()).padStart(2, '0')}`
|
||||
const seq = String(initList.length + 1).padStart(3, '0')
|
||||
return `INIT-${ymd}-${seq}`
|
||||
}
|
||||
|
||||
const genActivateCode = (): string => {
|
||||
const random = Math.random().toString(36).slice(2, 10).toUpperCase()
|
||||
const ts = Date.now().toString(36).toUpperCase().slice(-6)
|
||||
return `ACT-${ts}-${random}`
|
||||
}
|
||||
|
||||
const addInit = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = `init-${String(initList.length + 1).padStart(3, '0')}`
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
const record: InitRecord = {
|
||||
id,
|
||||
taskCode: genTaskCode(),
|
||||
deviceSn: String(params.deviceSn ?? ''),
|
||||
deviceName: String(params.deviceName ?? ''),
|
||||
deviceCategory: params.deviceCategory as InitRecord['deviceCategory'],
|
||||
targetSite: String(params.targetSite ?? ''),
|
||||
baselineFw: String(params.baselineFw ?? 'V1.0.0'),
|
||||
baselineApp: String(params.baselineApp ?? 'V1.0.0'),
|
||||
activateCode: String(params.activateCode ?? genActivateCode()),
|
||||
status: (params.status as InitRecord['status']) ?? 'pending',
|
||||
operator: '当前用户',
|
||||
initTime: null,
|
||||
outboundOrderNo: params.outboundOrderNo ? String(params.outboundOrderNo) : undefined,
|
||||
createTime: now,
|
||||
certPayload: '—',
|
||||
siteConfig: '{}',
|
||||
strategy: '标准烧录策略:证书 → 场所配置 → 固件 → APP → 自检',
|
||||
steps: [],
|
||||
/** 详情构造器:在列表项基础上扩展类别 / 数据分发 / 软件版本详情 */
|
||||
const buildDetail = (item: InitItem): InitDetail => {
|
||||
const versionMeta = VERSION_DATASET.find((v) => v.version === item.softwareVersion)
|
||||
const base: InitDetail = {
|
||||
...item,
|
||||
isIot: true,
|
||||
networkMode: 'wifi',
|
||||
manufacturer: '智养科技',
|
||||
model: `${item.deviceCategory}-PRO`,
|
||||
appDescription: versionMeta?.description ?? '',
|
||||
packageUrl: versionMeta?.packageUrl ?? '',
|
||||
}
|
||||
initList.unshift(record)
|
||||
return ok('新增成功')
|
||||
if (item.dataTransit) {
|
||||
base.downstreamName = '营养管理中台'
|
||||
base.downstreamCode = 'NUTRITION-HUB'
|
||||
base.callbackUrl = 'https://api.mock.example.com/v1/device/data'
|
||||
base.dispatchMode = 'realtime'
|
||||
base.dispatchStatus = 'enabled'
|
||||
}
|
||||
if (item.dataReport) {
|
||||
base.reportChannelName = '设备数据上报通道'
|
||||
base.reportChannelCode = 'DEV-REPORT'
|
||||
base.mqAddress = 'rabbitmq://mock.example.com:5672/dev-report'
|
||||
base.gatewayUrl = 'https://gateway.mock.example.com/device/report'
|
||||
base.reportStatus = 'enabled'
|
||||
}
|
||||
return base
|
||||
}
|
||||
|
||||
const updateInit = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const target = initList.find((item) => item.id === id)
|
||||
if (!target) return ok('记录不存在')
|
||||
if (params.deviceSn !== undefined) target.deviceSn = String(params.deviceSn)
|
||||
if (params.deviceName !== undefined) target.deviceName = String(params.deviceName)
|
||||
if (params.deviceCategory !== undefined) target.deviceCategory = params.deviceCategory as InitRecord['deviceCategory']
|
||||
if (params.targetSite !== undefined) target.targetSite = String(params.targetSite)
|
||||
if (params.baselineFw !== undefined) target.baselineFw = String(params.baselineFw)
|
||||
if (params.baselineApp !== undefined) target.baselineApp = String(params.baselineApp)
|
||||
if (params.activateCode !== undefined) target.activateCode = String(params.activateCode)
|
||||
if (params.outboundOrderNo !== undefined) target.outboundOrderNo = String(params.outboundOrderNo)
|
||||
return ok('更新成功')
|
||||
/** 列表查询(按筛选条件过滤) */
|
||||
const listInit = (ctx: MockContext): ApiResponse<InitItem[]> => {
|
||||
const params = getParams<ListParams>(ctx.params)
|
||||
let list = INIT_DATASET.slice()
|
||||
if (params.deviceCode) list = list.filter((i) => i.deviceCode.includes(params.deviceCode as string))
|
||||
if (params.deviceCategory) list = list.filter((i) => i.deviceCategory === params.deviceCategory)
|
||||
if (params.initStatus) list = list.filter((i) => i.initStatus === params.initStatus)
|
||||
if (params.deployStatus) list = list.filter((i) => i.deployStatus === params.deployStatus)
|
||||
if (params.customerId) list = list.filter((i) => i.customerId === params.customerId)
|
||||
if (params.siteId) list = list.filter((i) => i.siteId === params.siteId)
|
||||
if (params.outboundOrderNo) list = list.filter((i) => i.outboundOrderNo.includes(params.outboundOrderNo as string))
|
||||
if (params.keyword) list = list.filter((i) => i.deviceCode.includes(params.keyword as string) || i.serialNumber.includes(params.keyword as string))
|
||||
return ok('查询成功', list)
|
||||
}
|
||||
|
||||
const deleteInit = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const idx = initList.findIndex((item) => item.id === id)
|
||||
if (idx >= 0) initList.splice(idx, 1)
|
||||
return ok('删除成功')
|
||||
/** 详情查询 */
|
||||
const infoInit = (ctx: MockContext): ApiResponse<InitDetail | null> => {
|
||||
const params = getParams<{ id?: string }>(ctx.params)
|
||||
const item = INIT_DATASET.find((i) => i.id === params.id)
|
||||
if (!item) return ok('查询成功', null)
|
||||
return ok('查询成功', buildDetail(item))
|
||||
}
|
||||
|
||||
/** 重试:把失败步骤重新置为 pending,整条任务进入 processing */
|
||||
const retryInit = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = String(params.id ?? '')
|
||||
const target = initList.find((item) => item.id === id)
|
||||
if (!target) return ok('记录不存在')
|
||||
target.status = 'processing'
|
||||
target.failReason = undefined
|
||||
target.steps = target.steps.map((s) => s.status === 'failed' ? { ...s, status: 'processing', finishTime: null, remark: '重试中…' } : s)
|
||||
return ok('已触发重新初始化')
|
||||
/** 单条 / 批量版本设置(共用逻辑) */
|
||||
const applyVersionSet = (ids: string[], versionId?: string): number => {
|
||||
const version = VERSION_DATASET.find((v) => v.id === versionId)
|
||||
if (!version) return -1
|
||||
const appName = APP_DATASET.find((a) => a.id === version.appId)?.name ?? ''
|
||||
const now = new Date().toISOString().replace('T', ' ').substring(0, 19)
|
||||
INIT_DATASET.forEach((item) => {
|
||||
if (ids.includes(item.id)) {
|
||||
item.packageName = appName
|
||||
item.softwareVersion = version.version
|
||||
item.initStatus = 'success'
|
||||
item.initTime = now
|
||||
}
|
||||
})
|
||||
return ids.length
|
||||
}
|
||||
|
||||
/** 单条版本设置 */
|
||||
const setVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams<{ id?: string; ids?: string[]; versionId?: string }>(ctx.params)
|
||||
const ids = params.ids ?? (params.id ? [params.id] : [])
|
||||
const n = applyVersionSet(ids, params.versionId)
|
||||
if (n < 0) return ok('版本不存在')
|
||||
return ok('版本设置成功')
|
||||
}
|
||||
|
||||
/** 批量版本设置 */
|
||||
const batchSetVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams<{ ids?: string[]; versionId?: string }>(ctx.params)
|
||||
const ids = params.ids ?? []
|
||||
const n = applyVersionSet(ids, params.versionId)
|
||||
if (n < 0) return ok('版本不存在')
|
||||
return ok(`已成功为 ${ids.length} 台设备设置版本`)
|
||||
}
|
||||
|
||||
/** 异步导出(仅创建任务) */
|
||||
const exportInit = (): ApiResponse<null> => ok('导出任务已创建,请稍后到导出中心查看')
|
||||
|
||||
/** 应用名称下拉 */
|
||||
const appOpts = (): ApiResponse<AppOption[]> => ok('ok', APP_DATASET)
|
||||
|
||||
/** 版本号下拉(按应用联动) */
|
||||
const versionOpts = (ctx: MockContext): ApiResponse<VersionOption[]> => {
|
||||
const params = getParams<{ appId?: string }>(ctx.params)
|
||||
return ok('ok', VERSION_DATASET.filter((v) => v.appId === params.appId))
|
||||
}
|
||||
|
||||
/** 设备类别下拉 */
|
||||
const categoryOpts = (): ApiResponse<DeviceCategoryOption[]> => ok('ok', CATEGORY_DATASET)
|
||||
|
||||
/** 客户下拉 */
|
||||
const customerOpts = (): ApiResponse<CustomerOption[]> => ok('ok', CUSTOMER_DATASET)
|
||||
|
||||
/** 场所下拉(按客户联动) */
|
||||
const siteOpts = (ctx: MockContext): ApiResponse<SiteOption[]> => {
|
||||
const params = getParams<{ customerId?: string }>(ctx.params)
|
||||
return ok('ok', SITE_DATASET.filter((s) => s.customerId === params.customerId))
|
||||
}
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/ops/init/list', handler: listInit },
|
||||
{ method: 'POST', url: '/admin/device/ops/init/info', handler: infoInit },
|
||||
{ method: 'POST', url: '/admin/device/ops/init/info-for-edit', handler: infoForEdit },
|
||||
{ method: 'POST', url: '/admin/device/ops/init/add', handler: addInit },
|
||||
{ method: 'POST', url: '/admin/device/ops/init/update', handler: updateInit },
|
||||
{ method: 'POST', url: '/admin/device/ops/init/delete', handler: deleteInit },
|
||||
{ method: 'POST', url: '/admin/device/ops/init/retry', handler: retryInit },
|
||||
{ method: 'POST', url: BASE + '/list', handler: listInit },
|
||||
{ method: 'POST', url: BASE + '/info', handler: infoInit },
|
||||
{ method: 'POST', url: BASE + '/set-version', handler: setVersion },
|
||||
{ method: 'POST', url: BASE + '/batch-set-version', handler: batchSetVersion },
|
||||
{ method: 'POST', url: BASE + '/export', handler: exportInit },
|
||||
{ method: 'POST', url: BASE + '/app/options', handler: appOpts },
|
||||
{ method: 'POST', url: BASE + '/version/options', handler: versionOpts },
|
||||
{ method: 'POST', url: BASE + '/category/options', handler: categoryOpts },
|
||||
{ method: 'POST', url: BASE + '/customer/options', handler: customerOpts },
|
||||
{ method: 'POST', url: BASE + '/site/options', handler: siteOpts },
|
||||
])
|
||||
|
||||
@@ -3,30 +3,35 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 设备运维 / 设备监测 mock
|
||||
* - 主列表:设备运行状态总览
|
||||
* - 主列表:设备运行状态总览(软件版本 = 应用名 + 版本号)
|
||||
* - 详情 6 Tab:实施/自检/巡检/运行日志/更新记录/维修记录
|
||||
* - 远程下发:重启
|
||||
*/
|
||||
|
||||
interface MonitorRecord {
|
||||
id: string
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
deviceCategory: 'catering' | 'body' | 'wearable' | 'fitness' | 'monitor'
|
||||
/** 设备分类(末级分类名,中文展示,与设备初始化模块对齐) */
|
||||
deviceCategory: string
|
||||
tenantName: string
|
||||
siteName: string
|
||||
runStatus: 'online' | 'offline' | 'repairing' | 'scrapped'
|
||||
firmwareVersion: string
|
||||
/** 应用名称(与设备初始化模块对齐) */
|
||||
packageName: string
|
||||
/** 软件版本号 */
|
||||
softwareVersion: string
|
||||
lastHeartbeat: string
|
||||
onlineRate: number
|
||||
alertCount: number
|
||||
dataTransit: boolean
|
||||
onlineTime: string
|
||||
isIot: boolean
|
||||
implements: { id: string; implementType: 'newInstall' | 'migration' | 'removal' | 'reset'; implementer: string; implementTime: string; signedBy: string; remark: string }[]
|
||||
selfChecks: { id: string; checkType: 'powerOn' | 'scheduled' | 'remote'; checkItems: { name: string; category: 'hardware' | 'network' | 'sensor' | 'firmware'; result: 'pass' | 'fail'; detail?: string }[]; result: 'pass' | 'fail'; checkTime: string; triggerWorkOrder?: string }[]
|
||||
inspections: { id: string; planName: string; inspector: string; inspectTime: string; result: 'pass' | 'fail'; itemsCount: number; exceptionCount: number; handleType?: 'close' | 'workorder'; workOrderNo?: string }[]
|
||||
runLogs: { id: string; eventType: string; content: string; occurTime: string; level: 'info' | 'warn' | 'error' }[]
|
||||
updates: { id: string; targetVersion: string; beforeVersion: string; pushTask: string; result: 'success' | 'fail' | 'rollback'; retryCount: number; finishTime: string }[]
|
||||
/** 更新记录(每条含 packageName 应用名 + 目标/原版本号) */
|
||||
updates: { id: string; packageName: string; targetVersion: string; beforeVersion: string; pushTask: string; result: 'success' | 'fail' | 'rollback'; retryCount: number; finishTime: string }[]
|
||||
repairs: { id: string; workOrderNo: string; faultType: string; repairMethod: string; repairer: string; workHours: number; partsConsumed?: string; finishTime: string }[]
|
||||
}
|
||||
|
||||
@@ -51,9 +56,10 @@ const includes = (value: unknown, keyword: unknown): boolean => {
|
||||
|
||||
const monitorList: MonitorRecord[] = [
|
||||
{
|
||||
id: 'mon-001', deviceCode: 'CAB-A1-0001', deviceName: '总部食堂货柜A1', deviceCategory: 'catering',
|
||||
tenantName: '长庆能源总部食堂', siteName: '长庆总部一食堂', runStatus: 'online', firmwareVersion: 'V1.2.0',
|
||||
lastHeartbeat: '2026-07-02 14:50:12', onlineRate: 99, alertCount: 0, dataTransit: false, onlineTime: '2026-06-20 14:30:00', isIot: true,
|
||||
id: 'mon-001', deviceCode: 'CAB-A1-0001', deviceName: '总部食堂货柜A1', deviceCategory: '智能货柜',
|
||||
tenantName: '国庆能源总部食堂', siteName: '国庆总部一食堂', runStatus: 'online',
|
||||
packageName: '智养货柜 App', softwareVersion: 'V1.2.0',
|
||||
lastHeartbeat: '2026-07-02 14:50:12', onlineRate: 99, alertCount: 0, onlineTime: '2026-06-20 14:30:00', isIot: true,
|
||||
implements: [
|
||||
{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-06-20 14:00:00', signedBy: '王经理', remark: '首次装机,A1 货架第二层' },
|
||||
],
|
||||
@@ -69,16 +75,17 @@ const monitorList: MonitorRecord[] = [
|
||||
{ id: 'rl2', eventType: '开机', content: '设备开机', occurTime: '2026-07-02 06:00:00', level: 'info' },
|
||||
],
|
||||
updates: [
|
||||
{ id: 'u1', targetVersion: 'V1.2.0', beforeVersion: 'V1.1.5', pushTask: 'OTA-20260625-003', result: 'success', retryCount: 0, finishTime: '2026-06-25 03:15:00' },
|
||||
{ id: 'u1', packageName: '智养货柜 App', targetVersion: 'V1.2.0', beforeVersion: 'V1.1.5', pushTask: 'OTA-20260625-003', result: 'success', retryCount: 0, finishTime: '2026-06-25 03:15:00' },
|
||||
],
|
||||
repairs: [
|
||||
{ id: 'r1', workOrderNo: 'WO-20260620-001', faultType: '锁具故障', repairMethod: '更换锁芯', repairer: '李工程师', workHours: 1.5, partsConsumed: '锁芯×1', finishTime: '2026-06-20 17:30:00' },
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'mon-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', deviceCategory: 'wearable',
|
||||
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'repairing', firmwareVersion: 'V2.0.0',
|
||||
lastHeartbeat: '2026-07-02 13:20:00', onlineRate: 78, alertCount: 2, dataTransit: true, onlineTime: '2026-06-22 09:30:00', isIot: true,
|
||||
id: 'mon-002', deviceCode: 'WAT-W1-0001', deviceName: '健康站01手表', deviceCategory: '智能手表',
|
||||
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'repairing',
|
||||
packageName: '智能手表 App', softwareVersion: 'V2.0.0',
|
||||
lastHeartbeat: '2026-07-02 13:20:00', onlineRate: 78, alertCount: 2, onlineTime: '2026-06-22 09:30:00', isIot: true,
|
||||
implements: [
|
||||
{ id: 'i1', implementType: 'newInstall', implementer: '王工程师', implementTime: '2026-06-22 09:30:00', signedBy: '张医生', remark: '绑定用户 u-001' },
|
||||
],
|
||||
@@ -91,14 +98,15 @@ const monitorList: MonitorRecord[] = [
|
||||
{ id: 'rl2', eventType: '故障', content: '心率数据采集异常', occurTime: '2026-07-02 12:50:00', level: 'error' },
|
||||
],
|
||||
updates: [
|
||||
{ id: 'u1', targetVersion: 'V2.0.0', beforeVersion: 'V1.9.0', pushTask: 'OTA-20260620-002', result: 'success', retryCount: 0, finishTime: '2026-06-20 22:00:00' },
|
||||
{ id: 'u1', packageName: '智能手表 App', targetVersion: 'V2.0.0', beforeVersion: 'V1.9.0', pushTask: 'OTA-20260620-002', result: 'success', retryCount: 0, finishTime: '2026-06-20 22:00:00' },
|
||||
],
|
||||
repairs: [],
|
||||
},
|
||||
{
|
||||
id: 'mon-003', deviceCode: 'CHK-M1-0001', deviceName: '体检站一体机', deviceCategory: 'monitor',
|
||||
tenantName: '健康管理机构', siteName: '东区体检站', runStatus: 'offline', firmwareVersion: 'V3.0.0',
|
||||
lastHeartbeat: '2026-07-02 02:00:00', onlineRate: 45, alertCount: 3, dataTransit: true, onlineTime: '2026-06-24 10:30:00', isIot: true,
|
||||
id: 'mon-003', deviceCode: 'CHK-M1-0001', deviceName: '体检站一体机', deviceCategory: '智能健康一体机',
|
||||
tenantName: '健康管理机构', siteName: '东区体检站', runStatus: 'offline',
|
||||
packageName: '体检一体机 App', softwareVersion: 'V3.0.0',
|
||||
lastHeartbeat: '2026-07-02 02:00:00', onlineRate: 45, alertCount: 3, onlineTime: '2026-06-24 10:30:00', isIot: true,
|
||||
implements: [
|
||||
{ id: 'i1', implementType: 'newInstall', implementer: '赵工程师', implementTime: '2026-06-24 10:30:00', signedBy: '张医生', remark: '东区体检站主力机' },
|
||||
],
|
||||
@@ -111,9 +119,10 @@ const monitorList: MonitorRecord[] = [
|
||||
repairs: [],
|
||||
},
|
||||
{
|
||||
id: 'mon-004', deviceCode: 'FAT-INBODY-0001', deviceName: '体检站体脂秤', deviceCategory: 'body',
|
||||
tenantName: '健康管理机构', siteName: '东区体检站', runStatus: 'online', firmwareVersion: 'V1.5.0',
|
||||
lastHeartbeat: '2026-07-02 14:48:00', onlineRate: 96, alertCount: 0, dataTransit: true, onlineTime: '2026-06-25 16:00:00', isIot: true,
|
||||
id: 'mon-004', deviceCode: 'FAT-INBODY-0001', deviceName: '体检站体脂秤', deviceCategory: '体脂仪',
|
||||
tenantName: '健康管理机构', siteName: '东区体检站', runStatus: 'online',
|
||||
packageName: '智养秤终端 App', softwareVersion: 'V1.5.0',
|
||||
lastHeartbeat: '2026-07-02 14:48:00', onlineRate: 96, alertCount: 0, onlineTime: '2026-06-25 16:00:00', isIot: true,
|
||||
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '孙工程师', implementTime: '2026-06-25 16:00:00', signedBy: '张医生', remark: '—' }],
|
||||
selfChecks: [{ id: 'sc1', checkType: 'powerOn', checkItems: [{ name: '网络', category: 'network', result: 'pass' }], result: 'pass', checkTime: '2026-07-02 08:00:00' }],
|
||||
inspections: [{ id: 'ins1', planName: '体检站周巡检', inspector: '王工程师', inspectTime: '2026-06-29 14:00:00', result: 'pass', itemsCount: 8, exceptionCount: 0, handleType: 'close' }],
|
||||
@@ -122,9 +131,10 @@ const monitorList: MonitorRecord[] = [
|
||||
repairs: [],
|
||||
},
|
||||
{
|
||||
id: 'mon-005', deviceCode: 'SCL-W-0001', deviceName: '健康站体重秤', deviceCategory: 'body',
|
||||
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'online', firmwareVersion: 'V1.0.3',
|
||||
lastHeartbeat: '2026-07-02 14:45:00', onlineRate: 92, alertCount: 1, dataTransit: true, onlineTime: '2026-06-26 09:00:00', isIot: true,
|
||||
id: 'mon-005', deviceCode: 'SCL-W-0001', deviceName: '健康站体重秤', deviceCategory: '蓝牙秤',
|
||||
tenantName: '健康管理机构', siteName: '健康园区体检站', runStatus: 'online',
|
||||
packageName: '智养秤终端 App', softwareVersion: 'V1.0.3',
|
||||
lastHeartbeat: '2026-07-02 14:45:00', onlineRate: 92, alertCount: 1, onlineTime: '2026-06-26 09:00:00', isIot: true,
|
||||
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-06-26 09:00:00', signedBy: '张医生', remark: '—' }],
|
||||
selfChecks: [],
|
||||
inspections: [],
|
||||
@@ -133,9 +143,10 @@ const monitorList: MonitorRecord[] = [
|
||||
repairs: [{ id: 'r1', workOrderNo: 'WO-20260629-008', faultType: '蓝牙异常', repairMethod: '更换蓝牙模块', repairer: '王工程师', workHours: 1.0, partsConsumed: '蓝牙模块×1', finishTime: '2026-06-30 16:00:00' }],
|
||||
},
|
||||
{
|
||||
id: 'mon-006', deviceCode: 'FIT-S1-0001', deviceName: '健身房顿米车', deviceCategory: 'fitness',
|
||||
tenantName: '智养餐饮集团', siteName: '园区员工健身房', runStatus: 'repairing', firmwareVersion: 'V2.1.0',
|
||||
lastHeartbeat: '2026-07-02 12:00:00', onlineRate: 65, alertCount: 1, dataTransit: true, onlineTime: '2026-06-27 11:00:00', isIot: true,
|
||||
id: 'mon-006', deviceCode: 'FIT-S1-0001', deviceName: '健身房顿米车', deviceCategory: '健身车',
|
||||
tenantName: '智养餐饮集团', siteName: '园区员工健身房', runStatus: 'repairing',
|
||||
packageName: '智养健身 App', softwareVersion: 'V2.1.0',
|
||||
lastHeartbeat: '2026-07-02 12:00:00', onlineRate: 65, alertCount: 1, onlineTime: '2026-06-27 11:00:00', isIot: true,
|
||||
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-06-27 11:00:00', signedBy: '吴教练', remark: '—' }],
|
||||
selfChecks: [],
|
||||
inspections: [],
|
||||
@@ -144,9 +155,10 @@ const monitorList: MonitorRecord[] = [
|
||||
repairs: [],
|
||||
},
|
||||
{
|
||||
id: 'mon-007', deviceCode: 'SCR-P1-0001', deviceName: '总部大屏', deviceCategory: 'catering',
|
||||
tenantName: '智养餐饮集团', siteName: '长庆总部一食堂', runStatus: 'online', firmwareVersion: 'V2.5.0',
|
||||
lastHeartbeat: '2026-07-02 14:50:00', onlineRate: 99, alertCount: 0, dataTransit: false, onlineTime: '2026-05-01 10:00:00', isIot: false,
|
||||
id: 'mon-007', deviceCode: 'SCR-P1-0001', deviceName: '总部大屏', deviceCategory: '带鱼屏',
|
||||
tenantName: '智养餐饮集团', siteName: '国庆总部一食堂', runStatus: 'online',
|
||||
packageName: '智养大屏 App', softwareVersion: 'V2.5.0',
|
||||
lastHeartbeat: '2026-07-02 14:50:00', onlineRate: 99, alertCount: 0, onlineTime: '2026-05-01 10:00:00', isIot: false,
|
||||
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '赵工程师', implementTime: '2026-05-01 10:00:00', signedBy: '王经理', remark: '主餐厅入口' }],
|
||||
selfChecks: [],
|
||||
inspections: [{ id: 'ins1', planName: '食堂周巡检', inspector: '赵工程师', inspectTime: '2026-06-28 10:30:00', result: 'pass', itemsCount: 6, exceptionCount: 0, handleType: 'close' }],
|
||||
@@ -155,14 +167,15 @@ const monitorList: MonitorRecord[] = [
|
||||
repairs: [],
|
||||
},
|
||||
{
|
||||
id: 'mon-008', deviceCode: 'SCL-X1-0001', deviceName: '总部入库秤', deviceCategory: 'catering',
|
||||
tenantName: '智养餐饮集团', siteName: '长庆总部一食堂', runStatus: 'online', firmwareVersion: 'V1.3.0',
|
||||
lastHeartbeat: '2026-07-02 14:49:30', onlineRate: 98, alertCount: 0, dataTransit: false, onlineTime: '2026-04-15 09:00:00', isIot: true,
|
||||
id: 'mon-008', deviceCode: 'SCL-X1-0001', deviceName: '总部入库秤', deviceCategory: '入库秤',
|
||||
tenantName: '智养餐饮集团', siteName: '国庆总部一食堂', runStatus: 'online',
|
||||
packageName: '智养秤终端 App', softwareVersion: 'V1.3.0',
|
||||
lastHeartbeat: '2026-07-02 14:49:30', onlineRate: 98, alertCount: 0, onlineTime: '2026-04-15 09:00:00', isIot: true,
|
||||
implements: [{ id: 'i1', implementType: 'newInstall', implementer: '李工程师', implementTime: '2026-04-15 09:00:00', signedBy: '王经理', remark: '后厨入库区' }],
|
||||
selfChecks: [{ id: 'sc1', checkType: 'scheduled', checkItems: [{ name: '称重校准', category: 'sensor', result: 'pass' }], result: 'pass', checkTime: '2026-07-02 06:00:00' }],
|
||||
inspections: [],
|
||||
runLogs: [],
|
||||
updates: [{ id: 'u1', targetVersion: 'V1.3.0', beforeVersion: 'V1.2.0', pushTask: 'OTA-20260620-001', result: 'success', retryCount: 0, finishTime: '2026-06-20 22:00:00' }],
|
||||
updates: [{ id: 'u1', packageName: '智养秤终端 App', targetVersion: 'V1.3.0', beforeVersion: 'V1.2.0', pushTask: 'OTA-20260620-001', result: 'success', retryCount: 0, finishTime: '2026-06-20 22:00:00' }],
|
||||
repairs: [{ id: 'r1', workOrderNo: 'WO-20260625-004', faultType: '校准异常', repairMethod: '重新校准', repairer: '赵工程师', workHours: 0.5, finishTime: '2026-06-26 10:00:00' }],
|
||||
},
|
||||
]
|
||||
@@ -209,20 +222,34 @@ const remoteMonitor = (ctx: MockContext): ApiResponse<null> => {
|
||||
if (!target) return ok('设备不存在')
|
||||
/** 在运行日志追加一条记录 */
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
const actionText = params.type === 'self-check' ? '远程自检' : params.type === 'restart' ? '远程重启' : 'OTA 升级'
|
||||
target.runLogs.unshift({
|
||||
id: `rl-${Date.now()}`,
|
||||
eventType: actionText,
|
||||
content: `${actionText}指令已下发`,
|
||||
eventType: '远程重启',
|
||||
content: '远程重启指令已下发',
|
||||
occurTime: now,
|
||||
level: 'info',
|
||||
})
|
||||
return ok(`${actionText}指令已下发`)
|
||||
return ok('远程重启指令已下发')
|
||||
}
|
||||
|
||||
/** 设备分类下拉(末级分类名,与设备初始化模块对齐) */
|
||||
const CATEGORY_OPTIONS: { name: string }[] = [
|
||||
{ name: '智能货柜' },
|
||||
{ name: '智能手表' },
|
||||
{ name: '智能健康一体机' },
|
||||
{ name: '体脂仪' },
|
||||
{ name: '蓝牙秤' },
|
||||
{ name: '健身车' },
|
||||
{ name: '带鱼屏' },
|
||||
{ name: '入库秤' },
|
||||
]
|
||||
|
||||
const categoryOpts = (): ApiResponse<{ name: string }[]> => ok('查询成功', CATEGORY_OPTIONS)
|
||||
|
||||
registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/ops/monitor/list', handler: listMonitor },
|
||||
{ method: 'POST', url: '/admin/device/ops/monitor/stats', handler: statsMonitor },
|
||||
{ method: 'POST', url: '/admin/device/ops/monitor/info', handler: infoMonitor },
|
||||
{ method: 'POST', url: '/admin/device/ops/monitor/remote', handler: remoteMonitor },
|
||||
{ method: 'POST', url: '/admin/device/ops/monitor/category/options', handler: categoryOpts },
|
||||
])
|
||||
|
||||
@@ -3,106 +3,235 @@ import type { ApiResponse, MockContext, RequestParameter } from '@axios'
|
||||
|
||||
/**
|
||||
* 设备运维 / 版本管理 mock
|
||||
* - 固件 + APP 双轨
|
||||
* - 状态流转:草稿 → 灰度 → 全量 → 停用
|
||||
* - 两级结构:应用(App)→ 版本(Version)
|
||||
* - 应用列表:从版本数据汇总「最新版本 / 历史版本数 / 最后更新日期」
|
||||
* - 版本列表:按 appId 过滤历史版本
|
||||
* - 应用状态:启用 / 停用;版本状态:有效 / 停用(均支持 toggle)
|
||||
* - 研发主体为公司名称(字符串),不再使用枚举
|
||||
* - 版本记录含软件包字段:packageName / packageSize
|
||||
*/
|
||||
|
||||
type AppStatus = 'enabled' | 'disabled'
|
||||
type VersionStatus = 'active' | 'disabled'
|
||||
|
||||
interface VersionRecord {
|
||||
id: string
|
||||
appId: string
|
||||
/** 版本号,如 V1.2.0 */
|
||||
version: string
|
||||
track: 'firmware' | 'app'
|
||||
packageUrl: string
|
||||
upgradePoints: string
|
||||
grayPercent: number
|
||||
rollbackFlag: boolean
|
||||
publishTime: string | null
|
||||
status: 'draft' | 'gray' | 'full' | 'deprecated'
|
||||
upgradedCount: number
|
||||
creator: string
|
||||
/** 版本名称,如 首版 / 灰度版 / 稳定版 */
|
||||
versionName: string
|
||||
/** 更新说明 */
|
||||
releaseNote: string
|
||||
/** 是否强制更新 */
|
||||
forceUpdate: boolean
|
||||
status: VersionStatus
|
||||
/** 再用设备数(mock 静态) */
|
||||
usingDeviceCount: number
|
||||
/** 适配类目:设备分类 code 数组 */
|
||||
deviceCategories: string[]
|
||||
/** 软件包文件名 */
|
||||
packageName: string
|
||||
/** 软件包大小(字节) */
|
||||
packageSize: number
|
||||
updateTime: string
|
||||
createTime: string
|
||||
creator: string
|
||||
}
|
||||
|
||||
interface VersionParams {
|
||||
interface AppRecord {
|
||||
id: string
|
||||
/** 应用名称 */
|
||||
name: string
|
||||
/** 研发主体(公司名称) */
|
||||
devSubject: string
|
||||
/** 应用描述 */
|
||||
description: string
|
||||
status: AppStatus
|
||||
}
|
||||
|
||||
interface CommonParams {
|
||||
keyword?: string
|
||||
track?: string
|
||||
status?: string
|
||||
id?: string
|
||||
appId?: string
|
||||
toStatus?: string
|
||||
[key: string]: unknown
|
||||
}
|
||||
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
const getParams = (params: RequestParameter): VersionParams => (params ?? {}) as VersionParams
|
||||
const getParams = (params: RequestParameter): CommonParams => (params ?? {}) as CommonParams
|
||||
const includes = (value: unknown, keyword: unknown): boolean => {
|
||||
if (!keyword) return true
|
||||
return String(value ?? '').toLowerCase().includes(String(keyword).toLowerCase())
|
||||
}
|
||||
const now = (): string => new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
|
||||
/** 设备分类 code → 名称(与主页/二级页 types 中的 DEVICE_CATEGORY_OPTIONS 保持一致) */
|
||||
const deviceCategoryText: Record<string, string> = {
|
||||
cabinet: '净菜柜',
|
||||
watch: '智能手表',
|
||||
healthCheck: '体检一体机',
|
||||
bodyFat: '体脂仪',
|
||||
scale: '健康量表',
|
||||
}
|
||||
|
||||
const appList: AppRecord[] = [
|
||||
{ id: 'app-cabinet-fw', name: '净菜柜固件', devSubject: '寰汐科技(上海)有限公司', description: '净菜柜主控板固件,负责开门控制、温控、 OTA 升级', status: 'enabled' },
|
||||
{ id: 'app-cabinet-app', name: '净菜柜客户端 APP', devSubject: '寰汐科技(上海)有限公司', description: '净菜柜前端交互应用,提供扫码开门、菜品展示、订单查询', status: 'enabled' },
|
||||
{ id: 'app-watch-fw', name: '智能手表固件', devSubject: '深圳瑞康智能科技有限公司', description: '智能健康手表底层固件,由供应商提供底包 + 自研算法层', status: 'enabled' },
|
||||
{ id: 'app-healthcheck-fw', name: '体检一体机固件', devSubject: '寰汐科技(上海)有限公司', description: '体检一体机主控固件,集成血压 / 血尿酸 / 体脂多项检测', status: 'disabled' },
|
||||
{ id: 'app-bodyfat-fw', name: '体脂仪固件', devSubject: '江苏康体电子科技有限公司', description: '体脂仪底层固件,第三方厂商定制开发', status: 'enabled' },
|
||||
{ id: 'app-scale-app', name: '健康量表 APP', devSubject: '寰汐科技(上海)有限公司', description: '客户终端健康量表自评 APP(暂未发布任何版本)', status: 'enabled' },
|
||||
]
|
||||
|
||||
const versionList: VersionRecord[] = [
|
||||
{
|
||||
id: 'ver-001', version: 'V1.2.0', track: 'firmware',
|
||||
packageUrl: 'https://ota.yx-canteen.com/fw/cabinet/v1.2.0.bin',
|
||||
upgradePoints: '优化货柜开门响应速度\n修复温度传感器偶发漂移\n新增离线缓存支持',
|
||||
grayPercent: 100, rollbackFlag: true, publishTime: '2026-06-25 03:00:00',
|
||||
status: 'full', upgradedCount: 12, creator: '李工程师', createTime: '2026-06-22 15:00:00',
|
||||
id: 'ver-001', appId: 'app-cabinet-fw', version: 'V1.2.0', versionName: '稳定版',
|
||||
releaseNote: '优化货柜开门响应速度\n修复温度传感器偶发漂移\n新增离线缓存支持',
|
||||
forceUpdate: false, status: 'active', usingDeviceCount: 12,
|
||||
deviceCategories: ['cabinet'],
|
||||
packageName: 'cabinet-fw-V1.2.0.bin', packageSize: 3 * 1024 * 1024 + 240 * 1024,
|
||||
updateTime: '2026-06-25 03:00:00', createTime: '2026-06-22 15:00:00', creator: '李工程师',
|
||||
},
|
||||
{
|
||||
id: 'ver-002', version: 'V1.1.5', track: 'firmware',
|
||||
packageUrl: 'https://ota.yx-canteen.com/fw/cabinet/v1.1.5.bin',
|
||||
upgradePoints: '修复开门指令偶发丢失问题',
|
||||
grayPercent: 0, rollbackFlag: false, publishTime: '2026-05-10 10:00:00',
|
||||
status: 'deprecated', upgradedCount: 0, creator: '李工程师', createTime: '2026-05-08 14:00:00',
|
||||
id: 'ver-002', appId: 'app-cabinet-fw', version: 'V1.1.5', versionName: '稳定版',
|
||||
releaseNote: '修复开门指令偶发丢失问题',
|
||||
forceUpdate: true, status: 'disabled', usingDeviceCount: 0,
|
||||
deviceCategories: ['cabinet'],
|
||||
packageName: 'cabinet-fw-V1.1.5.bin', packageSize: 2 * 1024 * 1024 + 768 * 1024,
|
||||
updateTime: '2026-05-10 10:00:00', createTime: '2026-05-08 14:00:00', creator: '李工程师',
|
||||
},
|
||||
{
|
||||
id: 'ver-003', version: 'V2.0.1', track: 'firmware',
|
||||
packageUrl: 'https://ota.yx-canteen.com/fw/watch/v2.0.1.bin',
|
||||
upgradePoints: '心率监测算法升级\n新增睡眠分析\n优化电池续航',
|
||||
grayPercent: 30, rollbackFlag: true, publishTime: '2026-06-28 22:00:00',
|
||||
status: 'gray', upgradedCount: 8, creator: '王工程师', createTime: '2026-06-26 09:30:00',
|
||||
id: 'ver-003', appId: 'app-cabinet-fw', version: 'V1.1.0', versionName: '稳定版',
|
||||
releaseNote: '首版支持远程开门\n新增温度上报',
|
||||
forceUpdate: false, status: 'active', usingDeviceCount: 3,
|
||||
deviceCategories: ['cabinet'],
|
||||
packageName: 'cabinet-fw-V1.1.0.bin', packageSize: 2 * 1024 * 1024 + 100 * 1024,
|
||||
updateTime: '2026-04-20 16:00:00', createTime: '2026-04-18 09:00:00', creator: '李工程师',
|
||||
},
|
||||
{
|
||||
id: 'ver-004', version: 'V1.0.0', track: 'app',
|
||||
packageUrl: 'https://ota.yx-canteen.com/app/cabinet/v1.0.0.apk',
|
||||
upgradePoints: '货柜客户端首版\n支持扫码开门、菜品展示、订单查询',
|
||||
grayPercent: 100, rollbackFlag: true, publishTime: '2026-04-15 09:00:00',
|
||||
status: 'full', upgradedCount: 12, creator: '张产品', createTime: '2026-04-10 14:00:00',
|
||||
id: 'ver-004', appId: 'app-cabinet-app', version: 'V1.1.0', versionName: '灰度版',
|
||||
releaseNote: '新增会员登录\n优化 UI 加载速度\n修复部分小屏适配问题',
|
||||
forceUpdate: false, status: 'active', usingDeviceCount: 6,
|
||||
deviceCategories: ['cabinet'],
|
||||
packageName: 'cabinet-app-V1.1.0.apk', packageSize: 18 * 1024 * 1024 + 500 * 1024,
|
||||
updateTime: '2026-06-15 14:00:00', createTime: '2026-06-12 10:00:00', creator: '张产品',
|
||||
},
|
||||
{
|
||||
id: 'ver-005', version: 'V1.1.0', track: 'app',
|
||||
packageUrl: 'https://ota.yx-canteen.com/app/cabinet/v1.1.0.apk',
|
||||
upgradePoints: '新增会员登录\n优化 UI 加载速度\n修复部分小屏适配问题',
|
||||
grayPercent: 50, rollbackFlag: true, publishTime: '2026-06-15 14:00:00',
|
||||
status: 'gray', upgradedCount: 6, creator: '张产品', createTime: '2026-06-12 10:00:00',
|
||||
id: 'ver-005', appId: 'app-cabinet-app', version: 'V1.0.0', versionName: '首版',
|
||||
releaseNote: '货柜客户端首版\n支持扫码开门、菜品展示、订单查询',
|
||||
forceUpdate: true, status: 'disabled', usingDeviceCount: 0,
|
||||
deviceCategories: ['cabinet'],
|
||||
packageName: 'cabinet-app-V1.0.0.apk', packageSize: 16 * 1024 * 1024 + 200 * 1024,
|
||||
updateTime: '2026-04-15 09:00:00', createTime: '2026-04-10 14:00:00', creator: '张产品',
|
||||
},
|
||||
{
|
||||
id: 'ver-006', version: 'V1.2.0-beta', track: 'app',
|
||||
packageUrl: 'https://ota.yx-canteen.com/app/cabinet/v1.2.0-beta.apk',
|
||||
upgradePoints: '新增健康菜品推荐(灰度验证)',
|
||||
grayPercent: 0, rollbackFlag: true, publishTime: null,
|
||||
status: 'draft', upgradedCount: 0, creator: '张产品', createTime: '2026-07-01 16:00:00',
|
||||
id: 'ver-006', appId: 'app-watch-fw', version: 'V2.0.1', versionName: '灰度版',
|
||||
releaseNote: '心率监测算法升级\n新增睡眠分析\n优化电池续航',
|
||||
forceUpdate: true, status: 'active', usingDeviceCount: 8,
|
||||
deviceCategories: ['watch'],
|
||||
packageName: 'watch-fw-V2.0.1.bin', packageSize: 5 * 1024 * 1024 + 120 * 1024,
|
||||
updateTime: '2026-06-28 22:00:00', createTime: '2026-06-26 09:30:00', creator: '王工程师',
|
||||
},
|
||||
{
|
||||
id: 'ver-007', version: 'V3.0.0', track: 'firmware',
|
||||
packageUrl: 'https://ota.yx-canteen.com/fw/healthcheck/v3.0.0.bin',
|
||||
upgradePoints: '体检一体机 V3 主版本\n新增血尿酸检测\n优化血压采样算法',
|
||||
grayPercent: 100, rollbackFlag: false, publishTime: '2026-05-20 10:00:00',
|
||||
status: 'full', upgradedCount: 4, creator: '赵工程师', createTime: '2026-05-15 14:00:00',
|
||||
id: 'ver-007', appId: 'app-healthcheck-fw', version: 'V3.0.0', versionName: '稳定版',
|
||||
releaseNote: '体检一体机 V3 主版本\n新增血尿酸检测\n优化血压采样算法',
|
||||
forceUpdate: false, status: 'disabled', usingDeviceCount: 0,
|
||||
deviceCategories: ['healthCheck'],
|
||||
packageName: 'healthcheck-fw-V3.0.0.img', packageSize: 32 * 1024 * 1024 + 768 * 1024,
|
||||
updateTime: '2026-05-20 10:00:00', createTime: '2026-05-15 14:00:00', creator: '赵工程师',
|
||||
},
|
||||
{
|
||||
id: 'ver-008', version: 'V1.5.0', track: 'firmware',
|
||||
packageUrl: 'https://ota.yx-canteen.com/fw/bodyfat/v1.5.0.bin',
|
||||
upgradePoints: '体脂算法同步 InBody 最新公式\n阻抗采样精度提升',
|
||||
grayPercent: 100, rollbackFlag: true, publishTime: '2026-06-25 17:00:00',
|
||||
status: 'full', upgradedCount: 2, creator: '孙工程师', createTime: '2026-06-22 11:00:00',
|
||||
id: 'ver-008', appId: 'app-bodyfat-fw', version: 'V1.5.0', versionName: '稳定版',
|
||||
releaseNote: '体脂算法同步 InBody 最新公式\n阻抗采样精度提升',
|
||||
forceUpdate: false, status: 'active', usingDeviceCount: 2,
|
||||
deviceCategories: ['bodyFat'],
|
||||
packageName: 'bodyfat-fw-V1.5.0.hex', packageSize: 1 * 1024 * 1024 + 512 * 1024,
|
||||
updateTime: '2026-06-25 17:00:00', createTime: '2026-06-22 11:00:00', creator: '孙工程师',
|
||||
},
|
||||
]
|
||||
|
||||
/** 从版本记录汇总应用展示字段 */
|
||||
const buildAppSummary = (app: AppRecord) => {
|
||||
const versions = versionList
|
||||
.filter((v) => v.appId === app.id)
|
||||
.sort((a, b) => (a.updateTime > b.updateTime ? -1 : 1))
|
||||
const latestVersion = versions[0]?.version ?? ''
|
||||
const versionCount = versions.length
|
||||
const updateTime = versions[0]?.updateTime ?? ''
|
||||
return {
|
||||
id: app.id,
|
||||
name: app.name,
|
||||
devSubject: app.devSubject,
|
||||
description: app.description,
|
||||
status: app.status,
|
||||
latestVersion,
|
||||
versionCount,
|
||||
updateTime,
|
||||
}
|
||||
}
|
||||
|
||||
// ============ 应用相关 mock ============
|
||||
|
||||
const listApp = (ctx: MockContext): ApiResponse<ReturnType<typeof buildAppSummary>[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = appList.filter((item) => {
|
||||
if (params.keyword) {
|
||||
const kw = params.keyword
|
||||
if (!includes(item.name, kw) && !includes(item.description, kw)) return false
|
||||
}
|
||||
return true
|
||||
})
|
||||
return ok('查询成功', filtered.map(buildAppSummary))
|
||||
}
|
||||
|
||||
const infoAppForEdit = (ctx: MockContext): ApiResponse<Partial<AppRecord> | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = appList.find((i) => i.id === params.id)
|
||||
if (!target) return ok('查询成功', null)
|
||||
return ok('查询成功', {
|
||||
id: target.id, name: target.name, devSubject: target.devSubject,
|
||||
description: target.description, status: target.status,
|
||||
})
|
||||
}
|
||||
|
||||
const addApp = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = `app-${String(appList.length + 1).padStart(3, '0')}`
|
||||
appList.unshift({
|
||||
id,
|
||||
name: String(params.name ?? ''),
|
||||
devSubject: String(params.devSubject ?? ''),
|
||||
description: String(params.description ?? ''),
|
||||
status: (params.status as AppStatus) ?? 'enabled',
|
||||
})
|
||||
return ok('新增成功')
|
||||
}
|
||||
|
||||
const updateApp = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = appList.find((i) => i.id === String(params.id ?? ''))
|
||||
if (!target) return ok('记录不存在')
|
||||
if (params.name !== undefined) target.name = String(params.name)
|
||||
if (params.devSubject !== undefined) target.devSubject = String(params.devSubject)
|
||||
if (params.description !== undefined) target.description = String(params.description)
|
||||
return ok('更新成功')
|
||||
}
|
||||
|
||||
const toggleApp = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = appList.find((i) => i.id === String(params.id ?? ''))
|
||||
if (!target) return ok('记录不存在')
|
||||
target.status = target.status === 'enabled' ? 'disabled' : 'enabled'
|
||||
return ok('状态已更新')
|
||||
}
|
||||
|
||||
// ============ 版本相关 mock ============
|
||||
|
||||
const listVersion = (ctx: MockContext): ApiResponse<VersionRecord[]> => {
|
||||
const params = getParams(ctx.params)
|
||||
const filtered = versionList.filter((item) => {
|
||||
if (params.keyword && !includes(item.version, params.keyword) && !includes(item.upgradePoints, params.keyword)) return false
|
||||
if (params.track && item.track !== params.track) return false
|
||||
if (params.status && item.status !== params.status) return false
|
||||
if (params.appId && item.appId !== params.appId) return false
|
||||
return true
|
||||
})
|
||||
return ok('查询成功', filtered)
|
||||
@@ -113,79 +242,48 @@ const infoVersion = (ctx: MockContext): ApiResponse<VersionRecord | null> => {
|
||||
return ok('查询成功', versionList.find((i) => i.id === params.id) ?? null)
|
||||
}
|
||||
|
||||
/** 编辑回填版 */
|
||||
const infoForEdit = (ctx: MockContext): ApiResponse<Partial<VersionRecord> | null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = versionList.find((i) => i.id === params.id)
|
||||
if (!target) return ok('查询成功', null)
|
||||
return ok('查询成功', {
|
||||
id: target.id, version: target.version, track: target.track,
|
||||
packageUrl: target.packageUrl, upgradePoints: target.upgradePoints,
|
||||
grayPercent: target.grayPercent, rollbackFlag: target.rollbackFlag, status: target.status,
|
||||
})
|
||||
}
|
||||
|
||||
const addVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const id = `ver-${String(versionList.length + 1).padStart(3, '0')}`
|
||||
const now = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
const record: VersionRecord = {
|
||||
const ts = now()
|
||||
versionList.unshift({
|
||||
id,
|
||||
appId: String(params.appId ?? ''),
|
||||
version: String(params.version ?? ''),
|
||||
track: params.track as VersionRecord['track'],
|
||||
packageUrl: String(params.packageUrl ?? ''),
|
||||
upgradePoints: String(params.upgradePoints ?? ''),
|
||||
grayPercent: Number(params.grayPercent ?? 0),
|
||||
rollbackFlag: Boolean(params.rollbackFlag),
|
||||
publishTime: null,
|
||||
status: (params.status as VersionRecord['status']) ?? 'draft',
|
||||
upgradedCount: 0,
|
||||
versionName: String(params.versionName ?? ''),
|
||||
releaseNote: String(params.releaseNote ?? ''),
|
||||
forceUpdate: Boolean(params.forceUpdate),
|
||||
status: (params.status as VersionStatus) ?? 'active',
|
||||
usingDeviceCount: 0,
|
||||
deviceCategories: Array.isArray(params.deviceCategories) ? (params.deviceCategories as string[]) : [],
|
||||
packageName: String(params.packageName ?? ''),
|
||||
packageSize: Number(params.packageSize) || 0,
|
||||
updateTime: ts,
|
||||
createTime: ts,
|
||||
creator: '当前用户',
|
||||
createTime: now,
|
||||
}
|
||||
versionList.unshift(record)
|
||||
})
|
||||
return ok('新增成功')
|
||||
}
|
||||
|
||||
const updateVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const toggleVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = versionList.find((i) => i.id === String(params.id ?? ''))
|
||||
if (!target) return ok('记录不存在')
|
||||
if (params.version !== undefined) target.version = String(params.version)
|
||||
if (params.track !== undefined) target.track = params.track as VersionRecord['track']
|
||||
if (params.packageUrl !== undefined) target.packageUrl = String(params.packageUrl)
|
||||
if (params.upgradePoints !== undefined) target.upgradePoints = String(params.upgradePoints)
|
||||
if (params.grayPercent !== undefined) target.grayPercent = Number(params.grayPercent)
|
||||
if (params.rollbackFlag !== undefined) target.rollbackFlag = Boolean(params.rollbackFlag)
|
||||
return ok('更新成功')
|
||||
}
|
||||
|
||||
const deleteVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const idx = versionList.findIndex((i) => i.id === String(params.id ?? ''))
|
||||
if (idx >= 0) versionList.splice(idx, 1)
|
||||
return ok('删除成功')
|
||||
}
|
||||
|
||||
const transitionVersion = (ctx: MockContext): ApiResponse<null> => {
|
||||
const params = getParams(ctx.params)
|
||||
const target = versionList.find((i) => i.id === String(params.id ?? ''))
|
||||
if (!target) return ok('记录不存在')
|
||||
const to = params.toStatus as VersionRecord['status']
|
||||
if (!to) return ok('目标状态不能为空')
|
||||
target.status = to
|
||||
if (to === 'gray' || to === 'full') {
|
||||
target.publishTime = new Date().toISOString().replace('T', ' ').slice(0, 19)
|
||||
}
|
||||
target.status = target.status === 'active' ? 'disabled' : 'active'
|
||||
target.updateTime = now()
|
||||
return ok('状态已更新')
|
||||
}
|
||||
|
||||
registerMocks([
|
||||
// 应用
|
||||
{ method: 'POST', url: '/admin/device/ops/version/app/list', handler: listApp },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/app/info-for-edit', handler: infoAppForEdit },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/app/add', handler: addApp },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/app/update', handler: updateApp },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/app/toggle', handler: toggleApp },
|
||||
// 版本
|
||||
{ method: 'POST', url: '/admin/device/ops/version/list', handler: listVersion },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/info', handler: infoVersion },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/info-for-edit', handler: infoForEdit },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/add', handler: addVersion },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/update', handler: updateVersion },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/delete', handler: deleteVersion },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/transition', handler: transitionVersion },
|
||||
{ method: 'POST', url: '/admin/device/ops/version/toggle', handler: toggleVersion },
|
||||
])
|
||||
|
||||
@@ -56,16 +56,40 @@ const paginate = <T extends AmapRecord>(list: T[], params: AmapParams): ApiRespo
|
||||
const ok = <T = null>(msg: string, data: T = null as T): ApiResponse<T> => ({ code: '00000', msg, data })
|
||||
|
||||
const amapList: AmapRecord[] = [
|
||||
{ id: 'amap-001', name: '园区地图导航服务', apiKey: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', securityCode: 'yx-sec-2026-001', authenticatedCompany: '上海寰汐科技有限公司', usage: '健康园区/食堂地图展示与路径规划', status: true, remark: '主应用,Web 端 JS API 调用', createTime: '2026-03-08 09:30:12' },
|
||||
{ id: 'amap-002', name: '设备定位上报服务', apiKey: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7', securityCode: 'yx-sec-2026-002', authenticatedCompany: '上海寰汐科技有限公司', usage: '货柜/体检设备 GPS 定位回传', status: true, createTime: '2026-03-12 14:22:45' },
|
||||
{ id: 'amap-003', name: '地址解析与逆地理编码', apiKey: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8', securityCode: 'yx-sec-2026-003', authenticatedCompany: '上海寰汐科技有限公司', usage: '用户地址 → 坐标 / 坐标 → 地址', status: true, remark: 'Web 服务 REST API', createTime: '2026-03-15 10:08:33' },
|
||||
{ id: 'amap-004', name: '小程序地图组件', apiKey: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9', securityCode: 'yx-sec-2026-004', authenticatedCompany: '杭州智养餐饮联盟', usage: 'C 端小程序内置地图与定位', status: true, createTime: '2026-03-20 16:45:09' },
|
||||
{ id: 'amap-005', name: '食堂数据大屏地图', apiKey: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', securityCode: 'yx-sec-2026-005', authenticatedCompany: '杭州智养餐饮联盟', usage: '运营大屏热力图与分布展示', status: false, remark: '大屏重构中,临时停用', createTime: '2026-04-02 11:18:27' },
|
||||
{ id: 'amap-006', name: '巡检路径追踪服务', apiKey: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1', securityCode: 'yx-sec-2026-006', authenticatedCompany: '上海寰汐科技有限公司', usage: '运维小程序巡检轨迹记录与回放', status: true, createTime: '2026-04-10 08:55:41' },
|
||||
{ id: 'amap-007', name: '行政区域查询服务', apiKey: 'a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2', securityCode: 'yx-sec-2026-007', authenticatedCompany: '杭州智养餐饮联盟', usage: '租户档案省市县三级联动', status: true, createTime: '2026-05-18 13:40:55' },
|
||||
{ id: 'amap-008', name: '健康园区打卡围栏', apiKey: 'b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3', authenticatedCompany: '北京健康园区运营公司', usage: '体检站电子围栏与自动打卡', status: false, remark: '围栏方案调整,暂停使用', createTime: '2026-06-22 15:12:18' },
|
||||
{ id: 'amap-001', name: '华为watch-5', apiKey: 'a1b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6', securityCode: 'yx-sec-2026-001', authenticatedCompany: '数字味道', usage: '健康园区/食堂地图展示与路径规划', status: true, remark: '主应用,Web 端 JS API 调用', createTime: '2026-03-08 09:30:12' },
|
||||
{ id: 'amap-002', name: '华为watch-4', apiKey: 'b2c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7', securityCode: 'yx-sec-2026-002', authenticatedCompany: '数字味道', usage: '货柜/体检设备 GPS 定位回传', status: true, createTime: '2026-03-12 14:22:45' },
|
||||
{ id: 'amap-003', name: '终端N合一APP', apiKey: 'c3d4e5f6a7b8c9d0e1f2a3b4c5d6e7f8', securityCode: 'yx-sec-2026-003', authenticatedCompany: '数字味道', usage: '用户地址 → 坐标 / 坐标 → 地址', status: true, remark: 'Web 服务 REST API', createTime: '2026-03-15 10:08:33' },
|
||||
{ id: 'amap-004', name: '小程序地图组件', apiKey: 'd4e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9', securityCode: 'yx-sec-2026-004', authenticatedCompany: '仁康科技', usage: 'C 端小程序内置地图与定位', status: true, createTime: '2026-03-20 16:45:09' },
|
||||
{ id: 'amap-005', name: '食堂数据大屏地图', apiKey: 'e5f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0', securityCode: 'yx-sec-2026-005', authenticatedCompany: '仁康科技', usage: '运营大屏热力图与分布展示', status: false, remark: '大屏重构中,临时停用', createTime: '2026-04-02 11:18:27' },
|
||||
{ id: 'amap-006', name: '巡检路径追踪服务', apiKey: 'f6a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1', securityCode: 'yx-sec-2026-006', authenticatedCompany: '数字味道', usage: '运维小程序巡检轨迹记录与回放', status: true, createTime: '2026-04-10 08:55:41' },
|
||||
{ id: 'amap-007', name: '行政区域查询服务', apiKey: 'a7b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2', securityCode: 'yx-sec-2026-007', authenticatedCompany: '仁康科技', usage: '租户档案省市县三级联动', status: true, createTime: '2026-05-18 13:40:55' },
|
||||
{ id: 'amap-008', name: '健康园区打卡围栏', apiKey: 'b8c9d0e1f2a3b4c5d6e7f8a9b0c1d2e3', authenticatedCompany: '蚁熊智能', usage: '体检站电子围栏与自动打卡', status: false, remark: '围栏方案调整,暂停使用', createTime: '2026-06-22 15:12:18' },
|
||||
]
|
||||
|
||||
/**
|
||||
* 应用软件下拉选项
|
||||
* - 业务模型:先有高德 Key/秘钥,再选择性地配置给某个应用软件使用
|
||||
* - 此处为平台已注册的可选应用软件清单(独立于 amapList 维护)
|
||||
*/
|
||||
interface AppOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
const appOptionList: AppOption[] = [
|
||||
{ label: '华为watch-5', value: '华为watch-5' },
|
||||
{ label: '华为watch-4', value: '华为watch-4' },
|
||||
{ label: '终端N合一APP', value: '终端N合一APP' },
|
||||
{ label: '小程序地图组件', value: '小程序地图组件' },
|
||||
{ label: '食堂数据大屏地图', value: '食堂数据大屏地图' },
|
||||
{ label: '巡检路径追踪服务', value: '巡检路径追踪服务' },
|
||||
{ label: '行政区域查询服务', value: '行政区域查询服务' },
|
||||
{ label: '健康园区打卡围栏', value: '健康园区打卡围栏' },
|
||||
]
|
||||
|
||||
/** 应用软件下拉选项查询 */
|
||||
const listAmapAppOptions = (): ApiResponse<AppOption[]> => ok('查询成功', appOptionList)
|
||||
|
||||
/** 高德 ApiKey 模糊匹配 */
|
||||
const matchApiKey = (item: AmapRecord, apiKey?: string): boolean => includes(item.apiKey, apiKey)
|
||||
|
||||
@@ -145,4 +169,5 @@ registerMocks([
|
||||
{ method: 'POST', url: '/admin/device/third-auth/amap/add', handler: addAmap },
|
||||
{ method: 'POST', url: '/admin/device/third-auth/amap/update', handler: updateAmap },
|
||||
{ method: 'POST', url: '/admin/device/third-auth/amap/delete', handler: deleteAmap },
|
||||
{ method: 'POST', url: '/admin/device/third-auth/amap/app-options', handler: listAmapAppOptions },
|
||||
])
|
||||
|
||||
@@ -60,18 +60,16 @@ import './device-center/ops/monitor'
|
||||
import './device-center/ops/version'
|
||||
// 设备中心 - 设备运维 / 告警管理(告警处置 + 业务日志双 Tab)
|
||||
import './device-center/ops/alarm'
|
||||
// 设备中心 - 设备运维 / 系统配置(5 分组 + 变更历史)
|
||||
import './device-center/ops/config'
|
||||
// 设备中心 - 客户管理 / 客户档案(CRUD + 详情抽屉)
|
||||
import './device-center/customer/archive'
|
||||
// 设备中心 - 客户管理 / 布点场所(CRUD,含心跳状态展示)
|
||||
import './device-center/customer/site'
|
||||
// 设备中心 - 数据分发 / 分发配置(下游系统 + 订阅映射,双 Tab CRUD)
|
||||
// 设备中心 - 数据分发 / 分发配置(单列表 CRUD + 鉴权联动 + 设备分类树多选)
|
||||
import './device-center/distribute/config'
|
||||
// 设备中心 - 数据分发 / 分发记录(含重投、批量重投、导出)
|
||||
// 设备中心 - 数据分发 / 上报配置(单列表 CRUD + 客户→场所联动 + 鉴权联动 + 设备分类树多选)
|
||||
import './device-center/distribute/report'
|
||||
// 设备中心 - 数据分发 / 分发记录(分发配置二级子页面,含 downstreamId 过滤、重投、导出)
|
||||
import './device-center/distribute/record'
|
||||
// 设备中心 - 数据分发 / 失败重投(死信队列,含手动重投、永久丢弃)
|
||||
import './device-center/distribute/deadletter'
|
||||
// 设备中心 - 第三方授权 / 虹软人脸授权(按设备绑定激活码)
|
||||
import './device-center/third-auth/arcsoft'
|
||||
// 设备中心 - 第三方授权 / 高德地图应用(API Key + 安全密钥)
|
||||
|
||||
@@ -178,17 +178,17 @@ const onResetClick = (): void => {
|
||||
}
|
||||
|
||||
&__form {
|
||||
.ant-form-item {
|
||||
margin-bottom: 12px;
|
||||
:deep(.ant-form-item) {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
|
||||
&__item {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
&__actions {
|
||||
margin-bottom: 12px;
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
<span>{{ item.label }}</span>
|
||||
</template>
|
||||
<MenuTreeNode
|
||||
v-for="child in item.children"
|
||||
v-for="child in visibleChildren"
|
||||
:key="child.key"
|
||||
:item="child"
|
||||
@navigate="onNavigate"
|
||||
@@ -45,6 +45,15 @@ const iconComp = computed(() => {
|
||||
return icon ?? 'FileOutlined'
|
||||
})
|
||||
|
||||
/** NavGroup 的可见子节点(过滤 hidden leaf) */
|
||||
const visibleChildren = computed(() => {
|
||||
if (!isGroup.value) return []
|
||||
return (props.item as NavGroup).children.filter((child) => {
|
||||
const leaf = child as NavLeaf
|
||||
return !('hidden' in leaf) || !leaf.hidden
|
||||
})
|
||||
})
|
||||
|
||||
const onLeafClick = (): void => {
|
||||
if (!isGroup.value) {
|
||||
emit('navigate', props.item as NavLeaf)
|
||||
|
||||
@@ -10,7 +10,7 @@
|
||||
@openChange="onOpenChange"
|
||||
>
|
||||
<MenuTreeNode
|
||||
v-for="item in section.children"
|
||||
v-for="item in visibleChildren"
|
||||
:key="item.key"
|
||||
:item="item"
|
||||
:active-key="activeKey"
|
||||
@@ -38,6 +38,15 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{ (e: 'navigate', leaf: NavLeaf): void }>()
|
||||
|
||||
/** 侧边栏仅渲染未隐藏的菜单(hidden 的 leaf 仍参与路由,但不显示) */
|
||||
const visibleChildren = computed(() => {
|
||||
if (!props.section) return []
|
||||
return props.section.children.filter((item) => {
|
||||
const leaf = item as NavLeaf
|
||||
return !('hidden' in leaf) || !leaf.hidden
|
||||
})
|
||||
})
|
||||
|
||||
/** 递归查找激活叶子所在的所有祖先分组 key(用于受控展开) */
|
||||
const activeGroupKeys = computed<string[]>(() => {
|
||||
if (!props.section) return []
|
||||
|
||||
+22
-18
@@ -49,6 +49,8 @@ export interface NavLeaf {
|
||||
status?: 'draft' | 'review' | 'ready'
|
||||
/** 受众限制,'designer' 仅设计可见;默认 'all' */
|
||||
audience?: 'designer' | 'all'
|
||||
/** 是否在侧边栏隐藏(仍参与路由注册,用于"操作按钮跳转"的二级子页面) */
|
||||
hidden?: boolean
|
||||
}
|
||||
|
||||
/** 二级分组(侧边栏 SubMenu,里面装三级叶子) */
|
||||
@@ -441,7 +443,7 @@ export const adminSections: NavSection[] = [
|
||||
},
|
||||
],
|
||||
},
|
||||
// 5. 设备运维(重组:原"故障与维修"+ 原"系统设置"迁入,6 个二级菜单)
|
||||
// 5. 设备运维(重组:原"故障与维修"+ 原"系统设置"迁入,5 个二级菜单)
|
||||
{
|
||||
key: 'admin-device-ops',
|
||||
label: '设备运维',
|
||||
@@ -449,7 +451,7 @@ export const adminSections: NavSection[] = [
|
||||
children: [
|
||||
{
|
||||
key: 'admin-device-ops-init',
|
||||
label: '设备初始化',
|
||||
label: '初始化设置',
|
||||
path: 'ops/init',
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/ops/init/init.vue'),
|
||||
@@ -475,6 +477,14 @@ export const adminSections: NavSection[] = [
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/ops/version/version.vue'),
|
||||
},
|
||||
{
|
||||
key: 'admin-device-ops-version-list',
|
||||
label: '版本列表',
|
||||
path: 'ops/version-list',
|
||||
status: 'ready',
|
||||
hidden: true,
|
||||
component: () => import('@pages/admin-portal/device-center/ops/version/component/versionList/versionList.vue'),
|
||||
},
|
||||
{
|
||||
key: 'admin-device-ops-alarm',
|
||||
label: '告警管理',
|
||||
@@ -482,16 +492,9 @@ export const adminSections: NavSection[] = [
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/ops/alarm/alarm.vue'),
|
||||
},
|
||||
{
|
||||
key: 'admin-device-ops-config',
|
||||
label: '系统配置',
|
||||
path: 'ops/config',
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/ops/config/config.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
// 6. 数据分发(原"数据中转"更名,本期仅支持接口回调,3 个二级菜单)
|
||||
// 6. 数据分发(2 个可见菜单:分发配置 / 上报配置;分发记录作为分发配置的二级子页面,hidden 但可路由)
|
||||
{
|
||||
key: 'admin-device-distribute',
|
||||
label: '数据分发',
|
||||
@@ -504,19 +507,20 @@ export const adminSections: NavSection[] = [
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/distribute/config/config.vue'),
|
||||
},
|
||||
{
|
||||
key: 'admin-device-distribute-report',
|
||||
label: '上报配置',
|
||||
path: 'distribute/report',
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/distribute/report/report.vue'),
|
||||
},
|
||||
{
|
||||
key: 'admin-device-distribute-record',
|
||||
label: '分发记录',
|
||||
path: 'distribute/record',
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/distribute/record/record.vue'),
|
||||
},
|
||||
{
|
||||
key: 'admin-device-distribute-deadletter',
|
||||
label: '失败重投',
|
||||
path: 'distribute/deadletter',
|
||||
status: 'ready',
|
||||
component: () => import('@pages/admin-portal/device-center/distribute/deadletter/deadletter.vue'),
|
||||
hidden: true,
|
||||
component: () => import('@pages/admin-portal/device-center/distribute/config/component/record/record.vue'),
|
||||
},
|
||||
],
|
||||
},
|
||||
|
||||
@@ -21,7 +21,13 @@
|
||||
<a-descriptions-item label="投用状态">
|
||||
<a-tag :color="deployStatusColor(detail.deployStatus)">{{ deployStatusText(detail.deployStatus) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="软件版本">{{ detail.softwareVersion ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="软件版本">
|
||||
<template v-if="detail.packageName || detail.softwareVersion">
|
||||
<div class="info-drawer__pkg">{{ detail.packageName || '—' }}</div>
|
||||
<div class="info-drawer__ver">{{ detail.softwareVersion || '—' }}</div>
|
||||
</template>
|
||||
<template v-else>—</template>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="所属客户">{{ detail.customerName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属场所">{{ detail.siteName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="入库时间">{{ detail.onlineTime ?? '—' }}</a-descriptions-item>
|
||||
@@ -114,4 +120,15 @@ const deployStatusText = (status: DeployStatus | undefined): string => {
|
||||
.info-drawer__section {
|
||||
margin-top: 16px;
|
||||
}
|
||||
.info-drawer__pkg {
|
||||
color: #8c8c8c;
|
||||
font-size: 12px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
.info-drawer__ver {
|
||||
color: #262626;
|
||||
font-size: 13px;
|
||||
font-weight: 500;
|
||||
line-height: 1.6;
|
||||
}
|
||||
</style>
|
||||
|
||||
+3
-1
@@ -45,7 +45,9 @@ export interface DetailInfo {
|
||||
siteName: string
|
||||
/** 投用状态 */
|
||||
deployStatus: DeployStatus
|
||||
/** 软件版本 */
|
||||
/** 软件包名(初始化设置时写入) */
|
||||
packageName: string
|
||||
/** 软件版本(初始化设置时写入) */
|
||||
softwareVersion: string
|
||||
/** 入网时间 */
|
||||
onlineTime: string
|
||||
|
||||
+6
-16
@@ -101,17 +101,6 @@
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="软件版本">
|
||||
<a-input
|
||||
v-model:value="form.softwareVersion"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="30"
|
||||
placeholder="如 V1.0.0"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="投用状态">
|
||||
<a-tag :color="form.deployStatus === 'deployed' ? 'success' : 'default'">
|
||||
@@ -120,9 +109,6 @@
|
||||
<span class="deploy-tip">由客户 / 场所自动判定</span>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="虹软授权码">
|
||||
<a-input
|
||||
@@ -134,7 +120,10 @@
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col v-if="showSimIccid" :span="12">
|
||||
</a-row>
|
||||
|
||||
<a-row v-if="showSimIccid" :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="绑定 SIM 卡">
|
||||
<a-input
|
||||
v-model:value="form.simIccid"
|
||||
@@ -167,8 +156,9 @@
|
||||
*
|
||||
* modal-spec.md 强制项已遵循。
|
||||
* 业务字段:
|
||||
* - 通用字段:编码 / 类别 / 名称 / 序列号 / 客户 / 场所 / 软件版本 / 投用状态 / 虹软授权码
|
||||
* - 通用字段:编码 / 类别 / 名称 / 序列号 / 客户 / 场所 / 投用状态 / 虹软授权码
|
||||
* - 绑定 SIM 卡:仅当所选类别 networkMode === 'sim' 时展示
|
||||
* - 软件包名 / 软件版本:不在本弹框采集,由后续"初始化设置"流程写入
|
||||
* - 所属客户 / 场所 均为可搜索下拉,场所按客户联动过滤
|
||||
* - 5 个 Tab 共用同一表单,无差异化字段区块
|
||||
*/
|
||||
|
||||
+3
-2
@@ -13,6 +13,8 @@
|
||||
* - 所属客户 / 所属场所 均为可搜索下拉,场所按客户联动过滤
|
||||
* - 投用状态由客户 / 场所自动判定(任一有值 → deployed)
|
||||
* - 绑定 SIM 卡字段仅当所选类别 networkMode === 'sim' 时展示
|
||||
* - 软件包名 / 软件版本 不在弹框采集(由后续"初始化设置"流程写入);
|
||||
* 编辑回填时 info 接口仍会返回,经 ...data 透传到 params 一并提交,保证后端不丢字段
|
||||
* - 类别元数据(isIot/dataTransit/dataReport/networkMode/manufacturer/model)
|
||||
* 由后端按所选 deviceCategory 在 add/update 时回填到设备记录,前端表单不直接编辑
|
||||
*/
|
||||
@@ -38,7 +40,6 @@ const createInitForm = (): Form => ({
|
||||
siteId: '',
|
||||
siteName: '',
|
||||
deployStatus: 'in_stock',
|
||||
softwareVersion: '',
|
||||
deployTime: '',
|
||||
onlineTime: '',
|
||||
arcLicense: '',
|
||||
@@ -126,7 +127,7 @@ export const usePage = (emit: (e: 'load') => void) => {
|
||||
const rules: Record<string, Rule[]> = {
|
||||
deviceCode: [{ required: true, message: '请输入设备编码', trigger: 'blur' }],
|
||||
deviceCategory: [{ required: true, message: '请选择设备类别', trigger: 'change' }],
|
||||
// 设备名称 / 序列号 / 客户 / 场所 / 软件版本 / 虹软授权码 / SIM 卡 均非必填
|
||||
// 设备名称 / 序列号 / 客户 / 场所 / 虹软授权码 / SIM 卡 均非必填
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
|
||||
+1
-2
@@ -6,6 +6,7 @@
|
||||
* - activeTab 由 openModal 上下文传入,仅用于切换设备类别下拉数据源
|
||||
* - 所属客户 / 所属场所 非必填:留空代表在库未投用,由 usePage 联动计算 deployStatus
|
||||
* - 绑定 SIM 卡字段仅当所选类别 networkMode === 'sim' 时展示
|
||||
* - 软件包名 / 软件版本 不在弹框采集(由后续"初始化设置"流程写入),编辑回填时仅做透传
|
||||
*/
|
||||
|
||||
import type { SelectProps } from 'ant-design-vue'
|
||||
@@ -46,8 +47,6 @@ export interface Form {
|
||||
siteName: string
|
||||
/** 投用状态(依据客户 / 场所自动判定) */
|
||||
deployStatus: DeployStatus
|
||||
/** 软件版本 */
|
||||
softwareVersion: string
|
||||
/** 投用时间(投用后才存在,编辑回填) */
|
||||
deployTime: string
|
||||
/** 入网时间(编辑回填,新增时由后端写入) */
|
||||
|
||||
@@ -37,7 +37,23 @@ const buildCommonColumns = (): TableColumnsType => [
|
||||
},
|
||||
{ title: '所属客户', dataIndex: 'customerName', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
{ title: '所属场所', dataIndex: 'siteName', align: 'left', width: 160, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
{ title: '软件版本', dataIndex: 'softwareVersion', align: 'center', width: 110, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
{
|
||||
title: '软件版本',
|
||||
dataIndex: 'softwareVersion',
|
||||
align: 'center',
|
||||
width: 130,
|
||||
resizable: true,
|
||||
ellipsis: true,
|
||||
customRender: ({ record }: { record: DeviceItem }) => {
|
||||
const pkg = record.packageName ?? ''
|
||||
const ver = record.softwareVersion ?? ''
|
||||
if (!pkg && !ver) return '—'
|
||||
return h('div', { style: 'line-height: 1.4; text-align: center' }, [
|
||||
pkg ? h('div', { style: 'color: #8c8c8c; font-size: 12px' }, pkg) : null,
|
||||
ver ? h('div', { style: 'color: #262626; font-size: 13px; font-weight: 500' }, ver) : null,
|
||||
])
|
||||
},
|
||||
},
|
||||
{ title: '入库单号', dataIndex: 'inboundCode', align: 'left', width: 170, resizable: true, ellipsis: true, customRender: ({ value }: { value?: string }) => value ?? '—' },
|
||||
{ title: '入库时间', dataIndex: 'onlineTime', align: 'center', width: 170, sorter: true, resizable: true },
|
||||
{ title: '投用时间', dataIndex: 'deployTime', align: 'center', width: 170, resizable: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
|
||||
@@ -41,7 +41,9 @@ export interface DeviceItem extends BaseRecord {
|
||||
siteName: string
|
||||
/** 投用状态:在库 / 投用(依据客户与场所自动判定) */
|
||||
deployStatus: DeployStatus
|
||||
/** 软件版本 */
|
||||
/** 软件包名(初始化设置时写入,与 softwareVersion 同时产生) */
|
||||
packageName: string
|
||||
/** 软件版本(初始化设置时写入,新增/编辑弹框不采集) */
|
||||
softwareVersion: string
|
||||
/** 入网时间(即入库时间) */
|
||||
onlineTime: string
|
||||
|
||||
+1
-1
@@ -21,7 +21,7 @@
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="60"
|
||||
placeholder="如 长庆总部一食堂"
|
||||
placeholder="如 国庆总部一食堂"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* 数据分发 - 分发配置 接口层
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { CustomerOption } from '../types'
|
||||
import type { ConfigItem, ConfigPayload, SearchForm } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config'
|
||||
|
||||
/** 列表查询 */
|
||||
export const list = (params: SearchForm): Promise<ApiResponse<ConfigItem[]>> =>
|
||||
postRequest('axiosRequest', BASE + '/list', params)
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<ConfigItem>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
|
||||
/** 新增 */
|
||||
export const add = (params: ConfigPayload): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
/** 编辑 */
|
||||
export const update = (params: ConfigPayload): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
|
||||
/** 切换启用状态 */
|
||||
export const toggleStatus = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/toggleStatus', { id })
|
||||
|
||||
/**
|
||||
* 客户下拉选项(复用客户档案列表接口,仅取 id/name)
|
||||
* - 路径与 @pages/admin-portal/device-center/customer/archive/api 一致
|
||||
* - 研发接手时该接口保持不变,本处无需修改
|
||||
*/
|
||||
export const customerOptions = (): Promise<ApiResponse<{ id: string; name: string }[]>> =>
|
||||
postRequest('axiosRequest', '/admin/device/customer/archive/page', {})
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 分发配置 - 详情抽屉 - 接口层
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DetailInfo } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config'
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
@@ -0,0 +1,64 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-descriptions v-if="detail" title="基本信息" :column="2" bordered size="small">
|
||||
<a-descriptions-item label="下游系统名称">{{ detail.name ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="系统标识">{{ detail.code ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属客户">{{ detail.customerName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="分发模式">
|
||||
<a-tag :color="dispatchModeColor[detail.dispatchMode]">{{ dispatchModeText(detail.dispatchMode) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="数据分发地址" :span="2">{{ detail.callbackUrl ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权开关">
|
||||
<a-tag :color="detail.authEnabled ? 'green' : 'default'">{{ detail.authEnabled ? '已开启' : '已关闭' }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor[detail.status]">{{ statusText(detail.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最大重试次数">{{ detail.maxRetry ?? 0 }} 次</a-descriptions-item>
|
||||
<a-descriptions-item label="最新通讯时间">{{ detail.lastCommunicateTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="覆盖设备" :span="2">
|
||||
<a-tag
|
||||
v-for="name in detail.deviceCategoryNames"
|
||||
:key="name"
|
||||
color="blue"
|
||||
style="margin-bottom: 2px"
|
||||
>{{ name }}</a-tag>
|
||||
<span v-if="!detail.deviceCategoryNames || detail.deviceCategoryNames.length === 0">—</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions v-if="detail && detail.authEnabled" title="鉴权信息" :column="1" bordered size="small" style="margin-top: 16px">
|
||||
<a-descriptions-item label="鉴权账号">{{ detail.authAccount ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权秘钥">{{ detail.authSecret ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权接口地址">{{ detail.authApiUrl ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分发配置 - 详情抽屉
|
||||
* - 只读展示,所有字段 ?? '—'
|
||||
* - 鉴权关闭时不展示鉴权信息区块
|
||||
*/
|
||||
import { usePage } from './init/usePage'
|
||||
import {
|
||||
dispatchModeColor,
|
||||
dispatchModeText,
|
||||
statusColor,
|
||||
statusText,
|
||||
} from '../../../types'
|
||||
|
||||
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
|
||||
|
||||
defineExpose({ openDrawer, closeDrawer })
|
||||
</script>
|
||||
+19
-16
@@ -1,44 +1,47 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import * as api from '../api'
|
||||
/**
|
||||
* 分发配置 - 详情抽屉 - 主逻辑
|
||||
*/
|
||||
import { info } from '../api'
|
||||
import type { DetailInfo, OpenContext, PageInfo } from '../types'
|
||||
|
||||
export const usePage = () => {
|
||||
const { message } = useAntdStaticMethods()
|
||||
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
title: '下游系统详情',
|
||||
width: 720,
|
||||
title: '分发配置详情',
|
||||
width: 760,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const detail = ref<DetailInfo | null>(null)
|
||||
|
||||
const closeDrawer = (): void => {
|
||||
pageInfo.visible = false
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
const openDrawer = (ctx: OpenContext): void => {
|
||||
pageInfo.title = ctx.title ?? '下游系统详情'
|
||||
pageInfo.visible = true
|
||||
pageInfo.spin = true
|
||||
detail.value = null
|
||||
api.getInfo(ctx.id)
|
||||
|
||||
info(ctx.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000' && res.data) {
|
||||
detail.value = res.data
|
||||
} else {
|
||||
void message.error(res.msg || '查询失败')
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('查询失败:', err)
|
||||
console.error('分发配置详情查询失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const closeDrawer = (): void => {
|
||||
pageInfo.visible = false
|
||||
return {
|
||||
pageInfo,
|
||||
detail,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
}
|
||||
|
||||
return { pageInfo, detail, openDrawer, closeDrawer }
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { ConfigItem } from '../../../types'
|
||||
|
||||
/** 抽屉页面状态 */
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
/** 打开抽屉上下文 */
|
||||
export interface OpenContext {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** 详情数据(与列表项 ConfigItem 同构) */
|
||||
export type DetailInfo = ConfigItem
|
||||
+203
@@ -0,0 +1,203 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:keyboard="false"
|
||||
:mask-closable="false"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-col="{ style: { width: '120px', minWidth: '120px' } }"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="下游系统名称" name="name">
|
||||
<a-input
|
||||
v-model:value="form.name"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="60"
|
||||
placeholder="如:体重管理系统"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="系统标识" name="code">
|
||||
<a-input
|
||||
v-model:value="form.code"
|
||||
v-only-alphanumeric
|
||||
:disabled="!!form.id"
|
||||
allow-clear
|
||||
:maxlength="40"
|
||||
placeholder="如:WEIGHT-MGMT"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="所属客户" name="customerId">
|
||||
<a-select
|
||||
v-model:value="form.customerId"
|
||||
:options="customerOptions"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOption"
|
||||
placeholder="请选择所属客户"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="分发模式" name="dispatchMode">
|
||||
<a-select v-model:value="form.dispatchMode" :options="DISPATCH_MODE_OPTIONS" placeholder="请选择分发模式" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="数据分发地址" name="callbackUrl">
|
||||
<a-input
|
||||
v-model:value="form.callbackUrl"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="200"
|
||||
placeholder="https://downstream.example.com/api/v1/iot/callback"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="覆盖设备" name="deviceCategoryIds">
|
||||
<a-tree-select
|
||||
v-model:value="form.deviceCategoryIds"
|
||||
:tree-data="categoryTree"
|
||||
:field-names="treeFieldNames"
|
||||
tree-checkable
|
||||
tree-default-expand-all
|
||||
show-search
|
||||
tree-node-filter-prop="name"
|
||||
allow-clear
|
||||
multiple
|
||||
placeholder="选择该分发配置覆盖的设备分类(可多选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="鉴权开关" name="authEnabled">
|
||||
<a-switch v-model:checked="form.authEnabled" checked-children="开启" un-checked-children="关闭" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="form.status" :options="STATUS_OPTIONS" placeholder="请选择状态" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<template v-if="form.authEnabled">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="鉴权账号" name="authAccount">
|
||||
<a-input
|
||||
v-model:value="form.authAccount"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="60"
|
||||
placeholder="鉴权账号 / AppID"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="鉴权秘钥" name="authSecret">
|
||||
<a-input-password
|
||||
v-model:value="form.authSecret"
|
||||
allow-clear
|
||||
:maxlength="120"
|
||||
placeholder="鉴权秘钥 / Secret"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="鉴权接口地址" name="authApiUrl">
|
||||
<a-input
|
||||
v-model:value="form.authApiUrl"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="200"
|
||||
placeholder="https://downstream.example.com/api/v1/auth/token"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="最大重试次数" name="maxRetry">
|
||||
<a-input-number
|
||||
v-model:value="form.maxRetry"
|
||||
:min="0"
|
||||
:max="20"
|
||||
style="width: 100%"
|
||||
placeholder="0 ~ 20"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="备注">
|
||||
<a-textarea
|
||||
v-model:value="form.remark"
|
||||
:rows="2"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
placeholder="可选"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="closeModal">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="pageInfo.spin"
|
||||
:loading="pageInfo.spin"
|
||||
@click="submit"
|
||||
>确定</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分发配置 - 新增/编辑 弹框
|
||||
* - 鉴权开关联动:开启时显示鉴权账号 / 鉴权秘钥 / 鉴权接口地址,并设为必填
|
||||
* - 所属客户、分发模式为下拉;覆盖设备为设备分类树多选
|
||||
*/
|
||||
import { filterOption } from '@utils/antDesign/select'
|
||||
import { usePage } from './init/usePage'
|
||||
import { DISPATCH_MODE_OPTIONS, STATUS_OPTIONS } from '../../../types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'load'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
pageInfo,
|
||||
formRef,
|
||||
form,
|
||||
rules,
|
||||
customerOptions,
|
||||
categoryTree,
|
||||
treeFieldNames,
|
||||
openModal,
|
||||
closeModal,
|
||||
submit,
|
||||
} = usePage(emit)
|
||||
|
||||
defineExpose({ openModal })
|
||||
</script>
|
||||
+7
-4
@@ -1,18 +1,21 @@
|
||||
/**
|
||||
* 设备初始化 - 新增/编辑弹框 - 接口层
|
||||
* - 复用主页 add/update/info 接口
|
||||
* 分发配置 - 新增/编辑 弹框 - 接口层
|
||||
* - 复用主页的 info / add / update
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { Form } from '../types'
|
||||
|
||||
const BASE = '/admin/device/ops/init'
|
||||
const BASE = '/admin/device/distribute/config'
|
||||
|
||||
/** 详情查询(编辑回填) */
|
||||
export const info = (id: string): Promise<ApiResponse<Form>> =>
|
||||
postRequest('axiosRequest', BASE + '/info-for-edit', { id })
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
|
||||
/** 新增 */
|
||||
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
/** 编辑 */
|
||||
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
+236
@@ -0,0 +1,236 @@
|
||||
/**
|
||||
* 分发配置 - 新增/编辑 弹框 - 主逻辑
|
||||
*
|
||||
* 严格遵循 modal-spec.md:
|
||||
* - createInitForm 工厂函数
|
||||
* - openModal 用 Promise.all 拉取详情 + 客户下拉 + 设备分类树
|
||||
* - addRequest 必须 delete params.id
|
||||
* - submit 开头立即 pageInfo.spin = true
|
||||
* - 用 params.id 判断新增/编辑
|
||||
*/
|
||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { deviceCategoryTree } from '@api'
|
||||
import type { DeviceCategoryTreeNode } from '@api'
|
||||
import { customerOptions as fetchCustomerOptions } from '../../../../api'
|
||||
import { add, edit, info } from '../api'
|
||||
import type { CustomerOption, Form, ModalType, OpenContext, PageInfo } from '../types'
|
||||
|
||||
/** 初始表单工厂 */
|
||||
const createInitForm = (): Form => ({
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
customerId: undefined,
|
||||
callbackUrl: '',
|
||||
dispatchMode: 'realtime',
|
||||
authEnabled: false,
|
||||
authAccount: '',
|
||||
authSecret: '',
|
||||
authApiUrl: '',
|
||||
status: 'enabled',
|
||||
deviceCategoryIds: [],
|
||||
maxRetry: 5,
|
||||
remark: '',
|
||||
})
|
||||
|
||||
export const usePage = (emit: (e: 'load') => void) => {
|
||||
const { message } = useAntdStaticMethods()
|
||||
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
type: 'add',
|
||||
title: '',
|
||||
width: 820,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = ref<Form>(createInitForm())
|
||||
|
||||
/** 客户下拉选项 */
|
||||
const customerOptions = ref<CustomerOption[]>([])
|
||||
/** 设备分类树(a-tree-select 数据源) */
|
||||
const categoryTree = ref<DeviceCategoryTreeNode[]>([])
|
||||
/** a-tree-select 字段映射(与设备分类树节点字段对齐) */
|
||||
const treeFieldNames = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
name: [{ required: true, message: '请输入下游系统名称', trigger: 'blur' }],
|
||||
code: [{ required: true, message: '请输入系统标识', trigger: 'blur' }],
|
||||
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
|
||||
callbackUrl: [{ required: true, message: '请输入数据分发地址', trigger: 'blur' }],
|
||||
dispatchMode: [{ required: true, message: '请选择分发模式', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
maxRetry: [{ required: true, message: '请输入最大重试次数', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
/** 鉴权开关联动:开启时鉴权字段必填 */
|
||||
const authFieldRules = (field: 'authAccount' | 'authSecret' | 'authApiUrl'): Rule[] => {
|
||||
return [{
|
||||
required: true,
|
||||
message: `鉴权开启时必填`,
|
||||
trigger: 'blur',
|
||||
validator: (_rule: unknown, value: string) => {
|
||||
if (form.value.authEnabled && !value) return Promise.reject(new Error('鉴权开启时必填'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
}] as Rule[]
|
||||
}
|
||||
|
||||
const rulesWithAuth: Record<string, Rule[]> = {
|
||||
...rules,
|
||||
authAccount: authFieldRules('authAccount'),
|
||||
authSecret: authFieldRules('authSecret'),
|
||||
authApiUrl: authFieldRules('authApiUrl'),
|
||||
}
|
||||
|
||||
/** 拉取客户下拉 */
|
||||
const loadCustomerOptions = (): void => {
|
||||
fetchCustomerOptions()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
customerOptions.value = (res.data ?? []).map((c) => ({ label: c.name, value: c.id }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('客户下拉加载失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
/** 拉取设备分类树 */
|
||||
const loadCategoryTree = (): void => {
|
||||
deviceCategoryTree()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
categoryTree.value = res.data ?? []
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('加载设备分类树失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
formRef.value?.clearValidate()
|
||||
form.value = createInitForm()
|
||||
}
|
||||
|
||||
const closeModal = (): void => {
|
||||
resetForm()
|
||||
pageInfo.visible = false
|
||||
}
|
||||
|
||||
const infoRequest = (id: string) => info(id)
|
||||
|
||||
/** 打开弹框 */
|
||||
const openModal = (type: ModalType, ctx?: OpenContext): void => {
|
||||
pageInfo.visible = true
|
||||
pageInfo.spin = true
|
||||
pageInfo.type = type
|
||||
resetForm()
|
||||
|
||||
pageInfo.title = type === 'add' ? '新增分发配置' : '编辑分发配置'
|
||||
|
||||
/** 客户下拉 / 设备分类树为空时补拉一次 */
|
||||
if (!customerOptions.value.length) loadCustomerOptions()
|
||||
if (!categoryTree.value.length) loadCategoryTree()
|
||||
|
||||
const requests: Promise<unknown>[] = []
|
||||
if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id))
|
||||
|
||||
Promise.all(requests)
|
||||
.then((results) => {
|
||||
if (type === 'edit' && ctx?.id) {
|
||||
const infoRes = results[0] as { code: string; data: Form }
|
||||
if (infoRes?.code === '00000' && infoRes.data) {
|
||||
form.value = { ...createInitForm(), ...infoRes.data }
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('打开弹框初始化失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const successRequest = (msg: string): void => {
|
||||
void message.success(msg)
|
||||
emit('load')
|
||||
closeModal()
|
||||
}
|
||||
|
||||
const addRequest = (params: Record<string, unknown>): void => {
|
||||
delete params.id
|
||||
/** 鉴权关闭时清空鉴权字段,避免脏数据 */
|
||||
if (!params.authEnabled) {
|
||||
params.authAccount = ''
|
||||
params.authSecret = ''
|
||||
params.authApiUrl = ''
|
||||
}
|
||||
add(params)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') successRequest(res.msg)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('新增失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const editRequest = (params: Record<string, unknown>): void => {
|
||||
if (!params.authEnabled) {
|
||||
params.authAccount = ''
|
||||
params.authSecret = ''
|
||||
params.authApiUrl = ''
|
||||
}
|
||||
edit(params)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') successRequest(res.msg)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('编辑失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const submit = (): void => {
|
||||
pageInfo.spin = true
|
||||
formRef.value
|
||||
?.validate()
|
||||
.then(() => {
|
||||
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
|
||||
if (params.id) editRequest(params)
|
||||
else addRequest(params)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
pageInfo.spin = false
|
||||
console.error('表单验证失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomerOptions()
|
||||
loadCategoryTree()
|
||||
})
|
||||
|
||||
return {
|
||||
pageInfo,
|
||||
formRef,
|
||||
form,
|
||||
rules: rulesWithAuth,
|
||||
customerOptions,
|
||||
categoryTree,
|
||||
treeFieldNames,
|
||||
openModal,
|
||||
closeModal,
|
||||
submit,
|
||||
}
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
import type { FormInstance } from 'ant-design-vue/es/form'
|
||||
import type { DeviceCategoryTreeNode } from '@api'
|
||||
import type { DispatchMode, Status } from '../../../types'
|
||||
import type { CustomerOption } from '../../../types'
|
||||
|
||||
/** 弹框类型 */
|
||||
export type ModalType = 'add' | 'edit'
|
||||
|
||||
/** 弹框页面状态 */
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
type: ModalType
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
/** 打开弹框上下文 */
|
||||
export interface OpenContext {
|
||||
/** 编辑模式必传 */
|
||||
id?: string
|
||||
}
|
||||
|
||||
/** 表单字段(对齐 ConfigPayload) */
|
||||
export interface Form {
|
||||
id: string | undefined
|
||||
/** 下游系统名称 */
|
||||
name: string
|
||||
/** 系统标识(编辑时禁用) */
|
||||
code: string
|
||||
/** 所属客户 ID */
|
||||
customerId: string | undefined
|
||||
/** 数据分发地址 */
|
||||
callbackUrl: string
|
||||
/** 分发模式 */
|
||||
dispatchMode: DispatchMode
|
||||
/** 鉴权开关 */
|
||||
authEnabled: boolean
|
||||
/** 鉴权账号 */
|
||||
authAccount: string
|
||||
/** 鉴权秘钥 */
|
||||
authSecret: string
|
||||
/** 鉴权接口地址 */
|
||||
authApiUrl: string
|
||||
/** 状态 */
|
||||
status: Status
|
||||
/** 覆盖设备(设备分类 ID 多选) */
|
||||
deviceCategoryIds: string[]
|
||||
/** 最大重试次数 */
|
||||
maxRetry: number
|
||||
/** 备注 */
|
||||
remark: string
|
||||
}
|
||||
|
||||
/** 客户下拉选项(与主页共享类型) */
|
||||
export type { CustomerOption }
|
||||
|
||||
/** 设备分类树节点(与 @api 对齐) */
|
||||
export type CategoryTreeNode = DeviceCategoryTreeNode
|
||||
+9
-8
@@ -1,31 +1,32 @@
|
||||
/**
|
||||
* 分发记录 - 接口层
|
||||
*/
|
||||
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DetailInfo, RecordItem, SearchForm, StatCards } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/record'
|
||||
|
||||
/** 列表查询 */
|
||||
export const list = (params: SearchForm): Promise<ApiResponse<RecordItem[]>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/list', params)
|
||||
postRequest('axiosRequest', BASE + '/list', params)
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/info', { id })
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
|
||||
/** 顶部统计卡 */
|
||||
export const stat = (): Promise<ApiResponse<StatCards>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/stat', {})
|
||||
export const stat = (params: { downstreamId?: string }): Promise<ApiResponse<StatCards>> =>
|
||||
postRequest('axiosRequest', BASE + '/stat', params)
|
||||
|
||||
/** 手动重投(单条) */
|
||||
export const retry = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/retry', { id })
|
||||
postRequest('axiosRequest', BASE + '/retry', { id })
|
||||
|
||||
/** 批量重投 */
|
||||
export const batchRetry = (ids: string[]): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/batchRetry', { ids })
|
||||
postRequest('axiosRequest', BASE + '/batchRetry', { ids })
|
||||
|
||||
/** 导出 */
|
||||
export const exportList = (params: SearchForm): Promise<ApiResponse<{ url: string }>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/export', params)
|
||||
postRequest('axiosRequest', BASE + '/export', params)
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 分发记录 - 详情抽屉 - 接口层
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DetailInfo } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/record'
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
+31
-12
@@ -6,29 +6,42 @@
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-descriptions v-if="detail" :column="2" bordered size="small">
|
||||
<a-descriptions v-if="detail" title="分发信息" :column="2" bordered size="small">
|
||||
<a-descriptions-item label="分发时间">{{ detail.dispatchTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="分发状态">
|
||||
<a-tag :color="dispatchStatusColor[detail.status]">{{ dispatchStatusText(detail.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="设备编码">{{ detail.deviceCode ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="设备名称">{{ detail.deviceName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="数据类型">{{ dataTypeText(detail.dataType) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="下游系统">{{ detail.downstreamName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="分发时间">{{ detail.dispatchTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="HTTP 状态">
|
||||
<a-tag :color="detail.httpStatus >= 200 && detail.httpStatus < 300 ? 'success' : 'error'">{{ detail.httpStatus }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="响应耗时">{{ detail.responseTime ?? 0 }} ms</a-descriptions-item>
|
||||
<a-descriptions-item label="重试次数">{{ detail.retryCount ?? 0 }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="dispatchStatusColor[detail.status]">{{ dispatchStatusText(detail.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权密钥">{{ detail.authKey ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="失败原因" :span="2">{{ detail.failureReason ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-card v-if="detail" title="请求体" size="small" :bordered="false" class="detail-body">
|
||||
<pre>{{ formatJson(detail.requestBody) }}</pre>
|
||||
</a-card>
|
||||
<a-card v-if="detail" title="响应体" size="small" :bordered="false" class="detail-body">
|
||||
<pre>{{ formatJson(detail.responseBody) }}</pre>
|
||||
<!-- 成功 → 展示分发数据格式(请求体)+ 响应数据格式(响应体) -->
|
||||
<template v-if="detail && detail.status === 'success'">
|
||||
<a-card title="分发数据格式(请求体)" size="small" :bordered="false" class="detail-body">
|
||||
<pre>{{ formatJson(detail.requestBody) }}</pre>
|
||||
</a-card>
|
||||
<a-card title="响应数据格式(响应体)" size="small" :bordered="false" class="detail-body">
|
||||
<pre>{{ formatJson(detail.responseBody) }}</pre>
|
||||
</a-card>
|
||||
</template>
|
||||
|
||||
<!-- 失败 → 展示失败报错日志 -->
|
||||
<a-card
|
||||
v-if="detail && detail.status !== 'success'"
|
||||
title="失败报错日志"
|
||||
size="small"
|
||||
:bordered="false"
|
||||
class="detail-body detail-error"
|
||||
>
|
||||
<pre>{{ detail.failureReason || '—' }}</pre>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
@@ -37,7 +50,8 @@
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 分发记录 - 详情抽屉
|
||||
* - 字段 + 请求体/响应体 JSON 美化展示
|
||||
* - 成功:展示分发数据格式(请求体 JSON)+ 响应数据格式(响应体 JSON)
|
||||
* - 失败 / 重投中:展示失败报错日志
|
||||
*/
|
||||
import { usePage } from './init/usePage'
|
||||
import { dataTypeText, dispatchStatusColor, dispatchStatusText } from '../../../types'
|
||||
@@ -73,4 +87,9 @@ defineExpose({ openDrawer, closeDrawer })
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
|
||||
.detail-error pre {
|
||||
color: #cf1322;
|
||||
background: #fff1f0;
|
||||
}
|
||||
</style>
|
||||
+4
-5
@@ -1,14 +1,13 @@
|
||||
/**
|
||||
* 分发记录 - 详情抽屉 - 主逻辑
|
||||
*/
|
||||
|
||||
import { getInfo } from '../api'
|
||||
import { info } from '../api'
|
||||
import type { DetailInfo, OpenContext, PageInfo } from '../types'
|
||||
|
||||
export const usePage = () => {
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
title: '分发详情',
|
||||
title: '分发记录详情',
|
||||
width: 760,
|
||||
spin: false,
|
||||
})
|
||||
@@ -25,14 +24,14 @@ export const usePage = () => {
|
||||
pageInfo.spin = true
|
||||
detail.value = null
|
||||
|
||||
getInfo(ctx.id)
|
||||
info(ctx.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000' && res.data) {
|
||||
detail.value = res.data
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('分发详情查询失败:', err)
|
||||
console.error('分发记录详情查询失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { DetailInfo } from '../../../types'
|
||||
|
||||
/** 抽屉页面状态 */
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
/** 打开抽屉上下文 */
|
||||
export interface OpenContext {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** 详情数据复用 record 模块 DetailInfo */
|
||||
export type { DetailInfo }
|
||||
+23
-7
@@ -1,5 +1,6 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { createPaginationConfig } from '@utils/antDesign/table'
|
||||
import type { TableState } from '@utils/antDesign/table'
|
||||
@@ -10,15 +11,23 @@ import { createSearchKey } from './useSearch'
|
||||
import { tableColumns } from './useTable'
|
||||
|
||||
/**
|
||||
* 分发记录页主编排:
|
||||
* - 顶部统计卡(今日分发 / 成功率 / 平均响应耗时 / 失败待重投)
|
||||
* - 标准列表 + 详情抽屉(请求体/响应体)
|
||||
* 分发记录子页面主编排:
|
||||
* - 入口:从分发配置列表「分发记录」按钮跳转,URL 携带 downstreamId / downstreamName
|
||||
* - 顶部统计卡(按 downstreamId 维度统计)
|
||||
* - 标准列表 + 详情抽屉(请求体/响应体/失败日志)
|
||||
* - 手动重投(单条/批量)+ 导出
|
||||
* - 顶部"返回分发配置"按钮
|
||||
*/
|
||||
export const usePage = () => {
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const { message, Modal } = useAntdStaticMethods()
|
||||
|
||||
const search = reactive<SearchForm>(createSearchKey())
|
||||
/** 从 URL query 注入 downstreamId(隐藏筛选项,重置时不清除) */
|
||||
const downstreamId = (route.query.downstreamId as string | undefined) ?? undefined
|
||||
const downstreamName = (route.query.downstreamName as string | undefined) ?? undefined
|
||||
|
||||
const search = reactive<SearchForm>(createSearchKey(downstreamId))
|
||||
|
||||
const table = reactive<TableState<RecordItem>>({
|
||||
columns: tableColumns,
|
||||
@@ -38,7 +47,6 @@ export const usePage = () => {
|
||||
failedPending: 0,
|
||||
})
|
||||
|
||||
/** 详情抽屉 ref */
|
||||
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
|
||||
|
||||
const listRequest = (): void => {
|
||||
@@ -59,7 +67,7 @@ export const usePage = () => {
|
||||
}
|
||||
|
||||
const statRequest = (): void => {
|
||||
stat()
|
||||
stat({ downstreamId })
|
||||
.then((res) => {
|
||||
if (res.code === '00000' && res.data) {
|
||||
statCards.value = res.data
|
||||
@@ -76,7 +84,8 @@ export const usePage = () => {
|
||||
}
|
||||
|
||||
const resetQuery = (): void => {
|
||||
Object.assign(search, createSearchKey())
|
||||
/** 仅重置非 downstreamId 的筛选项 */
|
||||
Object.assign(search, createSearchKey(downstreamId))
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
@@ -93,6 +102,11 @@ export const usePage = () => {
|
||||
},
|
||||
}
|
||||
|
||||
/** 返回分发配置页 */
|
||||
const goBackToConfig = (): void => {
|
||||
router.push('/admin-portal/device-center/distribute/config')
|
||||
}
|
||||
|
||||
/** 打开详情抽屉 */
|
||||
const openInfoDrawer = (record: RecordItem): void => {
|
||||
infoDrawerRef.value?.openDrawer({ id: record.id })
|
||||
@@ -185,6 +199,7 @@ export const usePage = () => {
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
statCards,
|
||||
downstreamName,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
@@ -193,5 +208,6 @@ export const usePage = () => {
|
||||
listRequest,
|
||||
batchRetryRecord,
|
||||
exportRecord,
|
||||
goBackToConfig,
|
||||
}
|
||||
}
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import type { SearchForm } from '../types'
|
||||
|
||||
/** 搜索表单工厂:统一重置入口(downstreamId 由 URL query 注入,不在重置范围) */
|
||||
export const createSearchKey = (downstreamId?: string): SearchForm => ({
|
||||
downstreamId,
|
||||
deviceCode: undefined,
|
||||
dataType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
+1
-1
@@ -56,6 +56,6 @@ export const tableColumns: TableColumnsType = [
|
||||
customRender: ({ value }: { value: DispatchStatus }) =>
|
||||
h(Tag, { color: dispatchStatusColor[value] }, () => dispatchStatusText(value)),
|
||||
},
|
||||
{ title: '失败原因', dataIndex: 'failureReason', align: 'left', width: 180, resizable: true, ellipsis: true },
|
||||
{ title: '失败原因', dataIndex: 'failureReason', align: 'left', width: 200, resizable: true, ellipsis: true },
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 180 },
|
||||
]
|
||||
+35
-1
@@ -1,5 +1,18 @@
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<!-- 顶部返回栏 -->
|
||||
<div class="page-back-bar">
|
||||
<a-button type="link" size="small" @click="goBackToConfig">
|
||||
<template #icon><ArrowLeftOutlined /></template>
|
||||
返回分发配置
|
||||
</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<span class="bar-title">
|
||||
分发记录
|
||||
<template v-if="downstreamName">- {{ downstreamName }}</template>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<!-- 顶部统计卡 -->
|
||||
<a-row :gutter="12" class="record-stat">
|
||||
<a-col :span="6">
|
||||
@@ -65,7 +78,13 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DownloadOutlined, RedoOutlined } from '@ant-design/icons-vue'
|
||||
/**
|
||||
* 数据分发 - 分发记录(分发配置二级子页面)
|
||||
* - 从分发配置列表「分发记录」按钮跳转进入,URL 携带 downstreamId / downstreamName
|
||||
* - 顶部统计卡 + 标准列表 + 详情抽屉(请求体 / 响应体 / 失败日志)
|
||||
* - 手动重投(单条/批量)+ 导出
|
||||
*/
|
||||
import { ArrowLeftOutlined, DownloadOutlined, RedoOutlined } from '@ant-design/icons-vue'
|
||||
import StatCard from '@components/StatCard.vue'
|
||||
import InfoDrawer from './component/drawer/info/info.vue'
|
||||
import { DATA_TYPE_OPTIONS, DISPATCH_STATUS_OPTIONS } from './types'
|
||||
@@ -78,6 +97,7 @@ const {
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
statCards,
|
||||
downstreamName,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
@@ -85,6 +105,7 @@ const {
|
||||
handleAction,
|
||||
batchRetryRecord,
|
||||
exportRecord,
|
||||
goBackToConfig,
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
@@ -98,6 +119,19 @@ const {
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.page-back-bar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 32px;
|
||||
padding: 0 4px;
|
||||
|
||||
.bar-title {
|
||||
font-size: 14px;
|
||||
color: rgba(0, 0, 0, 0.85);
|
||||
font-weight: 500;
|
||||
}
|
||||
}
|
||||
|
||||
.record-stat {
|
||||
margin: 0 !important;
|
||||
}
|
||||
+4
-4
@@ -33,11 +33,11 @@ export interface RecordItem {
|
||||
failureReason?: string
|
||||
}
|
||||
|
||||
/** 详情数据(含请求体/响应体) */
|
||||
/** 详情数据(含请求体/响应体/失败日志) */
|
||||
export interface DetailInfo extends RecordItem {
|
||||
/** 请求体(JSON 字符串) */
|
||||
/** 请求体(分发数据格式,JSON 字符串) */
|
||||
requestBody: string
|
||||
/** 响应体(JSON 字符串) */
|
||||
/** 响应体(响应数据格式,JSON 字符串) */
|
||||
responseBody: string
|
||||
/** 鉴权密钥(脱敏展示) */
|
||||
authKey: string
|
||||
@@ -59,7 +59,7 @@ export interface StatCards {
|
||||
export interface SearchForm extends BaseSearchForm {
|
||||
/** 设备编码 */
|
||||
deviceCode?: string
|
||||
/** 下游系统 ID */
|
||||
/** 下游系统 ID(从分发配置跳转携带) */
|
||||
downstreamId?: string
|
||||
/** 数据类型 */
|
||||
dataType?: DataType
|
||||
-32
@@ -1,32 +0,0 @@
|
||||
/**
|
||||
* 分发配置 - 下游系统 接口
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DownstreamForm, DownstreamItem, SearchForm } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config/downstream'
|
||||
|
||||
/** 列表 */
|
||||
export const list = (params: SearchForm): Promise<ApiResponse<DownstreamItem[]>> =>
|
||||
postRequest('axiosRequest', BASE + '/list', params)
|
||||
|
||||
/** 详情 */
|
||||
export const info = (id: string): Promise<ApiResponse<DownstreamItem>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
|
||||
/** 新增 */
|
||||
export const add = (params: DownstreamForm): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
/** 编辑 */
|
||||
export const update = (params: DownstreamForm): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
|
||||
/** 删除 */
|
||||
export const remove = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/delete', { id })
|
||||
|
||||
/** 启用/禁用 */
|
||||
export const toggleStatus = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/toggleStatus', { id })
|
||||
-8
@@ -1,8 +0,0 @@
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DetailInfo } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config/downstream'
|
||||
|
||||
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
-53
@@ -1,53 +0,0 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-descriptions v-if="detail" :column="2" bordered size="small">
|
||||
<a-descriptions-item label="下游ID">{{ detail.id ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="系统名称">{{ detail.name ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="业务编码">{{ detail.code ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor[detail.status]">{{ statusText(detail.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="回调地址" :span="2">{{ detail.callbackUrl ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权方式">
|
||||
<a-tag :color="authTypeColor[detail.authType]">{{ authTypeText(detail.authType) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权密钥">{{ detail.authKey ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="订阅数据类型" :span="2">
|
||||
<a-tag v-for="t in detail.dataTypes" :key="t" color="blue" style="margin-bottom: 2px">
|
||||
{{ dataTypeText(t) }}
|
||||
</a-tag>
|
||||
<span v-if="!detail.dataTypes || detail.dataTypes.length === 0">—</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="重投策略">
|
||||
<a-tag :color="retryStrategyColor[detail.retryStrategy]">{{ retryStrategyText(detail.retryStrategy) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="最大重试">{{ detail.maxRetry ?? 0 }} 次</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间">{{ detail.createdAt ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注">{{ detail.remark ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePage } from './init/usePage'
|
||||
import {
|
||||
authTypeColor,
|
||||
authTypeText,
|
||||
dataTypeText,
|
||||
retryStrategyColor,
|
||||
retryStrategyText,
|
||||
statusColor,
|
||||
statusText,
|
||||
} from '../../../types'
|
||||
|
||||
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
|
||||
|
||||
defineExpose({ openDrawer, closeDrawer })
|
||||
</script>
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import type { DownstreamItem } from '../../../../types'
|
||||
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
export interface OpenContext {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type DetailInfo = DownstreamItem
|
||||
-108
@@ -1,108 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:confirm-loading="pageInfo.spin"
|
||||
:mask-closable="false"
|
||||
:keyboard="false"
|
||||
:destroy-on-close="true"
|
||||
@ok="submit(() => $emit('load'))"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="系统名称" required>
|
||||
<a-input v-model:value="formModel.name" v-no-space placeholder="如:体重管理系统" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="业务编码" required>
|
||||
<a-input
|
||||
v-model:value="formModel.code"
|
||||
v-only-alphanumeric
|
||||
:disabled="!!formModel.id"
|
||||
placeholder="如:WEIGHT-MGMT"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="回调地址" required>
|
||||
<a-input v-model:value="formModel.callbackUrl" v-no-space placeholder="https://downstream.example.com/api/v1/iot/callback" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="鉴权方式" required>
|
||||
<a-select v-model:value="formModel.authType" :options="AUTH_TYPE_OPTIONS" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="16">
|
||||
<a-form-item label="鉴权密钥">
|
||||
<a-input
|
||||
v-model:value="formModel.authKey"
|
||||
v-no-space
|
||||
:disabled="formModel.authType === 'none'"
|
||||
placeholder="按鉴权方式填写 API Key / Secret / Token"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="订阅数据类型" required>
|
||||
<a-select
|
||||
v-model:value="formModel.dataTypes"
|
||||
:options="DATA_TYPE_OPTIONS"
|
||||
mode="multiple"
|
||||
placeholder="勾选该系统订阅的数据类型"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="8">
|
||||
<a-form-item label="重投策略" required>
|
||||
<a-select v-model:value="formModel.retryStrategy" :options="RETRY_STRATEGY_OPTIONS" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="最大重试" required>
|
||||
<a-input-number
|
||||
v-model:value="formModel.maxRetry"
|
||||
:min="0"
|
||||
:max="20"
|
||||
:disabled="formModel.retryStrategy === 'none'"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="8">
|
||||
<a-form-item label="状态" required>
|
||||
<a-select v-model:value="formModel.status" :options="STATUS_OPTIONS" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="备注">
|
||||
<a-textarea
|
||||
v-model:value="formModel.remark"
|
||||
:rows="2"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
placeholder="可选"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePage } from './init/usePage'
|
||||
import {
|
||||
AUTH_TYPE_OPTIONS,
|
||||
DATA_TYPE_OPTIONS,
|
||||
RETRY_STRATEGY_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
} from '../../../types'
|
||||
|
||||
defineEmits<{ (event: 'load'): void }>()
|
||||
|
||||
const { pageInfo, formModel, openAdd, openEdit, submit } = usePage()
|
||||
|
||||
defineExpose({ openAdd, openEdit })
|
||||
</script>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { FormModel } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config/downstream'
|
||||
|
||||
export const add = (params: FormModel): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
export const update = (params: FormModel): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
-85
@@ -1,85 +0,0 @@
|
||||
import { reactive } from 'vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import * as api from '../api'
|
||||
import type { DetailRecord, FormModel, PageInfo } from '../types'
|
||||
|
||||
/** 新增的默认模型 */
|
||||
const createFormModel = (): FormModel => ({
|
||||
id: undefined,
|
||||
name: '',
|
||||
code: '',
|
||||
callbackUrl: '',
|
||||
authType: 'apikey',
|
||||
authKey: '',
|
||||
dataTypes: [],
|
||||
retryStrategy: 'exponential',
|
||||
maxRetry: 5,
|
||||
status: 'enabled',
|
||||
remark: '',
|
||||
})
|
||||
|
||||
export const usePage = () => {
|
||||
const { message } = useAntdStaticMethods()
|
||||
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
title: '新增下游系统',
|
||||
width: 720,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const formModel = reactive<FormModel>(createFormModel())
|
||||
|
||||
/** 新增 */
|
||||
const openAdd = (): void => {
|
||||
Object.assign(formModel, createFormModel())
|
||||
pageInfo.title = '新增下游系统'
|
||||
pageInfo.visible = true
|
||||
}
|
||||
|
||||
/** 编辑(携带行数据) */
|
||||
const openEdit = (record: DetailRecord): void => {
|
||||
Object.assign(formModel, createFormModel(), {
|
||||
id: record.id,
|
||||
name: record.name,
|
||||
code: record.code,
|
||||
callbackUrl: record.callbackUrl,
|
||||
authType: record.authType,
|
||||
authKey: record.authKey,
|
||||
dataTypes: [...record.dataTypes],
|
||||
retryStrategy: record.retryStrategy,
|
||||
maxRetry: record.maxRetry,
|
||||
status: record.status,
|
||||
remark: record.remark ?? '',
|
||||
})
|
||||
pageInfo.title = '编辑下游系统'
|
||||
pageInfo.visible = true
|
||||
}
|
||||
|
||||
const close = (): void => {
|
||||
pageInfo.visible = false
|
||||
}
|
||||
|
||||
/** 提交 */
|
||||
const submit = (emit: (event: 'load') => void): void => {
|
||||
pageInfo.spin = true
|
||||
const params = { ...formModel }
|
||||
const request = formModel.id ? api.update(params) : api.add({ ...params, id: undefined })
|
||||
request
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
emit('load')
|
||||
close()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('提交失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
return { pageInfo, formModel, openAdd, openEdit, close, submit }
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
import type { DownstreamForm, DownstreamItem } from '../../../../types'
|
||||
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
export type FormModel = DownstreamForm
|
||||
|
||||
export type DetailRecord = DownstreamItem
|
||||
-78
@@ -1,78 +0,0 @@
|
||||
<template>
|
||||
<div class="tab-list-page">
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="系统名称">
|
||||
<a-input v-model:value="search.name" v-no-space allow-clear placeholder="系统名称" style="width: 180px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="鉴权方式">
|
||||
<a-select v-model:value="search.authType" :options="AUTH_TYPE_OPTIONS" allow-clear placeholder="全部" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="重投策略">
|
||||
<a-select v-model:value="search.retryStrategy" :options="RETRY_STRATEGY_OPTIONS" allow-clear placeholder="全部" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="search.status" :options="STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 120px" />
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增下游系统
|
||||
</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" @click="handleAction('toggle', record)">
|
||||
{{ record.status === 'enabled' ? '禁用' : '启用' }}
|
||||
</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
|
||||
<AddOrEdit ref="addOrEditRef" @load="reload" />
|
||||
<InfoDrawer ref="infoDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
|
||||
import InfoDrawer from './component/drawer/info/info.vue'
|
||||
import {
|
||||
AUTH_TYPE_OPTIONS,
|
||||
RETRY_STRATEGY_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
} from './types'
|
||||
import { usePage } from './init/usePage'
|
||||
|
||||
const {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
addOrEditRef,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
openAdd,
|
||||
reload,
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tab-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
-59
@@ -1,59 +0,0 @@
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import type { AuthType, DownstreamItem, RetryStrategy, Status } from '../types'
|
||||
import {
|
||||
authTypeColor,
|
||||
authTypeText,
|
||||
dataTypeText,
|
||||
retryStrategyColor,
|
||||
retryStrategyText,
|
||||
statusColor,
|
||||
statusText,
|
||||
} from '../types'
|
||||
|
||||
/** 下游系统列定义 */
|
||||
export const tableColumns: TableColumnsType = [
|
||||
{ title: '下游ID', dataIndex: 'id', align: 'left', fixed: 'left', width: 100 },
|
||||
{ title: '系统名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
|
||||
{ title: '业务编码', dataIndex: 'code', align: 'left', width: 130, ellipsis: true },
|
||||
{ title: '回调地址', dataIndex: 'callbackUrl', align: 'left', width: 220, resizable: true, ellipsis: true },
|
||||
{
|
||||
title: '鉴权方式',
|
||||
dataIndex: 'authType',
|
||||
align: 'center',
|
||||
width: 110,
|
||||
customRender: ({ value }: { value: AuthType }) =>
|
||||
h(Tag, { color: authTypeColor[value] }, () => authTypeText(value)),
|
||||
},
|
||||
{ title: '鉴权密钥', dataIndex: 'authKey', align: 'left', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '订阅数据类型',
|
||||
dataIndex: 'dataTypes',
|
||||
align: 'left',
|
||||
width: 200,
|
||||
customRender: ({ value }: { value: DownstreamItem['dataTypes'] }) => {
|
||||
if (!value || value.length === 0) return '—'
|
||||
return value.map((v) => h(Tag, { color: 'blue', style: 'margin-bottom:2px' }, () => dataTypeText(v)))
|
||||
},
|
||||
},
|
||||
{
|
||||
title: '重投策略',
|
||||
dataIndex: 'retryStrategy',
|
||||
align: 'center',
|
||||
width: 110,
|
||||
customRender: ({ value }: { value: RetryStrategy }) =>
|
||||
h(Tag, { color: retryStrategyColor[value] }, () => retryStrategyText(value)),
|
||||
},
|
||||
{ title: '最大重试', dataIndex: 'maxRetry', align: 'center', width: 90 },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
customRender: ({ value }: { value: Status }) =>
|
||||
h(Tag, { color: statusColor[value] }, () => statusText(value)),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', align: 'center', width: 170 },
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 220 },
|
||||
]
|
||||
-110
@@ -1,110 +0,0 @@
|
||||
/**
|
||||
* 分发配置 - 下游系统 类型
|
||||
*/
|
||||
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
|
||||
import type { DataType } from '../../../../../record/types'
|
||||
|
||||
/** 鉴权方式 */
|
||||
export type AuthType = 'none' | 'apikey' | 'hmac' | 'token'
|
||||
|
||||
/** 重投策略 */
|
||||
export type RetryStrategy = 'exponential' | 'fixed' | 'none'
|
||||
|
||||
/** 启用状态 */
|
||||
export type Status = 'enabled' | 'disabled'
|
||||
|
||||
/** 下游系统列表项 */
|
||||
export interface DownstreamItem {
|
||||
id: string
|
||||
name: string
|
||||
code: string
|
||||
callbackUrl: string
|
||||
authType: AuthType
|
||||
authKey: string
|
||||
dataTypes: DataType[]
|
||||
retryStrategy: RetryStrategy
|
||||
maxRetry: number
|
||||
status: Status
|
||||
remark?: string
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 表单(新增/编辑) */
|
||||
export interface DownstreamForm {
|
||||
id?: string
|
||||
name: string
|
||||
code: string
|
||||
callbackUrl: string
|
||||
authType: AuthType
|
||||
authKey: string
|
||||
dataTypes: DataType[]
|
||||
retryStrategy: RetryStrategy
|
||||
maxRetry: number
|
||||
status: Status
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 搜索表单 */
|
||||
export interface SearchForm extends BaseSearchForm {
|
||||
name?: string
|
||||
authType?: AuthType
|
||||
retryStrategy?: RetryStrategy
|
||||
status?: Status
|
||||
}
|
||||
|
||||
/** 鉴权方式选项 */
|
||||
export const AUTH_TYPE_OPTIONS: { label: string; value: AuthType }[] = [
|
||||
{ label: '无鉴权', value: 'none' },
|
||||
{ label: 'API Key', value: 'apikey' },
|
||||
{ label: 'HMAC 签名', value: 'hmac' },
|
||||
{ label: 'Bearer Token', value: 'token' },
|
||||
]
|
||||
|
||||
/** 重投策略选项 */
|
||||
export const RETRY_STRATEGY_OPTIONS: { label: string; value: RetryStrategy }[] = [
|
||||
{ label: '指数退避', value: 'exponential' },
|
||||
{ label: '固定间隔', value: 'fixed' },
|
||||
{ label: '不重投', value: 'none' },
|
||||
]
|
||||
|
||||
/** 启用状态选项 */
|
||||
export const STATUS_OPTIONS: { label: string; value: Status }[] = [
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '禁用', value: 'disabled' },
|
||||
]
|
||||
|
||||
/** 鉴权方式 → 中文 */
|
||||
export const authTypeText = (value: AuthType | string | undefined): string =>
|
||||
AUTH_TYPE_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
|
||||
|
||||
/** 重投策略 → 中文 */
|
||||
export const retryStrategyText = (value: RetryStrategy | string | undefined): string =>
|
||||
RETRY_STRATEGY_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
|
||||
|
||||
/** 鉴权方式 → Tag 颜色 */
|
||||
export const authTypeColor: Record<AuthType, string> = {
|
||||
none: 'default',
|
||||
apikey: 'blue',
|
||||
hmac: 'geekblue',
|
||||
token: 'cyan',
|
||||
}
|
||||
|
||||
/** 重投策略 → Tag 颜色 */
|
||||
export const retryStrategyColor: Record<RetryStrategy, string> = {
|
||||
exponential: 'processing',
|
||||
fixed: 'warning',
|
||||
none: 'default',
|
||||
}
|
||||
|
||||
/** 启用状态 → Tag 颜色 */
|
||||
export const statusColor: Record<Status, string> = {
|
||||
enabled: 'success',
|
||||
disabled: 'default',
|
||||
}
|
||||
|
||||
/** 启用状态 → 中文 */
|
||||
export const statusText = (value: Status | string | undefined): string =>
|
||||
STATUS_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
|
||||
|
||||
/** 数据类型选项(复用 record) */
|
||||
export { DATA_TYPE_OPTIONS, dataTypeText } from '../../../../../record/types'
|
||||
-29
@@ -1,29 +0,0 @@
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { SearchForm, SubscribeForm, SubscribeItem } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config/subscribe'
|
||||
|
||||
/** 列表 */
|
||||
export const list = (params: SearchForm): Promise<ApiResponse<SubscribeItem[]>> =>
|
||||
postRequest('axiosRequest', BASE + '/list', params)
|
||||
|
||||
/** 新增 */
|
||||
export const add = (params: SubscribeForm): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
/** 编辑 */
|
||||
export const update = (params: SubscribeForm): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
|
||||
/** 删除 */
|
||||
export const remove = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/delete', { id })
|
||||
|
||||
/** 启用/禁用 */
|
||||
export const toggleStatus = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/toggleStatus', { id })
|
||||
|
||||
/** 下游下拉选项 */
|
||||
export const options = (): Promise<ApiResponse<{ downstream: { label: string; value: string }[] }>> =>
|
||||
postRequest('axiosRequest', BASE + '/options', {})
|
||||
-52
@@ -1,52 +0,0 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:confirm-loading="pageInfo.spin"
|
||||
:mask-closable="false"
|
||||
:keyboard="false"
|
||||
:destroy-on-close="true"
|
||||
@ok="submit(() => $emit('load'))"
|
||||
>
|
||||
<a-form layout="vertical">
|
||||
<a-form-item label="下游系统" required>
|
||||
<a-select
|
||||
v-model:value="formModel.downstreamId"
|
||||
:options="downstreamOptions"
|
||||
:disabled="!!formModel.id"
|
||||
placeholder="选择下游系统"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备编码" required>
|
||||
<a-input v-model:value="formModel.deviceCode" v-only-alphanumeric placeholder="如 WD-SCALE-0001" />
|
||||
</a-form-item>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="数据类型" required>
|
||||
<a-select v-model:value="formModel.dataType" :options="DATA_TYPE_OPTIONS" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" required>
|
||||
<a-select v-model:value="formModel.status" :options="STATUS_OPTIONS" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="过滤规则(可选)">
|
||||
<a-input v-model:value="formModel.filterRule" v-no-space placeholder="如 weight > 50,留空表示全量订阅" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { usePage } from './init/usePage'
|
||||
import { DATA_TYPE_OPTIONS, STATUS_OPTIONS } from '../../../types'
|
||||
|
||||
defineEmits<{ (event: 'load'): void }>()
|
||||
|
||||
const { pageInfo, formModel, downstreamOptions, openAdd, openEdit, submit } = usePage()
|
||||
|
||||
defineExpose({ openAdd, openEdit })
|
||||
</script>
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { FormModel } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/config/subscribe'
|
||||
|
||||
export const add = (params: FormModel): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
export const update = (params: FormModel): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
-74
@@ -1,74 +0,0 @@
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import * as api from '../api'
|
||||
import type { DetailRecord, DownstreamOptions, FormModel, PageInfo } from '../types'
|
||||
|
||||
const createFormModel = (): FormModel => ({
|
||||
id: undefined,
|
||||
downstreamId: '',
|
||||
deviceCode: '',
|
||||
dataType: 'weight',
|
||||
filterRule: '',
|
||||
status: 'enabled',
|
||||
})
|
||||
|
||||
export const usePage = () => {
|
||||
const { message } = useAntdStaticMethods()
|
||||
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
title: '新增订阅映射',
|
||||
width: 600,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const formModel = reactive<FormModel>(createFormModel())
|
||||
const downstreamOptions = ref<DownstreamOptions>([])
|
||||
|
||||
const openAdd = (options: DownstreamOptions): void => {
|
||||
Object.assign(formModel, createFormModel())
|
||||
downstreamOptions.value = options
|
||||
pageInfo.title = '新增订阅映射'
|
||||
pageInfo.visible = true
|
||||
}
|
||||
|
||||
const openEdit = (record: DetailRecord, options: DownstreamOptions): void => {
|
||||
Object.assign(formModel, createFormModel(), {
|
||||
id: record.id,
|
||||
downstreamId: record.downstreamId,
|
||||
deviceCode: record.deviceCode,
|
||||
dataType: record.dataType,
|
||||
filterRule: record.filterRule,
|
||||
status: record.status,
|
||||
})
|
||||
downstreamOptions.value = options
|
||||
pageInfo.title = '编辑订阅映射'
|
||||
pageInfo.visible = true
|
||||
}
|
||||
|
||||
const close = (): void => {
|
||||
pageInfo.visible = false
|
||||
}
|
||||
|
||||
const submit = (emit: (event: 'load') => void): void => {
|
||||
pageInfo.spin = true
|
||||
const params = { ...formModel }
|
||||
const request = formModel.id ? api.update(params) : api.add({ ...params, id: undefined })
|
||||
request
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
emit('load')
|
||||
close()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('提交失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
return { pageInfo, formModel, downstreamOptions, openAdd, openEdit, close, submit }
|
||||
}
|
||||
-15
@@ -1,15 +0,0 @@
|
||||
import type { DownstreamOption } from '../../../../types'
|
||||
import type { SubscribeForm, SubscribeItem } from '../../../../types'
|
||||
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
export type FormModel = SubscribeForm
|
||||
|
||||
export type DetailRecord = SubscribeItem
|
||||
|
||||
export type DownstreamOptions = DownstreamOption[]
|
||||
-161
@@ -1,161 +0,0 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { createPaginationConfig } from '@utils/antDesign/table'
|
||||
import type { TableState } from '@utils/antDesign/table'
|
||||
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
|
||||
import * as api from '../api'
|
||||
import type { DownstreamOption, SearchForm, SubscribeItem } from '../types'
|
||||
import { createSearchKey } from './useSearch'
|
||||
import { tableColumns } from './useTable'
|
||||
|
||||
export const usePage = () => {
|
||||
const { message, Modal } = useAntdStaticMethods()
|
||||
|
||||
const search = reactive<SearchForm>(createSearchKey())
|
||||
|
||||
const table = reactive<TableState<SubscribeItem>>({
|
||||
columns: tableColumns,
|
||||
dataSource: [],
|
||||
sort: { field: '', order: null },
|
||||
pagination: createPaginationConfig() as TablePaginationConfig,
|
||||
})
|
||||
|
||||
const pageLoading = ref<boolean>(false)
|
||||
const downstreamOptions = ref<DownstreamOption[]>([])
|
||||
|
||||
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
|
||||
|
||||
const listRequest = (): void => {
|
||||
pageLoading.value = true
|
||||
api.list(search)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
table.dataSource = res.data ?? []
|
||||
table.pagination.total = table.dataSource.length
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('订阅映射列表请求失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 拉下游选项(首次进入时调用) */
|
||||
const loadOptions = (): void => {
|
||||
api.options()
|
||||
.then((res) => {
|
||||
if (res.code === '00000' && res.data?.downstream) {
|
||||
downstreamOptions.value = res.data.downstream
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('下游选项拉取失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const searchQuery = (): void => {
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const resetQuery = (): void => {
|
||||
Object.assign(search, createSearchKey())
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const dataSourceChange = (pagination: TablePaginationConfig): void => {
|
||||
table.pagination.current = pagination.current ?? 1
|
||||
table.pagination.pageSize = pagination.pageSize ?? 30
|
||||
}
|
||||
|
||||
const openAdd = (): void => {
|
||||
if (downstreamOptions.value.length === 0) {
|
||||
void message.warning('请先在「下游系统」Tab 创建至少一条下游系统')
|
||||
return
|
||||
}
|
||||
addOrEditRef.value?.openAdd(downstreamOptions.value)
|
||||
}
|
||||
|
||||
const openEdit = (record: SubscribeItem): void => {
|
||||
addOrEditRef.value?.openEdit(record, downstreamOptions.value)
|
||||
}
|
||||
|
||||
const removeRecord = (record: SubscribeItem): void => {
|
||||
Modal.confirm({
|
||||
title: '确认删除?',
|
||||
content: `将删除映射 [${record.id}],确认继续?`,
|
||||
okText: '确认删除',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
api.remove(record.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
listRequest()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('删除失败:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const toggleStatus = (record: SubscribeItem): void => {
|
||||
const next = record.status === 'enabled' ? '禁用' : '启用'
|
||||
Modal.confirm({
|
||||
title: `确认${next}?`,
|
||||
content: `将${next}映射 [${record.id}],确认继续?`,
|
||||
okText: `确认${next}`,
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
api.toggleStatus(record.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
listRequest()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('状态切换失败:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const reload = (): void => {
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const handleAction = (type: string, record?: SubscribeItem): void => {
|
||||
if (!record) return
|
||||
if (type === 'edit') openEdit(record)
|
||||
if (type === 'delete') removeRecord(record)
|
||||
if (type === 'toggle') toggleStatus(record)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadOptions()
|
||||
listRequest()
|
||||
})
|
||||
|
||||
return {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
downstreamOptions,
|
||||
addOrEditRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
openAdd,
|
||||
reload,
|
||||
}
|
||||
}
|
||||
-12
@@ -1,12 +0,0 @@
|
||||
import type { SearchForm } from '../types'
|
||||
|
||||
export const createSearchKey = (): SearchForm => ({
|
||||
downstreamId: undefined,
|
||||
deviceCode: undefined,
|
||||
dataType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
|
||||
export const useSearch = () => {
|
||||
return { createSearchKey }
|
||||
}
|
||||
-31
@@ -1,31 +0,0 @@
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import type { DataType } from '../../../../../record/types'
|
||||
import { dataTypeText } from '../../../../../record/types'
|
||||
import type { Status } from '../../downstream/types'
|
||||
import { statusColor, statusText } from '../../downstream/types'
|
||||
|
||||
export const tableColumns: TableColumnsType = [
|
||||
{ title: '映射ID', dataIndex: 'id', align: 'left', fixed: 'left', width: 110 },
|
||||
{ title: '下游系统', dataIndex: 'downstreamName', align: 'left', width: 160, resizable: true, ellipsis: true },
|
||||
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 140, resizable: true, ellipsis: true },
|
||||
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 160, resizable: true, ellipsis: true },
|
||||
{
|
||||
title: '数据类型',
|
||||
dataIndex: 'dataType',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: DataType }) => h(Tag, { color: 'blue' }, () => dataTypeText(value)),
|
||||
},
|
||||
{ title: '过滤规则', dataIndex: 'filterRule', align: 'left', width: 220, resizable: true, ellipsis: true, customRender: ({ value }: { value: string }) => value ?? '—' },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
customRender: ({ value }: { value: Status }) => h(Tag, { color: statusColor[value] }, () => statusText(value)),
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createdAt', align: 'center', width: 170 },
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 },
|
||||
]
|
||||
-76
@@ -1,76 +0,0 @@
|
||||
<template>
|
||||
<div class="tab-list-page">
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="下游系统">
|
||||
<a-select
|
||||
v-model:value="search.downstreamId"
|
||||
:options="downstreamOptions"
|
||||
allow-clear
|
||||
placeholder="全部下游"
|
||||
style="width: 180px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="设备编码">
|
||||
<a-input v-model:value="search.deviceCode" v-no-space allow-clear placeholder="设备编码" style="width: 160px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="数据类型">
|
||||
<a-select v-model:value="search.dataType" :options="DATA_TYPE_OPTIONS" allow-clear placeholder="全部类型" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="search.status" :options="STATUS_OPTIONS" allow-clear placeholder="全部" style="width: 120px" />
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增订阅映射
|
||||
</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" @click="handleAction('toggle', record)">
|
||||
{{ record.status === 'enabled' ? '禁用' : '启用' }}
|
||||
</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" danger @click="handleAction('delete', record)">删除</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
|
||||
<AddOrEdit ref="addOrEditRef" @load="reload" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
|
||||
import { DATA_TYPE_OPTIONS, STATUS_OPTIONS } from './types'
|
||||
import { usePage } from './init/usePage'
|
||||
|
||||
const {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
downstreamOptions,
|
||||
addOrEditRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
openAdd,
|
||||
reload,
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.tab-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
|
||||
import type { DataType } from '../../../../../record/types'
|
||||
import type { Status } from '../../downstream/types'
|
||||
|
||||
/** 订阅映射列表项 */
|
||||
export interface SubscribeItem {
|
||||
id: string
|
||||
downstreamId: string
|
||||
downstreamName: string
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
dataType: DataType
|
||||
filterRule: string
|
||||
status: Status
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
/** 表单(新增/编辑) */
|
||||
export interface SubscribeForm {
|
||||
id?: string
|
||||
downstreamId: string
|
||||
deviceCode: string
|
||||
dataType: DataType
|
||||
filterRule: string
|
||||
status: Status
|
||||
}
|
||||
|
||||
/** 搜索表单 */
|
||||
export interface SearchForm extends BaseSearchForm {
|
||||
downstreamId?: string
|
||||
deviceCode?: string
|
||||
dataType?: DataType
|
||||
status?: Status
|
||||
}
|
||||
|
||||
/** 下游选项(用于搜索/表单的下拉) */
|
||||
export interface DownstreamOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
export { STATUS_OPTIONS, statusColor, statusText } from '../../downstream/types'
|
||||
export { DATA_TYPE_OPTIONS, dataTypeText } from '../../../../../record/types'
|
||||
@@ -1,44 +1,113 @@
|
||||
<template>
|
||||
<a-card class="listPageCard" :bordered="false">
|
||||
<a-tabs class="aTabs" v-model:activeKey="activeKey">
|
||||
<a-tab-pane key="downstream" tab="下游系统">
|
||||
<Downstream />
|
||||
</a-tab-pane>
|
||||
<a-tab-pane key="subscribe" tab="订阅映射">
|
||||
<Subscribe />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-card>
|
||||
<div class="admin-list-page">
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="所属客户">
|
||||
<a-select
|
||||
v-model:value="search.customerId"
|
||||
:options="customerOptions"
|
||||
:loading="customerLoading"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOption"
|
||||
placeholder="全部客户"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="系统名称">
|
||||
<a-input
|
||||
v-model:value="search.name"
|
||||
v-no-space
|
||||
allow-clear
|
||||
placeholder="下游系统名称"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="search.status" :options="STATUS_OPTIONS" allow-clear placeholder="全部状态" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="鉴权开关">
|
||||
<a-select v-model:value="search.authEnabled" allow-clear placeholder="全部" style="width: 140px">
|
||||
<a-select-option :value="true">已开启</a-select-option>
|
||||
<a-select-option :value="false">已关闭</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item label="最新通讯时间">
|
||||
<a-range-picker
|
||||
v-model:value="search.communicateTimeRange"
|
||||
:allow-clear="true"
|
||||
value-format="YYYY-MM-DD"
|
||||
style="width: 240px"
|
||||
/>
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button type="primary" @click="handleAction('add')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增分发配置
|
||||
</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'deviceCategoryNames'">
|
||||
<div v-if="record.deviceCategoryNames && record.deviceCategoryNames.length">
|
||||
<a-tag v-for="name in record.deviceCategoryNames" :key="name" color="blue" style="margin-bottom: 2px">
|
||||
{{ name }}
|
||||
</a-tag>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</template>
|
||||
<template v-else-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" @click="handleAction('record', record)">分发记录</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
|
||||
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
|
||||
<InfoDrawer ref="infoDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import Downstream from './component/tabs/downstream/downstream.vue'
|
||||
import Subscribe from './component/tabs/subscribe/subscribe.vue'
|
||||
/**
|
||||
* 数据分发 - 分发配置 主列表页
|
||||
* - 单列表(无 Tab),字段对齐产品规划
|
||||
* - 操作列:编辑 / 查看详情 / 分发记录(跳转二级子页面)
|
||||
*/
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { filterOption } from '@utils/antDesign/select'
|
||||
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
|
||||
import InfoDrawer from './component/drawer/info/info.vue'
|
||||
import { usePage } from './init/usePage'
|
||||
import { STATUS_OPTIONS } from './types'
|
||||
|
||||
const activeKey = ref<'downstream' | 'subscribe'>('downstream')
|
||||
const {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
customerOptions,
|
||||
customerLoading,
|
||||
addOrEditRef,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
listRequest,
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import "@assets/styles/listPage.less";
|
||||
|
||||
.aTabs {
|
||||
width: 100%;
|
||||
.admin-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
|
||||
:deep(.ant-tabs-tab) {
|
||||
padding-top: 0;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-content) {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
:deep(.ant-tabs-tabpane) {
|
||||
height: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { createPaginationConfig } from '@utils/antDesign/table'
|
||||
import type { TableState } from '@utils/antDesign/table'
|
||||
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
|
||||
import InfoDrawer from '../component/drawer/info/info.vue'
|
||||
import { customerOptions as fetchCustomerOptions, list } from '../api'
|
||||
import type { ConfigItem, CustomerOption, SearchForm } from '../types'
|
||||
import { createSearchKey } from './useSearch'
|
||||
import { tableColumns } from './useTable'
|
||||
|
||||
/**
|
||||
* 分发配置主编排:
|
||||
* - 标准列表 + 新增/编辑弹框 + 详情抽屉
|
||||
* - 所属客户下拉(首屏拉取一次,弹框共用)
|
||||
* - 「分发记录」操作按钮跳转到 hidden 子页面 distribute/record?downstreamId=xxx
|
||||
*/
|
||||
export const usePage = () => {
|
||||
const router = useRouter()
|
||||
const { Modal } = useAntdStaticMethods()
|
||||
|
||||
const search = reactive<SearchForm>(createSearchKey())
|
||||
|
||||
const table = reactive<TableState<ConfigItem>>({
|
||||
columns: tableColumns,
|
||||
dataSource: [],
|
||||
sort: { field: '', order: null },
|
||||
pagination: createPaginationConfig() as TablePaginationConfig,
|
||||
})
|
||||
|
||||
const pageLoading = ref<boolean>(false)
|
||||
|
||||
/** 客户下拉选项 */
|
||||
const customerOptions = ref<CustomerOption[]>([])
|
||||
const customerLoading = ref<boolean>(false)
|
||||
|
||||
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
|
||||
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
|
||||
|
||||
/** 拉取客户下拉(复用客户档案列表接口,仅取 id/name) */
|
||||
const loadCustomerOptions = (): void => {
|
||||
customerLoading.value = true
|
||||
fetchCustomerOptions()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
customerOptions.value = (res.data ?? []).map((c) => ({ label: c.name, value: c.id }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('客户下拉加载失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
customerLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 搜索参数序列化(Dayjs → 字符串) */
|
||||
const buildSearchParams = (form: SearchForm): Record<string, unknown> => {
|
||||
const { communicateTimeRange, ...rest } = form
|
||||
const params: Record<string, unknown> = { ...rest }
|
||||
if (communicateTimeRange && communicateTimeRange.length === 2) {
|
||||
params.lastCommunicateTimeStart = communicateTimeRange[0]?.format('YYYY-MM-DD')
|
||||
params.lastCommunicateTimeEnd = communicateTimeRange[1]?.format('YYYY-MM-DD')
|
||||
}
|
||||
return params
|
||||
}
|
||||
|
||||
const listRequest = (): void => {
|
||||
pageLoading.value = true
|
||||
list(buildSearchParams(search) as SearchForm)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
table.dataSource = res.data ?? []
|
||||
table.pagination.total = table.dataSource.length
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('分发配置列表请求失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const searchQuery = (): void => {
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const resetQuery = (): void => {
|
||||
Object.assign(search, createSearchKey())
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const dataSourceChange = (pagination: TablePaginationConfig): void => {
|
||||
table.pagination.current = pagination.current ?? 1
|
||||
table.pagination.pageSize = pagination.pageSize ?? 30
|
||||
}
|
||||
|
||||
/** 打开新增弹框 */
|
||||
const openAddModal = (): void => {
|
||||
addOrEditRef.value?.openModal('add')
|
||||
}
|
||||
|
||||
/** 打开编辑弹框 */
|
||||
const openEditModal = (record: ConfigItem): void => {
|
||||
addOrEditRef.value?.openModal('edit', { id: record.id })
|
||||
}
|
||||
|
||||
/** 打开详情抽屉 */
|
||||
const openInfoDrawer = (record: ConfigItem): void => {
|
||||
infoDrawerRef.value?.openDrawer({ id: record.id })
|
||||
}
|
||||
|
||||
/** 跳转分发记录子页面(携带下游系统维度过滤参数) */
|
||||
const openRecordPage = (record: ConfigItem): void => {
|
||||
router.push({
|
||||
path: '/admin-portal/device-center/distribute/record',
|
||||
query: {
|
||||
downstreamId: record.id,
|
||||
downstreamName: record.name,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 切换启用状态 */
|
||||
const toggleStatus = (record: ConfigItem): void => {
|
||||
const next = record.status === 'enabled' ? '停用' : '启用'
|
||||
Modal.confirm({
|
||||
title: `确认${next}?`,
|
||||
content: `将${next}下游系统 [${record.name}],确认继续?`,
|
||||
okText: `确认${next}`,
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
})
|
||||
}
|
||||
|
||||
const handleAction = (type: string, record?: ConfigItem): void => {
|
||||
if (type === 'add') {
|
||||
openAddModal()
|
||||
return
|
||||
}
|
||||
if (!record) return
|
||||
if (type === 'edit') openEditModal(record)
|
||||
if (type === 'detail') openInfoDrawer(record)
|
||||
if (type === 'record') openRecordPage(record)
|
||||
if (type === 'toggle') toggleStatus(record)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomerOptions()
|
||||
listRequest()
|
||||
})
|
||||
|
||||
return {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
customerOptions,
|
||||
customerLoading,
|
||||
addOrEditRef,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
listRequest,
|
||||
}
|
||||
}
|
||||
+3
-6
@@ -2,12 +2,9 @@ import type { SearchForm } from '../types'
|
||||
|
||||
/** 搜索表单工厂:统一重置入口 */
|
||||
export const createSearchKey = (): SearchForm => ({
|
||||
customerId: undefined,
|
||||
name: undefined,
|
||||
authType: undefined,
|
||||
retryStrategy: undefined,
|
||||
status: undefined,
|
||||
authEnabled: undefined,
|
||||
communicateTimeRange: undefined,
|
||||
})
|
||||
|
||||
export const useSearch = () => {
|
||||
return { createSearchKey }
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import type { DispatchMode, Status } from '../types'
|
||||
import { dispatchModeColor, dispatchModeText, statusColor, statusText } from '../types'
|
||||
|
||||
/** 分发配置列定义 */
|
||||
export const tableColumns: TableColumnsType = [
|
||||
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
|
||||
{ title: '下游系统名称', dataIndex: 'name', align: 'left', width: 160, resizable: true, ellipsis: true },
|
||||
{ title: '系统标识', dataIndex: 'code', align: 'left', width: 140, ellipsis: true },
|
||||
{ title: '所属客户', dataIndex: 'customerName', align: 'left', width: 140, ellipsis: true },
|
||||
{ title: '数据分发地址', dataIndex: 'callbackUrl', align: 'left', width: 240, resizable: true, ellipsis: true },
|
||||
{
|
||||
title: '分发模式',
|
||||
dataIndex: 'dispatchMode',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: DispatchMode }) =>
|
||||
h(Tag, { color: dispatchModeColor[value] }, () => dispatchModeText(value)),
|
||||
},
|
||||
{
|
||||
title: '鉴权开关',
|
||||
dataIndex: 'authEnabled',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: boolean }) =>
|
||||
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已开启' : '已关闭')),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
customRender: ({ value }: { value: Status }) =>
|
||||
h(Tag, { color: statusColor[value] }, () => statusText(value)),
|
||||
},
|
||||
{
|
||||
title: '覆盖设备',
|
||||
dataIndex: 'deviceCategoryNames',
|
||||
align: 'left',
|
||||
width: 220,
|
||||
},
|
||||
{ title: '最大重试', dataIndex: 'maxRetry', align: 'center', width: 90 },
|
||||
{ title: '最新通讯时间', dataIndex: 'lastCommunicateTime', align: 'center', width: 170, sorter: true, resizable: true },
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 200 },
|
||||
]
|
||||
@@ -0,0 +1,124 @@
|
||||
/**
|
||||
* 数据分发 - 分发配置 类型
|
||||
*/
|
||||
import type { Dayjs } from 'dayjs'
|
||||
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
|
||||
import type { DeviceCategoryTreeNode } from '@api'
|
||||
|
||||
/** 分发模式:实时 / 轮询 */
|
||||
export type DispatchMode = 'realtime' | 'polling'
|
||||
|
||||
/** 启用状态 */
|
||||
export type Status = 'enabled' | 'disabled'
|
||||
|
||||
/** 列表项 */
|
||||
export interface ConfigItem {
|
||||
id: string
|
||||
/** 下游系统名称 */
|
||||
name: string
|
||||
/** 系统标识 */
|
||||
code: string
|
||||
/** 所属客户 ID */
|
||||
customerId: string
|
||||
/** 所属客户名称 */
|
||||
customerName: string
|
||||
/** 数据分发地址 */
|
||||
callbackUrl: string
|
||||
/** 分发模式 */
|
||||
dispatchMode: DispatchMode
|
||||
/** 鉴权开关 */
|
||||
authEnabled: boolean
|
||||
/** 鉴权账号(authEnabled=true 时必填) */
|
||||
authAccount?: string
|
||||
/** 鉴权秘钥(authEnabled=true 时必填) */
|
||||
authSecret?: string
|
||||
/** 鉴权接口地址(authEnabled=true 时必填) */
|
||||
authApiUrl?: string
|
||||
/** 状态 */
|
||||
status: Status
|
||||
/** 覆盖设备(设备分类 ID 多选) */
|
||||
deviceCategoryIds: string[]
|
||||
/** 覆盖设备名称(列表展示用,由后端 join) */
|
||||
deviceCategoryNames?: string[]
|
||||
/** 最大重试次数 */
|
||||
maxRetry: number
|
||||
/** 最新通讯时间 */
|
||||
lastCommunicateTime?: string
|
||||
/** 备注 */
|
||||
remark?: string
|
||||
/** 创建时间 */
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
/** 新增/编辑提交载荷 */
|
||||
export interface ConfigPayload {
|
||||
id?: string
|
||||
name: string
|
||||
code: string
|
||||
customerId: string
|
||||
callbackUrl: string
|
||||
dispatchMode: DispatchMode
|
||||
authEnabled: boolean
|
||||
authAccount?: string
|
||||
authSecret?: string
|
||||
authApiUrl?: string
|
||||
status: Status
|
||||
deviceCategoryIds: string[]
|
||||
maxRetry: number
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 搜索表单 */
|
||||
export interface SearchForm extends BaseSearchForm {
|
||||
/** 所属客户 */
|
||||
customerId?: string
|
||||
/** 下游系统名称 */
|
||||
name?: string
|
||||
/** 状态 */
|
||||
status?: Status
|
||||
/** 鉴权开关(undefined=全部) */
|
||||
authEnabled?: boolean
|
||||
/** 最新通讯时间范围(Dayjs) */
|
||||
communicateTimeRange?: [Dayjs, Dayjs]
|
||||
}
|
||||
|
||||
/** 分发模式下拉选项 */
|
||||
export const DISPATCH_MODE_OPTIONS: { label: string; value: DispatchMode }[] = [
|
||||
{ label: '实时', value: 'realtime' },
|
||||
{ label: '轮询', value: 'polling' },
|
||||
]
|
||||
|
||||
/** 启用状态下拉选项 */
|
||||
export const STATUS_OPTIONS: { label: string; value: Status }[] = [
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]
|
||||
|
||||
/** 分发模式 → 中文 */
|
||||
export const dispatchModeText = (value: DispatchMode | string | undefined): string =>
|
||||
DISPATCH_MODE_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
|
||||
|
||||
/** 启用状态 → 中文 */
|
||||
export const statusText = (value: Status | string | undefined): string =>
|
||||
STATUS_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
|
||||
|
||||
/** 分发模式 → Tag 颜色 */
|
||||
export const dispatchModeColor: Record<DispatchMode, string> = {
|
||||
realtime: 'processing',
|
||||
polling: 'warning',
|
||||
}
|
||||
|
||||
/** 启用状态 → Tag 颜色 */
|
||||
export const statusColor: Record<Status, string> = {
|
||||
enabled: 'success',
|
||||
disabled: 'default',
|
||||
}
|
||||
|
||||
/** 客户下拉选项(列表页 / 弹框共用) */
|
||||
export interface CustomerOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 设备分类树节点(与 @api 对齐) */
|
||||
export type CategoryTreeNode = DeviceCategoryTreeNode
|
||||
@@ -1,34 +0,0 @@
|
||||
/**
|
||||
* 失败重投 - 接口层
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DeadletterDetail, DeadletterItem, SearchForm, StatCards } from '../types'
|
||||
|
||||
/** 列表查询 */
|
||||
export const list = (params: SearchForm): Promise<ApiResponse<DeadletterItem[]>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/list', params)
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<DeadletterDetail>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/info', { id })
|
||||
|
||||
/** 顶部统计卡 */
|
||||
export const stat = (): Promise<ApiResponse<StatCards>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/stat', {})
|
||||
|
||||
/** 手动重投(单条) */
|
||||
export const retry = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/retry', { id })
|
||||
|
||||
/** 批量重投 */
|
||||
export const batchRetry = (ids: string[]): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/batchRetry', { ids })
|
||||
|
||||
/** 永久丢弃(单条) */
|
||||
export const discard = (id: string): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/discard', { id })
|
||||
|
||||
/** 批量丢弃 */
|
||||
export const batchDiscard = (ids: string[]): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/deadletter/batchDiscard', { ids })
|
||||
-99
@@ -1,99 +0,0 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-alert
|
||||
v-if="detail"
|
||||
type="info"
|
||||
show-icon
|
||||
:message="detail.strategy"
|
||||
:description="detail.thresholdWarn"
|
||||
class="detail-alert"
|
||||
/>
|
||||
|
||||
<a-descriptions v-if="detail" :column="2" bordered size="small" class="detail-desc">
|
||||
<a-descriptions-item label="死信ID">{{ detail.id ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="原分发记录">{{ detail.originalRecordId ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="设备编码">{{ detail.deviceCode ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="设备名称">{{ detail.deviceName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="数据类型">{{ dataTypeText(detail.dataType) }}</a-descriptions-item>
|
||||
<a-descriptions-item label="下游系统">{{ detail.downstreamName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="首次失败时间">{{ detail.firstFailTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="最近重投时间">{{ detail.lastRetryTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="下次重投时间">{{ detail.nextRetryTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="重投次数">
|
||||
<a-tag :color="detail.retryCount >= 4 ? 'red' : 'default'">{{ detail.retryCount }} / 5</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="deadletterStatusColor[detail.status]">{{ deadletterStatusText(detail.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权密钥">{{ detail.authKey ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="失败原因" :span="2">{{ detail.failReason ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-card v-if="detail" title="请求体" size="small" :bordered="false" class="detail-body">
|
||||
<pre>{{ formatJson(detail.requestBody) }}</pre>
|
||||
</a-card>
|
||||
<a-card v-if="detail" title="响应体" size="small" :bordered="false" class="detail-body">
|
||||
<pre>{{ formatJson(detail.responseBody) }}</pre>
|
||||
</a-card>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 死信详情抽屉
|
||||
* - 字段 + 自动重投策略 + 请求体/响应体 JSON 美化展示
|
||||
*/
|
||||
import { usePage } from './init/usePage'
|
||||
import {
|
||||
dataTypeText,
|
||||
deadletterStatusColor,
|
||||
deadletterStatusText,
|
||||
} from '../../../types'
|
||||
|
||||
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
|
||||
|
||||
/** 尝试美化 JSON 字符串;非 JSON 则原样返回 */
|
||||
const formatJson = (raw: string | undefined): string => {
|
||||
if (!raw) return '—'
|
||||
try {
|
||||
return JSON.stringify(JSON.parse(raw), null, 2)
|
||||
} catch {
|
||||
return raw
|
||||
}
|
||||
}
|
||||
|
||||
defineExpose({ openDrawer, closeDrawer })
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
.detail-alert {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
|
||||
.detail-desc {
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.detail-body {
|
||||
margin-top: 12px;
|
||||
|
||||
pre {
|
||||
margin: 0;
|
||||
padding: 8px 12px;
|
||||
background: var(--bg-secondary, #fafafa);
|
||||
border-radius: 4px;
|
||||
font-size: 12px;
|
||||
max-height: 260px;
|
||||
overflow: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
-43
@@ -1,43 +0,0 @@
|
||||
/**
|
||||
* 死信详情抽屉 - hook
|
||||
*/
|
||||
import { reactive, ref } from 'vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import * as api from '../api'
|
||||
import type { DetailInfo, OpenContext, PageInfo } from '../types'
|
||||
|
||||
export const usePage = () => {
|
||||
const { message } = useAntdStaticMethods()
|
||||
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
title: '死信详情',
|
||||
width: 720,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const detail = ref<DetailInfo | null>(null)
|
||||
|
||||
const openDrawer = (ctx: OpenContext) => {
|
||||
pageInfo.title = ctx.title ?? '死信详情'
|
||||
pageInfo.visible = true
|
||||
pageInfo.spin = true
|
||||
detail.value = null
|
||||
api.getInfo(ctx.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
detail.value = res.data
|
||||
} else {
|
||||
message.error(res.msg || '查询失败')
|
||||
}
|
||||
})
|
||||
.catch(() => { message.error('查询失败') })
|
||||
.finally(() => { pageInfo.spin = false })
|
||||
}
|
||||
|
||||
const closeDrawer = () => {
|
||||
pageInfo.visible = false
|
||||
}
|
||||
|
||||
return { pageInfo, detail, openDrawer, closeDrawer }
|
||||
}
|
||||
-18
@@ -1,18 +0,0 @@
|
||||
/**
|
||||
* 死信详情抽屉 - 类型
|
||||
*/
|
||||
import type { DeadletterDetail } from '../../../../types'
|
||||
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
export interface OpenContext {
|
||||
id: string
|
||||
title?: string
|
||||
}
|
||||
|
||||
export type DetailInfo = DeadletterDetail
|
||||
@@ -1,134 +0,0 @@
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<!-- 顶部统计卡 -->
|
||||
<a-row :gutter="12" class="deadletter-stat">
|
||||
<a-col :span="6">
|
||||
<StatCard :value="statCards.pendingCount" label="待重投数" color="orange" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard :value="statCards.retryingCount" label="重投中数" color="blue" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard :value="statCards.recoveredCount" label="已恢复数" color="green" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard :value="statCards.discardedCount" label="已丢弃数" color="gray" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 策略说明条 -->
|
||||
<a-alert
|
||||
type="info"
|
||||
show-icon
|
||||
:message="RETRY_STRATEGY_TEXT"
|
||||
description="死信累积超过 100 条自动告警到「设备运维 → 告警管理」。"
|
||||
class="strategy-alert"
|
||||
/>
|
||||
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="设备编码">
|
||||
<a-input v-model:value="search.deviceCode" v-no-space allow-clear placeholder="设备编码" style="width: 180px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="数据类型">
|
||||
<a-select
|
||||
v-model:value="search.dataType"
|
||||
:options="DATA_TYPE_OPTIONS"
|
||||
allow-clear
|
||||
placeholder="全部类型"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select
|
||||
v-model:value="search.status"
|
||||
:options="DEADLETTER_STATUS_OPTIONS"
|
||||
allow-clear
|
||||
placeholder="全部状态"
|
||||
style="width: 140px"
|
||||
/>
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" :row-selection="rowSelection" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button type="primary" :disabled="!selectedRowKeys.length" @click="batchRetry">
|
||||
<template #icon><RedoOutlined /></template>
|
||||
批量重投
|
||||
</a-button>
|
||||
<a-button danger :disabled="!selectedRowKeys.length" @click="batchDiscard">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
批量丢弃
|
||||
</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
:disabled="record.status === 'recovered' || record.status === 'discarded'"
|
||||
@click="handleAction('retry', record)"
|
||||
>手动重投</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button
|
||||
type="link"
|
||||
size="small"
|
||||
danger
|
||||
:disabled="record.status === 'discarded'"
|
||||
@click="handleAction('discard', record)"
|
||||
>永久丢弃</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
|
||||
<InfoDrawer ref="infoDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { DeleteOutlined, RedoOutlined } from '@ant-design/icons-vue'
|
||||
import StatCard from '@components/StatCard.vue'
|
||||
import InfoDrawer from './component/drawer/info/info.vue'
|
||||
import {
|
||||
DATA_TYPE_OPTIONS,
|
||||
DEADLETTER_STATUS_OPTIONS,
|
||||
RETRY_STRATEGY_TEXT,
|
||||
} from './types'
|
||||
import { usePage } from './init/usePage'
|
||||
|
||||
const {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
statCards,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
batchRetry,
|
||||
batchDiscard,
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import "@assets/styles/listPage.less";
|
||||
|
||||
.admin-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.deadletter-stat {
|
||||
margin: 0 !important;
|
||||
}
|
||||
|
||||
.strategy-alert {
|
||||
margin: 0 !important;
|
||||
}
|
||||
</style>
|
||||
@@ -1,252 +0,0 @@
|
||||
/**
|
||||
* 失败重投 - 主 hook
|
||||
* - 列表查询 / 分页 / 排序
|
||||
* - 详情抽屉
|
||||
* - 手动重投(单条/批量)
|
||||
* - 永久丢弃(单条/批量,二次确认)
|
||||
*/
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { createPaginationConfig } from '@utils/antDesign/table'
|
||||
import type { TableState } from '@utils/antDesign/table'
|
||||
import InfoDrawer from '../component/drawer/info/info.vue'
|
||||
import { batchDiscard as batchDiscardApi, batchRetry as batchRetryApi, discard, list, retry, stat } from '../api'
|
||||
import type { DeadletterItem, SearchForm, StatCards } from '../types'
|
||||
import { createSearchKey } from './useSearch'
|
||||
import { tableColumns } from './useTable'
|
||||
|
||||
/**
|
||||
* 失败重投页主编排:
|
||||
* - 顶部统计卡(待重投 / 重投中 / 已恢复 / 已丢弃)
|
||||
* - 策略说明条(指数退避)
|
||||
* - 标准列表 + 详情抽屉(请求体/响应体)
|
||||
* - 手动重投 / 永久丢弃(单条/批量)
|
||||
*/
|
||||
export const usePage = () => {
|
||||
const { message, Modal } = useAntdStaticMethods()
|
||||
|
||||
const search = reactive<SearchForm>(createSearchKey())
|
||||
|
||||
const table = reactive<TableState<DeadletterItem>>({
|
||||
columns: tableColumns,
|
||||
dataSource: [],
|
||||
sort: { field: '', order: null },
|
||||
pagination: createPaginationConfig() as TablePaginationConfig,
|
||||
})
|
||||
|
||||
const pageLoading = ref<boolean>(false)
|
||||
const selectedRowKeys = ref<(string | number)[]>([])
|
||||
|
||||
/** 顶部统计卡数据 */
|
||||
const statCards = ref<StatCards>({
|
||||
pendingCount: 0,
|
||||
retryingCount: 0,
|
||||
recoveredCount: 0,
|
||||
discardedCount: 0,
|
||||
})
|
||||
|
||||
/** 详情抽屉 ref */
|
||||
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
|
||||
|
||||
const listRequest = (): void => {
|
||||
pageLoading.value = true
|
||||
list(search)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
table.dataSource = res.data ?? []
|
||||
table.pagination.total = table.dataSource.length
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('死信队列列表请求失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const statRequest = (): void => {
|
||||
stat()
|
||||
.then((res) => {
|
||||
if (res.code === '00000' && res.data) {
|
||||
statCards.value = res.data
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('死信统计请求失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const searchQuery = (): void => {
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const resetQuery = (): void => {
|
||||
Object.assign(search, createSearchKey())
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const dataSourceChange = (pagination: TablePaginationConfig): void => {
|
||||
table.pagination.current = pagination.current ?? 1
|
||||
table.pagination.pageSize = pagination.pageSize ?? 30
|
||||
}
|
||||
|
||||
const rowSelection = {
|
||||
selectedRowKeys,
|
||||
onChange: (keys: (string | number)[]): void => {
|
||||
selectedRowKeys.value = keys
|
||||
},
|
||||
}
|
||||
|
||||
/** 打开详情抽屉 */
|
||||
const openInfoDrawer = (record: DeadletterItem): void => {
|
||||
infoDrawerRef.value?.openDrawer({ id: record.id, title: '死信详情' })
|
||||
}
|
||||
|
||||
/** 手动重投(单条) */
|
||||
const retryRecord = (record: DeadletterItem): void => {
|
||||
Modal.confirm({
|
||||
title: '确认重投?',
|
||||
content: `死信 [${record.id}] 将立即触发一次手动重投,确认继续?`,
|
||||
okText: '确认重投',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
retry(record.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
listRequest()
|
||||
statRequest()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('重投失败:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 永久丢弃(单条) */
|
||||
const discardRecord = (record: DeadletterItem): void => {
|
||||
Modal.confirm({
|
||||
title: '永久丢弃?',
|
||||
content: `丢弃后死信 [${record.id}] 将不再自动重投,且无法恢复,确认继续?`,
|
||||
okText: '确认丢弃',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
discard(record.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
listRequest()
|
||||
statRequest()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('丢弃失败:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量重投 */
|
||||
const batchRetry = (): void => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
void message.warning('请先勾选要重投的死信')
|
||||
return
|
||||
}
|
||||
const ids = selectedRowKeys.value.map((key) => String(key))
|
||||
Modal.confirm({
|
||||
title: '确认批量重投?',
|
||||
content: `将立即重投 ${ids.length} 条死信,确认继续?`,
|
||||
okText: '确认重投',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
batchRetryApi(ids)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
selectedRowKeys.value = []
|
||||
listRequest()
|
||||
statRequest()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('批量重投失败:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/** 批量丢弃 */
|
||||
const batchDiscard = (): void => {
|
||||
if (selectedRowKeys.value.length === 0) {
|
||||
void message.warning('请先勾选要丢弃的死信')
|
||||
return
|
||||
}
|
||||
const ids = selectedRowKeys.value.map((key) => String(key))
|
||||
Modal.confirm({
|
||||
title: '确认批量丢弃?',
|
||||
content: `将永久丢弃 ${ids.length} 条死信,此操作不可恢复,确认继续?`,
|
||||
okText: '确认丢弃',
|
||||
okType: 'danger',
|
||||
cancelText: '取消',
|
||||
onOk: () => {
|
||||
batchDiscardApi(ids)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
void message.success(res.msg)
|
||||
selectedRowKeys.value = []
|
||||
listRequest()
|
||||
statRequest()
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('批量丢弃失败:', err)
|
||||
})
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleAction = (type: string, record?: DeadletterItem): void => {
|
||||
if (type === 'detail' && record) {
|
||||
openInfoDrawer(record)
|
||||
return
|
||||
}
|
||||
if (type === 'retry' && record) {
|
||||
retryRecord(record)
|
||||
return
|
||||
}
|
||||
if (type === 'discard' && record) {
|
||||
discardRecord(record)
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
listRequest()
|
||||
statRequest()
|
||||
})
|
||||
|
||||
return {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
statCards,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
handleAction,
|
||||
listRequest,
|
||||
batchRetry,
|
||||
batchDiscard,
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import type { SearchForm } from '../types'
|
||||
|
||||
/** 搜索表单工厂:统一重置入口 */
|
||||
export const createSearchKey = (): SearchForm => ({
|
||||
deviceCode: undefined,
|
||||
downstreamId: undefined,
|
||||
dataType: undefined,
|
||||
status: undefined,
|
||||
})
|
||||
|
||||
export const useSearch = () => {
|
||||
return { createSearchKey }
|
||||
}
|
||||
@@ -1,46 +0,0 @@
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import type { DataType } from '../../record/types'
|
||||
import { dataTypeText } from '../../record/types'
|
||||
import type { DeadletterStatus } from '../types'
|
||||
import { deadletterStatusColor, deadletterStatusText } from '../types'
|
||||
|
||||
/** 死信队列列定义 */
|
||||
export const tableColumns: TableColumnsType = [
|
||||
{ title: '死信ID', dataIndex: 'id', align: 'left', fixed: 'left', width: 110 },
|
||||
{ title: '原分发记录', dataIndex: 'originalRecordId', align: 'left', width: 120, ellipsis: true },
|
||||
{ title: '设备编码', dataIndex: 'deviceCode', align: 'left', width: 140, resizable: true, ellipsis: true },
|
||||
{ title: '设备名称', dataIndex: 'deviceName', align: 'left', width: 150, resizable: true, ellipsis: true },
|
||||
{
|
||||
title: '数据类型',
|
||||
dataIndex: 'dataType',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: DataType }) => h(Tag, { color: 'blue' }, () => dataTypeText(value)),
|
||||
},
|
||||
{ title: '下游系统', dataIndex: 'downstreamName', align: 'left', width: 140, resizable: true, ellipsis: true },
|
||||
{ title: '首次失败', dataIndex: 'firstFailTime', align: 'center', width: 170, sorter: true },
|
||||
{ title: '最近重投', dataIndex: 'lastRetryTime', align: 'center', width: 170, customRender: ({ value }: { value: string | null }) => value ?? '—' },
|
||||
{
|
||||
title: '重投次数',
|
||||
dataIndex: 'retryCount',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
customRender: ({ value }: { value: number }) => {
|
||||
const color = value >= 4 ? 'red' : value >= 2 ? 'orange' : 'default'
|
||||
return h(Tag, { color }, () => `${value} / 5`)
|
||||
},
|
||||
},
|
||||
{ title: '下次重投', dataIndex: 'nextRetryTime', align: 'center', width: 170, customRender: ({ value }: { value: string | null }) => value ?? '—' },
|
||||
{ title: '失败原因', dataIndex: 'failReason', align: 'left', width: 200, resizable: true, ellipsis: true },
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: DeadletterStatus }) =>
|
||||
h(Tag, { color: deadletterStatusColor[value] }, () => deadletterStatusText(value)),
|
||||
},
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 240 },
|
||||
]
|
||||
@@ -1,86 +0,0 @@
|
||||
/**
|
||||
* 失败重投 - 死信队列类型定义
|
||||
* - 记录分发失败进入死信队列的样本
|
||||
* - 自动重投策略:指数退避(1min / 5min / 30min / 2h / 12h),最多 5 次后转人工
|
||||
* - 状态机:pending(待重投)→ retrying(重投中)→ recovered(已恢复)/ discarded(已丢弃)
|
||||
*/
|
||||
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
|
||||
import type { DataType } from '../../record/types'
|
||||
|
||||
/** 死信状态 */
|
||||
export type DeadletterStatus = 'pending' | 'retrying' | 'recovered' | 'discarded'
|
||||
|
||||
/** 死信记录(列表项) */
|
||||
export interface DeadletterItem {
|
||||
id: string
|
||||
originalRecordId: string
|
||||
deviceCode: string
|
||||
deviceName: string
|
||||
dataType: DataType
|
||||
downstreamId: string
|
||||
downstreamName: string
|
||||
firstFailTime: string
|
||||
lastRetryTime: string | null
|
||||
retryCount: number
|
||||
nextRetryTime: string | null
|
||||
failReason: string
|
||||
status: DeadletterStatus
|
||||
}
|
||||
|
||||
/** 死信详情(含请求体/响应体) */
|
||||
export interface DeadletterDetail extends DeadletterItem {
|
||||
requestBody: string
|
||||
responseBody: string
|
||||
authKey: string
|
||||
strategy: string
|
||||
thresholdWarn: string
|
||||
}
|
||||
|
||||
/** 顶部统计卡 */
|
||||
export interface StatCards {
|
||||
pendingCount: number
|
||||
retryingCount: number
|
||||
recoveredCount: number
|
||||
discardedCount: number
|
||||
}
|
||||
|
||||
/** 搜索表单 */
|
||||
export interface SearchForm extends BaseSearchForm {
|
||||
deviceCode?: string
|
||||
dataType?: DataType
|
||||
downstreamId?: string
|
||||
status?: DeadletterStatus
|
||||
}
|
||||
|
||||
/** 死信状态选项 */
|
||||
export const DEADLETTER_STATUS_OPTIONS = [
|
||||
{ label: '待重投', value: 'pending' },
|
||||
{ label: '重投中', value: 'retrying' },
|
||||
{ label: '已恢复', value: 'recovered' },
|
||||
{ label: '已丢弃', value: 'discarded' },
|
||||
] as const
|
||||
|
||||
/** 复用 record 的数据类型选项 + 文本字典 */
|
||||
export { DATA_TYPE_OPTIONS, dataTypeText } from '../../record/types'
|
||||
|
||||
/** 死信状态 → Tag 颜色 */
|
||||
export const deadletterStatusColor: Record<DeadletterStatus, string> = {
|
||||
pending: 'warning',
|
||||
retrying: 'processing',
|
||||
recovered: 'success',
|
||||
discarded: 'default',
|
||||
}
|
||||
|
||||
/** 死信状态 → 中文文本 */
|
||||
export const deadletterStatusText = (status: DeadletterStatus): string => {
|
||||
const map: Record<DeadletterStatus, string> = {
|
||||
pending: '待重投',
|
||||
retrying: '重投中',
|
||||
recovered: '已恢复',
|
||||
discarded: '已丢弃',
|
||||
}
|
||||
return map[status] ?? '—'
|
||||
}
|
||||
|
||||
/** 指数退避策略说明(展示用) */
|
||||
export const RETRY_STRATEGY_TEXT = '指数退避:1min → 5min → 30min → 2h → 12h,最多 5 次后转人工'
|
||||
-11
@@ -1,11 +0,0 @@
|
||||
/**
|
||||
* 分发记录 - 详情抽屉 - 接口层
|
||||
*/
|
||||
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DetailInfo } from '../../../../types'
|
||||
|
||||
/** 详情查询(含请求体/响应体) */
|
||||
export const getInfo = (id: string): Promise<ApiResponse<DetailInfo>> =>
|
||||
postRequest('axiosRequest', '/admin/device/distribute/record/info', { id })
|
||||
@@ -0,0 +1,39 @@
|
||||
/**
|
||||
* 数据分发 - 上报配置 接口层
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { CustomerOption, ReportItem, ReportPayload, SearchForm, SiteOption } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/report'
|
||||
|
||||
/** 列表查询 */
|
||||
export const list = (params: SearchForm): Promise<ApiResponse<ReportItem[]>> =>
|
||||
postRequest('axiosRequest', BASE + '/list', params)
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<ReportItem>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
|
||||
/** 新增 */
|
||||
export const add = (params: ReportPayload): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
/** 编辑 */
|
||||
export const update = (params: ReportPayload): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
|
||||
/**
|
||||
* 客户下拉选项(复用客户档案列表接口,仅取 id/name)
|
||||
* - 路径与 @pages/admin-portal/device-center/customer/archive/api 一致
|
||||
*/
|
||||
export const customerOptions = (): Promise<ApiResponse<{ id: string; name: string }[]>> =>
|
||||
postRequest('axiosRequest', '/admin/device/customer/archive/page', {})
|
||||
|
||||
/**
|
||||
* 布点场所下拉选项(复用布点场所列表接口,可按 customerId 联动过滤)
|
||||
* - 路径与 @pages/admin-portal/device-center/customer/site/api 一致
|
||||
* - 传 customerId 时仅返回该客户的场所;不传时返回全部
|
||||
*/
|
||||
export const siteOptions = (customerId?: string): Promise<ApiResponse<SiteOption[]>> =>
|
||||
postRequest('axiosRequest', '/admin/device/site/list', { customerId, pageNum: 1, pageSize: 200 })
|
||||
+12
@@ -0,0 +1,12 @@
|
||||
/**
|
||||
* 上报配置 - 详情抽屉 - 接口层
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { DetailInfo } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/report'
|
||||
|
||||
/** 详情查询 */
|
||||
export const info = (id: string): Promise<ApiResponse<DetailInfo>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
@@ -0,0 +1,57 @@
|
||||
<template>
|
||||
<a-drawer
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-descriptions v-if="detail" title="基本信息" :column="2" bordered size="small">
|
||||
<a-descriptions-item label="上报通道名称">{{ detail.channelName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="通道编码">{{ detail.channelCode ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="所属客户">{{ detail.customerName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="布点场所">{{ detail.siteName ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权开关">
|
||||
<a-tag :color="detail.authEnabled ? 'green' : 'default'">{{ detail.authEnabled ? '已开启' : '已关闭' }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="状态">
|
||||
<a-tag :color="statusColor[detail.status]">{{ statusText(detail.status) }}</a-tag>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="MQ 通讯地址" :span="2">{{ detail.mqAddress ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="服务网关地址" :span="2">{{ detail.gatewayUrl ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="覆盖设备" :span="2">
|
||||
<div v-if="detail.deviceCategoryNames && detail.deviceCategoryNames.length">
|
||||
<div
|
||||
v-for="name in detail.deviceCategoryNames"
|
||||
:key="name"
|
||||
style="line-height: 1.8"
|
||||
>{{ name }}</div>
|
||||
</div>
|
||||
<span v-else>—</span>
|
||||
</a-descriptions-item>
|
||||
<a-descriptions-item label="创建时间" :span="2">{{ detail.createTime ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="备注" :span="2">{{ detail.remark ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
|
||||
<a-descriptions v-if="detail && detail.authEnabled" title="鉴权信息" :column="1" bordered size="small" style="margin-top: 16px">
|
||||
<a-descriptions-item label="鉴权账号">{{ detail.authAccount ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权秘钥">{{ detail.authSecret ?? '—' }}</a-descriptions-item>
|
||||
<a-descriptions-item label="鉴权接口地址">{{ detail.authApiUrl ?? '—' }}</a-descriptions-item>
|
||||
</a-descriptions>
|
||||
</a-spin>
|
||||
</a-drawer>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 上报配置 - 详情抽屉
|
||||
* - 只读展示,所有字段 ?? '—'
|
||||
* - 鉴权关闭时不展示鉴权信息区块
|
||||
*/
|
||||
import { usePage } from './init/usePage'
|
||||
import { statusColor, statusText } from '../../../types'
|
||||
|
||||
const { pageInfo, detail, openDrawer, closeDrawer } = usePage()
|
||||
|
||||
defineExpose({ openDrawer, closeDrawer })
|
||||
</script>
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
/**
|
||||
* 上报配置 - 详情抽屉 - 主逻辑
|
||||
*/
|
||||
import { info } from '../api'
|
||||
import type { DetailInfo, OpenContext, PageInfo } from '../types'
|
||||
|
||||
export const usePage = () => {
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
title: '上报配置详情',
|
||||
width: 760,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const detail = ref<DetailInfo | null>(null)
|
||||
|
||||
const closeDrawer = (): void => {
|
||||
pageInfo.visible = false
|
||||
detail.value = null
|
||||
}
|
||||
|
||||
const openDrawer = (ctx: OpenContext): void => {
|
||||
pageInfo.visible = true
|
||||
pageInfo.spin = true
|
||||
detail.value = null
|
||||
|
||||
info(ctx.id)
|
||||
.then((res) => {
|
||||
if (res.code === '00000' && res.data) {
|
||||
detail.value = res.data
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('上报配置详情查询失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
return {
|
||||
pageInfo,
|
||||
detail,
|
||||
openDrawer,
|
||||
closeDrawer,
|
||||
}
|
||||
}
|
||||
+17
@@ -0,0 +1,17 @@
|
||||
import type { ReportItem } from '../../../types'
|
||||
|
||||
/** 抽屉页面状态 */
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
/** 打开抽屉上下文 */
|
||||
export interface OpenContext {
|
||||
id: string
|
||||
}
|
||||
|
||||
/** 详情数据(与列表项 ReportItem 同构) */
|
||||
export type DetailInfo = ReportItem
|
||||
+213
@@ -0,0 +1,213 @@
|
||||
<template>
|
||||
<a-modal
|
||||
v-model:open="pageInfo.visible"
|
||||
:title="pageInfo.title"
|
||||
:width="pageInfo.width"
|
||||
:keyboard="false"
|
||||
:mask-closable="false"
|
||||
:destroy-on-close="true"
|
||||
>
|
||||
<a-spin :spinning="pageInfo.spin">
|
||||
<a-form
|
||||
ref="formRef"
|
||||
:model="form"
|
||||
:rules="rules"
|
||||
:label-col="{ style: { width: '120px', minWidth: '120px' } }"
|
||||
>
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="上报通道名称" name="channelName">
|
||||
<a-input
|
||||
v-model:value="form.channelName"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="60"
|
||||
placeholder="如:一厂食堂前置服务器"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="通道编码" name="channelCode">
|
||||
<a-input
|
||||
v-model:value="form.channelCode"
|
||||
v-only-alphanumeric
|
||||
:disabled="!!form.id"
|
||||
allow-clear
|
||||
:maxlength="40"
|
||||
placeholder="如:CQ-CANTEEN-01"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="所属客户" name="customerId">
|
||||
<a-select
|
||||
v-model:value="form.customerId"
|
||||
:options="customerOptions"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOption"
|
||||
placeholder="请选择所属客户"
|
||||
@change="onCustomerChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="布点场所" name="siteId">
|
||||
<a-select
|
||||
v-model:value="form.siteId"
|
||||
:options="siteOptions"
|
||||
:loading="siteLoading"
|
||||
:disabled="!form.customerId"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOption"
|
||||
placeholder="请先选择所属客户"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<a-form-item label="MQ 通讯地址" name="mqAddress">
|
||||
<a-input
|
||||
v-model:value="form.mqAddress"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="200"
|
||||
placeholder="如:mq://10.10.1.21:61616"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="服务网关地址" name="gatewayUrl">
|
||||
<a-input
|
||||
v-model:value="form.gatewayUrl"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="200"
|
||||
placeholder="如:https://gateway.example.com/api/v1/iot/report"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item label="覆盖设备" name="deviceCategoryIds">
|
||||
<a-tree-select
|
||||
v-model:value="form.deviceCategoryIds"
|
||||
:tree-data="categoryTree"
|
||||
:field-names="treeFieldNames"
|
||||
tree-checkable
|
||||
tree-default-expand-all
|
||||
show-search
|
||||
tree-node-filter-prop="name"
|
||||
allow-clear
|
||||
multiple
|
||||
placeholder="选择该上报通道覆盖的设备分类(可多选)"
|
||||
style="width: 100%"
|
||||
/>
|
||||
</a-form-item>
|
||||
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="鉴权开关" name="authEnabled">
|
||||
<a-switch v-model:checked="form.authEnabled" checked-children="开启" un-checked-children="关闭" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="状态" name="status">
|
||||
<a-select v-model:value="form.status" :options="STATUS_OPTIONS" placeholder="请选择状态" />
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<template v-if="form.authEnabled">
|
||||
<a-row :gutter="16">
|
||||
<a-col :span="12">
|
||||
<a-form-item label="鉴权账号" name="authAccount">
|
||||
<a-input
|
||||
v-model:value="form.authAccount"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="60"
|
||||
placeholder="鉴权账号 / AppID"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
<a-col :span="12">
|
||||
<a-form-item label="鉴权秘钥" name="authSecret">
|
||||
<a-input-password
|
||||
v-model:value="form.authSecret"
|
||||
allow-clear
|
||||
:maxlength="120"
|
||||
placeholder="鉴权秘钥 / Secret"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-col>
|
||||
</a-row>
|
||||
<a-form-item label="鉴权接口地址" name="authApiUrl">
|
||||
<a-input
|
||||
v-model:value="form.authApiUrl"
|
||||
v-no-space
|
||||
allow-clear
|
||||
:maxlength="200"
|
||||
placeholder="如:https://downstream.example.com/api/v1/auth/token"
|
||||
/>
|
||||
</a-form-item>
|
||||
</template>
|
||||
|
||||
<a-form-item label="备注">
|
||||
<a-textarea
|
||||
v-model:value="form.remark"
|
||||
:rows="2"
|
||||
:maxlength="200"
|
||||
show-count
|
||||
placeholder="可选"
|
||||
/>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-spin>
|
||||
|
||||
<template #footer>
|
||||
<a-button @click="closeModal">取消</a-button>
|
||||
<a-button
|
||||
type="primary"
|
||||
:disabled="pageInfo.spin"
|
||||
:loading="pageInfo.spin"
|
||||
@click="submit"
|
||||
>确定</a-button>
|
||||
</template>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 上报配置 - 新增/编辑 弹框
|
||||
* - 鉴权开关联动:开启时显示鉴权账号 / 鉴权秘钥 / 鉴权接口地址,并设为必填
|
||||
* - 所属客户、布点场所为下拉;布点场所随客户联动(选客户后才可选场所)
|
||||
* - 覆盖设备为设备分类树多选
|
||||
*/
|
||||
import { filterOption } from '@utils/antDesign/select'
|
||||
import { usePage } from './init/usePage'
|
||||
import { STATUS_OPTIONS } from '../../../types'
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'load'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
pageInfo,
|
||||
formRef,
|
||||
form,
|
||||
rules,
|
||||
customerOptions,
|
||||
siteOptions,
|
||||
siteLoading,
|
||||
categoryTree,
|
||||
treeFieldNames,
|
||||
openModal,
|
||||
closeModal,
|
||||
submit,
|
||||
onCustomerChange,
|
||||
} = usePage(emit)
|
||||
|
||||
defineExpose({ openModal })
|
||||
</script>
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 上报配置 - 新增/编辑 弹框 - 接口层
|
||||
* - 复用主页的 info / add / update
|
||||
*/
|
||||
import type { ApiResponse } from '@axios'
|
||||
import { postRequest } from '@axios'
|
||||
import type { Form } from '../types'
|
||||
|
||||
const BASE = '/admin/device/distribute/report'
|
||||
|
||||
/** 详情查询(编辑回填) */
|
||||
export const info = (id: string): Promise<ApiResponse<Form>> =>
|
||||
postRequest('axiosRequest', BASE + '/info', { id })
|
||||
|
||||
/** 新增 */
|
||||
export const add = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/add', params)
|
||||
|
||||
/** 编辑 */
|
||||
export const edit = (params: Record<string, unknown>): Promise<ApiResponse<null>> =>
|
||||
postRequest('axiosRequest', BASE + '/update', params)
|
||||
+273
@@ -0,0 +1,273 @@
|
||||
/**
|
||||
* 上报配置 - 新增/编辑 弹框 - 主逻辑
|
||||
*
|
||||
* 严格遵循 modal-spec.md:
|
||||
* - createInitForm 工厂函数
|
||||
* - openModal 用 Promise.all 拉取详情 + 客户下拉 + 设备分类树
|
||||
* - addRequest 必须 delete params.id
|
||||
* - submit 开头立即 pageInfo.spin = true
|
||||
* - 用 params.id 判断新增/编辑
|
||||
* - 客户 → 场所联动:选客户后加载该客户场所;切换客户清空 siteId
|
||||
* - 鉴权开关联动:关闭时清空 鉴权账号/秘钥/接口地址 且不校验
|
||||
*/
|
||||
import type { FormInstance, Rule } from 'ant-design-vue/es/form'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { deviceCategoryTree } from '@api'
|
||||
import type { DeviceCategoryTreeNode } from '@api'
|
||||
import { customerOptions as fetchCustomerOptions, siteOptions as fetchSiteOptions } from '../../../../api'
|
||||
import { add, edit, info } from '../api'
|
||||
import type { CustomerOption, Form, ModalType, OpenContext, PageInfo, SiteOption } from '../types'
|
||||
|
||||
/** 初始表单工厂 */
|
||||
const createInitForm = (): Form => ({
|
||||
id: undefined,
|
||||
channelName: '',
|
||||
channelCode: '',
|
||||
customerId: undefined,
|
||||
siteId: undefined,
|
||||
authEnabled: false,
|
||||
authAccount: '',
|
||||
authSecret: '',
|
||||
authApiUrl: '',
|
||||
status: 'enabled',
|
||||
mqAddress: '',
|
||||
gatewayUrl: '',
|
||||
deviceCategoryIds: [],
|
||||
remark: '',
|
||||
})
|
||||
|
||||
export const usePage = (emit: (e: 'load') => void) => {
|
||||
const { message } = useAntdStaticMethods()
|
||||
|
||||
const pageInfo = reactive<PageInfo>({
|
||||
visible: false,
|
||||
type: 'add',
|
||||
title: '',
|
||||
width: 820,
|
||||
spin: false,
|
||||
})
|
||||
|
||||
const formRef = ref<FormInstance>()
|
||||
const form = ref<Form>(createInitForm())
|
||||
|
||||
/** 客户下拉选项 */
|
||||
const customerOptions = ref<CustomerOption[]>([])
|
||||
/** 场所下拉选项(按当前 customerId 联动) */
|
||||
const siteOptions = ref<SiteOption[]>([])
|
||||
const siteLoading = ref<boolean>(false)
|
||||
/** 设备分类树(a-tree-select 数据源) */
|
||||
const categoryTree = ref<DeviceCategoryTreeNode[]>([])
|
||||
/** a-tree-select 字段映射(与设备分类树节点字段对齐) */
|
||||
const treeFieldNames = { label: 'name', value: 'id', children: 'children' }
|
||||
|
||||
const rules: Record<string, Rule[]> = {
|
||||
channelName: [{ required: true, message: '请输入上报通道名称', trigger: 'blur' }],
|
||||
channelCode: [{ required: true, message: '请输入通道编码', trigger: 'blur' }],
|
||||
customerId: [{ required: true, message: '请选择所属客户', trigger: 'change' }],
|
||||
siteId: [{ required: true, message: '请选择布点场所', trigger: 'change' }],
|
||||
status: [{ required: true, message: '请选择状态', trigger: 'change' }],
|
||||
mqAddress: [{ required: true, message: '请输入 MQ 通讯地址', trigger: 'blur' }],
|
||||
gatewayUrl: [{ required: true, message: '请输入服务网关地址', trigger: 'blur' }],
|
||||
}
|
||||
|
||||
/** 鉴权开关联动:开启时鉴权字段必填 */
|
||||
const authFieldRules = (): Rule[] =>
|
||||
[{
|
||||
required: true,
|
||||
message: '鉴权开启时必填',
|
||||
trigger: 'blur',
|
||||
validator: (_rule: unknown, value: string) => {
|
||||
if (form.value.authEnabled && !value) return Promise.reject(new Error('鉴权开启时必填'))
|
||||
return Promise.resolve()
|
||||
},
|
||||
}] as Rule[]
|
||||
|
||||
const rulesWithAuth: Record<string, Rule[]> = {
|
||||
...rules,
|
||||
authAccount: authFieldRules(),
|
||||
authSecret: authFieldRules(),
|
||||
authApiUrl: authFieldRules(),
|
||||
}
|
||||
|
||||
/** 拉取客户下拉 */
|
||||
const loadCustomerOptions = (): void => {
|
||||
fetchCustomerOptions()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
customerOptions.value = (res.data ?? []).map((c) => ({ label: c.name, value: c.id }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('客户下拉加载失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
/** 拉取场所下拉(按 customerId 联动) */
|
||||
const loadSiteOptions = (customerId?: string): void => {
|
||||
if (!customerId) {
|
||||
siteOptions.value = []
|
||||
return
|
||||
}
|
||||
siteLoading.value = true
|
||||
fetchSiteOptions(customerId)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
siteOptions.value = (res.data ?? []).map((s) => ({ label: s.name, value: s.id, customerId: s.customerId }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('场所下拉加载失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
siteLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 表单客户切换:清空 siteId 并重拉场所 */
|
||||
const onCustomerChange = (value: string | undefined): void => {
|
||||
form.value.siteId = undefined
|
||||
loadSiteOptions(value)
|
||||
}
|
||||
|
||||
/** 拉取设备分类树 */
|
||||
const loadCategoryTree = (): void => {
|
||||
deviceCategoryTree()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
categoryTree.value = res.data ?? []
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('加载设备分类树失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
const resetForm = (): void => {
|
||||
formRef.value?.clearValidate()
|
||||
form.value = createInitForm()
|
||||
siteOptions.value = []
|
||||
}
|
||||
|
||||
const closeModal = (): void => {
|
||||
resetForm()
|
||||
pageInfo.visible = false
|
||||
}
|
||||
|
||||
const infoRequest = (id: string) => info(id)
|
||||
|
||||
/** 打开弹框 */
|
||||
const openModal = (type: ModalType, ctx?: OpenContext): void => {
|
||||
pageInfo.visible = true
|
||||
pageInfo.spin = true
|
||||
pageInfo.type = type
|
||||
resetForm()
|
||||
|
||||
pageInfo.title = type === 'add' ? '新增上报配置' : '编辑上报配置'
|
||||
|
||||
/** 客户下拉 / 设备分类树为空时补拉一次 */
|
||||
if (!customerOptions.value.length) loadCustomerOptions()
|
||||
if (!categoryTree.value.length) loadCategoryTree()
|
||||
|
||||
const requests: Promise<unknown>[] = []
|
||||
if (type === 'edit' && ctx?.id) requests.push(infoRequest(ctx.id))
|
||||
|
||||
Promise.all(requests)
|
||||
.then((results) => {
|
||||
if (type === 'edit' && ctx?.id) {
|
||||
const infoRes = results[0] as { code: string; data: Form }
|
||||
if (infoRes?.code === '00000' && infoRes.data) {
|
||||
form.value = { ...createInitForm(), ...infoRes.data }
|
||||
/** 编辑回填后按当前 customerId 拉一次场所下拉,确保 siteId 能正常展示 */
|
||||
if (form.value.customerId) loadSiteOptions(form.value.customerId)
|
||||
}
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('打开弹框初始化失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const successRequest = (msg: string): void => {
|
||||
void message.success(msg)
|
||||
emit('load')
|
||||
closeModal()
|
||||
}
|
||||
|
||||
const addRequest = (params: Record<string, unknown>): void => {
|
||||
delete params.id
|
||||
/** 鉴权关闭时清空鉴权字段,避免脏数据 */
|
||||
if (!params.authEnabled) {
|
||||
params.authAccount = ''
|
||||
params.authSecret = ''
|
||||
params.authApiUrl = ''
|
||||
}
|
||||
add(params)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') successRequest(res.msg)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('新增失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const editRequest = (params: Record<string, unknown>): void => {
|
||||
if (!params.authEnabled) {
|
||||
params.authAccount = ''
|
||||
params.authSecret = ''
|
||||
params.authApiUrl = ''
|
||||
}
|
||||
edit(params)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') successRequest(res.msg)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('编辑失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageInfo.spin = false
|
||||
})
|
||||
}
|
||||
|
||||
const submit = (): void => {
|
||||
pageInfo.spin = true
|
||||
formRef.value
|
||||
?.validate()
|
||||
.then(() => {
|
||||
const params = JSON.parse(JSON.stringify(form.value)) as Record<string, unknown>
|
||||
if (params.id) editRequest(params)
|
||||
else addRequest(params)
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
pageInfo.spin = false
|
||||
console.error('表单验证失败:', err)
|
||||
})
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomerOptions()
|
||||
loadCategoryTree()
|
||||
})
|
||||
|
||||
return {
|
||||
pageInfo,
|
||||
formRef,
|
||||
form,
|
||||
rules: rulesWithAuth,
|
||||
customerOptions,
|
||||
siteOptions,
|
||||
siteLoading,
|
||||
categoryTree,
|
||||
treeFieldNames,
|
||||
openModal,
|
||||
closeModal,
|
||||
submit,
|
||||
onCustomerChange,
|
||||
}
|
||||
}
|
||||
+58
@@ -0,0 +1,58 @@
|
||||
import type { DeviceCategoryTreeNode } from '@api'
|
||||
import type { Status } from '../../../types'
|
||||
import type { CustomerOption, SiteOption } from '../../../types'
|
||||
|
||||
/** 弹框类型 */
|
||||
export type ModalType = 'add' | 'edit'
|
||||
|
||||
/** 弹框页面状态 */
|
||||
export interface PageInfo {
|
||||
visible: boolean
|
||||
type: ModalType
|
||||
title: string
|
||||
width: number
|
||||
spin: boolean
|
||||
}
|
||||
|
||||
/** 打开弹框上下文 */
|
||||
export interface OpenContext {
|
||||
/** 编辑模式必传 */
|
||||
id?: string
|
||||
}
|
||||
|
||||
/** 表单字段(对齐 ReportPayload) */
|
||||
export interface Form {
|
||||
id: string | undefined
|
||||
/** 上报通道名称 */
|
||||
channelName: string
|
||||
/** 通道编码(编辑时禁用) */
|
||||
channelCode: string
|
||||
/** 所属客户 ID */
|
||||
customerId: string | undefined
|
||||
/** 布点场所 ID */
|
||||
siteId: string | undefined
|
||||
/** 鉴权开关 */
|
||||
authEnabled: boolean
|
||||
/** 鉴权账号 */
|
||||
authAccount: string
|
||||
/** 鉴权秘钥 */
|
||||
authSecret: string
|
||||
/** 鉴权接口地址 */
|
||||
authApiUrl: string
|
||||
/** 状态 */
|
||||
status: Status
|
||||
/** MQ 通讯地址 */
|
||||
mqAddress: string
|
||||
/** 服务网关地址 */
|
||||
gatewayUrl: string
|
||||
/** 覆盖设备(设备分类 ID 多选) */
|
||||
deviceCategoryIds: string[]
|
||||
/** 备注 */
|
||||
remark: string
|
||||
}
|
||||
|
||||
/** 客户下拉选项(与主页共享类型) */
|
||||
export type { CustomerOption, SiteOption }
|
||||
|
||||
/** 设备分类树节点(与 @api 对齐) */
|
||||
export type CategoryTreeNode = DeviceCategoryTreeNode
|
||||
@@ -0,0 +1,166 @@
|
||||
import { onMounted, reactive, ref } from 'vue'
|
||||
import type { TablePaginationConfig } from 'ant-design-vue'
|
||||
import { useAntdStaticMethods } from '@utils/antDesign/popUp'
|
||||
import { createPaginationConfig } from '@utils/antDesign/table'
|
||||
import type { TableState } from '@utils/antDesign/table'
|
||||
import AddOrEdit from '../component/modal/addOrEdit/addOrEdit.vue'
|
||||
import InfoDrawer from '../component/drawer/info/info.vue'
|
||||
import { customerOptions as fetchCustomerOptions, list, siteOptions as fetchSiteOptions } from '../api'
|
||||
import type { CustomerOption, ReportItem, SearchForm, SiteOption } from '../types'
|
||||
import { createSearchKey } from './useSearch'
|
||||
import { tableColumns } from './useTable'
|
||||
|
||||
/**
|
||||
* 上报配置主编排:
|
||||
* - 标准列表 + 新增/编辑弹框 + 详情抽屉
|
||||
* - 客户下拉首屏拉一次,场所下拉随客户联动(列表搜索 + 弹框共用本 hook 的下拉数据)
|
||||
* - 切换客户时清空已选 siteId,避免脏数据
|
||||
*/
|
||||
export const usePage = () => {
|
||||
const { Modal } = useAntdStaticMethods()
|
||||
|
||||
const search = reactive<SearchForm>(createSearchKey())
|
||||
|
||||
const table = reactive<TableState<ReportItem>>({
|
||||
columns: tableColumns,
|
||||
dataSource: [],
|
||||
sort: { field: '', order: null },
|
||||
pagination: createPaginationConfig() as TablePaginationConfig,
|
||||
})
|
||||
|
||||
const pageLoading = ref<boolean>(false)
|
||||
|
||||
/** 客户下拉选项 */
|
||||
const customerOptions = ref<CustomerOption[]>([])
|
||||
const customerLoading = ref<boolean>(false)
|
||||
/** 场所下拉选项(按当前 customerId 联动) */
|
||||
const siteOptions = ref<SiteOption[]>([])
|
||||
const siteLoading = ref<boolean>(false)
|
||||
|
||||
const addOrEditRef = ref<InstanceType<typeof AddOrEdit> | null>(null)
|
||||
const infoDrawerRef = ref<InstanceType<typeof InfoDrawer> | null>(null)
|
||||
|
||||
/** 拉取客户下拉(复用客户档案列表接口,仅取 id/name) */
|
||||
const loadCustomerOptions = (): void => {
|
||||
customerLoading.value = true
|
||||
fetchCustomerOptions()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
customerOptions.value = (res.data ?? []).map((c) => ({ label: c.name, value: c.id }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('客户下拉加载失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
customerLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 拉取场所下拉(按 customerId 联动;不传时返回全部场所) */
|
||||
const loadSiteOptions = (customerId?: string): void => {
|
||||
siteLoading.value = true
|
||||
fetchSiteOptions(customerId)
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
siteOptions.value = (res.data ?? []).map((s) => ({ label: s.name, value: s.id, customerId: s.customerId }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('场所下拉加载失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
siteLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
/** 搜索区客户切换:联动重拉场所 + 清空已选 siteId */
|
||||
const onCustomerChange = (value: string | undefined): void => {
|
||||
search.siteId = undefined
|
||||
loadSiteOptions(value)
|
||||
}
|
||||
|
||||
const listRequest = (): void => {
|
||||
pageLoading.value = true
|
||||
list({ ...search })
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
table.dataSource = res.data ?? []
|
||||
table.pagination.total = table.dataSource.length
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => {
|
||||
console.error('上报配置列表请求失败:', err)
|
||||
})
|
||||
.finally(() => {
|
||||
pageLoading.value = false
|
||||
})
|
||||
}
|
||||
|
||||
const searchQuery = (): void => {
|
||||
table.pagination.current = 1
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const resetQuery = (): void => {
|
||||
Object.assign(search, createSearchKey())
|
||||
table.pagination.current = 1
|
||||
/** 重置后场所下拉回到全量 */
|
||||
loadSiteOptions()
|
||||
listRequest()
|
||||
}
|
||||
|
||||
const dataSourceChange = (pagination: TablePaginationConfig): void => {
|
||||
table.pagination.current = pagination.current ?? 1
|
||||
table.pagination.pageSize = pagination.pageSize ?? 30
|
||||
}
|
||||
|
||||
/** 打开新增弹框 */
|
||||
const openAddModal = (): void => {
|
||||
addOrEditRef.value?.openModal('add')
|
||||
}
|
||||
|
||||
/** 打开编辑弹框 */
|
||||
const openEditModal = (record: ReportItem): void => {
|
||||
addOrEditRef.value?.openModal('edit', { id: record.id })
|
||||
}
|
||||
|
||||
/** 打开详情抽屉 */
|
||||
const openInfoDrawer = (record: ReportItem): void => {
|
||||
infoDrawerRef.value?.openDrawer({ id: record.id })
|
||||
}
|
||||
|
||||
const handleAction = (type: string, record?: ReportItem): void => {
|
||||
if (type === 'add') {
|
||||
openAddModal()
|
||||
return
|
||||
}
|
||||
if (!record) return
|
||||
if (type === 'edit') openEditModal(record)
|
||||
if (type === 'detail') openInfoDrawer(record)
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCustomerOptions()
|
||||
loadSiteOptions()
|
||||
listRequest()
|
||||
})
|
||||
|
||||
return {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
customerOptions,
|
||||
customerLoading,
|
||||
siteOptions,
|
||||
siteLoading,
|
||||
addOrEditRef,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
onCustomerChange,
|
||||
handleAction,
|
||||
listRequest,
|
||||
}
|
||||
}
|
||||
+4
-3
@@ -2,8 +2,9 @@ import type { SearchForm } from '../types'
|
||||
|
||||
/** 搜索表单工厂:统一重置入口 */
|
||||
export const createSearchKey = (): SearchForm => ({
|
||||
deviceCode: undefined,
|
||||
downstreamId: undefined,
|
||||
dataType: undefined,
|
||||
customerId: undefined,
|
||||
siteId: undefined,
|
||||
name: undefined,
|
||||
status: undefined,
|
||||
authEnabled: undefined,
|
||||
})
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { TableColumnsType } from 'ant-design-vue'
|
||||
import { h } from 'vue'
|
||||
import { Tag } from 'ant-design-vue'
|
||||
import type { Status } from '../types'
|
||||
import { statusColor, statusText } from '../types'
|
||||
|
||||
/** 上报配置列定义(字段顺序对齐产品需求) */
|
||||
export const tableColumns: TableColumnsType = [
|
||||
{ title: '序号', dataIndex: 'index', align: 'center', fixed: 'left', width: 70, customRender: ({ index }: { index: number }) => index + 1 },
|
||||
{ title: '上报通道名称', dataIndex: 'channelName', align: 'left', width: 180, resizable: true, ellipsis: true },
|
||||
{ title: '通道编码', dataIndex: 'channelCode', align: 'left', width: 140, ellipsis: true },
|
||||
{ title: '所属客户', dataIndex: 'customerName', align: 'left', width: 160, ellipsis: true },
|
||||
{ title: '布点场所', dataIndex: 'siteName', align: 'left', width: 160, ellipsis: true },
|
||||
{
|
||||
title: '鉴权开关',
|
||||
dataIndex: 'authEnabled',
|
||||
align: 'center',
|
||||
width: 100,
|
||||
customRender: ({ value }: { value: boolean }) =>
|
||||
h(Tag, { color: value ? 'green' : 'default' }, () => (value ? '已开启' : '已关闭')),
|
||||
},
|
||||
{
|
||||
title: '状态',
|
||||
dataIndex: 'status',
|
||||
align: 'center',
|
||||
width: 90,
|
||||
customRender: ({ value }: { value: Status }) =>
|
||||
h(Tag, { color: statusColor[value] }, () => statusText(value)),
|
||||
},
|
||||
{ title: 'MQ 通讯地址', dataIndex: 'mqAddress', align: 'left', width: 220, ellipsis: true },
|
||||
{ title: '服务网关地址', dataIndex: 'gatewayUrl', align: 'left', width: 240, resizable: true, ellipsis: true },
|
||||
{
|
||||
title: '覆盖设备',
|
||||
dataIndex: 'deviceCategoryNames',
|
||||
align: 'left',
|
||||
width: 220,
|
||||
customRender: ({ value }: { value: string[] | undefined }) => {
|
||||
if (!value || value.length === 0) return '—'
|
||||
return h('div', value.map((name) => h('div', { key: name, style: 'line-height: 1.6' }, name)))
|
||||
},
|
||||
},
|
||||
{ title: '创建时间', dataIndex: 'createTime', align: 'center', width: 170, sorter: true, resizable: true },
|
||||
{ title: '操作', dataIndex: 'action', align: 'center', fixed: 'right', width: 160 },
|
||||
]
|
||||
@@ -0,0 +1,113 @@
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="所属客户">
|
||||
<a-select
|
||||
v-model:value="search.customerId"
|
||||
:options="customerOptions"
|
||||
:loading="customerLoading"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOption"
|
||||
placeholder="全部客户"
|
||||
style="width: 200px"
|
||||
@change="onCustomerChange"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="布点场所">
|
||||
<a-select
|
||||
v-model:value="search.siteId"
|
||||
:options="siteOptions"
|
||||
:loading="siteLoading"
|
||||
:disabled="!search.customerId"
|
||||
show-search
|
||||
allow-clear
|
||||
:filter-option="filterOption"
|
||||
placeholder="全部场所"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="上报通道名称">
|
||||
<a-input
|
||||
v-model:value="search.name"
|
||||
v-no-space
|
||||
allow-clear
|
||||
placeholder="通道名称"
|
||||
style="width: 200px"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="search.status" :options="STATUS_OPTIONS" allow-clear placeholder="全部状态" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="鉴权开关">
|
||||
<a-select v-model:value="search.authEnabled" allow-clear placeholder="全部" style="width: 140px">
|
||||
<a-select-option :value="true">已开启</a-select-option>
|
||||
<a-select-option :value="false">已关闭</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button type="primary" @click="handleAction('add')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
新增上报配置
|
||||
</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAction('edit', record)">编辑</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" @click="handleAction('detail', record)">查看详情</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
|
||||
<AddOrEdit ref="addOrEditRef" @load="listRequest" />
|
||||
<InfoDrawer ref="infoDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 数据分发 - 上报配置 主列表页
|
||||
* - 单列表,字段对齐产品需求
|
||||
* - 操作列:编辑 / 查看详情
|
||||
* - 客户 → 场所联动:切换客户时重置场所筛选并重拉下拉
|
||||
*/
|
||||
import { PlusOutlined } from '@ant-design/icons-vue'
|
||||
import { filterOption } from '@utils/antDesign/select'
|
||||
import AddOrEdit from './component/modal/addOrEdit/addOrEdit.vue'
|
||||
import InfoDrawer from './component/drawer/info/info.vue'
|
||||
import { usePage } from './init/usePage'
|
||||
import { STATUS_OPTIONS } from './types'
|
||||
|
||||
const {
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
customerOptions,
|
||||
customerLoading,
|
||||
siteOptions,
|
||||
siteLoading,
|
||||
addOrEditRef,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
dataSourceChange,
|
||||
onCustomerChange,
|
||||
handleAction,
|
||||
listRequest,
|
||||
} = usePage()
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@import "@assets/styles/listPage.less";
|
||||
|
||||
.admin-list-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
gap: 12px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* 数据分发 - 上报配置 类型
|
||||
*
|
||||
* 业务定位:管理「设备数据上报到外部下游系统」的通道配置
|
||||
* - 一条上报配置 = 一个上报通道(含 MQ 地址 + 服务网关地址 + 鉴权信息 + 覆盖设备)
|
||||
* - 与「分发配置」相互独立,分发配置面向"下游系统订阅",上报配置面向"主动推送上报"
|
||||
*/
|
||||
import type { BaseSearchForm } from '@pages/admin-portal/shared/types'
|
||||
import type { DeviceCategoryTreeNode } from '@api'
|
||||
|
||||
/** 启用状态 */
|
||||
export type Status = 'enabled' | 'disabled'
|
||||
|
||||
/** 列表项 / 详情 */
|
||||
export interface ReportItem {
|
||||
id: string
|
||||
/** 上报通道名称 */
|
||||
channelName: string
|
||||
/** 通道编码 */
|
||||
channelCode: string
|
||||
/** 所属客户 ID */
|
||||
customerId: string
|
||||
/** 所属客户名称(联表回填) */
|
||||
customerName: string
|
||||
/** 布点场所 ID */
|
||||
siteId: string
|
||||
/** 布点场所名称(联表回填) */
|
||||
siteName: string
|
||||
/** 鉴权开关 */
|
||||
authEnabled: boolean
|
||||
/** 鉴权账号(authEnabled=true 时必填) */
|
||||
authAccount?: string
|
||||
/** 鉴权秘钥(authEnabled=true 时必填) */
|
||||
authSecret?: string
|
||||
/** 鉴权接口地址(authEnabled=true 时必填) */
|
||||
authApiUrl?: string
|
||||
/** 状态 */
|
||||
status: Status
|
||||
/** MQ 通讯地址 */
|
||||
mqAddress: string
|
||||
/** 服务网关地址 */
|
||||
gatewayUrl: string
|
||||
/** 覆盖设备(设备分类 ID 多选) */
|
||||
deviceCategoryIds: string[]
|
||||
/** 覆盖设备名称(列表展示用,由后端 join) */
|
||||
deviceCategoryNames?: string[]
|
||||
/** 备注 */
|
||||
remark?: string
|
||||
/** 创建时间 */
|
||||
createTime?: string
|
||||
}
|
||||
|
||||
/** 新增/编辑提交载荷 */
|
||||
export interface ReportPayload {
|
||||
id?: string
|
||||
channelName: string
|
||||
channelCode: string
|
||||
customerId: string
|
||||
siteId: string
|
||||
authEnabled: boolean
|
||||
authAccount?: string
|
||||
authSecret?: string
|
||||
authApiUrl?: string
|
||||
status: Status
|
||||
mqAddress: string
|
||||
gatewayUrl: string
|
||||
deviceCategoryIds: string[]
|
||||
remark?: string
|
||||
}
|
||||
|
||||
/** 搜索表单 */
|
||||
export interface SearchForm extends BaseSearchForm {
|
||||
/** 所属客户 */
|
||||
customerId?: string
|
||||
/** 布点场所 */
|
||||
siteId?: string
|
||||
/** 上报通道名称(复用 BaseSearchForm.name) */
|
||||
name?: string
|
||||
/** 状态 */
|
||||
status?: Status
|
||||
/** 鉴权开关(undefined=全部) */
|
||||
authEnabled?: boolean
|
||||
}
|
||||
|
||||
/** 启用状态下拉选项 */
|
||||
export const STATUS_OPTIONS: { label: string; value: Status }[] = [
|
||||
{ label: '启用', value: 'enabled' },
|
||||
{ label: '停用', value: 'disabled' },
|
||||
]
|
||||
|
||||
/** 启用状态 → 中文 */
|
||||
export const statusText = (value: Status | string | undefined): string =>
|
||||
STATUS_OPTIONS.find((i) => i.value === value)?.label ?? value ?? '—'
|
||||
|
||||
/** 启用状态 → Tag 颜色 */
|
||||
export const statusColor: Record<Status, string> = {
|
||||
enabled: 'success',
|
||||
disabled: 'default',
|
||||
}
|
||||
|
||||
/** 客户下拉选项(列表页 / 弹框共用) */
|
||||
export interface CustomerOption {
|
||||
label: string
|
||||
value: string
|
||||
}
|
||||
|
||||
/** 布点场所下拉选项(列表页 / 弹框共用) */
|
||||
export interface SiteOption {
|
||||
label: string
|
||||
value: string
|
||||
/** 携带 customerId 供联动校验 */
|
||||
customerId: string
|
||||
}
|
||||
|
||||
/** 设备分类树节点(与 @api 对齐) */
|
||||
export type CategoryTreeNode = DeviceCategoryTreeNode
|
||||
@@ -1,129 +1,126 @@
|
||||
<template>
|
||||
<div class="admin-list-page">
|
||||
<a-tabs :active-key="activeTab" type="line" class="alarm-page__tabs" @change="switchTab">
|
||||
<a-tab-pane key="alarm" tab="告警处置" />
|
||||
<a-tab-pane key="log" tab="业务日志" />
|
||||
</a-tabs>
|
||||
<a-row :gutter="12" class="alarm-page__stats">
|
||||
<a-col :span="6">
|
||||
<StatCard label="今日新增" :value="statsData.todayNew" color="blue" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard label="待处理" :value="statsData.pending" color="red" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard label="严重告警" :value="statsData.critical" color="orange" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard label="本周新增" :value="statsData.weekTrend" color="green" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
|
||||
<!-- 告警处置 -->
|
||||
<template v-if="activeTab === 'alarm'">
|
||||
<a-row :gutter="12" class="alarm-page__stats">
|
||||
<a-col :span="6">
|
||||
<StatCard label="今日新增" :value="statsData.todayNew" color="blue" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard label="待处理" :value="statsData.pending" color="red" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard label="严重告警" :value="statsData.critical" color="orange" />
|
||||
</a-col>
|
||||
<a-col :span="6">
|
||||
<StatCard label="本周新增" :value="statsData.weekTrend" color="green" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="设备编码">
|
||||
<a-input v-model:value="search.deviceCode" v-no-space allow-clear placeholder="请输入设备编码" style="width: 200px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="设备分类">
|
||||
<a-select
|
||||
v-model:value="search.deviceCategory"
|
||||
:options="categoryOpts"
|
||||
show-search
|
||||
allow-clear
|
||||
placeholder="全部分类"
|
||||
style="width: 160px"
|
||||
:filter-option="filterOption"
|
||||
/>
|
||||
</a-form-item>
|
||||
<a-form-item label="来源">
|
||||
<a-select v-model:value="search.source" :options="ALARM_SOURCE_OPTIONS" allow-clear placeholder="全部来源" style="width: 160px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="级别">
|
||||
<a-select v-model:value="search.level" :options="ALARM_LEVEL_OPTIONS" allow-clear placeholder="全部级别" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="search.status" :options="ALARM_STATUS_OPTIONS" allow-clear placeholder="全部状态" style="width: 140px" />
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<FilterBar v-model="search" @search="searchQuery" @reset="resetQuery">
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model:value="search.keyword" v-no-space allow-clear placeholder="告警编码 / 设备 / 内容" style="width: 220px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="来源">
|
||||
<a-select v-model:value="search.source" :options="ALARM_SOURCE_OPTIONS" allow-clear placeholder="全部来源" style="width: 160px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="级别">
|
||||
<a-select v-model:value="search.level" :options="ALARM_LEVEL_OPTIONS" allow-clear placeholder="全部级别" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="状态">
|
||||
<a-select v-model:value="search.status" :options="ALARM_STATUS_OPTIONS" allow-clear placeholder="全部状态" style="width: 140px" />
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" :row-selection="rowSelection" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button :disabled="!selectedRowKeys.length" @click="batchHandle('workorder')">
|
||||
<template #icon><NotificationOutlined /></template>
|
||||
批量转工单
|
||||
</a-button>
|
||||
<a-button :disabled="!selectedRowKeys.length" @click="batchHandle('ignore')">
|
||||
<template #icon><StopOutlined /></template>
|
||||
批量忽略
|
||||
</a-button>
|
||||
<TableCard :table="table" :loading="pageLoading" row-key="id" :row-selection="rowSelection" @change="dataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button :disabled="!selectedRowKeys.length" @click="batchHandle('workorder')">
|
||||
<template #icon><NotificationOutlined /></template>
|
||||
批量转工单
|
||||
</a-button>
|
||||
<a-button :disabled="!selectedRowKeys.length" @click="batchHandle('ignore')">
|
||||
<template #icon><StopOutlined /></template>
|
||||
批量忽略
|
||||
</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" @click="handleAction('detail', record)">告警详情</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" :disabled="record.status !== 'pending' && record.status !== 'processing'" @click="handleAction('workorder', record)">转工单</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" :disabled="record.status !== 'pending'" @click="handleAction('ignore', record)">忽略</a-button>
|
||||
</template>
|
||||
<template #bodyCell="{ column, record }">
|
||||
<template v-if="column.dataIndex === 'action'">
|
||||
<a-button type="link" size="small" :disabled="record.status !== 'pending' && record.status !== 'processing'" @click="handleAction('workorder', record)">转工单</a-button>
|
||||
<a-divider type="vertical" />
|
||||
<a-button type="link" size="small" :disabled="record.status !== 'pending'" @click="handleAction('ignore', record)">忽略</a-button>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
</template>
|
||||
</template>
|
||||
</TableCard>
|
||||
|
||||
<!-- 业务日志 -->
|
||||
<template v-else>
|
||||
<FilterBar v-model="logSearch" @search="logSearchQuery" @reset="logResetQuery">
|
||||
<a-form-item label="关键字">
|
||||
<a-input v-model:value="logSearch.keyword" v-no-space allow-clear placeholder="操作模块 / 内容" style="width: 220px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="日志类型">
|
||||
<a-select v-model:value="logSearch.logType" :options="LOG_TYPE_OPTIONS" allow-clear placeholder="全部类型" style="width: 140px" />
|
||||
</a-form-item>
|
||||
<a-form-item label="所属场所">
|
||||
<a-input v-model:value="logSearch.siteName" v-no-space allow-clear placeholder="请输入场所名称" style="width: 180px" />
|
||||
</a-form-item>
|
||||
</FilterBar>
|
||||
|
||||
<TableCard :table="logTable" :loading="logLoading" row-key="id" @change="logDataSourceChange">
|
||||
<template #toolbar>
|
||||
<a-button>
|
||||
<template #icon><DownloadOutlined /></template>
|
||||
导出
|
||||
</a-button>
|
||||
</template>
|
||||
</TableCard>
|
||||
</template>
|
||||
<InfoDrawer ref="infoDrawerRef" />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
/**
|
||||
* 告警管理 - 主页(双 Tab)
|
||||
* 告警管理 - 主页
|
||||
* 业务对齐 §3.6 设备运维 → 告警管理:
|
||||
* - 告警处置:运行/自检/巡检/SLA/OTA/数据分发失败 等告警统一处置
|
||||
* - 业务日志:全量操作流水视图(原系统设置 → 业务日志迁入)
|
||||
* - 运行/自检/巡检/SLA/OTA/数据分发失败 等告警统一处置
|
||||
* - 告警详情:基础信息 + 报错日志(代码风格多行文本)
|
||||
*/
|
||||
import { DownloadOutlined, NotificationOutlined, StopOutlined } from '@ant-design/icons-vue'
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { NotificationOutlined, StopOutlined } from '@ant-design/icons-vue'
|
||||
import FilterBar from '@components/FilterBar.vue'
|
||||
import TableCard from '@components/TableCard.vue'
|
||||
import StatCard from '@components/StatCard.vue'
|
||||
import { filterOption } from '@utils/antDesign/select'
|
||||
import InfoDrawer from './component/drawer/info/info.vue'
|
||||
import {
|
||||
ALARM_LEVEL_OPTIONS,
|
||||
ALARM_SOURCE_OPTIONS,
|
||||
ALARM_STATUS_OPTIONS,
|
||||
LOG_TYPE_OPTIONS,
|
||||
type DeviceCategoryOption,
|
||||
} from './types'
|
||||
import { usePage } from './init/usePage'
|
||||
import * as api from './api'
|
||||
|
||||
const {
|
||||
activeTab,
|
||||
pageLoading,
|
||||
search,
|
||||
table,
|
||||
statsData,
|
||||
selectedRowKeys,
|
||||
rowSelection,
|
||||
logLoading,
|
||||
logSearch,
|
||||
logTable,
|
||||
infoDrawerRef,
|
||||
searchQuery,
|
||||
resetQuery,
|
||||
logSearchQuery,
|
||||
logResetQuery,
|
||||
dataSourceChange,
|
||||
logDataSourceChange,
|
||||
switchTab,
|
||||
handleAction,
|
||||
batchHandle,
|
||||
} = usePage()
|
||||
|
||||
/** 设备分类下拉(末级分类名,与设备监测模块对齐) */
|
||||
const categoryOpts = ref<{ label: string; value: string }[]>([])
|
||||
|
||||
const loadCategoryOpts = (): void => {
|
||||
api.categoryOptions()
|
||||
.then((res) => {
|
||||
if (res.code === '00000') {
|
||||
categoryOpts.value = (res.data ?? []).map((i: DeviceCategoryOption) => ({ label: i.name, value: i.name }))
|
||||
}
|
||||
})
|
||||
.catch((err: unknown) => console.error('设备分类下拉失败:', err))
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadCategoryOpts()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped lang="less">
|
||||
@@ -135,14 +132,6 @@ const {
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.alarm-page__tabs {
|
||||
flex-shrink: 0;
|
||||
|
||||
:deep(.ant-tabs-nav) {
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
}
|
||||
|
||||
.alarm-page__stats {
|
||||
flex-shrink: 0;
|
||||
margin-bottom: 12px;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user