add.vue 37 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085
  1. <template>
  2. <ele-modal
  3. :visible.sync="visible"
  4. :title="dialogTitle"
  5. width="60vw"
  6. append-to-body
  7. :maxable="true"
  8. @close="cancel"
  9. >
  10. <!-- 查看态:作业票信息 / 审批流程 切换 -->
  11. <div class="switch" v-if="isView">
  12. <div class="switch_left">
  13. <ul>
  14. <li
  15. :class="{ active: activeComp === 'main' }"
  16. @click="activeComp = 'main'"
  17. >
  18. 作业票信息
  19. </li>
  20. <li
  21. :class="{ active: activeComp === 'bpm' }"
  22. @click="activeComp = 'bpm'"
  23. >
  24. 审批流程
  25. </li>
  26. </ul>
  27. </div>
  28. </div>
  29. <el-form
  30. ref="form"
  31. v-show="activeComp === 'main'"
  32. v-loading="detailLoading"
  33. element-loading-text="正在加载作业票详情..."
  34. :model="form"
  35. :rules="rules"
  36. label-width="120px"
  37. :disabled="isView"
  38. class="el-form-box"
  39. >
  40. <el-row :gutter="20">
  41. <el-col :span="12">
  42. <el-form-item label="所属危险作业:">
  43. <el-input v-model="form.workName" disabled placeholder="-" />
  44. </el-form-item>
  45. </el-col>
  46. <el-col :span="12">
  47. <el-form-item label="作业类型:" prop="workType">
  48. <el-select
  49. v-model="form.workType"
  50. placeholder="请选择作业类型"
  51. style="width: 100%"
  52. :disabled="isEdit"
  53. @change="onWorkTypeChange"
  54. >
  55. <el-option
  56. v-for="item in workTypeList"
  57. :key="item.value"
  58. :label="item.label"
  59. :value="item.value"
  60. />
  61. </el-select>
  62. </el-form-item>
  63. </el-col>
  64. </el-row>
  65. <el-row :gutter="20">
  66. <el-col :span="12">
  67. <el-form-item label="申请单位:" prop="applyDeptId">
  68. <dept-select
  69. v-model="form.applyDeptId"
  70. placeholder="请选择申请单位"
  71. disabled
  72. style="width: 100%"
  73. @changeGroup="onDeptChange"
  74. />
  75. </el-form-item>
  76. </el-col>
  77. <el-col :span="12">
  78. <el-form-item label="申请人:" prop="applicantId">
  79. <person-select
  80. ref="personRef"
  81. v-model="form.applicantId"
  82. :init="false"
  83. :selectList="personList"
  84. placeholder="请选择申请人"
  85. style="width: 100%"
  86. disabled
  87. @selfChange="onPersonChange"
  88. />
  89. </el-form-item>
  90. </el-col>
  91. </el-row>
  92. <el-row :gutter="20">
  93. <el-col :span="12">
  94. <el-form-item label="作业地点:" prop="workLocation">
  95. <area-select
  96. v-model="form.areaId"
  97. :data="areaTreeList"
  98. placeholder="请选择作业地点"
  99. style="width: 100%"
  100. @checkedKeys="onAreaChange"
  101. />
  102. </el-form-item>
  103. </el-col>
  104. <el-col :span="12">
  105. <el-form-item label="作业开始时间:" prop="startTime">
  106. <el-date-picker
  107. v-model="form.startTime"
  108. type="datetime"
  109. placeholder="选择开始时间"
  110. style="width: 100%"
  111. value-format="yyyy-MM-dd HH:mm:ss"
  112. />
  113. </el-form-item>
  114. </el-col>
  115. </el-row>
  116. <el-row :gutter="20">
  117. <el-col :span="12">
  118. <el-form-item label="作业结束时间:" prop="endTime">
  119. <el-date-picker
  120. v-model="form.endTime"
  121. type="datetime"
  122. placeholder="选择结束时间"
  123. style="width: 100%"
  124. value-format="yyyy-MM-dd HH:mm:ss"
  125. />
  126. </el-form-item>
  127. </el-col>
  128. </el-row>
  129. <!-- 作业位置标记(每张底图可分别打点,由后端 map_point 字段单独保存) -->
  130. <el-row :gutter="20">
  131. <el-col :span="24">
  132. <el-form-item label="作业位置标记:">
  133. <div class="map-marker-field">
  134. <map-marker-preview :markers="mapMarkers" />
  135. <el-button
  136. v-if="!isView"
  137. size="mini"
  138. icon="el-icon-edit"
  139. @click="openMapMarker"
  140. >
  141. {{ mapMarkers.length ? '重新标记' : '标记位置' }}
  142. </el-button>
  143. </div>
  144. </el-form-item>
  145. </el-col>
  146. </el-row>
  147. <el-form-item label="作业票模板:">
  148. <el-input
  149. v-model="templateName"
  150. disabled
  151. :placeholder="
  152. form.workType ? '按作业类型自动匹配模板' : '请先选择作业类型'
  153. "
  154. >
  155. <el-button
  156. v-if="!frontendTemplate"
  157. slot="append"
  158. @click="openTemplate"
  159. >选择模板</el-button
  160. >
  161. </el-input>
  162. <div v-if="form.workType && !frontendTemplate" class="template-tip">
  163. 该作业类型未配置前端模板,可从发布规则中选择
  164. </div>
  165. </el-form-item>
  166. <!-- 模板内容 -->
  167. <el-form-item label="作业票内容:" v-show="showProcess">
  168. <workTicketForm
  169. v-if="frontendTemplate"
  170. ref="ticketFormRef"
  171. :template="frontendTemplate"
  172. :defaults="templateDefaults"
  173. :is-view="isView"
  174. :show-required="fullCheckEnabled"
  175. />
  176. <experimentationProcessNew
  177. v-else-if="!rawExtension"
  178. ref="experimentationProcessRef"
  179. :edit="false"
  180. ></experimentationProcessNew>
  181. <!-- 未匹配到前端模板时,降级展示已保存的原始作业票内容 -->
  182. <div v-else class="raw-extension">
  183. <div
  184. v-for="(value, key) in displayRawExtension"
  185. :key="key"
  186. class="raw-extension-row"
  187. >
  188. <span class="raw-extension-key">{{ key }}</span>
  189. <span class="raw-extension-value">{{ value }}</span>
  190. </div>
  191. <div
  192. v-if="!Object.keys(displayRawExtension).length"
  193. class="raw-extension-row"
  194. >
  195. 暂无作业票内容
  196. </div>
  197. </div>
  198. </el-form-item>
  199. <el-form-item label="备注:">
  200. <el-input
  201. v-model="form.remark"
  202. type="textarea"
  203. :rows="3"
  204. placeholder="请输入备注"
  205. />
  206. </el-form-item>
  207. </el-form>
  208. <!-- 审批流程(审批记录 + 高亮流程图) -->
  209. <div v-if="activeComp === 'bpm'" class="flow-box">
  210. <bpmDetail v-if="form.processInstanceId" :id="form.processInstanceId" />
  211. <div v-else class="flow-empty">该作业票暂无审批流程</div>
  212. </div>
  213. <template v-slot:footer>
  214. <el-button
  215. v-if="!isView"
  216. type="primary"
  217. @click="save()"
  218. :loading="loading"
  219. :disabled="submitLoading"
  220. >保存</el-button
  221. >
  222. <el-button
  223. v-if="canSubmit"
  224. type="success"
  225. @click="submit()"
  226. :loading="submitLoading"
  227. :disabled="loading"
  228. >提交</el-button
  229. >
  230. <el-button @click="cancel">{{ isView ? '关闭' : '取消' }}</el-button>
  231. </template>
  232. <releaseRules ref="releaseRulesRef" @success="onReleaseRulesSuccess" />
  233. <map-marker-dialog ref="mapMarkerRef" @confirm="onMapMarkerConfirm" />
  234. </ele-modal>
  235. </template>
  236. <script>
  237. import AreaSelect from '@/components/RegionsSelect/area-cascader.vue';
  238. import DeptSelect from '@/components/CommomSelect/dept-select.vue';
  239. import releaseRules from '@/components/releaseRules/index.vue';
  240. import personSelect from '@/components/CommomSelect/person-select.vue';
  241. import experimentationProcessNew from '@/components/releaseRules/experimentationProcessNew.vue';
  242. import workTicketForm from './components/workTicketForm.vue';
  243. import mapMarkerDialog from './components/mapMarkerDialog.vue';
  244. import mapMarkerPreview from './components/mapMarkerPreview.vue';
  245. import areaMixin from '@/mixins/areaMixin';
  246. import dictMixins from '@/mixins/dictMixins';
  247. import dictEnum from '@/enum/dict';
  248. import {
  249. getWorkTicketTemplate,
  250. getUnfilledRequiredFields
  251. } from './templates';
  252. import {
  253. FULL_CHECK_ENABLED,
  254. FULL_CHECK_KEY,
  255. REJECTED_STATUS,
  256. canSubmitStatus,
  257. getProcessDefinitionKey,
  258. getProcessInstanceId
  259. } from './constants';
  260. import { buildWorkTypeOptions, resolveWorkTypeLabel } from './workTypes';
  261. import { parseExtension, parseMapMarkers, buildMapPoint } from './mapMarker';
  262. import bpmDetail from '@/views/bpm/processInstance/detail.vue';
  263. import {
  264. ticketSave,
  265. ticketUpdate,
  266. ticketGetById,
  267. ticketSubmit,
  268. ticketListByWorkId,
  269. ticketMapPoint
  270. } from '@/api/dangerousWork';
  271. import { listOrganizations, getUserPage } from '@/api/system/organization';
  272. // 作业票表单初始值
  273. const defForm = {
  274. id: '',
  275. workId: '',
  276. workCode: '',
  277. workName: '',
  278. workType: '',
  279. applyDeptId: '',
  280. applyDeptName: '',
  281. applicantId: '',
  282. applicantName: '',
  283. areaId: '',
  284. workLocation: '',
  285. startTime: '',
  286. endTime: '',
  287. remark: '',
  288. // 作业票状态:1-待提交、2-审批中、3-已通过、4-已驳回
  289. status: '',
  290. // 审批流程实例 id,用于查看审批流程
  291. processInstanceId: ''
  292. };
  293. export default {
  294. components: {
  295. AreaSelect,
  296. DeptSelect,
  297. personSelect,
  298. releaseRules,
  299. experimentationProcessNew,
  300. workTicketForm,
  301. mapMarkerDialog,
  302. mapMarkerPreview,
  303. bpmDetail
  304. },
  305. mixins: [areaMixin, dictMixins],
  306. data() {
  307. return {
  308. visible: false,
  309. loading: false,
  310. // 提交审批中
  311. submitLoading: false,
  312. // 新增申请保存后记录作业票 id,提交失败重试时复用
  313. newTicketId: '',
  314. // 查询作业票详情中
  315. detailLoading: false,
  316. // 详情请求序号:用于丢弃过期响应(快速切换作业票时)
  317. detailSeq: 0,
  318. dialogType: 'add',
  319. // 弹窗内容页签:main-作业票信息、bpm-审批流程
  320. activeComp: 'main',
  321. form: { ...defForm },
  322. personList: [],
  323. // 匹配到的前端作业票模板
  324. frontendTemplate: null,
  325. // 未匹配到前端模板时的原始作业票内容(降级展示)
  326. rawExtension: null,
  327. // 作业位置标记(由后端 map_point 字段单独保存)
  328. mapMarkers: [],
  329. // 作业票未保存时暂存的地图位置标记,保存后补存
  330. pendingMarkers: null,
  331. // 选中的作业票模板(发布规则)
  332. templateData: {},
  333. templateName: '',
  334. rules: {
  335. workType: [
  336. { required: true, message: '请选择作业类型', trigger: 'change' }
  337. ],
  338. applyDeptId: [
  339. { required: true, message: '请选择申请单位', trigger: 'change' }
  340. ],
  341. applicantId: [
  342. { required: true, message: '请选择申请人', trigger: 'change' }
  343. ],
  344. workLocation: [
  345. { required: true, message: '请选择作业地点', trigger: 'change' }
  346. ],
  347. startTime: [
  348. { required: true, message: '请选择作业开始时间', trigger: 'change' }
  349. ],
  350. endTime: [
  351. { required: true, message: '请选择作业结束时间', trigger: 'change' }
  352. ]
  353. }
  354. };
  355. },
  356. computed: {
  357. dialogTitle() {
  358. if (this.dialogType === 'edit') {
  359. return '修改作业票';
  360. }
  361. if (this.dialogType === 'view') {
  362. return '查看作业票';
  363. }
  364. return '申请作业票';
  365. },
  366. isView() {
  367. return this.dialogType === 'view';
  368. },
  369. // 编辑模式:作业类型不允许变更(模板与已填内容强相关)
  370. isEdit() {
  371. return this.dialogType === 'edit';
  372. },
  373. // 当前作业类型名称
  374. currentWorkTypeName() {
  375. const option = this.workTypeList.find(
  376. (item) => String(item.value) === String(this.form.workType)
  377. );
  378. return (
  379. option?.label ||
  380. resolveWorkTypeLabel(
  381. this.form.workType,
  382. this.dict[dictEnum['危险作业类型']]
  383. )
  384. );
  385. },
  386. // 是否可提交:
  387. // - 新增申请:作业票还没保存,提交时内部会先保存再发起审批流
  388. // - 编辑:仅待提交(草稿)与已驳回状态允许提交,已驳回为重新提交
  389. canSubmit() {
  390. if (this.isView) {
  391. return false;
  392. }
  393. if (this.isEdit) {
  394. return !!this.form.id && canSubmitStatus(this.form.status);
  395. }
  396. return true;
  397. },
  398. // 作业类型下拉:优先取后端字典,字典没有时使用内置作业类型兜底
  399. // 与作业票管理弹窗共用同一套构建逻辑,保证申请页与列表页展示一致
  400. workTypeList() {
  401. return buildWorkTypeOptions(this.dict[dictEnum['危险作业类型']]);
  402. },
  403. // 是否展示模板内容
  404. showProcess() {
  405. return (
  406. !!this.frontendTemplate ||
  407. !!this.templateData?.tempJson?.tempJson ||
  408. !!this.rawExtension
  409. );
  410. },
  411. // 降级展示的原始作业票内容:整体校验标记属于控制参数,不作为票面内容展示
  412. displayRawExtension() {
  413. const raw = { ...(this.rawExtension || {}) };
  414. delete raw[FULL_CHECK_KEY];
  415. return raw;
  416. },
  417. // 是否整体校验作业票内容(按环境区分,不可人工调整)
  418. fullCheckEnabled() {
  419. return FULL_CHECK_ENABLED;
  420. },
  421. // 模板默认值:取自所属危险作业
  422. // 作业计划开始/结束时间默认留空,由申请人按实际计划填写
  423. templateDefaults() {
  424. const option = this.workTypeList.find(
  425. (item) => String(item.value) === String(this.form.workType)
  426. );
  427. return {
  428. apply_dept: {
  429. value: this.form.applyDeptId,
  430. name: this.form.applyDeptName
  431. },
  432. apply_time: this.getCurrentTime(),
  433. work_type: option?.label || this.currentWorkTypeName,
  434. work_site: this.form.workLocation,
  435. lifting_site: this.form.workLocation
  436. };
  437. }
  438. },
  439. created() {
  440. this.requestDict('危险作业类型');
  441. },
  442. methods: {
  443. // 部门变更时重置人员
  444. onDeptChange(deptId) {
  445. this.form.applicantId = '';
  446. this.form.applicantName = '';
  447. if (deptId) {
  448. this.loadPersonList(deptId);
  449. }
  450. this.$nextTick(() => {
  451. this.$refs.form.validateField('applicantId');
  452. });
  453. },
  454. // 人员选择回调
  455. onPersonChange(_val, item) {
  456. this.form.applicantName = item ? item.name : '';
  457. },
  458. // 区域选择回调
  459. onAreaChange(nodeInfo) {
  460. this.form.workLocation =
  461. nodeInfo && nodeInfo[0]?.pathLabels?.join('/')
  462. ? nodeInfo[0].pathLabels.join('/')
  463. : '';
  464. },
  465. // 作业类型变更后按作业类型匹配作业票模板
  466. onWorkTypeChange(val) {
  467. this.frontendTemplate = null;
  468. this.templateData = {};
  469. this.templateName = '';
  470. this.$refs.experimentationProcessRef?.init('[]');
  471. if (!val) {
  472. return;
  473. }
  474. // 作业类型名称,用于按名称兜底匹配模板
  475. const option = this.workTypeList.find(
  476. (item) => String(item.value) === String(val)
  477. );
  478. const workTypeName =
  479. option?.label ||
  480. resolveWorkTypeLabel(val, this.dict[dictEnum['危险作业类型']]);
  481. const template = getWorkTicketTemplate(val, workTypeName);
  482. if (template) {
  483. // 命中前端内置模板,直接按作业类型套用
  484. this.frontendTemplate = template;
  485. this.templateName = template.name;
  486. return;
  487. }
  488. // 未配置前端模板时,回退到发布规则中选择
  489. this.openTemplate();
  490. },
  491. // 当前时间 yyyy-MM-dd HH:mm:ss
  492. getCurrentTime() {
  493. const date = new Date();
  494. const pad = (n) => (n < 10 ? `0${n}` : `${n}`);
  495. return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(
  496. date.getDate()
  497. )} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(
  498. date.getSeconds()
  499. )}`;
  500. },
  501. // 打开模板选择
  502. openTemplate() {
  503. if (!this.form.workType) {
  504. this.$message.warning('请先选择作业类型');
  505. return;
  506. }
  507. this.$refs.releaseRulesRef.open(this.form.workType);
  508. },
  509. // 模板选择回调
  510. onReleaseRulesSuccess(data) {
  511. this.frontendTemplate = null;
  512. this.templateData = data || {};
  513. this.templateName = data?.name || data?.code || '';
  514. if (data?.tempJson?.tempJson) {
  515. this.$refs.experimentationProcessRef.init(data.tempJson.tempJson);
  516. } else {
  517. this.$refs.experimentationProcessRef?.init('[]');
  518. }
  519. },
  520. // 查询申请单位名称
  521. async loadDeptName(deptId) {
  522. if (!deptId) return;
  523. const userInfo = this.$store.state.user.info || {};
  524. let deptName = '';
  525. try {
  526. const list = await listOrganizations({});
  527. const dept = (list || []).find((item) => item.id == deptId);
  528. if (dept) {
  529. deptName = dept.name;
  530. }
  531. } catch (e) {
  532. console.error('查询申请单位失败', e);
  533. }
  534. if (!deptName) {
  535. deptName = userInfo.deptName || userInfo.groupName || '';
  536. }
  537. this.form.applyDeptName = deptName;
  538. },
  539. // 加载人员列表,并把申请人补进下拉,保证能回显姓名
  540. async loadPersonList(deptId, applicant) {
  541. const list = [];
  542. if (deptId) {
  543. try {
  544. const res = await getUserPage({
  545. pageNum: 1,
  546. size: -1,
  547. groupId: deptId
  548. });
  549. list.push(...(res?.list || []));
  550. } catch (e) {
  551. console.error('查询人员列表失败', e);
  552. }
  553. }
  554. if (applicant?.id && !list.find((item) => item.id == applicant.id)) {
  555. list.unshift({ id: applicant.id, name: applicant.name });
  556. }
  557. this.personList = list;
  558. },
  559. // 打开弹窗,work 为所属的危险作业(申请作业票)
  560. async open(work) {
  561. this.visible = true;
  562. this.dialogType = 'add';
  563. this.activeComp = 'main';
  564. this.newTicketId = '';
  565. const detail = work || {};
  566. this.resetTicketForm();
  567. this.form = {
  568. ...defForm,
  569. // id 是作业票自身 id(编辑时才有值),detail 是所属危险作业,不能混用
  570. id: '',
  571. workId: detail.id || '',
  572. workCode: detail.workCode || '',
  573. workName: detail.workName || '',
  574. applyDeptId: detail.applyDeptId || '',
  575. applyDeptName: detail.applyDeptName || '',
  576. applicantId: detail.applicantId || '',
  577. applicantName: detail.applicantName || '',
  578. workLocation: detail.workLocation || '',
  579. areaId: this.getAreaIdByName(detail.workLocation),
  580. startTime: detail.startTime || '',
  581. endTime: detail.endTime || ''
  582. };
  583. if (!this.form.applyDeptName) {
  584. this.loadDeptName(this.form.applyDeptId);
  585. }
  586. this.loadPersonList(this.form.applyDeptId, {
  587. id: this.form.applicantId,
  588. name: this.form.applicantName
  589. });
  590. this.$nextTick(() => {
  591. this.$refs.form && this.$refs.form.clearValidate();
  592. });
  593. },
  594. // 打开已有作业票(查看/编辑)
  595. // 详情以接口返回为准:列表行数据不含作业票内容(extensionJson),必须查详情
  596. // @param ticket 作业票对象(至少包含 id)
  597. // @param work 所属危险作业(用于展示所属作业名称,详情接口不返回)
  598. // @param mode view-查看 / edit-编辑
  599. async openTicket(ticket, work, mode) {
  600. this.visible = true;
  601. this.dialogType = mode || 'view';
  602. this.activeComp = 'main';
  603. this.newTicketId = '';
  604. this.detailLoading = true;
  605. const seq = ++this.detailSeq;
  606. // 先清空上一次的数据,避免详情返回前展示旧内容
  607. this.resetTicketForm();
  608. let detail = ticket || {};
  609. try {
  610. if (detail.id) {
  611. const data = await ticketGetById(detail.id, {
  612. showErrorToast: false
  613. });
  614. // 已有更新的请求,丢弃过期响应
  615. if (seq !== this.detailSeq) {
  616. return;
  617. }
  618. detail = data || detail;
  619. }
  620. } catch (e) {
  621. if (seq !== this.detailSeq) {
  622. return;
  623. }
  624. this.detailLoading = false;
  625. this.$message.error(e?.message || '查询作业票详情失败');
  626. this.cancel();
  627. return;
  628. }
  629. this.detailLoading = false;
  630. this.fillTicketForm(detail, work || {}, ticket);
  631. },
  632. // 重置作业票表单状态
  633. resetTicketForm() {
  634. this.form = { ...defForm };
  635. this.templateData = {};
  636. this.templateName = '';
  637. this.frontendTemplate = null;
  638. this.rawExtension = null;
  639. this.mapMarkers = [];
  640. this.pendingMarkers = null;
  641. this.$refs.experimentationProcessRef?.init('[]');
  642. },
  643. // 用作业票详情回填表单
  644. // @param detail 作业票详情
  645. // @param workDetail 所属危险作业(详情接口不返回作业名称)
  646. // @param row 列表行数据,详情未返回状态时用于兜底
  647. fillTicketForm(detail, workDetail, row) {
  648. this.form = {
  649. ...defForm,
  650. id: detail.id || '',
  651. workId: detail.workId || workDetail.id || '',
  652. workCode: workDetail.workCode || '',
  653. workName: workDetail.workName || '',
  654. applyDeptId: detail.applyDeptId || '',
  655. applyDeptName: detail.applyDeptName || '',
  656. applicantId: detail.applicantId || '',
  657. applicantName: detail.applicantName || '',
  658. workLocation: detail.workLocation || '',
  659. areaId: this.getAreaIdByName(detail.workLocation),
  660. startTime: detail.startTime || '',
  661. endTime: detail.endTime || '',
  662. workType: detail.workType || '',
  663. status: detail.status ?? row?.status ?? '',
  664. // 流程实例 id:详情没有时用列表行数据兜底
  665. processInstanceId:
  666. getProcessInstanceId(detail) || getProcessInstanceId(row)
  667. };
  668. // 解析已保存的作业票内容
  669. const extension = parseExtension(detail.extensionJson);
  670. this.form.remark = extension.remark || '';
  671. // 回显作业位置标记(每张底图一个标记点)
  672. this.mapMarkers = parseMapMarkers(
  673. detail?.mapPoint ?? detail?.map_point
  674. );
  675. // 按作业类型匹配前端内置模板,匹配到则结构化回显
  676. const option = this.workTypeList.find(
  677. (item) => String(item.value) === String(detail.workType)
  678. );
  679. const workTypeName =
  680. option?.label ||
  681. resolveWorkTypeLabel(
  682. detail.workType,
  683. this.dict[dictEnum['危险作业类型']]
  684. );
  685. const template = getWorkTicketTemplate(detail.workType, workTypeName);
  686. if (template) {
  687. this.frontendTemplate = template;
  688. this.templateName = template.name;
  689. // 模板表单是 v-if 渲染的:模板变化时组件会重建并清空表单,
  690. // 必须等它挂载初始化完成后再回填已保存内容
  691. this.$nextTick(() => {
  692. this.$refs.ticketFormRef?.fillValues(extension);
  693. });
  694. } else {
  695. // 未匹配到前端模板(发布规则模板),降级展示原始内容
  696. this.rawExtension = extension;
  697. }
  698. if (!this.form.applyDeptName) {
  699. this.loadDeptName(this.form.applyDeptId);
  700. }
  701. this.loadPersonList(this.form.applyDeptId, {
  702. id: this.form.applicantId,
  703. name: this.form.applicantName
  704. });
  705. this.$nextTick(() => {
  706. this.$refs.form && this.$refs.form.clearValidate();
  707. });
  708. },
  709. // 打开地图位置标记弹框
  710. openMapMarker() {
  711. this.$refs.mapMarkerRef.open(this.mapMarkers);
  712. },
  713. // 地图位置标记确认:调用后端专用接口保存(整段覆盖)
  714. async onMapMarkerConfirm(markers) {
  715. const list = markers || [];
  716. this.mapMarkers = list;
  717. // 作业票还没保存时没有 id,先暂存,等作业票保存后补存
  718. if (!this.form.id) {
  719. this.pendingMarkers = list;
  720. this.$message.success(
  721. list.length
  722. ? '地图位置已标记,保存作业票后生效'
  723. : '已清除地图位置标记'
  724. );
  725. return;
  726. }
  727. try {
  728. await ticketMapPoint({
  729. ticketId: this.form.id,
  730. // 没有标记时传 null,后端按空值清除标记
  731. mapPoint: buildMapPoint(list)
  732. });
  733. this.$message.success(
  734. list.length ? '地图位置标记成功' : '已清除地图位置标记'
  735. );
  736. } catch (e) {
  737. // 失败提示由响应拦截器统一处理
  738. }
  739. },
  740. // 保存暂存的地图位置标记:新增作业票保存后补存(编辑态标记已即时保存)
  741. // 标记保存失败不影响作业票本身的保存/提交,失败提示由响应拦截器统一处理
  742. // @param ticketId 作业票 id
  743. async flushPendingMarkers(ticketId) {
  744. if (!ticketId || !this.pendingMarkers) {
  745. return;
  746. }
  747. try {
  748. await ticketMapPoint({
  749. ticketId,
  750. mapPoint: buildMapPoint(this.pendingMarkers)
  751. });
  752. this.pendingMarkers = null;
  753. } catch (e) {
  754. // 忽略:作业票已保存,标记可稍后重新标记
  755. }
  756. },
  757. cancel() {
  758. this.form = { ...defForm };
  759. this.personList = [];
  760. this.templateData = {};
  761. this.templateName = '';
  762. this.frontendTemplate = null;
  763. this.rawExtension = null;
  764. this.mapMarkers = [];
  765. this.pendingMarkers = null;
  766. this.detailLoading = false;
  767. this.submitLoading = false;
  768. this.newTicketId = '';
  769. this.visible = false;
  770. this.$refs.form && this.$refs.form.clearValidate();
  771. },
  772. // 收集模板中填写的作业票内容,作为扩展数据提交
  773. getExtension() {
  774. // 降级场景(未匹配到前端模板)以已保存的原始内容为基础,避免覆盖丢失
  775. const extension = { ...(this.rawExtension || {}) };
  776. try {
  777. if (this.frontendTemplate) {
  778. // 前端内置模板:由 workTicketForm 收集
  779. Object.assign(
  780. extension,
  781. this.$refs.ticketFormRef?.getExtension() || {}
  782. );
  783. } else if (!this.rawExtension) {
  784. // 发布规则模板:由 experimentationProcessNew 收集
  785. const { data } = this.$refs.experimentationProcessRef.getObjValue();
  786. Object.assign(extension, data || {});
  787. }
  788. } catch (e) {
  789. console.error('获取作业票内容失败', e);
  790. }
  791. if (this.form.remark) {
  792. extension.remark = this.form.remark;
  793. } else {
  794. delete extension.remark;
  795. }
  796. // 整体校验标记随作业票内容一起保存,便于后端/审批流识别提交时的校验模式
  797. extension[FULL_CHECK_KEY] = FULL_CHECK_ENABLED;
  798. return extension;
  799. },
  800. // 业务校验并组装作业票保存参数
  801. // 校验不通过时给出提示并返回 null
  802. buildSaveParams() {
  803. // 整体校验开启:必须选择作业票模板,并完整填写作业票内容
  804. if (FULL_CHECK_ENABLED) {
  805. if (
  806. !this.frontendTemplate &&
  807. !this.templateData?.id &&
  808. !this.rawExtension
  809. ) {
  810. this.$message.warning('请选择作业票模板');
  811. return null;
  812. }
  813. }
  814. const extension = this.getExtension();
  815. // 前端内置模板按源系统表单做必填校验(整体校验开启时才校验)
  816. if (FULL_CHECK_ENABLED && this.frontendTemplate) {
  817. const unfilled = getUnfilledRequiredFields(
  818. this.frontendTemplate,
  819. extension
  820. );
  821. if (unfilled.length) {
  822. this.$message.warning(`请填写作业票内容:${unfilled.join('、')}`);
  823. return null;
  824. }
  825. }
  826. // 单独新增作业票时,所属危险作业必填
  827. if (!this.isEdit && !this.form.workId) {
  828. this.$message.warning('请先选择所属危险作业');
  829. return null;
  830. }
  831. const commonParams = {
  832. workType: String(this.form.workType),
  833. applyDeptId: this.form.applyDeptId,
  834. applyDeptName: this.form.applyDeptName,
  835. applicantId: this.form.applicantId,
  836. applicantName: this.form.applicantName,
  837. workLocation: this.form.workLocation,
  838. startTime: this.form.startTime,
  839. endTime: this.form.endTime,
  840. // 后端扩展数据字段为 extensionJson,且要求字符串(JSON 字符串)
  841. extensionJson: JSON.stringify(extension)
  842. };
  843. // 修改接口不接收 workId(所属危险作业不可变更)
  844. return this.isEdit
  845. ? { id: this.form.id, ...commonParams }
  846. : { workId: this.form.workId, ...commonParams };
  847. },
  848. save() {
  849. if (this.isView) {
  850. this.cancel();
  851. return;
  852. }
  853. this.$refs.form.validate((valid) => {
  854. if (!valid) {
  855. return;
  856. }
  857. const params = this.buildSaveParams();
  858. if (!params) {
  859. return;
  860. }
  861. this.loading = true;
  862. const request = this.isEdit
  863. ? ticketUpdate(params)
  864. : ticketSave(params);
  865. request
  866. .then(async (res) => {
  867. // 新增作业票:保存后补存暂存的地图位置标记
  868. if (this.pendingMarkers && !this.form.id) {
  869. const ticketId = await this.resolveSavedTicketId(res, params);
  870. if (ticketId) {
  871. await this.flushPendingMarkers(ticketId);
  872. } else {
  873. this.$message.warning(
  874. '作业票已保存,但未获取到作业票ID,地图位置标记未保存,请在作业票列表中重新标记'
  875. );
  876. }
  877. }
  878. this.$message.success(this.isEdit ? '保存成功' : '申请成功');
  879. this.cancel();
  880. this.$emit('reload');
  881. })
  882. .catch(() => {})
  883. .finally(() => {
  884. this.loading = false;
  885. });
  886. });
  887. },
  888. // 提交作业票并发起审批流:审批流编码按作业类型取
  889. // 新增时先保存拿到作业票 id,编辑时直接用已有 id,最终与列表提交走同一个接口
  890. submit() {
  891. this.$refs.form.validate((valid) => {
  892. if (!valid) {
  893. return;
  894. }
  895. const params = this.buildSaveParams();
  896. if (!params) {
  897. return;
  898. }
  899. const processDefinitionKey = getProcessDefinitionKey(
  900. this.form.workType,
  901. this.currentWorkTypeName
  902. );
  903. if (!processDefinitionKey) {
  904. this.$message.warning(
  905. '该作业类型暂未接入审批流,请联系管理员配置审批流编码'
  906. );
  907. return;
  908. }
  909. this.$confirm(this.getSubmitTip(), '提示', {
  910. confirmButtonText: '确定',
  911. cancelButtonText: '取消',
  912. type: 'warning'
  913. })
  914. .then(() => this.submitTicket(params, processDefinitionKey))
  915. .catch(() => {});
  916. });
  917. },
  918. // 提交确认文案:已驳回的作业票重新提交会重新发起一轮审批流
  919. getSubmitTip() {
  920. if (Number(this.form.status) === REJECTED_STATUS) {
  921. return '该作业票已被驳回,重新提交后将重新发起审批流,确认提交吗?';
  922. }
  923. if (this.isEdit) {
  924. return '提交前将自动保存作业票内容,提交后作业票不可再修改,确认提交吗?';
  925. }
  926. return '提交后将保存作业票并发起审批流,提交后不可再修改,确认提交吗?';
  927. },
  928. // 保存作业票并发起审批流
  929. async submitTicket(params, processDefinitionKey) {
  930. this.submitLoading = true;
  931. try {
  932. // form.id 为空时是新增;newTicketId 记录本次新增已保存的 id,
  933. // 提交失败重试时复用,避免重复建票
  934. let ticketId = this.form.id || this.newTicketId;
  935. if (ticketId) {
  936. // 已保存过:先保存最新内容,保证审批中的票与页面一致
  937. // 修改接口不接收 workId(所属危险作业不可变更)
  938. const { workId, ...updateParams } = params;
  939. await ticketUpdate({ ...updateParams, id: ticketId });
  940. } else {
  941. // 新增:先保存拿到作业票 id
  942. const saveRes = await ticketSave(params);
  943. ticketId = await this.resolveSavedTicketId(saveRes, params);
  944. if (!ticketId) {
  945. this.$message.warning(
  946. '作业票已保存,但未获取到作业票ID,请在作业票列表中点击「提交」发起审批'
  947. );
  948. this.cancel();
  949. this.$emit('reload');
  950. return;
  951. }
  952. this.newTicketId = ticketId;
  953. // 新增作业票:补存暂存的地图位置标记
  954. await this.flushPendingMarkers(ticketId);
  955. }
  956. await ticketSubmit({ id: ticketId, processDefinitionKey });
  957. this.$message.success('提交成功,已发起审批');
  958. this.cancel();
  959. this.$emit('reload');
  960. } catch (e) {
  961. // 失败提示由响应拦截器统一处理
  962. } finally {
  963. this.submitLoading = false;
  964. }
  965. },
  966. // 取新建作业票的 id:优先用保存响应,没有则按作业票信息回查
  967. async resolveSavedTicketId(saveRes, params) {
  968. const payload = saveRes?.data ?? saveRes;
  969. const id =
  970. this.extractId(payload) ||
  971. this.extractId(payload?.data) ||
  972. this.extractId(payload?.ticket);
  973. if (id) {
  974. return id;
  975. }
  976. // 兜底:按所属危险作业回查作业票列表,取与本次申请一致的最新一条
  977. try {
  978. const list = await ticketListByWorkId(params.workId);
  979. const matched = (list || [])
  980. .filter(
  981. (item) =>
  982. String(item.applicantId) === String(params.applicantId) &&
  983. String(item.workType) === String(params.workType) &&
  984. String(item.startTime || '') === String(params.startTime || '')
  985. )
  986. .sort((a, b) => (a.createTime < b.createTime ? 1 : -1));
  987. return matched.length ? matched[0].id : '';
  988. } catch (e) {
  989. return '';
  990. }
  991. },
  992. // 从响应数据中取作业票 id
  993. extractId(source) {
  994. if (source === undefined || source === null) {
  995. return '';
  996. }
  997. if (typeof source === 'number') {
  998. return source;
  999. }
  1000. if (typeof source === 'string') {
  1001. return /^\d+$/.test(source) ? source : '';
  1002. }
  1003. if (typeof source === 'object') {
  1004. const value = source.id ?? source.ticketId;
  1005. if (value !== undefined && value !== null && value !== '') {
  1006. return value;
  1007. }
  1008. }
  1009. return '';
  1010. }
  1011. }
  1012. };
  1013. </script>
  1014. <style lang="scss" scoped>
  1015. .el-form-box {
  1016. max-height: calc(100vh - 300px);
  1017. overflow-y: auto;
  1018. padding-right: 10px;
  1019. }
  1020. .template-tip {
  1021. color: #909399;
  1022. font-size: 12px;
  1023. line-height: 20px;
  1024. }
  1025. /* 作业位置标记:缩略图与按钮并排 */
  1026. .map-marker-field {
  1027. display: flex;
  1028. align-items: flex-start;
  1029. gap: 12px;
  1030. }
  1031. /* 未匹配到前端模板时的原始作业票内容 */
  1032. .raw-extension {
  1033. width: 100%;
  1034. max-height: 300px;
  1035. overflow-y: auto;
  1036. padding: 8px 12px;
  1037. background: #f6f7f9;
  1038. border-radius: 4px;
  1039. }
  1040. .raw-extension-row {
  1041. display: flex;
  1042. font-size: 13px;
  1043. line-height: 24px;
  1044. word-break: break-all;
  1045. }
  1046. .raw-extension-key {
  1047. flex: 0 0 180px;
  1048. color: #909399;
  1049. }
  1050. .raw-extension-value {
  1051. flex: 1;
  1052. color: #303133;
  1053. }
  1054. /* 审批流程 */
  1055. .flow-box {
  1056. max-height: calc(100vh - 300px);
  1057. overflow-y: auto;
  1058. }
  1059. .flow-empty {
  1060. padding: 40px 0;
  1061. font-size: 14px;
  1062. color: #909399;
  1063. text-align: center;
  1064. }
  1065. </style>