inspectionPlanEditor.vue 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596
  1. <route lang="json5" type="page">
  2. {
  3. layout: 'default',
  4. style: {
  5. navigationBarTitleText: '',
  6. navigationStyle: 'custom',
  7. disableScroll: true,
  8. },
  9. }
  10. </route>
  11. <!-- 检验方案编辑 -->
  12. <template>
  13. <div>
  14. <SpreadDesignerGeneric
  15. ref="designerRef"
  16. :businessConfig="businessConfig"
  17. :templateData="templateData"
  18. :templateBlob="templateBlob"
  19. @save="handleSave"
  20. @cancel="handleCancel"
  21. @customAction="handleCustomAction"
  22. >
  23. <template #navBar>
  24. <ReportNavBar>
  25. <template #right>
  26. <view class="nav-btn-group">
  27. <view
  28. class="nav-btn"
  29. @click="designerRef?.handleNavButtonClick({ action: 'cancel' })"
  30. >
  31. 取消
  32. </view>
  33. <view
  34. class="nav-btn primary"
  35. @click="designerRef?.handleNavButtonClick({ action: 'save' })"
  36. >
  37. 保存
  38. </view>
  39. <view
  40. class="nav-btn upload-btn"
  41. @click="designerRef?.handleNavButtonClick({ action: 'upload' })"
  42. >
  43. 方案上传
  44. </view>
  45. <view
  46. class="nav-btn submit-btn"
  47. @click="designerRef?.handleNavButtonClick({ action: 'submit' })"
  48. >
  49. 提交审核
  50. </view>
  51. </view>
  52. </template>
  53. </ReportNavBar>
  54. </template>
  55. </SpreadDesignerGeneric>
  56. <!-- 审核人选择弹窗 -->
  57. <view v-if="auditorVisible" class="auditor-overlay" @click="auditorVisible = false">
  58. <view class="auditor-dialog" @click.stop>
  59. <view class="auditor-title">选择审核人</view>
  60. <scroll-view scroll-y class="auditor-list">
  61. <view
  62. v-for="item in auditorList"
  63. :key="item.id"
  64. :class="['auditor-item', { active: selectedAuditorId === item.id }]"
  65. @click="selectedAuditorId = item.id"
  66. >
  67. <text class="auditor-name">{{ item.nickname || item.userName }}</text>
  68. <view v-if="selectedAuditorId === item.id" class="auditor-check">&#10003;</view>
  69. </view>
  70. <view v-if="auditorLoading" class="auditor-loading">加载中...</view>
  71. <view v-if="!auditorLoading && auditorList.length === 0" class="auditor-empty">
  72. 暂无可选审核人
  73. </view>
  74. </scroll-view>
  75. <view class="auditor-footer">
  76. <view class="auditor-btn cancel" @click="auditorVisible = false">取消</view>
  77. <view class="auditor-btn confirm" @click="confirmSubmit">确定</view>
  78. </view>
  79. </view>
  80. </view>
  81. </div>
  82. </template>
  83. <script setup lang="ts">
  84. import { ref, onUnmounted } from 'vue'
  85. import SpreadDesignerGeneric from '@/components/SpreadDesigner/spreadDesignerGeneric.vue'
  86. import ReportNavBar from '@/components/NavBar/ReportNavBar.vue'
  87. import { onLoad } from '@dcloudio/uni-app'
  88. import { getEnvBaseUrl } from '@/utils/index'
  89. import { getStandardTemplate, uploadFile } from '@/api/index'
  90. import { getAuditList } from '@/api/system/user'
  91. import { getDynamicTbVal, saveDynamicTbVal } from '@/api/task'
  92. import { useConfigStore } from '@/store/config'
  93. import { useUserStore } from '@/store'
  94. import { EquipmentType, PressureReportType } from '@/utils/dictMap'
  95. import { requestFunc, TaskOrderFuncName } from '@/api/ApiRouter/taskOrder'
  96. import dayjs from 'dayjs'
  97. const businessConfig = ref({
  98. businessType: 'JYFA',
  99. title: '检验方案编辑',
  100. disableNavigate: true,
  101. ui: {
  102. title: '检验方案',
  103. saveButtonText: '保存',
  104. cancelButtonText: '取消',
  105. showAdditionalToolbar: true,
  106. customButtons: [
  107. {
  108. text: '方案上传',
  109. class: 'upload-btn',
  110. action: 'upload',
  111. },
  112. {
  113. text: '提交审核',
  114. class: 'submit-btn',
  115. action: 'submit',
  116. },
  117. ],
  118. },
  119. })
  120. const skipEntryKeys = ref<string[]>([])
  121. const templateBlob = ref<string>('')
  122. const templateData = ref<any>({})
  123. const equipType = useConfigStore().getEquipType()
  124. const userStore = useUserStore()
  125. const userInfo = userStore.userInfo
  126. let templateId: string = ''
  127. let refId: string = ''
  128. let manualUrl: string = ''
  129. onLoad((options: any) => {
  130. templateId = options.templateId
  131. refId = options.refId
  132. manualUrl = options.manualUrl || ''
  133. // 注册RN文件选择回调
  134. if (isRNEnv()) {
  135. registerRNFileCallback()
  136. }
  137. init()
  138. })
  139. onUnmounted(() => {
  140. if ((window as any).onFileSelectedFromRN) {
  141. delete (window as any).onFileSelectedFromRN
  142. }
  143. })
  144. const instId = ref<string>('')
  145. const init = async () => {
  146. uni.hideLoading()
  147. skipEntryKeys.value = []
  148. if (!templateId || !refId) {
  149. return
  150. }
  151. const res = await getStandardTemplate({ id: templateId })
  152. const resData = (res as any).data
  153. const dataMap: any = {}
  154. const dynamicTbValResp = await getDynamicTbVal({ refId, templateId })
  155. const dynamicTb: any = dynamicTbValResp.data
  156. instId.value = dynamicTb?.dynamicTbInsRespVO?.id || ''
  157. for (let i = 0; i < dynamicTb.dynamicTbValRespVOList.length; i++) {
  158. const item = dynamicTb.dynamicTbValRespVOList[i]
  159. const val = item.valValue
  160. if (typeof val === 'string' && /\.(jpg|png)$/i.test(val)) {
  161. skipEntryKeys.value.push(item.colCode)
  162. continue
  163. }
  164. dataMap[item.colCode] = val
  165. }
  166. templateData.value = {
  167. schema: resData.bindingPathSchema ? JSON.parse(resData.bindingPathSchema) : {},
  168. data: {
  169. ...dataMap,
  170. templateId,
  171. templateUrl: resData.fileUrl,
  172. },
  173. pathNameMapping: JSON.parse(resData.bindingPathNameJson),
  174. template: templateId,
  175. templateUrl: resData.fileUrl,
  176. }
  177. const excelBlob = await fetchExcel(refId, templateId)
  178. const fileBase64 = await blobToBase64(excelBlob)
  179. templateBlob.value = fileBase64
  180. }
  181. const fetchExcel = async (refId: string | number, templateId: string | number): Promise<Blob> => {
  182. const apiPath = '/pressure2/excel/excel'
  183. let requestUrl = apiPath
  184. if (JSON.parse(import.meta.env.VITE_APP_PROXY) && import.meta.env.MODE === 'development') {
  185. requestUrl = import.meta.env.VITE_APP_PROXY_PREFIX + apiPath
  186. } else {
  187. requestUrl = getEnvBaseUrl() + apiPath
  188. }
  189. const token = uni.getStorageSync('APP_ACCESS_TOKEN')
  190. const headers: Record<string, string> = { 'Content-Type': 'application/json' }
  191. if (token) {
  192. headers.Authorization = `Bearer ${token}`
  193. }
  194. const response = await fetch(requestUrl, {
  195. method: 'POST',
  196. headers,
  197. body: JSON.stringify({ refId, templateId }),
  198. })
  199. if (!response.ok) {
  200. throw new Error(`获取Excel失败, status ${response.status}`)
  201. }
  202. return await response.blob()
  203. }
  204. const blobToBase64 = (blob: Blob): Promise<string> => {
  205. return new Promise((resolve, reject) => {
  206. const reader = new FileReader()
  207. reader.onload = () => {
  208. const base64 = (reader.result as string).split(',')[1]
  209. resolve(base64)
  210. }
  211. reader.onerror = reject
  212. reader.readAsDataURL(blob)
  213. })
  214. }
  215. const handleSave = async (data: any) => {
  216. const reqData = {}
  217. for (const key in data.dataJSON) {
  218. if (skipEntryKeys.value.includes(key)) {
  219. continue
  220. }
  221. const element = data.dataJSON[key];
  222. reqData[key] = element
  223. }
  224. const result = await saveDynamicTbVal({ params: reqData, instId: instId.value })
  225. if (result?.code === 0 && result?.data) {
  226. uni.showToast({ title: '保存报表成功', icon: 'success' })
  227. } else {
  228. const msg = result?.msg || '保存报表失败'
  229. uni.showToast({ title: msg, icon: 'error' })
  230. }
  231. }
  232. const handleCancel = () => {
  233. uni.navigateBack()
  234. }
  235. const designerRef = ref<any>(null)
  236. // 检测RN WebView环境
  237. const isRNEnv = () => {
  238. return !!(window as any).ReactNativeWebView
  239. }
  240. // 注册RN文件选择回调
  241. const registerRNFileCallback = () => {
  242. ;(window as any).onFileSelectedFromRN = handleFileFromRN
  243. }
  244. // 暂存待上传的文件
  245. const pendingFile = ref<{ base64: string; name: string; type: string; size: number } | null>(null)
  246. // 审核人选择相关
  247. const auditorVisible = ref(false)
  248. const auditorList = ref<any[]>([])
  249. const auditorLoading = ref(false)
  250. const selectedAuditorId = ref<string>('')
  251. // 处理RN回传的文件数据
  252. const handleFileFromRN = async (fileData: any) => {
  253. if (fileData.canceled) return
  254. if (fileData.error) {
  255. uni.showToast({ title: fileData.error, icon: 'none' })
  256. return
  257. }
  258. storeFile(fileData.base64, fileData.name, fileData.type, fileData.size)
  259. }
  260. // 暂存文件到内存,已存在则弹窗确认覆盖
  261. const storeFile = (base64: string, name: string, mimeType: string, size: number) => {
  262. if (pendingFile.value) {
  263. uni.showModal({
  264. title: '提示',
  265. content: `当前已选择文件「${pendingFile.value.name}」,是否覆盖为「${name}」?`,
  266. confirmText: '覆盖',
  267. cancelText: '取消',
  268. success: (res) => {
  269. if (res.confirm) {
  270. pendingFile.value = { base64, name, type: mimeType, size }
  271. uni.showToast({ title: '文件已更新', icon: 'success' })
  272. }
  273. },
  274. })
  275. } else {
  276. pendingFile.value = { base64, name, type: mimeType, size }
  277. uni.showToast({ title: '文件已选择', icon: 'success' })
  278. }
  279. }
  280. // 执行文件上传,返回文件URL
  281. const doUpload = async (): Promise<string | null> => {
  282. if (!pendingFile.value) return null
  283. const { base64, name, type: mimeType } = pendingFile.value
  284. try {
  285. uni.showLoading({ title: '正在上传...' })
  286. const byteString = atob(base64)
  287. const ab = new ArrayBuffer(byteString.length)
  288. const ia = new Uint8Array(ab)
  289. for (let i = 0; i < byteString.length; i++) {
  290. ia[i] = byteString.charCodeAt(i)
  291. }
  292. const blob = new Blob([ab], { type: mimeType || 'application/octet-stream' })
  293. const file = new File([blob], name, { type: mimeType || 'application/octet-stream' })
  294. const formData = new FormData()
  295. formData.append('file', file)
  296. const result = await uploadFile(formData)
  297. uni.hideLoading()
  298. if (result?.code === 0 && result.data) {
  299. return result.data as string
  300. } else {
  301. uni.showToast({ title: result?.msg || '上传失败', icon: 'none' })
  302. return null
  303. }
  304. } catch (error) {
  305. uni.hideLoading()
  306. console.error('上传文件异常:', error)
  307. uni.showToast({ title: '上传失败', icon: 'none' })
  308. return null
  309. }
  310. }
  311. // 选择文件(RN环境)
  312. const pickFileRN = () => {
  313. ;(window as any).ReactNativeWebView.postMessage(
  314. JSON.stringify({
  315. type: 'selectFile',
  316. allowedTypes: [
  317. 'application/pdf',
  318. 'application/vnd.openxmlformats-officedocument.wordprocessingml.document',
  319. ],
  320. multiple: false,
  321. }),
  322. )
  323. }
  324. // 选择文件(非RN环境)
  325. const pickFileH5 = () => {
  326. const input = document.createElement('input')
  327. input.type = 'file'
  328. input.accept = '.pdf,.docx'
  329. input.style.display = 'none'
  330. input.onchange = async (e: Event) => {
  331. const file = (e.target as HTMLInputElement).files?.[0]
  332. if (!file) return
  333. const reader = new FileReader()
  334. reader.onload = async () => {
  335. const base64 = (reader.result as string).split(',')[1]
  336. storeFile(base64, file.name, file.type, file.size)
  337. }
  338. reader.readAsDataURL(file)
  339. document.body.removeChild(input)
  340. }
  341. document.body.appendChild(input)
  342. input.click()
  343. }
  344. // 打开审核人选择弹窗
  345. const openAuditorDialog = async () => {
  346. auditorVisible.value = true
  347. auditorLoading.value = true
  348. auditorList.value = []
  349. selectedAuditorId.value = ''
  350. try {
  351. const roleCode = equipType === EquipmentType.BOILER ? 'Boiler Director' : 'Pipeline Director'
  352. const res = await getAuditList({ roleCode })
  353. auditorList.value = (res as any)?.data?.list || []
  354. } catch (e) {
  355. console.error('获取审核人列表失败:', e)
  356. uni.showToast({ title: '获取审核人列表失败', icon: 'none' })
  357. } finally {
  358. auditorLoading.value = false
  359. }
  360. }
  361. // 确认提交:上传文件后调用提交接口
  362. const confirmSubmit = async () => {
  363. if (!selectedAuditorId.value) {
  364. uni.showToast({ title: '请选择审核人', icon: 'none' })
  365. return
  366. }
  367. auditorVisible.value = false
  368. const fileUrl = await doUpload()
  369. if (!fileUrl) {
  370. uni.showToast({ title: '请先上传检验方案文件', icon: 'none' })
  371. return
  372. }
  373. try {
  374. const dataSource = designerRef.value?.getSpreadDataSource()
  375. if (dataSource) {
  376. const saveResult = await saveDynamicTbVal({ params: dataSource, instId: instId.value })
  377. if (saveResult?.code !== 0) {
  378. throw new Error(saveResult?.msg || '保存报表失败,请重试')
  379. }
  380. }
  381. const reqData = {
  382. id: refId,
  383. reportType: PressureReportType.INSPECTIONPLAN,
  384. prepareJson: JSON.stringify({
  385. prepareName: uni.getStorageSync('userInfo')?.name || '',
  386. prepareDate: dayjs().format('YYYY年MM月DD日'),
  387. }),
  388. auditUserIds: [selectedAuditorId.value],
  389. approveUserIds: [],
  390. manualUrl: fileUrl,
  391. }
  392. uni.showLoading({ title: '提交审核中...', mask: true })
  393. // 调用提交审核接口
  394. const res = await requestFunc(TaskOrderFuncName.SubmitOpinionNoticeApproval, equipType, reqData)
  395. if (res?.code === 0) {
  396. uni.showToast({ title: '提交审核成功', icon: 'success' })
  397. } else {
  398. throw new Error(res?.msg || '提交审核失败,请重试')
  399. }
  400. } catch (error) {
  401. console.error('提交审核异常:', error)
  402. uni.showToast({ title: error.message || '提交审核失败', icon: 'none' })
  403. } finally {
  404. uni.navigateBack()
  405. uni.hideLoading()
  406. }
  407. }
  408. const handleCustomAction = ({ action, data }) => {
  409. switch (action) {
  410. case 'upload':
  411. isRNEnv() ? pickFileRN() : pickFileH5()
  412. break
  413. case 'submit':
  414. if (!pendingFile.value) {
  415. uni.showToast({ title: '请先上传检验方案文件', icon: 'none' })
  416. break
  417. }
  418. uni.showModal({
  419. title: '提交审核',
  420. content: `检验方案文件目前为「${pendingFile.value.name}」,确认提交审核?`,
  421. confirmText: '确定',
  422. cancelText: '取消',
  423. success: async (res) => {
  424. if (!res.confirm) return
  425. await openAuditorDialog()
  426. },
  427. })
  428. break
  429. default:
  430. break
  431. }
  432. }
  433. </script>
  434. <style scoped>
  435. .nav-btn-group {
  436. display: flex;
  437. gap: 8px;
  438. align-items: center;
  439. }
  440. .nav-btn {
  441. padding: 0 12px;
  442. font-size: 12px;
  443. line-height: 28px;
  444. color: #495057;
  445. white-space: nowrap;
  446. background-color: #fff;
  447. border: 1px solid #dee2e6;
  448. border-radius: 4px;
  449. }
  450. .nav-btn.primary {
  451. color: white;
  452. background-color: #007bff;
  453. border-color: #007bff;
  454. }
  455. .nav-btn.upload-btn {
  456. color: white;
  457. background-color: #e6a23c;
  458. border-color: #e6a23c;
  459. }
  460. .nav-btn.submit-btn {
  461. color: white;
  462. background-color: #67c23a;
  463. border-color: #67c23a;
  464. }
  465. /* 审核人弹窗 */
  466. .auditor-overlay {
  467. position: fixed;
  468. top: 0;
  469. right: 0;
  470. bottom: 0;
  471. left: 0;
  472. z-index: 10001;
  473. display: flex;
  474. align-items: center;
  475. justify-content: center;
  476. background-color: rgba(0, 0, 0, 0.5);
  477. }
  478. .auditor-dialog {
  479. width: 80%;
  480. max-height: 70vh;
  481. overflow: hidden;
  482. background: #fff;
  483. border-radius: 12px;
  484. }
  485. .auditor-title {
  486. padding: 16px;
  487. font-size: 16px;
  488. font-weight: 600;
  489. color: #333;
  490. text-align: center;
  491. border-bottom: 1px solid #eee;
  492. }
  493. .auditor-list {
  494. max-height: 50vh;
  495. padding: 8px 0;
  496. }
  497. .auditor-item {
  498. display: flex;
  499. align-items: center;
  500. justify-content: space-between;
  501. padding: 12px 16px;
  502. cursor: pointer;
  503. }
  504. .auditor-item.active {
  505. background-color: #ecf5ff;
  506. }
  507. .auditor-name {
  508. font-size: 14px;
  509. color: #333;
  510. }
  511. .auditor-check {
  512. font-size: 16px;
  513. color: #007bff;
  514. }
  515. .auditor-loading,
  516. .auditor-empty {
  517. padding: 20px;
  518. font-size: 14px;
  519. color: #999;
  520. text-align: center;
  521. }
  522. .auditor-footer {
  523. display: flex;
  524. border-top: 1px solid #eee;
  525. }
  526. .auditor-btn {
  527. flex: 1;
  528. padding: 14px 0;
  529. font-size: 15px;
  530. text-align: center;
  531. cursor: pointer;
  532. }
  533. .auditor-btn.cancel {
  534. color: #666;
  535. border-right: 1px solid #eee;
  536. }
  537. .auditor-btn.confirm {
  538. font-weight: 600;
  539. color: #007bff;
  540. }
  541. </style>