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:
fengpu
2026-07-09 20:10:43 +08:00
co-authored by Claude Opus 4.7
parent 74a5419028
commit 68ea2a1167
213 changed files with 8142 additions and 5640 deletions
+1 -1
View File
@@ -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 },
])
+153 -87
View File
@@ -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 任务执行结果 = failwarn',
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: '心跳连续缺失次数 ≥ 3critical',
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: '分发重试次数 ≥ 4warn',
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 },
])
+340 -249
View File
@@ -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 },
])
+63 -36
View File
@@ -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 },
])
+212 -114
View File
@@ -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 },
])
+4 -6
View File
@@ -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 + 安全密钥)
+4 -4
View File
@@ -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>
+10 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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>
@@ -45,7 +45,9 @@ export interface DetailInfo {
siteName: string
/** 投用状态 */
deployStatus: DeployStatus
/** 软件版本 */
/** 软件包名(初始化设置时写入) */
packageName: string
/** 软件版本(初始化设置时写入) */
softwareVersion: string
/** 入网时间 */
onlineTime: string
@@ -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 共用同一表单,无差异化字段区块
*/
@@ -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 => {
@@ -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
@@ -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', {})
@@ -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>
@@ -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 }
}
@@ -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
@@ -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>
@@ -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)
@@ -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,
}
}
@@ -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
@@ -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)
@@ -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 })
@@ -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>
@@ -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
@@ -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 }
@@ -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,
}
}
@@ -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,
})
@@ -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 },
]
@@ -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;
}
@@ -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
@@ -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 })
@@ -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 })
@@ -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>
@@ -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
@@ -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>
@@ -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)
@@ -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 }
}
@@ -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
@@ -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>
@@ -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 },
]
@@ -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'
@@ -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', {})
@@ -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>
@@ -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)
@@ -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 }
}
@@ -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[]
@@ -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,
}
}
@@ -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 }
}
@@ -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 },
]
@@ -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>
@@ -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,
}
}
@@ -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 })
@@ -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>
@@ -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 }
}
@@ -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 次后转人工'
@@ -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 })
@@ -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>
@@ -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,
}
}
@@ -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
@@ -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>
@@ -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)
@@ -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,
}
}
@@ -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,
}
}
@@ -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