소스 검색

Merge branch 'master' into feature/shebei

Zimo 1 일 전
부모
커밋
575e7cbfef
100개의 변경된 파일8503개의 추가작업 그리고 1897개의 파일을 삭제
  1. 1 1
      .env.dev
  2. 1 1
      .env.local
  3. 48 0
      src/App.vue
  4. 32 33
      src/api/pms/iotmaintenancebom/index.ts
  5. 5 0
      src/api/pms/iotmainworkorder/index.ts
  6. 7 2
      src/api/pms/iotryimprovedailyreport/index.ts
  7. 117 0
      src/api/pms/qhse/index.ts
  8. 13 0
      src/api/pms/stat/index.ts
  9. BIN
      src/assets/images/danger/地刺.png
  10. BIN
      src/assets/images/danger/当心硫化氢气体.png
  11. BIN
      src/assets/images/danger/当心触电.png
  12. BIN
      src/assets/images/danger/当心高温.png
  13. BIN
      src/assets/images/danger/注意噪音防护.png
  14. BIN
      src/assets/images/danger/防冲撞栏.png
  15. BIN
      src/assets/images/danger/高压危险.png
  16. 3 0
      src/assets/images/icons/人群.svg
  17. 3 0
      src/assets/images/icons/扳手.svg
  18. 3 0
      src/assets/images/icons/账户.svg
  19. 3 0
      src/assets/images/icons/跑.svg
  20. 3 0
      src/assets/images/icons/锅.svg
  21. 3 0
      src/assets/images/icons/门.svg
  22. BIN
      src/assets/images/materials/安保设施.png
  23. BIN
      src/assets/images/materials/气防设施.png
  24. BIN
      src/assets/images/materials/消防设施.png
  25. BIN
      src/assets/images/materials/路障.png
  26. BIN
      src/assets/images/process-demo/PSA.png
  27. BIN
      src/assets/images/process-demo/厨房.png
  28. BIN
      src/assets/images/process-demo/变压器.png
  29. BIN
      src/assets/images/process-demo/地罐.png
  30. BIN
      src/assets/images/process-demo/增压机.png
  31. BIN
      src/assets/images/process-demo/房子.png
  32. BIN
      src/assets/images/process-demo/水罐.png
  33. BIN
      src/assets/images/process-demo/注水泵.png
  34. BIN
      src/assets/images/process-demo/空压机.png
  35. BIN
      src/assets/images/process-demo/空压机1.png
  36. BIN
      src/assets/images/process-demo/空气处理撬.png
  37. BIN
      src/assets/images/process-demo/采油树.png
  38. 120 0
      src/components/DeptSelectForm/qhseDept.vue
  39. 4 6
      src/components/ZmTable/README.md
  40. 12 15
      src/components/ZmTable/ZmTableColumn.vue
  41. 42 17
      src/components/ZmTable/index.vue
  42. 46 16
      src/components/custom-components/kv-vue/index.vue
  43. 107 23
      src/components/custom-components/sys-button-vue/index.vue
  44. 49 0
      src/components/custom-components/wireframe-vue/index.vue
  45. 48 7
      src/components/mt-edit/components/done-tree/index.vue
  46. 22 5
      src/components/mt-edit/components/draw-line-render/index.vue
  47. 15 1
      src/components/mt-edit/components/layout/main-panel/index.vue
  48. 20 4
      src/components/mt-edit/components/layout/right-aside/index.vue
  49. 56 1
      src/components/mt-edit/components/layout/right-aside/select-item-event-setting/index.vue
  50. 296 0
      src/components/mt-edit/components/layout/right-aside/select-item-event-setting/webtopo-project-select-dialog.vue
  51. 18 2
      src/components/mt-edit/components/layout/right-aside/select-item-props-setting.vue
  52. 3 1
      src/components/mt-edit/components/layout/right-aside/select-item-setting.vue
  53. 23 8
      src/components/mt-edit/components/line-render/index.vue
  54. 4 0
      src/components/mt-edit/components/render-core/index.vue
  55. 39 4
      src/components/mt-edit/index.vue
  56. 263 8
      src/components/mt-edit/store/config.ts
  57. 58 23
      src/components/mt-edit/store/global.ts
  58. 8 1
      src/components/mt-edit/store/types.ts
  59. 19 14
      src/components/mt-edit/utils/index.ts
  60. 39 3
      src/layout/components/Menu/src/components/useRenderMenuItem.tsx
  61. 11 14
      src/router/modules/remaining.ts
  62. 1 1
      src/store/modules/app.ts
  63. 19 8
      src/utils/routerHelper.ts
  64. 59 0
      src/views/Error/ComingSoon.vue
  65. 3 1
      src/views/Home/Index.vue
  66. 12 2
      src/views/maotu/edit.vue
  67. 4 2
      src/views/maotu/index.vue
  68. 15 3
      src/views/maotu/preview.vue
  69. 12 24
      src/views/pms/device/DeviceInfo.vue
  70. 178 183
      src/views/pms/device/maintenance/MaintenanceDetail.vue
  71. 1 1
      src/views/pms/device/monitor/index.vue
  72. 396 253
      src/views/pms/iotmainworkorder/IotMainWorkOrderAdd.vue
  73. 360 192
      src/views/pms/iotmainworkorder/IotMainWorkOrderDetail.vue
  74. 222 182
      src/views/pms/iotmainworkorder/IotMainWorkOrderOptimize.vue
  75. 9 8
      src/views/pms/iotrddailyreport/components/DailyStatistics.vue
  76. 32 10
      src/views/pms/iotrddailyreport/summary.vue
  77. 10 9
      src/views/pms/iotrhdailyreport/components/DailyStatistics.vue
  78. 43 7
      src/views/pms/iotrhdailyreport/index.vue
  79. 38 46
      src/views/pms/iotrhdailyreport/rh-table.vue
  80. 32 10
      src/views/pms/iotrhdailyreport/summary.vue
  81. 17 25
      src/views/pms/iotrydailyreport/components/DailyStatistics.vue
  82. 17 25
      src/views/pms/iotrydailyreport/components/XjDailyStatistics.vue
  83. 216 43
      src/views/pms/iotrydailyreport/components/equipment-form.vue
  84. 15 5
      src/views/pms/iotrydailyreport/equipment.vue
  85. 43 13
      src/views/pms/iotrydailyreport/index.vue
  86. 32 10
      src/views/pms/iotrydailyreport/summary.vue
  87. 49 19
      src/views/pms/iotrydailyreport/xjindex.vue
  88. 32 10
      src/views/pms/iotrydailyreport/xsummary.vue
  89. 311 293
      src/views/pms/maintenance/IotMaintenancePlan.vue
  90. 212 186
      src/views/pms/maintenance/IotMaintenancePlanDetail.vue
  91. 58 115
      src/views/pms/maintenance/IotMaintenancePlanEdit.vue
  92. 1605 0
      src/views/pms/maintenance/IotMaintenancePlanManage.vue
  93. 214 0
      src/views/pms/maintenance/maintenance-device-list.vue
  94. 526 0
      src/views/pms/maintenance/maintenance-plan-form.vue
  95. 901 0
      src/views/pms/maintenance/maintenance-plan-manage.vue
  96. 59 0
      src/views/pms/maintenance/types.ts
  97. 312 0
      src/views/pms/qhse/MeasureCertDrawer.vue
  98. 10 1
      src/views/pms/qhse/certificate.vue
  99. 405 0
      src/views/pms/qhse/deviceCert/DeviceCertForm.vue
  100. 459 0
      src/views/pms/qhse/deviceCert/index.vue

+ 1 - 1
.env.dev

@@ -4,7 +4,7 @@ NODE_ENV=production
 VITE_DEV=true
 
 # 请求路径
-VITE_BASE_URL='https://iot.deepoil.cc'
+VITE_BASE_URL='https://iot.deepoil.cc:5443'
 
 # MQTT服务地址
 VITE_MQTT_SERVER_URL = ''

+ 1 - 1
.env.local

@@ -4,7 +4,7 @@ NODE_ENV=development
 VITE_DEV=true
 
 # 请求路径  http://192.168.188.200:48080  https://iot.deepoil.cc  http://172.26.0.56:48080
-VITE_BASE_URL='https://iot.deepoil.cc'
+VITE_BASE_URL='https://iot.deepoil.cc:5443'
 
 # 文件上传类型:server - 后端上传, client - 前端直连上传,仅支持 S3 服务
 VITE_UPLOAD_TYPE=server

+ 48 - 0
src/App.vue

@@ -28,6 +28,21 @@ const materials_files = import.meta.glob('./assets/images/materials/**', {
   query: '?url',
   import: 'default'
 })
+const danger_files = import.meta.glob('./assets/images/danger/**', {
+  eager: true,
+  query: '?url',
+  import: 'default'
+})
+const icon_modules_files = {
+  ...import.meta.glob('./assets/images/icons/**.svg', {
+    eager: true,
+    as: 'raw'
+  })
+}
+
+const normalizeFillSvg = (svg: string) => {
+  return svg.replace(/\sfill="(?!none\b)[^"]*"/gi, '')
+}
 
 const electrical_register_config: any = []
 for (const key in electrical_modules_files) {
@@ -69,6 +84,27 @@ for (const key in electrical_stroke_modules_files) {
 }
 leftAsideStore.registerConfig('电气符号', electrical_register_config)
 
+const icon_register_config: any = []
+for (const key in icon_modules_files) {
+  const name = key.split('/').pop()!.split('.')[0]
+  const svg = normalizeFillSvg(icon_modules_files[key])
+  icon_register_config.push({
+    id: `icon-${name}`,
+    title: name,
+    type: 'svg',
+    thumbnail: 'data:image/svg+xml;utf8,' + encodeURIComponent(svg),
+    svg,
+    props: {
+      fill: {
+        type: 'color',
+        val: '#FF0000',
+        title: '填充色'
+      }
+    }
+  })
+}
+leftAsideStore.registerConfig('图标', icon_register_config)
+
 const process_demo_register_config: any[] = Object.entries(process_demo_image_files).map(
   ([key, url]) => {
     const name = key.split('/').pop()!.split('.')[0]
@@ -95,6 +131,18 @@ const materials_register_config: any[] = Object.entries(materials_files).map(([k
 })
 leftAsideStore.registerConfig('素材', materials_register_config)
 
+const danger_register_config: any[] = Object.entries(danger_files).map(([key, url]) => {
+  const name = key.split('/').pop()!.split('.')[0]
+  return {
+    id: `danger-${name}`,
+    title: name,
+    type: 'img',
+    thumbnail: url,
+    props: {}
+  }
+})
+leftAsideStore.registerConfig('危险标识', danger_register_config)
+
 const route = useRoute()
 const { addListeners, removeListeners } = useAutoLogout()
 

+ 32 - 33
src/api/pms/iotmaintenancebom/index.ts

@@ -8,71 +8,71 @@ export interface IotMaintenanceBomVO {
   deviceCategoryId: number // 所属设备分类
   deviceId: number // 设备id
   rule: string // 保养规则(里程 运行时间 自然日期) 可多选
-  mileageRule: number       // 保养规则-里程(0启用 1停用)
-  naturalDateRule: number   // 保养规则-自然日期(0启用 1停用)
-  runningTimeRule: number   // 保养规则-运行时间(0启用 1停用)
+  mileageRule: number // 保养规则-里程(0启用 1停用)
+  naturalDateRule: number // 保养规则-自然日期(0启用 1停用)
+  runningTimeRule: number // 保养规则-运行时间(0启用 1停用)
   lastRunningTime: number // 上次保养运行时长(小时)
   nextRunningTime: number // 下次保养运行时长(小时) 运行时长周期
   timePeriod: number // 时间周期(小时) 距离下次保养运行时长
   kilometerCycle: number // 公里数周期(千米) 距离下次保养公里数
   naturalDatePeriod: number // 自然日周期(天) 下次保养自然日期
-  timePeriodLead: number  // 运行时长周期提前量 H
+  timePeriodLead: number // 运行时长周期提前量 H
   lastRunningKilometers: number // 上次保养运行公里数(千米)
   nextRunningKilometers: number // 下次保养运行公里数(千米)
   kiloCycleLead: number // 公里数周期-提前量 km
   lastNaturalDate: Date // 上次保养自然日期(天)
-  tempLastNaturalDate: Date // 上次保养自然日期(天) 临时变量
-  nextNaturalDate: number // 下次保养自然日期(天)
+  tempLastNaturalDate: string | null // 上次保养自然日期(天) 临时变量
+  nextNaturalDate: number | null // 下次保养自然日期(天)
   naturalDatePeriodLead: number // 自然日周期-提前量(天)
   bomNodeId: number // bom节点id
   name: string // BOM名称
-  code: string // BOM编码
+  code: string | null // BOM编码
   parentId: number // 父BOM id 顶级为0
   childIds: string // 子节点id 逗号分隔
   level: number // 层级
   leafFlag: number // 是否叶子节点 0是 1否
   sort: number // 显示顺序
-  type: string // 1维修 2保养 维修+保养
+  type: string | null // 1维修 2保养 维修+保养
   status: number // 状态 0启用  1停用
   remark: string // 备注
   version: number // 版本
   // 扩展字段
-  deviceName: string  // 设备名称
-  deviceCode: string  // 设备编码
-  deviceStatus: string  // 设备状态
+  deviceName: string // 设备名称
+  deviceCode: string // 设备编码
+  deviceStatus: string // 设备状态
   assetProperty: string //资产性质
-  totalMileage: number  // 累计运行公里数
-  totalRunTime: number  // 累计运行时间
-  tempTotalMileage: number  // 临时 累计运行公里数
-  tempTotalRunTime: number  // 临时 累计运行时间
-  isRuntimeFromTemp: false
-  isMileageFromTemp: false
+  totalMileage: number | null // 累计运行公里数
+  totalRunTime: number | null // 累计运行时间
+  tempTotalMileage: number | null // 临时 累计运行公里数
+  tempTotalRunTime: number | null // 临时 累计运行时间
+  isRuntimeFromTemp: boolean
+  isMileageFromTemp: boolean
   // 运行记录模板中 包含多个 累计时长 属性列表
   timeAccumulatedAttrs: Array<{
-    pointName: string;
-    totalRunTime: number;
-    totalMileage: number;
-  }>;
+    pointName: string
+    totalRunTime: number
+    totalMileage: number
+  }>
   // 运行记录模板中 包含多个 累计公里数 属性列表
   mileageAccumulatedAttrs: Array<{
-    pointName: string;
-    totalRunTime: number;
-    totalMileage: number;
-  }>;
+    pointName: string
+    totalRunTime: number
+    totalMileage: number
+  }>
   // 上次保养时间 不同于自然日保养规则下的 上次保养自然日期
   lastMaintenanceDate: Date
   // 下次保养公里数
-  nextMaintenanceKm: number
+  nextMaintenanceKm: number | null
   // 剩余保养公里数
-  remainKm: number
+  remainKm: number | null
   // 下次保养运行时长
-  nextMaintenanceH: number
+  nextMaintenanceH: number | null
   // 剩余保养运行时长
-  remainH: number
+  remainH: number | null
   // 下次保养日期
-  nextMaintenanceDate: Date
+  nextMaintenanceDate: string | null
   // 自然日期保养 剩余天数
-  remainDay: number
+  remainDay: number | null
 }
 
 // PMS 保养计划明细BOM API
@@ -110,6 +110,5 @@ export const IotMaintenanceBomApi = {
   // 获得PMS 保养工单明细BOM列表
   getMainPlanBOMs: async (params: any) => {
     return await request.get({ url: `/pms/iot-maintenance-bom/getMainPlanBOMs`, params })
-  },
-
+  }
 }

+ 5 - 0
src/api/pms/iotmainworkorder/index.ts

@@ -83,6 +83,11 @@ export const IotMainWorkOrderApi = {
     return await request.put({ url: `/pms/iot-main-work-order/fillWorkOrder`, data })
   },
 
+  // 保存保养工单附件
+  hiWorkOrderAttachment: async (data: Pick<IotMainWorkOrderVO, 'id'> & { attachments: any[] }) => {
+    return await request.put({ url: `/pms/iot-main-work-order/hiWorkOrderAttachment`, data })
+  },
+
   // 修改保养工单
   modifyWorkOrder: async (data: any) => {
     return await request.put({ url: `/pms/iot-main-work-order/modifyWorkOrder`, data })

+ 7 - 2
src/api/pms/iotryimprovedailyreport/index.ts

@@ -7,13 +7,18 @@ export interface IotRyImproveDailyReportVO {
   workLocation: string
   workPurpose: string
   relocationDays: number
-  productionStatus: string
   personnel: string
-  nextPlan: string
+  improveReportDetails: IotRyImproveDailyReportDetailVO[]
   auditStatus?: 0 | 10 | 20 | 30 | 40
   opinion?: string
 }
 
+export interface IotRyImproveDailyReportDetailVO {
+  projectName: string
+  constructionDetail: string
+  nextPlan: string
+}
+
 export interface IotRyImproveDailyReportApprovalVO {
   id: number
   auditStatus: 20 | 30

+ 117 - 0
src/api/pms/qhse/index.ts

@@ -501,3 +501,120 @@ export const CertPersonApi = {
     return await request.get({ url: `/rq/qhse-cert-person/get?id=` + id })
   }
 }
+
+// QHSE月报 API
+export const QhseMonthReportApi = {
+  // 查询QHSE月报分页
+  getQhseMonthReportPage: async (params: any) => {
+    return await request.get({ url: `/rq/qhse-month-report/page`, params })
+  },
+
+  // 查询QHSE月报详情
+  getQhseMonthReport: async (id) => {
+    return await request.get({ url: `/rq/qhse-month-report/get?id=` + id })
+  },
+
+  // 新增QHSE月报
+  createQhseMonthReport: async (data) => {
+    return await request.post({ url: `/rq/qhse-month-report/create`, data })
+  },
+
+  // 修改QHSE月报
+  updateQhseMonthReport: async (data) => {
+    return await request.put({ url: `/rq/qhse-month-report/update`, data })
+  },
+
+  // 删除QHSE月报
+  deleteQhseMonthReport: async (id) => {
+    return await request.delete({ url: `/rq/qhse-month-report/delete?id=` + id })
+  },
+
+  // 导出QHSE月报 Excel
+  exportQhseMonthReport: async (params) => {
+    return await request.download({ url: `/rq/qhse-month-report/export-excel`, params })
+  }
+}
+
+// 应检设备证书
+export const InspectDeviceCertApi = {
+  // 获得设备证书分页 rq/qhse-device-cert/create
+  getInspectDeviceCertList: async (params) => {
+    return await request.get({ url: `/rq/qhse-device-cert/page`, params })
+  },
+  // 删除设备证书
+  deleteInspectDeviceCert: async (id) => {
+    return await request.delete({ url: `/rq/qhse-device-cert/delete?id=` + id })
+  },
+  // 添加设备证书
+  createInspectDeviceCert: async (data) => {
+    return await request.post({ url: `/rq/qhse-device-cert/create`, data })
+  },
+  // 获取详情
+  getInspectDeviceCert: async (id) => {
+    return await request.get({ url: `/rq/qhse-device-cert/get?id=` + id })
+  },
+  // 修改设备证书
+  updateInspectDeviceCert: async (data) => {
+    return await request.put({ url: `/rq/qhse-device-cert/update`, data })
+  },
+  // 导出设备证书 Excel
+  exportInspectDeviceCert: async (params) => {
+    return await request.download({ url: `/rq/qhse-device-cert/export-excel`, params })
+  }
+}
+
+// 应急演练
+export const EmergencyDrillApi = {
+  // 获得应急演练分页
+  getEmergencyDrillList: async (params) => {
+    return await request.get({ url: `/rq/qhse-emergency-book/page`, params })
+  },
+  // 删除应急演练
+  deleteEmergencyDrill: async (id) => {
+    return await request.delete({ url: `/rq/qhse-emergency-book/delete?id=` + id })
+  },
+  // 添加应急演练
+  createEmergencyDrill: async (data) => {
+    return await request.post({ url: `/rq/qhse-emergency-book/create`, data })
+  },
+  // 获取详情
+  getEmergencyDrill: async (id) => {
+    return await request.get({ url: `/rq/qhse-emergency-book/get?id=` + id })
+  },
+  // 修改应急演练
+  updateEmergencyDrill: async (data) => {
+    return await request.put({ url: `/rq/qhse-emergency-book/update`, data })
+  },
+  // 导出应急演练 Excel
+  exportEmergencyDrill: async (params) => {
+    return await request.download({ url: `/rq/qhse-emergency-book/export-excel`, params })
+  }
+}
+
+// 应急演练证书检测
+export const EmergencyDrillCertApi = {
+  // 获得应急演练证书检测分页
+  getEmergencyDrillCertList: async (params) => {
+    return await request.get({ url: `/rq/qhse-emergency-cert/page`, params })
+  },
+  // 删除应急演练证书检测
+  deleteEmergencyDrillCert: async (id) => {
+    return await request.delete({ url: `/rq/qhse-emergency-cert/delete?id=` + id })
+  },
+  // 添加应急演练证书检测
+  createEmergencyDrillCert: async (data) => {
+    return await request.post({ url: `/rq/qhse-emergency-cert/create`, data })
+  },
+  // 获取详情
+  getEmergencyDrillCert: async (id) => {
+    return await request.get({ url: `/rq/qhse-emergency-cert/get?id=` + id })
+  },
+  // 修改应急演练证书检测
+  updateEmergencyDrillCert: async (data) => {
+    return await request.put({ url: `/rq/qhse-emergency-cert/update`, data })
+  },
+  // 导出应急演练证书检测 Excel
+  exportEmergencyDrillCert: async (params) => {
+    return await request.download({ url: `/rq/qhse-emergency-cert/export-excel`, params })
+  }
+}

+ 13 - 0
src/api/pms/stat/index.ts

@@ -66,6 +66,9 @@ export const IotStatApi = {
   getInspectZjxjCount: async (params?: any) => {
     return await request.get({ url: `/rq/stat/home/ry/count/zjxj`, params })
   },
+  getInspectLayerWellCount: async (params?: any) => {
+    return await request.get({ url: `/rq/stat/rd/device/workloadStatistics`, params })
+  },
   getInspectRate: async (params: any) => {
     return await request.get({ url: `/rq/stat/ry/device/totalUtilizationRate`, params })
   },
@@ -165,6 +168,9 @@ export const IotStatApi = {
   getRhSevenDayUtilization: async () => {
     return await request.get({ url: `/rq/stat/rh/device/sevenDayUtilization` })
   },
+  getRdSevenDayUtilization: async () => {
+    return await request.get({ url: `/rq/stat/rd/device/sevenDayUtilization` })
+  },
   getRyRate: async (params: any) => {
     return await request.get({ url: `/rq/stat/ry/device/utilizationRate`, params })
   },
@@ -183,9 +189,13 @@ export const IotStatApi = {
   getRyNptCount: async (params: any) => {
     return await request.get({ url: `/rq/stat/ry/device/nptCount`, params })
   },
+  getRdNptCount: async (params: any) => {
+    return await request.get({ url: `/rq/stat/rd/device/productionAbnormality`, params })
+  },
   getRdTeamRate: async (params: any) => {
     return await request.get({ url: `rq/stat/rd/device/teamUtilizationRate`, params })
   },
+
   getMaintainCount: async (params?: any) => {
     return await request.get({ url: `/rq/stat/home/maintain/count/` + params })
   },
@@ -247,6 +257,9 @@ export const IotStatApi = {
   getConstructionBriefing: async () => {
     return await request.get({ url: `/pms/iot-rd-daily-report/constructionBriefing` })
   },
+  getRdRecentWbcs: async () => {
+    return await request.get({ url: `/rq/stat/recent/wbcs/rd` })
+  },
 
   // 瑞都看板(新)
   // 获取ssoToken

BIN
src/assets/images/danger/地刺.png


BIN
src/assets/images/danger/当心硫化氢气体.png


BIN
src/assets/images/danger/当心触电.png


BIN
src/assets/images/danger/当心高温.png


BIN
src/assets/images/danger/注意噪音防护.png


BIN
src/assets/images/danger/防冲撞栏.png


BIN
src/assets/images/danger/高压危险.png


+ 3 - 0
src/assets/images/icons/人群.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+	<path fill="currentColor" d="M14.754 10c.966 0 1.75.784 1.75 1.75v4.749a4.501 4.501 0 0 1-9.002 0V11.75c0-.966.783-1.75 1.75-1.75zm-7.623 0a2.7 2.7 0 0 0-.62 1.53l-.01.22v4.749c0 .847.192 1.649.534 2.365Q6.539 18.999 6 19a4 4 0 0 1-4-4.001V11.75a1.75 1.75 0 0 1 1.606-1.744L3.75 10zm9.744 0h3.375c.966 0 1.75.784 1.75 1.75V15a4 4 0 0 1-5.03 3.866c.3-.628.484-1.32.525-2.052l.009-.315V11.75c0-.665-.236-1.275-.63-1.75M12 3a3 3 0 1 1 0 6a3 3 0 0 1 0-6m6.5 1a2.5 2.5 0 1 1 0 5a2.5 2.5 0 0 1 0-5m-13 0a2.5 2.5 0 1 1 0 5a2.5 2.5 0 0 1 0-5" />
+</svg>

+ 3 - 0
src/assets/images/icons/扳手.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+	<path fill="currentColor" d="m22.7 19l-9.1-9.1c.9-2.3.4-5-1.5-6.9c-2-2-5-2.4-7.4-1.3L9 6L6 9L1.6 4.7C.4 7.1.9 10.1 2.9 12.1c1.9 1.9 4.6 2.4 6.9 1.5l9.1 9.1c.4.4 1 .4 1.4 0l2.3-2.3c.5-.4.5-1.1.1-1.4" />
+</svg>

+ 3 - 0
src/assets/images/icons/账户.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+	<path fill="currentColor" d="M12 2q1.25 0 2.125.875T15 5t-.875 2.125T12 8t-2.125-.875T9 5t.875-2.125T12 2m0 7q1.175 0 2.325.275t2.075.775q.95.475 1.525 1.125T18.5 12.6v5.8q0 .425-.2.838t-.55.762t-.812.65t-1.038.55v-2.25q0-.95-1.312-1.55T12 16.8q-1.25 0-2.412.513T8.15 18.65q.95.375 1.95.525t2.05.175H13v2.6q-.175.05-.362.05h-.388q-.9 0-2.062-.2t-2.213-.625t-1.762-1.112T5.5 18.4v-5.8q0-.775.575-1.425t1.5-1.125q.95-.5 2.1-.775T12 9m0 6q.825 0 1.413-.587T14 13t-.587-1.412T12 11t-1.412.588T10 13t.588 1.413T12 15" />
+</svg>

+ 3 - 0
src/assets/images/icons/跑.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+	<path fill="currentColor" d="M13.5 5.5c1.09 0 2-.92 2-2a2 2 0 0 0-2-2c-1.11 0-2 .88-2 2c0 1.08.89 2 2 2M9.89 19.38l1-4.38L13 17v6h2v-7.5l-2.11-2l.61-3A7.3 7.3 0 0 0 19 13v-2c-1.91 0-3.5-1-4.31-2.42l-1-1.58c-.4-.62-1-1-1.69-1c-.31 0-.5.08-.81.08L6 8.28V13h2V9.58l1.79-.7L8.19 17l-4.9-1l-.4 2z" />
+</svg>

+ 3 - 0
src/assets/images/icons/锅.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+	<path fill="currentColor" fill-rule="evenodd" d="M20 11v7a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2v-7H3a1 1 0 0 1 0-2h18a1 1 0 0 1 0 2zM6 11v7h12v-7zm5-5V5a1 1 0 0 1 2 0v1h6a1 1 0 0 1 0 2H5a1 1 0 1 1 0-2z" />
+</svg>

+ 3 - 0
src/assets/images/icons/门.svg

@@ -0,0 +1,3 @@
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
+	<path fill="currentColor" d="M4.5 20v-1h2V4h8v1h3v14h2v1h-3V6h-2v14zm7.54-7.46q.23-.23.23-.54t-.23-.54t-.54-.23t-.54.23t-.23.54t.23.54t.54.23t.54-.23" />
+</svg>

BIN
src/assets/images/materials/安保设施.png


BIN
src/assets/images/materials/气防设施.png


BIN
src/assets/images/materials/消防设施.png


BIN
src/assets/images/materials/路障.png


BIN
src/assets/images/process-demo/PSA.png


BIN
src/assets/images/process-demo/厨房.png


BIN
src/assets/images/process-demo/变压器.png


BIN
src/assets/images/process-demo/地罐.png


BIN
src/assets/images/process-demo/增压机.png


BIN
src/assets/images/process-demo/房子.png


BIN
src/assets/images/process-demo/水罐.png


BIN
src/assets/images/process-demo/注水泵.png


BIN
src/assets/images/process-demo/空压机.png


BIN
src/assets/images/process-demo/空压机1.png


BIN
src/assets/images/process-demo/空气处理撬.png


BIN
src/assets/images/process-demo/采油树.png


+ 120 - 0
src/components/DeptSelectForm/qhseDept.vue

@@ -0,0 +1,120 @@
+<template>
+  <Dialog v-model="dialogVisible" title="部门选择" width="600">
+    <el-row v-loading="formLoading">
+      <el-col :span="24">
+        <ContentWrap class="h-1/1">
+          <el-tree-select
+            ref="treeRef"
+            :data="deptTree"
+            :props="defaultProps"
+            show-checkbox
+            :check-strictly="true"
+            :multiple="false"
+            check-on-click-node
+            highlight-current
+            node-key="id"
+            @check="handleCheck" />
+        </ContentWrap>
+      </el-col>
+    </el-row>
+    <template #footer>
+      <el-button
+        :disabled="formLoading || !selectedDeptIds?.length"
+        type="primary"
+        @click="submitForm">
+        确 定
+      </el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script lang="ts" setup>
+import { defaultProps, handleTree } from '@/utils/tree'
+import * as DeptApi from '@/api/system/dept'
+
+defineOptions({ name: 'DeptSelectForm' })
+
+const emit = defineEmits<{
+  confirm: [deptList: any[]]
+}>()
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+
+const props = defineProps({
+  // 是否严格的遵循父子不互相关联
+  checkStrictly: {
+    type: Boolean,
+    default: false
+  },
+  // 是否支持多选
+  multiple: {
+    type: Boolean,
+    default: false
+  }
+})
+
+const treeRef = ref()
+const deptTree = ref<Tree[]>([]) // 部门树形结构
+const selectedDeptIds = ref<number[]>([]) // 选中的部门 ID 列表
+const dialogVisible = ref(false) // 弹窗的是否展示
+const formLoading = ref(false) // 表单的加载中
+
+/** 打开弹窗 */
+const open = async (selectedList?: DeptApi.DeptVO[]) => {
+  resetForm()
+  formLoading.value = true
+  try {
+    // 加载部门列表
+    const deptData = await DeptApi.getSimpleDeptList()
+    deptTree.value = handleTree(deptData)
+  } finally {
+    formLoading.value = false
+  }
+  dialogVisible.value = true
+  // 设置已选择的部门
+  if (selectedList?.length) {
+    await nextTick()
+    const selectedIds = selectedList
+      .map((dept) => dept.id)
+      .filter((id): id is number => id !== undefined)
+    selectedDeptIds.value = selectedIds
+    treeRef.value?.setCheckedKeys(selectedIds)
+  }
+}
+
+/** 处理选中状态变化 */
+const handleCheck = (data: any, checked: any) => {
+  selectedDeptIds.value = treeRef.value.getCheckedKeys()
+  if (!props.multiple && selectedDeptIds.value.length > 1) {
+    // 单选模式下,只保留最后选择的节点
+    const lastSelectedId = selectedDeptIds.value[selectedDeptIds.value.length - 1]
+    selectedDeptIds.value = [lastSelectedId]
+    treeRef.value.setCheckedKeys([lastSelectedId])
+  }
+}
+
+/** 提交选择 */
+const submitForm = async () => {
+  try {
+    // 获取选中的完整部门数据
+    const checkedNodes = treeRef.value.getCheckedNodes()
+    message.success(t('common.updateSuccess'))
+    dialogVisible.value = false
+    emit('confirm', checkedNodes)
+  } finally {
+  }
+}
+
+/** 重置表单 */
+const resetForm = () => {
+  deptTree.value = []
+  selectedDeptIds.value = []
+  if (treeRef.value) {
+    treeRef.value.setCheckedKeys([])
+  }
+}
+
+defineExpose({ open }) // 提供 open 方法,用于打开弹窗
+</script>

+ 4 - 6
src/components/ZmTable/README.md

@@ -73,6 +73,7 @@ const { ZmTable, ZmTableColumn } = useTableComponents<ListItem>()
 | `columnMaxWidth` | `number` | `360` | 自动计算列宽时的最大宽度。 |
 | `customClass` | `boolean` | `false` | 为 `true` 时不挂载默认 `.zm-table` class,也就不会使用组件默认样式变量。 |
 | `showBorder` | `boolean` | `false` | 控制组件自己的边框显示样式。 |
+| `hoverHighlight` | `boolean` | `true` | 控制鼠标悬浮行时是否显示 hover 背景。 |
 | `showOverflowTooltip` | `boolean` | `true` | 继承自 Element Plus。想让文字换行时传 `false`,再配合页面 CSS 覆盖 `.cell` 的换行样式。 |
 
 组件内部默认还会给 Element Plus 表格设置这些值:
@@ -147,8 +148,7 @@ function handleSort(prop: string, order: 'asc' | 'desc' | null) {
   label="会议日期"
   zm-sortable
   v-model:sort-order="meetingDateOrder"
-  @sort-change="handleSortChange"
-/>
+  @sort-change="handleSortChange" />
 ```
 
 ```ts
@@ -171,8 +171,7 @@ function handleSortChange(payload: { prop: string; order: 'asc' | 'desc' | null
   label="状态"
   zm-filterable
   v-model:filter-model-value="query.status"
-  @filter-visible-change="handleFilterVisibleChange"
->
+  @filter-visible-change="handleFilterVisibleChange">
   <template #filter="{ filterModelValue, updateFilterModelValue, close }">
     <div class="p-2">
       <el-select
@@ -329,8 +328,7 @@ Element Plus 原生多级表头可以直接写:
   prop="meetingDate"
   label="会议日期"
   cover-formatter
-  :real-value="(row) => dayjs(row.meetingDate).format('YYYY-MM-DD')"
-/>
+  :real-value="(row) => dayjs(row.meetingDate).format('YYYY-MM-DD')" />
 ```
 
 ## 插槽

+ 12 - 15
src/components/ZmTable/ZmTableColumn.vue

@@ -12,6 +12,7 @@ interface Props
   > {
   prop?: (keyof T & string) | (string & {})
   action?: boolean
+  visible?: boolean
   hideInColumnSettings?: boolean
   isParent?: boolean
   zmSortable?: boolean
@@ -60,12 +61,14 @@ const forwardedSlots = computed(() => {
 
 const defaultOptions = ref<Partial<Props>>({
   align: 'center',
-  resizable: true
+  resizable: true,
+  visible: true
 })
 
 const bindProps = computed(() => {
   const {
     action,
+    visible,
     hideInColumnSettings,
     zmSortable,
     zmFilterable,
@@ -221,6 +224,7 @@ const calculativeWidth = () => {
   if (hasHeaderAction.value) labelWidth += 8
   if (props.zmFilterable) labelWidth += 22
   if (props.zmSortable) labelWidth += 22
+
   const maxWidth = Math.min(
     Math.max(...values.map((value) => getTextWidth(String(value)) + 38), labelWidth),
     tableContext.columnMaxWidth.value
@@ -259,20 +263,17 @@ watch(
                     : '点击升序'
               "
               placement="top"
-              :show-after="500"
-            >
+              :show-after="500">
               <button
                 type="button"
                 class="icon-btn"
                 :class="{ 'is-active': isSortActive }"
-                @click.stop="handleSortClick"
-              >
+                @click.stop="handleSortClick">
                 <div v-if="currentOrder === 'asc'" class="sort-icon i-lucide:arrow-up-narrow-wide">
                 </div>
                 <div
                   v-else-if="currentOrder === 'desc'"
-                  class="sort-icon i-lucide:arrow-down-wide-narrow"
-                >
+                  class="sort-icon i-lucide:arrow-down-wide-narrow">
                 </div>
                 <div v-else class="sort-icon i-lucide:arrow-up-down"></div>
               </button>
@@ -285,15 +286,13 @@ watch(
               :popper-options="{ modifiers: [{ name: 'offset', options: { offset: [16, 18] } }] }"
               trigger="click"
               :width="260"
-              :show-arrow="false"
-            >
+              :show-arrow="false">
               <template #reference>
                 <button
                   type="button"
                   class="icon-btn"
                   :class="{ 'is-active': isFilterActive }"
-                  @click.stop="handleFilterReferenceClick"
-                >
+                  @click.stop="handleFilterReferenceClick">
                   <div class="filter-icon i-lucide:list-filter"></div>
                 </button>
               </template>
@@ -307,8 +306,7 @@ watch(
               trigger="click"
               :width="360"
               :show-arrow="false"
-              popper-class="zm-table-column-setting-popper"
-            >
+              popper-class="zm-table-column-setting-popper">
               <template #reference>
                 <button type="button" class="icon-btn" title="列设置" @click.stop>
                   <div class="setting-icon i-lucide:settings"></div>
@@ -323,8 +321,7 @@ watch(
                     link
                     type="primary"
                     size="small"
-                    @click="tableContext.resetColumnSettings"
-                  >
+                    @click="tableContext.resetColumnSettings">
                     重置
                   </el-button>
                 </div>

+ 42 - 17
src/components/ZmTable/index.vue

@@ -20,6 +20,7 @@ import type { VNode } from 'vue'
 interface ColumnMeta {
   key: string
   label: string
+  visible?: boolean
   action: boolean
   configurable: boolean
   fixed: ColumnFixed
@@ -34,6 +35,7 @@ interface Props
   loading: boolean
   customClass?: boolean
   showBorder?: boolean
+  hoverHighlight?: boolean
   align?: ColumnAlign
   columnMaxWidth?: number
 }
@@ -54,6 +56,7 @@ const defaultOptions: Partial<Props> = {
   showOverflowTooltip: true,
   scrollbarAlwaysOn: true,
   showBorder: false,
+  hoverHighlight: true,
   customClass: false,
   tooltipOptions: {
     popperClass: 'max-w-120'
@@ -65,6 +68,7 @@ const bindProps = computed(() => {
     data,
     customClass: _customClass,
     showBorder: _showBorder,
+    hoverHighlight: _hoverHighlight,
     align: _align,
     columnMaxWidth,
     ...otherProps
@@ -163,6 +167,7 @@ const getColumnMeta = (node: VNode, index: number, parentKey?: string): ColumnMe
   return {
     key,
     label: getColumnLabel(node, index, parentKey),
+    visible: getPropValue(node.props, 'visible'),
     action,
     configurable: !action && !hideInColumnSettings,
     fixed: normalizeFixed(getPropValue(node.props, 'fixed')),
@@ -315,10 +320,17 @@ const applyColumnSetting = (node: VNode, setting: ColumnSettingItem) => {
   } as VNode
 }
 
-const isColumnVisible = (setting: ColumnSettingItem) => {
-  if (setting.visible === false) return false
-  if (!setting.children?.length) return true
-  return setting.children.some(isColumnVisible)
+const getSettingVisible = (meta: ColumnMeta, setting?: ColumnSettingItem) => {
+  return meta.visible ?? setting?.visible ?? true
+}
+
+const isColumnVisible = (meta: ColumnMeta, setting?: ColumnSettingItem) => {
+  if (!getSettingVisible(meta, setting)) return false
+  if (!meta.children.length) return true
+
+  const childrenSettings = setting?.children || []
+  const childSettingMap = new Map(childrenSettings.map((item) => [item.key, item]))
+  return meta.children.some((child) => isColumnVisible(child, childSettingMap.get(child.key)))
 }
 
 const renderColumnNodes = (nodes: VNode[], settings: ColumnSettingItem[], parentKey?: string) => {
@@ -328,7 +340,7 @@ const renderColumnNodes = (nodes: VNode[], settings: ColumnSettingItem[], parent
     .map((node, index) => ({ node, meta: getColumnMeta(node, index, parentKey) }))
     .filter(({ meta }) => {
       const setting = settingMap.get(meta.key)
-      return meta.configurable && setting && isColumnVisible(setting)
+      return meta.configurable && setting && isColumnVisible(meta, setting)
     })
     .sort((a, b) => {
       const orderA = orderMap.get(a.meta.key) ?? Number.MAX_SAFE_INTEGER
@@ -339,13 +351,19 @@ const renderColumnNodes = (nodes: VNode[], settings: ColumnSettingItem[], parent
   return nodes.flatMap((node, index) => {
     const meta = getColumnMeta(node, index, parentKey)
 
+    const setting = settingMap.get(meta.key)
+
+    if (!isColumnVisible(meta, setting)) return []
+
     if (!meta.configurable) return [node]
 
     const nextColumn = sortedConfigurableNodes.shift()
     if (!nextColumn) return []
 
-    const setting = settingMap.get(nextColumn.meta.key)
-    return setting ? [applyColumnSetting(nextColumn.node, setting)] : [nextColumn.node]
+    const nextColumnSetting = settingMap.get(nextColumn.meta.key)
+    return nextColumnSetting
+      ? [applyColumnSetting(nextColumn.node, nextColumnSetting)]
+      : [nextColumn.node]
   })
 }
 
@@ -378,10 +396,13 @@ defineExpose({
   <el-table
     ref="tableRef"
     v-loading="loading"
-    :class="{ 'zm-table': !customClass, 'show-border': showBorder }"
+    :class="{
+      'zm-table': !customClass,
+      'show-border': showBorder,
+      'is-hover-highlight-disabled': hoverHighlight === false
+    }"
     v-bind="bindProps"
-    :data="data"
-  >
+    :data="data">
     <template v-for="(_, name) in forwardedSlots" #[name]="slotData">
       <slot :name="name" v-bind="slotData || {}"></slot>
     </template>
@@ -549,13 +570,6 @@ defineExpose({
       }
     }
 
-    tr:hover,
-    tr.hover-row {
-      .el-table__cell {
-        background: var(--zm-table-hover-bg) !important;
-      }
-    }
-
     tr.current-row {
       .el-table__cell {
         // color: var(--el-color-primary);
@@ -564,6 +578,17 @@ defineExpose({
     }
   }
 
+  &:not(.is-hover-highlight-disabled) {
+    .el-table__body {
+      tr:hover,
+      tr.hover-row {
+        .el-table__cell {
+          background: var(--zm-table-hover-bg) !important;
+        }
+      }
+    }
+  }
+
   .el-table__row {
     .el-table__cell {
       font-weight: var(--zm-table-row-font-weight);

+ 46 - 16
src/components/custom-components/kv-vue/index.vue

@@ -1,18 +1,22 @@
 <template>
-  <table class="w-1/1 h-1/1 kvTable">
-    <colgroup>
-      <col class="kvKeyCol" />
-      <col class="kvValueCol" />
-    </colgroup>
-    <tbody>
-      <tr>
-        <td class="kvKey kvKeyValue" colspan="1">{{ props.label }}</td>
-        <td class="kvValue kvKeyValue" colspan="1">{{ props.value }}</td>
-      </tr>
-    </tbody>
-  </table>
+  <div class="kvBox" :style="kvBoxStyle">
+    <table class="w-1/1 h-1/1 kvTable">
+      <colgroup>
+        <col class="kvKeyCol" />
+        <col class="kvValueCol" />
+      </colgroup>
+      <tbody>
+        <tr>
+          <td class="kvKey kvKeyValue" colspan="1">{{ props.label }}</td>
+          <td class="kvValue kvKeyValue" colspan="1">{{ props.value }}</td>
+        </tr>
+      </tbody>
+    </table>
+  </div>
 </template>
 <script setup lang="ts">
+import { computed } from 'vue'
+
 const props = defineProps({
   fontFamily: {
     type: String,
@@ -22,6 +26,10 @@ const props = defineProps({
     type: Number,
     default: 15
   },
+  textAlign: {
+    type: String,
+    default: 'left'
+  },
   label: {
     type: String,
     default: ''
@@ -46,17 +54,38 @@ const props = defineProps({
     type: Boolean,
     default: true
   },
+  backgroundColor: {
+    type: String,
+    default: 'rgba(255, 255, 255, 0)'
+  },
   borderColor: {
     type: String,
     default: ''
+  },
+  borderRadius: {
+    type: Number,
+    default: 0
   }
 })
+
+const kvBoxStyle = computed(() => ({
+  backgroundColor: props.backgroundColor,
+  border: `${props.border ? 1 : 0}px solid ${props.borderColor}`,
+  borderRadius: `${props.borderRadius}px`
+}))
 </script>
 <style scoped>
+.kvBox {
+  width: 100%;
+  height: 100%;
+  overflow: hidden;
+  box-sizing: border-box;
+}
+
 .kvTable {
-  border: v-bind('`${props.border?1:0}px solid ${props.borderColor}`');
+  border-collapse: separate;
+  border-spacing: 0;
   table-layout: fixed;
-  border-collapse: collapse;
 }
 
 .kvKeyCol {
@@ -68,12 +97,13 @@ const props = defineProps({
 }
 
 .kvKeyValue {
+  overflow: hidden;
   font-family: v-bind('`${props.fontFamily}`');
   font-size: v-bind('`${props.fontSize}px`');
   color: v-bind('`${props.color}`');
-  overflow: hidden;
-  white-space: nowrap;
+  text-align: v-bind('`${props.textAlign}`');
   text-overflow: ellipsis;
+  white-space: nowrap;
 }
 
 .kvKey {

+ 107 - 23
src/components/custom-components/sys-button-vue/index.vue

@@ -1,12 +1,12 @@
 <template>
-  <div style="width: 100%; height: 100%">
-    <button class="w-1/1 h-1/1" :style="getStyle(props.type, props.round)">
-      <el-text>{{ props.text }}</el-text>
+  <div class="sys-button-wrapper">
+    <button class="sys-button" :style="buttonStyle">
+      <span class="sys-button-text">{{ props.text }}</span>
     </button>
   </div>
 </template>
 <script setup lang="ts">
-import { ElText } from 'element-plus'
+import { computed } from 'vue'
 import type { PropType } from 'vue'
 type ButtonType = '' | 'default' | 'success' | 'warning' | 'info' | 'primary' | 'danger'
 const props = defineProps({
@@ -21,28 +21,112 @@ const props = defineProps({
   round: {
     type: Boolean,
     default: false
+  },
+  backgroundColor: {
+    type: String,
+    default: ''
+  },
+  borderColor: {
+    type: String,
+    default: ''
+  },
+  fontColor: {
+    type: String,
+    default: ''
+  },
+  fontFamily: {
+    type: String,
+    default: ''
+  },
+  fontSize: {
+    type: Number,
+    default: 14
+  },
+  borderRadius: {
+    type: Number,
+    default: 4
   }
 })
-const getStyle = (type: ButtonType, round: boolean) => {
-  let bg_color = ''
-  let border_radius = '4px'
-  if (type == 'primary') {
-    bg_color = '#409eff'
-  } else if (type == 'success') {
-    bg_color = '#67c23a'
-  } else if (type == 'warning') {
-    bg_color = '#e6a23c'
-  } else if (type == 'danger') {
-    bg_color = '#f56c6c'
-  } else if (type == 'info') {
-    bg_color = '#909399'
-  }
-  if (round) {
-    border_radius = '20px'
+
+const buttonTypeStyleMap: Record<
+  ButtonType,
+  { background: string; border: string; color: string }
+> = {
+  '': {
+    background: '#ffffff',
+    border: '#dcdfe6',
+    color: '#606266'
+  },
+  default: {
+    background: '#ffffff',
+    border: '#dcdfe6',
+    color: '#606266'
+  },
+  primary: {
+    background: '#409eff',
+    border: '#409eff',
+    color: '#ffffff'
+  },
+  success: {
+    background: '#67c23a',
+    border: '#67c23a',
+    color: '#ffffff'
+  },
+  warning: {
+    background: '#e6a23c',
+    border: '#e6a23c',
+    color: '#ffffff'
+  },
+  danger: {
+    background: '#f56c6c',
+    border: '#f56c6c',
+    color: '#ffffff'
+  },
+  info: {
+    background: '#909399',
+    border: '#909399',
+    color: '#ffffff'
   }
+}
+
+const buttonStyle = computed(() => {
+  const typeStyle = buttonTypeStyleMap[props.type] || buttonTypeStyleMap.default
+  const borderRadius = props.round ? 20 : props.borderRadius
   return {
-    backgroundColor: bg_color,
-    borderRadius: border_radius
+    backgroundColor: props.backgroundColor || typeStyle.background,
+    borderColor: props.borderColor || typeStyle.border,
+    color: props.fontColor || typeStyle.color,
+    fontFamily: props.fontFamily || undefined,
+    fontSize: `${props.fontSize}px`,
+    borderRadius: `${borderRadius}px`
   }
-}
+})
 </script>
+<style scoped>
+.sys-button-wrapper {
+  width: 100%;
+  height: 100%;
+}
+
+.sys-button {
+  width: 100%;
+  height: 100%;
+  padding: 0 12px;
+  overflow: hidden;
+  line-height: 1;
+  cursor: pointer;
+  border: 1px solid;
+  transition:
+    background-color 0.2s,
+    border-color 0.2s,
+    color 0.2s;
+}
+
+.sys-button-text {
+  display: block;
+  overflow: hidden;
+  color: inherit;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 49 - 0
src/components/custom-components/wireframe-vue/index.vue

@@ -0,0 +1,49 @@
+<template>
+  <div class="wireframe" :style="wireframeStyle"></div>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+import type { PropType } from 'vue'
+
+type BorderStyle = 'solid' | 'dashed' | 'dotted' | 'double' | 'none'
+
+const props = defineProps({
+  fill: {
+    type: String,
+    default: 'rgba(255, 255, 255, 0)'
+  },
+  borderColor: {
+    type: String,
+    default: '#409eff'
+  },
+  borderStyle: {
+    type: String as PropType<BorderStyle>,
+    default: 'solid'
+  },
+  borderWidth: {
+    type: Number,
+    default: 2
+  },
+  borderRadius: {
+    type: Number,
+    default: 0
+  }
+})
+
+const wireframeStyle = computed(() => ({
+  backgroundColor: props.fill,
+  borderColor: props.borderColor,
+  borderStyle: props.borderStyle,
+  borderWidth: `${props.borderWidth}px`,
+  borderRadius: `${props.borderRadius}px`
+}))
+</script>
+
+<style scoped>
+.wireframe {
+  width: 100%;
+  height: 100%;
+  box-sizing: border-box;
+}
+</style>

+ 48 - 7
src/components/mt-edit/components/done-tree/index.vue

@@ -9,13 +9,37 @@
     node-key="id"
     :current-node-key="current_node_key">
     <template #default="{ node, data }">
-      <div class="flex justify-between w-8/10">
+      <div class="flex justify-between w-9/10">
         <div>{{ node.label }}</div>
-        <el-button text circle size="small" class="mr-10px">
-          <el-icon :title="data.hide ? '隐藏' : '显示'" :size="20" @click.stop="changeHide(data)">
-            <svg-analysis :name="data.hide ? 'view-hide' : 'view-show'" />
-          </el-icon>
-        </el-button>
+        <div class="flex items-center">
+          <el-button
+            text
+            circle
+            size="small"
+            :disabled="!canMoveUp(data)"
+            title="上移一层"
+            @click.stop="moveLayer(data, 'up')">
+            <el-icon :size="16">
+              <ArrowUp />
+            </el-icon>
+          </el-button>
+          <el-button
+            text
+            circle
+            size="small"
+            :disabled="!canMoveDown(data)"
+            title="下移一层"
+            @click.stop="moveLayer(data, 'down')">
+            <el-icon :size="16">
+              <ArrowDown />
+            </el-icon>
+          </el-button>
+          <el-button text circle size="small" class="mr-10px">
+            <el-icon :title="data.hide ? '隐藏' : '显示'" :size="20" @click.stop="changeHide(data)">
+              <svg-analysis :name="data.hide ? 'view-hide' : 'view-show'" />
+            </el-icon>
+          </el-button>
+        </div>
       </div>
     </template>
   </el-tree>
@@ -23,6 +47,7 @@
 
 <script lang="ts" setup>
 import { ElTree, ElButton, ElIcon } from 'element-plus'
+import { ArrowDown, ArrowUp } from '@element-plus/icons-vue'
 import { computed } from 'vue'
 import type { IDoneJson } from '@/components/mt-edit/store/types'
 import SvgAnalysis from '@/components/mt-edit/components/svg-analysis/index.vue'
@@ -32,7 +57,7 @@ type DoneTree = {
 }
 const doneTreeProps = withDefaults(defineProps<DoneTree>(), {})
 
-const emits = defineEmits(['updateSelectedItemsId', 'updateSelectedIdHide'])
+const emits = defineEmits(['updateSelectedItemsId', 'updateSelectedIdHide', 'moveLayer'])
 
 const current_node_key = computed(() => {
   return doneTreeProps.selectedItemsId.length == 1 ? doneTreeProps.selectedItemsId[0] : ''
@@ -43,6 +68,22 @@ const handleNodeClick = (data: IDoneJson) => {
 const changeHide = (data: IDoneJson) => {
   emits('updateSelectedIdHide', data.id)
 }
+const getLayerIndex = (data: IDoneJson) => {
+  return doneTreeProps.doneJson.findIndex((item) => item.id === data.id)
+}
+const canMoveUp = (data: IDoneJson) => {
+  const index = getLayerIndex(data)
+  return index >= 0 && index < doneTreeProps.doneJson.length - 1
+}
+const canMoveDown = (data: IDoneJson) => {
+  return getLayerIndex(data) > 0
+}
+const moveLayer = (data: IDoneJson, direction: 'up' | 'down') => {
+  emits('moveLayer', {
+    id: data.id,
+    direction
+  })
+}
 const defaultProps = {
   children: 'nochildren',
   label: 'title'

+ 22 - 5
src/components/mt-edit/components/draw-line-render/index.vue

@@ -51,13 +51,10 @@
             : lineRenderProps.itemJson.props.stroke.val
         "
         :stroke-width="lineRenderProps.itemJson.props['stroke-width'].val"
+        :stroke-dasharray="line_dasharray"
+        :stroke-linecap="line_stroke_linecap"
         style="cursor: move"
         stroke-dashoffset="0"
-        :stroke-dasharray="
-          lineRenderProps.itemJson.props.ani_type.val === 'electricity'
-            ? lineRenderProps.itemJson.props['stroke-width'].val * 3
-            : 0
-        "
         :marker-start="
           lineRenderProps.itemJson.props?.['marker-start']?.val
             ? `url(#markerArrowStart${lineRenderProps.itemJson.id})`
@@ -118,6 +115,26 @@ const arrow_marker_size = computed(() => {
   const stroke_width = Number(lineRenderProps.itemJson.props['stroke-width'].val) || 1
   return Math.min(Math.max(16 / stroke_width, 2.5), 6)
 })
+const line_style = computed(() => lineRenderProps.itemJson.props.lineStyle?.val || 'solid')
+const line_dasharray = computed(() => {
+  const stroke_width = Number(lineRenderProps.itemJson.props['stroke-width'].val) || 1
+
+  if (lineRenderProps.itemJson.props.ani_type.val === 'electricity') {
+    return stroke_width * 3
+  }
+
+  if (line_style.value === 'dashed') {
+    return `${stroke_width * 4} ${stroke_width * 2}`
+  }
+  if (line_style.value === 'dotted') {
+    return `0 ${stroke_width * 2.5}`
+  }
+  if (line_style.value === 'dash-dot') {
+    return `${stroke_width * 4} ${stroke_width * 2} ${stroke_width} ${stroke_width * 2}`
+  }
+  return 0
+})
+const line_stroke_linecap = computed(() => (line_style.value === 'dotted' ? 'round' : 'butt'))
 const onMouseDown = (de: MouseTouchEvent, point_index: number, item: { x: number; y: number }) => {
   de.stopPropagation()
   // 记录鼠标按下时实际点的坐标

+ 15 - 1
src/components/mt-edit/components/layout/main-panel/index.vue

@@ -141,6 +141,16 @@ const done_json = computed({
     globalStore.setGlobalStoreDoneJson(val)
   }
 })
+const commitActiveRightAsideEditor = () => {
+  const activeElement = document.activeElement
+  if (!(activeElement instanceof HTMLElement)) {
+    return
+  }
+  if (!activeElement.closest('#mt-right-aside')) {
+    return
+  }
+  activeElement.blur()
+}
 const sys_line_init = configStore.sysComponent.find((f) => f.type == 'sys-line')!
 const draw_line_init_data: IDoneJson = {
   id: sys_line_init.id + '-' + randomString(),
@@ -277,6 +287,7 @@ const onDragOver = (e: DragEvent) => {
   e.preventDefault()
 }
 const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
+  commitActiveRightAsideEditor()
   beginListenerKeyDown()
   if (isSystemShortcutKey(e)) {
     shortcut_drag_select_item_id.value = item.lock ? '' : item.id
@@ -324,6 +335,7 @@ const onRenderCoreMouseDown = (item: IDoneJson, e: MouseEvent) => {
   }
 }
 const onMouseDown = (e: MouseEvent) => {
+  commitActiveRightAsideEditor()
   beginListenerKeyDown()
   if (isSystemShortcutKey(e)) {
     shortcut_drag_select_item_id.value = ''
@@ -776,7 +788,9 @@ const dragCanvasMouseUp = () => {
     globalStore.setIntention('endDragCanvas')
   } else {
     if (shortcut_drag_select_item_id.value) {
-      const find_item = globalStore.done_json.find((f) => f.id == shortcut_drag_select_item_id.value)
+      const find_item = globalStore.done_json.find(
+        (f) => f.id == shortcut_drag_select_item_id.value
+      )
       if (find_item) {
         find_item.active = !find_item.active
         globalStore.refreshSelectedItemsId()

+ 20 - 4
src/components/mt-edit/components/layout/right-aside/index.vue

@@ -1,9 +1,11 @@
 <template>
   <div id="mt-right-aside" class="px-4">
     <select-item-setting
-      v-if="globalStore.selected_items_id.length == 1"
-      v-model:item-json="globalStore.done_json[find_index_item_json]"
+      v-if="selected_item_json"
+      :key="selected_item_id"
+      :item-json="selected_item_json"
       :done-json="globalStore.done_json"
+      @update:item-json="onUpdateItemJson"
       @add-history="onAddHistory">
       <template v-if="hasDeviceBindSlot" #deviceBind="{ item }">
         <slot name="deviceBind" :item="item"></slot> </template
@@ -20,10 +22,24 @@ import PageSetting from './page-setting.vue'
 import SelectItemSetting from './select-item-setting.vue'
 import { globalStore } from '@/components/mt-edit/store/global'
 import { cacheStore } from '@/components/mt-edit/store/cache'
+import type { IDoneJson } from '@/components/mt-edit/store/types'
 const slots = useSlots()
-const find_index_item_json = computed(() => {
-  return globalStore.done_json.findIndex((f) => f.id == globalStore.selected_items_id[0])
+const selected_item_id = computed(() => globalStore.selected_items_id[0] || '')
+const selected_item_json = computed(() => {
+  if (globalStore.selected_items_id.length !== 1) {
+    return null
+  }
+  return globalStore.done_json.find((f) => f.id == selected_item_id.value) || null
 })
+const onUpdateItemJson = (item: IDoneJson) => {
+  const find_index_item_json = globalStore.done_json.findIndex((f) => f.id == item.id)
+  if (find_index_item_json < 0) {
+    return
+  }
+  const done_json_temp = [...globalStore.done_json]
+  done_json_temp[find_index_item_json] = item
+  globalStore.setGlobalStoreDoneJson(done_json_temp)
+}
 const onAddHistory = () => {
   cacheStore.addHistory(globalStore.done_json)
 }

+ 56 - 1
src/components/mt-edit/components/layout/right-aside/select-item-event-setting/index.vue

@@ -32,7 +32,10 @@
               </el-select>
             </el-form-item>
             <el-form-item label="事件行为" size="small">
-              <el-select placeholder="事件行为" v-model="event_list[event_list_index].action">
+              <el-select
+                placeholder="事件行为"
+                v-model="event_list[event_list_index].action"
+                @change="onEventActionChange(event_list_index)">
                 <el-option
                   v-for="item in event_action"
                   :key="item.value"
@@ -56,6 +59,20 @@
                 >点击编写</el-button
               >
             </el-form-item>
+            <el-form-item
+              v-else-if="event_list_item.action == 'openWebtopo'"
+              label="目标组态"
+              size="small">
+              <div class="webtopo-project-field">
+                <el-button @click="onWebtopoProjectClick(event_list_index)">选择组态</el-button>
+                <el-text
+                  truncated
+                  class="webtopo-project-name"
+                  :title="event_list_item.webtopo_project?.projectName">
+                  {{ event_list_item.webtopo_project?.projectName || '未选择' }}
+                </el-text>
+              </div>
+            </el-form-item>
             <el-form-item label="触发规则" size="small">
               <el-text>(不填直接触发)</el-text>
             </el-form-item>
@@ -198,6 +215,10 @@
           enableLiveAutocompletion: true
         }" />
     </el-dialog>
+    <webtopo-project-select-dialog
+      v-model:visible="webtopo_project_dialog_visible"
+      :model-value="event_list[select_event_index]?.webtopo_project"
+      @update:model-value="onWebtopoProjectSelected" />
   </div>
 </template>
 <script setup lang="ts">
@@ -224,11 +245,13 @@ import type {
   IDoneJson,
   IDoneJsonActionChangeAttr,
   IDoneJsonEventList,
+  IDoneJsonEventWebtopoProject,
   ILeftAsideConfigItemPublicPropsType
 } from '@/components/mt-edit/store/types'
 import InputTargetValue from './input-target-value.vue'
 import '@/components/mt-edit/ace-edit'
 import { VAceEditor } from 'vue3-ace-editor'
+import WebtopoProjectSelectDialog from './webtopo-project-select-dialog.vue'
 
 type SelectItemEventSettingProps = {
   doneJson: IDoneJson[]
@@ -277,6 +300,10 @@ const event_action: {
   {
     label: '自定义代码',
     value: 'customCode'
+  },
+  {
+    label: '打开组态',
+    value: 'openWebtopo'
   }
 ]
 const all_component_options = computed(() => {
@@ -296,6 +323,7 @@ const drawer_change_attr = ref<IDoneJsonActionChangeAttr[]>([]) // 属性更改
 const dialog_visiable = ref(false)
 const dialog_title = ref('自定义代码编写')
 const dialog_code = ref('')
+const webtopo_project_dialog_visible = ref(false)
 const onAddEvent = () => {
   event_list.value.push({
     id: randomString(),
@@ -303,6 +331,7 @@ const onAddEvent = () => {
     action: 'changeAttr',
     change_attr: [],
     custom_code: '',
+    webtopo_project: undefined,
     trigger_rule: {
       trigger_id: undefined,
       trigger_attr: undefined,
@@ -344,6 +373,18 @@ const onTargetAttrChange = (change_attr_index: number) => {
     drawer_change_attr.value[change_attr_index].target_value = undefined
   }
 }
+const onEventActionChange = (event_list_index: number) => {
+  if (event_list.value[event_list_index].action == 'openWebtopo') {
+    event_list.value[event_list_index].webtopo_project = {}
+  }
+}
+const onWebtopoProjectClick = (index: number) => {
+  select_event_index.value = index
+  webtopo_project_dialog_visible.value = true
+}
+const onWebtopoProjectSelected = (project: IDoneJsonEventWebtopoProject) => {
+  event_list.value[select_event_index.value].webtopo_project = project
+}
 /**
  * 根据id获取属性
  * @param id
@@ -475,3 +516,17 @@ watch(event_list.value, (val) => {
   selectItemEventSettingEmits('update:itemEvents', val)
 })
 </script>
+<style scoped>
+.webtopo-project-field {
+  display: flex;
+  width: 100%;
+  min-width: 0;
+  flex-direction: column;
+  align-items: flex-start;
+  gap: 8px;
+}
+
+.webtopo-project-name {
+  max-width: 100%;
+}
+</style>

+ 296 - 0
src/components/mt-edit/components/layout/right-aside/select-item-event-setting/webtopo-project-select-dialog.vue

@@ -0,0 +1,296 @@
+<template>
+  <el-dialog
+    v-model="visible"
+    title="选择组态"
+    width="980px"
+    class="webtopo-select-dialog"
+    destroy-on-close
+    :close-on-click-modal="false"
+    @open="loadList">
+    <div class="webtopo-select-toolbar">
+      <el-input
+        v-model="query.projectName"
+        clearable
+        class="!w-260px"
+        placeholder="请输入组态名称"
+        @keydown.enter.prevent="handleQuery()" />
+      <el-button type="primary" @click="handleQuery()">
+        <Icon icon="ep:search" class="mr-5px" /> 搜索
+      </el-button>
+      <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" />重置</el-button>
+    </div>
+
+    <el-scrollbar v-loading="loading" height="520px" view-class="webtopo-select-list">
+      <el-empty v-if="!loading && !list.length" description="暂无组态项目" />
+      <div v-else class="webtopo-select-grid">
+        <button
+          v-for="item in list"
+          :key="item.id"
+          type="button"
+          :class="['webtopo-select-card', modelValue?.id === item.id ? 'is-selected' : '']"
+          @click="onSelect(item)">
+          <div class="webtopo-select-thumb">
+            <el-image v-if="item.thumbnail" :src="item.thumbnail" fit="cover" class="w-full h-full">
+              <template #error>
+                <div class="webtopo-select-thumb-empty">
+                  <Icon icon="ep:picture" />
+                  缩略图加载失败
+                </div>
+              </template>
+            </el-image>
+            <div v-else class="webtopo-select-thumb-empty">
+              <Icon icon="ep:picture" />
+              暂无缩略图
+            </div>
+            <div class="webtopo-select-device">
+              <Icon icon="ep:cpu" />
+              {{ getLinkedDeviceCount(item) }} 台设备
+            </div>
+          </div>
+          <div class="webtopo-select-content">
+            <div class="webtopo-select-title" :title="item.projectName">
+              {{ item.projectName || '-' }}
+            </div>
+            <div class="webtopo-select-time">
+              <Icon icon="ep:clock" />
+              {{ formatCreateTime(item.createTime) }}
+            </div>
+            <div class="webtopo-select-remark" :title="item.remark">
+              {{ item.remark || '暂无备注' }}
+            </div>
+          </div>
+        </button>
+      </div>
+    </el-scrollbar>
+
+    <div class="webtopo-select-footer">
+      <el-pagination
+        v-show="total > 0"
+        v-model:current-page="query.pageNo"
+        v-model:page-size="query.pageSize"
+        background
+        :page-sizes="[12, 20, 30, 50]"
+        :total="total"
+        layout="total, sizes, prev, pager, next"
+        @size-change="handleSizeChange"
+        @current-change="handleCurrentChange" />
+    </div>
+  </el-dialog>
+</template>
+
+<script setup lang="ts">
+import {
+  WebtopoProjectApi,
+  type WebtopoProjectPageReqVO,
+  type WebtopoProjectVO
+} from '@/api/pms/maotu'
+import type { IDoneJsonEventWebtopoProject } from '@/components/mt-edit/store/types'
+import { useUserStore } from '@/store/modules/user'
+import { useDebounceFn } from '@vueuse/core'
+import dayjs from 'dayjs'
+
+type WebtopoProjectSelectDialogProps = {
+  modelValue?: IDoneJsonEventWebtopoProject
+}
+
+const props = defineProps<WebtopoProjectSelectDialogProps>()
+const emit = defineEmits(['update:modelValue', 'select'])
+const visible = defineModel<boolean>('visible', { default: false })
+
+const deptId = useUserStore().getUser.deptId
+const loading = ref(false)
+const list = ref<WebtopoProjectVO[]>([])
+const total = ref(0)
+const initQuery: WebtopoProjectPageReqVO = {
+  pageNo: 1,
+  pageSize: 12,
+  deptId,
+  projectName: ''
+}
+const query = ref<WebtopoProjectPageReqVO>({ ...initQuery })
+const modelValue = computed(() => props.modelValue)
+
+const loadList = useDebounceFn(async () => {
+  loading.value = true
+  try {
+    const data = await WebtopoProjectApi.getWebtopoProjectPage(query.value)
+    if (Array.isArray(data)) {
+      list.value = data
+      total.value = data.length
+      return
+    }
+
+    list.value = data?.list || []
+    total.value = data?.total || 0
+  } finally {
+    loading.value = false
+  }
+}, 200)
+
+const getLinkedDeviceIds = (row: WebtopoProjectVO) => {
+  return row.linkedDeviceIds || row.linkedDevices?.map((item) => item.id) || []
+}
+
+const getLinkedDeviceCount = (row: WebtopoProjectVO) => {
+  return getLinkedDeviceIds(row).length
+}
+
+const formatCreateTime = (value?: number | string) => {
+  if (!value) return '-'
+  const time = typeof value === 'number' && value.toString().length === 10 ? value * 1000 : value
+  return dayjs(time).format('YYYY-MM-DD HH:mm:ss')
+}
+
+const handleSizeChange = (val: number) => {
+  query.value.pageSize = val
+  handleQuery()
+}
+
+const handleCurrentChange = (val: number) => {
+  query.value.pageNo = val
+  loadList()
+}
+
+const handleQuery = (setPage = true) => {
+  if (setPage) {
+    query.value.pageNo = 1
+  }
+  loadList()
+}
+
+const resetQuery = () => {
+  query.value = { ...initQuery }
+  handleQuery()
+}
+
+const onSelect = (item: WebtopoProjectVO) => {
+  const targetProject = {
+    id: item.id,
+    projectName: item.projectName
+  }
+  emit('update:modelValue', targetProject)
+  emit('select', targetProject)
+  visible.value = false
+}
+</script>
+
+<style scoped>
+.webtopo-select-toolbar {
+  display: flex;
+  align-items: center;
+  gap: 12px;
+  margin-bottom: 16px;
+}
+
+.webtopo-select-list {
+  min-height: 360px;
+  padding: 2px 4px 8px;
+}
+
+.webtopo-select-grid {
+  display: grid;
+  grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
+  gap: 16px;
+}
+
+.webtopo-select-card {
+  padding: 0;
+  overflow: hidden;
+  text-align: left;
+  cursor: pointer;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  transition:
+    border-color 0.2s,
+    box-shadow 0.2s,
+    transform 0.2s;
+}
+
+.webtopo-select-card:hover,
+.webtopo-select-card.is-selected {
+  border-color: var(--el-color-primary);
+  transform: translateY(-2px);
+  box-shadow: 0 10px 24px rgb(15 23 42 / 12%);
+}
+
+.webtopo-select-thumb {
+  position: relative;
+  height: 146px;
+  overflow: hidden;
+  background: var(--el-fill-color-light);
+}
+
+.webtopo-select-thumb-empty {
+  display: flex;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+  width: 100%;
+  height: 100%;
+  gap: 8px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+  background: linear-gradient(135deg, var(--el-fill-color-blank), var(--el-fill-color-light));
+}
+
+.webtopo-select-thumb-empty .iconify {
+  font-size: 30px;
+  opacity: 0.65;
+}
+
+.webtopo-select-device {
+  position: absolute;
+  top: 10px;
+  left: 10px;
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  padding: 4px 10px;
+  font-size: 12px;
+  color: #fff;
+  background: rgb(0 0 0 / 45%);
+  border-radius: 999px;
+  backdrop-filter: blur(8px);
+}
+
+.webtopo-select-content {
+  padding: 14px;
+}
+
+.webtopo-select-title {
+  overflow: hidden;
+  font-size: 15px;
+  font-weight: 600;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.webtopo-select-time {
+  display: flex;
+  align-items: center;
+  gap: 6px;
+  margin-top: 10px;
+  font-size: 12px;
+  color: var(--el-text-color-secondary);
+}
+
+.webtopo-select-remark {
+  display: -webkit-box;
+  height: 40px;
+  margin-top: 8px;
+  overflow: hidden;
+  font-size: 12px;
+  line-height: 20px;
+  color: var(--el-text-color-placeholder);
+  -webkit-box-orient: vertical;
+  -webkit-line-clamp: 2;
+}
+
+.webtopo-select-footer {
+  display: flex;
+  justify-content: flex-end;
+  margin-top: 16px;
+}
+</style>

+ 18 - 2
src/components/mt-edit/components/layout/right-aside/select-item-props-setting.vue

@@ -1,6 +1,12 @@
 <template>
-  <div v-for="(attr_item, key) in selectItemPropsSettingProps.propsInfo" :key="key">
-    <el-form-item v-if="!attr_item.disabled" :label="attr_item.title" size="small">
+  <div
+    v-for="(attr_item, key) in selectItemPropsSettingProps.propsInfo"
+    :key="`${selectItemPropsSettingProps.itemId}-${key}`">
+    <el-form-item
+      v-if="!attr_item.disabled"
+      class="mt-edit-prop-form-item"
+      :label="attr_item.title"
+      size="small">
       <el-select
         v-if="attr_item.type === 'select' && !attr_item.disabled"
         v-model="attr_item.val"
@@ -29,6 +35,8 @@
       <el-color-picker
         v-else-if="attr_item.type === 'color' && !attr_item.disabled"
         v-model="attr_item.val"
+        :show-alpha="attr_item.showAlpha"
+        :color-format="attr_item.colorFormat"
         :disabled="attr_item?.disabled" />
       <el-switch
         v-else-if="attr_item.type === 'switch' && !attr_item.disabled"
@@ -53,7 +61,15 @@ import {
 } from 'element-plus'
 import JsonEdit from './json-edit.vue'
 type SelectItemPropsSettingProps = {
+  itemId?: string
   propsInfo: ILeftAsideConfigItemPublicProps | undefined
 }
 const selectItemPropsSettingProps = withDefaults(defineProps<SelectItemPropsSettingProps>(), {})
 </script>
+<style scoped>
+.mt-edit-prop-form-item :deep(.el-form-item__label) {
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+</style>

+ 3 - 1
src/components/mt-edit/components/layout/right-aside/select-item-setting.vue

@@ -55,7 +55,9 @@
             <el-form-item v-if="!item_lock && !is_line" label="可旋转" size="small">
               <el-switch size="small" v-model="item_rotate" />
             </el-form-item>
-            <select-item-props-setting v-model:propsInfo="item_props" />
+            <select-item-props-setting
+              :item-id="selectItemSettingProps.itemJson.id"
+              v-model:propsInfo="item_props" />
           </el-form>
         </el-collapse-item>
         <el-collapse-item title="动画配置" name="2">

+ 23 - 8
src/components/mt-edit/components/line-render/index.vue

@@ -66,13 +66,10 @@
             : lineRenderProps.itemJson.props.stroke.val
         "
         :stroke-width="lineRenderProps.itemJson.props['stroke-width'].val"
+        :stroke-dasharray="line_dasharray"
+        :stroke-linecap="line_stroke_linecap"
         style="cursor: move"
         stroke-dashoffset="0"
-        :stroke-dasharray="
-          lineRenderProps.itemJson.props.ani_type.val === 'electricity'
-            ? lineRenderProps.itemJson.props['stroke-width'].val * 3
-            : 0
-        "
         :marker-start="
           lineRenderProps.itemJson.props?.['marker-start']?.val
             ? `url(#markerArrowStart${lineRenderProps.itemJson.id})`
@@ -163,9 +160,7 @@
         :r="lineRenderProps.itemJson.props['stroke-width'].val * 2"
         :fill="lineRenderProps.itemJson.props.ani_color.val">
         <animateMotion
-          :path="
-            line_path
-          "
+          :path="line_path"
           :dur="`${
             lineRenderProps.itemJson.props.ani_dur.val < 1
               ? 1
@@ -262,6 +257,26 @@ const arrow_marker_size = computed(() => {
   const stroke_width = Number(lineRenderProps.itemJson.props['stroke-width'].val) || 1
   return Math.min(Math.max(16 / stroke_width, 2.5), 6)
 })
+const line_style = computed(() => lineRenderProps.itemJson.props.lineStyle?.val || 'solid')
+const line_dasharray = computed(() => {
+  const stroke_width = Number(lineRenderProps.itemJson.props['stroke-width'].val) || 1
+
+  if (lineRenderProps.itemJson.props.ani_type.val === 'electricity') {
+    return stroke_width * 3
+  }
+
+  if (line_style.value === 'dashed') {
+    return `${stroke_width * 4} ${stroke_width * 2}`
+  }
+  if (line_style.value === 'dotted') {
+    return `0 ${stroke_width * 2.5}`
+  }
+  if (line_style.value === 'dash-dot') {
+    return `${stroke_width * 4} ${stroke_width * 2} ${stroke_width} ${stroke_width * 2}`
+  }
+  return 0
+})
+const line_stroke_linecap = computed(() => (line_style.value === 'dotted' ? 'round' : 'butt'))
 const line_path = computed(() =>
   positionArrarToPath(
     lineRenderProps.itemJson.props.point_position.val,

+ 4 - 0
src/components/mt-edit/components/render-core/index.vue

@@ -91,6 +91,7 @@ import CardVue from '@/components/custom-components/card-vue/index.vue'
 import NowTimeVue from '@/components/custom-components/now-time-vue/index.vue'
 import KvVue from '@/components/custom-components/kv-vue/index.vue'
 import SysButtonVue from '@/components/custom-components/sys-button-vue/index.vue'
+import WireframeVue from '@/components/custom-components/wireframe-vue/index.vue'
 import { ElPopover } from 'element-plus'
 const instance = getCurrentInstance()
 const now_include_keys = Object.keys(instance?.appContext?.components as any)
@@ -109,6 +110,9 @@ if (!now_include_keys.includes('kv-vue')) {
 if (!now_include_keys.includes('sys-button-vue')) {
   instance?.appContext.app.component('sys-button-vue', SysButtonVue)
 }
+if (!now_include_keys.includes('wireframe-vue')) {
+  instance?.appContext.app.component('wireframe-vue', WireframeVue)
+}
 type RenderCoreProps = {
   doneJson: IDoneJson[]
   canvasCfg: IGlobalStoreCanvasCfg

+ 39 - 4
src/components/mt-edit/index.vue

@@ -90,7 +90,8 @@
         :done-json="globalStore.done_json"
         :selected-items-id="globalStore.selected_items_id"
         @update-selected-items-id="onTreeUpdateSelectedItemsId"
-        @update-selected-id-hide="onDoneTreeUpdateSelectedIdHide" />
+        @update-selected-id-hide="onDoneTreeUpdateSelectedIdHide"
+        @move-layer="onDoneTreeMoveLayer" />
     </el-drawer>
   </div>
 </template>
@@ -110,7 +111,7 @@ import {
   ElButton,
   ElMessage
 } from 'element-plus'
-import { globalStore } from '@/components/mt-edit/store/global'
+import { globalStore, resetGlobalStore } from '@/components/mt-edit/store/global'
 import { computed, reactive, ref, useSlots } from 'vue'
 import DoneTree from '@/components/mt-edit/components/done-tree/index.vue'
 import { cacheStore } from './store/cache'
@@ -182,6 +183,26 @@ const onDoneTreeUpdateSelectedIdHide = (id: string) => {
     item.hide = !item.hide
   }
 }
+const onDoneTreeMoveLayer = ({ id, direction }: { id: string; direction: 'up' | 'down' }) => {
+  const index = globalStore.done_json.findIndex((f) => f.id === id)
+  if (index < 0) {
+    return
+  }
+
+  const targetIndex = direction === 'up' ? index + 1 : index - 1
+  if (targetIndex < 0 || targetIndex >= globalStore.done_json.length) {
+    ElMessage.error(direction === 'up' ? '已经是最上层了' : '已经是最下层了')
+    return
+  }
+
+  const doneJson = [...globalStore.done_json]
+  const temp = doneJson[index]
+  doneJson[index] = doneJson[targetIndex]
+  doneJson[targetIndex] = temp
+  globalStore.setGlobalStoreDoneJson(doneJson)
+  globalStore.setSingleSelect(id)
+  cacheStore.addHistory(globalStore.done_json)
+}
 const onAlignSelected = (
   type:
     | 'left'
@@ -242,11 +263,25 @@ const setImportJson = (exportJson: IExportJson) => {
   globalStore.canvasCfg = canvasCfg
   globalStore.gridCfg = gridCfg
   globalStore.setGlobalStoreDoneJson(importDoneJson)
-  cacheStore.history[0] = importDoneJson
+  cacheStore.history = [objectDeepClone(importDoneJson)]
+  cacheStore.historyIndex = 0
   return true
 }
+const reset = () => {
+  resetGlobalStore()
+  cacheStore.setBoundingBox([])
+  cacheStore.setCopy([])
+  cacheStore.adsorbPoint = []
+  cacheStore.history = [[]]
+  cacheStore.historyIndex = 0
+  import_visible.value = false
+  export_visible.value = false
+  done_json_tree_visiable.value = false
+  line_append_enable.value = false
+}
 defineExpose({
-  setImportJson
+  setImportJson,
+  reset
 })
 </script>
 <style scoped>

+ 263 - 8
src/components/mt-edit/store/config.ts

@@ -17,6 +17,29 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
         type: 'number',
         val: 2
       },
+      lineStyle: {
+        title: '线条样式',
+        type: 'select',
+        val: 'solid',
+        options: [
+          {
+            label: '实线',
+            value: 'solid'
+          },
+          {
+            label: '虚线',
+            value: 'dashed'
+          },
+          {
+            label: '点线',
+            value: 'dotted'
+          },
+          {
+            label: '点划线',
+            value: 'dash-dot'
+          }
+        ]
+      },
       'marker-start': {
         title: '起点箭头',
         type: 'switch',
@@ -102,6 +125,29 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
         type: 'number',
         val: 2
       },
+      lineStyle: {
+        title: '线条样式',
+        type: 'select',
+        val: 'solid',
+        options: [
+          {
+            label: '实线',
+            value: 'solid'
+          },
+          {
+            label: '虚线',
+            value: 'dashed'
+          },
+          {
+            label: '点线',
+            value: 'dotted'
+          },
+          {
+            label: '点划线',
+            value: 'dash-dot'
+          }
+        ]
+      },
       'marker-start': {
         title: '起点箭头',
         type: 'switch',
@@ -280,6 +326,71 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       repeat: 'infinite'
     }
   },
+  {
+    id: 'wireframe-vue',
+    title: '线框',
+    type: 'vue',
+    thumbnail: `data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHdpZHRoPSIxZW0iIGhlaWdodD0iMWVtIiB2aWV3Qm94PSIwIDAgMTAyNCAxMDI0Ij48cmVjdCB4PSIxNjAiIHk9IjIyNCIgd2lkdGg9IjcwNCIgaGVpZ2h0PSI1NzYiIHJ4PSI2NCIgZmlsbD0ibm9uZSIgc3Ryb2tlPSIjNDA5ZWZmIiBzdHJva2Utd2lkdGg9IjY0IiBzdHJva2UtZGFzaGFycmF5PSIxMjAgNjQiLz48L3N2Zz4=`,
+    props: {
+      fill: {
+        title: '背景色',
+        type: 'color',
+        val: 'rgba(255, 255, 255, 0)',
+        showAlpha: true,
+        colorFormat: 'rgb'
+      },
+      borderColor: {
+        title: '边框颜色',
+        type: 'color',
+        val: '#409eff',
+        showAlpha: true,
+        colorFormat: 'rgb'
+      },
+      borderStyle: {
+        title: '边框样式',
+        type: 'select',
+        val: 'solid',
+        options: [
+          {
+            value: 'solid',
+            label: '实线'
+          },
+          {
+            value: 'dashed',
+            label: '虚线'
+          },
+          {
+            value: 'dotted',
+            label: '点线'
+          },
+          {
+            value: 'double',
+            label: '双线'
+          },
+          {
+            value: 'none',
+            label: '无'
+          }
+        ]
+      },
+      borderWidth: {
+        title: '边框宽度',
+        type: 'number',
+        val: 2
+      },
+      borderRadius: {
+        title: '圆角',
+        type: 'number',
+        val: 0
+      }
+    },
+    common_animations: {
+      val: '',
+      delay: 'delay-0s',
+      speed: 'slow',
+      repeat: 'infinite'
+    }
+  },
   {
     id: 'card-vue',
     title: '卡片',
@@ -299,7 +410,9 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
       backGroundColor: {
         title: '背景颜色',
         type: 'color',
-        val: '#ffffff'
+        val: '#ffffff',
+        showAlpha: true,
+        colorFormat: 'rgb'
       },
       boxShadow: {
         title: '阴影颜色',
@@ -359,18 +472,61 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
         type: 'switch',
         val: true
       },
+      backgroundColor: {
+        title: '背景色',
+        type: 'color',
+        val: 'rgba(255, 255, 255, 0)',
+        showAlpha: true,
+        colorFormat: 'rgb'
+      },
       fontFamily: {
         title: '字体',
         type: 'select',
-        val: '黑体',
+        val: 'Microsoft YaHei, PingFang SC, sans-serif',
         options: [
           {
-            value: '黑体',
-            label: '黑'
+            value: 'Microsoft YaHei, PingFang SC, sans-serif',
+            label: '微软雅黑'
           },
           {
-            value: '宋体',
+            value: 'SimSun, Songti SC, serif',
             label: '宋体'
+          },
+          {
+            value: 'SimHei, Heiti SC, sans-serif',
+            label: '黑体'
+          },
+          {
+            value: 'PingFang SC, Microsoft YaHei, sans-serif',
+            label: '苹方'
+          },
+          {
+            value: 'Noto Sans SC, Microsoft YaHei, sans-serif',
+            label: '思源黑体'
+          },
+          {
+            value: 'YouSheBiaoTiHei, Microsoft YaHei, sans-serif',
+            label: '优设标题黑'
+          },
+          {
+            value: 'KaiTi, Kaiti SC, serif',
+            label: '楷体'
+          },
+          {
+            value: 'FangSong, STFangsong, serif',
+            label: '仿宋'
+          },
+          {
+            value: 'Arial, Helvetica, sans-serif',
+            label: 'Arial'
+          },
+          {
+            value: 'DIN Alternate, Arial Narrow, Arial, sans-serif',
+            label: 'DIN 数字'
+          },
+          {
+            value: 'Consolas, Monaco, monospace',
+            label: '等宽字体'
           }
         ]
       },
@@ -379,6 +535,25 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
         type: 'number',
         val: 18
       },
+      textAlign: {
+        title: '文字对齐',
+        type: 'select',
+        val: 'left',
+        options: [
+          {
+            value: 'left',
+            label: '左对齐'
+          },
+          {
+            value: 'center',
+            label: '居中'
+          },
+          {
+            value: 'right',
+            label: '右对齐'
+          }
+        ]
+      },
       label: {
         title: '键名',
         type: 'input',
@@ -408,6 +583,11 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
         title: '边框颜色',
         type: 'color',
         val: '#000000'
+      },
+      borderRadius: {
+        title: '圆角',
+        type: 'number',
+        val: 0
       }
     },
     common_animations: {
@@ -455,10 +635,85 @@ const sysComponentItems: ILeftAsideConfigItem[] = [
           }
         ]
       },
-      round: {
+      backgroundColor: {
+        title: '背景色',
+        type: 'color',
+        val: ''
+      },
+      borderColor: {
+        title: '边框颜色',
+        type: 'color',
+        val: ''
+      },
+      fontColor: {
+        title: '文字颜色',
+        type: 'color',
+        val: ''
+      },
+      fontFamily: {
+        title: '字体',
+        type: 'select',
+        val: '',
+        options: [
+          {
+            value: '',
+            label: '默认'
+          },
+          {
+            value: 'Microsoft YaHei, PingFang SC, sans-serif',
+            label: '微软雅黑'
+          },
+          {
+            value: 'SimSun, Songti SC, serif',
+            label: '宋体'
+          },
+          {
+            value: 'SimHei, Heiti SC, sans-serif',
+            label: '黑体'
+          },
+          {
+            value: 'PingFang SC, Microsoft YaHei, sans-serif',
+            label: '苹方'
+          },
+          {
+            value: 'Noto Sans SC, Microsoft YaHei, sans-serif',
+            label: '思源黑体'
+          },
+          {
+            value: 'YouSheBiaoTiHei, Microsoft YaHei, sans-serif',
+            label: '优设标题黑'
+          },
+          {
+            value: 'KaiTi, Kaiti SC, serif',
+            label: '楷体'
+          },
+          {
+            value: 'FangSong, STFangsong, serif',
+            label: '仿宋'
+          },
+          {
+            value: 'Arial, Helvetica, sans-serif',
+            label: 'Arial'
+          },
+          {
+            value: 'DIN Alternate, Arial Narrow, Arial, sans-serif',
+            label: 'DIN 数字'
+          },
+          {
+            value: 'Consolas, Monaco, monospace',
+            label: '等宽字体'
+          }
+        ]
+      },
+      fontSize: {
+        title: '文字大小',
+        type: 'number',
+        val: 14
+      },
+      borderRadius: {
         title: '圆角',
-        type: 'switch',
-        val: false
+        type: 'number',
+        val: 4
       }
     },
     common_animations: {

+ 58 - 23
src/components/mt-edit/store/global.ts

@@ -3,37 +3,44 @@ import type {
   GlobalStoreIntention,
   IDoneJson,
   IGlobalStore,
+  IGlobalStoreCanvasCfg,
   IGlobalStoreCreateItemInfo,
+  IGlobalStoreGridCfg,
   IRealTimeData
 } from './types'
+
+export const createDefaultCanvasCfg = (): IGlobalStoreCanvasCfg => ({
+  width: 1920,
+  height: 1080,
+  scale: 1,
+  color: '',
+  img: '',
+  guide: true,
+  adsorp: true,
+  adsorp_diff: 5,
+  transform_origin: {
+    x: 0,
+    y: 0
+  },
+  drag_offset: {
+    x: 0,
+    y: 0
+  }
+})
+
+export const createDefaultGridCfg = (): IGlobalStoreGridCfg => ({
+  enabled: true,
+  align: true,
+  size: 10
+})
+
 export const globalStore: IGlobalStore = reactive({
   intention: 'none',
   create_item_info: null,
   selected_items_id: [],
   done_json: [],
-  canvasCfg: {
-    width: 1920,
-    height: 1080,
-    scale: 1,
-    color: '',
-    img: '',
-    guide: true,
-    adsorp: true,
-    adsorp_diff: 5,
-    transform_origin: {
-      x: 0,
-      y: 0
-    },
-    drag_offset: {
-      x: 0,
-      y: 0
-    }
-  },
-  gridCfg: {
-    enabled: true,
-    align: true,
-    size: 10
-  },
+  canvasCfg: createDefaultCanvasCfg(),
+  gridCfg: createDefaultGridCfg(),
   guideCfg: {
     x: {
       display: false,
@@ -110,3 +117,31 @@ export const globalStore: IGlobalStore = reactive({
     globalStore.real_time_data = val
   }
 })
+
+export const resetGlobalStore = () => {
+  globalStore.intention = 'none'
+  globalStore.create_item_info = null
+  globalStore.selected_items_id = []
+  globalStore.done_json = []
+  globalStore.canvasCfg = createDefaultCanvasCfg()
+  globalStore.gridCfg = createDefaultGridCfg()
+  globalStore.guideCfg = {
+    x: {
+      display: false,
+      top: 0
+    },
+    y: {
+      display: false,
+      left: 0
+    }
+  }
+  globalStore.lock = false
+  globalStore.real_time_data = {
+    show: false,
+    text: ''
+  }
+  globalStore.adsorp_diff = {
+    x: 0,
+    y: 0
+  }
+}

+ 8 - 1
src/components/mt-edit/store/types.ts

@@ -15,6 +15,8 @@ export type ILeftAsideConfigItemPublicProps = Record<
     type: ILeftAsideConfigItemPublicPropsType //属性的类型决定了修改属性的方式
     val: any
     options?: any //比如说修改属性的时候用到了下拉框,这里面就可以放下拉框的选项
+    showAlpha?: boolean //颜色选择器是否支持透明度
+    colorFormat?: string //颜色选择器的输出格式
     disabled?: boolean //如果禁用了将不会显示到右侧属性面板里,但是仍然可以通过代码修改属性
   }
 >
@@ -88,19 +90,24 @@ export interface IGlobalStoreGridCfg {
   size: number
 }
 export type DoneJsonEventListType = 'click' | 'dblclick' | 'mouseover' | 'mouseout'
-export type DoneJsonEventListAction = 'changeAttr' | 'customCode'
+export type DoneJsonEventListAction = 'changeAttr' | 'customCode' | 'openWebtopo'
 export interface IDoneJsonActionChangeAttr {
   id: string
   target_id: string
   target_attr: string | undefined
   target_value: any
 }
+export interface IDoneJsonEventWebtopoProject {
+  id?: number
+  projectName?: string
+}
 export interface IDoneJsonEventList {
   id: string
   type: DoneJsonEventListType // 事件类型
   action: DoneJsonEventListAction // 事件行为
   change_attr: IDoneJsonActionChangeAttr[] //属性更改
   custom_code: string
+  webtopo_project?: IDoneJsonEventWebtopoProject
   trigger_rule: {
     trigger_id?: string //触发图形的id
     trigger_attr?: string //触发图形的属性

+ 19 - 14
src/components/mt-edit/utils/index.ts

@@ -835,6 +835,12 @@ export const eventToVOn = (item: IDoneJson) => {
   const event_obj: Record<string, string> = {}
   item.events.forEach((event) => {
     let code_str = ''
+    const has_trigger_rule =
+      event.trigger_rule &&
+      event.trigger_rule.trigger_id &&
+      event.trigger_rule.trigger_attr &&
+      event.trigger_rule.value !== undefined &&
+      event.trigger_rule.operator
     if (event.action === 'changeAttr') {
       if (event.change_attr.length < 1) {
         return
@@ -843,13 +849,7 @@ export const eventToVOn = (item: IDoneJson) => {
         if (!attr.target_id || !attr.target_attr || attr.target_value === undefined) {
           return
         }
-        if (
-          !event.trigger_rule ||
-          !event.trigger_rule.trigger_id ||
-          !event.trigger_rule.trigger_attr ||
-          event.trigger_rule.value === undefined ||
-          !event.trigger_rule.operator
-        ) {
+        if (!has_trigger_rule) {
           if (typeof attr.target_value == 'boolean') {
             code_str += `$setItemAttrByID('${attr.target_id}', '${attr.target_attr}', ${attr.target_value});`
           } else {
@@ -864,17 +864,22 @@ export const eventToVOn = (item: IDoneJson) => {
         }
       })
     } else if (event.action === 'customCode') {
-      if (
-        !event.trigger_rule ||
-        !event.trigger_rule.trigger_id ||
-        !event.trigger_rule.trigger_attr ||
-        event.trigger_rule.value === undefined ||
-        !event.trigger_rule.operator
-      ) {
+      if (!has_trigger_rule) {
         code_str += event.custom_code + ';'
       } else {
         code_str += `if($previewCompareVal($getItemAttrByID('${event.trigger_rule.trigger_id}', '${event.trigger_rule.trigger_attr}'), '${event.trigger_rule.operator}', '${event.trigger_rule.value}')){${event.custom_code}};`
       }
+    } else if (event.action === 'openWebtopo') {
+      const target_project_id = event.webtopo_project?.id
+      if (!target_project_id) {
+        return
+      }
+      const callback_code = `$mtEventCallBack('openWebtopo', '${item.id}', ${target_project_id});`
+      if (!has_trigger_rule) {
+        code_str += callback_code
+      } else {
+        code_str += `if($previewCompareVal($getItemAttrByID('${event.trigger_rule.trigger_id}', '${event.trigger_rule.trigger_attr}'), '${event.trigger_rule.operator}', '${event.trigger_rule.value}')){${callback_code}};`
+      }
     }
     if (!Object.prototype.hasOwnProperty.call(event_obj, event.type)) {
       event_obj[event.type] = code_str

+ 39 - 3
src/layout/components/Menu/src/components/useRenderMenuItem.tsx

@@ -77,6 +77,7 @@ export const useRenderMenuItem = () =>
                 '应急体系',
                 '突发事件应急处理',
                 '环境合规',
+                'QHSE月报管理',
                 '环境因素识别',
                 '职业健康',
                 '劳保用品',
@@ -98,29 +99,64 @@ export const useRenderMenuItem = () =>
                 '计量器具管理',
                 '计量器具台账',
                 '检测证书管控',
+                '计量器具报废',
+                '设备检测管控',
+                '应检设备台账',
+                '检测证书管理',
+                '质量统筹管控',
+                '施工质量管控',
+                '产品服务质量',
+                '客户满意度',
                 '安全风控',
                 '行为安全管理',
                 '分析数据源',
                 '安全行为观察',
                 '工艺安全管理',
+                '变更管理',
                 '危险源辨识',
                 '工作安全分析',
                 '高危作业许可',
                 '系统安全管理',
+                '体系建设认证',
+                '内审',
+                '管理评审',
+                '外审',
                 '特种作业人员',
                 '人员台账',
                 '人员证书',
-                '隐患排查',
+                '安全培训',
+                '安全培训计划',
+                '安全培训档案',
+
+                '隐患排查治理',
                 '隐患排查分类',
                 '隐患排查记录',
-                '应急体系',
+                '应急体系管理',
+                '排查治理分类',
+                '排查治理记录',
+                '物资台账',
+                '应急物资',
+                '物资管控检测',
+                '海外应急处置',
+                '应急演练',
                 '突发事件应急处理',
+                '安全绩效',
+                '安全考核',
                 '环境合规',
+                '危废处置',
+                '环境事件处置',
                 '环境因素识别',
                 '职业健康',
                 '劳保用品',
                 '职业健康体检',
-                '健康培训'
+                '健康培训',
+                '突发事件处置',
+                'QHSE月报管理',
+                '月报填报',
+                'QHSE资料库',
+                'QHSE资质证书',
+                'QHSE体系文件',
+                '事故案例'
               ].includes(v.meta?.title)
             )
           } else {

+ 11 - 14
src/router/modules/remaining.ts

@@ -777,8 +777,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
     component: Layout,
     name: 'PmsMaintenanceCenter',
     meta: {
-      hidden: true,
-      keepAlive: true
+      hidden: true
     },
     children: [
       {
@@ -797,10 +796,9 @@ const remainingRouter: AppRouteRecordRaw[] = [
       },
       {
         path: 'maintenanceplan/add',
-        component: () => import('@/views/pms/maintenance/IotMaintenancePlan.vue'),
+        component: () => import('@/views/pms/maintenance/maintenance-plan-manage.vue'),
         name: 'IotAddMainPlan',
         meta: {
-          keepAlive: true,
           noCache: false,
           hidden: true,
           canTo: true,
@@ -811,10 +809,9 @@ const remainingRouter: AppRouteRecordRaw[] = [
       },
       {
         path: 'maintenanceplan/edit/:id(\\d+)',
-        component: () => import('@/views/pms/maintenance/IotMaintenancePlanEdit.vue'),
+        component: () => import('@/views/pms/maintenance/maintenance-plan-manage.vue'),
         name: 'IotMainPlanEdit',
         meta: {
-          keepAlive: true,
           noCache: true,
           hidden: true,
           canTo: true,
@@ -825,7 +822,7 @@ const remainingRouter: AppRouteRecordRaw[] = [
       },
       {
         path: 'maintenanceplan/detail/:id(\\d+)',
-        component: () => import('@/views/pms/maintenance/IotMaintenancePlanDetail.vue'),
+        component: () => import('@/views/pms/maintenance/maintenance-plan-manage.vue'),
         name: 'IotMaintenancePlanDetail',
         meta: {
           noCache: true,
@@ -2270,26 +2267,26 @@ const remainingRouter: AppRouteRecordRaw[] = [
         }
       },
       {
-        path: 'month_report/add',
+        path: 'month_report/add/:id',
         component: () => import('@/views/pms/qhse/monthlyReport/MonthlyReportAdd.vue'),
         name: 'MonthlyReportAdd',
         meta: {
           noCache: true,
           canto: true,
           icon: 'ep:plus',
-          title: '新增月报',
+          title: '月报填报',
           hidden: true
         }
       },
       {
-        path: 'month_report/edit/:id',
-        component: () => import('@/views/pms/qhse/monthlyReport/MonthlyReportEdit.vue'),
-        name: 'MonthlyReportEdit',
+        path: 'month_report/detail/:id',
+        component: () => import('@/views/pms/qhse/monthlyReport/MonthlyReport.vue'),
+        name: 'MonthlyReportInfo',
         meta: {
           noCache: true,
           canto: true,
-          icon: 'ep:edit',
-          title: '编辑月报',
+          icon: 'ep:view',
+          title: '月报详情',
           hidden: true
         }
       }

+ 1 - 1
src/store/modules/app.ts

@@ -51,7 +51,7 @@ export const useAppStore = defineStore('app', {
       breadcrumb: true, // 面包屑
       breadcrumbIcon: true, // 面包屑图标
       collapse: false, // 折叠菜单
-      uniqueOpened: true, // 是否只保持一个子菜单的展开
+      uniqueOpened: false, // 是否只保持一个子菜单的展开
       hamburger: true, // 折叠图标
       screenfull: true, // 全屏图标
       search: true, // 搜索图标

+ 19 - 8
src/utils/routerHelper.ts

@@ -5,6 +5,23 @@ import { cloneDeep, omit } from 'lodash-es'
 import qs from 'qs'
 
 const modules = import.meta.glob('../views/**/*.{vue,tsx}')
+const getCurrentSource = () => sessionStorage.getItem('LOGIN_SOURCE') || ''
+
+const getRouteComponent = (route: AppCustomRouteRecordRaw, modulesRoutesKeys: string[]) => {
+  const index = route?.component
+    ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component))
+    : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path))
+
+  if (index >= 0) {
+    return modules[modulesRoutesKeys[index]]
+  }
+
+  if (getCurrentSource() === 'qhse_nav') {
+    return () => import('@/views/Error/ComingSoon.vue')
+  }
+
+  return undefined
+}
 /**
  * 注册一个异步组件
  * @param componentPath 例:/bpm/oa/leave/detail
@@ -115,10 +132,7 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
         redirect: route.redirect,
         meta: meta
       }
-      const index = route?.component
-        ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component))
-        : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path))
-      childrenData.component = modules[modulesRoutesKeys[index]]
+      childrenData.component = getRouteComponent(route, modulesRoutesKeys)
       data.children = [childrenData]
     } else {
       // 目录
@@ -138,10 +152,7 @@ export const generateRoute = (routes: AppCustomRouteRecordRaw[]): AppRouteRecord
         // 菜单
       } else {
         // 对后端传component组件路径和不传做兼容(如果后端传component组件路径,那么path可以随便写,如果不传,component组件路径会根path保持一致)
-        const index = route?.component
-          ? modulesRoutesKeys.findIndex((ev) => ev.includes(route.component))
-          : modulesRoutesKeys.findIndex((ev) => ev.includes(route.path))
-        data.component = modules[modulesRoutesKeys[index]]
+        data.component = getRouteComponent(route, modulesRoutesKeys)
       }
       if (route.children) {
         data.children = generateRoute(route.children)

+ 59 - 0
src/views/Error/ComingSoon.vue

@@ -0,0 +1,59 @@
+<template>
+  <div class="coming-soon">
+    <div class="coming-soon__card">
+      <div class="coming-soon__badge">QHSE</div>
+      <h1>功能正在开发中,敬请期待</h1>
+      <p>该功能暂未开放,后续版本将尽快补齐。</p>
+    </div>
+  </div>
+</template>
+
+<script lang="ts" setup>
+defineOptions({ name: 'ComingSoon' })
+</script>
+
+<style scoped>
+.coming-soon {
+  min-height: 100%;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 32px;
+}
+
+.coming-soon__card {
+  width: min(560px, 100%);
+  padding: 48px 40px;
+  border-radius: 20px;
+  background: var(--el-bg-color);
+  box-shadow: 0 12px 32px rgb(0 0 0 / 8%);
+  text-align: center;
+}
+
+.coming-soon__badge {
+  display: inline-flex;
+  align-items: center;
+  justify-content: center;
+  margin-bottom: 16px;
+  padding: 6px 12px;
+  border-radius: 999px;
+  background: var(--el-color-primary-light-9);
+  color: var(--el-color-primary);
+  font-size: 12px;
+  font-weight: 600;
+  letter-spacing: 0.08em;
+}
+
+h1 {
+  margin: 0;
+  font-size: 28px;
+  line-height: 1.25;
+  color: var(--el-text-color-primary);
+}
+
+p {
+  margin: 12px 0 0;
+  color: var(--el-text-color-secondary);
+  font-size: 14px;
+}
+</style>

+ 3 - 1
src/views/Home/Index.vue

@@ -2,6 +2,7 @@
 import { IotOpeationFillApi } from '@/api/pms/iotopeationfill'
 import { IotStatApi } from '@/api/pms/stat'
 import { useUserStore } from '@/store/modules/user'
+import Rdkb from '@/views/pms/stat/rdkb.vue'
 import Rhkb from '@/views/pms/stat/rhkb.vue'
 import Rykb from '@/views/pms/stat/rykb.vue'
 import Stat from './stat.vue'
@@ -15,13 +16,13 @@ const iframeLoading = ref(false)
 let iframeRequestId = 0
 
 const reportUrls: Record<string, string> = {
-  rd: 'https://report.deepoil.cc/webroot/decision/v10/entry/access/a12df128-c84f-44be-a55d-bababbf4a132?preview=true&page_number=1',
   jt: 'https://report.deepoil.cc/webroot/decision/v10/entry/access/dbc9cf73-81ce-43f1-9923-45cdfa5d5d3a?preview=true&page_number=1'
 }
 
 const currentCompany = computed(() => company.value?.toLowerCase())
 const currentView = computed(() => {
   if (companyLoading.value) return 'loading'
+  if (currentCompany.value === 'rd') return 'rd'
   if (currentCompany.value === 'rh') return 'rh'
   if (currentCompany.value === 'ry') return 'ry'
   if (currentCompany.value && reportUrls[currentCompany.value]) return 'report'
@@ -80,6 +81,7 @@ watch(
       class="full-screen-iframe"
       allowfullscreen></iframe>
   </div>
+  <Rdkb v-else-if="currentView === 'rd'" />
   <Rhkb v-else-if="currentView === 'rh'" />
   <Rykb v-else-if="currentView === 'ry'" />
   <Stat v-else />

+ 12 - 2
src/views/maotu/edit.vue

@@ -10,7 +10,7 @@ import { MtEdit } from '@/export'
 import { Canvg } from 'canvg'
 import html2canvas from 'html2canvas'
 import { ElMessage } from 'element-plus'
-import { nextTick, onMounted, ref } from 'vue'
+import { nextTick, onMounted, ref, watch } from 'vue'
 import { useRoute, useRouter } from 'vue-router'
 import DeviceBindPanel from './components/DeviceBindPanel.vue'
 const route = useRoute()
@@ -54,6 +54,14 @@ const loadProject = async () => {
   }
 }
 
+const resetAndLoadProject = async () => {
+  await nextTick()
+  MtEditRef.value?.reset()
+  projectDetail.value = undefined
+  dataModelParseError.value = ''
+  await loadProject()
+}
+
 const genThumbnailDataUrl = async (canvasId = 'mtCanvasArea') => {
   const el = document.querySelector<HTMLElement>(`#${canvasId}`)
   if (!el) {
@@ -152,8 +160,10 @@ const onThumbnailClick = () => {
 }
 
 onMounted(() => {
-  loadProject()
+  resetAndLoadProject()
 })
+
+watch(() => route.params.id, resetAndLoadProject)
 </script>
 
 <template>

+ 4 - 2
src/views/maotu/index.vue

@@ -224,11 +224,13 @@ function getLinkedDeviceCount(row: WebtopoProjectVO) {
 }
 
 function handleEdit(row: WebtopoProjectVO) {
-  router.push(`/maotu/edit/${row.id}`)
+  const routeInfo = router.resolve(`/maotu/edit/${row.id}`)
+  window.open(routeInfo.href, '_blank')
 }
 
 function handlePreview(row: WebtopoProjectVO) {
-  router.push(`/maotu/preview/${row.id}`)
+  const routeInfo = router.resolve(`/maotu/preview/${row.id}`)
+  window.open(routeInfo.href, '_blank')
 }
 
 function handleConfig(row: WebtopoProjectVO) {

+ 15 - 3
src/views/maotu/preview.vue

@@ -7,10 +7,11 @@ import { IotDeviceApi } from '@/api/pms/device'
 import type { IExportJson } from '@/components/mt-edit/components/types'
 import { MtPreview } from '@/export'
 import { ElMessage } from 'element-plus'
-import { nextTick, onMounted, onUnmounted, ref } from 'vue'
-import { useRoute } from 'vue-router'
+import { nextTick, onMounted, onUnmounted, ref, watch } from 'vue'
+import { useRoute, useRouter } from 'vue-router'
 
 const route = useRoute()
+const router = useRouter()
 const MtPreviewRef = ref<InstanceType<typeof MtPreview>>()
 const previewData = ref<IExportJson>()
 const pollingTimer = ref<number>()
@@ -120,6 +121,7 @@ const stopDeviceBindPolling = () => {
 }
 
 const loadProject = async () => {
+  stopDeviceBindPolling()
   const id = getProjectId()
   if (!id) {
     const exportJson = sessionStorage.getItem('exportJson')
@@ -141,11 +143,14 @@ const loadProject = async () => {
   }
 }
 
-const onEventCallBack = (type: string, item_id: string) => {
+const onEventCallBack = (type: string, item_id: string, targetProjectId?: number) => {
   console.log(type, item_id)
 
   if (type == 'test-dialog') {
     ElMessage.success(`获取到了id:${item_id}`)
+  } else if (type == 'openWebtopo' && targetProjectId) {
+    const routeInfo = router.resolve(`/maotu/preview/${targetProjectId}`)
+    window.open(routeInfo.href, '_blank')
   }
 }
 
@@ -153,6 +158,13 @@ onMounted(() => {
   loadProject()
 })
 
+watch(
+  () => route.params.id,
+  () => {
+    loadProject()
+  }
+)
+
 onUnmounted(() => {
   stopDeviceBindPolling()
 })

+ 12 - 24
src/views/pms/device/DeviceInfo.vue

@@ -7,8 +7,7 @@
           :src="defaultPicUrl"
           style="width: 35em; height: 12em"
           @click="imagePreview(defaultPicUrl)"
-          fit="contain"
-        />
+          fit="contain" />
       </div>
       <div style="flex: 2; height: 12em; margin-top: 23px">
         <el-form ref="formRef" :disabled="false" :model="formData" label-width="120px">
@@ -169,8 +168,7 @@
           ref="fileRef"
           :deviceId="id"
           :deviceName="formData.deviceName"
-          v-if="loadedTabs.includes('1')"
-        />
+          v-if="loadedTabs.includes('1')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.deviceBOM')" name="bom">
         <BomList
@@ -178,72 +176,63 @@
           v-model:activeName="activeName"
           :deviceId="id"
           :deviceCategoryName="formData.assetClassName"
-          v-if="loadedTabs.includes('2')"
-        />
+          v-if="loadedTabs.includes('2')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.operationRecords')" name="record">
         <RecordList
           ref="recordRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('3')"
-        />
+          v-if="loadedTabs.includes('3')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.faultRecords')" name="failure">
         <FailureList
           ref="failureRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('4')"
-        />
+          v-if="loadedTabs.includes('4')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.repairRecords')" name="maintain">
         <MaintainList
           ref="maintainRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('5')"
-        />
+          v-if="loadedTabs.includes('5')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.maintenanceRecords')" name="maintenance">
         <MaintenanceList
           ref="maintenanceRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('6')"
-        />
+          v-if="loadedTabs.includes('6')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.inspectionRecords')" name="inspect">
         <InspectList
           ref="inspectRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('7')"
-        />
+          v-if="loadedTabs.includes('7')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.transferRecords')" name="allot">
         <AllotLogList
           ref="allotRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('8')"
-        />
+          v-if="loadedTabs.includes('8')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.statusChangeRecords')" name="status">
         <DeviceStatusLogList
           ref="statusRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('9')"
-        />
+          v-if="loadedTabs.includes('9')" />
       </el-tab-pane>
       <el-tab-pane :label="t('deviceInfo.RPAdjustmentRecords')" name="person">
         <PersonList
           ref="personRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('10')"
-        />
+          v-if="loadedTabs.includes('10')" />
       </el-tab-pane>
 
       <!-- 关联设备 -->
@@ -252,8 +241,7 @@
           ref="personRef"
           v-model:activeName="activeName"
           :deviceId="id"
-          v-if="loadedTabs.includes('11')"
-        />
+          v-if="loadedTabs.includes('11')" />
       </el-tab-pane>
     </el-tabs>
   </ContentWrap>

+ 178 - 183
src/views/pms/device/maintenance/MaintenanceDetail.vue

@@ -5,30 +5,33 @@
       :model="formData"
       :rules="formRules"
       v-loading="formLoading"
-      style="margin-right: 4em; margin-left: 0.5em; margin-top: 1em"
-      label-width="130px"
-    >
+      style="margin-top: 1em; margin-right: 4em; margin-left: 0.5em"
+      label-width="130px">
       <div class="base-expandable-content">
         <el-row>
           <el-col :span="8">
             <el-form-item label="工单名称" prop="name">
-              <el-input type="text" v-model="formData.name" disabled/>
+              <el-input type="text" v-model="formData.name" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item label="工单编号" prop="orderNumber">
-              <el-input type="text" v-model="formData.orderNumber" disabled/>
+              <el-input type="text" v-model="formData.orderNumber" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item label="责任人" prop="responsiblePerson">
-              <el-select v-model="formData.responsiblePerson" filterable clearable style="width: 100%" disabled>
+              <el-select
+                v-model="formData.responsiblePerson"
+                filterable
+                clearable
+                style="width: 100%"
+                disabled>
                 <el-option
                   v-for="item in deptUsers"
                   :key="item.id"
                   :label="item.nickname"
-                  :value="item.id"
-                />
+                  :value="item.id" />
               </el-select>
             </el-form-item>
           </el-col>
@@ -39,8 +42,7 @@
                   v-for="dict in getIntDictOptions(DICT_TYPE.PMS_MAIN_WORK_ORDER_TYPE)"
                   :key="dict.value"
                   :label="dict.label"
-                  :value="dict.value"
-                />
+                  :value="dict.value" />
               </el-select>
             </el-form-item>
           </el-col>
@@ -51,14 +53,13 @@
                   v-for="dict in getIntDictOptions(DICT_TYPE.PMS_MAIN_WORK_ORDER_RESULT)"
                   :key="dict.value"
                   :label="dict.label"
-                  :value="dict.value"
-                />
+                  :value="dict.value" />
               </el-select>
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item label="保养费用(元)" prop="cost">
-              <el-input type="text" v-model="formData.cost" disabled/>
+              <el-input type="text" v-model="formData.cost" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -69,8 +70,7 @@
                 type="datetime"
                 value-format="x"
                 placeholder="实际保养开始时间"
-                disabled
-              />
+                disabled />
             </el-form-item>
           </el-col>
           <el-col :span="8">
@@ -81,18 +81,21 @@
                 type="datetime"
                 value-format="x"
                 placeholder="实际保养结束时间"
-                disabled
-              />
+                disabled />
             </el-form-item>
           </el-col>
           <el-col :span="8">
             <el-form-item label="其他费用(元)" prop="otherCost">
-              <el-input type="text" v-model="formData.otherCost" disabled/>
+              <el-input type="text" v-model="formData.otherCost" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="24">
             <el-form-item label="备注" prop="remark">
-              <el-input v-model="formData.remark" type="textarea" placeholder="请输入备注" disabled/>
+              <el-input
+                v-model="formData.remark"
+                type="textarea"
+                placeholder="请输入备注"
+                disabled />
             </el-form-item>
           </el-col>
         </el-row>
@@ -107,8 +110,7 @@
         :model="queryParams"
         ref="queryFormRef"
         :inline="true"
-        label-width="68px"
-      >
+        label-width="68px">
         <!--
         <el-form-item>
           <el-button @click="openForm" type="warning">
@@ -122,17 +124,20 @@
     <ContentWrap>
       <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
         <!-- 添加序号列 -->
-        <el-table-column
-          type="index"
-          label="序号"
-          width="60"
-          align="center"
-        />
-        <el-table-column label="bom节点" align="center" prop="bomNodeId" v-if="false"/>
+        <el-table-column type="index" label="序号" width="60" align="center" />
+        <el-table-column label="bom节点" align="center" prop="bomNodeId" v-if="false" />
         <el-table-column label="设备编码" align="center" prop="deviceCode" />
         <el-table-column label="设备名称" align="center" prop="deviceName" />
-        <el-table-column label="累计运行时间(H)" align="center" prop="totalRunTime" :formatter="erpPriceTableColumnFormatter"/>
-        <el-table-column label="累计运行公里数(KM)" align="center" prop="totalMileage" :formatter="erpPriceTableColumnFormatter"/>
+        <el-table-column
+          label="累计运行时间(H)"
+          align="center"
+          prop="totalRunTime"
+          :formatter="erpPriceTableColumnFormatter" />
+        <el-table-column
+          label="累计运行公里数(KM)"
+          align="center"
+          prop="totalMileage"
+          :formatter="erpPriceTableColumnFormatter" />
         <el-table-column label="保养项" align="center" prop="name" />
         <el-table-column label="运行里程" key="mileageRule" width="80">
           <template #default="scope">
@@ -140,8 +145,7 @@
               v-model="scope.row.mileageRule"
               :active-value="0"
               :inactive-value="1"
-              :disabled="true"
-            />
+              :disabled="true" />
           </template>
         </el-table-column>
         <el-table-column label="运行时间" key="runningTimeRule" width="80">
@@ -150,8 +154,7 @@
               v-model="scope.row.runningTimeRule"
               :active-value="0"
               :inactive-value="1"
-              :disabled="true"
-            />
+              :disabled="true" />
           </template>
         </el-table-column>
         <el-table-column label="自然日期" key="naturalDateRule" width="80">
@@ -160,8 +163,7 @@
               v-model="scope.row.naturalDateRule"
               :active-value="0"
               :inactive-value="1"
-              :disabled="true"
-            />
+              :disabled="true" />
           </template>
         </el-table-column>
         <el-table-column label="已选物料" align="center" width="100">
@@ -174,11 +176,7 @@
             <div style="display: flex; justify-content: center; align-items: center; width: 100%">
               <!-- 新增配置按钮 -->
               <div style="margin-left: 12px">
-                <el-button
-                  link
-                  type="primary"
-                  @click="openConfigDialog(scope.row)"
-                >
+                <el-button link type="primary" @click="openConfigDialog(scope.row)">
                   配置
                 </el-button>
               </div>
@@ -197,8 +195,7 @@
                 <el-button
                   link
                   type="primary"
-                  @click="handleView(scope.row.id, scope.row.bomNodeId)"
-                >
+                  @click="handleView(scope.row.id, scope.row.bomNodeId)">
                   物料详情
                 </el-button>
               </div>
@@ -210,17 +207,25 @@
 
     <!-- 选择的物料列表 -->
     <ContentWrap>
-      <el-table v-loading="false" :data="materialList" :stripe="true" :show-overflow-tooltip="true" v-if="false">
+      <el-table
+        v-loading="false"
+        :data="materialList"
+        :stripe="true"
+        :show-overflow-tooltip="true"
+        v-if="false">
         <el-table-column label="bom节点" align="center" prop="bomNodeId" />
         <el-table-column label="物料编码" align="center" prop="materialCode" />
         <el-table-column label="物料名称" align="center" prop="materialName" />
         <el-table-column label="单位" align="center" prop="unit" />
-        <el-table-column label="单价(CNY/元)" align="center" prop="unitPrice" :formatter="erpPriceTableColumnFormatter"/>
+        <el-table-column
+          label="单价(CNY/元)"
+          align="center"
+          prop="unitPrice"
+          :formatter="erpPriceTableColumnFormatter" />
         <el-table-column label="消耗数量" align="center" prop="quantity" />
         <el-table-column label="总库存数量" align="center" prop="totalInventoryQuantity" />
       </el-table>
     </ContentWrap>
-
   </ContentWrap>
   <ContentWrap>
     <el-form>
@@ -232,170 +237,149 @@
   <!-- 新增配置对话框 -->
   <el-dialog
     v-model="configDialog.visible"
-    :title="`设备 ${configDialog.current?.deviceCode+'-'+configDialog.current?.name} 保养配置`"
-    width="600px"
-  >
-    <el-form :model="configDialog.form" label-width="200px" :rules="configFormRules" ref="configFormRef">
+    :title="`设备 ${configDialog.current?.deviceCode + '-' + configDialog.current?.name} 保养配置`"
+    width="600px">
+    <el-form
+      :model="configDialog.form"
+      label-width="200px"
+      :rules="configFormRules"
+      ref="configFormRef">
       <!-- 里程配置 -->
       <el-form-item
         v-if="configDialog.current?.mileageRule === 0"
         label="上次保养里程数(KM)"
-        prop="lastRunningKilometers"
-      >
+        prop="lastRunningKilometers">
         <el-input-number
           v-model="configDialog.form.lastRunningKilometers"
           :precision="2"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <!-- 推迟公里数 -->
       <el-form-item
         v-if="configDialog.current?.mileageRule === 0"
         label="推迟公里数(KM)"
-        prop="delayKilometers"
-      >
+        prop="delayKilometers">
         <el-input-number
           v-model="configDialog.form.delayKilometers"
           :precision="2"
           :min="0"
-          controls-position="right"
-        />
+          controls-position="right" />
       </el-form-item>
       <!-- 运行时间配置 -->
       <el-form-item
         v-if="configDialog.current?.runningTimeRule === 0"
         label="上次保养运行时间(H)"
-        prop="lastRunningTime"
-      >
+        prop="lastRunningTime">
         <el-input-number
           v-model="configDialog.form.lastRunningTime"
           :precision="1"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <!-- 推迟时长 -->
       <el-form-item
         v-if="configDialog.current?.runningTimeRule === 0"
         label="推迟时长(H)"
-        prop="delayDuration"
-      >
+        prop="delayDuration">
         <el-input-number
           v-model="configDialog.form.delayDuration"
           :precision="2"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <!-- 自然日期配置 -->
       <el-form-item
         v-if="configDialog.current?.naturalDateRule === 0"
         label="上次保养自然日期(D)"
-        prop="lastNaturalDate"
-      >
+        prop="lastNaturalDate">
         <el-date-picker
           v-model="configDialog.form.lastNaturalDate"
           type="date"
           placeholder="选择日期"
           format="YYYY-MM-DD"
           value-format="YYYY-MM-DD"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <!-- 推迟自然日期 -->
       <el-form-item
         v-if="configDialog.current?.naturalDateRule === 0"
         label="推迟自然日期(D)"
-        prop="delayNaturalDate"
-      >
+        prop="delayNaturalDate">
         <el-input-number
           v-model="configDialog.form.delayNaturalDate"
           :precision="2"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <!-- 保养规则周期值 + 提前量 -->
       <el-form-item
         v-if="configDialog.current?.mileageRule === 0"
         label="运行里程周期(KM)"
-        prop="nextRunningKilometers"
-      >
+        prop="nextRunningKilometers">
         <el-input-number
           v-model="configDialog.form.nextRunningKilometers"
           :precision="2"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <el-form-item
         v-if="configDialog.current?.mileageRule === 0"
         label="运行里程周期-提前量(KM)"
-        prop="kiloCycleLead"
-      >
+        prop="kiloCycleLead">
         <el-input-number
           v-model="configDialog.form.kiloCycleLead"
           :precision="2"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <el-form-item
         v-if="configDialog.current?.runningTimeRule === 0"
         label="运行时间周期(H)"
-        prop="nextRunningTime"
-      >
+        prop="nextRunningTime">
         <el-input-number
           v-model="configDialog.form.nextRunningTime"
           :precision="1"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <el-form-item
         v-if="configDialog.current?.runningTimeRule === 0"
         label="运行时间周期-提前量(H)"
-        prop="timePeriodLead"
-      >
+        prop="timePeriodLead">
         <el-input-number
           v-model="configDialog.form.timePeriodLead"
           :precision="1"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <el-form-item
         v-if="configDialog.current?.naturalDateRule === 0"
         label="自然日周期(D)"
-        prop="nextNaturalDate"
-      >
+        prop="nextNaturalDate">
         <el-input-number
           v-model="configDialog.form.nextNaturalDate"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
       <el-form-item
         v-if="configDialog.current?.naturalDateRule === 0"
         label="自然日周期-提前量(D)"
-        prop="naturalDatePeriodLead"
-      >
+        prop="naturalDatePeriodLead">
         <el-input-number
           v-model="configDialog.form.naturalDatePeriodLead"
           :min="0"
           controls-position="right"
-          :disabled="true"
-        />
+          :disabled="true" />
       </el-form-item>
     </el-form>
     <template #footer>
@@ -407,33 +391,28 @@
   <!-- 抽屉组件 展示已经选择的物料 并编辑物料消耗 -->
   <MaterialListDrawer
     :model-value="drawerVisible"
-    @update:model-value="val => drawerVisible = val"
+    @update:model-value="(val) => (drawerVisible = val)"
     :node-id="currentBomNodeId"
-    :materials="materialList.filter(item => item.bomNodeId === currentBomNodeId)"
-  />
+    :materials="materialList.filter((item) => item.bomNodeId === currentBomNodeId)" />
 </template>
 <script setup lang="ts">
-import { IotMaintainApi, IotMaintainVO } from '@/api/pms/maintain'
-import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
 import * as UserApi from '@/api/system/user'
 import { useUserStore } from '@/store/modules/user'
 import { ref } from 'vue'
-import type { ComponentPublicInstance } from 'vue'
-import { IotMaintenanceBomApi, IotMaintenanceBomVO } from '@/api/pms/iotmaintenancebom'
-import { IotMainWorkOrderBomApi, IotMainWorkOrderBomVO } from '@/api/pms/iotmainworkorderbom'
-import { IotMainWorkOrderBomMaterialApi, IotMainWorkOrderBomMaterialVO } from '@/api/pms/iotmainworkorderbommaterial'
-import { IotMaintenancePlanApi, IotMaintenancePlanVO } from '@/api/pms/maintenance'
-import { IotMainWorkOrderApi, IotMainWorkOrderVO } from '@/api/pms/iotmainworkorder'
+import { IotMaintenanceBomVO } from '@/api/pms/iotmaintenancebom'
+import { IotMainWorkOrderBomVO } from '@/api/pms/iotmainworkorderbom'
+import {
+  IotMainWorkOrderBomMaterialApi,
+  IotMainWorkOrderBomMaterialVO
+} from '@/api/pms/iotmainworkorderbommaterial'
+import { IotMainWorkOrderApi } from '@/api/pms/iotmainworkorder'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import {CACHE_KEY, useCache} from "@/hooks/web/useCache";
-import MainPlanDeviceList from "@/views/pms/maintenance/MainPlanDeviceList.vue";
-import * as DeptApi from "@/api/system/dept";
-import {erpPriceTableColumnFormatter} from "@/utils";
+import * as DeptApi from '@/api/system/dept'
+import { erpPriceTableColumnFormatter } from '@/utils'
 import dayjs from 'dayjs'
-import MaterialListDrawer from "@/views/pms/iotmainworkorder/SelectedMaterialDrawer.vue";
-import WorkOrderMaterial from "@/views/pms/iotmainworkorder/WorkOrderMaterial.vue";
-import {IotMaintainMaterialsApi} from "@/api/pms/maintain/materials";
-import {DICT_TYPE, getIntDictOptions, getStrDictOptions} from "@/utils/dict";
+import MaterialListDrawer from '@/views/pms/iotmainworkorder/SelectedMaterialDrawer.vue'
+import WorkOrderMaterial from '@/views/pms/iotmainworkorder/WorkOrderMaterial.vue'
+import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
 
 /** 保养计划 表单 */
 defineOptions({ name: 'IotMainWorkOrderDetail' })
@@ -468,11 +447,11 @@ const formData = ref({
   type: undefined,
   result: undefined,
   remark: undefined,
-  status: undefined,
+  status: undefined
 })
 const formRules = reactive({
   name: [{ required: true, message: '工单名称不能为空', trigger: 'blur' }],
-  responsiblePerson: [{ required: true, message: '责任人不能为空', trigger: 'blur' }],
+  responsiblePerson: [{ required: true, message: '责任人不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
 
@@ -480,7 +459,7 @@ interface MaterialFormExpose {
   open: (deptId: number, bomNodeId: number) => void
 }
 
-const materialFormRef = ref<MaterialFormExpose>();
+const materialFormRef = ref<MaterialFormExpose>()
 
 // 新增配置相关状态
 const configDialog = reactive({
@@ -539,7 +518,7 @@ const openConfigDialog = (row: IotMainWorkOrderBomVO) => {
 
 // const materialFormRef = ref()
 const openMaterialForm = (row: any) => {
-  bomNodeId.value = row.bomNodeId;
+  bomNodeId.value = row.bomNodeId
   console.log('这是一个对象:', row.bomNodeId)
   materialFormRef.value.open(formData.value.deptId, bomNodeId.value)
 }
@@ -547,23 +526,22 @@ const openMaterialForm = (row: any) => {
 const selectChoose = (selectedMaterial) => {
   selectedMaterial.bomNodeId = bomNodeId.value
   // 关联 bomNodeId
-  const processedMaterials = selectedMaterial.map(material => ({
+  const processedMaterials = selectedMaterial.map((material) => ({
     ...material,
     bomNodeId: bomNodeId.value // 统一关联当前行的 bomNodeId
-  }));
+  }))
 
   // 避免重复添加
-  processedMaterials.forEach(newMaterial => {
+  processedMaterials.forEach((newMaterial) => {
     // 检查是否已存在相同 bomNodeId + materialCode 的条目
-    const isExist = materialList.value.some(item =>
-      item.bomNodeId === bomNodeId.value &&
-      item.materialCode === newMaterial.materialCode
-    );
+    const isExist = materialList.value.some(
+      (item) => item.bomNodeId === bomNodeId.value && item.materialCode === newMaterial.materialCode
+    )
 
     if (!isExist) {
-      materialList.value.push(newMaterial);
+      materialList.value.push(newMaterial)
     }
-  });
+  })
   console.log('选择完成的数据:', JSON.stringify(selectedMaterial))
   console.log('添加到本地列表的数据:', materialList.value)
 }
@@ -587,12 +565,12 @@ const handleView = (nodeId, bomId) => {
 }
 
 const hasMaterial = (bomNodeId: number) => {
-  return materialList.value.some(item => item.bomNodeId === bomNodeId)
+  return materialList.value.some((item) => item.bomNodeId === bomNodeId)
 }
 
 // 保存配置
 const saveConfig = () => {
-  (configFormRef.value as any).validate((valid: boolean) => {
+  ;(configFormRef.value as any).validate((valid: boolean) => {
     if (!valid) return
     if (!configDialog.current) return
 
@@ -608,8 +586,8 @@ const saveConfig = () => {
       requiredFields.push('nextNaturalDate', 'naturalDatePeriodLead')
     }
 
-    const missingFields = requiredFields.filter(field =>
-      !configDialog.form[field as keyof typeof configDialog.form]
+    const missingFields = requiredFields.filter(
+      (field) => !configDialog.form[field as keyof typeof configDialog.form]
     )
 
     if (missingFields.length > 0) {
@@ -652,7 +630,7 @@ const queryParams = reactive({
 
 const close = () => {
   delView(unref(currentRoute))
-  push({ name: 'IotMainWorkOrder', params:{}})
+  push({ name: 'IotMainWorkOrder', params: {} })
 }
 
 /** 提交表单 */
@@ -693,46 +671,58 @@ const submitForm = async () => {
 
 // 新增表单校验规则
 const configFormRules = reactive({
-  nextRunningKilometers: [{
-    required: true,
-    message: '里程周期必须填写',
-    trigger: 'blur'
-  }],
-  kiloCycleLead: [{
-    required: true,
-    message: '提前量必须填写',
-    trigger: 'blur'
-  }],
-  nextRunningTime: [{
-    required: true,
-    message: '时间周期必须填写',
-    trigger: 'blur'
-  }],
-  timePeriodLead: [{
-    required: true,
-    message: '提前量必须填写',
-    trigger: 'blur'
-  }],
-  nextNaturalDate: [{
-    required: true,
-    message: '自然日周期必须填写',
-    trigger: 'blur'
-  }],
-  naturalDatePeriodLead: [{
-    required: true,
-    message: '提前量必须填写',
-    trigger: 'blur'
-  }]
+  nextRunningKilometers: [
+    {
+      required: true,
+      message: '里程周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  kiloCycleLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ],
+  nextRunningTime: [
+    {
+      required: true,
+      message: '时间周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  timePeriodLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ],
+  nextNaturalDate: [
+    {
+      required: true,
+      message: '自然日周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  naturalDatePeriodLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ]
 })
 
 /** 校验表格数据 */
 const validateTableData = (): boolean => {
   let isValid = true
   const errorMessages: string[] = []
-  const noRulesErrorMessages: string[] = []  // 未设置任何保养项规则 的错误提示信息
-  const noRules: string[] = []  // 行记录中设置了保养规则的记录数量
-  const configErrors: string[] = []   // 保养规则配置弹出框
-  let shouldBreak = false;
+  const noRulesErrorMessages: string[] = [] // 未设置任何保养项规则 的错误提示信息
+  const noRules: string[] = [] // 行记录中设置了保养规则的记录数量
+  const configErrors: string[] = [] // 保养规则配置弹出框
+  let shouldBreak = false
 
   if (list.value.length === 0) {
     errorMessages.push('请至少添加一条设备保养明细')
@@ -743,20 +733,24 @@ const validateTableData = (): boolean => {
   }
 
   list.value.forEach((row, index) => {
-    if (shouldBreak) return;
+    if (shouldBreak) return
     const rowNumber = index + 1 // 用户可见的行号从1开始
     const deviceIdentifier = `${row.deviceCode}-${row.name}` // 设备标识
     // 校验逻辑
     const checkConfig = (ruleName: string, ruleValue: number, configField: keyof typeof row) => {
-      if (ruleValue === 0) { // 规则开启
+      if (ruleValue === 0) {
+        // 规则开启
         if (!row[configField] || row[configField] <= 0) {
-          configErrors.push(`第 ${rowNumber} 行(${deviceIdentifier}):请点击【配置】维护${ruleName}上次保养值`)
+          configErrors.push(
+            `第 ${rowNumber} 行(${deviceIdentifier}):请点击【配置】维护${ruleName}上次保养值`
+          )
           isValid = false
         }
       }
     }
     // 里程校验逻辑
-    if (row.mileageRule === 0) { // 假设 0 表示开启状态
+    if (row.mileageRule === 0) {
+      // 假设 0 表示开启状态
       if (!row.nextRunningKilometers || row.nextRunningKilometers <= 0) {
         errorMessages.push(`第 ${rowNumber} 行:开启里程规则必须填写有效的里程周期`)
         isValid = false
@@ -789,10 +783,10 @@ const validateTableData = (): boolean => {
     // 如果选中的一行记录未设置任何保养规则 提示 ‘保养项未设置任何保养规则’
     if (noRules.length === 3) {
       isValid = false
-      shouldBreak = true; // 设置标志变量为true,退出循环
+      shouldBreak = true // 设置标志变量为true,退出循环
       noRulesErrorMessages.push('保养项至少设置1个保养规则')
     }
-    noRules.length = 0;
+    noRules.length = 0
   })
   if (errorMessages.length > 0) {
     message.error('设置保养规则后,请维护对应的周期值')
@@ -829,17 +823,17 @@ onMounted(async () => {
   deptUsers.value = await UserApi.getDeptUsersByDeptId(deptId)
   formData.value.deptId = deptId
   // if (id){
-  try{
+  try {
     formType.value = 'update'
-    const workOrder = await IotMainWorkOrderApi.getDeviceIotWorkOrder(orderId, deviceId);
+    const workOrder = await IotMainWorkOrderApi.getDeviceIotWorkOrder(orderId, deviceId)
     formData.value = workOrder
     // 查询保养责任人
-    const personId = formData.value.responsiblePerson ? Number(formData.value.responsiblePerson) : 0;
+    const personId = formData.value.responsiblePerson ? Number(formData.value.responsiblePerson) : 0
     UserApi.getUser(personId).then((res) => {
-      formData.value.responsiblePerson = res.nickname;
+      formData.value.responsiblePerson = res.nickname
     })
 
-    const data = workOrder.workOrderBomS;
+    const data = workOrder.workOrderBomS
 
     // // 查询保养工单 主表数据
     // const workOrder = await IotMainWorkOrderApi.getIotMainWorkOrder(id);
@@ -853,10 +847,12 @@ onMounted(async () => {
     // const data = await IotMainWorkOrderBomApi.getWorkOrderBOMs(queryParams);
     list.value = []
     if (Array.isArray(data)) {
-      list.value = data.map(item => ({
+      list.value = data.map((item) => ({
         ...item,
         // 这里可以添加必要的字段转换(如果有日期等需要格式化的字段)
-        lastNaturalDate: item.lastNaturalDate ? dayjs(item.lastNaturalDate).format('YYYY-MM-DD') : null
+        lastNaturalDate: item.lastNaturalDate
+          ? dayjs(item.lastNaturalDate).format('YYYY-MM-DD')
+          : null
       }))
     }
   } catch (error) {
@@ -864,7 +860,6 @@ onMounted(async () => {
     message.error('数据加载失败,请重试')
   }
 })
-
 </script>
 <style scoped>
 .base-expandable-content {

+ 1 - 1
src/views/pms/device/monitor/index.vue

@@ -326,7 +326,7 @@ const queryParams = reactive({
   templateJson: undefined,
   creator: undefined,
   ifInline: undefined,
-  source: undefined
+  source: 'gateway'
 })
 const queryFormRef = ref() // 搜索的表单
 const exportLoading = ref(false) // 导出加载状态

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 396 - 253
src/views/pms/iotmainworkorder/IotMainWorkOrderAdd.vue


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 360 - 192
src/views/pms/iotmainworkorder/IotMainWorkOrderDetail.vue


파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 222 - 182
src/views/pms/iotmainworkorder/IotMainWorkOrderOptimize.vue


+ 9 - 8
src/views/pms/iotrddailyreport/components/DailyStatistics.vue

@@ -447,28 +447,29 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
 </script>
 
 <template>
-  <div class="grid grid-rows-[128px_1fr] gap-4 h-full min-h-0">
-    <div class="grid grid-cols-9 gap-8" v-loading="totalLoading">
+  <div class="grid grid-rows-[96px_1fr] gap-2 h-full min-h-0">
+    <div class="grid grid-cols-9 gap-4" v-loading="totalLoading">
       <div
         v-for="info in totalWorkKeys"
         :key="info[0]"
-        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-1 flex flex-col items-center justify-center gap-1">
-        <div class="size-7.5" :class="info[3]"></div>
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-1 py-2 flex flex-col items-center justify-center gap-0.5 min-w-0">
+        <div class="size-6.5" :class="info[3]"></div>
         <count-to
-          class="text-2xl font-medium"
+          class="text-xl font-medium leading-6"
           :start-val="0"
           :end-val="totalWork[info[0]]"
           :decimals="info[4]">
           <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
         </count-to>
-        <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+        <div
+          class="text-xs font-medium leading-5 text-[var(--el-text-color-regular)] whitespace-nowrap">
           {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
         </div>
       </div>
     </div>
 
-    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2 min-h-0">
-      <div class="flex h-12 items-center justify-between">
+    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-3 gap-2 min-h-0">
+      <div class="flex h-10 items-center justify-between">
         <el-button-group>
           <el-button
             size="default"

+ 32 - 10
src/views/pms/iotrddailyreport/summary.vue

@@ -57,7 +57,7 @@ const resetQuery = () => {
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_48px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    class="grid grid-cols-[auto_1fr] grid-rows-[48px_44px_1fr] gap-3 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <DeptTreeSelect
       :deptId="id"
       :top-id="163"
@@ -65,16 +65,16 @@ const resetQuery = () => {
       @node-click="handleDeptNodeClick"
       class="row-span-3" />
     <el-form
-      size="default"
-      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between">
-      <div class="flex items-center gap-8">
+      size="small"
+      class="summary-query-form bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-5 flex items-center justify-between">
+      <div class="flex items-center gap-5 min-w-0">
         <el-form-item label="项目">
           <el-input
             v-model="query.contractName"
             placeholder="请输入项目"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="任务">
           <el-input
@@ -82,7 +82,7 @@ const resetQuery = () => {
             placeholder="请输入任务"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="创建时间">
           <el-date-picker
@@ -93,14 +93,16 @@ const resetQuery = () => {
             end-placeholder="结束日期"
             :shortcuts="rangeShortcuts"
             :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-            class="!w-220px" />
+            class="!w-260px" />
         </el-form-item>
       </div>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery()">
+      <el-form-item class="summary-query-actions">
+        <el-button size="small" type="primary" @click="handleQuery()">
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button size="small" @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" /> 重置
+        </el-button>
       </el-form-item>
     </el-form>
 
@@ -136,4 +138,24 @@ const resetQuery = () => {
 :deep(.el-form-item) {
   margin-bottom: 0;
 }
+
+.summary-query-form {
+  min-height: 48px;
+
+  :deep(.el-form-item__label) {
+    height: 28px;
+    line-height: 28px;
+  }
+}
+
+.summary-query-actions {
+  :deep(.el-form-item__content) {
+    flex-wrap: nowrap;
+    gap: 8px;
+  }
+
+  :deep(.el-button + .el-button) {
+    margin-left: 0;
+  }
+}
 </style>

+ 10 - 9
src/views/pms/iotrhdailyreport/components/DailyStatistics.vue

@@ -49,7 +49,7 @@ const totalWorkKeys: [string, string, string, string, number][] = [
     '%',
     '设备利用率',
     'i-material-symbols:check-circle-outline-rounded text-emerald',
-    0
+    2
   ],
   [
     'totalPowerConsumption',
@@ -769,8 +769,8 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
 </script>
 
 <template>
-  <div class="grid grid-rows-[128px_1fr] gap-4 h-full min-h-0">
-    <div class="grid grid-cols-8 gap-8">
+  <div class="grid grid-rows-[96px_1fr] gap-2 h-full min-h-0">
+    <div class="grid grid-cols-8 gap-4">
       <template v-for="info in totalWorkKeys" :key="info[0]">
         <el-tooltip :disabled="info[0] !== 'totalGasInjection'" placement="top">
           <template #content>
@@ -788,11 +788,11 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
             </div>
           </template>
           <div
-            class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-1 flex flex-col items-center justify-center gap-1">
-            <div class="size-7.5" :class="info[3]"></div>
+            class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-1 py-2 flex flex-col items-center justify-center gap-0.5 min-w-0">
+            <div class="size-6.5" :class="info[3]"></div>
 
             <count-to
-              class="text-2xl font-medium"
+              class="text-xl font-medium leading-6"
               :class="{ 'cursor-help': info[0] === 'totalGasInjection' }"
               :start-val="0"
               :end-val="totalWork[info[0]]"
@@ -801,7 +801,8 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
               <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
             </count-to>
 
-            <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+            <div
+              class="text-xs font-medium leading-5 text-[var(--el-text-color-regular)] whitespace-nowrap">
               {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
             </div>
           </div>
@@ -809,8 +810,8 @@ const { ZmTable, ZmTableColumn } = useTableComponents()
       </template>
     </div>
 
-    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2 min-h-0">
-      <div class="flex h-12 items-center justify-between">
+    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-3 gap-2 min-h-0">
+      <div class="flex h-10 items-center justify-between">
         <el-button-group>
           <el-button
             size="default"

+ 43 - 7
src/views/pms/iotrhdailyreport/index.vue

@@ -259,6 +259,8 @@ async function handleExport() {
 
 const unfilledDialogRef = ref()
 
+const alarmCollapse = ref<string[]>([])
+
 const openUnfilledDialog = () => {
   if (!query.value.createTime || query.value.createTime.length === 0) {
     message.warning('请先选择创建时间范围')
@@ -399,13 +401,25 @@ const openUnfilledDialog = () => {
       is-index
       @current-change="handleCurrentChange"
       @size-change="handleSizeChange" />
-    <div class="p-2 bg-white dark:bg-[#1d1e1f] rounded-lg shadow">
-      <el-alert
-        class="h-8!"
-        title="运行时效=当日注气量/产能&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;超过120%红色预警"
-        type="error"
-        show-icon
-        :closable="false" />
+    <div class="p-1 bg-white dark:bg-[#1d1e1f] rounded-lg shadow">
+      <el-collapse v-model="alarmCollapse" class="alarm-collapse">
+        <el-collapse-item title="告警提示" name="alarm">
+          <div class="flex flex-col gap-2">
+            <el-alert
+              class="h-8!"
+              title="运行时效超过100%红色预警"
+              type="error"
+              show-icon
+              :closable="false" />
+            <el-alert
+              class="h-8!"
+              title="气电比计算结果大于15橙色预警"
+              type="warning"
+              show-icon
+              :closable="false" />
+          </div>
+        </el-collapse-item>
+      </el-collapse>
     </div>
   </div>
 
@@ -422,4 +436,26 @@ const openUnfilledDialog = () => {
     height: 42px;
   }
 }
+
+:deep(.alarm-collapse) {
+  border-top: 0;
+  border-bottom: 0;
+
+  .el-collapse-item__header {
+    height: 32px;
+    padding-left: 12px;
+    font-size: 15px;
+    font-weight: 700;
+    line-height: 32px;
+    border-bottom: 0;
+  }
+
+  .el-collapse-item__wrap {
+    border-bottom: 0;
+  }
+
+  .el-collapse-item__content {
+    padding-bottom: 4px;
+  }
+}
 </style>

+ 38 - 46
src/views/pms/iotrhdailyreport/rh-table.vue

@@ -46,6 +46,7 @@ interface ListItem {
   yearTotalFuel: number
   capacity: number
   techniqueNames: string
+  transitTimeRate: string
 }
 
 const props = defineProps({
@@ -85,16 +86,7 @@ const { list, loading, total, pageNo, pageSize, showAction, isIndex } = toRefs(p
 
 const { ZmTable, ZmTableColumn } = useTableComponents<ListItem>()
 
-function percentageFormatter(row: ListItem) {
-  const capacity = Number(row?.capacity)
-  const dailyGasInjection = Number(row?.dailyGasInjection)
-
-  if (!capacity || !dailyGasInjection) {
-    return '0.00%'
-  }
-
-  return ((dailyGasInjection / capacity) * 100).toFixed(2) + '%'
-}
+const GAS_ELECTRICITY_RATIO_WARNING_THRESHOLD = 15
 
 function unitformatter(
   _row: ListItem,
@@ -107,21 +99,34 @@ function unitformatter(
   return (value / 10000).toFixed(4)
 }
 
+const isGasElectricityRatioWarning = (value: unknown) => {
+  const ratio = Number(value)
+
+  return Number.isFinite(ratio) && ratio > GAS_ELECTRICITY_RATIO_WARNING_THRESHOLD
+}
+
 const cellStyle = ({ row, column }: { row: any; column: any }) => {
-  if (column.property === 'transitTime') {
-    const capacity = Number(row?.capacity)
-    const dailyGasInjection = Number(row?.dailyGasInjection)
-    if (capacity && dailyGasInjection) {
-      const ratio = dailyGasInjection / capacity
-      if (ratio > 1.2) {
-        return {
-          color: 'red',
-          fontWeight: 'bold',
-          backgroundColor: 'var(--el-color-danger-light-9)'
-        }
+  if (column.property === 'transitTimeRate') {
+    const ratio = Number(row?.transitTimeRate.replace('%', ''))
+    if (ratio > 100.0) {
+      return {
+        color: 'red',
+        fontWeight: 'bold',
+        backgroundColor: 'var(--el-color-danger-light-9)'
       }
     }
   }
+
+  if (
+    column.property === 'gasElectricityRatio' &&
+    isGasElectricityRatioWarning(row?.gasElectricityRatio)
+  ) {
+    return {
+      color: 'var(--el-color-warning)',
+      fontWeight: 'bold'
+    }
+  }
+
   return {}
 }
 
@@ -146,22 +151,19 @@ function handleCurrentChange(val: number) {
             :max-height="height"
             :height="height"
             show-border
-            :cell-style="cellStyle"
-          >
+            :cell-style="cellStyle">
             <zm-table-column
               v-if="isIndex"
               type="index"
               :label="t('monitor.serial')"
               :width="60"
-              fixed="left"
-            />
+              fixed="left" />
             <zm-table-column
               label="日期"
               prop="createTime"
               fixed="left"
               cover-formatter
-              :real-value="(row: ListItem) => dayjs(row.createTime).format('YYYY-MM-DD')"
-            />
+              :real-value="(row: ListItem) => dayjs(row.createTime).format('YYYY-MM-DD')" />
             <zm-table-column label="施工队伍" prop="deptName" fixed="left" />
             <zm-table-column label="任务" prop="taskName" fixed="left" />
             <zm-table-column
@@ -171,13 +173,11 @@ function handleCurrentChange(val: number) {
               :real-value="
                 (row: ListItem) =>
                   realValue(DICT_TYPE.PMS_PROJECT_TASK_SCHEDULE, row.constructionStatus ?? '')
-              "
-            >
+              ">
               <template #default="scope">
                 <dict-tag
                   :type="DICT_TYPE.PMS_PROJECT_TASK_SCHEDULE"
-                  :value="scope.row.constructionStatus ?? ''"
-                />
+                  :value="scope.row.constructionStatus ?? ''" />
               </template>
             </zm-table-column>
             <zm-table-column prop="auditStatus" label="审批状态" v-if="!isIndex">
@@ -198,21 +198,15 @@ function handleCurrentChange(val: number) {
             </zm-table-column>
             <zm-table-column label="施工区域" prop="location" />
             <zm-table-column label="施工工艺" prop="techniqueNames" />
-            <zm-table-column label="搬迁安装天数" prop="relocationDays" />
+            <zm-table-column label="设备号" prop="deviceNo" />
             <zm-table-column label="设计注气量(万方)" prop="designInjection" />
-            <zm-table-column
-              label="运行时效"
-              prop="transitTime"
-              cover-formatter
-              :real-value="percentageFormatter"
-            />
+            <zm-table-column label="运行时效" prop="transitTimeRate" />
             <zm-table-column label="当日">
               <zm-table-column
                 label="注气量(万方)"
                 prop="dailyGasInjection"
                 cover-formatter
-                :real-value="unitformatter"
-              />
+                :real-value="unitformatter" />
               <zm-table-column label="注水量(方)" prop="dailyWaterInjection" />
               <zm-table-column label="注气时间(H)" prop="dailyInjectGasTime" />
               <zm-table-column label="注水时间(H)" prop="dailyInjectWaterTime" />
@@ -224,8 +218,7 @@ function handleCurrentChange(val: number) {
               prop="nonProductionRate"
               label="非生产时效"
               cover-formatter
-              :real-value="(row) => (Number(row.nonProductionRate ?? 0) * 100).toFixed(2) + '%'"
-            />
+              :real-value="(row) => (Number(row.nonProductionRate ?? 0) * 100).toFixed(2) + '%'" />
             <zm-table-column label="非生产时间">
               <zm-table-column prop="accidentTime" label="工程质量" />
               <zm-table-column prop="repairTime" label="设备故障" />
@@ -243,6 +236,7 @@ function handleCurrentChange(val: number) {
             <zm-table-column prop="otherNptReason" label="其他非生产时间原因" />
             <zm-table-column prop="productionStatus" label="生产动态" />
             <zm-table-column prop="contractName" label="项目" />
+            <zm-table-column label="搬迁安装天数" prop="relocationDays" />
             <zm-table-column label="井累计" v-if="isIndex">
               <zm-table-column prop="wellTotalGasInjection" label="注气量(万方)" />
               <zm-table-column prop="wellTotalWaterInjection" label="注水量(方)" />
@@ -265,8 +259,7 @@ function handleCurrentChange(val: number) {
               label="产能(万方)"
               cover-formatter
               :action="!showAction"
-              :real-value="unitformatter"
-            />
+              :real-value="unitformatter" />
 
             <zm-table-column label="操作" :width="120" fixed="right" action v-if="showAction">
               <template #default="scope">
@@ -288,8 +281,7 @@ function handleCurrentChange(val: number) {
         :total="total"
         layout="total, sizes, prev, pager, next, jumper"
         @size-change="handleSizeChange"
-        @current-change="handleCurrentChange"
-      />
+        @current-change="handleCurrentChange" />
     </div>
   </div>
 </template>

+ 32 - 10
src/views/pms/iotrhdailyreport/summary.vue

@@ -106,7 +106,7 @@ watch(
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_48px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    class="grid grid-cols-[auto_1fr] grid-rows-[48px_44px_1fr] gap-3 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <DeptTreeSelect
       :deptId="id"
       :top-id="157"
@@ -114,16 +114,16 @@ watch(
       @node-click="handleDeptNodeClick"
       class="row-span-3" />
     <el-form
-      size="default"
-      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between">
-      <div class="flex items-center gap-8">
+      size="small"
+      class="summary-query-form bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-5 flex items-center justify-between">
+      <div class="flex items-center gap-5 min-w-0">
         <el-form-item label="项目">
           <el-input
             v-model="query.contractName"
             placeholder="请输入项目"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="任务">
           <el-input
@@ -131,7 +131,7 @@ watch(
             placeholder="请输入任务"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="创建时间">
           <el-date-picker
@@ -142,14 +142,16 @@ watch(
             end-placeholder="结束日期"
             :shortcuts="rangeShortcuts"
             :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-            class="!w-220px" />
+            class="!w-260px" />
         </el-form-item>
       </div>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery()">
+      <el-form-item class="summary-query-actions">
+        <el-button size="small" type="primary" @click="handleQuery()">
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button size="small" @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" /> 重置
+        </el-button>
       </el-form-item>
     </el-form>
 
@@ -186,4 +188,24 @@ watch(
 :deep(.el-form-item) {
   margin-bottom: 0;
 }
+
+.summary-query-form {
+  min-height: 48px;
+
+  :deep(.el-form-item__label) {
+    height: 28px;
+    line-height: 28px;
+  }
+}
+
+.summary-query-actions {
+  :deep(.el-form-item__content) {
+    flex-wrap: nowrap;
+    gap: 8px;
+  }
+
+  :deep(.el-button + .el-button) {
+    margin-left: 0;
+  }
+}
 </style>

+ 17 - 25
src/views/pms/iotrydailyreport/components/DailyStatistics.vue

@@ -551,31 +551,30 @@ const tolist = (id: number, non: boolean = false) => {
 </script>
 
 <template>
-  <div class="grid grid-rows-[128px_1fr] gap-4 h-full min-h-0">
-    <div class="grid grid-cols-8 gap-8">
+  <div class="grid grid-rows-[96px_1fr] gap-2 h-full min-h-0">
+    <div class="grid grid-cols-8 gap-4">
       <div
         v-for="info in totalWorkKeys"
         :key="info[0]"
-        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-4 flex flex-col items-center justify-center gap-2"
-      >
-        <div class="size-7.5" :class="info[3]"></div>
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-1 py-2 flex flex-col items-center justify-center gap-0.5 min-w-0">
+        <div class="size-6.5" :class="info[3]"></div>
         <count-to
-          class="text-2xl font-medium"
+          class="text-xl font-medium leading-6"
           :start-val="0"
           :end-val="totalWork[info[0]]"
           :decimals="info[4]"
-          @click="info[2] === '未填报' ? openUnfilledDialog() : ''"
-        >
+          @click="info[2] === '未填报' ? openUnfilledDialog() : ''">
           <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
         </count-to>
-        <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+        <div
+          class="text-xs font-medium leading-5 text-[var(--el-text-color-regular)] whitespace-nowrap">
           {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
         </div>
       </div>
     </div>
 
-    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2 min-h-0">
-      <div class="flex h-12 items-center justify-between">
+    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-3 gap-2 min-h-0">
+      <div class="flex h-10 items-center justify-between">
         <el-button-group>
           <el-button
             size="default"
@@ -600,8 +599,7 @@ const tolist = (id: number, non: boolean = false) => {
               as="div"
               :style="{ position: 'relative', overflow: 'hidden' }"
               :animate="{ height: `${height}px`, width: `100%` }"
-              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
-            >
+              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }">
               <AnimatePresence :initial="false" mode="sync">
                 <Motion
                   :key="currentTab"
@@ -610,29 +608,25 @@ const tolist = (id: number, non: boolean = false) => {
                   :animate="{ x: '0%', opacity: 1 }"
                   :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
                   :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
-                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
-                >
+                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }">
                   <div :style="{ width: `100%`, height: `${height}px` }">
                     <zm-table
                       v-if="currentTab === '表格'"
                       :loading="listLoading"
                       :data="list"
                       :height="height"
-                      show-border
-                    >
+                      show-border>
                       <template v-for="item in columns(type)" :key="item.prop">
                         <zm-table-column
                           v-if="item.prop !== 'name' && item.prop !== 'nonProductiveTime'"
                           :label="item.label"
                           :prop="item.prop"
                           :formatter="formatter"
-                          :action="item.action"
-                        />
+                          :action="item.action" />
                         <zm-table-column
                           v-else-if="item.prop === 'name'"
                           :label="item.label"
-                          :prop="item.prop"
-                        >
+                          :prop="item.prop">
                           <template #default="{ row }">
                             <el-button text type="primary" @click.prevent="tolist(row.id)">{{
                               row.name
@@ -645,8 +639,7 @@ const tolist = (id: number, non: boolean = false) => {
                               v-if="row.nonProductiveTime > 0"
                               text
                               type="primary"
-                              @click.prevent="tolist(row.id, true)"
-                            >
+                              @click.prevent="tolist(row.id, true)">
                               {{ (Number(row.nonProductiveTime ?? 0) * 100).toFixed(2) + '%' }}
                             </el-button>
                             <span v-else>
@@ -661,8 +654,7 @@ const tolist = (id: number, non: boolean = false) => {
                       v-loading="chartLoading"
                       :key="dayjs().valueOf()"
                       v-else
-                      :style="{ width: `100%`, height: `${height}px` }"
-                    >
+                      :style="{ width: `100%`, height: `${height}px` }">
                     </div>
                   </div>
                 </Motion>

+ 17 - 25
src/views/pms/iotrydailyreport/components/XjDailyStatistics.vue

@@ -549,31 +549,30 @@ const tolist = (id: number) => {
 </script>
 
 <template>
-  <div class="grid grid-rows-[128px_1fr] gap-4 h-full min-h-0">
-    <div class="grid grid-cols-9 gap-8">
+  <div class="grid grid-rows-[96px_1fr] gap-2 h-full min-h-0">
+    <div class="grid grid-cols-9 gap-4">
       <div
         v-for="info in totalWorkKeys"
         :key="info[0]"
-        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow p-4 flex flex-col items-center justify-center gap-2"
-      >
-        <div class="size-7.5" :class="info[3]"></div>
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-1 py-2 flex flex-col items-center justify-center gap-0.5 min-w-0">
+        <div class="size-6.5" :class="info[3]"></div>
         <count-to
-          class="text-2xl font-medium"
+          class="text-xl font-medium leading-6"
           :start-val="0"
           :end-val="totalWork[info[0]]"
           :decimals="info[4]"
-          @click="info[2] === '未填报' ? openUnfilledDialog() : ''"
-        >
+          @click="info[2] === '未填报' ? openUnfilledDialog() : ''">
           <span class="text-xs leading-8 text-[var(--el-text-color-regular)]">暂无数据</span>
         </count-to>
-        <div class="text-sm font-medium text-[var(--el-text-color-regular)] whitespace-nowrap">
+        <div
+          class="text-xs font-medium leading-5 text-[var(--el-text-color-regular)] whitespace-nowrap">
           {{ info[1] ? info[2] + '(' + info[1] + ')' : info[2] }}
         </div>
       </div>
     </div>
 
-    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-4 gap-2 min-h-0">
-      <div class="flex h-12 items-center justify-between">
+    <div class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col p-3 gap-2 min-h-0">
+      <div class="flex h-10 items-center justify-between">
         <el-button-group>
           <el-button
             size="default"
@@ -598,8 +597,7 @@ const tolist = (id: number) => {
               as="div"
               :style="{ position: 'relative', overflow: 'hidden' }"
               :animate="{ height: `${height}px`, width: `100%` }"
-              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }"
-            >
+              :transition="{ type: 'spring', stiffness: 200, damping: 25, duration: 0.3 }">
               <AnimatePresence :initial="false" mode="sync">
                 <Motion
                   :key="currentTab"
@@ -608,29 +606,25 @@ const tolist = (id: number) => {
                   :animate="{ x: '0%', opacity: 1 }"
                   :exit="{ x: direction === 'left' ? '50%' : '-50%', opacity: 0 }"
                   :transition="{ type: 'tween', stiffness: 300, damping: 30, duration: 0.3 }"
-                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }"
-                >
+                  :style="{ position: 'absolute', left: 0, right: 0, top: 0 }">
                   <div :style="{ width: `100%`, height: `${height}px` }">
                     <zm-table
                       v-if="currentTab === '表格'"
                       :loading="listLoading"
                       :data="list"
                       :height="height"
-                      show-border
-                    >
+                      show-border>
                       <template v-for="item in columns(type)" :key="item.prop">
                         <zm-table-column
                           v-if="item.prop !== 'name' && item.prop !== 'nonProductiveTime'"
                           :label="item.label"
                           :prop="item.prop"
                           :formatter="formatter"
-                          :action="item.action"
-                        />
+                          :action="item.action" />
                         <zm-table-column
                           v-else-if="item.prop === 'name'"
                           :label="item.label"
-                          :prop="item.prop"
-                        >
+                          :prop="item.prop">
                           <template #default="{ row }">
                             <el-button text type="primary" @click.prevent="tolist(row.id)">{{
                               row.name
@@ -643,8 +637,7 @@ const tolist = (id: number) => {
                               v-if="row.nonProductiveTime > 0"
                               text
                               type="primary"
-                              @click.prevent="tolist(row.id)"
-                            >
+                              @click.prevent="tolist(row.id)">
                               {{ (Number(row.nonProductiveTime ?? 0) * 100).toFixed(2) + '%' }}
                             </el-button>
                             <span v-else>
@@ -659,8 +652,7 @@ const tolist = (id: number) => {
                       v-loading="chartLoading"
                       :key="dayjs().valueOf()"
                       v-else
-                      :style="{ width: `100%`, height: `${height}px` }"
-                    >
+                      :style="{ width: `100%`, height: `${height}px` }">
                     </div>
                   </div>
                 </Motion>

+ 216 - 43
src/views/pms/iotrydailyreport/components/equipment-form.vue

@@ -1,11 +1,12 @@
 <script setup lang="ts">
 import { IotRyImproveDailyReportApi } from '@/api/pms/iotryimprovedailyreport'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
 import { FormInstance, FormRules } from 'element-plus'
-import { Close } from '@element-plus/icons-vue'
+import { Close, Delete, Plus } from '@element-plus/icons-vue'
 
 defineOptions({ name: 'IotRyEquipmentReportForm' })
 
-type FormMode = 'create' | 'edit' | 'detail' | 'approval'
+type FormMode = 'create' | 'edit' | 'detail' | 'approval' | 'modify'
 
 interface EquipmentReportForm {
   id?: number
@@ -14,11 +15,18 @@ interface EquipmentReportForm {
   workLocation: string
   workPurpose: string
   relocationDays: number | undefined
-  productionStatus: string
-  nextPlan: string
+  improveReportDetails: ImproveReportDetail[]
   personnel: string
   auditStatus?: 0 | 10 | 20 | 30 | 40
   opinion?: string
+  modifyFlag?: 'Y'
+}
+
+interface ImproveReportDetail {
+  detailKey?: string
+  projectName: string
+  constructionDetail: string
+  nextPlan: string
 }
 
 interface Props {
@@ -41,8 +49,7 @@ const defaultForm: EquipmentReportForm = {
   workLocation: '',
   workPurpose: '',
   relocationDays: undefined,
-  productionStatus: '',
-  nextPlan: '',
+  improveReportDetails: [],
   personnel: '',
   auditStatus: undefined,
   opinion: ''
@@ -54,6 +61,7 @@ const form = ref<EquipmentReportForm>({ ...defaultForm })
 const formLoading = ref(false)
 const detailLoading = ref(false)
 const message = useMessage()
+const { ZmTable, ZmTableColumn } = useTableComponents<ImproveReportDetail>()
 
 const auditStatusMap = {
   0: { label: '未提交', type: 'info' },
@@ -63,14 +71,14 @@ const auditStatusMap = {
   40: { label: '已取消', type: 'info' }
 } as const
 
+let detailKeySeed = 0
+
 const rules: FormRules = {
   title: [{ required: true, message: '请输入标题', trigger: ['blur', 'change'] }],
   createTime: [{ required: true, message: '请选择汇报时间', trigger: ['blur', 'change'] }],
   workLocation: [{ required: true, message: '请输入工作地点', trigger: ['blur', 'change'] }],
   workPurpose: [{ required: true, message: '请输入施工目的', trigger: ['blur', 'change'] }],
   relocationDays: [{ required: true, message: '请输入安全作业天数', trigger: ['blur', 'change'] }],
-  productionStatus: [{ required: true, message: '请输入施工动态', trigger: ['blur', 'change'] }],
-  nextPlan: [{ required: true, message: '请输入下步计划', trigger: ['blur', 'change'] }],
   personnel: [{ required: true, message: '请输入人员情况', trigger: ['blur', 'change'] }]
 }
 
@@ -79,7 +87,8 @@ const drawerTitle = computed(() => {
     create: '新建设备汇报',
     edit: '编辑设备汇报',
     detail: '查看设备汇报',
-    approval: '审批设备汇报'
+    approval: '审批设备汇报',
+    modify: '修改设备汇报'
   }
 
   return titleMap[formMode.value]
@@ -95,8 +104,58 @@ function getAuditStatus(status?: number) {
   return auditStatusMap[status as keyof typeof auditStatusMap] || auditStatusMap[0]
 }
 
+function createDefaultDetail(): ImproveReportDetail {
+  return {
+    detailKey: createDetailKey(),
+    projectName: '',
+    constructionDetail: '',
+    nextPlan: ''
+  }
+}
+
+function createDetailKey() {
+  detailKeySeed += 1
+  return `detail-${Date.now()}-${detailKeySeed}`
+}
+
+function getDefaultDetails() {
+  return [createDefaultDetail()]
+}
+
+function normalizeDetails(details?: ImproveReportDetail[]) {
+  if (!Array.isArray(details) || !details.length) {
+    return getDefaultDetails()
+  }
+
+  return details.map((item) => ({
+    detailKey: item.detailKey || createDetailKey(),
+    projectName: item.projectName || '',
+    constructionDetail: item.constructionDetail || '',
+    nextPlan: item.nextPlan || ''
+  }))
+}
+
+function resolveDetailIndex(row: ImproveReportDetail) {
+  const index = form.value.improveReportDetails.findIndex(
+    (item) => item.detailKey === row.detailKey
+  )
+  return index >= 0 ? index : 0
+}
+
+function getDetailFieldProp(row: ImproveReportDetail, field: keyof ImproveReportDetail) {
+  return `improveReportDetails.${resolveDetailIndex(row)}.${field}`
+}
+
+function getDetailRowKey(row: ImproveReportDetail) {
+  return row.detailKey || ''
+}
+
+function getSubmitDetails(details: ImproveReportDetail[]) {
+  return details.map(({ detailKey: _detailKey, ...item }) => item)
+}
+
 function toFormData(row?: any): EquipmentReportForm {
-  if (!row) return { ...defaultForm }
+  if (!row) return { ...defaultForm, improveReportDetails: getDefaultDetails() }
 
   return {
     id: row.id,
@@ -105,8 +164,7 @@ function toFormData(row?: any): EquipmentReportForm {
     workLocation: row.workLocation,
     workPurpose: row.workPurpose,
     relocationDays: row.relocationDays,
-    productionStatus: row.productionStatus,
-    nextPlan: row.nextPlan,
+    improveReportDetails: normalizeDetails(row.improveReportDetails),
     personnel: row.personnel,
     auditStatus: row.auditStatus,
     opinion: row.opinion || ''
@@ -115,7 +173,10 @@ function toFormData(row?: any): EquipmentReportForm {
 
 async function handleOpenForm(mode: FormMode = 'create', row?: any) {
   formMode.value = mode
-  form.value = mode === 'create' ? { ...defaultForm } : toFormData(row)
+  form.value =
+    mode === 'create'
+      ? { ...defaultForm, improveReportDetails: getDefaultDetails() }
+      : toFormData(row)
   emits('update:visible', true)
 
   if (mode !== 'create' && row?.id) {
@@ -137,6 +198,18 @@ function handleCloseForm() {
   emits('update:visible', false)
 }
 
+function addImproveReportDetail() {
+  form.value.improveReportDetails.push(createDefaultDetail())
+}
+
+function removeImproveReportDetail(row: ImproveReportDetail) {
+  if (form.value.improveReportDetails.length <= 1) {
+    return
+  }
+
+  form.value.improveReportDetails.splice(resolveDetailIndex(row), 1)
+}
+
 async function submitForm() {
   if (!formRef.value || formDisabled.value || formMode.value === 'approval') return
 
@@ -144,7 +217,11 @@ async function submitForm() {
     formLoading.value = true
     await formRef.value.validate()
 
-    const data = { ...form.value, createTime: Number(form.value.createTime) }
+    const data = {
+      ...form.value,
+      createTime: Number(form.value.createTime),
+      improveReportDetails: getSubmitDetails(form.value.improveReportDetails)
+    }
 
     if (data.id) {
       await IotRyImproveDailyReportApi.updateIotRyImproveDailyReport({
@@ -154,11 +231,11 @@ async function submitForm() {
         workLocation: data.workLocation,
         workPurpose: data.workPurpose,
         relocationDays: Number(data.relocationDays),
-        productionStatus: data.productionStatus,
         personnel: data.personnel,
-        nextPlan: data.nextPlan
+        improveReportDetails: data.improveReportDetails,
+        ...(formMode.value === 'modify' ? { modifyFlag: 'Y' as const } : {})
       })
-      message.success('编辑成功')
+      message.success(formMode.value === 'modify' ? '修改成功' : '编辑成功')
       await props.loadList()
     } else {
       await IotRyImproveDailyReportApi.createIotRyImproveDailyReport({
@@ -167,9 +244,8 @@ async function submitForm() {
         workLocation: data.workLocation,
         workPurpose: data.workPurpose,
         relocationDays: Number(data.relocationDays),
-        productionStatus: data.productionStatus,
         personnel: data.personnel,
-        nextPlan: data.nextPlan
+        improveReportDetails: data.improveReportDetails
       })
       message.success('新增成功')
       await (props.reloadAfterCreate ? props.reloadAfterCreate() : props.loadList())
@@ -293,29 +369,108 @@ defineExpose({ handleOpenForm })
               :disabled="mainFieldDisabled" />
           </el-form-item>
 
-          <el-form-item class="col-span-2" label="施工动态" prop="productionStatus">
-            <el-input
-              v-model="form.productionStatus"
-              type="textarea"
-              :autosize="{ minRows: 8 }"
-              resize="none"
-              show-word-limit
-              :maxlength="2000"
-              placeholder="请输入施工动态,每行一条"
-              :disabled="mainFieldDisabled" />
-          </el-form-item>
-
-          <el-form-item class="col-span-2" label="下步计划" prop="nextPlan">
-            <el-input
-              v-model="form.nextPlan"
-              type="textarea"
-              :autosize="{ minRows: 2 }"
-              resize="none"
-              show-word-limit
-              :maxlength="1000"
-              placeholder="请输入下步计划"
-              :disabled="mainFieldDisabled" />
-          </el-form-item>
+          <div class="col-span-2 detail-toolbar">
+            <div class="text-base font-medium text-[var(--el-text-color-primary)]">
+              施工动态明细
+            </div>
+            <el-button
+              type="primary"
+              plain
+              :icon="Plus"
+              @click="addImproveReportDetail"
+              :disabled="mainFieldDisabled">
+              新增明细
+            </el-button>
+          </div>
+
+          <div class="col-span-2">
+            <ZmTable
+              :data="form.improveReportDetails"
+              :loading="false"
+              class="detail-table"
+              :row-key="getDetailRowKey"
+              :show-border="true"
+              :show-overflow-tooltip="false">
+              <ZmTableColumn label="项目名称" prop="projectName" :width="220">
+                <template #default="{ row }">
+                  <el-form-item
+                    class="mb-0!"
+                    :show-message="false"
+                    :prop="getDetailFieldProp(row, 'projectName')"
+                    :rules="{
+                      required: true,
+                      message: '请输入项目名称',
+                      trigger: ['blur', 'change']
+                    }">
+                    <el-input
+                      v-model="row.projectName"
+                      clearable
+                      placeholder="请输入项目名称"
+                      :disabled="mainFieldDisabled" />
+                  </el-form-item>
+                </template>
+              </ZmTableColumn>
+
+              <ZmTableColumn label="施工动态" min-width="280">
+                <template #default="{ row }">
+                  <el-form-item
+                    class="mb-0!"
+                    :show-message="false"
+                    :prop="getDetailFieldProp(row, 'constructionDetail')"
+                    :rules="{
+                      required: true,
+                      message: '请输入施工动态',
+                      trigger: ['blur', 'change']
+                    }">
+                    <el-input
+                      v-model="row.constructionDetail"
+                      type="textarea"
+                      :autosize="{ minRows: 2, maxRows: 8 }"
+                      resize="vertical"
+                      :maxlength="1000"
+                      placeholder="请输入施工动态"
+                      :disabled="mainFieldDisabled" />
+                  </el-form-item>
+                </template>
+              </ZmTableColumn>
+
+              <ZmTableColumn label="下步计划" min-width="240">
+                <template #default="{ row }">
+                  <el-form-item
+                    class="mb-0!"
+                    :show-message="false"
+                    :prop="getDetailFieldProp(row, 'nextPlan')"
+                    :rules="{
+                      required: true,
+                      message: '请输入下步计划',
+                      trigger: ['blur', 'change']
+                    }">
+                    <el-input
+                      v-model="row.nextPlan"
+                      type="textarea"
+                      :autosize="{ minRows: 2, maxRows: 8 }"
+                      resize="vertical"
+                      :maxlength="1000"
+                      placeholder="请输入下步计划"
+                      :disabled="mainFieldDisabled" />
+                  </el-form-item>
+                </template>
+              </ZmTableColumn>
+
+              <ZmTableColumn label="操作" width="90" fixed="right">
+                <template #default="{ row }">
+                  <el-button
+                    link
+                    type="danger"
+                    :icon="Delete"
+                    @click="removeImproveReportDetail(row)"
+                    :disabled="mainFieldDisabled || form.improveReportDetails.length <= 1">
+                    删除
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </div>
 
           <el-form-item v-if="showAuditInfo" class="col-span-2" label="审批意见" prop="opinion">
             <el-input
@@ -334,7 +489,7 @@ defineExpose({ handleOpenForm })
 
     <template #footer>
       <el-button
-        v-if="formMode === 'create' || formMode === 'edit'"
+        v-if="formMode === 'create' || formMode === 'edit' || formMode === 'modify'"
         size="default"
         type="primary"
         @click="submitForm"
@@ -366,4 +521,22 @@ defineExpose({ handleOpenForm })
 :deep(.el-form-item__label) {
   font-weight: 500;
 }
+
+.detail-toolbar {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-top: 8px;
+  margin-bottom: 12px;
+}
+
+.detail-table :deep(.el-form-item) {
+  width: 100%;
+}
+
+.detail-table :deep(.el-table__body .el-table__cell) {
+  .cell {
+    padding: 10px 8px;
+  }
+}
 </style>

+ 15 - 5
src/views/pms/iotrydailyreport/equipment.vue

@@ -90,7 +90,10 @@ function getAuditStatus(status?: number) {
   return auditStatusMap[status as keyof typeof auditStatusMap] || auditStatusMap[0]
 }
 
-function handleOpenForm(type: 'create' | 'edit' | 'detail' | 'approval', row?: ReportRow) {
+function handleOpenForm(
+  type: 'create' | 'edit' | 'detail' | 'approval' | 'modify',
+  row?: ReportRow
+) {
   formRef.value?.handleOpenForm(type, row)
 }
 
@@ -132,12 +135,12 @@ onMounted(() => {
         </el-form-item>
       </div>
       <el-form-item>
-        <el-button
+        <!-- <el-button
           type="primary"
           @click="handleOpenForm('create')"
           v-hasPermi="['pms:iot-ry-improve-daily-report:create']">
           <Icon icon="ep:plus" class="mr-5px" /> 新增
-        </el-button>
+        </el-button> -->
         <el-button type="primary" @click="handleQuery">
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
@@ -182,13 +185,20 @@ onMounted(() => {
                   </el-tag>
                 </template>
               </zm-table-column>
-              <zm-table-column label="操作" width="160" fixed="right" action>
+              <zm-table-column label="操作" width="200" fixed="right" action>
                 <template #default="{ row }">
                   <el-button link type="primary" @click="handleOpenForm('detail', row)">
                     查看
                   </el-button>
                   <el-button
-                    v-if="row.auditStatus !== 20"
+                    link
+                    type="primary"
+                    @click="handleOpenForm('modify', row)"
+                    v-hasPermi="['pms:iot-ry-improve-daily-report:modify']">
+                    修改
+                  </el-button>
+                  <el-button
+                    v-if="row.status === 0"
                     link
                     type="primary"
                     @click="handleOpenForm('edit', row)"

+ 43 - 13
src/views/pms/iotrydailyreport/index.vue

@@ -152,6 +152,8 @@ const visible = ref(false)
 
 const formRef = ref()
 
+const alarmCollapse = ref<string[]>([])
+
 function handleOpenForm(id: number, type: 'edit' | 'readonly') {
   if (formRef.value) {
     formRef.value.handleOpenForm(id, type)
@@ -255,19 +257,25 @@ function handleOpenForm(id: number, type: 'edit' | 'readonly') {
       </template>
     </ry-table>
 
-    <div class="p-2 bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col gap-2">
-      <el-alert
-        class="h-8!"
-        title="当日油耗大于9000升&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;红色预警"
-        type="error"
-        show-icon
-        :closable="false" />
-      <el-alert
-        class="h-8!"
-        title="进尺工作时间+其它生产时间+非生产时间=24H&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;否则橙色预警"
-        type="warning"
-        show-icon
-        :closable="false" />
+    <div class="p-1 bg-white dark:bg-[#1d1e1f] rounded-lg shadow">
+      <el-collapse v-model="alarmCollapse" class="alarm-collapse">
+        <el-collapse-item title="告警提示" name="alarm">
+          <div class="flex flex-col gap-2">
+            <el-alert
+              class="h-8!"
+              title="当日油耗大于9000升&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;红色预警"
+              type="error"
+              show-icon
+              :closable="false" />
+            <el-alert
+              class="h-8!"
+              title="进尺工作时间+其它生产时间+非生产时间=24H&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;否则橙色预警"
+              type="warning"
+              show-icon
+              :closable="false" />
+          </div>
+        </el-collapse-item>
+      </el-collapse>
     </div>
   </div>
 
@@ -283,4 +291,26 @@ function handleOpenForm(id: number, type: 'edit' | 'readonly') {
 :deep(.el-form-item) {
   margin-bottom: 0;
 }
+
+:deep(.alarm-collapse) {
+  border-top: 0;
+  border-bottom: 0;
+
+  .el-collapse-item__header {
+    height: 32px;
+    padding-left: 12px;
+    font-size: 15px;
+    font-weight: 700;
+    line-height: 32px;
+    border-bottom: 0;
+  }
+
+  .el-collapse-item__wrap {
+    border-bottom: 0;
+  }
+
+  .el-collapse-item__content {
+    padding-bottom: 4px;
+  }
+}
 </style>

+ 32 - 10
src/views/pms/iotrydailyreport/summary.vue

@@ -59,7 +59,7 @@ const resetQuery = () => {
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_48px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    class="grid grid-cols-[auto_1fr] grid-rows-[48px_44px_1fr] gap-3 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <DeptTreeSelect
       :deptId="id"
       :top-id="158"
@@ -67,16 +67,16 @@ const resetQuery = () => {
       @node-click="handleDeptNodeClick"
       class="row-span-3" />
     <el-form
-      size="default"
-      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between">
-      <div class="flex items-center gap-8">
+      size="small"
+      class="summary-query-form bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-5 flex items-center justify-between">
+      <div class="flex items-center gap-5 min-w-0">
         <el-form-item label="项目">
           <el-input
             v-model="query.contractName"
             placeholder="请输入项目"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="任务">
           <el-input
@@ -84,7 +84,7 @@ const resetQuery = () => {
             placeholder="请输入任务"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="创建时间">
           <el-date-picker
@@ -95,14 +95,16 @@ const resetQuery = () => {
             end-placeholder="结束日期"
             :shortcuts="rangeShortcuts"
             :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-            class="!w-220px" />
+            class="!w-260px" />
         </el-form-item>
       </div>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery()">
+      <el-form-item class="summary-query-actions">
+        <el-button size="small" type="primary" @click="handleQuery()">
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button size="small" @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" /> 重置
+        </el-button>
       </el-form-item>
     </el-form>
 
@@ -138,4 +140,24 @@ const resetQuery = () => {
 :deep(.el-form-item) {
   margin-bottom: 0;
 }
+
+.summary-query-form {
+  min-height: 48px;
+
+  :deep(.el-form-item__label) {
+    height: 28px;
+    line-height: 28px;
+  }
+}
+
+.summary-query-actions {
+  :deep(.el-form-item__content) {
+    flex-wrap: nowrap;
+    gap: 8px;
+  }
+
+  :deep(.el-button + .el-button) {
+    margin-left: 0;
+  }
+}
 </style>

+ 49 - 19
src/views/pms/iotrydailyreport/xjindex.vue

@@ -151,6 +151,8 @@ const visible = ref(false)
 
 const formRef = ref()
 
+const alarmCollapse = ref<string[]>([])
+
 function handleOpenForm(id: number, type: 'edit' | 'readonly') {
   if (formRef.value) {
     formRef.value.handleOpenForm(id, type)
@@ -255,25 +257,31 @@ function handleOpenForm(id: number, type: 'edit' | 'readonly') {
       </template>
     </ry-xj-table>
 
-    <div class="p-2 bg-white dark:bg-[#1d1e1f] rounded-lg shadow flex flex-col gap-2">
-      <el-alert
-        class="h-8!"
-        title="运行时效=生产时间/额定生产时间&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;超过100%红色预警"
-        type="error"
-        show-icon
-        :closable="false" />
-      <el-alert
-        class="h-8!"
-        title="生产时间+非生产时间=额定生产时间&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;否则橙色预警"
-        type="warning"
-        show-icon
-        :closable="false" />
-      <el-alert
-        class="h-8!"
-        title="当日油耗大于3500升&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;蓝色预警"
-        type="primary"
-        show-icon
-        :closable="false" />
+    <div class="p-1 bg-white dark:bg-[#1d1e1f] rounded-lg shadow">
+      <el-collapse v-model="alarmCollapse" class="alarm-collapse">
+        <el-collapse-item title="告警提示" name="alarm">
+          <div class="flex flex-col gap-2">
+            <el-alert
+              class="h-8!"
+              title="运行时效=生产时间/额定生产时间&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;超过100%红色预警"
+              type="error"
+              show-icon
+              :closable="false" />
+            <el-alert
+              class="h-8!"
+              title="生产时间+非生产时间=额定生产时间&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;否则橙色预警"
+              type="warning"
+              show-icon
+              :closable="false" />
+            <el-alert
+              class="h-8!"
+              title="当日油耗大于3500升&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;蓝色预警"
+              type="primary"
+              show-icon
+              :closable="false" />
+          </div>
+        </el-collapse-item>
+      </el-collapse>
     </div>
   </div>
 
@@ -289,4 +297,26 @@ function handleOpenForm(id: number, type: 'edit' | 'readonly') {
 :deep(.el-form-item) {
   margin-bottom: 0;
 }
+
+:deep(.alarm-collapse) {
+  border-top: 0;
+  border-bottom: 0;
+
+  .el-collapse-item__header {
+    height: 32px;
+    padding-left: 12px;
+    font-size: 15px;
+    font-weight: 700;
+    line-height: 32px;
+    border-bottom: 0;
+  }
+
+  .el-collapse-item__wrap {
+    border-bottom: 0;
+  }
+
+  .el-collapse-item__content {
+    padding-bottom: 4px;
+  }
+}
 </style>

+ 32 - 10
src/views/pms/iotrydailyreport/xsummary.vue

@@ -59,7 +59,7 @@ const resetQuery = () => {
 
 <template>
   <div
-    class="grid grid-cols-[auto_1fr] grid-rows-[62px_48px_1fr] gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    class="grid grid-cols-[auto_1fr] grid-rows-[48px_44px_1fr] gap-3 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
     <DeptTreeSelect
       :deptId="id"
       :top-id="158"
@@ -67,16 +67,16 @@ const resetQuery = () => {
       @node-click="handleDeptNodeClick"
       class="row-span-3" />
     <el-form
-      size="default"
-      class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 gap-8 flex items-center justify-between">
-      <div class="flex items-center gap-8">
+      size="small"
+      class="summary-query-form bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-5 flex items-center justify-between">
+      <div class="flex items-center gap-5 min-w-0">
         <el-form-item label="项目">
           <el-input
             v-model="query.contractName"
             placeholder="请输入项目"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="任务">
           <el-input
@@ -84,7 +84,7 @@ const resetQuery = () => {
             placeholder="请输入任务"
             clearable
             @keyup.enter="handleQuery()"
-            class="!w-240px" />
+            class="!w-220px" />
         </el-form-item>
         <el-form-item label="创建时间">
           <el-date-picker
@@ -95,14 +95,16 @@ const resetQuery = () => {
             end-placeholder="结束日期"
             :shortcuts="rangeShortcuts"
             :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
-            class="!w-220px" />
+            class="!w-260px" />
         </el-form-item>
       </div>
-      <el-form-item>
-        <el-button type="primary" @click="handleQuery()">
+      <el-form-item class="summary-query-actions">
+        <el-button size="small" type="primary" @click="handleQuery()">
           <Icon icon="ep:search" class="mr-5px" /> 搜索
         </el-button>
-        <el-button @click="resetQuery"><Icon icon="ep:refresh" class="mr-5px" /> 重置</el-button>
+        <el-button size="small" @click="resetQuery">
+          <Icon icon="ep:refresh" class="mr-5px" /> 重置
+        </el-button>
       </el-form-item>
     </el-form>
 
@@ -138,4 +140,24 @@ const resetQuery = () => {
 :deep(.el-form-item) {
   margin-bottom: 0;
 }
+
+.summary-query-form {
+  min-height: 48px;
+
+  :deep(.el-form-item__label) {
+    height: 28px;
+    line-height: 28px;
+  }
+}
+
+.summary-query-actions {
+  :deep(.el-form-item__content) {
+    flex-wrap: nowrap;
+    gap: 8px;
+  }
+
+  :deep(.el-button + .el-button) {
+    margin-left: 0;
+  }
+}
 </style>

파일 크기가 너무 크기때문에 변경 상태를 표시하지 않습니다.
+ 311 - 293
src/views/pms/maintenance/IotMaintenancePlan.vue


+ 212 - 186
src/views/pms/maintenance/IotMaintenancePlanDetail.vue

@@ -5,24 +5,27 @@
       :model="formData"
       :rules="formRules"
       v-loading="formLoading"
-      style="margin-right: 4em; margin-left: 0.5em; margin-top: 1em"
-      label-width="130px"
-    >
+      style="margin-top: 1em; margin-right: 4em; margin-left: 0.5em"
+      label-width="130px">
       <div class="base-expandable-content">
         <el-row>
           <el-col :span="12">
             <el-form-item :label="t('main.planName')" prop="name">
-              <el-input type="text" v-model="formData.name" disabled/>
+              <el-input type="text" v-model="formData.name" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="12">
             <el-form-item :label="t('main.planCode')" prop="serialNumber">
-              <el-input type="text" v-model="formData.serialNumber" disabled/>
+              <el-input type="text" v-model="formData.serialNumber" disabled />
             </el-form-item>
           </el-col>
           <el-col :span="24">
             <el-form-item :label="t('form.remark')" prop="remark">
-              <el-input v-model="formData.remark" type="textarea" :placeholder="t('deviceForm.remarkHolder')" disabled/>
+              <el-input
+                v-model="formData.remark"
+                type="textarea"
+                :placeholder="t('deviceForm.remarkHolder')"
+                disabled />
             </el-form-item>
           </el-col>
         </el-row>
@@ -34,26 +37,39 @@
     <ContentWrap>
       <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
         <!-- 添加序号列 -->
-        <el-table-column
-          type="index"
-          :label="t('monitor.serial')"
-          width="70"
-          align="center"
-        />
+        <el-table-column type="index" :label="t('monitor.serial')" width="70" align="center" />
         <el-table-column :label="t('monitor.deviceCode')" align="center" prop="deviceCode" />
         <el-table-column :label="t('monitor.deviceName')" align="center" prop="deviceName" />
-        <el-table-column :label="t('operationFillForm.sumTime')" align="center" prop="totalRunTime" :formatter="erpPriceTableColumnFormatter">
+        <el-table-column
+          :label="t('operationFillForm.sumTime')"
+          align="center"
+          prop="totalRunTime"
+          :formatter="erpPriceTableColumnFormatter">
           <template #default="{ row }">
             {{ row.totalRunTime ?? row.tempTotalRunTime }}
           </template>
         </el-table-column>
-        <el-table-column :label="t('operationFillForm.sumKil')" align="center" prop="totalMileage" :formatter="erpPriceTableColumnFormatter">
+        <el-table-column
+          :label="t('operationFillForm.sumKil')"
+          align="center"
+          prop="totalMileage"
+          :formatter="erpPriceTableColumnFormatter">
           <template #default="{ row }">
             {{ row.totalMileage ?? row.tempTotalMileage }}
           </template>
         </el-table-column>
-        <el-table-column label="tempTotalRunTime" align="center" prop="tempTotalRunTime" :formatter="erpPriceTableColumnFormatter" v-if="false"/>
-        <el-table-column label="tempTotalMileage" align="center" prop="tempTotalMileage" :formatter="erpPriceTableColumnFormatter" v-if="false"/>
+        <el-table-column
+          label="tempTotalRunTime"
+          align="center"
+          prop="tempTotalRunTime"
+          :formatter="erpPriceTableColumnFormatter"
+          v-if="false" />
+        <el-table-column
+          label="tempTotalMileage"
+          align="center"
+          prop="tempTotalMileage"
+          :formatter="erpPriceTableColumnFormatter"
+          v-if="false" />
         <el-table-column :label="t('bomList.bomNode')" align="center" prop="name" />
         <el-table-column :label="t('main.mileage')" key="mileageRule" width="80">
           <template #default="scope">
@@ -61,8 +77,7 @@
               v-model="scope.row.mileageRule"
               :active-value="0"
               :inactive-value="1"
-              disabled
-            />
+              disabled />
           </template>
         </el-table-column>
         <el-table-column :label="t('main.runTime')" key="runningTimeRule" width="90">
@@ -71,8 +86,7 @@
               v-model="scope.row.runningTimeRule"
               :active-value="0"
               :inactive-value="1"
-              disabled
-            />
+              disabled />
           </template>
         </el-table-column>
         <el-table-column :label="t('main.date')" key="naturalDateRule" width="80">
@@ -81,22 +95,16 @@
               v-model="scope.row.naturalDateRule"
               :active-value="0"
               :inactive-value="1"
-              disabled
-            />
+              disabled />
           </template>
         </el-table-column>
         <el-table-column :label="t('workplace.operation')" align="center" min-width="120px">
           <template #default="scope">
             <div style="display: flex; justify-content: center; align-items: center; width: 100%">
-              <div>
-              </div>
+              <div> </div>
               <!-- 新增配置按钮 -->
               <div style="margin-left: 12px">
-                <el-button
-                  link
-                  type="primary"
-                  @click="openConfigDialog(scope.row)"
-                >
+                <el-button link type="primary" @click="openConfigDialog(scope.row)">
                   {{ t('form.set') }}
                 </el-button>
               </div>
@@ -109,7 +117,7 @@
   <ContentWrap>
     <el-form>
       <el-form-item style="float: right">
-        <el-button @click="close">{{t('operationFillForm.cancel')}}</el-button>
+        <el-button @click="close">{{ t('operationFillForm.cancel') }}</el-button>
       </el-form-item>
     </el-form>
   </ContentWrap>
@@ -117,52 +125,54 @@
   <!-- 新增配置对话框 -->
   <el-dialog
     v-model="configDialog.visible"
-    :title="`设备 ${configDialog.current?.deviceCode+'-'+configDialog.current?.name} 保养项配置`"
-    width="600px"
-  >
+    :title="`设备 ${configDialog.current?.deviceCode + '-' + configDialog.current?.name} 保养项配置`"
+    width="600px">
     <!-- 使用header插槽自定义标题 -->
     <template #header>
-      <span>设备 <strong>{{ configDialog.current?.deviceCode }}-{{ configDialog.current?.name }}</strong> 保养项配置</span>
+      <span
+        >设备
+        <strong>{{ configDialog.current?.deviceCode }}-{{ configDialog.current?.name }}</strong>
+        保养项配置</span
+      >
     </template>
-    <el-form :model="configDialog.form" label-width="200px" :rules="configFormRules" ref="configFormRef">
+    <el-form
+      :model="configDialog.form"
+      label-width="200px"
+      :rules="configFormRules"
+      ref="configFormRef">
       <div class="form-group">
         <div class="group-title">{{ t('mainPlan.basicMaintenanceRecords') }}</div>
         <!-- 里程配置 -->
         <el-form-item
           v-if="configDialog.current?.mileageRule === 0"
           :label="t('mainPlan.lastMaintenanceMileage')"
-          prop="lastRunningKilometers"
-        >
+          prop="lastRunningKilometers">
           <el-input-number
             v-model="configDialog.form.lastRunningKilometers"
             :precision="2"
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
         <!-- 运行时间配置 -->
         <el-form-item
           v-if="configDialog.current?.runningTimeRule === 0"
           :label="t('mainPlan.lastMaintenanceOperationTime')"
-          prop="lastRunningTime"
-        >
+          prop="lastRunningTime">
           <el-input-number
             v-model="configDialog.form.lastRunningTime"
             :precision="1"
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
         <!-- 自然日期配置 -->
         <el-form-item
           v-if="configDialog.current?.naturalDateRule === 0"
           :label="t('mainPlan.lastMaintenanceNaturalDate')"
-          prop="lastNaturalDate"
-        >
+          prop="lastNaturalDate">
           <el-date-picker
             v-model="configDialog.form.lastNaturalDate"
             type="date"
@@ -170,8 +180,7 @@
             format="YYYY-MM-DD"
             value-format="YYYY-MM-DD"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
       </div>
 
@@ -181,8 +190,7 @@
         <el-form-item
           v-if="configDialog.current?.mileageRule === 0"
           :label="t('mainPlan.operatingMileageCycle')"
-          prop="nextRunningKilometers"
-        >
+          prop="nextRunningKilometers">
           <el-input-number
             v-model="configDialog.form.nextRunningKilometers"
             :precision="2"
@@ -190,14 +198,12 @@
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
         <el-form-item
           v-if="configDialog.current?.mileageRule === 0"
           :label="t('mainPlan.OperatingMileageCycle_lead')"
-          prop="kiloCycleLead"
-        >
+          prop="kiloCycleLead">
           <el-input-number
             v-model="configDialog.form.kiloCycleLead"
             :precision="2"
@@ -205,8 +211,7 @@
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
       </div>
 
@@ -215,8 +220,7 @@
         <el-form-item
           v-if="configDialog.current?.runningTimeRule === 0"
           :label="t('mainPlan.RunTimeCycle')"
-          prop="nextRunningTime"
-        >
+          prop="nextRunningTime">
           <el-input-number
             v-model="configDialog.form.nextRunningTime"
             :precision="1"
@@ -224,14 +228,12 @@
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
         <el-form-item
           v-if="configDialog.current?.runningTimeRule === 0"
           :label="t('mainPlan.RunTimeCycle_Lead')"
-          prop="timePeriodLead"
-        >
+          prop="timePeriodLead">
           <el-input-number
             v-model="configDialog.form.timePeriodLead"
             :precision="1"
@@ -239,8 +241,7 @@
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
       </div>
 
@@ -248,94 +249,104 @@
         <div class="group-title">{{ t('mainPlan.NaturalDayRuleConfig') }}</div>
         <el-form-item
           v-if="configDialog.current?.naturalDateRule === 0"
-          :label="t('mainPlan.NaturalDailyCycle') "
-          prop="nextNaturalDate"
-        >
+          :label="t('mainPlan.NaturalDailyCycle')"
+          prop="nextNaturalDate">
           <el-input-number
             v-model="configDialog.form.nextNaturalDate"
             :min="0"
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
         <el-form-item
           v-if="configDialog.current?.naturalDateRule === 0"
-          :label="t('mainPlan.NaturalDailyCycle_Lead') "
-          prop="naturalDatePeriodLead"
-        >
+          :label="t('mainPlan.NaturalDailyCycle_Lead')"
+          prop="naturalDatePeriodLead">
           <el-input-number
             v-model="configDialog.form.naturalDatePeriodLead"
             :min="0"
             :controls="false"
             controls-position="right"
             style="width: 60%"
-            disabled
-          />
+            disabled />
         </el-form-item>
       </div>
 
       <!-- 运行记录模板中 多个 累计运行时长 累计运行里程 属性匹配-->
-      <div class="form-group"
-           v-if="(configDialog.current?.runningTimeRule === 0 || configDialog.current?.mileageRule === 0)
-            && (configDialog.current?.timeAccumulatedAttrs?.length || configDialog.current?.mileageAccumulatedAttrs?.length)
-            && !configDialog.current.totalRunTime && !configDialog.current.totalMileage" >
+      <div
+        class="form-group"
+        v-if="
+          (configDialog.current?.runningTimeRule === 0 ||
+            configDialog.current?.mileageRule === 0) &&
+          (configDialog.current?.timeAccumulatedAttrs?.length ||
+            configDialog.current?.mileageAccumulatedAttrs?.length) &&
+          !configDialog.current.totalRunTime &&
+          !configDialog.current.totalMileage
+        ">
         <div class="group-title">{{ t('mainPlan.accumulatedParams') }}</div>
         <!-- 累计运行时长 -->
         <el-form-item
-          v-if="configDialog.current?.runningTimeRule === 0
-          && configDialog.current?.timeAccumulatedAttrs?.length && !configDialog.current.totalRunTime"
+          v-if="
+            configDialog.current?.runningTimeRule === 0 &&
+            configDialog.current?.timeAccumulatedAttrs?.length &&
+            !configDialog.current.totalRunTime
+          "
           :label="t('mainPlan.accumulatedRunTime')"
           prop="accumulatedTimeOption"
-          :rules="[{
-            required: configDialog.current?.runningTimeRule === 0 && configDialog.current?.timeAccumulatedAttrs?.length,
-            message: '请选择累计运行时长',
-            trigger: 'change'
-          }]"
-        >
+          :rules="[
+            {
+              required:
+                configDialog.current?.runningTimeRule === 0 &&
+                configDialog.current?.timeAccumulatedAttrs?.length,
+              message: '请选择累计运行时长',
+              trigger: 'change'
+            }
+          ]">
           <el-select
             v-model="configDialog.form.accumulatedTimeOption"
             placeholder="请选择累计运行时长"
             style="width: 80%"
             clearable
             @change="handleAccumulatedTimeChange"
-            disabled
-          >
+            disabled>
             <el-option
               v-for="(item, index) in configDialog.current.timeAccumulatedAttrs"
               :key="`time-${item.pointName}-${index}`"
               :label="item.pointName"
-              :value="item.pointName"
-            />
+              :value="item.pointName" />
           </el-select>
         </el-form-item>
         <!-- 累计运行公里数 -->
         <el-form-item
-          v-if="configDialog.current?.mileageRule === 0
-          && configDialog.current?.mileageAccumulatedAttrs?.length && !configDialog.current.totalMileage"
+          v-if="
+            configDialog.current?.mileageRule === 0 &&
+            configDialog.current?.mileageAccumulatedAttrs?.length &&
+            !configDialog.current.totalMileage
+          "
           :label="t('mainPlan.accumulatedMileage')"
           prop="accumulatedMileageOption"
-          :rules="[{
-            required: configDialog.current?.mileageRule === 0 && configDialog.current?.mileageAccumulatedAttrs?.length,
-            message: '请选择累计运行公里数',
-            trigger: 'change'
-          }]"
-        >
+          :rules="[
+            {
+              required:
+                configDialog.current?.mileageRule === 0 &&
+                configDialog.current?.mileageAccumulatedAttrs?.length,
+              message: '请选择累计运行公里数',
+              trigger: 'change'
+            }
+          ]">
           <el-select
             v-model="configDialog.form.accumulatedMileageOption"
             placeholder="请选择累计运行公里数"
             style="width: 80%"
             clearable
             @change="handleAccumulatedMileageChange"
-            disabled
-          >
+            disabled>
             <el-option
               v-for="(item, index) in configDialog.current.mileageAccumulatedAttrs"
               :key="`mileage-${item.pointName}-${index}`"
               :label="item.pointName"
-              :value="item.pointName"
-            />
+              :value="item.pointName" />
           </el-select>
         </el-form-item>
       </div>
@@ -344,20 +355,19 @@
       <el-button @click="configDialog.visible = false">{{ t('common.cancel') }}</el-button>
     </template>
   </el-dialog>
-
 </template>
 <script setup lang="ts">
-import { IotMaintainApi, IotMaintainVO } from '@/api/pms/maintain'
-import { IotDeviceApi, IotDeviceVO } from '@/api/pms/device'
+import { IotMaintainApi } from '@/api/pms/maintain'
+import { IotDeviceApi } from '@/api/pms/device'
 import * as UserApi from '@/api/system/user'
 import { useUserStore } from '@/store/modules/user'
 import { ref } from 'vue'
 import { IotMaintenanceBomApi, IotMaintenanceBomVO } from '@/api/pms/iotmaintenancebom'
-import { IotMaintenancePlanApi, IotMaintenancePlanVO } from '@/api/pms/maintenance'
+import { IotMaintenancePlanApi } from '@/api/pms/maintenance'
 import { useTagsViewStore } from '@/store/modules/tagsView'
-import MainPlanDeviceList from "@/views/pms/maintenance/MainPlanDeviceList.vue";
-import * as DeptApi from "@/api/system/dept";
-import {erpPriceTableColumnFormatter} from "@/utils";
+import MainPlanDeviceList from '@/views/pms/maintenance/maintenance-device-list.vue'
+import * as DeptApi from '@/api/system/dept'
+import { erpPriceTableColumnFormatter } from '@/utils'
 import dayjs from 'dayjs'
 
 /** 保养计划 表单 */
@@ -386,11 +396,11 @@ const formData = ref({
   responsiblePerson: undefined,
   remark: undefined,
   failureName: undefined,
-  status: undefined,
+  status: undefined
 })
 const formRules = reactive({
   name: [{ required: true, message: '计划名称不能为空', trigger: 'blur' }],
-  responsiblePerson: [{ required: true, message: '责任人不能为空', trigger: 'blur' }],
+  responsiblePerson: [{ required: true, message: '责任人不能为空', trigger: 'blur' }]
 })
 const formRef = ref() // 表单 Ref
 
@@ -411,8 +421,8 @@ const configDialog = reactive({
     timePeriodLead: 0,
     naturalDatePeriodLead: 0,
     // 多个累计时长 累计里程 匹配
-    accumulatedTimeOption: null,    // 累计运行时长选项
-    accumulatedMileageOption: null, // 累计运行公里数选项
+    accumulatedTimeOption: null, // 累计运行时长选项
+    accumulatedMileageOption: null // 累计运行公里数选项
   }
 })
 
@@ -444,15 +454,15 @@ const openConfigDialog = (row: IotMaintenanceBomVO) => {
     naturalDatePeriodLead: row.naturalDatePeriodLead || 0
   }
 
-  configDialog.form.accumulatedTimeOption = row.code || null;
-  configDialog.form.accumulatedMileageOption = row.type || null;
+  configDialog.form.accumulatedTimeOption = row.code || null
+  configDialog.form.accumulatedMileageOption = row.type || null
 
   configDialog.visible = true
 }
 
 // 保存配置
 const saveConfig = () => {
-  (configFormRef.value as any).validate((valid: boolean) => {
+  ;(configFormRef.value as any).validate((valid: boolean) => {
     if (!valid) return
     if (!configDialog.current) return
 
@@ -468,8 +478,8 @@ const saveConfig = () => {
       requiredFields.push('nextNaturalDate', 'naturalDatePeriodLead')
     }
 
-    const missingFields = requiredFields.filter(field =>
-      !configDialog.form[field as keyof typeof configDialog.form]
+    const missingFields = requiredFields.filter(
+      (field) => !configDialog.form[field as keyof typeof configDialog.form]
     )
 
     if (missingFields.length > 0) {
@@ -510,8 +520,8 @@ const queryParams = reactive({
   planId: id
 })
 
-const deviceChoose = async(selectedDevices) => {
-  const newIds = selectedDevices.map(device => device.id)
+const deviceChoose = async (selectedDevices) => {
+  const newIds = selectedDevices.map((device) => device.id)
   deviceIds.value = [...new Set([...deviceIds.value, ...newIds])]
   const params = {
     deviceIds: deviceIds.value.join(',') // 明确传递数组参数
@@ -520,7 +530,7 @@ const deviceChoose = async(selectedDevices) => {
   // 根据选择的设备筛选出设备关系的分类BOM中与保养相关的节点项
   const res = await IotDeviceApi.deviceAssociateBomList(queryParams)
   const rawData = res || []
-  if(rawData.length === 0){
+  if (rawData.length === 0) {
     message.error('选择的设备不存在待保养BOM项')
   }
   if (!Array.isArray(rawData)) {
@@ -528,7 +538,7 @@ const deviceChoose = async(selectedDevices) => {
     return
   }
   // 转换数据结构(根据你的接口定义调整)
-  const newItems = rawData.map(device => ({
+  const newItems = rawData.map((device) => ({
     assetClass: device.assetClass,
     deviceCode: device.deviceCode,
     deviceName: device.deviceName,
@@ -537,7 +547,7 @@ const deviceChoose = async(selectedDevices) => {
     name: device.name,
     code: device.code,
     assetProperty: device.assetProperty,
-    remark: null,    // 初始化备注
+    remark: null, // 初始化备注
     deviceId: device.id, // 移除操作需要
     bomNodeId: device.bomNodeId,
     totalRunTime: device.totalRunTime,
@@ -552,13 +562,13 @@ const deviceChoose = async(selectedDevices) => {
     naturalDatePeriodLead: 0
   }))
   // 获取选择的设备相关的id数组
-  newItems.forEach(item => {
+  newItems.forEach((item) => {
     deviceIds.value.push(item.deviceId)
   })
   // 合并到现有列表(去重)
-  newItems.forEach(item => {
+  newItems.forEach((item) => {
     const exists = list.value.some(
-      existing => (existing.deviceId === item.deviceId && existing.bomNodeId === item.bomNodeId)
+      (existing) => existing.deviceId === item.deviceId && existing.bomNodeId === item.bomNodeId
     )
     if (!exists) {
       list.value.push(item)
@@ -568,21 +578,19 @@ const deviceChoose = async(selectedDevices) => {
 
 const deviceFormRef = ref<InstanceType<typeof MainPlanDeviceList>>()
 const openForm = () => {
-  deviceFormRef.value?.open();
+  deviceFormRef.value?.open()
 }
 
 const close = () => {
   delView(unref(currentRoute))
-  push({ name: 'IotMaintenancePlan', params:{}})
+  push({ name: 'IotMaintenancePlan', params: {} })
 }
 
 // 累计运行时长变更
-const handleAccumulatedTimeChange = (option) => {
-}
+const handleAccumulatedTimeChange = (option) => {}
 
 // 累计运行公里数变更
-const handleAccumulatedMileageChange = (option) => {
-}
+const handleAccumulatedMileageChange = (option) => {}
 
 /** 提交表单 */
 const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
@@ -617,46 +625,58 @@ const submitForm = async () => {
 
 // 新增表单校验规则
 const configFormRules = reactive({
-  nextRunningKilometers: [{
-    required: true,
-    message: '里程周期必须填写',
-    trigger: 'blur'
-  }],
-  kiloCycleLead: [{
-    required: true,
-    message: '提前量必须填写',
-    trigger: 'blur'
-  }],
-  nextRunningTime: [{
-    required: true,
-    message: '时间周期必须填写',
-    trigger: 'blur'
-  }],
-  timePeriodLead: [{
-    required: true,
-    message: '提前量必须填写',
-    trigger: 'blur'
-  }],
-  nextNaturalDate: [{
-    required: true,
-    message: '自然日周期必须填写',
-    trigger: 'blur'
-  }],
-  naturalDatePeriodLead: [{
-    required: true,
-    message: '提前量必须填写',
-    trigger: 'blur'
-  }]
+  nextRunningKilometers: [
+    {
+      required: true,
+      message: '里程周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  kiloCycleLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ],
+  nextRunningTime: [
+    {
+      required: true,
+      message: '时间周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  timePeriodLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ],
+  nextNaturalDate: [
+    {
+      required: true,
+      message: '自然日周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  naturalDatePeriodLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ]
 })
 
 /** 校验表格数据 */
 const validateTableData = (): boolean => {
   let isValid = true
   const errorMessages: string[] = []
-  const noRulesErrorMessages: string[] = []  // 未设置任何保养项规则 的错误提示信息
-  const noRules: string[] = []  // 行记录中设置了保养规则的记录数量
-  const configErrors: string[] = []   // 保养规则配置弹出框
-  let shouldBreak = false;
+  const noRulesErrorMessages: string[] = [] // 未设置任何保养项规则 的错误提示信息
+  const noRules: string[] = [] // 行记录中设置了保养规则的记录数量
+  const configErrors: string[] = [] // 保养规则配置弹出框
+  let shouldBreak = false
 
   if (list.value.length === 0) {
     errorMessages.push('请至少添加一条设备保养明细')
@@ -667,20 +687,24 @@ const validateTableData = (): boolean => {
   }
 
   list.value.forEach((row, index) => {
-    if (shouldBreak) return;
+    if (shouldBreak) return
     const rowNumber = index + 1 // 用户可见的行号从1开始
     const deviceIdentifier = `${row.deviceCode}-${row.name}` // 设备标识
     // 校验逻辑
     const checkConfig = (ruleName: string, ruleValue: number, configField: keyof typeof row) => {
-      if (ruleValue === 0) { // 规则开启
+      if (ruleValue === 0) {
+        // 规则开启
         if (!row[configField] || row[configField] <= 0) {
-          configErrors.push(`第 ${rowNumber} 行(${deviceIdentifier}):请点击【配置】维护${ruleName}上次保养值`)
+          configErrors.push(
+            `第 ${rowNumber} 行(${deviceIdentifier}):请点击【配置】维护${ruleName}上次保养值`
+          )
           isValid = false
         }
       }
     }
     // 里程校验逻辑
-    if (row.mileageRule === 0) { // 假设 0 表示开启状态
+    if (row.mileageRule === 0) {
+      // 假设 0 表示开启状态
       if (!row.nextRunningKilometers || row.nextRunningKilometers <= 0) {
         errorMessages.push(`第 ${rowNumber} 行:开启里程规则必须填写有效的里程周期`)
         isValid = false
@@ -713,10 +737,10 @@ const validateTableData = (): boolean => {
     // 如果选中的一行记录未设置任何保养规则 提示 ‘保养项未设置任何保养规则’
     if (noRules.length === 3) {
       isValid = false
-      shouldBreak = true; // 设置标志变量为true,退出循环
+      shouldBreak = true // 设置标志变量为true,退出循环
       noRulesErrorMessages.push('保养项至少设置1个保养规则')
     }
-    noRules.length = 0;
+    noRules.length = 0
   })
   if (errorMessages.length > 0) {
     message.error('设置保养规则后,请维护对应的周期值')
@@ -761,29 +785,31 @@ onMounted(async () => {
   deptUsers.value = await UserApi.getDeptUsersByDeptId(deptId)
   formData.value.deptId = deptId
   // if (id){
-    formType.value = 'update'
-    const plan = await IotMaintenancePlanApi.getIotMaintenancePlan(id);
-    deviceLabel.value = plan.deviceName
-    formData.value = plan
+  formType.value = 'update'
+  const plan = await IotMaintenancePlanApi.getIotMaintenancePlan(id)
+  deviceLabel.value = plan.deviceName
+  formData.value = plan
   // 查询保养责任人
-  const personId = formData.value.responsiblePerson ? Number(formData.value.responsiblePerson) : 0;
+  const personId = formData.value.responsiblePerson ? Number(formData.value.responsiblePerson) : 0
   UserApi.getUser(personId).then((res) => {
-    formData.value.responsiblePerson = res.nickname;
+    formData.value.responsiblePerson = res.nickname
   })
   // 查询保养计划明细
-  const data = await IotMaintenanceBomApi.getMainPlanBOMs(queryParams);
+  const data = await IotMaintenanceBomApi.getMainPlanBOMs(queryParams)
   list.value = []
   if (Array.isArray(data)) {
-    list.value = data.map(item => ({
+    list.value = data.map((item) => ({
       ...item,
       // 这里可以添加必要的字段转换(如果有日期等需要格式化的字段)
-      lastNaturalDate: item.lastNaturalDate ? dayjs(item.lastNaturalDate).format('YYYY-MM-DD') : null
+      lastNaturalDate: item.lastNaturalDate
+        ? dayjs(item.lastNaturalDate).format('YYYY-MM-DD')
+        : null
     }))
   }
 })
 const handleDelete = async (str: string) => {
   try {
-    const index = list.value.findIndex((item) => (item.id+'-'+item.bomNodeId) === str)
+    const index = list.value.findIndex((item) => item.id + '-' + item.bomNodeId === str)
     if (index !== -1) {
       // 通过 splice 删除元素
       list.value.splice(index, 1)
@@ -799,17 +825,17 @@ const handleDelete = async (str: string) => {
 }
 
 :deep(.el-input-number .el-input__inner) {
-  text-align: left !important;
   padding-left: 10px; /* 保持左侧间距 */
+  text-align: left !important;
 }
 
 /* 分组容器样式 */
 .form-group {
   position: relative;
-  border: 1px solid #dcdfe6;
-  border-radius: 4px;
   padding: 20px 15px 10px;
   margin-bottom: 18px;
+  border: 1px solid #dcdfe6;
+  border-radius: 4px;
   transition: border-color 0.2s;
 }
 
@@ -818,10 +844,10 @@ const handleDelete = async (str: string) => {
   position: absolute;
   top: -10px;
   left: 20px;
-  background: white;
   padding: 0 8px;
-  color: #606266;
   font-size: 12px;
   font-weight: 500;
+  color: #606266;
+  background: white;
 }
 </style>

+ 58 - 115
src/views/pms/maintenance/IotMaintenancePlanEdit.vue

@@ -6,8 +6,7 @@
       :rules="formRules"
       v-loading="formLoading"
       style="margin-top: 1em; margin-right: 4em; margin-left: 0.5em"
-      label-width="130px"
-    >
+      label-width="130px">
       <div class="base-expandable-content">
         <el-row>
           <el-col :span="12">
@@ -25,8 +24,7 @@
               <el-input
                 v-model="formData.remark"
                 type="textarea"
-                :placeholder="t('iotMaintain.remarkHolder')"
-              />
+                :placeholder="t('iotMaintain.remarkHolder')" />
             </el-form-item>
           </el-col>
         </el-row>
@@ -41,8 +39,7 @@
         :model="queryParams"
         ref="queryFormRef"
         :inline="true"
-        label-width="68px"
-      >
+        label-width="68px">
         <el-form-item>
           <el-button @click="openForm" type="warning">
             <Icon icon="ep:plus" class="mr-5px" /> {{ t('operationFill.add') }}</el-button
@@ -66,8 +63,7 @@
         :cell-class-name="cellClassName"
         :max-height="420"
         scrollbar-always-on
-        :cell-style="cellStyle"
-      >
+        :cell-style="cellStyle">
         <!-- 添加序号列 -->
         <el-table-column
           type="index"
@@ -75,31 +71,27 @@
           :min-width="48"
           align="center"
           prop="serial"
-          fixed="left"
-        />
+          fixed="left" />
         <el-table-column label="设备id" align="center" prop="deviceId" v-if="false" />
         <el-table-column
           :label="t('iotMaintain.deviceCode')"
           align="center"
           prop="deviceCode"
           :min-width="columnWidths.deviceCode"
-          fixed="left"
-        />
+          fixed="left" />
         <el-table-column
           :label="t('iotMaintain.deviceName')"
           align="center"
           prop="deviceName"
           :min-width="columnWidths.deviceName"
-          fixed="left"
-        />
+          fixed="left" />
         <el-table-column
           :label="t('bomList.bomNode')"
           align="center"
           prop="name"
           :show-overflow-tooltip="false"
           :min-width="columnWidths.name"
-          fixed="left"
-        >
+          fixed="left">
           <template #default="{ row }">
             <div class="full-content-cell">
               {{ row.name }}
@@ -111,15 +103,13 @@
           align="center"
           key="runningTimeRule"
           prop="runningTimeRule"
-          :min-width="columnWidths.runningTimeRule"
-        >
+          :min-width="columnWidths.runningTimeRule">
           <template #default="scope">
             <el-switch
               v-model="scope.row.runningTimeRule"
               :active-value="0"
               :inactive-value="1"
-              @change="handleRuleChange(scope.row, 'runningTime')"
-            />
+              @change="handleRuleChange(scope.row, 'runningTime')" />
           </template>
         </el-table-column>
         <el-table-column
@@ -127,15 +117,13 @@
           align="center"
           key="mileageRule"
           prop="mileageRule"
-          :min-width="columnWidths.mileageRule"
-        >
+          :min-width="columnWidths.mileageRule">
           <template #default="scope">
             <el-switch
               v-model="scope.row.mileageRule"
               :active-value="0"
               :inactive-value="1"
-              @change="handleRuleChange(scope.row, 'mileage')"
-            />
+              @change="handleRuleChange(scope.row, 'mileage')" />
           </template>
         </el-table-column>
 
@@ -144,15 +132,13 @@
           align="center"
           key="naturalDateRule"
           prop="naturalDateRule"
-          :min-width="columnWidths.naturalDateRule"
-        >
+          :min-width="columnWidths.naturalDateRule">
           <template #default="scope">
             <el-switch
               v-model="scope.row.naturalDateRule"
               :active-value="0"
               :inactive-value="1"
-              @change="handleRuleChange(scope.row, 'date')"
-            />
+              @change="handleRuleChange(scope.row, 'date')" />
           </template>
         </el-table-column>
         <el-table-column
@@ -160,8 +146,7 @@
           align="center"
           prop="totalRunTime"
           :formatter="erpPriceTableColumnFormatter"
-          :min-width="columnWidths.totalRunTime"
-        >
+          :min-width="columnWidths.totalRunTime">
           <template #default="{ row }">
             {{ row.totalRunTime ?? row.tempTotalRunTime }}
           </template>
@@ -171,8 +156,7 @@
           align="center"
           prop="totalMileage"
           :formatter="erpPriceTableColumnFormatter"
-          :min-width="columnWidths.totalMileage"
-        >
+          :min-width="columnWidths.totalMileage">
           <template #default="{ row }">
             {{ row.totalMileage ?? row.tempTotalMileage }}
           </template>
@@ -182,21 +166,18 @@
           align="center"
           prop="tempTotalRunTime"
           :formatter="erpPriceTableColumnFormatter"
-          v-if="false"
-        />
+          v-if="false" />
         <el-table-column
           label="tempTotalMileage"
           align="center"
           prop="tempTotalMileage"
           :formatter="erpPriceTableColumnFormatter"
-          v-if="false"
-        />
+          v-if="false" />
         <el-table-column
           :label="t('mainPlan.lastMaintenanceDate')"
           prop="lastMaintenanceDate"
           align="center"
-          :min-width="columnWidths.lastMaintenanceDate"
-        >
+          :min-width="columnWidths.lastMaintenanceDate">
           <template #default="{ row }">
             <div class="full-content-cell">
               {{ row.lastMaintenanceDate }}
@@ -210,8 +191,7 @@
             align="center"
             prop="lastRunningTime"
             :formatter="erpPriceTableColumnFormatter"
-            :min-width="columnWidths.lastRunningTime"
-          >
+            :min-width="columnWidths.lastRunningTime">
             <template #default="{ row }">
               {{ row.lastRunningTime }}
             </template>
@@ -220,8 +200,7 @@
             :label="t('mainPlan.nextMaintenanceH')"
             align="center"
             prop="nextMaintenanceH"
-            :min-width="columnWidths.nextMaintenanceH"
-          >
+            :min-width="columnWidths.nextMaintenanceH">
             <template #default="{ row }">
               {{ row.nextMaintenanceH ?? '-' }}
             </template>
@@ -230,8 +209,7 @@
             :label="t('mainPlan.remainH')"
             align="center"
             prop="remainH"
-            :min-width="columnWidths.remainH"
-          >
+            :min-width="columnWidths.remainH">
             <template #default="{ row }">
               {{ row.remainH ?? '-' }}
             </template>
@@ -244,8 +222,7 @@
             align="center"
             prop="lastRunningKilometers"
             :formatter="erpPriceTableColumnFormatter"
-            :min-width="columnWidths.lastRunningKilometers"
-          >
+            :min-width="columnWidths.lastRunningKilometers">
             <template #default="{ row }">
               {{ row.lastRunningKilometers }}
             </template>
@@ -254,8 +231,7 @@
             :label="t('mainPlan.nextMaintenanceKm')"
             align="center"
             prop="nextMaintenanceKm"
-            :min-width="columnWidths.nextMaintenanceKm"
-          >
+            :min-width="columnWidths.nextMaintenanceKm">
             <template #default="{ row }">
               {{ row.nextMaintenanceKm ?? '-' }}
             </template>
@@ -264,8 +240,7 @@
             :label="t('mainPlan.remainKm')"
             align="center"
             prop="remainKm"
-            :min-width="columnWidths.remainKm"
-          >
+            :min-width="columnWidths.remainKm">
             <template #default="{ row }">
               {{ row.remainKm ?? '-' }}
             </template>
@@ -279,8 +254,7 @@
             align="center"
             prop="tempLastNaturalDate"
             :formatter="erpPriceTableColumnFormatter"
-            :min-width="columnWidths.tempLastNaturalDate"
-          >
+            :min-width="columnWidths.tempLastNaturalDate">
             <template #default="{ row }">
               {{ row.tempLastNaturalDate }}
             </template>
@@ -289,8 +263,7 @@
             :label="t('mainPlan.nextMaintDate')"
             align="center"
             prop="nextMaintenanceDate"
-            :min-width="columnWidths.nextMaintenanceDate"
-          >
+            :min-width="columnWidths.nextMaintenanceDate">
             <template #default="{ row }">
               {{ row.nextMaintenanceDate ?? '-' }}
             </template>
@@ -299,8 +272,7 @@
             :label="t('mainPlan.remainDay')"
             align="center"
             prop="remainDay"
-            :min-width="columnWidths.remainDay"
-          >
+            :min-width="columnWidths.remainDay">
             <template #default="{ row }">
               {{ row.remainDay ?? '-' }}
             </template>
@@ -312,8 +284,7 @@
           align="center"
           :min-width="columnWidths.operation"
           prop="operation"
-          fixed="right"
-        >
+          fixed="right">
           <template #default="scope">
             <div style="display: flex; justify-content: center; align-items: center; width: 100%">
               <div>
@@ -322,8 +293,7 @@
                   style="vertical-align: middle"
                   link
                   type="danger"
-                  @click="handleDelete(scope.row.deviceId + '-' + scope.row.bomNodeId)"
-                >
+                  @click="handleDelete(scope.row.deviceId + '-' + scope.row.bomNodeId)">
                   {{ t('modelTemplate.delete') }}
                 </el-button>
               </div>
@@ -367,8 +337,7 @@
     v-model="configDialog.visible"
     :title="`设备 ${configDialog.current?.deviceCode + '-' + configDialog.current?.name} 保养配置`"
     width="600px"
-    :close-on-click-modal="false"
-  >
+    :close-on-click-modal="false">
     <!-- 使用header插槽自定义标题 -->
     <template #header>
       <span
@@ -381,54 +350,47 @@
       :model="configDialog.form"
       label-width="200px"
       :rules="configFormRules"
-      ref="configFormRef"
-    >
+      ref="configFormRef">
       <div class="form-group">
         <div class="group-title">{{ t('mainPlan.basicMaintenanceRecords') }}</div>
         <!-- 里程配置 -->
         <el-form-item
           v-if="configDialog.current?.mileageRule === 0"
           :label="t('mainPlan.lastMaintenanceMileage')"
-          prop="lastRunningKilometers"
-        >
+          prop="lastRunningKilometers">
           <el-input-number
             v-model="configDialog.form.lastRunningKilometers"
             :precision="2"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
         <!-- 运行时间配置 -->
         <el-form-item
           v-if="configDialog.current?.runningTimeRule === 0"
           :label="t('mainPlan.lastMaintenanceOperationTime')"
-          prop="lastRunningTime"
-        >
+          prop="lastRunningTime">
           <el-input-number
             v-model="configDialog.form.lastRunningTime"
             :precision="1"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
         <!-- 自然日期配置 -->
         <el-form-item
           v-if="configDialog.current?.naturalDateRule === 0"
           :label="t('mainPlan.lastMaintenanceNaturalDate')"
-          prop="lastNaturalDate"
-        >
+          prop="lastNaturalDate">
           <el-date-picker
             v-model="configDialog.form.lastNaturalDate"
             type="date"
             placeholder="选择日期"
             format="YYYY-MM-DD"
             value-format="YYYY-MM-DD"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
       </div>
 
@@ -438,30 +400,26 @@
         <el-form-item
           v-if="configDialog.current?.mileageRule === 0"
           :label="t('mainPlan.operatingMileageCycle')"
-          prop="nextRunningKilometers"
-        >
+          prop="nextRunningKilometers">
           <el-input-number
             v-model="configDialog.form.nextRunningKilometers"
             :precision="2"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
         <el-form-item
           v-if="configDialog.current?.mileageRule === 0"
           :label="t('mainPlan.OperatingMileageCycle_lead')"
-          prop="kiloCycleLead"
-        >
+          prop="kiloCycleLead">
           <el-input-number
             v-model="configDialog.form.kiloCycleLead"
             :precision="2"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
       </div>
 
@@ -470,30 +428,26 @@
         <el-form-item
           v-if="configDialog.current?.runningTimeRule === 0"
           :label="t('mainPlan.RunTimeCycle')"
-          prop="nextRunningTime"
-        >
+          prop="nextRunningTime">
           <el-input-number
             v-model="configDialog.form.nextRunningTime"
             :precision="1"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
         <el-form-item
           v-if="configDialog.current?.runningTimeRule === 0"
           :label="t('mainPlan.RunTimeCycle_Lead')"
-          prop="timePeriodLead"
-        >
+          prop="timePeriodLead">
           <el-input-number
             v-model="configDialog.form.timePeriodLead"
             :precision="1"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
       </div>
 
@@ -502,28 +456,24 @@
         <el-form-item
           v-if="configDialog.current?.naturalDateRule === 0"
           :label="t('mainPlan.NaturalDailyCycle')"
-          prop="nextNaturalDate"
-        >
+          prop="nextNaturalDate">
           <el-input-number
             v-model="configDialog.form.nextNaturalDate"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
         <el-form-item
           v-if="configDialog.current?.naturalDateRule === 0"
           :label="t('mainPlan.NaturalDailyCycle_Lead')"
-          prop="naturalDatePeriodLead"
-        >
+          prop="naturalDatePeriodLead">
           <el-input-number
             v-model="configDialog.form.naturalDatePeriodLead"
             :min="0"
             controls-position="right"
             :controls="false"
-            style="width: 60%"
-          />
+            style="width: 60%" />
         </el-form-item>
       </div>
 
@@ -537,8 +487,7 @@
             configDialog.current?.mileageAccumulatedAttrs?.length) &&
           (configDialog.current.totalRunTime == null || isNaN(configDialog.current.totalRunTime)) &&
           (configDialog.current.totalMileage == null || isNaN(configDialog.current.totalMileage))
-        "
-      >
+        ">
         <div class="group-title">{{ t('mainPlan.accumulatedParams') }}</div>
         <!-- 累计运行时长 -->
         <el-form-item
@@ -559,21 +508,18 @@
               message: '请选择累计运行时长',
               trigger: 'change'
             }
-          ]"
-        >
+          ]">
           <el-select
             v-model="configDialog.form.accumulatedTimeOption"
             placeholder="请选择累计运行时长"
             style="width: 80%"
             clearable
-            @change="handleAccumulatedTimeChange"
-          >
+            @change="handleAccumulatedTimeChange">
             <el-option
               v-for="(item, index) in configDialog.current.timeAccumulatedAttrs"
               :key="`time-${item.pointName}-${index}`"
               :label="item.pointName"
-              :value="item.pointName"
-            />
+              :value="item.pointName" />
           </el-select>
         </el-form-item>
         <!-- 累计运行公里数 -->
@@ -595,21 +541,18 @@
               message: '请选择累计运行公里数',
               trigger: 'change'
             }
-          ]"
-        >
+          ]">
           <el-select
             v-model="configDialog.form.accumulatedMileageOption"
             placeholder="请选择累计运行公里数"
             style="width: 80%"
             clearable
-            @change="handleAccumulatedMileageChange"
-          >
+            @change="handleAccumulatedMileageChange">
             <el-option
               v-for="(item, index) in configDialog.current.mileageAccumulatedAttrs"
               :key="`mileage-${item.pointName}-${index}`"
               :label="item.pointName"
-              :value="item.pointName"
-            />
+              :value="item.pointName" />
           </el-select>
         </el-form-item>
       </div>
@@ -629,7 +572,7 @@ import { IotMaintenanceBomApi, IotMaintenanceBomVO } from '@/api/pms/iotmaintena
 import { IotMaintenancePlanApi } from '@/api/pms/maintenance'
 import { useTagsViewStore } from '@/store/modules/tagsView'
 import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
-import MainPlanDeviceList from '@/views/pms/maintenance/MainPlanDeviceList.vue'
+import MainPlanDeviceList from '@/views/pms/maintenance/maintenance-device-list.vue'
 import * as DeptApi from '@/api/system/dept'
 import { erpPriceTableColumnFormatter } from '@/utils'
 import dayjs from 'dayjs'

+ 1605 - 0
src/views/pms/maintenance/IotMaintenancePlanManage.vue

@@ -0,0 +1,1605 @@
+<template>
+  <div class="maintenance-plan-page" v-loading="formLoading">
+    <section class="plan-section plan-form-section">
+      <el-form
+        ref="formRef"
+        :model="formData"
+        :rules="formRules"
+        label-width="88px"
+        class="plan-form">
+        <el-row :gutter="48">
+          <el-col :span="13">
+            <el-form-item :label="t('main.planName')" prop="name">
+              <el-input v-model="formData.name" :disabled="isReadonly" />
+            </el-form-item>
+          </el-col>
+          <el-col :span="11">
+            <el-form-item :label="t('main.planCode')" prop="serialNumber">
+              <el-input v-model="formData.serialNumber" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24">
+            <el-form-item :label="t('iotMaintain.remark')" prop="remark">
+              <el-input
+                v-model="formData.remark"
+                type="textarea"
+                :rows="2"
+                :placeholder="t('iotMaintain.remarkHolder')"
+                :disabled="isReadonly" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+    </section>
+
+    <section class="plan-section plan-table-section">
+      <div v-if="!isReadonly" class="table-toolbar">
+        <el-button @click="openForm" type="warning">
+          <Icon icon="ep:plus" class="mr-5px" /> {{ t('operationFill.add') }}
+        </el-button>
+      </div>
+
+      <div class="table-resizer">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <ZmTable
+              ref="tableRef"
+              class="maintenance-plan-table"
+              :data="list"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :show-border="true"
+              :cell-class-name="cellClassName"
+              :cell-style="cellStyle"
+              :column-max-width="420"
+              :highlightCurrentRow="false"
+              :hover-highlight="false"
+              @header-dragend="handleHeaderDragEnd">
+              <ZmTableColumn
+                type="index"
+                :label="t('iotDevice.serial')"
+                :width="62"
+                fixed="left"
+                hide-in-column-settings />
+              <ZmTableColumn
+                prop="deviceCode"
+                :label="t('iotMaintain.deviceCode')"
+                :min-width="100"
+                fixed="left" />
+              <ZmTableColumn
+                prop="deviceName"
+                :label="t('iotMaintain.deviceName')"
+                :min-width="110"
+                fixed="left" />
+              <ZmTableColumn
+                prop="name"
+                :label="t('bomList.bomNode')"
+                :min-width="190"
+                fixed="left"
+                :show-overflow-tooltip="false">
+                <template #default="{ row }">
+                  <div class="full-content-cell">{{ row.name }}</div>
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="runningTimeRule" :label="t('main.runTime')" :width="92">
+                <template #default="{ row }">
+                  <el-switch
+                    v-model="row.runningTimeRule"
+                    :active-value="0"
+                    :inactive-value="1"
+                    :disabled="isReadonly"
+                    @change="handleRuleChange(row, 'runningTime')" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="mileageRule" :label="t('main.mileage')" :width="92">
+                <template #default="{ row }">
+                  <el-switch
+                    v-model="row.mileageRule"
+                    :active-value="0"
+                    :inactive-value="1"
+                    :disabled="isReadonly"
+                    @change="handleRuleChange(row, 'mileage')" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="naturalDateRule" :label="t('main.date')" :width="92">
+                <template #default="{ row }">
+                  <el-switch
+                    v-model="row.naturalDateRule"
+                    :active-value="0"
+                    :inactive-value="1"
+                    :disabled="isReadonly"
+                    @change="handleRuleChange(row, 'date')" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                prop="totalRunTime"
+                :label="t('operationFillForm.sumTime')"
+                :min-width="130"
+                :real-value="(row) => row.totalRunTime ?? row.tempTotalRunTime">
+                <template #default="{ row }">
+                  {{ row.totalRunTime ?? row.tempTotalRunTime ?? '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                prop="totalMileage"
+                :label="t('operationFillForm.sumKil')"
+                :min-width="150"
+                :real-value="(row) => row.totalMileage ?? row.tempTotalMileage">
+                <template #default="{ row }">
+                  {{ row.totalMileage ?? row.tempTotalMileage ?? '-' }}
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                prop="lastMaintenanceDate"
+                :label="t('mainPlan.lastMaintenanceDate')"
+                :min-width="126"
+                :real-value="(row) => formatDate(row.lastMaintenanceDate)">
+                <template #default="{ row }">
+                  {{ formatDate(row.lastMaintenanceDate) }}
+                </template>
+              </ZmTableColumn>
+
+              <ZmTableColumn v-if="hasTimeRule" column-key="time-group" label="保养时长" is-parent>
+                <ZmTableColumn
+                  prop="lastRunningTime"
+                  :label="t('mainPlan.lastMaintenanceOperationTime')"
+                  :min-width="150" />
+                <ZmTableColumn
+                  prop="nextMaintenanceH"
+                  :label="t('mainPlan.nextMaintenanceH')"
+                  :min-width="150">
+                  <template #default="{ row }">{{ row.nextMaintenanceH ?? '-' }}</template>
+                </ZmTableColumn>
+                <ZmTableColumn prop="remainH" :label="t('mainPlan.remainH')" :min-width="120">
+                  <template #default="{ row }">{{ row.remainH ?? '-' }}</template>
+                </ZmTableColumn>
+              </ZmTableColumn>
+
+              <ZmTableColumn
+                v-if="hasMileageRule"
+                column-key="mileage-group"
+                label="保养里程"
+                is-parent>
+                <ZmTableColumn
+                  prop="lastRunningKilometers"
+                  :label="t('mainPlan.lastMaintenanceMileage')"
+                  :min-width="150" />
+                <ZmTableColumn
+                  prop="nextMaintenanceKm"
+                  :label="t('mainPlan.nextMaintenanceKm')"
+                  :min-width="150">
+                  <template #default="{ row }">{{ row.nextMaintenanceKm ?? '-' }}</template>
+                </ZmTableColumn>
+                <ZmTableColumn prop="remainKm" :label="t('mainPlan.remainKm')" :min-width="120">
+                  <template #default="{ row }">{{ row.remainKm ?? '-' }}</template>
+                </ZmTableColumn>
+              </ZmTableColumn>
+
+              <ZmTableColumn v-if="hasDateRule" column-key="date-group" label="保养日期" is-parent>
+                <ZmTableColumn
+                  prop="lastNaturalDate"
+                  :label="t('mainPlan.lastMaintenanceNaturalDate')"
+                  :min-width="150"
+                  :real-value="(row) => formatDate(row.lastNaturalDate)">
+                  <template #default="{ row }">{{ formatDate(row.lastNaturalDate) }}</template>
+                </ZmTableColumn>
+                <ZmTableColumn
+                  prop="nextMaintenanceDate"
+                  :label="t('mainPlan.nextMaintDate')"
+                  :min-width="140">
+                  <template #default="{ row }">{{ row.nextMaintenanceDate ?? '-' }}</template>
+                </ZmTableColumn>
+                <ZmTableColumn prop="remainDay" :label="t('mainPlan.remainDay')" :min-width="120">
+                  <template #default="{ row }">{{ row.remainDay ?? '-' }}</template>
+                </ZmTableColumn>
+              </ZmTableColumn>
+
+              <ZmTableColumn
+                column-key="operation"
+                :label="t('operationFill.operation')"
+                :width="150"
+                fixed="right"
+                action>
+                <template #default="{ row }">
+                  <div class="table-actions">
+                    <el-button v-if="!isReadonly" link type="danger" @click="handleDelete(row)">
+                      <Icon icon="ep:zoom-out" class="mr-3px" />
+                      {{ t('modelTemplate.delete') }}
+                    </el-button>
+                    <el-button link type="primary" @click="openConfigDialog(row)">
+                      {{ isReadonly ? t('form.set') : t('modelTemplate.update') }}
+                    </el-button>
+                  </div>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+    </section>
+
+    <section class="plan-footer">
+      <el-button v-if="!isReadonly" type="primary" @click="submitForm" :disabled="formLoading">
+        {{ t('iotMaintain.save') }}
+      </el-button>
+      <el-button @click="close">
+        {{ isReadonly ? t('operationFillForm.cancel') : t('iotMaintain.cancel') }}
+      </el-button>
+    </section>
+  </div>
+  <MainPlanDeviceList v-if="!isReadonly" ref="deviceFormRef" @choose="deviceChoose" />
+  <!-- 新增配置对话框 -->
+  <el-dialog
+    v-model="configDialog.visible"
+    :title="`设备 ${configDialog.current?.deviceCode + '-' + configDialog.current?.name} 保养配置`"
+    width="600px"
+    :close-on-click-modal="false">
+    <!-- 使用header插槽自定义标题 -->
+    <template #header>
+      <span
+        >设备
+        <strong>{{ configDialog.current?.deviceCode }}-{{ configDialog.current?.name }}</strong>
+        保养项配置</span
+      >
+    </template>
+    <el-form
+      :model="configDialog.form"
+      label-width="200px"
+      :rules="configFormRules"
+      ref="configFormRef">
+      <div class="form-group">
+        <div class="group-title">{{ t('mainPlan.basicMaintenanceRecords') }}</div>
+        <!-- 里程配置 -->
+        <el-form-item
+          v-if="configDialog.current?.mileageRule === 0"
+          :label="t('mainPlan.lastMaintenanceMileage')"
+          prop="lastRunningKilometers">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.lastRunningKilometers"
+            :precision="2"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+        <!-- 运行时间配置 -->
+        <el-form-item
+          v-if="configDialog.current?.runningTimeRule === 0"
+          :label="t('mainPlan.lastMaintenanceOperationTime')"
+          prop="lastRunningTime">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.lastRunningTime"
+            :precision="1"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+        <!-- 自然日期配置 -->
+        <el-form-item
+          v-if="configDialog.current?.naturalDateRule === 0"
+          :label="t('mainPlan.lastMaintenanceNaturalDate')"
+          prop="lastNaturalDate">
+          <el-date-picker
+            :disabled="isReadonly"
+            v-model="configDialog.form.lastNaturalDate"
+            type="date"
+            placeholder="选择日期"
+            format="YYYY-MM-DD"
+            value-format="x"
+            style="width: 60%" />
+        </el-form-item>
+      </div>
+
+      <div class="form-group" v-if="configDialog.current?.mileageRule === 0">
+        <div class="group-title">{{ t('mainPlan.operatingMileageRuleConfiguration') }}</div>
+        <!-- 保养规则周期值 + 提前量 -->
+        <el-form-item
+          v-if="configDialog.current?.mileageRule === 0"
+          :label="t('mainPlan.operatingMileageCycle')"
+          prop="nextRunningKilometers">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.nextRunningKilometers"
+            :precision="2"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+        <el-form-item
+          v-if="configDialog.current?.mileageRule === 0"
+          :label="t('mainPlan.OperatingMileageCycle_lead')"
+          prop="kiloCycleLead">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.kiloCycleLead"
+            :precision="2"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+      </div>
+
+      <div class="form-group" v-if="configDialog.current?.runningTimeRule === 0">
+        <div class="group-title">{{ t('mainPlan.RunTimeRuleConfiguration') }}</div>
+        <el-form-item
+          v-if="configDialog.current?.runningTimeRule === 0"
+          :label="t('mainPlan.RunTimeCycle')"
+          prop="nextRunningTime">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.nextRunningTime"
+            :precision="1"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+        <el-form-item
+          v-if="configDialog.current?.runningTimeRule === 0"
+          :label="t('mainPlan.RunTimeCycle_Lead')"
+          prop="timePeriodLead">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.timePeriodLead"
+            :precision="1"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+      </div>
+
+      <div class="form-group" v-if="configDialog.current?.naturalDateRule === 0">
+        <div class="group-title">{{ t('mainPlan.NaturalDayRuleConfig') }}</div>
+        <el-form-item
+          v-if="configDialog.current?.naturalDateRule === 0"
+          :label="t('mainPlan.NaturalDailyCycle')"
+          prop="nextNaturalDate">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.nextNaturalDate"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+        <el-form-item
+          v-if="configDialog.current?.naturalDateRule === 0"
+          :label="t('mainPlan.NaturalDailyCycle_Lead')"
+          prop="naturalDatePeriodLead">
+          <el-input-number
+            :disabled="isReadonly"
+            v-model="configDialog.form.naturalDatePeriodLead"
+            :min="0"
+            controls-position="right"
+            :controls="false"
+            style="width: 60%" />
+        </el-form-item>
+      </div>
+
+      <!-- 运行记录模板中 多个 累计运行时长 累计运行里程 属性匹配-->
+      <div
+        class="form-group"
+        v-if="
+          (configDialog.current?.runningTimeRule === 0 ||
+            configDialog.current?.mileageRule === 0) &&
+          (configDialog.current?.timeAccumulatedAttrs?.length ||
+            configDialog.current?.mileageAccumulatedAttrs?.length) &&
+          (configDialog.current.totalRunTime == null || isNaN(configDialog.current.totalRunTime)) &&
+          (configDialog.current.totalMileage == null || isNaN(configDialog.current.totalMileage))
+        ">
+        <div class="group-title">{{ t('mainPlan.accumulatedParams') }}</div>
+        <!-- 累计运行时长 -->
+        <el-form-item
+          v-if="
+            configDialog.current?.runningTimeRule === 0 &&
+            configDialog.current?.timeAccumulatedAttrs?.length &&
+            (configDialog.current.totalRunTime == null || isNaN(configDialog.current.totalRunTime))
+          "
+          :label="t('mainPlan.accumulatedRunTime')"
+          prop="accumulatedTimeOption"
+          :rules="[
+            {
+              required: Boolean(
+                configDialog.current?.runningTimeRule === 0 &&
+                  configDialog.current?.timeAccumulatedAttrs?.length &&
+                  (configDialog.current.totalRunTime === null ||
+                    isNaN(configDialog.current.totalRunTime))
+              ),
+              message: '请选择累计运行时长',
+              trigger: 'change'
+            }
+          ]">
+          <el-select
+            :disabled="isReadonly"
+            v-model="configDialog.form.accumulatedTimeOption"
+            placeholder="请选择累计运行时长"
+            style="width: 80%"
+            clearable
+            @change="handleAccumulatedTimeChange">
+            <el-option
+              v-for="(item, index) in configDialog.current.timeAccumulatedAttrs"
+              :key="`time-${item.pointName}-${index}`"
+              :label="item.pointName"
+              :value="item.pointName" />
+          </el-select>
+        </el-form-item>
+        <!-- 累计运行公里数 -->
+        <el-form-item
+          v-if="
+            configDialog.current?.mileageRule === 0 &&
+            configDialog.current?.mileageAccumulatedAttrs?.length &&
+            (configDialog.current.totalMileage == null || isNaN(configDialog.current.totalMileage))
+          "
+          :label="t('mainPlan.accumulatedMileage')"
+          prop="accumulatedMileageOption"
+          :rules="[
+            {
+              required: Boolean(
+                configDialog.current?.mileageRule === 0 &&
+                  configDialog.current?.mileageAccumulatedAttrs?.length &&
+                  (configDialog.current.totalMileage == null ||
+                    isNaN(configDialog.current.totalMileage))
+              ),
+              message: '请选择累计运行公里数',
+              trigger: 'change'
+            }
+          ]">
+          <el-select
+            :disabled="isReadonly"
+            v-model="configDialog.form.accumulatedMileageOption"
+            placeholder="请选择累计运行公里数"
+            style="width: 80%"
+            clearable
+            @change="handleAccumulatedMileageChange">
+            <el-option
+              v-for="(item, index) in configDialog.current.mileageAccumulatedAttrs"
+              :key="`mileage-${item.pointName}-${index}`"
+              :label="item.pointName"
+              :value="item.pointName" />
+          </el-select>
+        </el-form-item>
+      </div>
+    </el-form>
+    <template #footer>
+      <el-button @click="configDialog.visible = false">{{ t('common.cancel') }}</el-button>
+      <el-button v-if="!isReadonly" type="primary" @click="saveConfig">{{
+        t('common.save')
+      }}</el-button>
+    </template>
+  </el-dialog>
+</template>
+<script setup lang="ts">
+import { IotDeviceApi } from '@/api/pms/device'
+import { useUserStore } from '@/store/modules/user'
+import { ref, computed, watch } from 'vue'
+import { IotMaintenanceBomApi, IotMaintenanceBomVO } from '@/api/pms/iotmaintenancebom'
+import { IotMaintenancePlanApi } from '@/api/pms/maintenance'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import MainPlanDeviceList from '@/views/pms/maintenance/maintenance-device-list.vue'
+import * as DeptApi from '@/api/system/dept'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import dayjs from 'dayjs'
+
+const { t } = useI18n() // 国际化
+const message = useMessage() // 消息弹窗
+const { delView } = useTagsViewStore() // 视图操作
+const { currentRoute, push } = useRouter()
+const route = useRoute()
+const mode = computed(() => {
+  if (route.name === 'IotMaintenancePlanDetail') return 'detail'
+  if (route.name === 'IotMainPlanEdit') return 'edit'
+  return 'create'
+})
+const isReadonly = computed(() => mode.value === 'detail')
+const { ZmTable, ZmTableColumn } = useTableComponents<IotMaintenanceBomVO>()
+const dept = ref() // 当前登录人所属部门对象
+const configFormRef = ref() // 配置弹出框对象
+const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
+const formType = ref('') // 表单的类型:create - 新增;update - 修改
+const deviceLabel = ref('') // 表单的类型:create - 新增;update - 修改
+const list = ref<IotMaintenanceBomVO[]>([]) // 设备bom关联列表的数据
+const loading = ref(false)
+
+const lastNaturalDateWatchers = ref(new Map())
+
+const deviceIds = ref<number[]>([]) // 已经选择的设备id数组
+
+const tableRef = ref()
+
+const cellStyle: any = ({ row, column }: any) => {
+  if (['remainH', 'remainKm', 'remainDay'].includes(column.property)) {
+    if (row[column.property] < 0) {
+      return {
+        color: 'var(--el-color-danger)'
+      }
+    }
+  }
+}
+
+const id = computed(() => route.params.id as string | undefined)
+const formData = ref<any>({
+  id: undefined,
+  deptId: undefined,
+  name: '',
+  serialNumber: undefined,
+  responsiblePerson: undefined,
+  remark: undefined,
+  failureName: undefined,
+  status: undefined,
+  devicePersons: ''
+})
+
+const formRules = reactive({
+  name: [{ required: true, message: '计划名称不能为空', trigger: 'blur' }],
+  responsiblePerson: [{ required: true, message: '责任人不能为空', trigger: 'blur' }]
+})
+const formRef = ref() // 表单 Ref
+
+// 新增配置相关状态
+const configDialog = reactive({
+  visible: false,
+  current: null as IotMaintenanceBomVO | null,
+  form: {
+    lastRunningKilometers: 0,
+    lastRunningTime: 0,
+    lastNaturalDate: null,
+    // 保养规则 周期
+    nextRunningKilometers: 0,
+    nextRunningTime: 0,
+    nextNaturalDate: 0,
+    // 提前量
+    kiloCycleLead: 0,
+    timePeriodLead: 0,
+    naturalDatePeriodLead: 0,
+    // 多个累计时长 累计里程 匹配
+    accumulatedTimeOption: null, // 累计运行时长选项
+    accumulatedMileageOption: null // 累计运行公里数选项
+  } as any
+})
+
+// 打开配置对话框
+const openConfigDialog = (row: IotMaintenanceBomVO) => {
+  // 新增规则校验:至少一个规则开启
+  if (row.mileageRule !== 0 && row.runningTimeRule !== 0 && row.naturalDateRule !== 0) {
+    message.error('请先设置保养规则')
+    return
+  }
+
+  configDialog.current = row
+
+  configDialog.form = {
+    lastRunningKilometers: row.lastRunningKilometers || 0,
+    lastRunningTime: row.lastRunningTime || 0,
+    lastNaturalDate: row.lastNaturalDate || null,
+    // 保养规则 周期值
+    nextRunningKilometers: row.nextRunningKilometers || 0,
+    nextRunningTime: row.nextRunningTime || 0,
+    nextNaturalDate: row.nextNaturalDate || 0,
+    // 提前量
+    kiloCycleLead: row.kiloCycleLead || 0,
+    timePeriodLead: row.timePeriodLead || 0,
+    naturalDatePeriodLead: row.naturalDatePeriodLead || 0,
+    // 多个累计时长 累计里程 匹配
+    accumulatedTimeOption: null, // 累计运行时长选项
+    accumulatedMileageOption: null // 累计运行公里数选项
+  }
+
+  // 初始化累计参数选择
+  /* configDialog.form.accumulatedTimeOption = row.isRuntimeFromTemp
+    ? row.code
+    : null;
+
+  configDialog.form.accumulatedMileageOption = row.isMileageFromTemp
+    ? row.type
+    : null; */
+
+  configDialog.form.accumulatedTimeOption = row.code || null
+  configDialog.form.accumulatedMileageOption = row.type || null
+
+  configDialog.visible = true
+}
+
+// 列宽调整后的处理
+const handleHeaderDragEnd = () => {
+  nextTick(() => {
+    tableRef.value?.elTableRef?.doLayout?.()
+  })
+}
+
+// 保存配置
+const saveConfig = () => {
+  if (isReadonly.value) {
+    configDialog.visible = false
+    return
+  }
+  ;(configFormRef.value as any).validate((valid: boolean) => {
+    if (!valid) return
+    if (!configDialog.current) return
+
+    // 累计运行时长配置 校验逻辑
+    if (
+      configDialog.current.runningTimeRule === 0 &&
+      configDialog.current.timeAccumulatedAttrs?.length &&
+      !configDialog.form.accumulatedTimeOption &&
+      (configDialog.current.totalRunTime == null || isNaN(configDialog.current.totalRunTime))
+    ) {
+      message.error('请选择累计运行时长')
+      return
+    }
+    // 累计运行公里数配置 校验逻辑
+    if (
+      configDialog.current.mileageRule === 0 &&
+      configDialog.current.mileageAccumulatedAttrs?.length &&
+      !configDialog.form.accumulatedMileageOption &&
+      (configDialog.current.totalMileage == null || isNaN(configDialog.current.totalMileage))
+    ) {
+      message.error('请选择累计运行公里数')
+      return
+    }
+
+    // 动态校验逻辑
+    const requiredFields: any = []
+    if (configDialog.current.mileageRule === 0) {
+      requiredFields.push('nextRunningKilometers', 'kiloCycleLead')
+    }
+    if (configDialog.current.runningTimeRule === 0) {
+      requiredFields.push('nextRunningTime', 'timePeriodLead')
+    }
+    if (configDialog.current.naturalDateRule === 0) {
+      requiredFields.push('nextNaturalDate', 'naturalDatePeriodLead')
+    }
+
+    const missingFields = requiredFields.filter(
+      (field) => !configDialog.form[field as keyof typeof configDialog.form]
+    )
+
+    if (missingFields.length > 0) {
+      message.error('请填写所有必填项')
+      return
+    }
+
+    // 强制校验逻辑
+    if (configDialog.current.naturalDateRule === 0) {
+      if (!configDialog.form.lastNaturalDate) {
+        message.error('必须选择自然日期')
+        return
+      }
+
+      // 验证日期有效性
+      const dateValue = dayjs(Number(configDialog.form.lastNaturalDate))
+      if (!dateValue.isValid()) {
+        message.error('日期格式不正确')
+        return
+      }
+    }
+
+    // 转换逻辑(关键修改)
+    const finalDate = configDialog.form.lastNaturalDate
+      ? Number(configDialog.form.lastNaturalDate)
+      : null // 改为null而不是0
+
+    const updateData = {
+      ...configDialog.form,
+      lastNaturalDate: finalDate
+    }
+
+    // 关闭保养规则,无论是否有选择值,都清除相关数据
+    // 处理累计运行时长
+    if (configDialog.current.runningTimeRule !== 0) {
+      configDialog.current.code = null
+      configDialog.current.totalRunTime = null
+      configDialog.form.accumulatedTimeOption = null // 清除选择值
+    } else if (configDialog.form.accumulatedTimeOption) {
+      // 查找选中的累计运行时长项
+      const selectedTimeOption = configDialog.current.timeAccumulatedAttrs?.find(
+        (item) => item.pointName === configDialog.form.accumulatedTimeOption
+      )
+      if (selectedTimeOption) {
+        configDialog.current.code = selectedTimeOption.pointName
+        // 优先使用接口值,没有则使用临时值
+        configDialog.current.tempTotalRunTime = selectedTimeOption.totalRunTime
+        configDialog.current.isRuntimeFromTemp = true
+        // 只有接口未提供值时才使用临时值
+        if (!configDialog.current.totalRunTime) {
+        }
+      }
+    }
+    // 处理累计运行公里数
+    if (configDialog.current.mileageRule !== 0) {
+      configDialog.current.type = null
+      configDialog.current.totalMileage = null
+      configDialog.form.accumulatedMileageOption = null // 清除选择值
+    } else if (configDialog.form.accumulatedMileageOption) {
+      // 查找选中的累计运行公里数项
+      const selectedMileageOption = configDialog.current.mileageAccumulatedAttrs?.find(
+        (item) => item.pointName === configDialog.form.accumulatedMileageOption
+      )
+      if (selectedMileageOption) {
+        configDialog.current.type = selectedMileageOption.pointName
+        configDialog.current.tempTotalMileage = selectedMileageOption.totalRunTime
+        configDialog.current.isMileageFromTemp = true
+        if (!configDialog.current.totalMileage) {
+        }
+      }
+    }
+
+    // 更新当前行的数据
+    if (configDialog.current) {
+      Object.assign(configDialog.current, updateData)
+      // 重新计算 下次保养公里数 剩余公里数
+      configDialog.current.nextMaintenanceKm = calculateNextMaintenanceKm(configDialog.current)
+      configDialog.current.remainKm = calculateRemainKm(configDialog.current)
+      // 重新计算 下次保养运行时长 剩余时长
+      configDialog.current.nextMaintenanceH = calculateNextMaintenanceH(configDialog.current)
+      configDialog.current.remainH = calculateRemainH(configDialog.current)
+      // 重新计算 下次保养日期 剩余天数
+      if (configDialog.form.lastNaturalDate) {
+        configDialog.current.tempLastNaturalDate = dayjs(configDialog.form.lastNaturalDate).format(
+          'YYYY-MM-DD'
+        )
+        configDialog.current.nextMaintenanceDate = calculateNextMaintenanceDate(
+          configDialog.current
+        )
+        configDialog.current.remainDay = calculateRemainDay(configDialog.current)
+      }
+    }
+    configDialog.visible = false
+  })
+}
+
+const queryParams = reactive<any>({
+  deviceIds: undefined,
+  planId: undefined,
+  bomFlag: 'b'
+})
+
+// 处理保养规则变化 取消保养规则 时 清空已经设置的相应保养规则数据
+const handleRuleChange = (
+  row: IotMaintenanceBomVO,
+  ruleType: 'mileage' | 'runningTime' | 'date'
+) => {
+  // 当规则关闭时(inactive-value=1)
+  console.log('执行了保养规则变化事件' + row.totalRunTime + ' - ' + row.totalMileage)
+  // 当前保养项行已经返回了 totalRunTime totalMileage 数据 不需要再清空 累计运行时长 累计公里数
+
+  // 选择完设备匹配了保养项后 不能直接置空 因为可能是正常的累计时长 累计公里数
+  if (ruleType === 'runningTime' && row.runningTimeRule === 1) {
+    // 清除临时来源的值
+    if (row.isRuntimeFromTemp) {
+      row.totalRunTime = null
+      row.tempTotalRunTime = null
+      row.code = null
+      // row.isRuntimeFromTemp = false;
+    }
+    // 强制清除配置对话框中的值(如果打开的是当前行)
+    if (
+      configDialog.current?.deviceId === row.deviceId &&
+      configDialog.current?.bomNodeId === row.bomNodeId
+    ) {
+      configDialog.form.accumulatedTimeOption = null
+    }
+  } else if (ruleType === 'mileage' && row.mileageRule === 1) {
+    if (row.isMileageFromTemp) {
+      row.totalMileage = null
+      row.tempTotalMileage = null
+      row.type = null
+      // row.isMileageFromTemp = false;
+    }
+    // 强制清除配置对话框中的值(如果打开的是当前行)
+    if (
+      configDialog.current?.deviceId === row.deviceId &&
+      configDialog.current?.bomNodeId === row.bomNodeId
+    ) {
+      configDialog.form.accumulatedMileageOption = null
+    }
+  }
+
+  // 如果配置对话框打开的是当前行,同步清除对话框中的选择值
+  if (
+    configDialog.visible &&
+    configDialog.current &&
+    configDialog.current.deviceId === row.deviceId &&
+    configDialog.current.bomNodeId === row.bomNodeId
+  ) {
+    if (ruleType === 'runningTime') {
+      configDialog.form.accumulatedTimeOption = null
+    } else if (ruleType === 'mileage') {
+      configDialog.form.accumulatedMileageOption = null
+    }
+  }
+
+  // 规则变化后按新条件重新计算 下次保养公里数 剩余公里数
+  if (ruleType === 'mileage') {
+    if (row.mileageRule === 0) {
+      row.nextMaintenanceKm = calculateNextMaintenanceKm(row)
+      row.remainKm = calculateRemainKm(row)
+    } else {
+      row.nextMaintenanceKm = null
+      row.remainKm = null
+    }
+  }
+
+  // 规则变化后按新条件重新计算 下次保养时长 剩余时长
+  if (ruleType === 'runningTime') {
+    if (row.runningTimeRule === 0) {
+      row.nextMaintenanceH = calculateNextMaintenanceH(row)
+      row.remainH = calculateRemainH(row)
+    } else {
+      row.nextMaintenanceH = null
+      row.remainH = null
+    }
+  }
+
+  // 规则变化后按新条件重新计算 下次保养日期 剩余天数
+  if (ruleType === 'date') {
+    if (row.naturalDateRule === 0) {
+      row.nextMaintenanceDate = calculateNextMaintenanceDate(row)
+      row.remainDay = calculateRemainDay(row)
+    } else {
+      row.nextMaintenanceDate = null
+      row.remainDay = null
+    }
+  }
+}
+
+const deviceChoose = async (selectedDevices) => {
+  const newIds = selectedDevices.map((device) => device.id)
+  deviceIds.value = [...new Set([...deviceIds.value, ...newIds])]
+  const params = {
+    deviceIds: newIds.join(',') // 明确传递数组参数
+  }
+  queryParams.deviceIds = JSON.parse(JSON.stringify(params.deviceIds))
+  queryParams.bomFlag = 'b'
+  // 根据选择的设备筛选出设备BOM中与保养相关的节点项
+  const res = await IotDeviceApi.deviceAssociateBomList(queryParams)
+  const rawData = res || []
+  if (rawData.length === 0) {
+    message.error('选择的设备不存在待保养BOM项')
+  }
+  if (!Array.isArray(rawData)) {
+    console.error('接口返回数据结构异常:', rawData)
+    return
+  }
+
+  // 创建当前列表的唯一键集合(关键修改)
+  const existingKeys = new Set(list.value.map((item) => `${item.deviceId}-${item.bomNodeId}`))
+
+  // 转换数据结构(根据你的接口定义调整)
+  const newItems = rawData
+    .filter((device) => {
+      // 排除已存在的项(设备ID+bom节点ID)
+      const key = `${device.id}-${device.bomNodeId}`
+      return !existingKeys.has(key)
+    })
+    .map((device) => ({
+      assetClass: device.assetClass,
+      deviceCode: device.deviceCode,
+      deviceName: device.deviceName,
+      deviceStatus: device.deviceStatus,
+      deptName: device.deptName,
+      name: device.name,
+      code: device.code,
+      assetProperty: device.assetProperty,
+      remark: null, // 初始化备注
+      deviceId: device.id, // 移除操作需要
+      bomNodeId: device.bomNodeId,
+      totalRunTime: device.totalRunTime,
+      totalMileage: device.totalMileage,
+      nextRunningKilometers: 0,
+      nextRunningTime: 0,
+      nextNaturalDate: 0,
+      lastNaturalDate: null, // 初始化为null而不是0
+      // 保养规则 提前量
+      kiloCycleLead: 0,
+      timePeriodLead: 0,
+      naturalDatePeriodLead: 0,
+      tempTotalRunTime: null,
+      tempTotalMileage: null,
+      isRuntimeFromTemp: false,
+      isMileageFromTemp: false,
+      // 添加累计时长参数列表 属性
+      timeAccumulatedAttrs: device.timeAccumulatedAttrs || [],
+      // 添加累计里程参数列表 属性
+      mileageAccumulatedAttrs: device.mileageAccumulatedAttrs || []
+    }))
+  // 获取选择的设备相关的id数组
+  newItems.forEach((item) => {
+    deviceIds.value.push(item.deviceId)
+  })
+  // 合并到现有列表(去重)
+  newItems.forEach((item) => {
+    const exists = list.value.some(
+      (existing) => existing.deviceId === item.deviceId && existing.bomNodeId === item.bomNodeId
+    )
+    if (!exists) {
+      list.value.push(item as any)
+    }
+  })
+  // 排序保养项
+  applySorting()
+}
+
+// 计算下次保养公里数(通用函数)
+const calculateNextMaintenanceKm = (row: IotMaintenanceBomVO) => {
+  // 验证条件:规则开启 + 两个值都存在且 > 0
+  const isValid =
+    row.mileageRule === 0 && row.lastRunningKilometers > 0 && row.nextRunningKilometers > 0
+
+  return isValid ? row.lastRunningKilometers + row.nextRunningKilometers : null // 不满足条件返回null
+}
+
+// 计算剩余保养公里数(通用函数)
+const calculateRemainKm = (row: IotMaintenanceBomVO) => {
+  // 确定使用的里程值(优先totalMileage)
+  const mileageValue = row.totalMileage ?? row.tempTotalMileage ?? 0
+  // 验证条件:规则开启 + 3个值都存在且 > 0
+  const isValid =
+    row.mileageRule === 0 &&
+    row.lastRunningKilometers > 0 &&
+    mileageValue > 0 &&
+    row.nextRunningKilometers > 0
+
+  return isValid
+    ? parseFloat(
+        (row.nextRunningKilometers - (mileageValue - row.lastRunningKilometers)).toFixed(2)
+      )
+    : null // 不满足条件返回null
+}
+
+// 计算下次保养运行时长(通用函数)
+const calculateNextMaintenanceH = (row: IotMaintenanceBomVO) => {
+  // 验证条件:规则开启 + 两个值都存在且 > 0
+  const isValid = row.runningTimeRule === 0 && row.lastRunningTime > 0 && row.nextRunningTime > 0
+
+  return isValid ? row.lastRunningTime + row.nextRunningTime : null // 不满足条件返回null
+}
+
+// 计算剩余运行时间(通用函数)
+const calculateRemainH = (row: IotMaintenanceBomVO) => {
+  // 确定使用的 运行时长 值(优先 totalRunTime)
+  const runTimeValue = row.totalRunTime ?? row.tempTotalRunTime ?? 0
+  // 验证条件:规则开启 + 3个值都存在且 > 0
+  const isValid =
+    row.runningTimeRule === 0 &&
+    row.lastRunningTime > 0 &&
+    runTimeValue > 0 &&
+    row.nextRunningTime > 0
+
+  return isValid
+    ? parseFloat((row.nextRunningTime - (runTimeValue - row.lastRunningTime)).toFixed(2))
+    : null // 不满足条件返回null
+}
+
+// 计算下次保养日期(通用函数)
+const calculateNextMaintenanceDate = (row: IotMaintenanceBomVO) => {
+  // 验证条件:规则开启 + 两个值都存在且 > 0
+  const isValid = row.naturalDateRule === 0 && row.lastNaturalDate && row.nextNaturalDate
+
+  return isValid
+    ? dayjs(row.lastNaturalDate)
+        .add(row.nextNaturalDate as any, 'day')
+        .format('YYYY-MM-DD')
+    : null // 不满足条件返回null
+}
+
+// 计算 自然日期保养 剩余天数(通用函数)
+const calculateRemainDay = (row: IotMaintenanceBomVO) => {
+  // 验证条件:规则开启 + 两个值都存在且有效
+  const isValid =
+    row.naturalDateRule === 0 &&
+    row.lastNaturalDate !== null &&
+    row.nextNaturalDate !== null &&
+    row.nextNaturalDate > 0
+
+  if (!isValid) {
+    return null
+  }
+
+  try {
+    // 上次保养日期:将时间戳转换为 Day.js 对象
+    const lastNaturalDate = dayjs(row.lastNaturalDate)
+
+    // 计算下次保养日期
+    const nextMaintenanceDate = lastNaturalDate.add(row.nextNaturalDate as any, 'day')
+
+    // 计算剩余天数(当前日期到下次保养日期的天数差)
+    return nextMaintenanceDate.diff(dayjs(), 'day')
+  } catch (error) {
+    console.error('计算保养剩余天数错误:', error)
+    return null
+  }
+}
+
+const formatDate = (value?: number | string | null) => {
+  if (!value) return '-'
+  const date = dayjs(Number(value))
+  return date.isValid() ? date.format('YYYY-MM-DD') : '-'
+}
+
+// 单元格类名回调方法
+const cellClassName = ({ row, column }) => {
+  // 只对序号列进行处理
+  if (column.type === 'index') {
+    // 检查该行所有启用的规则是否都已配置完整
+    if (checkRowFilled(row)) {
+      return 'all-filled' // 返回自定义类名
+    }
+  }
+  return ''
+}
+
+// 检查行数据是否完整填写
+const checkRowFilled = (row: IotMaintenanceBomVO) => {
+  // 检查是否启用了至少一个规则
+  const hasRuleEnabled =
+    row.mileageRule === 0 || row.runningTimeRule === 0 || row.naturalDateRule === 0
+
+  if (!hasRuleEnabled) {
+    return false // 没有任何规则启用,不显示背景色
+  }
+  // 检查里程规则
+  const mileageFilled =
+    row.mileageRule !== 0
+      ? true // 规则未启用,视为已"填写"
+      : row.lastRunningKilometers > 0 &&
+        row.nextRunningKilometers > 0 &&
+        row.kiloCycleLead > 0 &&
+        // 检查累计里程参数是否已选择(当条件满足时)
+        (!(
+          row.mileageAccumulatedAttrs?.length &&
+          (row.totalMileage == null || isNaN(row.totalMileage))
+        ) ||
+          (row.mileageAccumulatedAttrs?.length &&
+            (row.totalMileage == null || isNaN(row.totalMileage)) &&
+            row.type))
+
+  // 检查运行时间规则
+  const runningTimeFilled =
+    row.runningTimeRule !== 0
+      ? true
+      : row.lastRunningTime > 0 &&
+        row.nextRunningTime > 0 &&
+        row.timePeriodLead > 0 &&
+        // 检查累计时间参数是否已选择(当条件满足时)
+        (!(
+          row.timeAccumulatedAttrs?.length &&
+          (row.totalRunTime == null || isNaN(row.totalRunTime))
+        ) ||
+          (row.timeAccumulatedAttrs?.length &&
+            (row.totalRunTime == null || isNaN(row.totalRunTime)) &&
+            row.code))
+
+  // 检查自然日期规则
+  const naturalDateFilled =
+    row.naturalDateRule !== 0
+      ? true
+      : row.lastNaturalDate && (row.nextNaturalDate ?? 0) > 0 && row.naturalDatePeriodLead > 0
+
+  return mileageFilled && runningTimeFilled && naturalDateFilled
+}
+
+// 计算属性 - 检查是否有开启的里程规则
+const hasMileageRule = computed(() => {
+  return list.value.some((row) => row.mileageRule === 0)
+})
+
+// 计算属性 - 检查是否有开启的运行时间规则
+const hasTimeRule = computed(() => {
+  return list.value.some((row) => row.runningTimeRule === 0)
+})
+
+// 计算属性 - 检查是否有开启的自然日期规则
+const hasDateRule = computed(() => {
+  return list.value.some((row) => row.naturalDateRule === 0)
+})
+
+// 为每一行建立lastNaturalDate到tempLastNaturalDate的同步
+const setupNaturalDateSync = (row: IotMaintenanceBomVO) => {
+  // 如果该行已有watcher则跳过
+  if (lastNaturalDateWatchers.value.has(row.id)) return
+
+  // 为该行创建单独的watcher
+  const unwatch = watch(
+    () => row.lastNaturalDate,
+    (newVal) => {
+      // 转换日期格式 (时间戳 -> YYYY-MM-DD)
+      row.tempLastNaturalDate = newVal ? dayjs(newVal).format('YYYY-MM-DD') : null
+    },
+    { immediate: true, deep: true }
+  )
+
+  // 保存watcher用于后续清理
+  lastNaturalDateWatchers.value.set(row.id, unwatch)
+}
+
+// 监听规则列变化,重新布局表格
+watch(
+  [list, hasMileageRule, hasTimeRule, hasDateRule],
+  () => {
+    nextTick(() => {
+      tableRef.value?.elTableRef?.doLayout?.()
+    })
+  },
+  { deep: true }
+)
+
+const deviceFormRef = ref<InstanceType<typeof MainPlanDeviceList>>()
+const openForm = () => {
+  if (isReadonly.value) return
+  deviceFormRef.value?.open()
+}
+
+const close = () => {
+  delView(unref(currentRoute))
+  push({ name: 'IotMaintenancePlan', params: {} })
+}
+
+/** 提交表单 */
+const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
+const submitForm = async () => {
+  if (isReadonly.value) return
+  // 校验表单
+  await formRef.value.validate()
+  // 校验表格数据
+  const isValid = validateTableData()
+  if (!isValid) return
+  // 提交请求
+  formLoading.value = true
+  try {
+    // 将值为NULL 的保养规则 设置为 1
+    list.value.forEach((item) => {
+      // 确保保养规则不为null
+      item.mileageRule = item.mileageRule ?? 1
+      item.runningTimeRule = item.runningTimeRule ?? 1
+      item.naturalDateRule = item.naturalDateRule ?? 1
+    })
+
+    // 转换日期格式
+    const convertedList = list.value.map((item) => ({
+      ...item,
+      lastNaturalDate:
+        typeof item.lastNaturalDate === 'number'
+          ? item.lastNaturalDate
+          : item.lastNaturalDate
+            ? dayjs(item.lastNaturalDate).valueOf()
+            : null
+    }))
+
+    const data = {
+      mainPlan: formData.value,
+      mainPlanBom: convertedList
+    }
+    if (formType.value === 'create') {
+      await IotMaintenancePlanApi.createIotMaintenancePlan(data)
+      message.success(t('common.createSuccess'))
+      close()
+    } else {
+      await IotMaintenancePlanApi.updatePlan(data)
+      message.success(t('common.updateSuccess'))
+      close()
+    }
+    // 发送操作成功的事件
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+// 新增表单校验规则
+const configFormRules = reactive({
+  nextRunningKilometers: [
+    {
+      required: true,
+      message: '里程周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  kiloCycleLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ],
+  nextRunningTime: [
+    {
+      required: true,
+      message: '时间周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  timePeriodLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ],
+  nextNaturalDate: [
+    {
+      required: true,
+      message: '自然日周期必须填写',
+      trigger: 'blur'
+    }
+  ],
+  naturalDatePeriodLead: [
+    {
+      required: true,
+      message: '提前量必须填写',
+      trigger: 'blur'
+    }
+  ]
+})
+
+/** 校验表格数据 */
+const validateTableData = (): boolean => {
+  let isValid = true
+  const errorMessages: string[] = []
+  const noRulesErrorMessages: string[] = [] // 未设置任何保养项规则 的错误提示信息
+  const noRules: string[] = [] // 行记录中设置了保养规则的记录数量
+  const configErrors: string[] = [] // 保养规则配置弹出框
+  let shouldBreak = false
+
+  if (list.value.length === 0) {
+    errorMessages.push('请至少添加一条设备保养明细')
+    isValid = false
+    // 直接返回无需后续校验
+    message.error('请至少添加一条设备保养明细')
+    return isValid
+  }
+
+  list.value.forEach((row, index) => {
+    if (shouldBreak) return
+    const rowNumber = index + 1 // 用户可见的行号从1开始
+    const deviceIdentifier = `${row.deviceCode}-${row.name}` // 设备标识
+
+    // 累计参数校验逻辑
+    if (
+      row.mileageRule === 0 &&
+      row.mileageAccumulatedAttrs?.length &&
+      (row.totalMileage == null || isNaN(row.totalMileage)) &&
+      !row.type
+    ) {
+      errorMessages.push(`第 ${rowNumber} 行(${deviceIdentifier}):请选择累计运行公里数参数`)
+      isValid = false
+    }
+
+    if (
+      row.runningTimeRule === 0 &&
+      row.timeAccumulatedAttrs?.length &&
+      (row.totalRunTime == null || isNaN(row.totalRunTime)) &&
+      !row.code
+    ) {
+      errorMessages.push(`第 ${rowNumber} 行(${deviceIdentifier}):请选择累计运行时长参数`)
+      isValid = false
+    }
+
+    // 校验逻辑
+    const checkConfig = (ruleName: string, ruleValue: number, configField: keyof typeof row) => {
+      if (ruleValue === 0) {
+        // 规则开启
+        if (!row[configField] || (row[configField] as any) <= 0) {
+          configErrors.push(
+            `第 ${rowNumber} 行(${deviceIdentifier}):请点击【配置】维护${ruleName}上次保养值`
+          )
+          isValid = false
+        }
+      }
+    }
+    // 里程校验逻辑
+    if (row.mileageRule === 0) {
+      // 假设 0 表示开启状态
+      if (!row.nextRunningKilometers || row.nextRunningKilometers <= 0) {
+        errorMessages.push(`第 ${rowNumber} 行:开启里程规则必须填写有效的里程周期`)
+        isValid = false
+      }
+      // 再校验配置值
+      checkConfig('里程', row.mileageRule, 'lastRunningKilometers')
+    } else {
+      noRules.push(`第 ${rowNumber} 行:未设置里程规则`)
+    }
+    // 运行时间校验逻辑
+    if (row.runningTimeRule === 0) {
+      if (!row.nextRunningTime || row.nextRunningTime <= 0) {
+        errorMessages.push(`第 ${rowNumber} 行:开启运行时间规则必须填写有效的时间周期`)
+        isValid = false
+      }
+      checkConfig('运行时间', row.runningTimeRule, 'lastRunningTime')
+    } else {
+      noRules.push(`第 ${rowNumber} 行:未设置运行时间规则`)
+    }
+    // 自然日期校验逻辑
+    if (row.naturalDateRule === 0) {
+      if (!row.nextNaturalDate) {
+        errorMessages.push(`第 ${rowNumber} 行:开启自然日期规则必须填写有效的自然日期周期`)
+        isValid = false
+      }
+      checkConfig('自然日期', row.naturalDateRule, 'lastNaturalDate')
+    } else {
+      noRules.push(`第 ${rowNumber} 行:未设置自然日期规则`)
+    }
+    // 如果选中的一行记录未设置任何保养规则 提示 ‘保养项未设置任何保养规则’
+    if (noRules.length === 3) {
+      isValid = false
+      shouldBreak = true // 设置标志变量为true,退出循环
+      noRulesErrorMessages.push('保养项至少设置1个保养规则')
+    }
+    noRules.length = 0
+  })
+  if (errorMessages.length > 0) {
+    message.error('设置保养规则后,请维护对应的周期值')
+  } else if (noRulesErrorMessages.length > 0) {
+    message.error(noRulesErrorMessages.pop() ?? '')
+  } else if (configErrors.length > 0) {
+    message.error(configErrors.pop() ?? '')
+  }
+  return isValid
+}
+
+// 修改后的排序应用方法
+const applySorting = () => {
+  // 创建新数组并排序
+  const sortedList = sortDeviceList(list.value)
+
+  // 使用Vue的响应式方法更新数组
+  list.value = sortedList
+}
+
+// 保养项排序函数
+const sortDeviceList = (devices: IotMaintenanceBomVO[]) => {
+  // 使用slice()创建数组副本,避免修改原数组
+  return devices.slice().sort((a, b) => {
+    // 处理可能的空值
+    const aCode = a.deviceCode || ''
+    const bCode = b.deviceCode || ''
+    const aName = a.name || ''
+    const bName = b.name || ''
+
+    // 设备编码排序
+    if (aCode !== bCode) {
+      return aCode.localeCompare(bCode)
+    }
+
+    // 保养项名称排序
+    return aName.localeCompare(bName)
+  })
+}
+
+// 累计运行时长变更
+const handleAccumulatedTimeChange = (option) => {}
+
+// 累计运行公里数变更
+const handleAccumulatedMileageChange = (option) => {}
+
+/** 重置表单 */
+const resetForm = () => {
+  formData.value = {
+    id: undefined,
+    deptId: undefined,
+    name: '',
+    serialNumber: undefined,
+    responsiblePerson: undefined,
+    remark: undefined,
+    failureName: undefined,
+    status: undefined,
+    devicePersons: ''
+  }
+  formRef.value?.resetFields()
+}
+
+const normalizePlanBom = (item: IotMaintenanceBomVO) => {
+  if (item.mileageRule === 0) {
+    item.nextMaintenanceKm = calculateNextMaintenanceKm(item)
+    item.remainKm = calculateRemainKm(item)
+  }
+  if (item.runningTimeRule === 0) {
+    item.nextMaintenanceH = calculateNextMaintenanceH(item)
+    item.remainH = calculateRemainH(item)
+  }
+  if (item.naturalDateRule === 0) {
+    item.nextMaintenanceDate = calculateNextMaintenanceDate(item)
+    item.remainDay = calculateRemainDay(item)
+  }
+  setupNaturalDateSync(item)
+  return item
+}
+
+const initPage = async () => {
+  loading.value = true
+  formLoading.value = true
+  list.value = []
+  deviceIds.value = []
+  lastNaturalDateWatchers.value.clear()
+  resetForm()
+
+  queryParams.planId = id.value
+  queryParams.deviceIds = undefined
+  queryParams.bomFlag = 'b'
+
+  const deptId = useUserStore().getUser.deptId
+  dept.value = await DeptApi.getDept(deptId)
+  formData.value.name = dept.value.name + ' - 保养计划'
+  formData.value.deptId = deptId
+
+  try {
+    if (mode.value === 'create') {
+      formType.value = 'create'
+      const { wsCache } = useCache()
+      const userInfo = wsCache.get(CACHE_KEY.USER)
+      formData.value.responsiblePerson = userInfo.user.id
+    } else if (id.value) {
+      formType.value = mode.value === 'detail' ? 'detail' : 'update'
+      const plan = await IotMaintenancePlanApi.getIotMaintenancePlan(Number(id.value))
+      deviceLabel.value = plan.deviceName
+      formData.value = plan
+      const data = await IotMaintenanceBomApi.getMainPlanBOMs(queryParams)
+      if (Array.isArray(data)) {
+        list.value = data.map(normalizePlanBom)
+        applySorting()
+      }
+    }
+  } finally {
+    loading.value = false
+    formLoading.value = false
+  }
+}
+
+watch(
+  () => route.fullPath,
+  () => {
+    initPage()
+  },
+  { immediate: true }
+)
+
+onUnmounted(async () => {})
+
+const handleDelete = async (row: IotMaintenanceBomVO) => {
+  if (isReadonly.value) return
+  try {
+    const deviceId = row.deviceId
+    const bomNodeId = row.bomNodeId
+    // 删除列表项
+    const index = list.value.findIndex(
+      (item) => item.deviceId === deviceId && item.bomNodeId === bomNodeId
+    )
+    if (index !== -1) {
+      list.value.splice(index, 1)
+      // 删除保养项后对保养项重新排序
+      applySorting()
+      deviceIds.value = []
+    }
+    // 更新设备ID列表(需要检查是否还有该设备的其他项)
+    const hasOtherItems = list.value.some((item) => item.deviceId === deviceId)
+    if (!hasOtherItems) {
+      deviceIds.value = deviceIds.value.filter((id) => id !== deviceId)
+    }
+    // message.success('移除成功')
+  } catch (error) {
+    console.error('移除失败:', error)
+    message.error('移除失败')
+  }
+}
+</script>
+<style scoped>
+.maintenance-plan-page {
+  display: flex;
+  height: calc(
+    100vh - 20px - var(--top-tool-height) - var(--tags-view-height) - var(--app-footer-height)
+  );
+  min-height: 0;
+  background: var(--el-bg-color-page);
+  flex-direction: column;
+  gap: 12px;
+}
+
+.plan-section {
+  padding: 12px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-light);
+}
+
+.plan-form-section {
+  flex: 0 0 auto;
+}
+
+.plan-form :deep(.el-form-item) {
+  margin-bottom: 12px;
+}
+
+.plan-form :deep(.el-textarea__inner) {
+  resize: none;
+}
+
+.plan-table-section {
+  display: flex;
+  min-height: 0;
+  flex: 1 1 auto;
+  flex-direction: column;
+  gap: 12px;
+}
+
+.table-toolbar {
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: flex-start;
+}
+
+.table-resizer {
+  position: relative;
+  min-height: 0;
+  flex: 1 1 auto;
+}
+
+.maintenance-plan-table {
+  --zm-table-radius: 2px;
+  --zm-table-cell-height: 32px;
+  --zm-table-header-cell-height: 38px;
+  --zm-table-header-group-cell-height: 42px;
+  --zm-table-font-size: 12px;
+}
+
+.full-content-cell {
+  overflow: visible;
+  white-space: nowrap;
+}
+
+.table-actions {
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  gap: 10px;
+  white-space: nowrap;
+}
+
+.plan-footer {
+  display: flex;
+  flex: 0 0 auto;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 10px 12px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-light);
+}
+
+:deep(.el-input-number .el-input__inner) {
+  padding-left: 10px;
+  text-align: left !important;
+}
+
+.form-group {
+  position: relative;
+  padding: 20px 15px 10px;
+  margin-bottom: 18px;
+  border: 1px solid var(--el-border-color);
+  border-radius: 4px;
+}
+
+.group-title {
+  position: absolute;
+  top: -10px;
+  left: 20px;
+  padding: 0 8px;
+  font-size: 12px;
+  font-weight: 500;
+  color: var(--el-text-color-regular);
+  background: var(--el-bg-color);
+}
+
+:deep(.zm-table .all-filled) {
+  background-color: #67c23a !important;
+}
+
+:deep(.zm-table .all-filled .cell) {
+  color: #fff;
+}
+</style>

+ 214 - 0
src/views/pms/maintenance/maintenance-device-list.vue

@@ -0,0 +1,214 @@
+<script setup lang="ts">
+import { IotDeviceApi } from '@/api/pms/device'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DICT_TYPE, realValue } from '@/utils/dict'
+import { WarningFilled } from '@element-plus/icons-vue'
+import { DeviceList, DeviceQuery } from './types'
+
+const { t } = useI18n()
+
+const emits = defineEmits<{
+  choose: [value: DeviceList[]]
+  close: []
+}>()
+
+const visible = ref(false)
+
+const open = () => {
+  visible.value = true
+  query.value = initQuery()
+  selectedRows.value = []
+  tableRef.value?.elTableRef.clearSelection()
+  getList()
+}
+
+defineExpose({ open })
+
+const initQuery = (): DeviceQuery => ({
+  pageNo: 1,
+  pageSize: 10
+})
+
+const loading = ref(false)
+const query = ref(initQuery())
+const total = ref(0)
+
+const list = ref<DeviceList[]>([])
+const selectedRows = ref<DeviceList[]>([])
+const tableRef = ref()
+
+const getList = async () => {
+  loading.value = true
+  try {
+    const res = await IotDeviceApi.getIotDevicePage(query.value)
+    total.value = res.total
+    list.value = res.list
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleSizeChange = (val: number) => {
+  query.value.pageSize = val
+  getList()
+}
+
+const handleCurrentChange = (val: number) => {
+  query.value.pageNo = val
+  getList()
+}
+
+const handleQuery = () => {
+  query.value.pageNo = 1
+  getList()
+}
+
+const reset = () => {
+  query.value = initQuery()
+  handleQuery()
+}
+
+const handleClose = () => {
+  tableRef.value?.elTableRef.clearSelection()
+  selectedRows.value = []
+  emits('close')
+}
+
+const handleConfirm = () => {
+  if (selectedRows.value.length === 0) {
+    ElMessage.warning('请至少选择一个设备')
+    return
+  }
+
+  emits('choose', selectedRows.value)
+  visible.value = false
+}
+
+const handleSelectionChange = (val: DeviceList[]) => {
+  selectedRows.value = val
+}
+
+const handleRowClick = (row: DeviceList) => {
+  if (!row.hasSetMaintenanceBom) {
+    ElMessage.warning({
+      message: '请选择有保养项的设备',
+      grouping: true
+    })
+    return
+  }
+
+  tableRef.value?.elTableRef.toggleRowSelection(row)
+}
+
+const { ZmTable, ZmTableColumn } = useTableComponents<DeviceList>()
+</script>
+
+<template>
+  <Dialog
+    v-model="visible"
+    :title="t('deviceList.selectDevice')"
+    :style="{ width: '1200px' }"
+    :body-style="{ height: '100%' }"
+    :close-on-click-modal="false"
+    @close="handleClose">
+    <el-form
+      :model="query"
+      class="flex border-1 border-solid border-gray-300 p-4 rounded"
+      size="default"
+      label-width="86px">
+      <el-form-item class="mb-0!" :label="t('deviceList.deviceName')" prop="deviceName">
+        <el-input
+          v-model="query.deviceName"
+          @keyup.enter="handleQuery"
+          :placeholder="t('deviceList.nameHolder')"
+          clearable
+          class="!w-200px" />
+      </el-form-item>
+      <el-form-item class="mb-0!" :label="t('deviceList.deviceCode')" prop="deviceCode">
+        <el-input
+          v-model="query.deviceCode"
+          @keyup.enter="handleQuery"
+          :placeholder="t('deviceList.codeHolder')"
+          clearable
+          class="!w-200px" />
+      </el-form-item>
+      <el-form-item class="mb-0!">
+        <el-button @click="handleQuery" type="primary">
+          <Icon icon="ep:search" class="mr-5px" /> {{ t('deviceList.search') }}
+        </el-button>
+        <el-button @click="reset">
+          <Icon icon="ep:refresh" class="mr-5px" /> {{ t('deviceList.reset') }}
+        </el-button>
+        <el-button @click="handleConfirm" type="success">
+          <Icon icon="ep:check" class="mr-5px" /> {{ t('workOrderMaterial.confirm') }}
+        </el-button>
+      </el-form-item>
+    </el-form>
+    <section class="mt-4 p-4 pb-1 border-1 border-solid border-gray-300 rounded">
+      <ZmTable
+        ref="tableRef"
+        :data="list"
+        :loading="loading"
+        :height="420"
+        row-key="id"
+        :column-max-width="420"
+        @row-click="handleRowClick"
+        @selection-change="handleSelectionChange">
+        <ZmTableColumn
+          column-key="selection"
+          type="selection"
+          width="55"
+          reserve-selection
+          :selectable="(row) => row.hasSetMaintenanceBom" />
+        <ZmTableColumn :label="t('chooseMaintain.deviceCode')" prop="deviceCode" />
+        <ZmTableColumn :label="t('deviceList.deviceName')" prop="deviceName" />
+        <ZmTableColumn :label="t('iotDevice.dept')" prop="deptName" />
+        <ZmTableColumn
+          :label="t('iotDevice.status')"
+          prop="deviceStatus"
+          :real-value="(row) => realValue(DICT_TYPE.PMS_DEVICE_STATUS, row.deviceStatus ?? '')">
+          <template #default="{ row }">
+            <dict-tag :type="DICT_TYPE.PMS_DEVICE_STATUS" :value="row.deviceStatus ?? ''" />
+          </template>
+        </ZmTableColumn>
+        <ZmTableColumn
+          :label="t('deviceInfo.deviceBOM')"
+          prop="hasSetMaintenanceBom"
+          :real-value="
+            (row) =>
+              row.hasSetMaintenanceBom ? t('mainPlan.haveMaintItems') : t('mainPlan.noMaintItems')
+          ">
+          <template #header>
+            {{ t('deviceInfo.deviceBOM') }}
+            <el-tooltip effect="dark" content="请选择有保养项的设备" placement="top">
+              <el-icon :size="12" color="#e6a23c" style="margin-left: 3px; cursor: pointer">
+                <WarningFilled />
+              </el-icon>
+            </el-tooltip>
+          </template>
+          <template #default="{ row }">
+            <el-tag :type="row.hasSetMaintenanceBom ? 'success' : 'danger'">
+              {{
+                row.hasSetMaintenanceBom ? t('mainPlan.haveMaintItems') : t('mainPlan.noMaintItems')
+              }}
+            </el-tag>
+          </template>
+        </ZmTableColumn>
+      </ZmTable>
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <el-pagination
+          v-show="total > 0"
+          :current-page="query.pageNo"
+          :page-size="query.pageSize"
+          :background="true"
+          :page-sizes="[10, 20, 30, 50, 100]"
+          :total="total"
+          layout="total, sizes, prev, pager, next, jumper"
+          @size-change="handleSizeChange"
+          @current-change="handleCurrentChange" />
+      </div>
+    </section>
+  </Dialog>
+</template>
+
+<style scoped lang="scss"></style>

+ 526 - 0
src/views/pms/maintenance/maintenance-plan-form.vue

@@ -0,0 +1,526 @@
+<script setup lang="ts">
+import type { FormInstance, FormRules } from 'element-plus'
+import { List } from './types'
+
+const { t } = useI18n()
+
+const visible = ref(false)
+
+type AccumulatedAttr = {
+  pointName: string
+  totalRunTime?: number | null
+}
+
+interface Props {
+  row: List
+  readonly: boolean
+  timeAccumulatedAttrs: AccumulatedAttr[]
+  mileageAccumulatedAttrs: AccumulatedAttr[]
+}
+
+const form = ref<List>({
+  deviceId: 0,
+  bomNodeId: '',
+  deviceCode: '',
+  deviceName: '',
+  name: '',
+  runningTimeRule: 0,
+  mileageRule: 0,
+  naturalDateRule: 0,
+  totalRunTime: null,
+  tempTotalRunTime: null,
+  totalMileage: null,
+  tempTotalMileage: null,
+  lastMaintenanceDate: null,
+  lastRunningTime: null,
+  nextRunningTime: null,
+  lastRunningKilometers: null,
+  nextRunningKilometers: null,
+  lastNaturalDate: null,
+  nextNaturalDate: null,
+  kiloCycleLead: null,
+  timePeriodLead: null,
+  naturalDatePeriodLead: null,
+  code: null,
+  type: null
+})
+const isReadonly = ref<boolean>(false)
+const sourceRow = ref<List>()
+const formRef = ref<FormInstance>()
+const timeAccumulatedAttrs = ref<AccumulatedAttr[]>([])
+const mileageAccumulatedAttrs = ref<AccumulatedAttr[]>([])
+
+const timeEnable = computed(() => form.value.runningTimeRule === 0)
+const mileageEnable = computed(() => form.value.mileageRule === 0)
+const naturalDateEnable = computed(() => form.value.naturalDateRule === 0)
+const timeAccumulatedVisible = computed(
+  () =>
+    timeEnable.value &&
+    timeAccumulatedAttrs.value.length > 0 &&
+    (form.value.totalRunTime == null || isNaN(form.value.totalRunTime))
+)
+const mileageAccumulatedVisible = computed(
+  () =>
+    mileageEnable.value &&
+    mileageAccumulatedAttrs.value.length > 0 &&
+    (form.value.totalMileage == null || isNaN(form.value.totalMileage))
+)
+const accumulatedVisible = computed(
+  () => timeAccumulatedVisible.value || mileageAccumulatedVisible.value
+)
+
+type NumberFieldProp =
+  | 'lastRunningTime'
+  | 'nextRunningTime'
+  | 'timePeriodLead'
+  | 'lastRunningKilometers'
+  | 'nextRunningKilometers'
+  | 'kiloCycleLead'
+  | 'nextNaturalDate'
+  | 'naturalDatePeriodLead'
+type DateFieldProp = 'lastNaturalDate'
+type NumberFieldConfig = {
+  label: string
+  prop: NumberFieldProp
+  type: 'number'
+  precision?: number
+}
+type DateFieldConfig = {
+  label: string
+  prop: DateFieldProp
+  type: 'date'
+  placeholder?: string
+}
+type FieldConfig = NumberFieldConfig | DateFieldConfig
+type ConditionalFieldConfig = FieldConfig & { visible?: boolean }
+
+type SectionConfig = {
+  key: string
+  title: string
+  visible: boolean
+  fields: FieldConfig[]
+}
+
+const enabledRuleCount = computed(
+  () => [timeEnable.value, mileageEnable.value, naturalDateEnable.value].filter(Boolean).length
+)
+
+const enabledFields = (fields: ConditionalFieldConfig[]): FieldConfig[] =>
+  fields.filter(({ visible = true }) => visible)
+
+const ruleSections = computed<SectionConfig[]>(() => [
+  {
+    key: 'basic',
+    title: t('mainPlan.basicMaintenanceRecords'),
+    visible: enabledRuleCount.value > 0,
+    fields: enabledFields([
+      {
+        label: t('mainPlan.lastMaintenanceOperationTime'),
+        prop: 'lastRunningTime',
+        type: 'number',
+        precision: 1,
+        visible: timeEnable.value
+      },
+      {
+        label: t('mainPlan.lastMaintenanceMileage'),
+        prop: 'lastRunningKilometers',
+        type: 'number',
+        precision: 2,
+        visible: mileageEnable.value
+      },
+      {
+        label: t('mainPlan.lastMaintenanceNaturalDate'),
+        prop: 'lastNaturalDate',
+        type: 'date',
+        placeholder: '选择日期',
+        visible: naturalDateEnable.value
+      }
+    ])
+  },
+  {
+    key: 'time',
+    title: t('mainPlan.RunTimeRuleConfiguration'),
+    visible: timeEnable.value,
+    fields: [
+      {
+        label: t('mainPlan.RunTimeCycle'),
+        prop: 'nextRunningTime',
+        type: 'number',
+        precision: 1
+      },
+      {
+        label: t('mainPlan.RunTimeCycle_Lead'),
+        prop: 'timePeriodLead',
+        type: 'number',
+        precision: 1
+      }
+    ]
+  },
+  {
+    key: 'mileage',
+    title: t('mainPlan.operatingMileageRuleConfiguration'),
+    visible: mileageEnable.value,
+    fields: [
+      {
+        label: t('mainPlan.operatingMileageCycle'),
+        prop: 'nextRunningKilometers',
+        type: 'number',
+        precision: 2
+      },
+      {
+        label: t('mainPlan.OperatingMileageCycle_lead'),
+        prop: 'kiloCycleLead',
+        type: 'number',
+        precision: 2
+      }
+    ]
+  },
+  {
+    key: 'natural-date',
+    title: t('mainPlan.NaturalDayRuleConfig'),
+    visible: naturalDateEnable.value,
+    fields: [
+      {
+        label: t('mainPlan.NaturalDailyCycle'),
+        prop: 'nextNaturalDate',
+        type: 'number'
+      },
+      {
+        label: t('mainPlan.NaturalDailyCycle_Lead'),
+        prop: 'naturalDatePeriodLead',
+        type: 'number'
+      }
+    ]
+  }
+])
+
+const cloneRow = (row: List): List => ({
+  ...row
+})
+
+const isPositiveRequiredValue = (value: unknown) => typeof value === 'number' && value > 0
+
+const positiveNumberRule = (label: string) => ({
+  required: true,
+  validator: (_rule: unknown, value: unknown, callback: (error?: Error) => void) => {
+    if (isPositiveRequiredValue(value)) {
+      callback()
+      return
+    }
+
+    callback(new Error(`请填写${label}`))
+  },
+  trigger: ['blur', 'change']
+})
+
+const requiredRule = (message: string) => ({
+  required: true,
+  message,
+  trigger: ['blur', 'change']
+})
+
+const formRules = computed<FormRules>(() => {
+  const rules: FormRules = {}
+
+  for (const section of ruleSections.value) {
+    if (!section.visible) continue
+
+    for (const field of section.fields) {
+      rules[field.prop] =
+        field.type === 'number'
+          ? [positiveNumberRule(field.label)]
+          : [requiredRule(`请选择${field.label}`)]
+    }
+  }
+
+  if (timeAccumulatedVisible.value) {
+    rules.code = [requiredRule('请选择累计运行时长')]
+  }
+
+  if (mileageAccumulatedVisible.value) {
+    rules.type = [requiredRule('请选择累计运行公里数')]
+  }
+
+  return rules
+})
+
+const emit = defineEmits<{
+  saved: []
+}>()
+
+const close = () => {
+  visible.value = false
+}
+
+const save = async () => {
+  if (isReadonly.value) {
+    close()
+    return
+  }
+
+  if (!sourceRow.value) return
+  if (!formRef.value) return
+
+  try {
+    await formRef.value.validate()
+  } catch {
+    return
+  }
+
+  const data = cloneRow(form.value)
+
+  if (timeAccumulatedVisible.value) {
+    const selectedTimeAttr = timeAccumulatedAttrs.value.find((item) => item.pointName === data.code)
+    data.tempTotalRunTime = selectedTimeAttr?.totalRunTime ?? null
+  }
+
+  if (mileageAccumulatedVisible.value) {
+    const selectedMileageAttr = mileageAccumulatedAttrs.value.find(
+      (item) => item.pointName === data.type
+    )
+    data.tempTotalMileage = selectedMileageAttr?.totalRunTime ?? null
+  }
+
+  Object.assign(sourceRow.value, data)
+  emit('saved')
+  close()
+}
+
+const open = ({
+  row,
+  readonly,
+  timeAccumulatedAttrs: timeAttrs,
+  mileageAccumulatedAttrs: mileageAttrs
+}: Props) => {
+  sourceRow.value = row
+  form.value = cloneRow(row)
+  isReadonly.value = readonly
+  timeAccumulatedAttrs.value = timeAttrs || []
+  mileageAccumulatedAttrs.value = mileageAttrs || []
+  visible.value = true
+  nextTick(() => {
+    formRef.value?.clearValidate()
+  })
+}
+
+defineExpose({
+  open
+})
+</script>
+
+<template>
+  <Dialog v-model="visible" title="保养项配置" width="720px" :close-on-click-modal="false">
+    <el-form
+      ref="formRef"
+      size="default"
+      :model="form"
+      :rules="formRules"
+      label-position="top"
+      class="maintenance-config-form">
+      <div class="config-summary">
+        <div class="summary-item">
+          <span class="summary-label">{{ t('iotMaintain.deviceCode') }}</span>
+          <span class="summary-value">{{ form.deviceCode || '-' }}</span>
+        </div>
+        <div class="summary-item">
+          <span class="summary-label">{{ t('iotMaintain.deviceName') }}</span>
+          <span class="summary-value">{{ form.deviceName || '-' }}</span>
+        </div>
+        <div class="summary-item summary-item-full">
+          <span class="summary-label">{{ t('bomList.bomNode') }}</span>
+          <span class="summary-value">{{ form.name || '-' }}</span>
+        </div>
+      </div>
+
+      <el-empty v-if="enabledRuleCount === 0" :image-size="88" description="当前保养项未启用规则" />
+
+      <section
+        v-for="section in ruleSections"
+        v-show="section.visible"
+        :key="section.key"
+        class="config-section">
+        <div class="section-header">
+          <div class="section-title">{{ section.title }}</div>
+        </div>
+        <el-row :gutter="16">
+          <el-col v-for="field in section.fields" :key="field.prop" :xs="24" :sm="12">
+            <el-form-item :label="field.label" :prop="field.prop">
+              <el-input-number
+                v-if="field.type === 'number'"
+                v-model="form[field.prop]"
+                :precision="field.precision"
+                :min="0"
+                :controls="false"
+                :disabled="isReadonly"
+                class="w-full!" />
+              <el-date-picker
+                v-else
+                v-model="form[field.prop]"
+                type="date"
+                :placeholder="field.placeholder"
+                value-format="x"
+                :disabled="isReadonly"
+                class="w-full!" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </section>
+      <section v-if="accumulatedVisible" class="config-section">
+        <div class="section-header">
+          <div class="section-title">{{ t('mainPlan.accumulatedParams') }}</div>
+        </div>
+        <el-row :gutter="16">
+          <el-col v-if="timeAccumulatedVisible" :xs="24" :sm="12">
+            <el-form-item :label="t('mainPlan.accumulatedRunTime')" prop="code">
+              <el-select
+                v-model="form.code"
+                placeholder="请选择累计运行时长"
+                clearable
+                :disabled="isReadonly"
+                class="w-full!">
+                <el-option
+                  v-for="(item, index) in timeAccumulatedAttrs"
+                  :key="`time-${item.pointName}-${index}`"
+                  :label="item.pointName"
+                  :value="item.pointName" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+          <el-col v-if="mileageAccumulatedVisible" :xs="24" :sm="12">
+            <el-form-item :label="t('mainPlan.accumulatedMileage')" prop="type">
+              <el-select
+                v-model="form.type"
+                placeholder="请选择累计运行公里数"
+                clearable
+                :disabled="isReadonly"
+                class="w-full!">
+                <el-option
+                  v-for="(item, index) in mileageAccumulatedAttrs"
+                  :key="`mileage-${item.pointName}-${index}`"
+                  :label="item.pointName"
+                  :value="item.pointName" />
+              </el-select>
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </section>
+    </el-form>
+    <template #footer>
+      <el-button @click="close">{{ t('common.cancel') }}</el-button>
+      <el-button v-if="!isReadonly" type="primary" @click="save">{{ t('common.save') }}</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<style scoped lang="scss">
+.maintenance-config-form {
+  display: flex;
+  flex-direction: column;
+  gap: 14px;
+}
+
+.config-summary {
+  display: grid;
+  grid-template-columns: repeat(2, minmax(0, 1fr));
+  gap: 10px 14px;
+  padding: 12px 14px;
+  background: var(--el-fill-color-light);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.summary-item {
+  min-width: 0;
+}
+
+.summary-item-full {
+  grid-column: 1 / -1;
+}
+
+.summary-label {
+  display: block;
+  margin-bottom: 4px;
+  font-size: 12px;
+  line-height: 1.2;
+  color: var(--el-text-color-secondary);
+}
+
+.summary-value {
+  display: block;
+  overflow: hidden;
+  font-size: 14px;
+  font-weight: 500;
+  line-height: 1.4;
+  color: var(--el-text-color-primary);
+  text-overflow: ellipsis;
+  white-space: nowrap;
+}
+
+.config-section {
+  padding: 14px 14px 2px;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+}
+
+.section-header {
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+  margin-bottom: 12px;
+}
+
+.section-title {
+  position: relative;
+  padding-left: 10px;
+  font-size: 14px;
+  font-weight: 600;
+  line-height: 1.4;
+  color: var(--el-text-color-primary);
+}
+
+.section-title::before {
+  position: absolute;
+  top: 3px;
+  bottom: 3px;
+  left: 0;
+  width: 3px;
+  background: var(--el-color-primary);
+  border-radius: 3px;
+  content: '';
+}
+
+.form-control {
+  width: 100% !important;
+}
+
+.maintenance-config-form :deep(.el-form-item) {
+  margin-bottom: 14px;
+}
+
+.maintenance-config-form :deep(.el-form-item__label) {
+  margin-bottom: 6px;
+  font-size: 13px;
+  line-height: 1.4;
+  color: var(--el-text-color-regular);
+}
+
+.maintenance-config-form :deep(.el-input__wrapper) {
+  border-radius: 6px;
+  box-shadow: 0 0 0 1px var(--el-border-color-light) inset;
+}
+
+.maintenance-config-form :deep(.el-input__wrapper:hover) {
+  box-shadow: 0 0 0 1px var(--el-border-color) inset;
+}
+
+.maintenance-config-form :deep(.el-input.is-disabled .el-input__wrapper) {
+  background-color: var(--el-fill-color-lighter);
+}
+
+@media (width <= 640px) {
+  .config-summary {
+    grid-template-columns: 1fr;
+  }
+}
+</style>

+ 901 - 0
src/views/pms/maintenance/maintenance-plan-manage.vue

@@ -0,0 +1,901 @@
+<script lang="ts" setup>
+import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
+import { useUserStore } from '@/store/modules/user'
+import * as DeptApi from '@/api/system/dept'
+import { IotMaintenancePlanApi } from '@/api/pms/maintenance'
+import { dayjs, FormInstance, FormRules } from 'element-plus'
+import { IotMaintenanceBomApi } from '@/api/pms/iotmaintenancebom'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { DeviceList, List, Plan, Query } from './types'
+import { IotDeviceApi } from '@/api/pms/device'
+import MaintenancePlanForm from './maintenance-plan-form.vue'
+import maintenanceDeviceList from './maintenance-device-list.vue'
+import { useTagsViewStore } from '@/store/modules/tagsView'
+
+const route = useRoute()
+const router = useRouter()
+const { delView } = useTagsViewStore()
+const message = useMessage()
+const { t } = useI18n()
+
+const mode = computed(() => {
+  if (route.name === 'IotMaintenancePlanDetail') return 'detail'
+  if (route.name === 'IotMainPlanEdit') return 'edit'
+  return 'create'
+})
+
+const isReadonly = computed(() => mode.value === 'detail')
+
+const id = computed(() => route.params.id as string | undefined)
+
+const initQuery = (): Query => ({
+  deviceIds: undefined,
+  planId: undefined,
+  bomFlag: 'b'
+})
+
+const queryParams = ref<Query>(initQuery())
+
+const initPlan = (): Plan => ({
+  name: '',
+  remark: '',
+  responsiblePerson: '',
+  serialNumber: ''
+})
+
+const list = ref<List[]>([])
+
+const { ZmTable, ZmTableColumn } = useTableComponents<List>()
+
+const loading = ref(false)
+const panelLoading = ref(false)
+const saving = ref(false)
+const plan = ref<Plan>(initPlan())
+const deviceIds = ref<Set<number>>(new Set())
+const originalRowMap = ref(new Map<string, List>())
+const attrs = ref(
+  new Map<string, { timeAccumulatedAttrs: any[]; mileageAccumulatedAttrs: any[] }>()
+)
+const planRef = ref<FormInstance>()
+const planRules = reactive<FormRules>({
+  name: [{ required: true, message: '计划名称不能为空', trigger: ['blur', 'change'] }]
+})
+
+const getRowKey = (row: Pick<List, 'deviceId' | 'bomNodeId'>) => `${row.deviceId}-${row.bomNodeId}`
+
+const cloneRow = (row: List): List => ({
+  ...row
+})
+
+const refreshOriginalRows = () => {
+  const currentKeys = new Set(list.value.map(getRowKey))
+
+  list.value.forEach((row) => {
+    const key = getRowKey(row)
+    originalRowMap.value.set(key, cloneRow(row))
+  })
+
+  Array.from(originalRowMap.value.keys()).forEach((key) => {
+    if (!currentKeys.has(key)) {
+      originalRowMap.value.delete(key)
+    }
+  })
+}
+
+const resetAttrs = (rows: List[]) => {
+  attrs.value.clear()
+  rows.forEach((row: any) => {
+    attrs.value.set(getRowKey(row), {
+      timeAccumulatedAttrs: row.timeAccumulatedAttrs || [],
+      mileageAccumulatedAttrs: row.mileageAccumulatedAttrs || []
+    })
+  })
+}
+
+const syncListMeta = () => {
+  deviceIds.value = new Set(list.value.map((item) => item.deviceId))
+  sortList()
+}
+
+const resetPageRows = (rows: List[]) => {
+  list.value = rows
+  resetAttrs(rows)
+  refreshOriginalRows()
+  syncListMeta()
+}
+
+const initPage = async () => {
+  panelLoading.value = true
+
+  plan.value = initPlan()
+  queryParams.value = initQuery()
+
+  try {
+    if (mode.value === 'create') {
+      const deptId = useUserStore().getUser.deptId
+      const dept = await DeptApi.getDept(deptId)
+      plan.value.name = `${dept?.name || ''} - 保养计划`
+      plan.value.deptId = deptId
+
+      const { wsCache } = useCache()
+      const userInfo = wsCache.get(CACHE_KEY.USER)
+      plan.value.responsiblePerson = userInfo.user.id
+    } else if (id.value) {
+      const res = await IotMaintenancePlanApi.getIotMaintenancePlan(Number(id.value))
+      plan.value = res
+
+      queryParams.value.planId = Number(id.value)
+      const data = await IotMaintenanceBomApi.getMainPlanBOMs(queryParams.value)
+      resetPageRows(data || [])
+    }
+  } finally {
+    panelLoading.value = false
+  }
+}
+
+const sortList = () => {
+  list.value = list.value.sort((a, b) => {
+    const aCode = a.deviceCode || ''
+    const bCode = b.deviceCode || ''
+    const aName = a.name || ''
+    const bName = b.name || ''
+
+    if (aCode !== bCode) {
+      return aCode.localeCompare(bCode)
+    }
+
+    return aName.localeCompare(bName)
+  })
+}
+
+watch(
+  () => route.fullPath,
+  () => {
+    initPage()
+  },
+  { immediate: true }
+)
+
+watch(
+  () => list.value.length,
+  () => {
+    syncListMeta()
+  }
+)
+
+const runningTimeVisible = computed(() => list.value.some((item) => item.runningTimeRule === 0))
+const mileageVisible = computed(() => list.value.some((item) => item.mileageRule === 0))
+const naturalDateVisible = computed(() => list.value.some((item) => item.naturalDateRule === 0))
+
+const EMPTY_TEXT = '——'
+type DisplayValue = number | string
+
+const isPositiveNumber = (value: unknown): value is number => typeof value === 'number' && value > 0
+
+const toFixedNumber = (value: number, fractionDigits = 2) =>
+  parseFloat(value.toFixed(fractionDigits))
+
+const emptyFormatter = (row: List, keys: (keyof List)[], isTime: boolean = false) => {
+  const data = [...keys].reverse().find((key) => row[key] !== null && row[key] !== undefined)
+  const value = data ? row[data] : EMPTY_TEXT
+
+  return value !== EMPTY_TEXT && isTime ? dayjs(value as number).format('YYYY-MM-DD') : value
+}
+
+const positiveField = (row: List, key: keyof List) => {
+  const value = row[key]
+  return isPositiveNumber(value) ? value : null
+}
+
+const positiveDisplayValue = (row: List, keys: (keyof List)[]) => {
+  const value = emptyFormatter(row, keys)
+  return isPositiveNumber(value) ? value : null
+}
+
+const ruleValue = (
+  rule: List['runningTimeRule'],
+  values: Array<number | null>,
+  calculator: (...values: number[]) => DisplayValue
+) => {
+  const numericValues = values.filter(isPositiveNumber)
+  return rule === 0 && numericValues.length === values.length
+    ? calculator(...numericValues)
+    : EMPTY_TEXT
+}
+
+const nextMaintenanceH = (row: List) =>
+  ruleValue(
+    row.runningTimeRule,
+    [positiveField(row, 'lastRunningTime'), positiveField(row, 'nextRunningTime')],
+    (last, next) => last + next
+  )
+
+const remainH = (row: List) =>
+  ruleValue(
+    row.runningTimeRule,
+    [
+      positiveField(row, 'lastRunningTime'),
+      positiveField(row, 'nextRunningTime'),
+      positiveDisplayValue(row, ['totalRunTime', 'tempTotalRunTime'])
+    ],
+    (last, next, current) => toFixedNumber(next - (current - last))
+  )
+
+const nextMaintenanceKm = (row: List) =>
+  ruleValue(
+    row.mileageRule,
+    [positiveField(row, 'lastRunningKilometers'), positiveField(row, 'nextRunningKilometers')],
+    (last, next) => last + next
+  )
+
+const remainKm = (row: List) =>
+  ruleValue(
+    row.mileageRule,
+    [
+      positiveField(row, 'lastRunningKilometers'),
+      positiveField(row, 'nextRunningKilometers'),
+      positiveDisplayValue(row, ['totalMileage', 'tempTotalMileage'])
+    ],
+    (last, next, current) => toFixedNumber(next - (current - last))
+  )
+
+const nextNaturalDateValue = (row: List) => {
+  if (row.naturalDateRule !== 0 || !row.lastNaturalDate || !row.nextNaturalDate) return null
+  return dayjs(row.lastNaturalDate).add(row.nextNaturalDate, 'day')
+}
+
+const nextMaintenanceDate = (row: List) =>
+  nextNaturalDateValue(row)?.format('YYYY-MM-DD') ?? EMPTY_TEXT
+
+const remainDay = (row: List) =>
+  isPositiveNumber(row.nextNaturalDate)
+    ? (nextNaturalDateValue(row)?.diff(dayjs(), 'day') ?? EMPTY_TEXT)
+    : EMPTY_TEXT
+
+const hasEnabledRule = (row: List) =>
+  row.runningTimeRule === 0 || row.mileageRule === 0 || row.naturalDateRule === 0
+
+const requiresTimeAccumulatedAttr = (row: List) => {
+  const rowAttrs = attrs.value.get(getRowKey(row))
+  return (
+    row.runningTimeRule === 0 &&
+    (rowAttrs?.timeAccumulatedAttrs.length ?? 0) > 0 &&
+    (row.totalRunTime == null || isNaN(row.totalRunTime))
+  )
+}
+
+const requiresMileageAccumulatedAttr = (row: List) => {
+  const rowAttrs = attrs.value.get(getRowKey(row))
+  return (
+    row.mileageRule === 0 &&
+    (rowAttrs?.mileageAccumulatedAttrs.length ?? 0) > 0 &&
+    (row.totalMileage == null || isNaN(row.totalMileage))
+  )
+}
+
+const checkRowFilled = (row: List) => {
+  if (!hasEnabledRule(row)) return false
+
+  const runningTimeFilled =
+    row.runningTimeRule !== 0 ||
+    (isPositiveNumber(row.lastRunningTime) &&
+      isPositiveNumber(row.nextRunningTime) &&
+      isPositiveNumber(row.timePeriodLead) &&
+      (!requiresTimeAccumulatedAttr(row) || !!row.code))
+
+  const mileageFilled =
+    row.mileageRule !== 0 ||
+    (isPositiveNumber(row.lastRunningKilometers) &&
+      isPositiveNumber(row.nextRunningKilometers) &&
+      isPositiveNumber(row.kiloCycleLead) &&
+      (!requiresMileageAccumulatedAttr(row) || !!row.type))
+
+  const naturalDateFilled =
+    row.naturalDateRule !== 0 ||
+    (!!row.lastNaturalDate &&
+      isPositiveNumber(row.nextNaturalDate) &&
+      isPositiveNumber(row.naturalDatePeriodLead))
+
+  return runningTimeFilled && mileageFilled && naturalDateFilled
+}
+
+const remainValueMap = {
+  remainH,
+  remainKmometers: remainKm,
+  remainDay
+}
+type RemainColumnProp = keyof typeof remainValueMap
+
+const isNegativeValue = (value: DisplayValue) => typeof value === 'number' && value < 0
+
+const isRemainColumnProp = (property?: string): property is RemainColumnProp =>
+  !!property && property in remainValueMap
+
+const cellClassName = ({
+  row,
+  column
+}: {
+  row: List
+  column: { type?: string; property?: string }
+}) => {
+  if (column.type === 'index' && checkRowFilled(row)) return 'all-filled'
+
+  const getRemainValue = isRemainColumnProp(column.property)
+    ? remainValueMap[column.property]
+    : undefined
+  return getRemainValue && isNegativeValue(getRemainValue(row)) ? 'negative-remain-value' : ''
+}
+
+const getRowDisplayName = (row: List) => `${row.deviceCode || '-'}-${row.name || '-'}`
+
+const validateTableData = () => {
+  if (list.value.length === 0) {
+    message.error('请至少添加一条设备保养明细')
+    return false
+  }
+
+  for (const [index, row] of list.value.entries()) {
+    const rowNumber = index + 1
+    const rowName = getRowDisplayName(row)
+
+    if (!hasEnabledRule(row)) {
+      message.error(`第 ${rowNumber} 行(${rowName}):保养项至少设置1个保养规则`)
+      return false
+    }
+
+    if (row.runningTimeRule === 0) {
+      if (
+        !isPositiveNumber(row.lastRunningTime) ||
+        !isPositiveNumber(row.nextRunningTime) ||
+        !isPositiveNumber(row.timePeriodLead)
+      ) {
+        message.error(`第 ${rowNumber} 行(${rowName}):请完整配置运行时长规则`)
+        return false
+      }
+
+      if (requiresTimeAccumulatedAttr(row) && !row.code) {
+        message.error(`第 ${rowNumber} 行(${rowName}):请选择累计运行时长参数`)
+        return false
+      }
+    }
+
+    if (row.mileageRule === 0) {
+      if (
+        !isPositiveNumber(row.lastRunningKilometers) ||
+        !isPositiveNumber(row.nextRunningKilometers) ||
+        !isPositiveNumber(row.kiloCycleLead)
+      ) {
+        message.error(`第 ${rowNumber} 行(${rowName}):请完整配置运行里程规则`)
+        return false
+      }
+
+      if (requiresMileageAccumulatedAttr(row) && !row.type) {
+        message.error(`第 ${rowNumber} 行(${rowName}):请选择累计运行公里数参数`)
+        return false
+      }
+    }
+
+    if (
+      row.naturalDateRule === 0 &&
+      (!row.lastNaturalDate ||
+        !isPositiveNumber(row.nextNaturalDate) ||
+        !isPositiveNumber(row.naturalDatePeriodLead))
+    ) {
+      message.error(`第 ${rowNumber} 行(${rowName}):请完整配置自然日期规则`)
+      return false
+    }
+  }
+
+  return true
+}
+
+const close = () => {
+  delView(unref(router.currentRoute))
+  router.push({ name: 'IotMaintenancePlan', params: {} })
+}
+
+const savePlan = async () => {
+  if (isReadonly.value) return
+  if (!planRef.value) return
+
+  try {
+    await planRef.value.validate()
+  } catch {
+    return
+  }
+
+  if (!validateTableData()) return
+
+  saving.value = true
+  try {
+    const data = {
+      mainPlan: plan.value,
+      mainPlanBom: list.value
+    }
+
+    if (mode.value === 'create') {
+      await IotMaintenancePlanApi.createIotMaintenancePlan(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await IotMaintenancePlanApi.updatePlan(data)
+      message.success(t('common.updateSuccess'))
+    }
+
+    close()
+  } finally {
+    saving.value = false
+  }
+}
+
+type RuleType = 'runningTime' | 'mileage' | 'naturalDate'
+type RuleField =
+  | 'tempTotalRunTime'
+  | 'lastRunningTime'
+  | 'nextRunningTime'
+  | 'timePeriodLead'
+  | 'tempTotalMileage'
+  | 'lastRunningKilometers'
+  | 'nextRunningKilometers'
+  | 'kiloCycleLead'
+  | 'lastNaturalDate'
+  | 'nextNaturalDate'
+  | 'naturalDatePeriodLead'
+  | 'code'
+  | 'type'
+
+const ruleFieldMap: Record<RuleType, RuleField[]> = {
+  runningTime: ['lastRunningTime', 'nextRunningTime', 'timePeriodLead', 'code', 'tempTotalRunTime'],
+  mileage: [
+    'lastRunningKilometers',
+    'nextRunningKilometers',
+    'kiloCycleLead',
+    'type',
+    'tempTotalMileage'
+  ],
+  naturalDate: ['lastNaturalDate', 'nextNaturalDate', 'naturalDatePeriodLead']
+}
+
+const clearRuleFields = (row: List, fields: RuleField[]) => {
+  fields.forEach((field) => {
+    row[field] = null
+  })
+}
+
+const restoreRuleFields = (row: List, fields: RuleField[]) => {
+  const originalRow = originalRowMap.value.get(getRowKey(row))
+  if (!originalRow) return
+
+  fields.forEach((field) => {
+    row[field] = originalRow[field] as any
+  })
+}
+
+const handleRuleChange = (row: List, ruleType: RuleType, value: unknown) => {
+  const fields = ruleFieldMap[ruleType]
+
+  if (value === 1) {
+    clearRuleFields(row, fields)
+    return
+  }
+
+  restoreRuleFields(row, fields)
+}
+
+const deviceList = ref()
+
+const onDeviceSelect = () => {
+  if (isReadonly.value) return
+  deviceList.value.open()
+}
+
+const deviceChoose = async (rows: DeviceList[]) => {
+  const selectIds = rows.map((item) => item.id)
+  const params = {
+    deviceIds: selectIds.join(','),
+    bomFlag: 'b'
+  }
+  try {
+    const res = await IotDeviceApi.deviceAssociateBomList(params)
+
+    if (res.length === 0) {
+      message.error('选择的设备不存在待保养BOM项')
+    }
+
+    if (!Array.isArray(res)) return
+
+    const existingKeys = new Set(list.value.map((item) => `${item.deviceId}-${item.bomNodeId}`))
+
+    const items = res
+      .filter((item) => !existingKeys.has(`${item.id}-${item.bomNodeId}`))
+      .map<List>((item) => {
+        const row: List = {
+          deviceId: item.id,
+          bomNodeId: item.bomNodeId,
+          deviceCode: item.deviceCode,
+          deviceName: item.deviceName,
+          name: item.name,
+          runningTimeRule: 1,
+          mileageRule: 1,
+          naturalDateRule: 1,
+          totalRunTime: item.totalRunTime,
+          tempTotalRunTime: null,
+          totalMileage: item.totalMileage,
+          tempTotalMileage: null,
+          lastMaintenanceDate: null,
+          lastRunningTime: null,
+          nextRunningTime: null,
+          lastRunningKilometers: null,
+          nextRunningKilometers: null,
+          lastNaturalDate: null,
+          nextNaturalDate: null,
+          kiloCycleLead: null,
+          timePeriodLead: null,
+          naturalDatePeriodLead: null,
+          code: null,
+          type: null
+        }
+
+        attrs.value.set(getRowKey(row), {
+          timeAccumulatedAttrs: item.timeAccumulatedAttrs || [],
+          mileageAccumulatedAttrs: item.mileageAccumulatedAttrs || []
+        })
+
+        return row
+      })
+
+    list.value.push(...items)
+    refreshOriginalRows()
+    syncListMeta()
+  } catch (error) {
+    message.error('获取设备关联保养BOM项失败')
+  }
+}
+
+const handleDelete = (str: string) => {
+  list.value = list.value.filter((item) => `${item.deviceId}-${item.bomNodeId}` !== str)
+}
+
+const planForm = ref()
+
+const editPlan = (row: List) => {
+  if (row.runningTimeRule !== 0 && row.mileageRule !== 0 && row.naturalDateRule !== 0) {
+    message.error('请先设置保养规则')
+    return
+  }
+
+  const rowAttrs = attrs.value.get(getRowKey(row))
+  planForm.value?.open({
+    row,
+    readonly: isReadonly.value,
+    timeAccumulatedAttrs: rowAttrs?.timeAccumulatedAttrs || [],
+    mileageAccumulatedAttrs: rowAttrs?.mileageAccumulatedAttrs || []
+  })
+}
+</script>
+
+<template>
+  <div
+    v-loading="panelLoading"
+    class="flex flex-col gap-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <section class="panel">
+      <div class="plan-info-accent"></div>
+      <el-form
+        :model="plan"
+        size="default"
+        :rules="planRules"
+        label-width="88px"
+        class="plan-info-form"
+        ref="planRef">
+        <el-row :gutter="24">
+          <el-col :xs="24" :md="13">
+            <el-form-item :label="t('main.planName')" prop="name">
+              <el-input v-model="plan.name" :disabled="isReadonly" clearable />
+            </el-form-item>
+          </el-col>
+          <el-col :xs="24" :md="11">
+            <el-form-item :label="t('main.planCode')" prop="serialNumber">
+              <el-input v-model="plan.serialNumber" disabled />
+            </el-form-item>
+          </el-col>
+          <el-col :span="24" class="mt-2">
+            <el-form-item :label="t('iotMaintain.remark')" prop="remark">
+              <el-input
+                v-model="plan.remark"
+                type="textarea"
+                :rows="3"
+                :placeholder="t('iotMaintain.remarkHolder')"
+                :disabled="isReadonly"
+                resize="none"
+                show-word-limit
+                maxlength="300" />
+            </el-form-item>
+          </el-col>
+        </el-row>
+      </el-form>
+    </section>
+    <section class="panel content-panel flex-1!">
+      <div v-if="!isReadonly" class="operation">
+        <el-button size="default" type="success" @click="onDeviceSelect">
+          <Icon icon="ep:plus" class="mr-5px" />{{ t('operationFill.add') }}
+        </el-button>
+      </div>
+      <div class="table">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <ZmTable
+              :data="list"
+              :loading="loading"
+              :width="width"
+              :height="height"
+              :highlight-current-row="false"
+              :cell-class-name="cellClassName">
+              <ZmTableColumn
+                type="index"
+                :label="t('iotDevice.serial')"
+                fixed="left"
+                hide-in-column-settings />
+              <ZmTableColumn
+                :min-width="120"
+                prop="deviceCode"
+                :label="t('iotMaintain.deviceCode')" />
+              <ZmTableColumn
+                :min-width="240"
+                prop="deviceName"
+                :label="t('iotMaintain.deviceName')" />
+              <ZmTableColumn :min-width="240" prop="name" :label="t('bomList.bomNode')" />
+              <ZmTableColumn prop="runningTimeRule" width="66" :label="t('main.runTime')">
+                <template #default="{ row }">
+                  <el-switch
+                    v-model="row.runningTimeRule"
+                    :active-value="0"
+                    :inactive-value="1"
+                    :disabled="isReadonly"
+                    @change="(value) => handleRuleChange(row, 'runningTime', value)" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="mileageRule" width="66" :label="t('main.mileage')">
+                <template #default="{ row }">
+                  <el-switch
+                    v-model="row.mileageRule"
+                    :active-value="0"
+                    :inactive-value="1"
+                    :disabled="isReadonly"
+                    @change="(value) => handleRuleChange(row, 'mileage', value)" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn prop="naturalDateRule" width="66" :label="t('main.date')">
+                <template #default="{ row }">
+                  <el-switch
+                    v-model="row.naturalDateRule"
+                    :active-value="0"
+                    :inactive-value="1"
+                    :disabled="isReadonly"
+                    @change="(value) => handleRuleChange(row, 'naturalDate', value)" />
+                </template>
+              </ZmTableColumn>
+              <ZmTableColumn
+                prop="totalRunTime"
+                :label="t('operationFillForm.sumTime')"
+                width="108"
+                :real-value="(row) => emptyFormatter(row, ['totalRunTime', 'tempTotalRunTime'])"
+                cover-formatter />
+              <ZmTableColumn
+                prop="totalMileage"
+                :label="t('operationFillForm.sumKil')"
+                width="128"
+                :real-value="(row) => emptyFormatter(row, ['totalMileage', 'tempTotalMileage'])"
+                cover-formatter />
+              <ZmTableColumn
+                prop="lastMaintenanceDate"
+                :label="t('mainPlan.lastMaintenanceDate')"
+                width="90"
+                :real-value="(row) => emptyFormatter(row, ['lastMaintenanceDate'], true)"
+                cover-formatter />
+
+              <ZmTableColumn
+                :visible="runningTimeVisible"
+                column-key="time-group"
+                label="保养时长"
+                is-parent>
+                <ZmTableColumn
+                  prop="lastRunningTime"
+                  :label="t('mainPlan.lastMaintenanceOperationTime')"
+                  :real-value="(row) => emptyFormatter(row, ['lastRunningTime'])"
+                  cover-formatter />
+                <ZmTableColumn
+                  prop="nextMaintenanceH"
+                  :label="t('mainPlan.nextMaintenanceH')"
+                  :real-value="nextMaintenanceH"
+                  cover-formatter />
+                <ZmTableColumn
+                  prop="remainH"
+                  :label="t('mainPlan.remainH')"
+                  :real-value="remainH"
+                  cover-formatter />
+              </ZmTableColumn>
+              <ZmTableColumn
+                :visible="mileageVisible"
+                column-key="mileage-group"
+                label="保养里程"
+                is-parent>
+                <ZmTableColumn
+                  prop="lastRunningKilometers"
+                  :label="t('mainPlan.lastMaintenanceMileage')"
+                  :real-value="(row) => emptyFormatter(row, ['lastRunningKilometers'])"
+                  cover-formatter />
+                <ZmTableColumn
+                  prop="nextRunningKilometers"
+                  :label="t('mainPlan.nextMaintenanceKm')"
+                  :real-value="nextMaintenanceKm"
+                  cover-formatter />
+                <ZmTableColumn
+                  prop="remainKmometers"
+                  :label="t('mainPlan.remainKm')"
+                  :real-value="remainKm"
+                  cover-formatter />
+              </ZmTableColumn>
+              <ZmTableColumn
+                :visible="naturalDateVisible"
+                column-key="date-group"
+                label="保养日期"
+                is-parent>
+                <ZmTableColumn
+                  prop="lastNaturalDate"
+                  :label="t('mainPlan.lastMaintenanceNaturalDate')"
+                  :real-value="(row) => emptyFormatter(row, ['lastNaturalDate'], true)"
+                  cover-formatter />
+                <ZmTableColumn
+                  prop="nextMaintenanceDate"
+                  :label="t('mainPlan.nextMaintDate')"
+                  :real-value="nextMaintenanceDate"
+                  cover-formatter />
+                <ZmTableColumn
+                  prop="remainDay"
+                  :label="t('mainPlan.remainDay')"
+                  :real-value="remainDay"
+                  cover-formatter />
+              </ZmTableColumn>
+              <ZmTableColumn
+                column-key="operation"
+                :label="t('operationFill.operation')"
+                :width="140"
+                fixed="right">
+                <template #default="{ row }">
+                  <el-button size="default" link type="primary" @click="editPlan(row)">
+                    <div class="i-lucide:edit-3 mr-1 translate-y-1px"></div>
+                    {{ isReadonly ? t('form.set') : t('modelTemplate.update') }}
+                  </el-button>
+                  <el-button
+                    size="default"
+                    v-if="!isReadonly"
+                    link
+                    type="danger"
+                    @click="handleDelete(`${row.deviceId}-${row.bomNodeId}`)">
+                    <div class="i-lucide:x mr-1 translate-y-1px"></div>
+                    {{ t('modelTemplate.delete') }}
+                  </el-button>
+                </template>
+              </ZmTableColumn>
+            </ZmTable>
+          </template>
+        </el-auto-resizer>
+      </div>
+    </section>
+    <section class="panel footer-panel">
+      <el-button @click="close">{{ t('iotMaintain.cancel') }}</el-button>
+      <el-button v-if="!isReadonly" type="primary" :loading="saving" @click="savePlan">{{
+        t('iotMaintain.save')
+      }}</el-button>
+    </section>
+  </div>
+  <maintenance-device-list ref="deviceList" @choose="deviceChoose" />
+  <maintenance-plan-form ref="planForm" @saved="refreshOriginalRows" />
+</template>
+
+<style scoped>
+.panel {
+  position: relative;
+  flex: 0 0 auto;
+  padding: 18px 20px 8px;
+  overflow: hidden;
+  background: var(--el-bg-color);
+  border: 1px solid var(--el-border-color-lighter);
+  border-radius: 8px;
+  box-shadow: 0 1px 3px rgb(0 0 0 / 4%);
+}
+
+.plan-info-accent {
+  position: absolute;
+  top: 0;
+  right: 0;
+  left: 0;
+  height: 3px;
+  background: linear-gradient(
+    90deg,
+    var(--el-color-primary),
+    var(--el-color-success),
+    var(--el-color-warning)
+  );
+}
+
+.plan-info-form {
+  width: 100%;
+}
+
+.plan-info-form :deep(.el-form-item) {
+  margin-bottom: 14px;
+}
+
+.plan-info-form :deep(.el-form-item__label) {
+  height: 32px;
+  font-size: 13px;
+  line-height: 32px;
+  color: var(--el-text-color-regular);
+}
+
+.plan-info-form :deep(.el-input__wrapper),
+.plan-info-form :deep(.el-textarea__inner) {
+  border-radius: 6px;
+  box-shadow: 0 0 0 1px var(--el-border-color-light) inset;
+}
+
+.plan-info-form :deep(.el-input__wrapper:hover),
+.plan-info-form :deep(.el-textarea__inner:hover) {
+  box-shadow: 0 0 0 1px var(--el-border-color) inset;
+}
+
+.plan-info-form :deep(.el-input.is-disabled .el-input__wrapper) {
+  background-color: var(--el-fill-color-lighter);
+}
+
+.plan-info-form :deep(.el-textarea__inner) {
+  min-height: 76px !important;
+  padding-top: 8px;
+  line-height: 1.6;
+}
+
+.content-panel {
+  display: flex;
+  min-height: 0;
+  flex-direction: column;
+}
+
+.operation {
+  display: flex;
+  flex: 0 0 auto;
+  align-items: center;
+  justify-content: flex-end;
+  padding-bottom: 12px;
+}
+
+.table {
+  position: relative;
+  min-height: 0;
+  flex: 1 1 auto;
+}
+
+.footer-panel {
+  display: flex;
+  justify-content: flex-end;
+  gap: 10px;
+  padding: 12px 20px;
+}
+
+:deep(.zm-table .all-filled) {
+  background-color: #67c23a !important;
+}
+
+:deep(.zm-table .all-filled .cell) {
+  color: #fff;
+}
+
+:deep(.zm-table .negative-remain-value .cell) {
+  color: var(--el-color-danger);
+}
+
+@media (width <= 768px) {
+  .panel {
+    padding: 16px 14px 4px;
+  }
+}
+</style>

+ 59 - 0
src/views/pms/maintenance/types.ts

@@ -0,0 +1,59 @@
+export interface DeviceQuery {
+  pageNo: number
+  pageSize: number
+  deviceName?: string
+  deviceCode?: string
+}
+
+export interface DeviceList {
+  id: number
+  deviceCode: string
+  deviceName: string
+  deptName: string
+  deviceStatus: string
+  hasSetMaintenanceBom: boolean
+}
+
+export interface Query {
+  deviceIds?: string
+  planId?: number
+  bomFlag?: string
+}
+
+export interface Plan {
+  createtime?: number
+  id?: number
+  deptId?: number
+  name: string
+  remark: string
+  responsiblePerson: string
+  serialNumber: string
+  status?: number
+}
+
+export interface List {
+  deviceId: number
+  bomNodeId: string
+  deviceCode: string
+  deviceName: string
+  name: string
+  runningTimeRule: 0 | 1
+  mileageRule: 0 | 1
+  naturalDateRule: 0 | 1
+  totalRunTime: number | null
+  tempTotalRunTime: number | null
+  totalMileage: number | null
+  tempTotalMileage: number | null
+  lastMaintenanceDate: number | null
+  lastRunningTime: number | null
+  nextRunningTime: number | null
+  lastRunningKilometers: number | null
+  nextRunningKilometers: number | null
+  lastNaturalDate: number | null
+  nextNaturalDate: number | null
+  kiloCycleLead: number | null
+  timePeriodLead: number | null
+  naturalDatePeriodLead: number | null
+  code: string | null
+  type: string | null
+}

+ 312 - 0
src/views/pms/qhse/MeasureCertDrawer.vue

@@ -0,0 +1,312 @@
+<template>
+  <el-drawer
+    v-model="drawerVisible"
+    title="查看证书"
+    size="60%"
+    direction="rtl"
+    :before-close="handleClose">
+    <div class="cert-drawer-content">
+      <el-form
+        ref="queryFormRef"
+        :model="queryParams"
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-4 pt-4 mb-4">
+        <div class="flex items-center gap-4 flex-wrap">
+          <el-form-item label="证书编码" prop="measureCertNo">
+            <el-input
+              v-model="queryParams.measureCertNo"
+              placeholder="请输入证书编码"
+              clearable
+              class="!w-180px" />
+          </el-form-item>
+          <el-form-item label="检测/校准日期" prop="detectDate">
+            <el-date-picker
+              v-model="queryParams.detectDate"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              type="daterange"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+              class="!w-200px" />
+          </el-form-item>
+          <el-form-item>
+            <el-button type="primary" plain @click="handleCreate">
+              <Icon icon="ep:plus" class="mr-5px" /> 新增
+            </el-button>
+            <el-button @click="handleQuery">
+              <Icon icon="ep:search" class="mr-5px" /> 搜索
+            </el-button>
+            <el-button @click="resetQuery">
+              <Icon icon="ep:refresh" class="mr-5px" /> 重置
+            </el-button>
+          </el-form-item>
+        </div>
+      </el-form>
+
+      <div class="cert-table-wrapper" v-loading="loading">
+        <el-auto-resizer>
+          <template #default="{ width, height }">
+            <zm-table
+              :data="list"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :show-overflow-tooltip="true">
+              <zm-table-column :label="t('monitor.serial')" width="70" align="center" fixed="left">
+                <template #default="scope">
+                  {{ scope.$index + 1 }}
+                </template>
+              </zm-table-column>
+
+              <zm-table-column
+                label="证书编码"
+                align="center"
+                prop="measureCertNo"
+                min-width="140" />
+              <zm-table-column label="检测日期" align="center" prop="detectDate" width="140">
+                <template #default="scope">
+                  <span class="iot-md-date">{{ formatDateCorrectly(scope.row.detectDate) }}</span>
+                </template>
+              </zm-table-column>
+              <zm-table-column label="检测机构" align="center" prop="detectOrg" min-width="160" />
+              <zm-table-column
+                label="检测标准"
+                align="center"
+                prop="detectStandard"
+                min-width="160" />
+              <zm-table-column label="检测/校准内容" align="center" prop="detectContent">
+                <template #default="scope">
+                  <div class="detect-content" v-html="scope.row.detectContent"></div>
+                </template>
+              </zm-table-column>
+              <zm-table-column label="有效期" align="center" prop="validityPeriod" width="140">
+                <template #default="scope">
+                  <span class="iot-md-date">{{
+                    formatDateCorrectly(scope.row.validityPeriod)
+                  }}</span>
+                </template>
+              </zm-table-column>
+              <zm-table-column label="校准金额" align="center" prop="detectAmount" />
+              <zm-table-column label="附件" fixed="right" align="center" prop="file" width="90">
+                <template #default="scope">
+                  <el-button
+                    v-if="scope.row.file"
+                    link
+                    type="primary"
+                    @click="viewFile(scope.row.file)">
+                    查看
+                  </el-button>
+                  <span v-else class="text-[#999ca1]">暂无</span>
+                </template>
+              </zm-table-column>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList" />
+      </div>
+    </div>
+  </el-drawer>
+
+  <Dialog v-model="dialogFileView" title="附件" width="500">
+    <div
+      v-for="(file, index) in fileList"
+      :key="index"
+      class="flex items-center justify-between mt-5">
+      <span class="file-name-text">{{ extractFileName(file) }}</span>
+      <div>
+        <el-button link type="primary" @click="viewFileInfo(file)">
+          <Icon icon="ep:view" class="mr-2px" />查看
+        </el-button>
+        <el-button link type="primary" @click="handleDownload(file)">
+          <Icon icon="ep:download" class="mr-2px" />下载
+        </el-button>
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer mt-10">
+        <el-button type="primary" @click="dialogFileView = false">确认</el-button>
+      </div>
+    </template>
+  </Dialog>
+
+  <IotMeasureDetectForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import { IotMeasureDetectApi } from '@/api/pms/qhse/index'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+import { formatDate } from '@/utils/formatTime'
+import IotMeasureDetectForm from './iotmeasuredetect/IotMeasureDetectForm.vue'
+
+const { ZmTable, ZmTableColumn } = useTableComponents()
+
+defineOptions({ name: 'MeasureCertDrawer' })
+
+const { t } = useI18n()
+
+type MeasureRow = {
+  id: number
+  measureName?: string
+  measureCode?: string
+  deptId?: number | string
+}
+
+const drawerVisible = ref(false)
+const loading = ref(false)
+const list = ref<any[]>([])
+const total = ref(0)
+const currentMeasure = ref<MeasureRow | null>(null)
+
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  measureId: undefined as number | undefined,
+  detectDate: undefined as string | undefined,
+  measureCertNo: undefined as string | undefined
+})
+
+const queryFormRef = ref()
+const formRef = ref()
+
+const getList = async () => {
+  if (!currentMeasure.value?.id) return
+
+  loading.value = true
+  try {
+    const data = await IotMeasureDetectApi.getIotMeasureDetectPage(queryParams)
+    list.value = data.list || []
+    total.value = data.total || 0
+  } finally {
+    loading.value = false
+  }
+}
+
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const resetQuery = () => {
+  queryFormRef.value?.resetFields()
+  queryParams.measureId = currentMeasure.value?.id
+  handleQuery()
+}
+
+const handleCreate = () => {
+  if (!currentMeasure.value?.id) return
+
+  formRef.value?.open('create', undefined, {
+    measureId: currentMeasure.value.id,
+    measureName: currentMeasure.value.measureName || currentMeasure.value.measureCode || '',
+    deptId: currentMeasure.value.deptId
+  })
+}
+
+const open = async (measureRow: MeasureRow) => {
+  currentMeasure.value = measureRow
+  queryParams.measureId = measureRow.id
+  queryParams.measureName = undefined
+  queryParams.measureCertNo = undefined
+  queryParams.pageNo = 1
+  drawerVisible.value = true
+  await getList()
+}
+
+const handleClose = () => {
+  drawerVisible.value = false
+  currentMeasure.value = null
+  list.value = []
+  total.value = 0
+}
+
+const formatDateCorrectly = (timestamp: string | number) => {
+  if (!timestamp) return ''
+
+  let time = Number(timestamp)
+  if (time < 10000000000) {
+    time = time * 1000
+  }
+
+  const date = new Date(time)
+  if (isNaN(date.getTime()) || date.getFullYear() < 1900) {
+    return ''
+  }
+
+  return formatDate(time).substring(0, 10)
+}
+
+const dialogFileView = ref(false)
+const fileList = ref<string[]>([])
+
+const viewFile = (file: string) => {
+  fileList.value = file.split(',')
+  dialogFileView.value = true
+}
+
+const viewFileInfo = (file: string) => {
+  window.open(
+    'http://doc.deepoil.cc:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(file))
+  )
+}
+
+const extractFileName = (url: string): string => {
+  try {
+    const cleanUrl = url.split('?')[0].split('#')[0]
+    const parts = cleanUrl.split('/')
+    const fileName = parts[parts.length - 1]
+    return decodeURIComponent(fileName) || url
+  } catch {
+    return url
+  }
+}
+
+const handleDownload = async (url: string) => {
+  try {
+    const response = await fetch(url)
+    const blob = await response.blob()
+    const downloadUrl = window.URL.createObjectURL(blob)
+    const link = document.createElement('a')
+    link.href = downloadUrl
+    link.download = url.split('/').pop() || 'file'
+    link.click()
+    URL.revokeObjectURL(downloadUrl)
+  } catch (error) {
+    console.error('下载失败:', error)
+  }
+}
+
+defineExpose({
+  open
+})
+</script>
+
+<style scoped>
+.cert-drawer-content {
+  height: 100%;
+  display: flex;
+  flex-direction: column;
+  margin-top: -10px;
+}
+
+.cert-table-wrapper {
+  flex: 1;
+  min-height: 0;
+  position: relative;
+}
+
+.file-name-text {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-right: 12px;
+  color: var(--el-text-color-primary);
+}
+</style>

+ 10 - 1
src/views/pms/qhse/certificate.vue

@@ -707,13 +707,22 @@ const submitForm = async () => {
     await formRef.value.validate()
     submitLoading.value = true
 
+    console.log('提交数据:', formData.value.certPic)
+
+    let certPic: any = null
+    if (isEdit.value) {
+      certPic = formData.value.certPic ? formData.value.certPic.join(',') : ''
+    } else {
+      certPic = formData.value.certPic
+    }
+
     // 准备提交数据
     const submitData = {
       ...formData.value,
       // 确保日期字段以正确的格式提交
       certIssue: formData.value.certIssue,
       certExpire: formData.value.certExpire,
-      certPic: formData.value.certPic ? formData.value.certPic.join(',') : '' // 将图片数组转换为逗号分隔的字符串
+      certPic // 将图片数组转换为逗号分隔的字符串
     }
 
     if (isEdit.value) {

+ 405 - 0
src/views/pms/qhse/deviceCert/DeviceCertForm.vue

@@ -0,0 +1,405 @@
+<template>
+  <Dialog :title="dialogTitle" v-model="dialogVisible" width="50%">
+    <el-form
+      ref="formRef"
+      :model="formData"
+      :rules="formRules"
+      label-width="auto"
+      v-loading="formLoading">
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="关联设备" prop="deviceId">
+            <el-input
+              v-model="formData.deviceName"
+              disabled
+              placeholder="请选择关联设备"
+              style="width: 100%">
+              <template #append>
+                <el-link @click="selectDevice" :underline="false">选择</el-link>
+              </template>
+            </el-input>
+          </el-form-item>
+        </el-col>
+
+        <!-- <el-col :span="12">
+          <el-form-item label="证书编号" prop="certNo">
+            <el-input v-model="formData.certNo" placeholder="请输入证书编号" />
+          </el-form-item>
+        </el-col> -->
+        <el-col :span="12">
+          <el-form-item label="设备编码" prop="deviceCode">
+            <el-input v-model="formData.deviceCode" placeholder="请输入设备编码" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <!-- <el-col :span="12">
+          <el-form-item label="设备编码" prop="deviceCode">
+            <el-input v-model="formData.deviceCode" placeholder="请输入设备编码" />
+          </el-form-item>
+        </el-col> -->
+        <el-col :span="12">
+          <el-form-item label="设备名称" prop="deviceName">
+            <el-input v-model="formData.deviceName" placeholder="请输入设备名称" />
+          </el-form-item>
+        </el-col>
+
+        <el-col :span="12">
+          <el-form-item label="证书编号" prop="certNo">
+            <el-input v-model="formData.certNo" placeholder="请输入证书编号" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="检测单位" prop="certOrg">
+            <el-input v-model="formData.certOrg" placeholder="请输入检测单位" />
+          </el-form-item>
+        </el-col>
+
+        <el-col :span="12">
+          <el-form-item label="检测日期" prop="certTime">
+            <el-date-picker
+              v-model="formData.certTime"
+              type="date"
+              value-format="x"
+              placeholder="请选择检测日期"
+              style="width: 100%" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="12">
+          <el-form-item label="有效期至" prop="certExpire">
+            <el-date-picker
+              v-model="formData.certExpire"
+              type="date"
+              value-format="x"
+              placeholder="请选择有效期至"
+              style="width: 100%" />
+          </el-form-item>
+        </el-col>
+
+        <el-col :span="12">
+          <el-form-item label="部门" prop="deptId">
+            <el-tree-select
+              v-model="formData.deptId"
+              :data="deptList"
+              :props="defaultProps"
+              node-key="id"
+              filterable
+              :check-strictly="false"
+              clearable
+              placeholder="请选择部门"
+              style="width: 100%" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="附件" prop="file">
+            <UploadFile
+              v-model="formData.file"
+              :file-type="['doc', 'docx', 'pdf', 'jpg', 'png', 'jpeg', 'xls', 'xlsx']"
+              :limit="3"
+              :file-size="100"
+              class="min-w-80px" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+
+      <el-row :gutter="20">
+        <el-col :span="24">
+          <el-form-item label="备注" prop="remark">
+            <el-input
+              v-model="formData.remark"
+              type="textarea"
+              :rows="3"
+              placeholder="请输入备注" />
+          </el-form-item>
+        </el-col>
+      </el-row>
+    </el-form>
+
+    <template #footer>
+      <el-button @click="submitForm" type="primary" :disabled="formLoading">确 定</el-button>
+      <el-button @click="dialogVisible = false">取 消</el-button>
+    </template>
+  </Dialog>
+
+  <Dialog title="选择设备" v-model="deviceDialogVisible" width="70%">
+    <ContentWrap>
+      <el-form class="-mb-15px" :model="deviceQueryParams" :inline="true">
+        <el-form-item label="部门" prop="deptId">
+          <el-tree-select
+            v-model="deviceQueryParams.deptId"
+            :data="deptList"
+            :props="defaultProps"
+            node-key="id"
+            filterable
+            clearable
+            check-strictly
+            placeholder="请选择部门"
+            class="!w-200px" />
+        </el-form-item>
+        <el-form-item label="设备名称" prop="deviceName">
+          <el-input
+            v-model="deviceQueryParams.deviceName"
+            placeholder="请输入设备名称"
+            clearable
+            @keyup.enter="handleDeviceQuery"
+            class="!w-150px" />
+        </el-form-item>
+        <el-form-item label="设备编码" prop="deviceCode">
+          <el-input
+            v-model="deviceQueryParams.deviceCode"
+            placeholder="请输入设备编码"
+            clearable
+            @keyup.enter="handleDeviceQuery"
+            class="!w-150px" />
+        </el-form-item>
+        <el-form-item>
+          <el-button @click="handleDeviceQuery">
+            <Icon icon="ep:search" class="mr-5px" /> {{ t('devicePerson.search') }}
+          </el-button>
+          <el-button @click="resetDeviceQuery">
+            <Icon icon="ep:refresh" class="mr-5px" /> {{ t('devicePerson.reset') }}
+          </el-button>
+        </el-form-item>
+      </el-form>
+    </ContentWrap>
+
+    <div class="pb-10">
+      <zm-table
+        :loading="deviceLoading"
+        :data="deviceList"
+        :stripe="true"
+        :show-overflow-tooltip="true">
+        <zm-table-column width="50" align="center">
+          <template #default="scope">
+            <el-radio
+              :model-value="selectedDeviceId"
+              :label="scope.row.id"
+              @change="handleDeviceRadioChange(scope.row)">
+              &nbsp;
+            </el-radio>
+          </template>
+        </zm-table-column>
+        <zm-table-column :label="t('monitor.serial')" width="70" align="center">
+          <template #default="scope">
+            {{ scope.$index + 1 }}
+          </template>
+        </zm-table-column>
+        <zm-table-column label="设备编码" align="center" prop="deviceCode" min-width="140" />
+        <zm-table-column label="设备名称" align="center" prop="deviceName" min-width="160" />
+        <zm-table-column label="所在部门" align="center" prop="deptName" min-width="140" />
+        <zm-table-column label="设备状态" align="center" prop="deviceStatusName" min-width="120" />
+        <zm-table-column label="位置" align="center" prop="location" min-width="140" />
+        <zm-table-column label="备注" align="center" prop="remark" min-width="160" />
+      </zm-table>
+
+      <Pagination
+        :total="deviceTotal"
+        v-model:page="deviceQueryParams.pageNo"
+        v-model:limit="deviceQueryParams.pageSize"
+        @pagination="getDeviceList" />
+    </div>
+
+    <template #footer>
+      <el-button @click="confirmSelectDevice" type="primary">确 定</el-button>
+      <el-button @click="closeDeviceDialog">取 消</el-button>
+    </template>
+  </Dialog>
+</template>
+
+<script setup lang="ts">
+import { InspectDeviceCertApi } from '@/api/pms/qhse/index'
+import { IotDeviceApi } from '@/api/pms/device'
+import { defaultProps, handleTree } from '@/utils/tree'
+import { onMounted, reactive, ref } from 'vue'
+import * as DeptApi from '@/api/system/dept'
+import { useUserStore } from '@/store/modules/user'
+defineOptions({ name: 'QHSEDeviceCertForm' })
+
+const { t } = useI18n()
+const message = useMessage()
+const userStore = useUserStore()
+const dialogVisible = ref(false)
+const dialogTitle = ref('')
+const formLoading = ref(false)
+const formType = ref('')
+const formRef = ref()
+const deptList = ref<Tree[]>([])
+
+const createDefaultFormData = () => ({
+  id: undefined,
+  deviceId: undefined,
+  deviceName: '',
+  deptId: undefined,
+  certNo: '',
+  certOrg: '',
+  certTime: undefined,
+  certExpire: undefined,
+  file: '',
+  remark: '',
+  deviceCode: ''
+})
+
+const formData = ref(createDefaultFormData())
+
+const formRules = reactive({
+  // deviceId: [{ required: true, message: '关联设备不能为空', trigger: 'blur' }],
+  deviceName: [{ required: true, message: '设备名称不能为空', trigger: 'blur' }],
+  deviceCode: [{ required: true, message: '设备编码不能为空', trigger: 'blur' }],
+  certNo: [{ required: true, message: '证书编号不能为空', trigger: 'blur' }],
+  certOrg: [{ required: true, message: '检测单位不能为空', trigger: 'blur' }],
+  certTime: [{ required: true, message: '检测日期不能为空', trigger: 'blur' }],
+  certExpire: [{ required: true, message: '有效期不能为空', trigger: 'blur' }],
+  file: [{ required: true, message: '请上传附件', trigger: 'blur' }],
+  deptId: [{ required: true, message: '所在部门不能为空', trigger: 'blur' }]
+})
+
+const emit = defineEmits(['success'])
+
+const resetForm = () => {
+  formData.value = createDefaultFormData()
+  formRef.value?.resetFields()
+}
+
+const open = async (type: string, id?: number) => {
+  dialogVisible.value = true
+  dialogTitle.value = t('action.' + type)
+  formType.value = type
+  resetForm()
+  formData.value.deptId = userStore.getUser.deptId
+  if (id) {
+    formLoading.value = true
+    try {
+      const res = await InspectDeviceCertApi.getInspectDeviceCert(id)
+      formData.value = {
+        ...createDefaultFormData(),
+        ...res,
+        certTime: res.certTime ? Number(res.certTime) : undefined,
+        certExpire: res.certExpire ? Number(res.certExpire) : undefined
+      }
+    } finally {
+      formLoading.value = false
+    }
+  }
+}
+
+defineExpose({ open })
+
+const submitForm = async () => {
+  await formRef.value.validate()
+  formLoading.value = true
+  try {
+    const data = { ...formData.value } as any
+    if (data.file instanceof Array) {
+      data.file = data.file.join(',')
+    }
+
+    if (formType.value === 'create') {
+      await InspectDeviceCertApi.createInspectDeviceCert(data)
+      message.success(t('common.createSuccess'))
+    } else {
+      await InspectDeviceCertApi.updateInspectDeviceCert(data)
+      message.success(t('common.updateSuccess'))
+    }
+
+    dialogVisible.value = false
+    emit('success')
+  } finally {
+    formLoading.value = false
+  }
+}
+
+const deviceDialogVisible = ref(false)
+const deviceLoading = ref(false)
+const deviceList = ref<any[]>([])
+const deviceTotal = ref(0)
+const selectedDeviceId = ref<number | undefined>(undefined)
+const selectedDevice = ref<any>(null)
+
+const deviceQueryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deptId: undefined as number | undefined,
+  deviceName: undefined as string | undefined,
+  deviceCode: undefined as string | undefined
+})
+
+const getDeviceList = async () => {
+  deviceLoading.value = true
+  try {
+    const data = await IotDeviceApi.getIotDevicePage(deviceQueryParams)
+    deviceList.value = data.list
+    deviceTotal.value = data.total
+
+    if (selectedDeviceId.value) {
+      selectedDevice.value =
+        deviceList.value.find((item) => item.id === selectedDeviceId.value) || selectedDevice.value
+    }
+  } finally {
+    deviceLoading.value = false
+  }
+}
+
+const handleDeviceQuery = () => {
+  deviceQueryParams.pageNo = 1
+  getDeviceList()
+}
+
+const resetDeviceQuery = () => {
+  deviceQueryParams.deptId = undefined
+  deviceQueryParams.deviceName = undefined
+  deviceQueryParams.deviceCode = undefined
+  handleDeviceQuery()
+}
+
+const handleDeviceRadioChange = (row: any) => {
+  selectedDeviceId.value = row.id
+  selectedDevice.value = row
+}
+
+const selectDevice = () => {
+  deviceDialogVisible.value = true
+  deviceQueryParams.deptId = formData.value.deptId as number | undefined
+  selectedDeviceId.value = formData.value.deviceId as number | undefined
+  selectedDevice.value =
+    selectedDeviceId.value && formData.value.deviceName
+      ? {
+          id: formData.value.deviceId,
+          deviceName: formData.value.deviceName,
+          deptId: formData.value.deptId
+        }
+      : null
+  getDeviceList()
+}
+
+const closeDeviceDialog = () => {
+  deviceDialogVisible.value = false
+  selectedDeviceId.value = undefined
+  selectedDevice.value = null
+}
+
+const confirmSelectDevice = () => {
+  if (!selectedDevice.value) {
+    message.warning('请先选择一个设备')
+    return
+  }
+
+  formData.value.deviceId = selectedDevice.value.id
+  formData.value.deviceName = selectedDevice.value.deviceName
+  formData.value.deptId = selectedDevice.value.deptId
+  closeDeviceDialog()
+}
+
+onMounted(async () => {
+  deptList.value = handleTree(await DeptApi.getSimpleDeptList())
+})
+</script>

+ 459 - 0
src/views/pms/qhse/deviceCert/index.vue

@@ -0,0 +1,459 @@
+<template>
+  <div
+    class="grid grid-cols-[auto_1fr] grid-rows-[auto_auto_minmax(0,1fr)] gap-0 gap-x-4 h-[calc(100vh-20px-var(--top-tool-height)-var(--tags-view-height)-var(--app-footer-height))]">
+    <DeptTreeSelect
+      class="row-span-4"
+      :top-id="rootDeptId"
+      :deptId="deptId"
+      v-model="queryParams.deptId"
+      :init-select="false"
+      :show-title="false"
+      request-api="getSimpleDeptList"
+      @node-click="handleDeptNodeClick" />
+
+    <div class="mb-1">
+      <el-form
+        :model="queryParams"
+        ref="queryFormRef"
+        class="bg-white dark:bg-[#1d1e1f] rounded-lg shadow px-8 py-2 pt-4 flex items-center flex-wrap min-w-0">
+        <div class="flex items-center gap-4 flex-wrap">
+          <el-form-item label="设备名称" prop="deviceName">
+            <el-input
+              v-model="queryParams.deviceName"
+              placeholder="请输入设备名称"
+              clearable
+              class="!w-150px" />
+          </el-form-item>
+          <el-form-item label="证书编号" prop="certNo">
+            <el-input
+              v-model="queryParams.certNo"
+              placeholder="请输入证书编号"
+              clearable
+              class="!w-150px" />
+          </el-form-item>
+          <el-form-item label="检测日期" prop="certTime">
+            <el-date-picker
+              v-model="queryParams.certTime"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              type="daterange"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+              class="!w-200px" />
+          </el-form-item>
+          <el-form-item label="检测单位" prop="certOrg">
+            <el-input
+              v-model="queryParams.certOrg"
+              placeholder="请输入检测单位"
+              clearable
+              @keyup.enter="handleQuery"
+              class="!w-150px" />
+          </el-form-item>
+          <el-form-item label="有效期" prop="certExpire">
+            <el-date-picker
+              v-model="queryParams.certExpire"
+              value-format="YYYY-MM-DD HH:mm:ss"
+              type="daterange"
+              start-placeholder="开始日期"
+              end-placeholder="结束日期"
+              :default-time="[new Date('1 00:00:00'), new Date('1 23:59:59')]"
+              class="!w-200px" />
+          </el-form-item>
+
+          <el-form-item>
+            <el-button @click="handleQuery" v-hasPermi="['rq:qhse-device-cert:query']">
+              <Icon icon="ep:search" class="mr-5px" /> 搜索
+            </el-button>
+            <el-button @click="resetQuery">
+              <Icon icon="ep:refresh" class="mr-5px" /> 重置
+            </el-button>
+            <el-button
+              v-hasPermi="['rq:qhse-device-cert:create']"
+              type="primary"
+              plain
+              @click="openForm('create')">
+              <Icon icon="ep:plus" class="mr-5px" /> 新增
+            </el-button>
+            <el-button
+              v-hasPermi="['rq:qhse-device-cert:export']"
+              type="success"
+              plain
+              @click="handleExport"
+              :loading="exportLoading">
+              <Icon icon="ep:download" class="mr-5px" /> 导出
+            </el-button>
+          </el-form-item>
+        </div>
+      </el-form>
+    </div>
+
+    <div class="min-w-0"></div>
+
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg flex flex-col p-2 pt-4 min-w-0">
+      <div class="flex-1 relative min-h-0">
+        <el-auto-resizer class="absolute">
+          <template #default="{ width, height }">
+            <zm-table
+              :loading="loading"
+              :data="list"
+              :width="width"
+              :height="height"
+              :max-height="height"
+              :row-style="tableRowStyle"
+              :row-class-name="tableRowClassName">
+              <zm-table-column :label="t('monitor.serial')" width="70" align="center">
+                <template #default="scope">
+                  {{ scope.$index + 1 }}
+                </template>
+              </zm-table-column>
+              <zm-table-column label="设备名称" align="center" prop="deviceName" />
+              <zm-table-column label="证书编号" align="center" prop="certNo" />
+              <zm-table-column label="检测日期" align="center" prop="certTime" width="140">
+                <template #default="scope">
+                  <span class="iot-md-date">{{ formatDateCorrectly(scope.row.certTime) }}</span>
+                </template>
+              </zm-table-column>
+              <zm-table-column label="检测单位" align="center" prop="certOrg" />
+              <zm-table-column label="有效期" align="center" prop="certExpire" width="140">
+                <template #default="scope">
+                  <span class="iot-md-date">{{ formatDateCorrectly(scope.row.certExpire) }}</span>
+                </template>
+              </zm-table-column>
+              <zm-table-column label="部门名称" align="center" prop="deptName" />
+              <zm-table-column label="备注" align="center" prop="remark" show-overflow-tooltip />
+              <zm-table-column label="附件" align="center" prop="file" min-width="90">
+                <template #default="scope">
+                  <el-button
+                    v-if="scope.row.file"
+                    link
+                    type="primary"
+                    @click="viewFile(scope.row.file)">
+                    查看
+                  </el-button>
+                </template>
+              </zm-table-column>
+              <zm-table-column label="操作" align="center" width="140" fixed="right" action>
+                <template #default="scope">
+                  <el-button
+                    link
+                    v-hasPermi="['rq:qhse-device-cert:update']"
+                    type="primary"
+                    @click="openForm('update', scope.row.id)">
+                    编辑
+                  </el-button>
+                  <el-button
+                    v-hasPermi="['rq:qhse-device-cert:delete']"
+                    link
+                    type="danger"
+                    @click="handleDelete(scope.row.id)">
+                    删除
+                  </el-button>
+                </template>
+              </zm-table-column>
+            </zm-table>
+          </template>
+        </el-auto-resizer>
+      </div>
+      <div class="h-8 mt-2 flex items-center justify-end">
+        <Pagination
+          :total="total"
+          v-model:page="queryParams.pageNo"
+          v-model:limit="queryParams.pageSize"
+          @pagination="getList" />
+      </div>
+    </div>
+
+    <div class="bg-white dark:bg-[#1d1e1f] shadow rounded-lg p-3 min-w-0 mt-2">
+      <el-alert title="应检设备证书已过期红色预警" type="error" show-icon :closable="false">
+        <template #icon>
+          <Bell />
+        </template>
+      </el-alert>
+      <el-alert
+        title="应检设备证书90天橙色预警"
+        type="warning"
+        show-icon
+        :closable="false"
+        style="margin-top: 5px">
+        <template #icon>
+          <Bell />
+        </template>
+      </el-alert>
+    </div>
+  </div>
+
+  <Dialog v-model="dialogFileView" title="附件" width="500">
+    <div
+      v-for="(file, index) in fileList"
+      :key="index"
+      class="flex items-center justify-between mt-5">
+      <span class="file-name-text">{{ extractFileName(file) }}</span>
+      <div>
+        <el-button link type="primary" @click="viewFileInfo(file)">
+          <Icon icon="ep:view" class="mr-2px" />查看</el-button
+        >
+        <el-button link type="primary" @click="handleDownload(file)">
+          <Icon icon="ep:download" class="mr-2px" />下载</el-button
+        >
+      </div>
+    </div>
+
+    <template #footer>
+      <div class="dialog-footer mt-10">
+        <el-button type="primary" @click="dialogFileView = false"> 确认 </el-button>
+      </div>
+    </template>
+  </Dialog>
+
+  <!-- 表单弹窗:添加/修改 -->
+  <DeviceCertForm ref="formRef" @success="getList" />
+</template>
+
+<script setup lang="ts">
+import download from '@/utils/download'
+import { InspectDeviceCertApi } from '@/api/pms/qhse/index'
+import DeviceCertForm from './DeviceCertForm.vue'
+import { formatDate } from '@/utils/formatTime'
+import DeptTreeSelect from '@/components/DeptTreeSelect/index.vue'
+import { useTableComponents } from '@/components/ZmTable/useTableComponents'
+const { ZmTable, ZmTableColumn } = useTableComponents()
+import { useUserStore } from '@/store/modules/user'
+
+/** 应检设备证书 列表 */
+defineOptions({ name: 'QHSEDeviceCert' })
+// const userStore = useUserStore()
+const rootDeptId = 156
+const deptId = useUserStore().getUser.deptId || rootDeptId
+
+const message = useMessage() // 消息弹窗
+const { t } = useI18n() // 国际化
+
+const loading = ref(true) // 列表的加载中
+const list = ref<any[]>([]) // 列表的数据
+const total = ref(0) // 列表的总页数
+const queryParams = reactive({
+  pageNo: 1,
+  pageSize: 10,
+  deviceId: undefined,
+  deviceName: undefined,
+  certNo: undefined,
+  certOrg: undefined,
+  certTime: [],
+  certExpire: undefined,
+  createTime: [],
+  deptId: undefined
+})
+const queryFormRef = ref() // 搜索的表单
+const exportLoading = ref(false) // 导出的加载中
+
+/** 查询列表 */
+const getList = async () => {
+  loading.value = true
+  try {
+    const data = await InspectDeviceCertApi.getInspectDeviceCertList(queryParams)
+    list.value = data.list
+    total.value = data.total
+  } finally {
+    loading.value = false
+  }
+}
+
+const tableRowStyle = ({ row }) => {
+  if (row.expired) {
+    return { backgroundColor: '#ffe6e6' }
+  }
+  if (row.alertWarn) {
+    return { backgroundColor: '#e19f1a' }
+  }
+  return {}
+}
+
+const tableRowClassName = ({ row }) => {
+  if (row.expired) {
+    return 'expired-row'
+  }
+  if (row.alertWarn) {
+    return 'alert-warn-row'
+  }
+  return ''
+}
+
+/** 搜索按钮操作 */
+const handleQuery = () => {
+  queryParams.pageNo = 1
+  getList()
+}
+
+const selectedDept = ref<{ id: number; name: string }>()
+
+const handleDeptNodeClick = async (row) => {
+  selectedDept.value = { id: row.id, name: row.name }
+  queryParams.deptId = row.id
+  await getList()
+}
+
+const formatDateCorrectly = (timestamp) => {
+  if (!timestamp) return ''
+
+  // 确保处理各种可能的时间戳格式
+  let time = Number(timestamp)
+
+  // 处理不同时间戳格式
+  if (time < 10000000000) {
+    time = time * 1000
+  }
+
+  // 检查是否为有效日期
+  const date = new Date(time)
+  if (isNaN(date.getTime())) {
+    return ''
+  }
+
+  // 验证日期合理性(例如:不能是过于久远的日期)
+  const minValidYear = 1900
+  if (date.getFullYear() < minValidYear) {
+    console.warn('Invalid date detected:', timestamp)
+    return ''
+  }
+
+  return formatDate(time).substring(0, 10)
+}
+/** 重置按钮操作 */
+const resetQuery = () => {
+  queryFormRef.value.resetFields()
+  handleQuery()
+}
+
+/** 添加/修改操作 */
+const formRef = ref()
+const openForm = (type: string, id?: number) => {
+  formRef.value.open(type, id)
+}
+
+/** 删除按钮操作 */
+const handleDelete = async (id: number) => {
+  try {
+    // 删除的二次确认
+    await message.delConfirm()
+    // 发起删除
+    await InspectDeviceCertApi.deleteInspectDeviceCert(id)
+    message.success(t('common.delSuccess'))
+    // 刷新列表
+    await getList()
+  } catch {}
+}
+
+/** 导出按钮操作 */
+const handleExport = async () => {
+  try {
+    // 导出的二次确认
+    await message.exportConfirm()
+    // 发起导出
+    exportLoading.value = true
+    const data = await InspectDeviceCertApi.exportInspectDeviceCert(queryParams)
+    download.excel(data, '应检设备证书.xls')
+  } catch {
+  } finally {
+    exportLoading.value = false
+  }
+}
+
+let dialogFileView = ref(false)
+let fileList = ref([])
+const viewFile = (file) => {
+  fileList.value = file.split(',')
+  dialogFileView.value = true
+  // window.open(file)
+}
+
+const viewFileInfo = (file) => {
+  window.open(
+    'http://doc.deepoil.cc:8012/onlinePreview?url=' + encodeURIComponent(Base64.encode(file))
+  )
+}
+
+const extractFileName = (url: string): string => {
+  try {
+    // 移除查询参数和哈希
+    const cleanUrl = url.split('?')[0].split('#')[0]
+    // 获取最后一个斜杠后的内容
+    const parts = cleanUrl.split('/')
+    const fileName = parts[parts.length - 1]
+    // URL 解码
+    return decodeURIComponent(fileName) || url
+  } catch {
+    // 如果解析失败,返回原始 URL
+    return url
+  }
+}
+
+const handleDownload = async (url) => {
+  try {
+    const response = await fetch(url)
+    const blob = await response.blob()
+    const downloadUrl = window.URL.createObjectURL(blob)
+
+    const link = document.createElement('a')
+    link.href = downloadUrl
+    link.download = url.split('/').pop() // 自动获取文件名‌:ml-citation{ref="3" data="citationList"}
+    link.click()
+
+    URL.revokeObjectURL(downloadUrl)
+  } catch (error) {
+    console.error('下载失败:', error)
+  }
+}
+/** 初始化 **/
+onMounted(() => {
+  getList()
+})
+</script>
+
+<style scoped>
+.file-name-text {
+  flex: 1;
+  overflow: hidden;
+  text-overflow: ellipsis;
+  white-space: nowrap;
+  margin-right: 12px;
+  color: var(--el-text-color-primary);
+}
+
+/* 过期行的红色背景 - 基础状态 */
+:deep(.el-table__body tr.expired-row > td.el-table__cell) {
+  background-color: #ffe6e6 !important;
+}
+
+/* 过期行 - 鼠标悬浮状态 */
+:deep(.el-table__body tr.expired-row:hover > td.el-table__cell) {
+  background-color: #ffcccc !important;
+}
+
+/* 确保斑马纹不影响过期行 */
+:deep(.el-table__body tr.expired-row.el-table__row--striped > td.el-table__cell) {
+  background-color: #ffe6e6 !important;
+}
+
+:deep(.el-table__body tr.expired-row.el-table__row--striped:hover > td.el-table__cell) {
+  background-color: #ffcccc !important;
+}
+
+/* 预警行的橙色背景 - 基础状态 */
+:deep(.el-table__body tr.alert-warn-row > td.el-table__cell) {
+  background-color: #fff1df !important;
+}
+
+/* 预警行 - 鼠标悬浮状态 */
+:deep(.el-table__body tr.alert-warn-row:hover > td.el-table__cell) {
+  background-color: #ffe2bf !important;
+}
+
+/* 确保斑马纹不影响预警行 */
+:deep(.el-table__body tr.alert-warn-row.el-table__row--striped > td.el-table__cell) {
+  background-color: #fff1df !important;
+}
+
+:deep(.el-table__body tr.alert-warn-row.el-table__row--striped:hover > td.el-table__cell) {
+  background-color: #ffe2bf !important;
+}
+</style>

이 변경점에서 너무 많은 파일들이 변경되어 몇몇 파일들은 표시되지 않았습니다.