IotMainWorkOrderAdd.vue 38 KB

1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677787980818283848586878889909192939495969798991001011021031041051061071081091101111121131141151161171181191201211221231241251261271281291301311321331341351361371381391401411421431441451461471481491501511521531541551561571581591601611621631641651661671681691701711721731741751761771781791801811821831841851861871881891901911921931941951961971981992002012022032042052062072082092102112122132142152162172182192202212222232242252262272282292302312322332342352362372382392402412422432442452462472482492502512522532542552562572582592602612622632642652662672682692702712722732742752762772782792802812822832842852862872882892902912922932942952962972982993003013023033043053063073083093103113123133143153163173183193203213223233243253263273283293303313323333343353363373383393403413423433443453463473483493503513523533543553563573583593603613623633643653663673683693703713723733743753763773783793803813823833843853863873883893903913923933943953963973983994004014024034044054064074084094104114124134144154164174184194204214224234244254264274284294304314324334344354364374384394404414424434444454464474484494504514524534544554564574584594604614624634644654664674684694704714724734744754764774784794804814824834844854864874884894904914924934944954964974984995005015025035045055065075085095105115125135145155165175185195205215225235245255265275285295305315325335345355365375385395405415425435445455465475485495505515525535545555565575585595605615625635645655665675685695705715725735745755765775785795805815825835845855865875885895905915925935945955965975985996006016026036046056066076086096106116126136146156166176186196206216226236246256266276286296306316326336346356366376386396406416426436446456466476486496506516526536546556566576586596606616626636646656666676686696706716726736746756766776786796806816826836846856866876886896906916926936946956966976986997007017027037047057067077087097107117127137147157167177187197207217227237247257267277287297307317327337347357367377387397407417427437447457467477487497507517527537547557567577587597607617627637647657667677687697707717727737747757767777787797807817827837847857867877887897907917927937947957967977987998008018028038048058068078088098108118128138148158168178188198208218228238248258268278288298308318328338348358368378388398408418428438448458468478488498508518528538548558568578588598608618628638648658668678688698708718728738748758768778788798808818828838848858868878888898908918928938948958968978988999009019029039049059069079089099109119129139149159169179189199209219229239249259269279289299309319329339349359369379389399409419429439449459469479489499509519529539549559569579589599609619629639649659669679689699709719729739749759769779789799809819829839849859869879889899909919929939949959969979989991000100110021003100410051006100710081009101010111012101310141015101610171018101910201021102210231024102510261027102810291030103110321033103410351036103710381039104010411042104310441045104610471048104910501051105210531054105510561057105810591060106110621063106410651066106710681069107010711072107310741075107610771078107910801081108210831084108510861087108810891090109110921093109410951096109710981099110011011102110311041105110611071108110911101111111211131114111511161117111811191120112111221123112411251126112711281129113011311132113311341135113611371138113911401141114211431144114511461147114811491150115111521153115411551156115711581159116011611162116311641165116611671168116911701171117211731174117511761177117811791180118111821183118411851186118711881189119011911192119311941195119611971198119912001201120212031204120512061207120812091210121112121213121412151216121712181219122012211222122312241225122612271228122912301231123212331234123512361237123812391240124112421243124412451246124712481249125012511252125312541255125612571258125912601261126212631264
  1. <template>
  2. <ContentWrap v-loading="formLoading">
  3. <el-form
  4. ref="formRef"
  5. :model="formData"
  6. :rules="formRules"
  7. v-loading="formLoading"
  8. style="margin-top: 1em; margin-right: 4em; margin-left: 0.5em"
  9. label-width="130px">
  10. <div class="base-expandable-content">
  11. <el-row>
  12. <el-col :span="8">
  13. <el-form-item :label="t('bomList.name')" prop="name">
  14. <!-- <el-input type="text" v-model="formData.name" />-->
  15. <lang-input v-model="formData.name" />
  16. </el-form-item>
  17. </el-col>
  18. <el-col :span="8">
  19. <el-form-item :label="t('mainPlan.MaintenanceMethod')" prop="type">
  20. <el-select
  21. v-model="formData.outsourcingFlag"
  22. :placeholder="t('faultForm.choose')"
  23. clearable>
  24. <el-option
  25. v-for="dict in getIntDictOptions(DICT_TYPE.PMS_ORDER_PROCESS_MODE)"
  26. :key="dict.value"
  27. :label="dict.label"
  28. :value="dict.value" />
  29. </el-select>
  30. </el-form-item>
  31. </el-col>
  32. <el-col :span="8">
  33. <el-form-item :label="t('mainPlan.MaintenanceCost')" prop="cost">
  34. <el-input v-model="formData.cost" placeholder="根据物料消耗自动生成" disabled />
  35. </el-form-item>
  36. </el-col>
  37. <el-col :span="8">
  38. <el-form-item :label="t('fault.start')" prop="actualStartTime">
  39. <el-date-picker
  40. style="width: 150%"
  41. v-model="formData.actualStartTime"
  42. type="datetime"
  43. value-format="x"
  44. :placeholder="t('fault.start')" />
  45. </el-form-item>
  46. </el-col>
  47. <el-col :span="8">
  48. <el-form-item :label="t('fault.end')" prop="actualEndTime">
  49. <el-date-picker
  50. style="width: 150%"
  51. v-model="formData.actualEndTime"
  52. type="datetime"
  53. value-format="x"
  54. :placeholder="t('fault.end')" />
  55. </el-form-item>
  56. </el-col>
  57. <el-col :span="8">
  58. <el-form-item :label="t('mainPlan.otherCost')" prop="otherCost">
  59. <el-input
  60. v-model="formData.otherCost"
  61. @input="handleInput(formData.otherCost, 'otherCost')"
  62. placeholder="其他费用" />
  63. </el-form-item>
  64. </el-col>
  65. <el-col :span="24">
  66. <el-form-item :label="t('faultForm.remark')" prop="remark">
  67. <el-input
  68. v-model="formData.remark"
  69. type="textarea"
  70. :placeholder="t('faultForm.rHolder')" />
  71. </el-form-item>
  72. </el-col>
  73. <el-col :span="8">
  74. <el-form-item label="附件">
  75. <FileUpload
  76. class="work-order-attachment-upload"
  77. :device-id="undefined"
  78. :show-folder-button="false"
  79. @upload-success="handleUploadSuccess" />
  80. <div
  81. v-if="formData.attachments && formData.attachments.length > 0"
  82. class="attachment-container">
  83. <div class="attachment-list">
  84. <div
  85. v-for="(attachment, index) in formData.attachments"
  86. :key="attachment.id || index"
  87. class="attachment-item">
  88. <a class="attachment-name" @click="inContent(attachment)">
  89. {{ attachment.filename }}
  90. </a>
  91. <el-button type="danger" link size="small" @click="removeAttachment(index)">
  92. 删除
  93. </el-button>
  94. </div>
  95. </div>
  96. </div>
  97. <div
  98. v-else-if="!formData.attachments || formData.attachments.length === 0"
  99. class="no-attachment">
  100. 无附件
  101. </div>
  102. </el-form-item>
  103. </el-col>
  104. </el-row>
  105. </div>
  106. </el-form>
  107. </ContentWrap>
  108. <ContentWrap>
  109. <ContentWrap>
  110. <!-- 搜索工作栏 -->
  111. <el-form
  112. class="-mb-15px"
  113. :model="queryParams"
  114. ref="queryFormRef"
  115. :inline="true"
  116. label-width="68px">
  117. <el-form-item>
  118. <el-button @click="openForm" type="warning">
  119. <Icon icon="ep:plus" class="mr-5px" />
  120. {{ t('maintain.added') }}</el-button
  121. >
  122. </el-form-item>
  123. </el-form>
  124. </ContentWrap>
  125. <!-- 列表 -->
  126. <ContentWrap>
  127. <el-table v-loading="loading" :data="list" :stripe="true" :show-overflow-tooltip="true">
  128. <!-- 添加序号列 -->
  129. <el-table-column type="index" :label="t('maintain.serial')" width="70" align="center" />
  130. <el-table-column
  131. :label="t('bomList.bomNode')"
  132. align="center"
  133. prop="bomNodeId"
  134. v-if="false" />
  135. <el-table-column :label="t('iotDevice.code')" align="center" prop="deviceCode" />
  136. <el-table-column :label="t('iotDevice.name')" align="center" prop="deviceName" />
  137. <el-table-column
  138. :label="t('operationFillForm.sumTime')"
  139. align="center"
  140. prop="totalRunTime"
  141. :formatter="erpPriceTableColumnFormatter" />
  142. <el-table-column
  143. :label="t('operationFillForm.sumKil')"
  144. align="center"
  145. prop="totalMileage"
  146. :formatter="erpPriceTableColumnFormatter" />
  147. <el-table-column :label="t('mainPlan.MaintItems')" align="center" prop="name" />
  148. <el-table-column :label="t('iotMaintain.numberOfMaterials')" align="center" width="100">
  149. <template #default="scope">
  150. {{ getMaterialCount(scope.row.bomNodeId) }}
  151. </template>
  152. </el-table-column>
  153. <el-table-column :label="t('iotMaintain.operation')" align="center" min-width="120px">
  154. <template #default="scope">
  155. <div style="display: flex; justify-content: center; align-items: center; width: 100%">
  156. <div>
  157. <Icon style="color: #ea3434; vertical-align: middle" icon="ep:zoom-out" />
  158. <el-button
  159. style="vertical-align: middle"
  160. link
  161. type="danger"
  162. @click="handleDelete(scope.row.deviceId + '-' + scope.row.bomNodeId)">
  163. {{ t('iotMaintain.remove') }}
  164. </el-button>
  165. </div>
  166. <div style="margin-left: 12px">
  167. <el-button link type="primary" @click="openMaterialForm(scope.row)">
  168. {{ t('iotMaintain.selectMaterials') }}
  169. </el-button>
  170. </div>
  171. <div style="margin-left: 12px">
  172. <el-button link type="primary" @click="handleView(scope.row.bomNodeId)">
  173. {{ t('bomList.materialDetail') }}
  174. </el-button>
  175. </div>
  176. </div>
  177. </template>
  178. </el-table-column>
  179. </el-table>
  180. </ContentWrap>
  181. <!-- 选择的物料列表 -->
  182. <ContentWrap>
  183. <el-table
  184. v-loading="false"
  185. :data="materialList"
  186. :stripe="true"
  187. :show-overflow-tooltip="true"
  188. v-if="false">
  189. <el-table-column :label="t('bomList.bomNode')" align="center" prop="bomNodeId" />
  190. <el-table-column
  191. :label="t('workOrderMaterial.materialCode')"
  192. align="center"
  193. prop="materialCode" />
  194. <el-table-column
  195. :label="t('workOrderMaterial.materialName')"
  196. align="center"
  197. prop="materialName" />
  198. <el-table-column :label="t('workOrderMaterial.unit')" align="center" prop="unit" />
  199. <el-table-column
  200. :label="t('workOrderMaterial.unitPrice')"
  201. align="center"
  202. prop="unitPrice"
  203. :formatter="erpPriceTableColumnFormatter" />
  204. <el-table-column
  205. :label="t('workOrderMaterial.ConsumptionQuantity')"
  206. align="center"
  207. prop="quantity" />
  208. <el-table-column
  209. :label="t('workOrderMaterial.total')"
  210. align="center"
  211. prop="totalInventoryQuantity" />
  212. </el-table>
  213. </ContentWrap>
  214. </ContentWrap>
  215. <ContentWrap>
  216. <el-form>
  217. <el-form-item style="float: right">
  218. <el-button @click="submitForm" type="primary" :disabled="formLoading">{{
  219. t('common.save')
  220. }}</el-button>
  221. <el-button @click="close">{{ t('common.cancel') }}</el-button>
  222. </el-form-item>
  223. </el-form>
  224. </ContentWrap>
  225. <MainPlanDeviceList ref="deviceFormRef" @choose="deviceChoose" />
  226. <!-- 新增配置对话框 -->
  227. <el-dialog
  228. v-model="configDialog.visible"
  229. :title="`设备 ${configDialog.current?.deviceCode + '-' + configDialog.current?.name} 保养配置`"
  230. width="600px">
  231. <!-- 使用header插槽自定义标题 -->
  232. <template #header>
  233. <span
  234. >设备
  235. <strong>{{ configDialog.current?.deviceCode }}-{{ configDialog.current?.name }}</strong>
  236. 保养项配置</span
  237. >
  238. </template>
  239. <el-form
  240. :model="configDialog.form"
  241. label-width="200px"
  242. :rules="configFormRules"
  243. ref="configFormRef">
  244. <!-- 里程配置 -->
  245. <el-form-item
  246. v-if="configDialog.current?.mileageRule === 0"
  247. label="上次保养里程数(KM)"
  248. prop="lastRunningKilometers">
  249. <el-input-number
  250. v-model="configDialog.form.lastRunningKilometers"
  251. :precision="2"
  252. :min="0"
  253. controls-position="right"
  254. :disabled="true" />
  255. </el-form-item>
  256. <!-- 推迟公里数 -->
  257. <el-form-item
  258. v-if="configDialog.current?.mileageRule === 0"
  259. label="推迟公里数(KM)"
  260. prop="delayKilometers">
  261. <el-input-number
  262. v-model="configDialog.form.delayKilometers"
  263. :precision="2"
  264. :min="0"
  265. controls-position="right" />
  266. </el-form-item>
  267. <!-- 运行时间配置 -->
  268. <el-form-item
  269. v-if="configDialog.current?.runningTimeRule === 0"
  270. label="上次保养运行时间(H)"
  271. prop="lastRunningTime">
  272. <el-input-number
  273. v-model="configDialog.form.lastRunningTime"
  274. :precision="1"
  275. :min="0"
  276. controls-position="right"
  277. :disabled="true" />
  278. </el-form-item>
  279. <!-- 推迟时长 -->
  280. <el-form-item
  281. v-if="configDialog.current?.runningTimeRule === 0"
  282. label="推迟时长(H)"
  283. prop="delayDuration">
  284. <el-input-number
  285. v-model="configDialog.form.delayDuration"
  286. :precision="2"
  287. :min="0"
  288. controls-position="right" />
  289. </el-form-item>
  290. <!-- 自然日期配置 -->
  291. <el-form-item
  292. v-if="configDialog.current?.naturalDateRule === 0"
  293. label="上次保养自然日期(D)"
  294. prop="lastNaturalDate">
  295. <el-date-picker
  296. v-model="configDialog.form.lastNaturalDate"
  297. type="date"
  298. placeholder="选择日期"
  299. format="YYYY-MM-DD"
  300. value-format="YYYY-MM-DD"
  301. :disabled="true" />
  302. </el-form-item>
  303. <!-- 推迟自然日期 -->
  304. <el-form-item
  305. v-if="configDialog.current?.naturalDateRule === 0"
  306. label="推迟自然日期(D)"
  307. prop="delayNaturalDate">
  308. <el-input-number
  309. v-model="configDialog.form.delayNaturalDate"
  310. :precision="2"
  311. :min="0"
  312. controls-position="right" />
  313. </el-form-item>
  314. <!-- 保养规则周期值 + 提前量 -->
  315. <el-form-item
  316. v-if="configDialog.current?.mileageRule === 0"
  317. label="运行里程周期(KM)"
  318. prop="nextRunningKilometers">
  319. <el-input-number
  320. v-model="configDialog.form.nextRunningKilometers"
  321. :precision="2"
  322. :min="0"
  323. controls-position="right"
  324. :disabled="true" />
  325. </el-form-item>
  326. <el-form-item
  327. v-if="configDialog.current?.mileageRule === 0"
  328. label="运行里程周期-提前量(KM)"
  329. prop="kiloCycleLead">
  330. <el-input-number
  331. v-model="configDialog.form.kiloCycleLead"
  332. :precision="2"
  333. :min="0"
  334. controls-position="right"
  335. :disabled="true" />
  336. </el-form-item>
  337. <el-form-item
  338. v-if="configDialog.current?.runningTimeRule === 0"
  339. label="运行时间周期(H)"
  340. prop="nextRunningTime">
  341. <el-input-number
  342. v-model="configDialog.form.nextRunningTime"
  343. :precision="1"
  344. :min="0"
  345. controls-position="right"
  346. :disabled="true" />
  347. </el-form-item>
  348. <el-form-item
  349. v-if="configDialog.current?.runningTimeRule === 0"
  350. label="运行时间周期-提前量(H)"
  351. prop="timePeriodLead">
  352. <el-input-number
  353. v-model="configDialog.form.timePeriodLead"
  354. :precision="1"
  355. :min="0"
  356. controls-position="right"
  357. :disabled="true" />
  358. </el-form-item>
  359. <el-form-item
  360. v-if="configDialog.current?.naturalDateRule === 0"
  361. label="自然日周期(D)"
  362. prop="nextNaturalDate">
  363. <el-input-number
  364. v-model="configDialog.form.nextNaturalDate"
  365. :min="0"
  366. controls-position="right"
  367. :disabled="true" />
  368. </el-form-item>
  369. <el-form-item
  370. v-if="configDialog.current?.naturalDateRule === 0"
  371. label="自然日周期-提前量(D)"
  372. prop="naturalDatePeriodLead">
  373. <el-input-number
  374. v-model="configDialog.form.naturalDatePeriodLead"
  375. :min="0"
  376. controls-position="right"
  377. :disabled="true" />
  378. </el-form-item>
  379. </el-form>
  380. <template #footer>
  381. <el-button @click="configDialog.visible = false">取消</el-button>
  382. <el-button type="primary" @click="saveConfig">保存</el-button>
  383. </template>
  384. </el-dialog>
  385. <!-- 表单弹窗:添加/修改 -->
  386. <WorkOrderMaterial ref="materialFormRef" @choose="selectChoose" />
  387. <!-- 抽屉组件 展示已经选择的物料 并编辑物料消耗 -->
  388. <MaterialListDrawer
  389. :model-value="drawerVisible"
  390. @update:model-value="(val) => (drawerVisible = val)"
  391. :node-id="currentBomNodeId"
  392. :materials="materialList.filter((item) => item.bomNodeId === currentBomNodeId)"
  393. @delete="handleMaterialDelete" />
  394. </template>
  395. <script setup lang="ts">
  396. import { IotDeviceApi } from '@/api/pms/device'
  397. import * as UserApi from '@/api/system/user'
  398. import { useUserStore } from '@/store/modules/user'
  399. import { ref } from 'vue'
  400. import { IotMaintenanceBomVO } from '@/api/pms/iotmaintenancebom'
  401. import { IotMainWorkOrderBomVO } from '@/api/pms/iotmainworkorderbom'
  402. import { IotMainWorkOrderBomMaterialVO } from '@/api/pms/iotmainworkorderbommaterial'
  403. import { IotMainWorkOrderApi } from '@/api/pms/iotmainworkorder'
  404. import { useTagsViewStore } from '@/store/modules/tagsView'
  405. import { CACHE_KEY, useCache } from '@/hooks/web/useCache'
  406. import MainPlanDeviceList from '@/views/pms/maintenance/maintenance-device-list.vue'
  407. import * as DeptApi from '@/api/system/dept'
  408. import { erpPriceTableColumnFormatter } from '@/utils'
  409. import dayjs from 'dayjs'
  410. import MaterialListDrawer from '@/views/pms/iotmainworkorder/SelectedMaterialDrawer.vue'
  411. import WorkOrderMaterial from '@/views/pms/iotmainworkorder/WorkOrderMaterial.vue'
  412. import { IotDevicePersonApi } from '@/api/pms/iotdeviceperson'
  413. import { DICT_TYPE, getIntDictOptions } from '@/utils/dict'
  414. import FileUpload from '@/components/UploadFile/src/FileUpload.vue'
  415. import { Base64 } from 'js-base64'
  416. /** 保养计划 表单 */
  417. defineOptions({ name: 'IotMainWorkOrderAdd' })
  418. const { t } = useI18n() // 国际化
  419. const message = useMessage() // 消息弹窗
  420. const { delView } = useTagsViewStore() // 视图操作
  421. const { currentRoute, push } = useRouter()
  422. const deptUsers = ref<UserApi.UserVO[]>([]) // 用户列表
  423. const dept = ref() // 当前登录人所属部门对象
  424. const configFormRef = ref() // 配置弹出框对象
  425. const bomNodeId = ref() // 最新的bomNodeId
  426. const dialogTitle = ref('') // 弹窗的标题
  427. const formLoading = ref(false) // 表单的加载中:1)修改时的数据加载;2)提交的按钮禁用
  428. const formType = ref('') // 表单的类型:create - 新增;update - 修改
  429. const deviceLabel = ref('') // 表单的类型:create - 新增;update - 修改
  430. const drawerVisible = ref<boolean>(false)
  431. const currentBomNodeId = ref() // 当前选中的bom节点
  432. const showDrawer = ref()
  433. const list = ref<IotMainWorkOrderBomVO[]>([]) // 保养工单bom关联列表的数据
  434. const materialList = ref<IotMainWorkOrderBomMaterialVO[]>([]) // 保养工单bom关联物料列表
  435. const deviceIds = ref<number[]>([]) // 已经选择的设备id数组
  436. const { params, name } = useRoute() // 查询参数
  437. const id = params.id
  438. const devicePersonsMap = ref<Map<number, Set<string>>>(new Map()) // 存储设备-责任人映射
  439. const formData = ref({
  440. id: undefined,
  441. deptId: undefined,
  442. name: '',
  443. orderNumber: undefined,
  444. responsiblePerson: undefined,
  445. actualStartTime: undefined,
  446. actualEndTime: undefined,
  447. cost: undefined,
  448. otherCost: undefined,
  449. outsourcingFlag: 0,
  450. remark: undefined,
  451. status: undefined,
  452. devicePersons: '',
  453. attachments: [] as any[]
  454. })
  455. const getFileType = (filename: string) => {
  456. const ext = filename.split('.').pop()?.toLowerCase()
  457. if (['jpg', 'jpeg', 'png', 'gif', 'bmp'].includes(ext || '')) {
  458. return 'image'
  459. } else if (['pdf'].includes(ext || '')) {
  460. return 'pdf'
  461. } else if (['doc', 'docx'].includes(ext || '')) {
  462. return 'word'
  463. } else if (['xls', 'xlsx'].includes(ext || '')) {
  464. return 'excel'
  465. }
  466. return 'other'
  467. }
  468. const formatFileSize = (bytes: number) => {
  469. if (bytes === 0) return '0 Bytes'
  470. const k = 1024
  471. const sizes = ['Bytes', 'KB', 'MB', 'GB']
  472. const i = Math.floor(Math.log(bytes) / Math.log(k))
  473. return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i]
  474. }
  475. const handleUploadSuccess = (result: any) => {
  476. try {
  477. if (!result.response) {
  478. message.error('上传响应数据异常')
  479. return
  480. }
  481. if (result.response.code !== 0) {
  482. message.error(result.response.msg || '文件上传失败')
  483. return
  484. }
  485. const responseData = result.response.data
  486. if (!responseData) {
  487. message.error('上传数据为空')
  488. return
  489. }
  490. if (responseData.files && Array.isArray(responseData.files) && responseData.files.length > 0) {
  491. responseData.files.forEach((file: any) => {
  492. if (!file.filePath) {
  493. return
  494. }
  495. const attachment = {
  496. id: undefined,
  497. category: 'main_work_order',
  498. bizId: formData.value.id,
  499. type: 'attachment',
  500. filename: file.name || '未知文件',
  501. fileType: getFileType(file.name),
  502. filePath: file.filePath,
  503. fileSize: formatFileSize(file.size || 0),
  504. remark: ''
  505. }
  506. if (!formData.value.attachments) {
  507. formData.value.attachments = []
  508. }
  509. formData.value.attachments.push(attachment)
  510. })
  511. message.success(`成功上传 ${responseData.files.length} 个文件`)
  512. } else {
  513. message.warning('上传成功但未获取到文件信息')
  514. }
  515. } catch (error) {
  516. message.error('处理上传结果失败')
  517. }
  518. }
  519. const removeAttachment = (index: number) => {
  520. if (formData.value.attachments && formData.value.attachments.length > index) {
  521. formData.value.attachments.splice(index, 1)
  522. }
  523. }
  524. const inContent = async (attachment: any) => {
  525. if (!attachment || !attachment.filePath) {
  526. message.error('附件路径不存在')
  527. return
  528. }
  529. try {
  530. const encodedPath = encodeURIComponent(Base64.encode(attachment.filePath))
  531. window.open(`http://doc.deepoil.cc:8012/onlinePreview?url=${encodedPath}`)
  532. } catch (error) {
  533. message.error('预览附件失败')
  534. }
  535. }
  536. /** 获取当前所有设备ID集合 */
  537. const getCurrentDeviceIds = (): number[] => {
  538. return [...new Set(list.value.map((item) => item.deviceId))]
  539. }
  540. const formRules = reactive({
  541. name: [{ required: true, message: '工单名称不能为空', trigger: 'blur' }],
  542. actualStartTime: [
  543. {
  544. required: true,
  545. message: t('fault.start') + '不能为空',
  546. trigger: 'change'
  547. },
  548. {
  549. validator: (rule, value, callback) => {
  550. const now = dayjs()
  551. const start = value ? dayjs(Number(value)) : null
  552. // 验证开始时间 <= 当前日期
  553. if (start && start.isAfter(now)) {
  554. callback(new Error(t('fault.start') + '不能超过当前日期'))
  555. return
  556. }
  557. const end = formData.value.actualEndTime
  558. ? dayjs(Number(formData.value.actualEndTime))
  559. : null
  560. // 只有当结束时间有效时,才比较开始和结束时间
  561. if (end && end.isValid() && !end.isAfter(now)) {
  562. if (start && start.isAfter(end)) {
  563. callback(new Error(t('fault.start') + '不能超过' + t('fault.end')))
  564. return
  565. }
  566. }
  567. // 触发结束时间的重新校验
  568. if (formRef.value) {
  569. formRef.value.validateField('actualEndTime', () => {})
  570. }
  571. callback()
  572. },
  573. trigger: 'change'
  574. }
  575. ],
  576. actualEndTime: [
  577. {
  578. required: true,
  579. message: t('fault.end') + '不能为空',
  580. trigger: 'change'
  581. },
  582. {
  583. validator: (rule, value, callback) => {
  584. const now = dayjs()
  585. const end = value ? dayjs(Number(value)) : null
  586. // 验证结束时间 <= 当前日期
  587. if (end && end.isAfter(now)) {
  588. callback(new Error(t('fault.end') + '不能超过当前日期'))
  589. return
  590. }
  591. const start = formData.value.actualStartTime
  592. ? dayjs(Number(formData.value.actualStartTime))
  593. : null
  594. // 验证结束时间 >= 开始时间(仅当开始时间存在时)
  595. if (start && end && end.isBefore(start)) {
  596. callback(new Error(t('fault.end') + '必须大于等于' + t('fault.start')))
  597. return
  598. }
  599. callback()
  600. },
  601. trigger: 'change'
  602. }
  603. ]
  604. })
  605. const formRef = ref() // 表单 Ref
  606. interface MaterialFormExpose {
  607. open: (deptId: number, bomNodeId: number, row: any, type: string) => void
  608. }
  609. const materialFormRef = ref<MaterialFormExpose>()
  610. // 新增配置相关状态
  611. const configDialog = reactive({
  612. visible: false,
  613. current: null as IotMaintenanceBomVO | null,
  614. form: {
  615. lastRunningKilometers: 0,
  616. delayKilometers: 0,
  617. lastRunningTime: 0,
  618. delayDuration: 0,
  619. lastNaturalDate: '',
  620. delayNaturalDate: 0,
  621. // 保养规则 周期
  622. nextRunningKilometers: 0,
  623. nextRunningTime: 0,
  624. nextNaturalDate: 0,
  625. // 提前量
  626. kiloCycleLead: 0,
  627. timePeriodLead: 0,
  628. naturalDatePeriodLead: 0
  629. }
  630. })
  631. // 监听物料列表变化
  632. watch(
  633. () => materialList.value,
  634. () => {
  635. calculateTotalCost()
  636. },
  637. { deep: true }
  638. )
  639. // 计算保养金额
  640. const calculateTotalCost = () => {
  641. // 物料总金额 = ∑(单价 * 消耗数量)
  642. const materialTotal = materialList.value.reduce((sum, item) => {
  643. const price = Number(item.unitPrice) || 0
  644. const quantity = Number(item.quantity) || 0
  645. return sum + price * quantity
  646. }, 0)
  647. // 保养 = 物料总金额
  648. formData.value.cost = materialTotal.toFixed(2)
  649. }
  650. // 打开配置对话框
  651. const openConfigDialog = (row: IotMainWorkOrderBomVO) => {
  652. configDialog.current = row
  653. // 处理日期初始化(核心修改)
  654. let initialDate = ''
  655. if (row.lastNaturalDate) {
  656. // 如果已有值:时间戳 -> 日期字符串
  657. initialDate = dayjs(row.lastNaturalDate).format('YYYY-MM-DD')
  658. } else {
  659. // 如果无值:设置默认值避免1970问题
  660. initialDate = ''
  661. }
  662. configDialog.form = {
  663. lastRunningKilometers: row.lastRunningKilometers || 0,
  664. delayKilometers: row.delayKilometers || 0,
  665. lastRunningTime: row.lastRunningTime || 0,
  666. delayDuration: row.delayDuration || 0,
  667. lastNaturalDate: initialDate,
  668. delayNaturalDate: row.delayNaturalDate || 0,
  669. // 保养规则 周期值
  670. nextRunningKilometers: row.nextRunningKilometers || 0,
  671. nextRunningTime: row.nextRunningTime || 0,
  672. nextNaturalDate: row.nextNaturalDate || 0,
  673. // 提前量
  674. kiloCycleLead: row.kiloCycleLead || 0,
  675. timePeriodLead: row.timePeriodLead || 0,
  676. naturalDatePeriodLead: row.naturalDatePeriodLead || 0
  677. }
  678. configDialog.visible = true
  679. }
  680. // const materialFormRef = ref()
  681. const openMaterialForm = (row: any) => {
  682. bomNodeId.value = row.bomNodeId
  683. console.log('这是一个对象:', row.bomNodeId)
  684. const type = 'maintenance'
  685. materialFormRef.value.open(formData.value.deptId, bomNodeId.value, row, type)
  686. }
  687. // 获取指定bomNodeId的物料数量
  688. const getMaterialCount = (bomNodeId: number) => {
  689. return materialList.value.filter((item) => item.bomNodeId === bomNodeId).length
  690. }
  691. const selectChoose = (selectedMaterial) => {
  692. selectedMaterial.bomNodeId = bomNodeId.value
  693. // 关联 bomNodeId
  694. const processedMaterials = selectedMaterial.map((material) => ({
  695. ...material,
  696. bomNodeId: bomNodeId.value // 统一关联当前行的 bomNodeId
  697. }))
  698. // 避免重复添加
  699. processedMaterials.forEach((newMaterial) => {
  700. // 检查是否已存在相同 bomNodeId + materialCode 的条目
  701. const isExist = materialList.value.some(
  702. (item) => item.bomNodeId === bomNodeId.value && item.materialCode === newMaterial.materialCode
  703. )
  704. if (!isExist) {
  705. materialList.value.push(newMaterial)
  706. }
  707. })
  708. }
  709. // 新增方法:动态更新工单名称
  710. function updateWorkOrderName() {
  711. if (list.value.length > 0) {
  712. const firstDevice = list.value[0]
  713. const currentDate = dayjs().format('YYYY-MM-DD')
  714. formData.value.name = `${firstDevice.deviceCode} - ${firstDevice.deviceName} - ${currentDate} 保养工单`
  715. } else {
  716. formData.value.name = `${dept.value.name} - 保养工单`
  717. }
  718. }
  719. const deviceChoose = async (selectedDevices) => {
  720. const newIds = selectedDevices.map((device) => device.id)
  721. deviceIds.value = [...new Set([...deviceIds.value, ...newIds])]
  722. const params = {
  723. deviceIds: deviceIds.value.join(',') // 明确传递数组参数
  724. }
  725. queryParams.deviceIds = JSON.parse(JSON.stringify(params.deviceIds))
  726. queryParams.bomFlag = 'b'
  727. // 根据选择的设备筛选出设备关系的分类BOM中与保养相关的节点项
  728. const res = await IotDeviceApi.deviceAssociateBomList(queryParams)
  729. const rawData = res || []
  730. if (rawData.length === 0) {
  731. message.error('选择的设备不存在待保养BOM项')
  732. }
  733. if (!Array.isArray(rawData)) {
  734. console.error('接口返回数据结构异常:', rawData)
  735. return
  736. }
  737. // 转换数据结构(根据你的接口定义调整)
  738. const newItems = rawData.map((device) => ({
  739. assetClass: device.assetClass,
  740. deviceCode: device.deviceCode,
  741. deviceName: device.deviceName,
  742. deviceStatus: device.deviceStatus,
  743. deptName: device.deptName,
  744. name: device.name,
  745. code: device.code,
  746. assetProperty: device.assetProperty,
  747. remark: null, // 初始化备注
  748. deviceId: device.id, // 移除操作需要
  749. bomNodeId: device.bomNodeId,
  750. totalRunTime: device.totalRunTime,
  751. totalMileage: device.totalMileage,
  752. nextRunningKilometers: 0,
  753. nextRunningTime: 0,
  754. nextNaturalDate: 0,
  755. lastNaturalDate: null, // 初始化为null而不是0
  756. // 保养规则 提前量
  757. kiloCycleLead: 0,
  758. timePeriodLead: 0,
  759. naturalDatePeriodLead: 0
  760. }))
  761. // 获取选择的设备相关的id数组
  762. newItems.forEach((item) => {
  763. deviceIds.value.push(item.deviceId)
  764. })
  765. // 合并到现有列表(去重)
  766. newItems.forEach((item) => {
  767. const exists = list.value.some(
  768. (existing) => existing.deviceId === item.deviceId && existing.bomNodeId === item.bomNodeId
  769. )
  770. if (!exists) {
  771. list.value.push(item)
  772. }
  773. })
  774. // 更新工单名称
  775. updateWorkOrderName()
  776. }
  777. /** 查看已经选择的物料 并编辑 */
  778. const handleView = (nodeId) => {
  779. currentBomNodeId.value = nodeId
  780. drawerVisible.value = true
  781. // showDrawer.value.openDrawer()
  782. console.log('当前bom节点:', currentBomNodeId.value)
  783. }
  784. const deviceFormRef = ref<InstanceType<typeof MainPlanDeviceList>>()
  785. const openForm = () => {
  786. deviceFormRef.value?.open()
  787. }
  788. const hasMaterial = (bomNodeId: number) => {
  789. return materialList.value.some((item) => item.bomNodeId === bomNodeId)
  790. }
  791. // 保存配置
  792. const saveConfig = () => {
  793. ;(configFormRef.value as any).validate((valid: boolean) => {
  794. if (!valid) return
  795. if (!configDialog.current) return
  796. // 动态校验逻辑
  797. const requiredFields = []
  798. if (configDialog.current.mileageRule === 0) {
  799. requiredFields.push('nextRunningKilometers', 'kiloCycleLead')
  800. }
  801. if (configDialog.current.runningTimeRule === 0) {
  802. requiredFields.push('nextRunningTime', 'timePeriodLead')
  803. }
  804. if (configDialog.current.naturalDateRule === 0) {
  805. requiredFields.push('nextNaturalDate', 'naturalDatePeriodLead')
  806. }
  807. const missingFields = requiredFields.filter(
  808. (field) => !configDialog.form[field as keyof typeof configDialog.form]
  809. )
  810. if (missingFields.length > 0) {
  811. message.error('请填写所有必填项')
  812. return
  813. }
  814. // 强制校验逻辑
  815. if (configDialog.current.naturalDateRule === 0) {
  816. if (!configDialog.form.lastNaturalDate) {
  817. message.error('必须选择 上次保养自然日期')
  818. return
  819. }
  820. // 验证日期有效性
  821. const dateValue = dayjs(configDialog.form.lastNaturalDate)
  822. if (!dateValue.isValid()) {
  823. message.error('日期格式不正确')
  824. return
  825. }
  826. }
  827. // 转换逻辑(关键修改)
  828. const finalDate = configDialog.form.lastNaturalDate
  829. ? dayjs(configDialog.form.lastNaturalDate).valueOf()
  830. : null // 改为null而不是0
  831. // 更新当前行的数据
  832. Object.assign(configDialog.current, {
  833. ...configDialog.form,
  834. lastNaturalDate: finalDate
  835. })
  836. configDialog.visible = false
  837. })
  838. }
  839. const handleInput = (value, obj) => {
  840. // 1. 过滤非法字符(只允许数字和小数点)
  841. let filtered = value.replace(/[^\d.]/g, '')
  842. // 2. 处理多个小数点的情况
  843. filtered = filtered.replace(/\.{2,}/g, '.')
  844. // 3. 限制小数点后最多两位
  845. let decimalParts = filtered.split('.')
  846. if (decimalParts.length > 1) {
  847. decimalParts = decimalParts.slice(0, 2)
  848. filtered = decimalParts.join('.')
  849. }
  850. // 4. 处理以小数点开头的情况(自动补0)
  851. if (filtered.startsWith('.')) {
  852. filtered = '0' + filtered
  853. }
  854. // 5. 更新绑定值(同时处理连续输入多个0的情况)
  855. formData.value[obj] = filtered.replace(/^0+(?=\d)/, '')
  856. }
  857. const queryParams = reactive({
  858. deviceIds: undefined,
  859. workOrderId: id,
  860. bomFlag: 'b'
  861. })
  862. const close = () => {
  863. delView(unref(currentRoute))
  864. push({ name: 'IotMainWorkOrder', params: {} })
  865. }
  866. // 添加处理物料删除的方法
  867. const handleMaterialDelete = (row: any) => {
  868. // 找到要删除的物料索引
  869. const index = materialList.value.findIndex(
  870. (item) => item.bomNodeId === row.bomNodeId && item.materialCode === row.materialCode
  871. )
  872. if (index !== -1) {
  873. // 从物料列表中删除
  874. materialList.value.splice(index, 1)
  875. message.success('物料删除成功')
  876. // 重新计算总成本
  877. calculateTotalCost()
  878. }
  879. }
  880. /** 更新责任人显示 */
  881. function updateDevicePersonsDisplay() {
  882. const allNames = new Set<string>()
  883. devicePersonsMap.value.forEach((names) => {
  884. names.forEach((name) => allNames.add(name))
  885. })
  886. formData.value.devicePersons = Array.from(allNames).join(', ')
  887. }
  888. /**
  889. * 根据选择的设备查询所有设备关联的 责任人姓名 逗号分隔
  890. */
  891. async function getDevicePersons() {
  892. // 获取当前已经选择的设备ID集合
  893. const existDeviceIds = getCurrentDeviceIds()
  894. if (existDeviceIds.length === 0) {
  895. formData.value.devicePersons = ''
  896. return
  897. }
  898. try {
  899. // 调用接口获取数据
  900. const params = {
  901. deviceIds: existDeviceIds.join(',') // 明确传递数组参数
  902. }
  903. const res = await IotDevicePersonApi.getPersonsByDeviceIds(params)
  904. const personsData = res || []
  905. // 清空旧数据
  906. devicePersonsMap.value.clear()
  907. // 处理接口数据
  908. personsData.forEach((item: { deviceId: number; personName: string }) => {
  909. if (!devicePersonsMap.value.has(item.deviceId)) {
  910. devicePersonsMap.value.set(item.deviceId, new Set())
  911. }
  912. devicePersonsMap.value.get(item.deviceId)?.add(item.personName)
  913. })
  914. // 生成展示字符串
  915. updateDevicePersonsDisplay()
  916. } catch (error) {
  917. console.error('获取设备责任人失败:', error)
  918. }
  919. }
  920. /** 提交表单 */
  921. const emit = defineEmits(['success']) // 定义 success 事件,用于操作成功后的回调
  922. const submitForm = async () => {
  923. // 校验表单
  924. await formRef.value.validate()
  925. // 校验表格数据
  926. const isValid = validateTableData()
  927. if (!isValid) return
  928. // 新增:校验物料选择
  929. const hasMaterial = validateMaterialSelection()
  930. if (!hasMaterial) return
  931. // 提交请求
  932. formLoading.value = true
  933. try {
  934. const data = {
  935. mainWorkOrder: formData.value,
  936. mainWorkOrderBom: list.value,
  937. mainWorkOrderMaterials: materialList.value
  938. }
  939. await IotMainWorkOrderApi.addWorkOrder(data)
  940. message.success(t('common.createSuccess'))
  941. close()
  942. // 发送操作成功的事件
  943. emit('success')
  944. } finally {
  945. formLoading.value = false
  946. }
  947. }
  948. // 新增表单校验规则
  949. const configFormRules = reactive({
  950. nextRunningKilometers: [
  951. {
  952. required: true,
  953. message: '里程周期必须填写',
  954. trigger: 'blur'
  955. }
  956. ],
  957. kiloCycleLead: [
  958. {
  959. required: true,
  960. message: '提前量必须填写',
  961. trigger: 'blur'
  962. }
  963. ],
  964. nextRunningTime: [
  965. {
  966. required: true,
  967. message: '时间周期必须填写',
  968. trigger: 'blur'
  969. }
  970. ],
  971. timePeriodLead: [
  972. {
  973. required: true,
  974. message: '提前量必须填写',
  975. trigger: 'blur'
  976. }
  977. ],
  978. nextNaturalDate: [
  979. {
  980. required: true,
  981. message: '自然日周期必须填写',
  982. trigger: 'blur'
  983. }
  984. ],
  985. naturalDatePeriodLead: [
  986. {
  987. required: true,
  988. message: '提前量必须填写',
  989. trigger: 'blur'
  990. }
  991. ]
  992. })
  993. /** 校验表格数据 */
  994. const validateTableData = (): boolean => {
  995. let isValid = true
  996. const errorMessages: string[] = []
  997. if (list.value.length === 0) {
  998. errorMessages.push('请至少添加一条设备保养明细')
  999. isValid = false
  1000. // 直接返回无需后续校验
  1001. message.error('请至少添加一条设备保养明细')
  1002. return isValid
  1003. }
  1004. return isValid
  1005. }
  1006. /** 检查每个保养项是否都有物料 */
  1007. const validateMaterialSelection = (): boolean => {
  1008. let isValid = true
  1009. const errorMessages: string[] = []
  1010. // 遍历所有保养项
  1011. list.value.forEach((item) => {
  1012. const hasMaterial = materialList.value.some((material) => material.bomNodeId === item.bomNodeId)
  1013. if (!hasMaterial) {
  1014. isValid = false
  1015. errorMessages.push(
  1016. `设备 ${item.deviceCode}-${item.deviceName} 的保养项【${item.name}】未添加物料`
  1017. )
  1018. }
  1019. })
  1020. // 显示错误信息
  1021. if (!isValid) {
  1022. let displayMessage
  1023. if (errorMessages.length > 3) {
  1024. // 超过3条错误时显示前3条+总结
  1025. const firstThree = errorMessages.slice(0, 3)
  1026. displayMessage =
  1027. firstThree.join('<br/>') + `<br/>...等共 ${errorMessages.length} 个保养项未添加物料`
  1028. } else {
  1029. // 3条及以内直接显示全部
  1030. displayMessage = errorMessages.join('<br/>')
  1031. }
  1032. message.error({
  1033. message: displayMessage,
  1034. dangerouslyUseHTMLString: true
  1035. })
  1036. }
  1037. return isValid
  1038. }
  1039. /** 重置表单 */
  1040. const resetForm = () => {
  1041. formData.value = {
  1042. id: undefined,
  1043. status: undefined,
  1044. description: undefined,
  1045. pic: undefined,
  1046. remark: undefined,
  1047. deviceName: undefined,
  1048. processInstanceId: undefined,
  1049. auditStatus: undefined,
  1050. deptId: undefined,
  1051. attachments: []
  1052. }
  1053. formRef.value?.resetFields()
  1054. }
  1055. onMounted(async () => {
  1056. materialList.value = []
  1057. const deptId = useUserStore().getUser.deptId
  1058. // 查询当前登录人所属部门名称
  1059. dept.value = await DeptApi.getDept(deptId)
  1060. // formData.value.name = dept.value.name + ' - 保养工单'
  1061. deptUsers.value = await UserApi.getDeptUsersByDeptId(deptId)
  1062. formData.value.deptId = deptId
  1063. try {
  1064. formType.value = 'create'
  1065. const { wsCache } = useCache()
  1066. const userInfo = wsCache.get(CACHE_KEY.USER)
  1067. // 手工新增保养工单 责任人为当前登录人
  1068. formData.value.responsiblePerson = userInfo.user.id
  1069. // 初始化工单名称
  1070. updateWorkOrderName()
  1071. } catch (error) {
  1072. console.error('数据加载失败:', error)
  1073. message.error('数据加载失败,请重试')
  1074. }
  1075. })
  1076. const handleDelete = async (str: string) => {
  1077. try {
  1078. const [deviceIdStr, bomNodeId] = str.split('-')
  1079. const deviceId = parseInt(deviceIdStr)
  1080. // 删除列表项
  1081. const index = list.value.findIndex((item) => item.deviceId + '-' + item.bomNodeId === str)
  1082. if (index !== -1) {
  1083. list.value.splice(index, 1)
  1084. deviceIds.value = []
  1085. }
  1086. // 更新设备ID列表(需要检查是否还有该设备的其他项)
  1087. const hasOtherItems = list.value.some((item) => item.deviceId === deviceId)
  1088. if (!hasOtherItems) {
  1089. deviceIds.value = deviceIds.value.filter((id) => id !== deviceId)
  1090. devicePersonsMap.value.delete(deviceId) // 移除对应设备的责任人
  1091. updateDevicePersonsDisplay() // 立即更新显示
  1092. }
  1093. // 更新工单名称
  1094. updateWorkOrderName()
  1095. } catch (error) {
  1096. console.error('移除失败:', error)
  1097. message.error('移除失败')
  1098. }
  1099. }
  1100. </script>
  1101. <style scoped>
  1102. .base-expandable-content {
  1103. overflow: hidden; /* 隐藏溢出的内容 */
  1104. transition: max-height 0.3s ease; /* 平滑过渡效果 */
  1105. }
  1106. :deep(.el-input-number .el-input__inner) {
  1107. padding-left: 10px; /* 保持左侧间距 */
  1108. text-align: left !important;
  1109. }
  1110. /* 分组容器样式 */
  1111. .form-group {
  1112. position: relative;
  1113. padding: 20px 15px 10px;
  1114. margin-bottom: 18px;
  1115. border: 1px solid #dcdfe6;
  1116. border-radius: 4px;
  1117. transition: border-color 0.2s;
  1118. }
  1119. /* 分组标题样式 */
  1120. .group-title {
  1121. position: absolute;
  1122. top: -10px;
  1123. left: 20px;
  1124. padding: 0 8px;
  1125. font-size: 12px;
  1126. font-weight: 500;
  1127. color: #606266;
  1128. background: white;
  1129. }
  1130. .attachment-container {
  1131. width: 100%;
  1132. }
  1133. .work-order-attachment-upload :deep(.upload-list-card) {
  1134. display: none;
  1135. }
  1136. .attachment-list {
  1137. width: 100%;
  1138. padding: 10px;
  1139. margin-top: 5px;
  1140. background-color: #fafafa;
  1141. border: 1px solid #e0e0e0;
  1142. border-radius: 4px;
  1143. box-sizing: border-box;
  1144. }
  1145. .attachment-item {
  1146. display: flex;
  1147. align-items: center;
  1148. justify-content: space-between;
  1149. padding: 8px 12px;
  1150. border-bottom: 1px solid #f0f0f0;
  1151. }
  1152. .attachment-item:last-child {
  1153. border-bottom: none;
  1154. }
  1155. .attachment-name {
  1156. flex: 1;
  1157. font-size: 12px;
  1158. color: #606266;
  1159. cursor: pointer;
  1160. }
  1161. .no-attachment {
  1162. padding: 10px;
  1163. margin-top: 5px;
  1164. font-style: italic;
  1165. color: #909399;
  1166. }
  1167. </style>