gaoluyang
3 天以前 0333d66e4b397c161c6a44ce1e2a121c2cc41082
Merge branch 'dev_NEW_pro' into dev_天津_中兴实强

# Conflicts:
# src/App.vue
# src/config.js
# src/manifest.json
# src/pages/works.vue
# src/store/modules/user.ts
已添加116个文件
已修改49个文件
已删除2个文件
36775 ■■■■ 文件已修改
src/App.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basic/enum.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/parameterMaintenance.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/storageAttachment.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/collaborativeApproval/approvalProcess.js 89 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/fileManagement/bookshelf.js 129 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/fileManagement/borrow.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/fileManagement/document.js 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/fileManagement/return.js 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/fileManagement/statistics.js 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/inventoryManagement/stockInventory.js 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/login.js 71 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalInstance.js 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalTemplate.js 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/finReimbursement.js 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/procurementManagement/procurementLedger.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/bom.js 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/processManagement.js 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/processRoute.js 37 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productProcessRoute.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionCosting.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionOrder.js 78 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionPlan.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionProductMain.js 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/productionReporting.js 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/productionManagement/workOrder.js 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/post.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/CommonUpload.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaWorkbench.js 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/manifest.json 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 352 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/collaborativeApproval/approve.vue 812 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/collaborativeApproval/detail.vue 1113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/cooperativeOffice/collaborativeApproval/index.vue 28 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/repair/add.vue 57 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/repair/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/upkeep/add.vue 767 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/upkeep/fileList.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/upkeep/index.vue 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/equipmentManagement/upkeep/maintain.vue 97 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/fileManagement/borrow/edit.vue 333 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/fileManagement/borrow/index.vue 262 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/fileManagement/return/edit.vue 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/fileManagement/return/index.vue 262 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/index.vue 448 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/indexItem.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionUpload/attachment.vue 485 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionUpload/components/formDia.vue 228 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionUpload/index.vue 1488 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inspectionUpload/upload.vue 982 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/stockManagement/Qualified.vue 151 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/stockManagement/Record.vue 443 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/stockManagement/Unqualified.vue 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/inventoryManagement/stockManagement/index.vue 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/login.vue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/message.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue 413 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/apply.vue 1274 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/approve.vue 180 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/detail.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 282 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/template-select.vue 378 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/detail.vue 419 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/edit.vue 2634 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/index.vue 322 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/leave-apply/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/overtime-apply/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ContractManage/purchase-contract/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ContractManage/sale-contract/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/EnterpriseNews/news-manage/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/post-manage/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/regular-apply/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/resign-apply/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/staff-archive/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/staff-contract/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/transfer-apply/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/work-handover/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/NoticeAnnouncement/notice-manage/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue 426 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js 33 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js 159 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/cost-reimburse/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-detail/index.vue 120 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss 344 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/index.vue 564 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss 354 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js 440 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/travel-reimburse/index.vue 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/ApprovalInstanceListPage.vue 353 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/ApprovalModuleSearchPopup.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/FinReimbursementListPage.vue 362 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/OaListPage.vue 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/OaUserSearchPicker.vue 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_styles/oa-approval-list.scss 414 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalFormField.js 372 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleApplyExtras.js 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleListSearch.js 456 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleRegistry.js 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalTemplateType.js 178 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approveListUtils.js 358 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/finReimbursementMappers.js 1058 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/oaPageRegistry.js 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/oaStorage.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/oaUi.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/reimburseApproveBridge.js 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/useOaPage.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/userPickerUtils.js 53 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/procurementManagement/procurementLedger/detail.vue 52 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/basicParameters/edit.vue 290 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/basicParameters/index.vue 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/BomStructureItem.vue 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/index.vue 179 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/bom/structure.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/processManagement/edit.vue 236 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/processManagement/index.vue 261 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionDesign/processManagement/params.vue 413 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/mainProductionPlan/detail.vue 252 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/mainProductionPlan/index.vue 300 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/processRoute/index.vue 287 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/processRoute/items.vue 554 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/processStatistics/index.vue 370 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionAccounting/index.vue 506 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionDispatching/components/DispatchModal.vue 708 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionDispatching/components/formDia.vue 265 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionDispatching/index.vue 421 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionOrder/index.vue 697 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionOrder/pickingDetail.vue 350 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionOrder/source.vue 166 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionReport/index.vue 375 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionReporting/ledger.vue 424 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionScheduling/index.vue 241 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/productionManagement/productionTraceability/index.vue 1032 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/add.vue 15 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/detail.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/finalInspection/index.vue 19 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/materialInspection/add.vue 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/materialInspection/detail.vue 3 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/materialInspection/index.vue 19 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/processInspection/add.vue 29 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/processInspection/detail.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/qualityManagement/processInspection/index.vue 19 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesAccount/goOut.vue 976 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesQuotation/detail.vue 54 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesQuotation/edit.vue 547 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/sales/salesQuotation/index.vue 118 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/works.vue 303 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/baogongtaizhang.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/bom.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/gongxuguanli.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/gongyiluxian.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/guihuandengji.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/jichucanshu.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/jieyuedengji.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/kucunguanli.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/shengchandingdan.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/shengchanhesuan.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/shengchanjihua.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/shengchanpaichan.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/shengchanshikuang.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/static/images/icon/shengchanzhuisu.svg 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/store/modules/user.ts 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/utils/versionUpgrade.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/App.vue
@@ -16,12 +16,11 @@
      showSplash.value = false;
    }, 5000);
    // åˆå§‹åŒ–推送服务
    // åˆå§‹åŒ–推送服务,暂时注释,客户需要打开
    // initPushService();
  });
  // åˆå§‹åŒ–推送服务(uni-push 1.0)
  const initPushService = () => {
    return;
    // #ifdef APP-PLUS
    console.log("开始初始化推送服务(uni-push 1.0)");
    if (typeof plus !== "undefined" && plus.push) {
@@ -109,4 +108,4 @@
<style lang="scss">
  @import "uview-plus/index.scss";
  @import "@/static/scss/index.scss";
</style>
</style>
src/api/basic/enum.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
import request from "@/utils/request";
/** å®¡æ‰¹æ¨¡æ¿ç±»åž‹æžšä¸¾ GET /basic/enum/TypeEnums */
export function getTypeEnums() {
  return request({
    url: "/basic/enum/TypeEnums",
    method: "get",
  });
}
src/api/basicData/parameterMaintenance.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,36 @@
import request from "@/utils/request";
// æŸ¥è¯¢åŸºç¡€å‚数列表
export function getBaseParamList(query) {
  return request({
    url: "/technologyParam/list",
    method: "get",
    params: query,
  });
}
// æ–°å¢žåŸºç¡€å‚æ•°
export function addBaseParam(data) {
  return request({
    url: "/technologyParam/add",
    method: "post",
    data: data,
  });
}
// ç¼–辑基础参数
export function editBaseParam(data) {
  return request({
    url: "/technologyParam/edit",
    method: "put",
    data: data,
  });
}
// åˆ é™¤åŸºç¡€å‚æ•°
export function removeBaseParam(id) {
  return request({
    url: "/technologyParam/remove/" + id,
    method: "delete",
  });
}
src/api/basicData/storageAttachment.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
// é™„件页面接口
import request from '@/utils/request'
// é™„件查询
export function attachmentList(query) {
    return request({
        url: '/storageAttachment/list',
        method: 'get',
        params: query
    })
}
// é™„件新增
export function createAttachment(data) {
    return request({
        url: '/storageAttachment/add',
        method: 'post',
        data
    })
}
// é™„件删除
export function deleteAttachment(data) {
    return request({
        url: '/storageAttachment/delete',
        method: 'delete',
        data
    })
}
src/api/collaborativeApproval/approvalProcess.js
@@ -2,63 +2,72 @@
import request from "@/utils/request";
export function approveProcessListPage(query) {
    return request({
        url: '/approveProcess/list',
        method: 'get',
        params: query,
    })
  return request({
    url: "/approveProcess/list",
    method: "get",
    params: query,
  });
}
export function getDept(query) {
    return request({
        url: '/approveProcess/getDept',
        method: 'get',
        params: query,
    })
  return request({
    url: "/approveProcess/getDept",
    method: "get",
    params: query,
  });
}
export function approveProcessGetInfo(query) {
    return request({
        url: '/approveProcess/get',
        method: 'get',
        params: query,
    })
  return request({
    url: "/approveProcess/get",
    method: "get",
    params: query,
  });
}
// æ–°å¢žå®¡æ‰¹æµç¨‹
export function approveProcessAdd(query) {
    return request({
        url: '/approveProcess/add',
        method: 'post',
        data: query,
    })
  return request({
    url: "/approveProcess/add",
    method: "post",
    data: query,
  });
}
// ä¿®æ”¹å®¡æ‰¹æµç¨‹
export function approveProcessUpdate(query) {
    return request({
        url: '/approveProcess/update',
        method: 'post',
        data: query,
    })
  return request({
    url: "/approveProcess/update",
    method: "post",
    data: query,
  });
}
// æäº¤å®¡æ‰¹
export function updateApproveNode(query) {
    return request({
        url: '/approveNode/updateApproveNode',
        method: 'post',
        data: query,
    })
  return request({
    url: "/approveNode/updateApproveNode",
    method: "post",
    data: query,
  });
}
// åˆ é™¤å®¡æ‰¹æµç¨‹
export function approveProcessDelete(query) {
    return request({
        url: '/approveProcess/deleteIds',
        method: 'delete',
        data: query,
    })
  return request({
    url: "/approveProcess/deleteIds",
    method: "delete",
    data: query,
  });
}
// æŸ¥è¯¢å®¡æ‰¹æµç¨‹
export function approveProcessDetails(query) {
    return request({
        url: '/approveNode/details/' + query,
        method: 'get',
    })
}
  return request({
    url: "/approveNode/details/" + query,
    method: "get",
  });
}
// å®¡æ‰¹è¯¦æƒ…
export function getDeliveryDetailByShippingNo(query) {
  return request({
    url: "/shippingInfo/getDateilByShippingNo",
    method: "get",
    params: query,
  });
}
src/api/fileManagement/bookshelf.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,129 @@
import request from "@/utils/request";
/**
 * ä¹¦æž¶ç®¡ç†ç›¸å…³API接口
 * åŒ…含仓库管理、货架管理、图书管理等功能的接口
 */
/**
 * èŽ·å–ä»“åº“åˆ—è¡¨
 * @description èŽ·å–æ‰€æœ‰ä»“åº“çš„åŸºæœ¬ä¿¡æ¯åˆ—è¡¨
 * @returns {Promise} è¿”回仓库列表数据
 */
export function getWarehouseList() {
  return request({
    url: "/warehouse/tree",
    method: "get",
  });
}
/**
 * æ–°å¢žä»“库
 * @description åˆ›å»ºæ–°çš„仓库记录
 * @param {Object} data ä»“库信息对象,包含仓库名称等字段
 * @returns {Promise} è¿”回新增结果
 */
export function addWarehouse(data) {
  return request({
    url: "/warehouse/add",
    method: "post",
    data,
  });
}
/**
 * æ›´æ–°ä»“库信息
 * @description ä¿®æ”¹çŽ°æœ‰ä»“åº“çš„åŸºæœ¬ä¿¡æ¯
 * @param {Object} data ä»“库信息对象,必须包含仓库ID
 * @returns {Promise} è¿”回更新结果
 */
export function updateWarehouse(data) {
  return request({
    url: "/warehouse/update",
    method: "put",
    data,
  });
}
/**
 * åˆ é™¤ä»“库
 * @description æ ¹æ®ä»“库ID删除指定的仓库记录
 * @param {string|number} id ä»“库ID
 * @returns {Promise} è¿”回删除结果
 */
export function deleteWarehouse(data) {
  return request({
    url: `/warehouse/delete/`,
    method: "delete",
    data,
  });
}
/**
 * èŽ·å–è´§æž¶åˆ—è¡¨
 * @description æ ¹æ®ä»“库ID获取该仓库下的所有货架信息
 * @param {string|number} warehouseId ä»“库ID
 * @returns {Promise} è¿”回货架列表数据
 */
export function getShelfList(warehouseId) {
  return request({
    url: `/shelf/list/${warehouseId}`,
    method: "get",
  });
}
/**
 * æ–°å¢žè´§æž¶
 * @description åœ¨æŒ‡å®šä»“库下创建新的货架记录
 * @param {Object} data è´§æž¶ä¿¡æ¯å¯¹è±¡ï¼ŒåŒ…含货架名称、层数、列数等字段
 * @returns {Promise} è¿”回新增结果
 */
export function addShelf(data) {
  return request({
    url: "/warehouse/goodsShelves/add",
    method: "post",
    data,
  });
}
/**
 * æ›´æ–°è´§æž¶ä¿¡æ¯
 * @description ä¿®æ”¹çŽ°æœ‰è´§æž¶çš„åŸºæœ¬ä¿¡æ¯
 * @param {Object} data è´§æž¶ä¿¡æ¯å¯¹è±¡ï¼Œå¿…须包含货架ID
 * @returns {Promise} è¿”回更新结果
 */
export function updateShelf(data) {
  return request({
    url: "/warehouse/goodsShelves/update",
    method: "put",
    data,
  });
}
/**
 * åˆ é™¤è´§æž¶
 * @description æ ¹æ®è´§æž¶ID删除指定的货架记录,后端要求传入 ID æ•°ç»„(支持批量)
 * @param {Array<string|number>} data è´§æž¶ID数组
 * @returns {Promise} è¿”回删除结果
 */
export function deleteShelf(data) {
  return request({
    url: `/warehouse/goodsShelves/delete/`,
    method: "delete",
    data,
  });
}
/**
 * èŽ·å–ä»“åº“ç»“æž„
 * @description èŽ·å–æŒ‡å®šä»“åº“çš„å®Œæ•´ç»“æž„ä¿¡æ¯ï¼ŒåŒ…æ‹¬è´§æž¶ã€å±‚æ•°ã€åˆ—æ•°ç­‰
 * @param {string|number} warehouseId ä»“库ID
 * @returns {Promise} è¿”回仓库的完整结构数据
 */
export function getWarehouseStructure(data) {
  return request({
    url: `/warehouse/goodsShelvesRowcol/list`,
    method: "get",
    params: data,
  });
}
src/api/fileManagement/borrow.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
import request from "@/utils/request";
// æ–‡æ¡£å€Ÿé˜…管理相关接口
// èŽ·å–æ–‡æ¡£åˆ—è¡¨ï¼ˆç”¨äºŽå€Ÿé˜…ä¹¦ç±é€‰æ‹©ï¼‰
export function getDocumentList() {
  return request({
    url: "/documentation/list",
    method: "get",
  });
}
// å€Ÿé˜…分页查询
export function getBorrowList(params) {
  return request({
    url: "/documentationBorrowManagement/listPage",
    method: "get",
    params: params,
  });
}
// æ–°å¢žå€Ÿé˜…
export function addBorrow(data) {
  return request({
    url: "/documentationBorrowManagement/add",
    method: "post",
    data: data,
  });
}
// æ›´æ–°å€Ÿé˜…
export function updateBorrow(data) {
  return request({
    url: "/documentationBorrowManagement/update",
    method: "put",
    data: data,
  });
}
// åˆ é™¤å€Ÿé˜…
export function deleteBorrow(ids) {
  return request({
    url: "/documentationBorrowManagement/delete",
    method: "delete",
    data: ids,
  });
}
src/api/fileManagement/document.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,189 @@
import request from "@/utils/request";
// èŽ·å–åˆ†ç±»æ ‘
export function getCategoryTree() {
  return request({
    url: "/warehouse/documentClassification/getList",
    method: "get",
  });
}
// æ–°å¢žåˆ†ç±»
export function addCategory(data) {
  return request({
    url: "/warehouse/documentClassification/add",
    method: "post",
    data: {
      category: data.category,
      parentId: data.parentId,
    },
  });
}
// ä¿®æ”¹åˆ†ç±»
export function updateCategory(data) {
  return request({
    url: "/warehouse/documentClassification/update",
    method: "put",
    data: {
      id: data.id,
      category: data.category,
    },
  });
}
// åˆ é™¤åˆ†ç±»
export function deleteCategory(ids) {
  return request({
    url: "/warehouse/documentClassification/delete",
    method: "delete",
    data: ids,
  });
}
// èŽ·å–æ–‡æ¡£åˆ—è¡¨ï¼ˆåˆ†é¡µï¼‰
export function getDocumentList(query) {
  return request({
    url: "/documentation/listPage",
    method: "get",
    params: query,
  });
}
// æ–°å¢žæ–‡æ¡£
export function addDocument(data) {
  return request({
    url: "/documentation/add",
    method: "post",
    data: data,
  });
}
// ä¿®æ”¹æ–‡æ¡£
export function updateDocument(data) {
  return request({
    url: "/documentation/update",
    method: "put",
    data: data,
  });
}
// åˆ é™¤æ–‡æ¡£
export function deleteDocument(ids) {
  return request({
    url: "/documentation/delete",
    method: "delete",
    data: ids,
  });
}
// èŽ·å–æ–‡æ¡£è¯¦æƒ…
export function getDocumentDetail(id) {
  return request({
    url: "/document/" + id,
    method: "get",
  });
}
// æœç´¢æ–‡æ¡£
export function searchDocument(query) {
  return request({
    url: "/document/search",
    method: "get",
    params: query,
  });
}
// èŽ·å–ä»“åº“ç»“æž„
export function getWarehouseStructure() {
  return request({
    url: "/document/warehouse/structure",
    method: "get",
  });
}
// é™„件管理相关接口
// æ·»åР附件
export function addDocumentationFile(data) {
  return request({
    url: "/documentation/documentationFile/add",
    method: "post",
    data: data,
  });
}
// èŽ·å–é™„ä»¶åˆ—è¡¨
export function getDocumentationFileList(params) {
  return request({
    url: "/documentation/documentationFile/listPage",
    method: "get",
    params: params,
  });
}
// åˆ é™¤é™„ä»¶
export function deleteDocumentationFile(ids) {
  return request({
    url: "/documentation/documentationFile/del",
    method: "delete",
    data: ids,
  });
}
// æ–‡æ¡£å€Ÿé˜…管理相关接口
export function getBorrowList(params) {
  return request({
    url: "/documentationBorrowManagement/listPage",
    method: "get",
    params: params,
  });
}
export function addBorrow(data) {
  return request({
    url: "/documentationBorrowManagement/add",
    method: "post",
    data: data,
  });
}
export function updateBorrow(data) {
  return request({
    url: "/documentationBorrowManagement/update",
    method: "put",
    data: data,
  });
}
export function deleteBorrow(ids) {
  return request({
    url: "/documentationBorrowManagement/delete",
    method: "delete",
    data: ids,
  });
}
// ç»Ÿè®¡ç›¸å…³æŽ¥å£
// èŽ·å–æ€»ä½“ç»Ÿè®¡æ•°æ®
export function getDocumentationOverview() {
  return request({
    url: "/documentation/overview",
    method: "get",
  });
}
// èŽ·å–åˆ†ç±»ç»Ÿè®¡æ•°æ®
export function getDocumentationCategoryStats() {
  return request({
    url: "/documentation/category",
    method: "get",
  });
}
// èŽ·å–çŠ¶æ€ç»Ÿè®¡æ•°æ®
export function getDocumentationStatusStats() {
  return request({
    url: "/documentation/status",
    method: "get",
  });
}
src/api/fileManagement/return.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,61 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢å½’还记录
export function getReturnListPage(query) {
  return request({
    url: "/documentationBorrowManagement/listPageReturn",
    method: "get",
    params: query,
  });
}
// å½’还操作
export function returnDocument(data) {
  return request({
    url: "/documentationBorrowManagement/revent",
    method: "put",
    data: data,
  });
}
// åˆ é™¤å½’还记录
export function deleteReturn(ids) {
  return request({
    url: "/documentationBorrowManagement/reventDelete",
    method: "delete",
    data: ids,
  });
}
//根据书籍id查询借阅记录
export function getBorrowListByDocumentationId(id) {
  return request({
    url: "/documentationBorrowManagement/getByDocumentationId/"+id,
    method: "get"
  });
}
// æ›´æ–°å€Ÿé˜…记录
export function updateBorrow(data) {
  return request({
    url: "/documentationBorrowManagement/update",
    method: "put",
    data: data,
  });
}
// å½’还更新
export function reventUpdate(data) {
  return request({
    url: "/documentationBorrowManagement/reventUpdate",
    method: "put",
    data: data,
  });
}
// èŽ·å–æ–‡æ¡£åˆ—è¡¨
export function getDocumentList() {
  return request({
    url: "/documentationBorrowManagement/list",
    method: "get",
  });
}
src/api/fileManagement/statistics.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
import request from "@/utils/request";
// èŽ·å–æ¡£æ¡ˆæ€»ä½“ç»Ÿè®¡
export function getDocumentStatistics() {
  return request({
    url: "/fileManagement/statistics/overview",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆåˆ†ç±»ç»Ÿè®¡
export function getCategoryStatistics() {
  return request({
    url: "/fileManagement/statistics/category",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆçŠ¶æ€ç»Ÿè®¡
export function getStatusStatistics() {
  return request({
    url: "/fileManagement/statistics/status",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆå€Ÿé˜…ç»Ÿè®¡
export function getBorrowStatistics() {
  return request({
    url: "/fileManagement/statistics/borrow",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆå¹´åº¦ç»Ÿè®¡
export function getYearStatistics() {
  return request({
    url: "/fileManagement/statistics/year",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆä½ç½®ç»Ÿè®¡
export function getLocationStatistics() {
  return request({
    url: "/fileManagement/statistics/location",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆè¶‹åŠ¿ç»Ÿè®¡
export function getTrendStatistics(params) {
  return request({
    url: "/fileManagement/statistics/trend",
    method: "get",
    params: params,
  });
}
// èŽ·å–æ¡£æ¡ˆå€Ÿé˜…æŽ’è¡Œ
export function getBorrowRanking() {
  return request({
    url: "/fileManagement/statistics/borrowRanking",
    method: "get",
  });
}
// èŽ·å–æ¡£æ¡ˆåˆ†ç±»è¯¦æƒ…ç»Ÿè®¡
export function getCategoryDetailStatistics(categoryId) {
  return request({
    url: `/fileManagement/statistics/categoryDetail/${categoryId}`,
    method: "get",
  });
}
src/api/inventoryManagement/stockInventory.js
@@ -1,61 +1,78 @@
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢åº“存记录列表
export const getStockInventoryListPage = (params) => {
    return request({
        url: "/stockInventory/pagestockInventory",
        method: "get",
        params,
    });
export const getStockInventoryListPage = params => {
  return request({
    url: "/stockInventory/pagestockInventory",
    method: "get",
    params,
  });
};
// åˆ†é¡µæŸ¥è¯¢è”合库存记录列表(包含商品信息)
export const getStockInventoryListPageCombined = params => {
  return request({
    url: "/stockInventory/pageListCombinedStockInventory",
    method: "get",
    params,
  });
};
// åˆ›å»ºåº“存记录
export const createStockInventory = (params) => {
    return request({
        url: "/stockInventory/addstockInventory",
        method: "post",
        data: params,
    });
export const createStockInventory = params => {
  return request({
    url: "/stockInventory/addstockInventory",
    method: "post",
    data: params,
  });
};
// å‡å°‘库存记录
export const subtractStockInventory = (params) => {
    return request({
        url: "/stockInventory/subtractStockInventory",
        method: "post",
        data: params,
    });
export const subtractStockInventory = params => {
  return request({
    url: "/stockInventory/subtractStockInventory",
    method: "post",
    data: params,
  });
};
export const getStockInventoryReportList = (params) => {
    return request({
        url: "/stockInventory/stockInventoryPage",
        method: "get",
        params,
    });
export const getStockInventoryReportList = params => {
  return request({
    url: "/stockInventory/stockInventoryPage",
    method: "get",
    params,
  });
};
export const getStockInventoryInAndOutReportList = (params) => {
    return request({
        url: "/stockInventory/stockInAndOutRecord",
        method: "get",
        params,
    });
export const getStockInventoryInAndOutReportList = params => {
  return request({
    url: "/stockInventory/stockInAndOutRecord",
    method: "get",
    params,
  });
};
// å†»ç»“库存记录
export const frozenStockInventory = (params) => {
    return request({
        url: "/stockInventory/frozenStock",
        method: "post",
        data: params,
    });
export const frozenStockInventory = params => {
  return request({
    url: "/stockInventory/frozenStock",
    method: "post",
    data: params,
  });
};
// è§£å†»åº“存记录
export const thawStockInventory = (params) => {
    return request({
        url: "/stockInventory/thawStock",
        method: "post",
        data: params,
    });
export const thawStockInventory = params => {
  return request({
    url: "/stockInventory/thawStock",
    method: "post",
    data: params,
  });
};
export const getStockInventoryByModelId = productModelId => {
  return request({
    url: "/stockInventory/getByModelId",
    method: "get",
    params: { productModelId },
  });
};
src/api/login.js
@@ -1,81 +1,82 @@
import request from '@/utils/request'
import request from "@/utils/request";
// ç™»å½•方法
export function loginCheckFactory(username, password) {
export function loginCheckFactory(username, password, factoryId) {
  const data = {
    username,
    password,
  }
    factoryId,
  };
  return request({
    url: '/loginCheckFactory',
    url: "/loginCheckFactory",
    headers: {
      isToken: false
      isToken: false,
      repeatSubmit: false,
    },
    method: 'post',
    data: data
  })
    method: "post",
    data: data,
  });
}
// èŽ·å–ç”¨æˆ·è¯¦ç»†ä¿¡æ¯
export function getInfo() {
  return request({
    url: '/getInfo',
    method: 'get'
  })
    url: "/getInfo",
    method: "get",
  });
}
// é€€å‡ºæ–¹æ³•
export function logout() {
  return request({
    url: '/logout',
    method: 'post'
  })
    url: "/logout",
    method: "post",
  });
}
// èŽ·å–å…¬å¸åˆ—è¡¨
export function userLoginFacotryList(params) {
  return request({
    url: '/userLoginFacotryList',
    method: 'get',
    params: params
  })
    url: "/userLoginFacotryList",
    method: "get",
    params: params,
  });
}
// èŽ·å–æœªè¿‡æœŸå…¬å‘Šæ•°é‡
export function noticesList(params) {
  return request({
    url: '/collaborativeApproval/notice/page',
    method: 'get',
    params: params
  })
    url: "/collaborativeApproval/notice/page",
    method: "get",
    params: params,
  });
}
// å‘送客户端推送标识到服务器
export function updateClientId(data) {
  return request({
    url: '/system/client/addOrUpdateClientId',
    method: 'post',
    data: data
  })
    url: "/system/client/addOrUpdateClientId",
    method: "post",
    data: data,
  });
}
// æŸ¥è¯¢å…¬å‘Šåˆ—表
export function listNotice(query) {
  return request({
    url: '/system/notice/list',
    method: 'get',
    params: query
  })
    url: "/system/notice/list",
    method: "get",
    params: query,
  });
}
// èŽ·å–æœªè¯»æ¶ˆæ¯æ•°é‡
export function getNoticeCount(consigneeId) {
  return request({
    url: '/system/notice/getCount',
    method: 'get',
    params: { consigneeId }
  })
    url: "/system/notice/getCount",
    method: "get",
    params: { consigneeId },
  });
}
// æ ‡è®°æ¶ˆæ¯ä¸ºå·²è¯»
src/api/oa/approvalInstance.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,52 @@
import request from "@/utils/request";
/** å®¡æ‰¹å®žä¾‹åˆ†é¡µæŸ¥è¯¢ GET /approvalInstance/listPage */
export function listApprovalInstancePage(params) {
  return request({
    url: "/approvalInstance/listPage",
    method: "get",
    params,
  });
}
/** æ–°å»ºå®¡æ‰¹å®žä¾‹ POST /approvalInstance/save */
export function saveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/save",
    method: "post",
    data: { approvalInstanceDto },
  });
}
/**
 * ä¿®æ”¹å®¡æ‰¹å®žä¾‹ PUT /approvalInstance/update
 * @param {Object} approvalInstanceDto å®¡æ‰¹å®žä¾‹ï¼ˆéœ€å« id,其余字段按业务保留/更新)
 */
export function updateApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/update",
    method: "put",
    data: { approvalInstanceDto },
  });
}
/** å®¡æ‰¹ï¼ˆé€šè¿‡/驳回)POST /approvalInstance/approve */
export function approveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/approve",
    method: "post",
    data: { approvalInstanceDto },
  });
}
/** åˆ é™¤å®¡æ‰¹å®žä¾‹ DELETE /approvalInstance/delete */
export function deleteApprovalInstance(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    id => id != null && id !== ""
  );
  return request({
    url: "/approvalInstance/delete",
    method: "delete",
    data: idList,
  });
}
src/api/oa/approvalTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
import request from "@/utils/request";
/**
 * æŒ‰ templateType æŸ¥è¯¢å·²å¯ç”¨æ¨¡æ¿åˆ—表(非 businessType)
 * GET /approvalTemplate/list/{templateType}  ä¾‹ï¼šlist/1 = è‡ªå®šä¹‰å·²å¯ç”¨
 */
export function listApprovalTemplateByType(templateType) {
  return request({
    url: `/approvalTemplate/list/${templateType}`,
    method: "get",
  });
}
/** å®¡æ‰¹æ¨¡æ¿åˆ†é¡µæŸ¥è¯¢ */
export function listApprovalTemplatePage(params) {
  return request({
    url: "/approvalTemplate/listPage",
    method: "get",
    params,
  });
}
/** å®¡æ‰¹æ¨¡æ¿è¯¦æƒ… */
export function getApprovalTemplateDetail(id) {
  return request({
    url: `/approvalTemplate/detail/${id}`,
    method: "get",
  });
}
/** æ–°å¢žå®¡æ‰¹æ¨¡æ¿ */
export function addApprovalTemplate(data) {
  return request({
    url: "/approvalTemplate/add",
    method: "post",
    data,
  });
}
/** ä¿®æ”¹å®¡æ‰¹æ¨¡æ¿ */
export function updateApprovalTemplate(data) {
  return request({
    url: "/approvalTemplate/update",
    method: "put",
    data,
  });
}
/** åˆ é™¤å®¡æ‰¹æ¨¡æ¿ï¼ˆä¼  ID æ•°ç»„) */
export function deleteApprovalTemplate(ids) {
  return request({
    url: "/approvalTemplate/delete",
    method: "post",
    data: ids,
  });
}
src/api/oa/finReimbursement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢è´¢åŠ¡æŠ¥é”€ GET /finReimbursement/listPage */
export function listFinReimbursementPage(params) {
  return request({
    url: "/finReimbursement/listPage",
    method: "get",
    params,
  });
}
/** è¯¦æƒ… query:Spring ç»‘定 finReimbursementDto.id,勿用 finReimbursementDto[id] */
function buildFinReimbursementDetailParams(idOrDto) {
  const raw =
    typeof idOrDto === "object" && idOrDto !== null
      ? idOrDto.id ?? idOrDto.reimbursementId
      : idOrDto;
  return {
    "finReimbursementDto.id": raw,
    id: raw,
  };
}
/** æŸ¥è¯¢è´¢åŠ¡æŠ¥é”€è¯¦æƒ… GET /finReimbursement/detail */
export function getFinReimbursementDetail(idOrDto) {
  return request({
    url: "/finReimbursement/detail",
    method: "get",
    params: buildFinReimbursementDetailParams(idOrDto),
  });
}
/** æ–°å¢žè´¢åŠ¡æŠ¥é”€ POST /finReimbursement/save */
export function saveFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/save",
    method: "post",
    data: finReimbursementDto,
  });
}
/** ä¿®æ”¹è´¢åŠ¡æŠ¥é”€ POST /finReimbursement/update */
export function updateFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/update",
    method: "post",
    data: finReimbursementDto,
  });
}
/** åˆ é™¤è´¢åŠ¡æŠ¥é”€ DELETE /finReimbursement/delete(body ä¸º ID æ•°ç»„) */
export function deleteFinReimbursement(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    (id) => id != null && id !== ""
  );
  return request({
    url: "/finReimbursement/delete",
    method: "delete",
    data: idList,
  });
}
/** æ–°å¢žèµ° save,修改走 update(与接口文档一致) */
export function persistFinReimbursement(finReimbursementDto, isEdit = false) {
  if (isEdit) {
    return updateFinReimbursement(finReimbursementDto);
  }
  const payload = { ...finReimbursementDto };
  delete payload.id;
  return saveFinReimbursement(payload);
}
src/api/procurementManagement/procurementLedger.js
@@ -72,6 +72,16 @@
    method: "get",
  });
}
// æŸ¥è¯¢é‡‡è´­è¯¦æƒ…
export function getPurchaseByCode(query) {
  return request({
    url: "/purchase/ledger/getPurchaseByCode",
    method: "get",
    params: query,
  });
}
export function approveProcessGetInfo(query) {
    return request({
        url: '/approveProcess/get',
src/api/productionManagement/bom.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
import request from "@/utils/request";
// BOM åˆ—表分页查询
export function listPage(query) {
  return request({
    url: "/technologyBom/listPage",
    method: "get",
    params: query,
  });
}
// æ–°å¢ž BOM
export function add(data) {
  return request({
    url: "/technologyBom/add",
    method: "post",
    data: data,
  });
}
// ä¿®æ”¹ BOM
export function update(data) {
  return request({
    url: "/technologyBom/update",
    method: "put",
    data: data,
  });
}
// åˆ é™¤ BOM
export function batchDelete(ids) {
  return request({
    url: "/technologyBom/batchDelete",
    method: "delete",
    data: ids,
  });
}
// å¤åˆ¶ BOM
export function copy(data) {
  return request({
    url: "/technologyBom/copy",
    method: "post",
    data: data,
  });
}
// èŽ·å–äº§å“åˆ—è¡¨ (用于新增BOM时选择产品)
export function getProductList(query) {
  return request({
    url: "/product/ledger/listPage",
    method: "get",
    params: query,
  });
}
// --- BOM ç»“构相关 ---
// æ ¹æ® BOM ID èŽ·å–ç»“æž„åˆ—è¡¨
export function queryStructureList(bomId) {
  return request({
    url: "/technologyBomStructure/listByBomId/" + bomId,
    method: "get",
  });
}
// ä¿å­˜ BOM ç»“æž„
export function addStructure(data) {
  return request({
    url: "/technologyBomStructure/batchSave",
    method: "post",
    data: data,
  });
}
// åˆ é™¤ BOM ç»“构项
export function deleteStructure(id) {
  return request({
    url: "/technologyBomStructure/batchDelete/" + id,
    method: "delete",
  });
}
src/api/productionManagement/processManagement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
import request from "@/utils/request";
export function getProcessList(query) {
  return request({
    url: "/technologyOperation/listPage",
    method: "get",
    params: query,
  });
}
export function list() {
  return request({
    url: "/technologyOperation/list",
    method: "get",
  });
}
export function add(data) {
  return request({
    url: "/technologyOperation/add",
    method: "post",
    data: data,
  });
}
export function update(data) {
  return request({
    url: "/technologyOperation/update",
    method: "put",
    data: data,
  });
}
export function del(ids) {
  return request({
    url: "/technologyOperation/batchDelete",
    method: "delete",
    data: ids,
  });
}
export function getProcessParamList(params) {
  return request({
    url: "/technologyOperationParam/list",
    method: "get",
    params,
  });
}
export function addProcessParam(data) {
  return request({
    url: "/technologyOperationParam/",
    method: "post",
    data: data,
  });
}
export function editProcessParam(data) {
  return request({
    url: "/technologyOperationParam/",
    method: "post",
    data: data,
  });
}
export function deleteProcessParam(id) {
  return request({
    url: `/technologyOperationParam/batchDelete/${id}`,
    method: "delete",
  });
}
export function getDeviceLedger(query) {
  return request({
    url: "/device/ledger/getDeviceLedger",
    method: "get",
    params: query,
  });
}
export function getBaseParamList(query) {
  return request({
    url: "/technologyParam/list",
    method: "get",
    params: query,
  });
}
src/api/productionManagement/processRoute.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,37 @@
// å·¥è‰ºè·¯çº¿ç›¸å…³æŽ¥å£
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢å·¥è‰ºè·¯çº¿åˆ—表
export function listPage(query) {
  return request({
    url: "/technologyRouting/page",
    method: "get",
    params: query,
  });
}
// æŸ¥è¯¢å·¥è‰ºè·¯çº¿é¡¹ç›®åˆ—表
export function findProcessRouteItemList(query) {
  return request({
    url: "/technologyRoutingOperation/list",
    method: "get",
    params: query,
  });
}
// èŽ·å–å·¥åºå‚æ•°åˆ—è¡¨
export function getProcessParamList(query) {
  return request({
    url: "/technologyRoutingOperationParam/list",
    method: "get",
    params: query,
  });
}
// æŸ¥è¯¢BOM结构 (工艺路线)
export function queryBomList(bomId) {
  return request({
    url: "/technologyBomStructure/listByBomId/" + bomId,
    method: "get",
  });
}
src/api/productionManagement/productProcessRoute.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
// ç”Ÿäº§æŠ¥å·¥é¡µé¢æŽ¥å£
import request from "@/utils/request";
// èŽ·å–å·¥åºå‚æ•°åˆ—è¡¨-生产订单
export function findProcessParamListOrder(query) {
  return request({
    url: `/productionOrderRoutingOperationParam/list`,
    method: "get",
    params: query,
  });
}
src/api/productionManagement/productionCosting.js
@@ -8,4 +8,22 @@
    method: "get",
    params: query,
  });
}
// å·¦è¾¹è¡¨æ ¼çš„æŽ¥å£ (汇总)
export function salesLedgerProductionAccountingList(query) {
  return request({
    url: "/productionAccount/listPage",
    method: "get",
    params: query,
  });
}
// å³è¾¹è¡¨æ ¼çš„æŽ¥å£ (明细)
export function salesLedgerProductionAccountingListProductionDetails(query) {
  return request({
    url: "/productionAccount/listProductionDetails",
    method: "get",
    params: query,
  });
}
src/api/productionManagement/productionOrder.js
@@ -1,19 +1,79 @@
// ç”Ÿäº§è®¢å•页面接口
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢
export function schedulingListPage(query) {
// åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§è®¢å•
export function productOrderListPage(query) {
  return request({
    url: "/salesLedger/scheduling/listPage",
    url: "/productionOrder/page",
    method: "get",
    params: query,
  });
}
// ç”Ÿäº§æ´¾å·¥
export function productionDispatch(query) {
// ç”Ÿäº§è®¢å•溯源详情
export function getOrderDetail(npsNo) {
  return request({
    url: "/salesLedger/scheduling/productionDispatch",
    method: "post",
    data: query,
    url: "/productionOrder/ordeDetail",
    method: "get",
    params: { npsNo },
  });
}
}
// èŽ·å–ç”Ÿäº§è®¢å•æ¥æºæ•°æ®
export function getProductOrderSource(id) {
  return request({
    url: `/productionOrder/source/${id}`,
    method: "get",
  });
}
// é¢†æ–™è¯¦æƒ…列表
export function listMaterialPickingDetail(productionOrderId) {
  return request({
    url: "/productionOrderPick/detail/" + productionOrderId,
    method: "get",
  });
}
// è¡¥æ–™è®°å½•列表
export function listMaterialSupplementRecord(query) {
  return request({
    url: "/productionOrderPickRecord/feeding",
    method: "get",
    params: query,
  });
}
// èŽ·å–é¢†æ–™BOM信息 (可选,备用)
export function listMaterialPickingBom(productionOrderId) {
  return request({
    url: "/productionOrder/pick/" + productionOrderId,
    method: "get",
  });
}
// èŽ·å–ç”Ÿäº§è®¢å•å…³è”çš„å·¥è‰ºè·¯çº¿ä¸»ä¿¡æ¯
export function getOrderProcessRouteMain(orderId) {
  return request({
    url: "/productionOrderRouting/listMain",
    method: "get",
    params: { orderId },
  });
}
// æŸ¥è¯¢BOM结构 (生产订单)
export function queryOrderBomList(bomId) {
  return request({
    url: "/productionBomStructure/listByBomId/" + bomId,
    method: "get",
  });
}
// èŽ·å–å·¥åºå‚æ•°åˆ—è¡¨ (生产订单)
export function findProcessParamListOrder(query) {
  return request({
    url: "/productionOrderRoutingOperationParam/list",
    method: "get",
    params: query,
  });
}
src/api/productionManagement/productionPlan.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
// ä¸»ç”Ÿäº§è®¡åˆ’接口
import request from "@/utils/request";
// åˆ†é¡µåˆ—表
export function productionPlanListPage(query) {
  return request({
    url: "/productionPlan/listPage",
    method: "get",
    params: query,
  });
}
// æ‹‰å–数据
export function loadProdData(query) {
  return request({
    url: "/productionPlan/loadProdData",
    method: "get",
    params: query,
  });
}
// æ±‡æ€»ç»Ÿè®¡
export function summaryByProductType(query) {
  return request({
    url: "/productionPlan/summaryByProductType",
    method: "get",
    params: query,
  });
}
src/api/productionManagement/productionProductMain.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,29 @@
// ç”Ÿäº§æŠ¥å·¥é¡µé¢æŽ¥å£
import request from "@/utils/request";
// åˆ†é¡µæŸ¥è¯¢ç”Ÿäº§æŠ¥å·¥ä¸»è¡¨
export function productionProductMainListPage(query) {
    return request({
        url: "/productionProductMain/listPage",
        method: "get",
        params: query,
    });
}
// åˆ é™¤æŠ¥å·¥
export function productionReportDelete(query) {
    return request({
        url: "/productionProductMain/delete",
        method: "get",
        params: query,
    });
}
// æŸ¥è¯¢æŠ•入列表
export function productionProductInputListPage(query) {
    return request({
        url: "/productionProductInput/listPage",
        method: "get",
        params: query,
    });
}
src/api/productionManagement/productionReporting.js
@@ -20,9 +20,8 @@
// æ ¹æ®ID获取工单详情
export function getProductWorkOrderById(query) {
  return request({
    url: "/productWorkOrder/getProductWorkOrderById",
    url: "/productionOperationTask/" + query.id,
    method: "get",
    params: query,
  });
}
// ç”Ÿäº§æŠ¥å·¥
src/api/productionManagement/workOrder.js
@@ -2,7 +2,7 @@
export function productWorkOrderPage(query) {
  return request({
    url: "/productWorkOrder/page",
    url: "/productionOperationTask/page",
    method: "get",
    params: query,
  });
@@ -10,7 +10,7 @@
export function updateProductWorkOrder(data) {
  return request({
    url: "/productWorkOrder/updateProductWorkOrder",
    url: "/productionOperationTask/updateProductWorkOrder",
    method: "post",
    data: data,
  });
@@ -24,12 +24,29 @@
  });
}
export function assignProductWorkOrder(data) {
  return request({
    url: "/productionOperationTask/assign",
    method: "post",
    data: data,
  });
}
// ä¸‹è½½å·¥å•流转卡(返回文件流)
export function downProductWorkOrder(id) {
  return request({
    url: "/productWorkOrder/down",
    url: "/productionOperationTask/down",
    method: "post",
    data: { id },
    responseType: "blob",
  });
}
// èŽ·å–å·¥åºç»Ÿè®¡æ•°æ®
export function getOperationStatistics(query) {
  return request({
    url: "/productionOperationTask/getOperation",
    method: "get",
    params: query,
  });
}
src/api/system/post.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
/** å²—位下拉 GET /system/post/optionselect */
export function findPostOptions(query) {
  return request({
    url: "/system/post/optionselect",
    method: "get",
    params: query,
  });
}
src/components/CommonUpload.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
<template>
  <view class="common-upload">
    <u-upload
      :fileList="internalFileList"
      @afterRead="afterRead"
      @delete="deleteFile"
      :name="name"
      :multiple="multiple"
      :maxCount="maxCount"
      :accept="accept"
      :disabled="disabled"
    ></u-upload>
  </view>
</template>
<script setup>
import { ref, watch, onMounted } from 'vue';
import { getToken } from "@/utils/auth";
import config from "@/config";
const props = defineProps({
  // çˆ¶ç»„件传入的文件列表(对应后端存储的对象列表)
  modelValue: {
    type: Array,
    default: () => []
  },
  // æœ€å¤§ä¸Šä¼ æ•°é‡
  maxCount: {
    type: Number,
    default: 9
  },
  // æ˜¯å¦æ”¯æŒå¤šé€‰
  multiple: {
    type: Boolean,
    default: true
  },
  // æŽ¥å—的文件类型
  accept: {
    type: String,
    default: 'image'
  },
  // ä¸Šä¼ æŽ¥å£å¯¹åº”的参数名
  name: {
    type: String,
    default: 'file'
  },
  // æ˜¯å¦ç¦ç”¨
  disabled: {
    type: Boolean,
    default: false
  }
});
const emit = defineEmits(['update:modelValue']);
// ç”¨äºŽ u-upload æ˜¾ç¤ºçš„内部列表
const internalFileList = ref([]);
// ç›‘听外部 modelValue å˜åŒ–,同步到内部显示列表
watch(() => props.modelValue, (newVal) => {
  if (newVal) {
    internalFileList.value = newVal.map(item => ({
      ...item,
      url: item.url || item.previewURL,
      status: 'success',
      message: ''
    }));
  }
}, { immediate: true, deep: true });
const showToast = (message) => {
  uni.showToast({
    title: message,
    icon: "none",
  });
};
// ä¸Šä¼ é€»è¾‘
const uploadFilePromise = (url) => {
  return new Promise((resolve, reject) => {
    uni.uploadFile({
      url: config.baseUrl + "/common/upload",
      filePath: url,
      name: "files", // æ³¨æ„ï¼šè¿™é‡Œæ ¹æ®åŽŸä»£ç æ˜¯ "files"
      header: {
        Authorization: "Bearer " + getToken(),
      },
      success: (res) => {
        try {
          const data = JSON.parse(res.data);
          if (data.code === 200) {
            // å¦‚果返回的是数组,取第一个元素
            const resultData = Array.isArray(data.data) ? data.data[0] : data.data;
            // å¤„理 url èµ‹å€¼
            if (!resultData.url && resultData.previewURL) {
              resultData.url = resultData.previewURL;
            }
            // å…¼å®¹åŽŸä»£ç ä¸­çš„ name èµ‹å€¼
            if (!resultData.name && resultData.originalFilename) {
              resultData.name = resultData.originalFilename;
            }
            resolve(resultData);
          } else {
            reject(data.msg || "上传失败");
          }
        } catch (e) {
          reject("解析响应失败");
        }
      },
      fail: (err) => {
        reject(err);
      },
    });
  });
};
// ä¸Šä¼ åŽçš„处理
const afterRead = async (event) => {
  let lists = [].concat(event.file);
  let currentModelValue = [...props.modelValue];
  // å…ˆåœ¨å†…部列表中添加占位(上传中状态)
  lists.forEach(item => {
    internalFileList.value.push({
      ...item,
      status: 'uploading',
      message: '上传中'
    });
  });
  for (let i = 0; i < lists.length; i++) {
    try {
      const result = await uploadFilePromise(lists[i].url);
      // æ›´æ–° modelValue
      currentModelValue.push(result);
      emit('update:modelValue', currentModelValue);
    } catch (e) {
      // å¦‚果上传失败,从内部列表中移除刚才添加的项
      const errorIndex = internalFileList.value.findIndex(item => item.status === 'uploading');
      if (errorIndex > -1) {
        internalFileList.value.splice(errorIndex, 1);
      }
      showToast(typeof e === "string" ? e : "上传失败");
    }
  }
};
// åˆ é™¤å¤„理
const deleteFile = (event) => {
  const newList = [...props.modelValue];
  newList.splice(event.index, 1);
  emit('update:modelValue', newList);
};
</script>
<style scoped lang="scss">
.common-upload {
  width: 100%;
}
</style>
src/config/oaPaths.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
/**
 * OA æ¨¡å—路径常量(pages.json path ä¸å«å‰ç¼€ /)
 * å¯¼èˆªä½¿ç”¨ï¼šuni.navigateTo({ url: OA_NAV.xxx })
 */
const P = "pages/oa";
export const OA_NAV = {
  /** äººäº‹ç®¡ç† */
  staffArchive: `/${P}/HrManage/staff-archive/index`,
  staffContract: `/${P}/HrManage/staff-contract/index`,
  regularApply: `/${P}/HrManage/regular-apply/index`,
  transferApply: `/${P}/HrManage/transfer-apply/index`,
  resignApply: `/${P}/HrManage/resign-apply/index`,
  workHandover: `/${P}/HrManage/work-handover/index`,
  postManage: `/${P}/HrManage/post-manage/index`,
  /** å‡å‹¤ç®¡ç† */
  leaveApply: `/${P}/AttendManage/leave-apply/index`,
  overtimeApply: `/${P}/AttendManage/overtime-apply/index`,
  /** æŠ¥é”€ç®¡ç† */
  travelReimburse: `/${P}/ReimburseManage/travel-reimburse/index`,
  costReimburse: `/${P}/ReimburseManage/cost-reimburse/index`,
  reimburseDetail: `/${P}/ReimburseManage/reimburse-detail/index`,
  reimburseForm: `/${P}/ReimburseManage/reimburse-form/index`,
  /** åˆåŒç®¡ç† */
  purchaseContract: `/${P}/ContractManage/purchase-contract/index`,
  saleContract: `/${P}/ContractManage/sale-contract/index`,
  /** å®¡æ‰¹ç®¡ç† */
  approveList: `/${P}/ApproveManage/approve-list/index`,
  approveListTemplateSelect: `/${P}/ApproveManage/approve-list/template-select`,
  approveListApply: `/${P}/ApproveManage/approve-list/apply`,
  approveListDetail: `/${P}/ApproveManage/approve-list/detail`,
  approveListApprove: `/${P}/ApproveManage/approve-list/approve`,
  approveTemplate: `/${P}/ApproveManage/approve-template/index`,
  approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`,
  approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`,
  /** ä¼ä¸šæ–°é—» / å…¬å‘Šé€šçŸ¥ */
  enterpriseNews: `/${P}/EnterpriseNews/news-manage/index`,
  noticeAnnouncement: `/${P}/NoticeAnnouncement/notice-manage/index`,
};
/** pages.json æ³¨å†Œç”¨ path(无 / å‰ç¼€ï¼‰ */
export const OA_PAGE_PATHS = Object.fromEntries(
  Object.entries(OA_NAV).map(([key, url]) => [
    key,
    url.replace(/^\//, ""),
  ])
);
src/config/oaWorkbench.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
import { OA_NAV } from "./oaPaths.js";
/**
 * OA æ¨¡å—分组(工作台展示 / æ–‡æ¡£å¯¹ç…§ï¼‰
 */
export const OA_MODULES = [
  {
    key: "HrManage",
    name: "人事管理",
    children: [
      // { label: "员工档案", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.staffArchive },
      // { label: "员工合同", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract },
      { label: "转正申请", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.regularApply },
      { label: "调岗申请", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.transferApply },
      // { label: "离职申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
      { label: "工作交接", icon: "/static/images/icon/gongchuguanli.svg", path: OA_NAV.workHandover },
      // { label: "岗位管理", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
    ],
  },
  {
    key: "AttendManage",
    name: "假勤管理",
    children: [
      { label: "请假申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.leaveApply },
      { label: "加班申请", icon: "/static/images/icon/dakaqiandao.svg", path: OA_NAV.overtimeApply },
    ],
  },
  {
    key: "ReimburseManage",
    name: "报销管理",
    children: [
      { label: "差旅报销", icon: "/static/images/icon/chuchaiguanli.svg", path: OA_NAV.travelReimburse },
      { label: "费用报销", icon: "/static/images/icon/baoxiaoguanli.svg", path: OA_NAV.costReimburse },
    ],
  },
  // {
  //   key: "ContractManage",
  //   name: "合同管理",
  //   children: [
  //     { label: "采购合同", icon: "/static/images/icon/caigoutaizhang.svg", path: OA_NAV.purchaseContract },
  //     { label: "销售合同", icon: "/static/images/icon/xiaoshoutaizhang.svg", path: OA_NAV.saleContract },
  //   ],
  // },
  {
    key: "ApproveManage",
    name: "审批管理",
    children: [
      { label: "审批列表", icon: "/static/images/icon/xietongshenpi.svg", path: OA_NAV.approveList },
      { label: "审批模板", icon: "/static/images/icon/guizhangzhidu.svg", path: OA_NAV.approveTemplate },
    ],
  },
  // {
  //   key: "EnterpriseNews",
  //   name: "企业新闻",
  //   children: [
  //     { label: "企业新闻", icon: "/static/images/icon/zhishiku.svg", path: OA_NAV.enterpriseNews },
  //   ],
  // },
  // {
  //   key: "NoticeAnnouncement",
  //   name: "公告通知",
  //   children: [
  //     { label: "公告通知", icon: "/static/images/icon/tongzhigonggao.svg", path: OA_NAV.noticeAnnouncement },
  //   ],
  // },
];
/** å·¥ä½œå°æ‰å¹³èœå•(纯前端配置) */
export const OA_WORKBENCH_ITEMS = OA_MODULES.flatMap(module =>
  module.children.map(item => ({
    ...item,
    module: module.name,
    moduleKey: module.key,
  }))
);
src/manifest.json
@@ -24,7 +24,6 @@
        "modules" : {
            "Camera" : {},
            "Barcode" : {},
            // "Push" : {},
            "Maps" : {}
        },
        /* åº”用发布信息 */
src/pages.json
@@ -367,6 +367,55 @@
      }
    },
    {
      "path": "pages/productionDesign/basicParameters/index",
      "style": {
        "navigationBarTitleText": "基础参数",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/basicParameters/edit",
      "style": {
        "navigationBarTitleText": "参数详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/processManagement/index",
      "style": {
        "navigationBarTitleText": "工序管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/processManagement/edit",
      "style": {
        "navigationBarTitleText": "工序详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/processManagement/params",
      "style": {
        "navigationBarTitleText": "工序参数配置",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/bom/index",
      "style": {
        "navigationBarTitleText": "BOM管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionDesign/bom/structure",
      "style": {
        "navigationBarTitleText": "BOM结构",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/cooperativeOffice/collaborativeApproval/index1",
      "style": {
        "navigationBarTitleText": "公出管理",
@@ -712,6 +761,20 @@
      }
    },
    {
      "path": "pages/inspectionUpload/upload",
      "style": {
        "navigationBarTitleText": "上传巡检记录",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inspectionUpload/attachment",
      "style": {
        "navigationBarTitleText": "查看附件",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/equipmentManagement/faultAnalysis/index",
      "style": {
        "navigationBarTitleText": "故障分析追溯",
@@ -722,6 +785,34 @@
      "path": "pages/productionManagement/productionOrder/index",
      "style": {
        "navigationBarTitleText": "生产订单",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/productionOrder/source",
      "style": {
        "navigationBarTitleText": "来源数据",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/productionOrder/pickingDetail",
      "style": {
        "navigationBarTitleText": "领料详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/processRoute/index",
      "style": {
        "navigationBarTitleText": "工艺路线",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/processRoute/items",
      "style": {
        "navigationBarTitleText": "路线项目",
        "navigationStyle": "custom"
      }
    },
@@ -747,19 +838,61 @@
      }
    },
    {
      "path": "pages/productionManagement/productionReporting/ledger",
      "style": {
        "navigationBarTitleText": "报工台账",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/workOrder/index",
      "style": {
        "navigationBarTitleText": "生产工单",
        "navigationStyle": "custom"
      }
    },
    // {
    //   "path": "pages/productionManagement/productionCosting/index",
    //   "style": {
    //     "navigationBarTitleText": "生产核算",
    //     "navigationStyle": "custom"
    //   }
    // },
    {
      "path": "pages/productionManagement/mainProductionPlan/index",
      "style": {
        "navigationBarTitleText": "主生产计划",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/mainProductionPlan/detail",
      "style": {
        "navigationBarTitleText": "生产计划详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/productionScheduling/index",
      "style": {
        "navigationBarTitleText": "生产排产",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/productionAccounting/index",
      "style": {
        "navigationBarTitleText": "生产核算",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/productionTraceability/index",
      "style": {
        "navigationBarTitleText": "生产追溯",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/productionManagement/processStatistics/index",
      "style": {
        "navigationBarTitleText": "工序生产实况",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/inventoryManagement/receiptManagement/index",
      "style": {
@@ -1143,6 +1276,209 @@
      "style": {
        "navigationBarTitleText": "消息中心"
      }
    },
    {
      "path": "pages/fileManagement/borrow/index",
      "style": {
        "navigationBarTitleText": "借阅管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/fileManagement/borrow/edit",
      "style": {
        "navigationBarTitleText": "借阅登记",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/fileManagement/return/index",
      "style": {
        "navigationBarTitleText": "归还管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/fileManagement/return/edit",
      "style": {
        "navigationBarTitleText": "归还登记",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/staff-archive/index",
      "style": {
        "navigationBarTitleText": "员工档案",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/staff-contract/index",
      "style": {
        "navigationBarTitleText": "员工合同",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/regular-apply/index",
      "style": {
        "navigationBarTitleText": "转正申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/transfer-apply/index",
      "style": {
        "navigationBarTitleText": "调岗申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/resign-apply/index",
      "style": {
        "navigationBarTitleText": "离职申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/work-handover/index",
      "style": {
        "navigationBarTitleText": "工作交接",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/post-manage/index",
      "style": {
        "navigationBarTitleText": "岗位管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/AttendManage/leave-apply/index",
      "style": {
        "navigationBarTitleText": "请假申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/AttendManage/overtime-apply/index",
      "style": {
        "navigationBarTitleText": "加班申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/travel-reimburse/index",
      "style": {
        "navigationBarTitleText": "差旅报销",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/cost-reimburse/index",
      "style": {
        "navigationBarTitleText": "费用报销",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/reimburse-detail/index",
      "style": {
        "navigationBarTitleText": "报销详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/reimburse-form/index",
      "style": {
        "navigationBarTitleText": "报销填报",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ContractManage/purchase-contract/index",
      "style": {
        "navigationBarTitleText": "采购合同",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ContractManage/sale-contract/index",
      "style": {
        "navigationBarTitleText": "销售合同",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/index",
      "style": {
        "navigationBarTitleText": "审批列表",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/template-select",
      "style": {
        "navigationBarTitleText": "选择审批模板",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/apply",
      "style": {
        "navigationBarTitleText": "发起审批",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/detail",
      "style": {
        "navigationBarTitleText": "审批详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/approve",
      "style": {
        "navigationBarTitleText": "审批处理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/index",
      "style": {
        "navigationBarTitleText": "审批模板",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/edit",
      "style": {
        "navigationBarTitleText": "新建审批模板",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/detail",
      "style": {
        "navigationBarTitleText": "模板详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/EnterpriseNews/news-manage/index",
      "style": {
        "navigationBarTitleText": "企业新闻",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/NoticeAnnouncement/notice-manage/index",
      "style": {
        "navigationBarTitleText": "公告通知",
        "navigationStyle": "custom"
      }
    }
  ],
  "subPackages": [
@@ -1379,4 +1715,4 @@
    "navigationBarTitleText": "RuoYi",
    "navigationBarBackgroundColor": "#FFFFFF"
  }
}
}
src/pages/cooperativeOffice/collaborativeApproval/approve.vue
@@ -1,8 +1,7 @@
<template>
  <view class="approve-page">
    <PageHeader title="审核" @back="goBack" />
    <PageHeader title="审核"
                @back="goBack" />
    <!-- ç”³è¯·ä¿¡æ¯ -->
    <view class="application-info">
      <view class="info-header">
@@ -25,7 +24,6 @@
          <text class="info-label">申请日期</text>
          <text class="info-value">{{ approvalData.approveTime }}</text>
        </view>
        <!-- approveType=2 è¯·å‡ç›¸å…³å­—段 -->
        <template v-if="approvalData.approveType === 2">
          <view class="info-row">
@@ -37,462 +35,472 @@
            <text class="info-value">{{ approvalData.endDate || '-' }}</text>
          </view>
        </template>
        <!-- approveType=3 å‡ºå·®ç›¸å…³å­—段 -->
        <view v-if="approvalData.approveType === 3" class="info-row">
        <view v-if="approvalData.approveType === 3"
              class="info-row">
          <text class="info-label">出差地点</text>
          <text class="info-value">{{ approvalData.location || '-' }}</text>
        </view>
        <!-- approveType=4 æŠ¥é”€ç›¸å…³å­—段 -->
        <view v-if="approvalData.approveType === 4" class="info-row">
        <view v-if="approvalData.approveType === 4"
              class="info-row">
          <text class="info-label">报销金额</text>
          <text class="info-value">{{ approvalData.price ? `Â¥${approvalData.price}` : '-' }}</text>
        </view>
      </view>
    </view>
    <!-- å®¡æ‰¹æµç¨‹ -->
    <view class="approval-process">
      <view class="process-header">
        <text class="process-title">审批流程</text>
      </view>
      <view class="process-steps">
        <view
          v-for="(step, index) in approvalSteps"
          :key="index"
          class="process-step"
          :class="{
        <view v-for="(step, index) in approvalSteps"
              :key="index"
              class="process-step"
              :class="{
            'completed': step.status === 'completed',
            'current': step.status === 'current',
            'pending': step.status === 'pending',
            'rejected': step.status === 'rejected'
          }"
        >
          }">
          <view class="step-indicator">
            <view class="step-dot">
              <text v-if="step.status === 'completed'" class="step-icon">✓</text>
              <text v-else-if="step.status === 'rejected'" class="step-icon">✗</text>
              <text v-else class="step-number">{{ index + 1 }}</text>
              <text v-if="step.status === 'completed'"
                    class="step-icon">✓</text>
              <text v-else-if="step.status === 'rejected'"
                    class="step-icon">✗</text>
              <text v-else
                    class="step-number">{{ index + 1 }}</text>
            </view>
            <view v-if="index < approvalSteps.length - 1" class="step-line"></view>
            <view v-if="index < approvalSteps.length - 1"
                  class="step-line"></view>
          </view>
          <view class="step-content">
            <view class="step-info">
              <text class="step-title">{{ step.title }}</text>
              <text class="step-approver">{{ step.approverName }}</text>
              <text v-if="step.approveTime" class="step-time">{{ step.approveTime }}</text>
              <text v-if="step.approveTime"
                    class="step-time">{{ step.approveTime }}</text>
            </view>
            <view v-if="step.opinion" class="step-opinion">
            <view v-if="step.opinion"
                  class="step-opinion">
              <text class="opinion-label">审批意见:</text>
              <text class="opinion-content">{{ step.opinion }}</text>
            </view>
            <!-- ç­¾åå±•示 -->
            <view v-if="step.urlTem" class="step-opinion" style="margin-top:8px;">
            <view v-if="step.urlTem"
                  class="step-opinion"
                  style="margin-top:8px;">
              <text class="opinion-label">签名:</text>
              <image :src="step.urlTem" mode="widthFix" style="width:180px;border-radius:6px;border:1px solid #eee;" />
              <image :src="step.urlTem"
                     mode="widthFix"
                     style="width:180px;border-radius:6px;border:1px solid #eee;" />
            </view>
          </view>
        </view>
      </view>
    </view>
    <!-- å®¡æ ¸æ„è§è¾“å…¥ -->
    <view v-if="canApprove" class="approval-input">
    <view v-if="canApprove"
          class="approval-input">
      <view class="input-header">
        <text class="input-title">审核意见</text>
      </view>
      <view class="input-content">
        <u-textarea
          v-model="approvalOpinion"
          rows="4"
          placeholder="请输入审核意见"
          maxlength="200"
          count
        />
        <u-textarea v-model="approvalOpinion"
                    rows="4"
                    placeholder="请输入审核意见"
                    maxlength="200"
                    count />
      </view>
    </view>
    <!-- åº•部操作按钮 -->
    <view v-if="canApprove" class="footer-actions">
      <u-button class="reject-btn" @click="handleReject">驳回</u-button>
      <u-button class="approve-btn" @click="handleApprove">通过</u-button>
    <view v-if="canApprove"
          class="footer-actions">
      <u-button class="reject-btn"
                @click="handleReject">驳回</u-button>
      <u-button class="approve-btn"
                @click="handleApprove">通过</u-button>
    </view>
  </view>
</template>
<script setup>
import { ref, onMounted, computed } from 'vue'
import { approveProcessGetInfo, approveProcessDetails, updateApproveNode } from '@/api/collaborativeApproval/approvalProcess'
import useUserStore from '@/store/modules/user'
const showToast = (message) => {
    uni.showToast({
        title: message,
        icon: 'none'
    })
}
import PageHeader from "@/components/PageHeader.vue";
  import { ref, onMounted, computed } from "vue";
  import {
    approveProcessGetInfo,
    approveProcessDetails,
    updateApproveNode,
  } from "@/api/collaborativeApproval/approvalProcess";
  import useUserStore from "@/store/modules/user";
  const showToast = message => {
    uni.showToast({
      title: message,
      icon: "none",
    });
  };
  import PageHeader from "@/components/PageHeader.vue";
const userStore = useUserStore()
const approvalData = ref({})
const approvalSteps = ref([])
const approvalOpinion = ref('')
const approveId = ref('')
  const userStore = useUserStore();
  const approvalData = ref({});
  const approvalSteps = ref([]);
  const approvalOpinion = ref("");
  const approveId = ref("");
// ä»Žè¯¦æƒ…接口字段对齐 canApprove:仅当有 isShen çš„节点时可审批
const canApprove = computed(() => {
  return approvalSteps.value.some(step => step.isShen === true)
})
  // ä»Žè¯¦æƒ…接口字段对齐 canApprove:仅当有 isShen çš„节点时可审批
  const canApprove = computed(() => {
    return approvalSteps.value.some(step => step.isShen === true);
  });
onMounted(() => {
  approveId.value = uni.getStorageSync('approveId')
  if (approveId.value) {
    loadApprovalData()
  }
})
const loadApprovalData = () => {
  // åŸºæœ¬ç”³è¯·ä¿¡æ¯
  approveProcessGetInfo({ id: approveId.value }).then(res => {
    approvalData.value = res.data || {}
  })
  // å®¡æ‰¹èŠ‚ç‚¹è¯¦æƒ…
  approveProcessDetails(approveId.value).then(res => {
    const list = Array.isArray(res.data) ? res.data : []
    // ä¿å­˜åŽŸå§‹èŠ‚ç‚¹æ•°æ®ä¾›æäº¤ä½¿ç”¨
    activities.value = list
    approvalSteps.value = list.map((it, idx) => {
      // èŠ‚ç‚¹çŠ¶æ€æ˜ å°„ï¼š1=通过,2=不通过,否则看是否当前(isShen),再默认为待处理
      let status = 'pending'
      if (it.approveNodeStatus === 1) status = 'completed'
      else if (it.approveNodeStatus === 2) status = 'rejected'
      else if (it.isShen) status = 'current'
      return {
        title: `第${idx + 1}步审批`,
        approverName: it.approveNodeUser || '未知用户',
        status,
        approveTime: it.approveTime || null,
        opinion: it.approveNodeReason || '',
        urlTem: it.urlTem || '',
        isShen: !!it.isShen
      }
    })
  })
}
const goBack = () => {
  uni.removeStorageSync('approveId');
  uni.navigateBack()
}
const submitForm = (status) => {
  // å¯é€‰ï¼šæ ¡éªŒå®¡æ ¸æ„è§
  if (!approvalOpinion.value?.trim()) {
    showToast('请输入审核意见')
    return
  }
  // æ‰¾åˆ°å½“前可审批节点
  const filteredActivities = activities.value.filter(activity => activity.isShen)
  if (!filteredActivities.length) {
    showToast('当前无可审批节点')
    return
  }
  // å†™å…¥çŠ¶æ€å’Œæ„è§
  filteredActivities[0].approveNodeStatus = status
  filteredActivities[0].approveNodeReason = approvalOpinion.value || ''
  // è®¡ç®—是否为最后一步
  const isLast = activities.value.findIndex(a => a.isShen) === activities.value.length - 1
  // è°ƒç”¨åŽç«¯
  updateApproveNode({ ...filteredActivities[0], isLast }).then(() => {
    const msg = status === 1 ? '审批通过' : '审批已驳回'
    showToast(msg)
    // æç¤ºåŽè¿”回上一个页面
    setTimeout(() => {
      goBack() // å†…部是 uni.navigateBack()
    }, 800)
  })
}
const handleApprove = () => {
  uni.showModal({
    title: '确认操作',
    content: '确定要通过此审批吗?',
    success: (res) => {
      if (res.confirm) submitForm(1)
  onMounted(() => {
    approveId.value = uni.getStorageSync("approveId");
    if (approveId.value) {
      loadApprovalData();
    }
  })
}
  });
const handleReject = () => {
  uni.showModal({
    title: '确认操作',
    content: '确定要驳回此审批吗?',
    success: (res) => {
      if (res.confirm) submitForm(2)
  const loadApprovalData = () => {
    // åŸºæœ¬ç”³è¯·ä¿¡æ¯
    approveProcessGetInfo({ id: approveId.value }).then(res => {
      approvalData.value = res.data || {};
    });
    // å®¡æ‰¹èŠ‚ç‚¹è¯¦æƒ…
    approveProcessDetails(approveId.value).then(res => {
      const list = Array.isArray(res.data) ? res.data : [];
      // ä¿å­˜åŽŸå§‹èŠ‚ç‚¹æ•°æ®ä¾›æäº¤ä½¿ç”¨
      activities.value = list;
      approvalSteps.value = list.map((it, idx) => {
        // èŠ‚ç‚¹çŠ¶æ€æ˜ å°„ï¼š1=通过,2=不通过,否则看是否当前(isShen),再默认为待处理
        let status = "pending";
        if (it.approveNodeStatus === 1) status = "completed";
        else if (it.approveNodeStatus === 2) status = "rejected";
        else if (it.isShen) status = "current";
        return {
          title: `第${idx + 1}步审批`,
          approverName: it.approveNodeUser || "未知用户",
          status,
          approveTime: it.approveTime || null,
          opinion: it.approveNodeReason || "",
          urlTem: it.urlTem || "",
          isShen: !!it.isShen,
        };
      });
    });
  };
  const goBack = () => {
    uni.removeStorageSync("approveId");
    uni.navigateBack();
  };
  const submitForm = status => {
    // å¯é€‰ï¼šæ ¡éªŒå®¡æ ¸æ„è§
    if (!approvalOpinion.value?.trim()) {
      showToast("请输入审核意见");
      return;
    }
  })
}
// åŽŸå§‹èŠ‚ç‚¹æ•°æ®ï¼ˆç”¨äºŽæäº¤é€»è¾‘ï¼‰
const activities = ref([])
    // æ‰¾åˆ°å½“前可审批节点
    const filteredActivities = activities.value.filter(
      activity => activity.isShen
    );
    if (!filteredActivities.length) {
      showToast("当前无可审批节点");
      return;
    }
    // å†™å…¥çŠ¶æ€å’Œæ„è§
    filteredActivities[0].approveNodeStatus = status;
    filteredActivities[0].approveNodeReason = approvalOpinion.value || "";
    // è®¡ç®—是否为最后一步
    const isLast =
      activities.value.findIndex(a => a.isShen) === activities.value.length - 1;
    // è°ƒç”¨åŽç«¯
    updateApproveNode({ ...filteredActivities[0], isLast }).then(() => {
      const msg = status === 1 ? "审批通过" : "审批已驳回";
      showToast(msg);
      // æç¤ºåŽè¿”回上一个页面
      setTimeout(() => {
        goBack(); // å†…部是 uni.navigateBack()
      }, 800);
    });
  };
  const handleApprove = () => {
    uni.showModal({
      title: "确认操作",
      content: "确定要通过此审批吗?",
      success: res => {
        if (res.confirm) submitForm(1);
      },
    });
  };
  const handleReject = () => {
    uni.showModal({
      title: "确认操作",
      content: "确定要驳回此审批吗?",
      success: res => {
        if (res.confirm) submitForm(2);
      },
    });
  };
  // åŽŸå§‹èŠ‚ç‚¹æ•°æ®ï¼ˆç”¨äºŽæäº¤é€»è¾‘ï¼‰
  const activities = ref([]);
</script>
<style scoped lang="scss">
.approve-page {
  min-height: 100vh;
  background: #f8f9fa;
  padding-bottom: 80px;
}
.header {
  display: flex;
  align-items: center;
  background: #fff;
  padding: 16px 20px;
  border-bottom: 1px solid #f0f0f0;
  position: sticky;
  top: 0;
  z-index: 100;
}
.title {
  flex: 1;
  text-align: center;
  font-size: 18px;
  font-weight: 600;
  color: #333;
}
.application-info {
  background: #fff;
  margin: 16px;
  border-radius: 12px;
  overflow: hidden;
}
.info-header {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #f8f9fa;
}
.info-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}
.info-content {
  padding: 16px;
}
.info-row {
  display: flex;
  align-items: center;
  margin-bottom: 12px;
  &:last-child {
    margin-bottom: 0;
  .approve-page {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 80px;
  }
}
.info-label {
  font-size: 14px;
  color: #666;
  width: 80px;
  flex-shrink: 0;
}
  .header {
    display: flex;
    align-items: center;
    background: #fff;
    padding: 16px 20px;
    border-bottom: 1px solid #f0f0f0;
    position: sticky;
    top: 0;
    z-index: 100;
  }
.info-value {
  font-size: 14px;
  color: #333;
  flex: 1;
}
  .title {
    flex: 1;
    text-align: center;
    font-size: 18px;
    font-weight: 600;
    color: #333;
  }
.approval-process {
  background: #fff;
  margin: 16px;
  border-radius: 12px;
  overflow: hidden;
}
  .application-info {
    background: #fff;
    margin: 16px;
    border-radius: 12px;
    overflow: hidden;
  }
.process-header {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #f8f9fa;
}
  .info-header {
    padding: 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
  }
.process-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}
  .info-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
.process-steps {
  padding: 20px;
}
  .info-content {
    padding: 16px;
  }
.process-step {
  display: flex;
  position: relative;
  margin-bottom: 24px;
  &:last-child {
    margin-bottom: 0;
    .step-line {
      display: none;
  .info-row {
    display: flex;
    align-items: center;
    margin-bottom: 12px;
    &:last-child {
      margin-bottom: 0;
    }
  }
}
.step-indicator {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-right: 16px;
}
  .info-label {
    font-size: 14px;
    color: #666;
    width: 80px;
    flex-shrink: 0;
  }
.step-dot {
  width: 32px;
  height: 32px;
  border-radius: 50%;
  display: flex;
  align-items: center;
  justify-content: center;
  font-size: 14px;
  font-weight: 600;
  position: relative;
  z-index: 2;
}
  .info-value {
    font-size: 14px;
    color: #333;
    flex: 1;
  }
.process-step.completed .step-dot {
  background: #52c41a;
  color: #fff;
}
  .approval-process {
    background: #fff;
    margin: 16px;
    border-radius: 12px;
    overflow: hidden;
  }
.process-step.current .step-dot {
  background: #1890ff;
  color: #fff;
  animation: pulse 2s infinite;
}
  .process-header {
    padding: 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
  }
.process-step.pending .step-dot {
  background: #d9d9d9;
  color: #999;
}
  .process-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
.step-line {
  width: 2px;
  height: 40px;
  background: #d9d9d9;
  margin-top: 8px;
}
  .process-steps {
    padding: 20px;
  }
.process-step.completed .step-line {
  background: #52c41a;
}
  .process-step {
    display: flex;
    position: relative;
    margin-bottom: 24px;
.process-step.rejected .step-dot {
  background: #ff4d4f;
  color: #fff;
}
.process-step.rejected .step-line {
  background: #ff4d4f;
}
    &:last-child {
      margin-bottom: 0;
.step-content {
  flex: 1;
  padding-top: 4px;
}
      .step-line {
        display: none;
      }
    }
  }
.step-info {
  margin-bottom: 8px;
}
  .step-indicator {
    display: flex;
    flex-direction: column;
    align-items: center;
    margin-right: 16px;
  }
.step-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
  display: block;
  margin-bottom: 4px;
}
  .step-dot {
    width: 32px;
    height: 32px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 14px;
    font-weight: 600;
    position: relative;
    z-index: 2;
  }
.step-approver {
  font-size: 14px;
  color: #666;
  display: block;
  margin-bottom: 4px;
}
  .process-step.completed .step-dot {
    background: #52c41a;
    color: #fff;
  }
.step-time {
  font-size: 12px;
  color: #999;
  display: block;
}
  .process-step.current .step-dot {
    background: #1890ff;
    color: #fff;
    animation: pulse 2s infinite;
  }
.step-opinion {
  background: #f8f9fa;
  padding: 12px;
  border-radius: 8px;
  border-left: 4px solid #52c41a;
}
  .process-step.pending .step-dot {
    background: #d9d9d9;
    color: #999;
  }
.opinion-label {
  font-size: 12px;
  color: #666;
  display: block;
  margin-bottom: 4px;
}
  .step-line {
    width: 2px;
    height: 40px;
    background: #d9d9d9;
    margin-top: 8px;
  }
.opinion-content {
  font-size: 14px;
  color: #333;
  line-height: 1.5;
}
  .process-step.completed .step-line {
    background: #52c41a;
  }
.approval-input {
  background: #fff;
  margin: 16px;
  border-radius: 12px;
  overflow: hidden;
}
  .process-step.rejected .step-dot {
    background: #ff4d4f;
    color: #fff;
  }
  .process-step.rejected .step-line {
    background: #ff4d4f;
  }
.input-header {
  padding: 16px;
  border-bottom: 1px solid #f0f0f0;
  background: #f8f9fa;
}
  .step-content {
    flex: 1;
    padding-top: 4px;
  }
.input-title {
  font-size: 16px;
  font-weight: 600;
  color: #333;
}
  .step-info {
    margin-bottom: 8px;
  }
.input-content {
  padding: 16px;
}
  .step-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
    display: block;
    margin-bottom: 4px;
  }
.footer-actions {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  background: #fff;
  display: flex;
  justify-content: space-around;
  align-items: center;
  padding: 16px;
  box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
  z-index: 1000;
}
  .step-approver {
    font-size: 14px;
    color: #666;
    display: block;
    margin-bottom: 4px;
  }
.reject-btn {
  .step-time {
    font-size: 12px;
    color: #999;
    display: block;
  }
  .step-opinion {
    background: #f8f9fa;
    padding: 12px;
    border-radius: 8px;
    border-left: 4px solid #52c41a;
  }
  .opinion-label {
    font-size: 12px;
    color: #666;
    display: block;
    margin-bottom: 4px;
  }
  .opinion-content {
    font-size: 14px;
    color: #333;
    line-height: 1.5;
  }
  .approval-input {
    background: #fff;
    margin: 16px;
    border-radius: 12px;
    overflow: hidden;
  }
  .input-header {
    padding: 16px;
    border-bottom: 1px solid #f0f0f0;
    background: #f8f9fa;
  }
  .input-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
  .input-content {
    padding: 16px;
  }
  .footer-actions {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    background: #fff;
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 16px;
    box-shadow: 0 -2px 8px rgba(0, 0, 0, 0.1);
    z-index: 1000;
  }
  .reject-btn {
    width: 120px;
    background: #ff4d4f;
    color: #fff;
@@ -503,47 +511,47 @@
    background: #52c41a;
    color: #fff;
  }
  /* é€‚配u-button样式 */
  :deep(.u-button) {
    border-radius: 6px;
  }
@keyframes pulse {
  0% {
    box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.7);
  @keyframes pulse {
    0% {
      box-shadow: 0 0 0 0 rgba(24, 144, 255, 0.7);
    }
    70% {
      box-shadow: 0 0 0 10px rgba(24, 144, 255, 0);
    }
    100% {
      box-shadow: 0 0 0 0 rgba(24, 144, 255, 0);
    }
  }
  70% {
    box-shadow: 0 0 0 10px rgba(24, 144, 255, 0);
  .signature-section {
    background: #fff;
    padding: 12px 16px 16px;
    border-top: 1px solid #f0f0f0;
  }
  100% {
    box-shadow: 0 0 0 0 rgba(24, 144, 255, 0);
  .signature-header {
    margin-bottom: 8px;
  }
}
.signature-section {
  background: #fff;
  padding: 12px 16px 16px;
  border-top: 1px solid #f0f0f0;
}
.signature-header {
  margin-bottom: 8px;
}
.signature-title {
  font-size: 14px;
  font-weight: 600;
  color: #333;
}
.signature-box {
  width: 100%;
  height: 180px;
  background: #fff;
  border: 1px dashed #d9d9d9;
  border-radius: 8px;
  overflow: hidden;
}
.signature-actions {
  margin-top: 8px;
  display: flex;
  justify-content: flex-end;
}
  .signature-title {
    font-size: 14px;
    font-weight: 600;
    color: #333;
  }
  .signature-box {
    width: 100%;
    height: 180px;
    background: #fff;
    border: 1px dashed #d9d9d9;
    border-radius: 8px;
    overflow: hidden;
  }
  .signature-actions {
    margin-top: 8px;
    display: flex;
    justify-content: flex-end;
  }
</style>
src/pages/cooperativeOffice/collaborativeApproval/detail.vue
@@ -1,6 +1,6 @@
<template>
  <view class="account-detail">
    <PageHeader title="审批流程"
    <PageHeader :title="operationType === 'detail' ? '详情' : '审批流程'"
                @back="goBack" />
    <!-- è¡¨å•区域 -->
    <u-form ref="formRef"
@@ -8,38 +8,39 @@
            :rules="rules"
            :model="form"
            label-width="140rpx">
      <u-form-item prop="approveReason"
                   label="流程编号">
        <u-input v-model="form.approveId"
                 disabled
                 placeholder="自动编号" />
      </u-form-item>
      <u-form-item prop="approveReason"
                   :label="approveType === 5 ? '采购事由' : '申请事由'"
                   required>
        <u-input v-model="form.approveReason"
                 type="textarea"
                 rows="2"
                 auto-height
                 maxlength="200"
                 :placeholder="approveType === 5 ? '请输入采购事由' : '请输入申请事由'"
                 show-word-limit />
      </u-form-item>
      <u-form-item prop="approveDeptName"
                   label="申请部门"
                   required>
        <!-- <u-input v-model="form.approveDeptName"
      <template v-if="operationType !== 'detail'">
        <u-form-item prop="approveReason"
                     label="流程编号">
          <u-input v-model="form.approveId"
                   disabled
                   placeholder="自动编号" />
        </u-form-item>
        <u-form-item prop="approveReason"
                     :label="approveType === 5 ? '采购事由' : '申请事由'"
                     required>
          <u-input v-model="form.approveReason"
                   type="textarea"
                   rows="2"
                   auto-height
                   maxlength="200"
                   :placeholder="approveType === 5 ? '请输入采购事由' : '请输入申请事由'"
                   show-word-limit />
        </u-form-item>
        <u-form-item prop="approveDeptName"
                     label="申请部门"
                     required>
          <!-- <u-input v-model="form.approveDeptName"
                 placeholder="请选择申请部门" /> -->
        <u-input v-model="form.approveDeptName"
                 readonly
                 placeholder="请选择申请部门"
                 @click="showPicker = true" />
        <template #right>
          <up-icon name="arrow-right"
                   @click="showPicker = true"></up-icon>
        </template>
      </u-form-item>
      <u-form-item prop="approveUser"
          <u-input v-model="form.approveDeptName"
                   readonly
                   placeholder="请选择申请部门"
                   @click="showPicker = true" />
          <template #right>
            <up-icon name="arrow-right"
                     @click="showPicker = true"></up-icon>
          </template>
        </u-form-item>
        <!-- <u-form-item prop="approveUser"
                   label="申请人"
                   required>
        <u-input v-model="form.approveUserName"
@@ -57,141 +58,277 @@
          <up-icon name="arrow-right"
                   @click="showDatePicker"></up-icon>
        </template>
      </u-form-item>
      <!-- approveType=2 è¯·å‡ç›¸å…³å­—段 -->
      <template v-if="approveType === 2">
        <u-form-item prop="startDate"
                     label="开始时间"
      </u-form-item> -->
        <!-- approveType=2 è¯·å‡ç›¸å…³å­—段 -->
        <template v-if="approveType === 2">
          <u-form-item prop="startDate"
                       label="开始时间"
                       required>
            <u-input v-model="form.startDate"
                     readonly
                     placeholder="请假开始时间"
                     @click="showStartDatePicker" />
            <template #right>
              <up-icon name="arrow-right"
                       @click="showStartDatePicker"></up-icon>
            </template>
          </u-form-item>
          <u-form-item prop="endDate"
                       label="结束时间"
                       required>
            <u-input v-model="form.endDate"
                     readonly
                     placeholder="请假结束时间"
                     @click="showEndDatePicker" />
            <template #right>
              <up-icon name="arrow-right"
                       @click="showEndDatePicker"></up-icon>
            </template>
          </u-form-item>
        </template>
        <!-- approveType=3 å‡ºå·®ç›¸å…³å­—段 -->
        <u-form-item v-if="approveType === 3"
                     prop="location"
                     label="出差地点"
                     required>
          <u-input v-model="form.startDate"
                   readonly
                   placeholder="请假开始时间"
                   @click="showStartDatePicker" />
          <template #right>
            <up-icon name="arrow-right"
                     @click="showStartDatePicker"></up-icon>
          </template>
          <u-input v-model="form.location"
                   placeholder="请输入出差地点"
                   clearable />
        </u-form-item>
        <u-form-item prop="endDate"
                     label="结束时间"
        <!-- approveType=4 æŠ¥é”€ç›¸å…³å­—段 -->
        <u-form-item v-if="approveType === 4"
                     prop="price"
                     label="报销金额"
                     required>
          <u-input v-model="form.endDate"
                   readonly
                   placeholder="请假结束时间"
                   @click="showEndDatePicker" />
          <template #right>
            <up-icon name="arrow-right"
                     @click="showEndDatePicker"></up-icon>
          </template>
          <u-input v-model="form.price"
                   type="number"
                   placeholder="请输入报销金额"
                   clearable />
        </u-form-item>
      </template>
      <!-- approveType=3 å‡ºå·®ç›¸å…³å­—段 -->
      <u-form-item v-if="approveType === 3"
                   prop="location"
                   label="出差地点"
                   required>
        <u-input v-model="form.location"
                 placeholder="请输入出差地点"
                 clearable />
      </u-form-item>
      <!-- approveType=4 æŠ¥é”€ç›¸å…³å­—段 -->
      <u-form-item v-if="approveType === 4"
                   prop="price"
                   label="报销金额"
                   required>
        <u-input v-model="form.price"
                 type="number"
                 placeholder="请输入报销金额"
                 clearable />
      <!-- æŠ¥ä»·å®¡æ‰¹è¯¦æƒ… -->
      <view v-if="isQuotationApproval"
            style="margin: 20rpx 0;">
        <u-divider text="报价详情"
                   text-size="28rpx"
                   color="#2979ff"></u-divider>
        <u-skeleton :loading="quotationLoading"
                    rows="3"
                    animated>
          <view v-if="!currentQuotation || !currentQuotation.quotationNo"
                style="padding: 40rpx; text-align: center; color: #999;">
            æœªæŸ¥è¯¢åˆ°å¯¹åº”报价详情
          </view>
          <view v-else>
            <u-cell-group :border="false">
              <u-cell title="报价单号"
                      :value="currentQuotation.quotationNo"></u-cell>
              <u-cell title="客户名称"
                      :value="currentQuotation.customer"></u-cell>
              <u-cell title="业务员"
                      :value="currentQuotation.salesperson"></u-cell>
              <u-cell title="报价日期"
                      :value="currentQuotation.quotationDate"></u-cell>
              <u-cell title="有效期至"
                      :value="currentQuotation.validDate"></u-cell>
              <u-cell title="付款方式"
                      :value="currentQuotation.paymentMethod"></u-cell>
              <u-cell title="报价总额">
                <template #value>
                  <text style="font-size: 32rpx; color: #e6a23c; font-weight: bold;">
                    Â¥{{ Number(currentQuotation.totalAmount ?? 0).toFixed(2) }}
                  </text>
                </template>
              </u-cell>
            </u-cell-group>
            <view style="margin-top: 20rpx; padding: 0 30rpx;">
              <view style="font-size: 28rpx; font-weight: bold; margin-bottom: 10rpx;">产品明细</view>
              <view v-for="(item, index) in (currentQuotation.products || [])"
                    :key="index"
                    style="background: #f8f8f8; border-radius: 8rpx; padding: 20rpx; margin-bottom: 10rpx;">
                <view style="display: flex; justify-content: space-between;">
                  <text style="font-weight: bold;">{{ item.product }}</text>
                  <text style="color: #e6a23c;">Â¥{{ Number(item.unitPrice ?? 0).toFixed(2) }}</text>
                </view>
                <view style="font-size: 24rpx; color: #666; margin-top: 10rpx;">
                  è§„æ ¼: {{ item.specification }} | å•位: {{ item.unit }}
                </view>
              </view>
            </view>
            <view v-if="currentQuotation.remark"
                  style="margin-top: 20rpx; padding: 0 30rpx;">
              <view style="font-size: 28rpx; font-weight: bold;">备注</view>
              <view style="font-size: 26rpx; color: #666; margin-top: 10rpx;">{{ currentQuotation.remark }}</view>
            </view>
          </view>
        </u-skeleton>
      </view>
      <!-- é‡‡è´­å®¡æ‰¹è¯¦æƒ… -->
      <view v-if="isPurchaseApproval"
            style="margin: 20rpx 0;">
        <u-divider text="采购详情"
                   text-size="28rpx"
                   color="#2979ff"></u-divider>
        <u-skeleton :loading="purchaseLoading"
                    rows="3"
                    animated>
          <view v-if="!currentPurchase || !currentPurchase.purchaseContractNumber"
                style="padding: 40rpx; text-align: center; color: #999;">
            æœªæŸ¥è¯¢åˆ°å¯¹åº”采购详情
          </view>
          <view v-else>
            <u-cell-group :border="false">
              <u-cell title="采购合同号"
                      :value="currentPurchase.purchaseContractNumber"></u-cell>
              <u-cell title="供应商名称"
                      :value="currentPurchase.supplierName"></u-cell>
              <u-cell title="项目名称"
                      :value="currentPurchase.projectName"></u-cell>
              <u-cell title="销售合同号"
                      :value="currentPurchase.salesContractNo"></u-cell>
              <u-cell title="签订日期"
                      :value="currentPurchase.executionDate"></u-cell>
              <u-cell title="录入日期"
                      :value="currentPurchase.entryDate"></u-cell>
              <u-cell title="付款方式"
                      :value="currentPurchase.paymentMethod"></u-cell>
              <u-cell title="合同金额">
                <template #value>
                  <text style="font-size: 32rpx; color: #e6a23c; font-weight: bold;">
                    Â¥{{ Number(currentPurchase.contractAmount ?? 0).toFixed(2) }}
                  </text>
                </template>
              </u-cell>
            </u-cell-group>
            <view style="margin-top: 20rpx; padding: 0 30rpx;">
              <view style="font-size: 28rpx; font-weight: bold; margin-bottom: 10rpx;">产品明细</view>
              <view v-for="(item, index) in (currentPurchase.productData || [])"
                    :key="index"
                    style="background: #f8f8f8; border-radius: 8rpx; padding: 20rpx; margin-bottom: 10rpx;">
                <view style="display: flex; justify-content: space-between;">
                  <text style="font-weight: bold;">{{ item.productCategory }}</text>
                  <text style="color: #e6a23c;">Â¥{{ Number(item.taxInclusiveTotalPrice ?? 0).toFixed(2) }}</text>
                </view>
                <view style="font-size: 24rpx; color: #666; margin-top: 10rpx;">
                  è§„æ ¼: {{ item.specificationModel }} | æ•°é‡: {{ item.quantity }} {{ item.unit }}
                </view>
                <view style="font-size: 24rpx; color: #999; margin-top: 4rpx;">
                  å«ç¨Žå•ä»·: Â¥{{ Number(item.taxInclusiveUnitPrice ?? 0).toFixed(2) }}
                </view>
              </view>
            </view>
          </view>
        </u-skeleton>
      </view>
      <!-- å‘货审批详情 -->
      <view v-if="isDeliveryApproval"
            style="margin: 20rpx 0;">
        <u-divider text="发货详情"
                   text-size="28rpx"
                   color="#2979ff"></u-divider>
        <u-skeleton :loading="deliveryLoading"
                    rows="3"
                    animated>
          <view v-if="!currentDelivery || !currentDelivery.shippingInfo"
                style="padding: 40rpx; text-align: center; color: #999;">
            æœªæŸ¥è¯¢åˆ°å¯¹åº”发货详情
          </view>
          <view v-else>
            <u-cell-group :border="false">
              <u-cell title="销售订单"
                      :value="currentDelivery.shippingInfo.salesContractNo || '--'"></u-cell>
              <u-cell title="发货订单号"
                      :value="currentDelivery.shippingInfo.shippingNo || '--'"></u-cell>
              <u-cell title="客户名称"
                      :value="currentDelivery.shippingInfo.customerName || '--'"></u-cell>
              <u-cell title="发货类型"
                      :value="currentDelivery.shippingInfo.type || '--'"></u-cell>
              <u-cell title="发货日期"
                      :value="currentDelivery.shippingInfo.shippingDate || '--'"></u-cell>
              <u-cell title="审核状态"
                      :value="currentDelivery.shippingInfo.status || '--'"></u-cell>
              <u-cell title="发货车牌号"
                      :value="currentDelivery.shippingInfo.shippingCarNumber || '--'"></u-cell>
              <u-cell title="快递公司"
                      :value="currentDelivery.shippingInfo.expressCompany || '--'"></u-cell>
              <u-cell title="快递单号"
                      :value="currentDelivery.shippingInfo.expressNumber || '--'"></u-cell>
            </u-cell-group>
            <view style="margin-top: 20rpx; padding: 0 30rpx;">
              <view style="font-size: 28rpx; font-weight: bold; margin-bottom: 10rpx;">产品明细</view>
              <view v-for="(item, index) in deliveryProductList"
                    :key="index"
                    style="background: #f8f8f8; border-radius: 8rpx; padding: 20rpx; margin-bottom: 10rpx;">
                <view style="display: flex; justify-content: space-between;">
                  <text style="font-weight: bold;">{{ item.productName }}</text>
                  <text style="color: #2979ff;">数量: {{ item.deliveryQuantity }}</text>
                </view>
                <view style="font-size: 24rpx; color: #666; margin-top: 10rpx;">
                  è§„æ ¼: {{ item.specificationModel }}
                </view>
                <view v-if="item.batchNo"
                      style="font-size: 24rpx; color: #999; margin-top: 4rpx;">
                  æ‰¹å·: {{ item.batchNo }}
                </view>
              </view>
            </view>
            <view v-if="currentDelivery.shippingInfo.storageBlobVOs && currentDelivery.shippingInfo.storageBlobVOs.length"
                  style="margin-top: 20rpx; padding: 0 30rpx;">
              <view style="font-size: 28rpx; font-weight: bold; margin-bottom: 10rpx;">发货图片</view>
              <CommonUpload :model-value="currentDelivery.shippingInfo.storageBlobVOs"
                            disabled />
            </view>
          </view>
        </u-skeleton>
      </view>
      <u-form-item v-if="operationType !== 'detail'"
                   label="图片附件"
                   prop="storageBlobDTOS"
                   border-bottom>
        <CommonUpload v-model="form.storageBlobDTOS" />
      </u-form-item>
    </u-form>
    <!-- é€‰æ‹©å™¨å¼¹çª— -->
    <up-action-sheet :show="showPicker"
                     :actions="productOptions"
                     title="选择部门"
                     @select="onConfirm"
                     @close="showPicker = false" />
    <!-- æ—¥æœŸé€‰æ‹©å™¨ -->
    <up-popup :show="showDate"
              mode="bottom"
              @close="showDate = false">
      <up-datetime-picker :show="true"
                          v-model="currentDate"
                          @confirm="onDateConfirm"
                          @cancel="showDate = false"
                          mode="date" />
    </up-popup>
    <!-- è¯·å‡å¼€å§‹æ—¶é—´é€‰æ‹©å™¨ -->
    <up-popup :show="showStartDate"
              mode="bottom"
              @close="showStartDate = false">
      <up-datetime-picker :show="true"
                          v-model="startDateValue"
                          @confirm="onStartDateConfirm"
                          @cancel="showStartDate = false"
                          mode="date" />
    </up-popup>
    <!-- è¯·å‡ç»“束时间选择器 -->
    <up-popup :show="showEndDate"
              mode="bottom"
              @close="showEndDate = false">
      <up-datetime-picker :show="true"
                          v-model="endDateValue"
                          @confirm="onEndDateConfirm"
                          @cancel="showEndDate = false"
                          mode="date" />
    </up-popup>
    <!-- å®¡æ ¸æµç¨‹åŒºåŸŸ -->
    <view class="approval-process">
      <view class="approval-header">
        <text class="approval-title">审核流程</text>
        <text class="approval-desc">每个步骤只能选择一个审批人</text>
      </view>
      <view class="approval-steps">
        <view v-for="(step, stepIndex) in approverNodes"
              :key="stepIndex"
              class="approval-step">
          <view class="step-dot"></view>
          <view class="step-title">
            <text>审批人</text>
          </view>
          <view class="approver-container">
            <view v-if="step.nickName"
                  class="approver-item">
              <view class="approver-avatar">
                <text class="avatar-text">{{ step.nickName.charAt(0) }}</text>
                <view class="status-dot"></view>
              </view>
              <view class="approver-info">
                <text class="approver-name">{{ step.nickName }}</text>
              </view>
              <view class="delete-approver-btn"
                    @click="removeApprover(stepIndex)">×</view>
            </view>
            <view v-else
                  class="add-approver-btn"
                  @click="addApprover(stepIndex)">
              <view class="add-circle">+</view>
              <text class="add-label">选择审批人</text>
            </view>
          </view>
          <view class="step-line"
                v-if="stepIndex < approverNodes.length - 1"></view>
          <view class="delete-step-btn"
                v-if="approverNodes.length > 1"
                @click="removeApprovalStep(stepIndex)">删除节点</view>
        </view>
      </view>
      <view class="add-step-btn">
        <u-button icon="plus"
                  plain
                  type="primary"
                  style="width: 100%"
                  @click="addApprovalStep">新增节点</u-button>
      </view>
    </view>
    <template v-if="operationType !== 'detail'">
      <up-action-sheet :show="showPicker"
                       :actions="productOptions"
                       title="选择部门"
                       @select="onConfirm"
                       @close="showPicker = false" />
      <!-- æ—¥æœŸé€‰æ‹©å™¨ -->
      <up-popup :show="showDate"
                mode="bottom"
                @close="showDate = false">
        <up-datetime-picker :show="true"
                            v-model="currentDate"
                            @confirm="onDateConfirm"
                            @cancel="showDate = false"
                            mode="date" />
      </up-popup>
      <!-- è¯·å‡å¼€å§‹æ—¶é—´é€‰æ‹©å™¨ -->
      <up-popup :show="showStartDate"
                mode="bottom"
                @close="showStartDate = false">
        <up-datetime-picker :show="true"
                            v-model="startDateValue"
                            @confirm="onStartDateConfirm"
                            @cancel="showStartDate = false"
                            mode="date" />
      </up-popup>
      <!-- è¯·å‡ç»“束时间选择器 -->
      <up-popup :show="showEndDate"
                mode="bottom"
                @close="showEndDate = false">
        <up-datetime-picker :show="true"
                            v-model="endDateValue"
                            @confirm="onEndDateConfirm"
                            @cancel="showEndDate = false"
                            mode="date" />
      </up-popup>
    </template>
    <!-- åº•部按钮 -->
    <view class="footer-btns">
    <view class="footer-btns"
          v-if="operationType !== 'detail'">
      <u-button class="cancel-btn"
                @click="goBack">取消</u-button>
      <u-button class="save-btn"
@@ -201,8 +338,17 @@
</template>
<script setup>
  import { ref, onMounted, onUnmounted, reactive, toRefs } from "vue";
  import {
    ref,
    onMounted,
    onUnmounted,
    reactive,
    toRefs,
    computed,
    watch,
  } from "vue";
  import PageHeader from "@/components/PageHeader.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import useUserStore from "@/store/modules/user";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import {
@@ -210,14 +356,16 @@
    approveProcessGetInfo,
    approveProcessAdd,
    approveProcessUpdate,
    getDeliveryDetailByShippingNo,
  } from "@/api/collaborativeApproval/approvalProcess";
  import { getQuotationList } from "@/api/salesManagement/salesQuotation";
  import { getPurchaseByCode } from "@/api/procurementManagement/procurementLedger";
  const showToast = message => {
    uni.showToast({
      title: message,
      icon: "none",
    });
  };
  import { userListNoPageByTenantId } from "@/api/system/user";
  const data = reactive({
    form: {
@@ -229,8 +377,7 @@
      approveDeptId: "",
      approveReason: "",
      checkResult: "",
      tempFileIds: [],
      approverList: [], // æ–°å¢žå­—段,存储所有节点的审批人id
      storageBlobDTOS: [],
      startDate: "",
      endDate: "",
      location: "",
@@ -258,8 +405,6 @@
  const productOptions = ref([]);
  const operationType = ref("");
  const currentApproveStatus = ref("");
  const approverNodes = ref([]);
  const userList = ref([]);
  const formRef = ref(null);
  const message = ref("");
  const showDate = ref(false);
@@ -270,6 +415,19 @@
  const endDateValue = ref(Date.now());
  const userStore = useUserStore();
  const approveType = ref(0);
  const isInitialLoading = ref(false);
  const quotationLoading = ref(false);
  const currentQuotation = ref({});
  const purchaseLoading = ref(false);
  const currentPurchase = ref({});
  const deliveryLoading = ref(false);
  const currentDelivery = ref({});
  const deliveryProductList = ref([]);
  const isQuotationApproval = computed(() => Number(approveType.value) === 6);
  const isPurchaseApproval = computed(() => Number(approveType.value) === 5);
  const isDeliveryApproval = computed(() => Number(approveType.value) === 7);
  const getProductOptions = () => {
    getDept().then(res => {
@@ -279,20 +437,133 @@
      }));
    });
  };
  const fileList = ref([]);
  let nextApproverId = 2;
  const getCurrentinfo = () => {
    userStore.getInfo().then(res => {
      form.value.approveDeptId = res.user.tenantId;
      console.log(res.user.tenantId, "res.user.tenantId");
    });
  };
  // æ˜¾ç¤ºæ—¥æœŸé€‰æ‹©å™¨
  const showDatePicker = () => {
    showDate.value = true;
  };
  // ç¡®è®¤æ—¥æœŸé€‰æ‹©
  const onDateConfirm = e => {
    form.value.approveTime = formatDateToYMD(e.value);
    currentDate.value = formatDateToYMD(e.value);
    showDate.value = false;
  };
  // æ˜¾ç¤ºè¯·å‡å¼€å§‹æ—¶é—´é€‰æ‹©å™¨
  const showStartDatePicker = () => {
    showStartDate.value = true;
  };
  // ç¡®è®¤è¯·å‡å¼€å§‹æ—¶é—´é€‰æ‹©
  const onStartDateConfirm = e => {
    form.value.startDate = formatDateToYMD(e.value);
    showStartDate.value = false;
  };
  const showEndDatePicker = () => {
    showEndDate.value = true;
  };
  // ç¡®è®¤è¯·å‡ç»“束时间选择
  const onEndDateConfirm = e => {
    form.value.endDate = formatDateToYMD(e.value);
    showEndDate.value = false;
  };
  const fetchDetailData = async row => {
    // æŠ¥ä»·å®¡æ‰¹
    if (isQuotationApproval.value) {
      const quotationNo = row?.approveReason;
      if (quotationNo) {
        quotationLoading.value = true;
        getQuotationList({ quotationNo })
          .then(res => {
            const records = res?.data?.records || [];
            currentQuotation.value = records[0] || {};
          })
          .finally(() => {
            quotationLoading.value = false;
          });
      }
    }
    // é‡‡è´­å®¡æ‰¹
    if (isPurchaseApproval.value) {
      const purchaseContractNumber = row?.approveReason;
      if (purchaseContractNumber) {
        purchaseLoading.value = true;
        getPurchaseByCode({ purchaseContractNumber })
          .then(res => {
            currentPurchase.value = res;
          })
          .catch(err => {
            console.error("查询采购详情失败:", err);
          })
          .finally(() => {
            purchaseLoading.value = false;
          });
      }
    }
    // å‘货审批
    if (isDeliveryApproval.value) {
      const deliveryNo = row?.approveReason;
      if (deliveryNo) {
        deliveryLoading.value = true;
        currentDelivery.value = {};
        deliveryProductList.value = [];
        getDeliveryDetailByShippingNo({ shippingNo: deliveryNo })
          .then(res => {
            const detailData = res?.data || res || {};
            currentDelivery.value = detailData;
            deliveryProductList.value =
              detailData.shippingProductDetailDtoList || [];
          })
          .catch(err => {
            console.error("查询发货详情失败:", err);
          })
          .finally(() => {
            deliveryLoading.value = false;
          });
      }
    }
  };
  // ç›‘听审批事由变化,如果是特定审批类型则尝试获取详情
  watch(
    () => form.value.approveReason,
    newVal => {
      if (isInitialLoading.value) return;
      if (
        newVal &&
        (isQuotationApproval.value ||
          isPurchaseApproval.value ||
          isDeliveryApproval.value)
      ) {
        // å»¶è¿Ÿä¸€ä¼šå†è¯·æ±‚,避免输入过程中频繁触发
        debounceFetchDetail();
      }
    }
  );
  let timer = null;
  const debounceFetchDetail = () => {
    if (timer) clearTimeout(timer);
    timer = setTimeout(() => {
      fetchDetailData(form.value);
    }, 800);
  };
  onMounted(async () => {
    try {
      getProductOptions();
      userListNoPageByTenantId().then(res => {
        userList.value = res.data;
      });
      form.value.approveUser = userStore.id;
      form.value.approveUserName = userStore.nickName;
      form.value.approveTime = getCurrentDate();
@@ -302,57 +573,39 @@
      approveType.value = uni.getStorageSync("approveType") || 0;
      // å¦‚果是编辑模式,从本地存储获取数据
      if (operationType.value === "edit") {
      if (operationType.value === "edit" || operationType.value === "detail") {
        const storedData = uni.getStorageSync("invoiceLedgerEditRow");
        if (storedData) {
          const row = JSON.parse(storedData);
          fileList.value = row.commonFileList || [];
          form.value.tempFileIds = fileList.value.map(file => file.id);
          currentApproveStatus.value = row.approveStatus;
          approveProcessGetInfo({ id: row.approveId, approveReason: "1" }).then(
            res => {
          isInitialLoading.value = true;
          approveProcessGetInfo({ id: row.approveId, approveReason: "1" })
            .then(res => {
              form.value = { ...res.data };
              // åæ˜¾å®¡æ‰¹äºº
              if (res.data && res.data.approveUserIds) {
                const userIds = res.data.approveUserIds.split(",");
                approverNodes.value = userIds.map((userId, idx) => {
                  const userIdNum = parseInt(userId.trim());
                  // ä»ŽuserList中找到对应的用户信息
                  const userInfo = userList.value.find(
                    user => user.userId === userIdNum
                  );
                  return {
                    id: idx + 1,
                    userId: userIdNum,
                    nickName: userInfo ? userInfo.nickName : null,
                  };
                });
                nextApproverId = userIds.length + 1;
              } else {
                // æ–°å¢žæ¨¡å¼ï¼Œåˆå§‹åŒ–一个空的审批节点
                approverNodes.value = [{ id: 1, userId: null, nickName: null }];
                nextApproverId = 2;
              // è®¾ç½®å›¾ç‰‡åˆ—表显示
              const fileData =
                res.data.storageBlobVOS || res.data.commonFileList || [];
              if (fileData.length > 0) {
                form.value.storageBlobDTOS = fileData;
              }
            }
          );
              // èŽ·å–é¢å¤–è¯¦æƒ…
              fetchDetailData(res.data);
            })
            .finally(() => {
              // å»¶è¿Ÿä¸€ä¼šé‡ç½®ï¼Œç¡®ä¿ watch ä¸ä¼šè¢«è§¦å‘
              setTimeout(() => {
                isInitialLoading.value = false;
              }, 100);
            });
        }
      } else {
        // æ–°å¢žæ¨¡å¼ï¼Œåˆå§‹åŒ–一个空的审批节点
        approverNodes.value = [{ id: 1, userId: null }];
      }
      // ç›‘听联系人选择事件
      uni.$on("selectContact", handleSelectContact);
    } catch (error) {
      console.error("获取部门数据失败:", error);
      console.error("获取数据失败:", error);
    }
  });
  onUnmounted(() => {
    // ç§»é™¤äº‹ä»¶ç›‘听
    uni.$off("selectContact", handleSelectContact);
  });
  onUnmounted(() => {});
  const onConfirm = item => {
    // è®¾ç½®é€‰ä¸­çš„部门
@@ -375,13 +628,6 @@
  };
  const submitForm = () => {
    // æ£€æŸ¥æ¯ä¸ªå®¡æ‰¹æ­¥éª¤æ˜¯å¦éƒ½æœ‰å®¡æ‰¹äºº
    const hasEmptyStep = approverNodes.value.some(step => !step.nickName);
    if (hasEmptyStep) {
      showToast("请为每个审批步骤选择审批人");
      return;
    }
    // æ‰‹åŠ¨æ£€æŸ¥å¿…å¡«å­—æ®µï¼Œé˜²æ­¢å› æ•°æ®ç±»åž‹é—®é¢˜å¯¼è‡´çš„æ ¡éªŒå¤±è´¥
    if (!form.value.approveReason || !form.value.approveReason.trim()) {
      showToast("请输入申请事由");
@@ -406,26 +652,8 @@
      .then(valid => {
        if (valid) {
          // è¡¨å•校验通过,可以提交数据
          // æ”¶é›†æ‰€æœ‰èŠ‚ç‚¹çš„å®¡æ‰¹äººid
          console.log("approverNodes---", approverNodes.value);
          form.value.approveUserIds = approverNodes.value
            .map(node => node.userId)
            .join(",");
          form.value.approveType = approveType.value;
          form.value.approveDeptId = Number(form.value.approveDeptId);
          // const submitForm = {
          //   approveDeptId: form.value.approveDeptId,
          //   approveDeptName: form.value.approveDeptName,
          //   approveReason: form.value.approveReason,
          //   approveTime: form.value.approveTime,
          //   approveType: form.value.approveType,
          //   approveUser: form.value.approveUser,
          //   approveUserIds: form.value.approveUserIds,
          //   endDate: form.value.endDate,
          //   startDate: form.value.startDate,
          // };
          // console.log("form.value---", form.value);
          // console.log("submitForm", submitForm);
          if (operationType.value === "add" || currentApproveStatus.value == 3) {
            approveProcessAdd(form.value).then(res => {
@@ -461,77 +689,6 @@
      });
  };
  // å¤„理联系人选择结果
  const handleSelectContact = data => {
    const { stepIndex, contact } = data;
    // å°†é€‰ä¸­çš„联系人设置为对应审批步骤的审批人
    approverNodes.value[stepIndex].userId = contact.userId;
    approverNodes.value[stepIndex].nickName = contact.nickName;
  };
  const addApprover = stepIndex => {
    // è·³è½¬åˆ°è”系人选择页面
    uni.setStorageSync("stepIndex", stepIndex);
    uni.navigateTo({
      url: "/pages/cooperativeOffice/collaborativeApproval/contactSelect",
    });
  };
  const addApprovalStep = () => {
    // æ·»åŠ æ–°çš„å®¡æ‰¹æ­¥éª¤
    approverNodes.value.push({ userId: null, nickName: null });
  };
  const removeApprover = stepIndex => {
    // ç§»é™¤å®¡æ‰¹äºº
    approverNodes.value[stepIndex].userId = null;
    approverNodes.value[stepIndex].nickName = null;
  };
  const removeApprovalStep = stepIndex => {
    // ç¡®ä¿è‡³å°‘保留一个审批步骤
    if (approverNodes.value.length > 1) {
      approverNodes.value.splice(stepIndex, 1);
    } else {
      uni.showToast({
        title: "至少需要一个审批步骤",
        icon: "none",
      });
    }
  };
  // æ˜¾ç¤ºæ—¥æœŸé€‰æ‹©å™¨
  const showDatePicker = () => {
    showDate.value = true;
  };
  // ç¡®è®¤æ—¥æœŸé€‰æ‹©
  const onDateConfirm = e => {
    form.value.approveTime = formatDateToYMD(e.value);
    currentDate.value = formatDateToYMD(e.value);
    showDate.value = false;
  };
  // æ˜¾ç¤ºè¯·å‡å¼€å§‹æ—¶é—´é€‰æ‹©å™¨
  const showStartDatePicker = () => {
    showStartDate.value = true;
  };
  // ç¡®è®¤è¯·å‡å¼€å§‹æ—¶é—´é€‰æ‹©
  const onStartDateConfirm = e => {
    form.value.startDate = formatDateToYMD(e.value);
    showStartDate.value = false;
  };
  const showEndDatePicker = () => {
    showEndDate.value = true;
  };
  // ç¡®è®¤è¯·å‡ç»“束时间选择
  const onEndDateConfirm = e => {
    form.value.endDate = formatDateToYMD(e.value);
    showEndDate.value = false;
  };
  // èŽ·å–å½“å‰æ—¥æœŸå¹¶æ ¼å¼åŒ–ä¸º YYYY-MM-DD
  function getCurrentDate() {
    const today = new Date();
@@ -544,238 +701,8 @@
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  .approval-process {
    background: #fff;
    margin: 16px;
    border-radius: 16px;
    padding: 16px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04);
  }
  .approval-header {
    margin-bottom: 16px;
  }
  .approval-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
    display: block;
    margin-bottom: 4px;
  }
  .approval-desc {
    font-size: 12px;
    color: #999;
  }
  /* æ ·å¼å¢žå¼ºä¸ºâ€œç®€æ´å°åœ†åœˆé£Žæ ¼â€ */
  .approval-steps {
    padding-left: 22px;
    position: relative;
    &::before {
      content: "";
      position: absolute;
      left: 11px;
      top: 40px;
      bottom: 40px;
      width: 2px;
      background: linear-gradient(
        to bottom,
        #e6f7ff 0%,
        #bae7ff 50%,
        #91d5ff 100%
      );
      border-radius: 1px;
    }
  }
  .approval-step {
    position: relative;
    margin-bottom: 24px;
    &::before {
      content: "";
      position: absolute;
      left: -18px;
      top: 14px; // ä»Ž 8px è°ƒæ•´ä¸º 14px,与文字中心对齐
      width: 12px;
      height: 12px;
      background: #fff;
      border: 3px solid #006cfb;
      border-radius: 50%;
      z-index: 2;
      box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
    }
  }
  .step-title {
    top: 12px;
    margin-bottom: 12px;
    position: relative;
    margin-left: 6px;
  }
  .step-title text {
    font-size: 14px;
    color: #666;
    background: #f0f0f0;
    padding: 4px 12px;
    border-radius: 12px;
    position: relative;
    line-height: 1.4; // ç¡®ä¿æ–‡å­—行高一致
  }
  .approver-item {
    display: flex;
    align-items: center;
    background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
    border-radius: 16px;
    padding: 16px;
    gap: 12px;
    position: relative;
    border: 1px solid #e6f7ff;
    box-shadow: 0 4px 12px rgba(0, 108, 251, 0.08);
    transition: all 0.3s ease;
  }
  .approver-avatar {
    width: 48px;
    height: 48px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    position: relative;
    box-shadow: 0 4px 12px rgba(102, 126, 234, 0.3);
  }
  .avatar-text {
    color: #fff;
    font-size: 18px;
    font-weight: 600;
    text-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
  }
  .approver-info {
    flex: 1;
    position: relative;
  }
  .approver-name {
    display: block;
    font-size: 16px;
    color: #333;
    font-weight: 500;
    position: relative;
  }
  .approver-dept {
    font-size: 12px;
    color: #999;
    background: rgba(0, 108, 251, 0.05);
    padding: 2px 8px;
    border-radius: 8px;
    display: inline-block;
    position: relative;
    &::before {
      content: "";
      position: absolute;
      left: 4px;
      top: 50%;
      transform: translateY(-50%);
      width: 2px;
      height: 2px;
      background: #006cfb;
      border-radius: 50%;
    }
  }
  .delete-approver-btn {
    font-size: 16px;
    color: #ff4d4f;
    background: linear-gradient(
      135deg,
      rgba(255, 77, 79, 0.1) 0%,
      rgba(255, 77, 79, 0.05) 100%
    );
    width: 28px;
    height: 28px;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    transition: all 0.3s ease;
    position: relative;
  }
  .add-approver-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    background: linear-gradient(135deg, #f0f8ff 0%, #e6f7ff 100%);
    border: 2px dashed #006cfb;
    border-radius: 16px;
    padding: 20px;
    color: #006cfb;
    font-size: 14px;
    position: relative;
    transition: all 0.3s ease;
    &::before {
      content: "";
      position: absolute;
      left: 50%;
      top: 50%;
      transform: translate(-50%, -50%);
      width: 32px;
      height: 32px;
      border: 2px solid #006cfb;
      border-radius: 50%;
      opacity: 0;
      transition: all 0.3s ease;
    }
  }
  .delete-step-btn {
    color: #ff4d4f;
    font-size: 12px;
    background: linear-gradient(
      135deg,
      rgba(255, 77, 79, 0.1) 0%,
      rgba(255, 77, 79, 0.05) 100%
    );
    padding: 6px 12px;
    border-radius: 12px;
    display: inline-block;
    position: relative;
    transition: all 0.3s ease;
    &::before {
      content: "";
      position: absolute;
      left: 6px;
      top: 50%;
      transform: translateY(-50%);
      width: 4px;
      height: 4px;
      background: #ff4d4f;
      border-radius: 50%;
    }
  }
  .step-line {
    display: none; // éšè—åŽŸæ¥çš„çº¿æ¡ï¼Œä½¿ç”¨ä¼ªå…ƒç´ ä»£æ›¿
  }
  .add-step-btn {
    display: flex;
    align-items: center;
    justify-content: center;
  .account-detail {
    background-color: #fff;
  }
  .footer-btns {
    position: fixed;
@@ -809,121 +736,5 @@
    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
    box-shadow: 0 0.25rem 0.625rem 0 rgba(3, 88, 185, 0.2);
    border-radius: 2.5rem 2.5rem 2.5rem 2.5rem;
  }
  // åŠ¨ç”»å®šä¹‰
  @keyframes pulse {
    0% {
      transform: scale(1);
      opacity: 1;
    }
    50% {
      transform: scale(1.2);
      opacity: 0.7;
    }
    100% {
      transform: scale(1);
      opacity: 1;
    }
  }
  @keyframes rotate {
    0% {
      transform: rotate(0deg);
    }
    100% {
      transform: rotate(360deg);
    }
  }
  @keyframes ripple {
    0% {
      transform: translate(-50%, -50%) scale(0.8);
      opacity: 1;
    }
    100% {
      transform: translate(-50%, -50%) scale(1.6);
      opacity: 0;
    }
  }
  /* å¦‚果已有 .step-line,这里更精准定位到左侧与小圆点对齐 */
  .step-line {
    position: absolute;
    left: 4px;
    top: 48px;
    width: 2px;
    height: calc(100% - 48px);
    background: #e5e7eb;
  }
  .approver-container {
    display: flex;
    align-items: center;
    background: linear-gradient(135deg, #f8f9fa 0%, #ffffff 100%);
    border-radius: 16px;
    gap: 12px;
    padding: 10px 0;
    background: transparent;
    border: none;
    box-shadow: none;
  }
  .approver-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 8px 10px;
    background: transparent;
    border: none;
    box-shadow: none;
    border-radius: 0;
  }
  .approver-avatar {
    position: relative;
    width: 40px;
    height: 40px;
    border-radius: 50%;
    background: #f3f4f6;
    border: 2px solid #e5e7eb;
    display: flex;
    align-items: center;
    justify-content: center;
    animation: none; /* ç¦ç”¨æ—‹è½¬ç­‰åŠ¨ç”»ï¼Œå›žå½’ç®€æ´ */
  }
  .avatar-text {
    font-size: 14px;
    color: #374151;
    font-weight: 600;
  }
  .add-approver-btn {
    display: flex;
    align-items: center;
    gap: 8px;
    background: transparent;
    border: none;
    box-shadow: none;
    padding: 0;
  }
  .add-approver-btn .add-circle {
    width: 40px;
    height: 40px;
    border: 2px dashed #a0aec0;
    border-radius: 50%;
    color: #6b7280;
    display: flex;
    align-items: center;
    justify-content: center;
    font-size: 22px;
    line-height: 1;
  }
  .add-approver-btn .add-label {
    color: #3b82f6;
    font-size: 14px;
  }
</style>
src/pages/cooperativeOffice/collaborativeApproval/index.vue
@@ -97,13 +97,20 @@
              </view>
              <view class="detail-row">
                <view class="actions">
                  <!-- <u-button type="primary"
                  <u-button type="primary"
                            size="small"
                            class="action-btn edit"
                            :disabled="item.approveStatus == 2 || item.approveStatus == 1 || item.approveStatus == 4 || item.approveStatus == 8"
                            v-if="!(item.approveStatus == 2 || item.approveStatus == 1 || item.approveStatus == 4 || item.approveStatus == 8 || item.approveType == 5 || item.approveType == 6 || item.approveType == 7)"
                            @click="handleItemClick(item)">
                    ç¼–辑
                  </u-button> -->
                  </u-button>
                  <u-button type="info"
                            v-if="item.approveType == 5 || item.approveType == 6 || item.approveType == 7"
                            size="small"
                            class="action-btn detail"
                            @click="handleDetailClick(item)">
                    è¯¦æƒ…
                  </u-button>
                  <u-button type="success"
                            size="small"
                            class="action-btn approve"
@@ -123,13 +130,13 @@
      <text>暂无审批数据</text>
    </view>
    <!-- æµ®åŠ¨æ“ä½œæŒ‰é’® -->
    <!-- <view class="fab-button"
    <view class="fab-button"
          v-if="props.approveType != 5 && props.approveType != 6 && props.approveType != 7"
          @click="handleAdd">
      <up-icon name="plus"
               size="24"
               color="#ffffff"></up-icon>
    </view> -->
    </view>
  </view>
</template>
@@ -262,6 +269,17 @@
    });
  };
  // æŸ¥çœ‹è¯¦æƒ…
  const handleDetailClick = item => {
    uni.setStorageSync("invoiceLedgerEditRow", JSON.stringify(item));
    uni.setStorageSync("operationType", "detail");
    uni.setStorageSync("approveId", item.approveId);
    uni.setStorageSync("approveType", props.approveType);
    uni.navigateTo({
      url: "/pages/cooperativeOffice/collaborativeApproval/detail",
    });
  };
  // æ·»åŠ æ–°è®°å½•
  const handleAdd = () => {
    uni.setStorageSync("operationType", "add");
src/pages/equipmentManagement/repair/add.vue
@@ -69,6 +69,20 @@
                   placeholder="请输入报修人"
                   clearable />
        </u-form-item>
        <u-form-item label="维修人"
                     prop="maintenanceName"
                     border-bottom>
          <u-input v-model="form.maintenanceName"
                   placeholder="请输入维修人"
                   clearable />
        </u-form-item>
        <u-form-item label="维修项目"
                     prop="machineryCategory"
                     border-bottom>
          <u-input v-model="form.machineryCategory"
                   placeholder="请输入维修项目"
                   clearable />
        </u-form-item>
        <u-form-item label="故障现象"
                     prop="remark"
                     required
@@ -79,6 +93,11 @@
                      clearable
                      count
                      maxlength="200" />
        </u-form-item>
        <u-form-item label="图片附件"
                     prop="storageBlobDTOs"
                     border-bottom>
          <CommonUpload v-model="form.storageBlobDTOs" />
        </u-form-item>
      </u-cell-group>
      <!-- æäº¤æŒ‰é’® -->
@@ -108,8 +127,9 @@
<script setup>
  import { ref, computed, onMounted, onUnmounted } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import { onShow, onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger";
  import {
    addRepair,
@@ -132,10 +152,18 @@
  // è¡¨å•引用
  const formRef = ref(null);
  const operationType = ref("add");
  const repairId = ref("");
  const loading = ref(false);
  const showDevice = ref(false);
  const showDate = ref(false);
  const pickerDateValue = ref(Date.now());
  onLoad(options => {
    if (options.id) {
      repairId.value = options.id;
    }
    getPageParams();
  });
  // è®¾å¤‡é€‰é¡¹
  const deviceOptions = ref([]);
@@ -169,7 +197,10 @@
    deviceModel: undefined, // è§„格型号
    repairTime: dayjs().format("YYYY-MM-DD"), // æŠ¥ä¿®æ—¥æœŸ
    repairName: undefined, // æŠ¥ä¿®äºº
    maintenanceName: undefined, // ç»´ä¿®äºº
    machineryCategory: undefined, // ç»´ä¿®é¡¹ç›®
    remark: undefined, // æ•…障现象
    storageBlobDTOs: [], // å›¾ç‰‡é™„ä»¶
  });
  // æŠ¥ä¿®çŠ¶æ€é€‰é¡¹
@@ -221,7 +252,10 @@
          form.value.deviceModel = data.deviceModel;
          form.value.repairTime = dayjs(data.repairTime).format("YYYY-MM-DD");
          form.value.repairName = data.repairName;
          form.value.maintenanceName = data.maintenanceName;
          form.value.machineryCategory = data.machineryCategory;
          form.value.remark = data.remark;
          form.value.storageBlobDTOs = data.storageBlobVOs || [];
          repairStatusText.value =
            repairStatusOptions.value.find(item => item.value == data.status)
              ?.name || "";
@@ -328,14 +362,12 @@
  };
  onShow(() => {
    // é¡µé¢æ˜¾ç¤ºæ—¶èŽ·å–å‚æ•°
    getPageParams();
    // é¡µé¢æ˜¾ç¤ºæ—¶é€»è¾‘
  });
  onMounted(() => {
    // é¡µé¢åŠ è½½æ—¶èŽ·å–è®¾å¤‡åˆ—è¡¨å’Œå‚æ•°
    // é¡µé¢åŠ è½½æ—¶èŽ·å–è®¾å¤‡åˆ—è¡¨
    loadDeviceName();
    getPageParams();
  });
  // ç»„件卸载时清理定时器
@@ -375,7 +407,6 @@
      // å‡†å¤‡æäº¤æ•°æ®
      const submitData = { ...form.value };
      const { code } = id
        ? await editRepair({ id: id, ...submitData })
        : await addRepair(submitData);
@@ -396,21 +427,15 @@
  // è¿”回上一页
  const goBack = () => {
    uni.removeStorageSync("repairId");
    uni.navigateBack();
  };
  // èŽ·å–é¡µé¢å‚æ•°
  const getPageParams = () => {
    // ä½¿ç”¨uni.getStorageSync获取id
    const id = uni.getStorageSync("repairId");
    // æ ¹æ®æ˜¯å¦æœ‰id参数来判断是新增还是编辑
    if (id) {
    if (repairId.value) {
      // ç¼–辑模式,获取详情
      loadForm(id);
      // å¯é€‰ï¼šèŽ·å–åŽæ¸…é™¤å­˜å‚¨çš„id,避免影响后续操作
      uni.removeStorageSync("repairId");
      loadForm(repairId.value);
    } else {
      // æ–°å¢žæ¨¡å¼
      loadForm();
@@ -419,9 +444,7 @@
  // èŽ·å–é¡µé¢ID
  const getPageId = () => {
    // ä½¿ç”¨uni.getStorageSync获取id
    const id = uni.getStorageSync("repairId");
    return id;
    return repairId.value;
  };
</script>
src/pages/equipmentManagement/repair/index.vue
@@ -58,12 +58,16 @@
              <text class="detail-value">{{ item.repairName || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">故障现象</text>
              <text class="detail-value">{{ item.remark || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">维修人</text>
              <text class="detail-value">{{ item.maintenanceName || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">维修项目</text>
              <text class="detail-value">{{ item.machineryCategory || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">故障现象</text>
              <text class="detail-value">{{ item.remark || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">维修结果</text>
@@ -208,9 +212,9 @@
  const edit = id => {
    if (!id) return;
    // ä½¿ç”¨uni.setStorageSync存储id
    uni.setStorageSync("repairId", id);
    // uni.setStorageSync("repairId", id);
    uni.navigateTo({
      url: "/pages/equipmentManagement/repair/add",
      url: "/pages/equipmentManagement/repair/add?id=" + id,
    });
  };
src/pages/equipmentManagement/upkeep/add.vue
@@ -1,393 +1,444 @@
<template>
    <view class="upkeep-add">
        <!-- ä½¿ç”¨é€šç”¨é¡µé¢å¤´éƒ¨ç»„ä»¶ -->
        <PageHeader :title="operationType === 'edit' ? '编辑保养计划' : '新增保养计划'" @back="goBack" />
        <!-- è¡¨å•内容 -->
        <u-form ref="formRef" :model="form" :rules="formRules" label-width="110px">
            <!-- åŸºæœ¬ä¿¡æ¯ -->
            <u-form-item label="设备名称" prop="deviceNameText" required border-bottom>
                <u-input
                    v-model="form.deviceNameText"
                    placeholder="请选择设备名称"
                    readonly
                    @click="showDevicePicker"
                    clearable
                />
                <template #right>
                    <u-icon name="scan" @click="startScan" class="scan-icon" />
                </template>
            </u-form-item>
            <u-form-item label="规格型号" prop="deviceModel" border-bottom>
                <u-input
                    v-model="form.deviceModel"
                    placeholder="请输入规格型号"
                    readonly
                    clearable
                />
            </u-form-item>
            <u-form-item label="计划保养日期" prop="maintenancePlanTime" required border-bottom>
                <u-input
                    v-model="form.maintenancePlanTime"
                    placeholder="请选择计划保养日期"
                    readonly
                    @click="showDatePicker"
                    clearable
                />
                <template #right>
                    <u-icon name="arrow-right" @click="showDatePicker" />
                </template>
            </u-form-item>
            <!-- æäº¤æŒ‰é’® -->
            <view class="footer-btns">
                <u-button class="cancel-btn" @click="goBack">取消</u-button>
                <u-button class="save-btn" @click="sendForm" :loading="loading">保存</u-button>
            </view>
        </u-form>
        <!-- è®¾å¤‡é€‰æ‹©å™¨ -->
        <up-action-sheet
            :show="showDevice"
            :actions="deviceActions"
            title="选择设备"
            @select="onDeviceConfirm"
            @close="showDevice = false"
        />
<up-datetime-picker
            :show="showDate"
            v-model="pickerDateValue"
            @confirm="onDateConfirm"
            @cancel="showDate = false"
            mode="date"
        />
    </view>
  <view class="upkeep-add">
    <!-- ä½¿ç”¨é€šç”¨é¡µé¢å¤´éƒ¨ç»„ä»¶ -->
    <PageHeader :title="operationType === 'edit' ? '编辑保养计划' : '新增保养计划'"
                @back="goBack" />
    <!-- è¡¨å•内容 -->
    <u-form ref="formRef"
            :model="form"
            :rules="formRules"
            label-width="110px">
      <!-- åŸºæœ¬ä¿¡æ¯ -->
      <u-form-item label="设备名称"
                   prop="deviceNameText"
                   required
                   border-bottom>
        <u-input v-model="form.deviceNameText"
                 placeholder="请选择设备名称"
                 readonly
                 @click="showDevicePicker"
                 clearable />
        <template #right>
          <u-icon name="scan"
                  @click="startScan"
                  class="scan-icon" />
        </template>
      </u-form-item>
      <u-form-item label="规格型号"
                   prop="deviceModel"
                   border-bottom>
        <u-input v-model="form.deviceModel"
                 placeholder="请输入规格型号"
                 readonly
                 clearable />
      </u-form-item>
      <u-form-item label="计划保养日期"
                   prop="maintenancePlanTime"
                   required
                   border-bottom>
        <u-input v-model="form.maintenancePlanTime"
                 placeholder="请选择计划保养日期"
                 readonly
                 @click="showDatePicker"
                 clearable />
        <template #right>
          <u-icon name="arrow-right"
                  @click="showDatePicker" />
        </template>
      </u-form-item>
      <u-form-item label="保养人"
                   prop="maintenancePerson"
                   border-bottom>
        <u-input v-model="form.maintenancePerson"
                 placeholder="请输入保养人"
                 clearable />
      </u-form-item>
      <u-form-item label="保养项目"
                   prop="machineryCategory"
                   border-bottom>
        <u-input v-model="form.machineryCategory"
                 placeholder="请输入保养项目"
                 clearable />
      </u-form-item>
      <u-form-item label="附件图片"
                   prop="storageBlobDTOs"
                   border-bottom>
        <CommonUpload v-model="form.storageBlobDTOs" />
      </u-form-item>
      <!-- æäº¤æŒ‰é’® -->
      <view class="footer-btns">
        <u-button class="cancel-btn"
                  @click="goBack">取消</u-button>
        <u-button class="save-btn"
                  @click="sendForm"
                  :loading="loading">保存</u-button>
      </view>
    </u-form>
    <!-- è®¾å¤‡é€‰æ‹©å™¨ -->
    <up-action-sheet :show="showDevice"
                     :actions="deviceActions"
                     title="选择设备"
                     @select="onDeviceConfirm"
                     @close="showDevice = false" />
    <up-datetime-picker :show="showDate"
                        v-model="pickerDateValue"
                        @confirm="onDateConfirm"
                        @cancel="showDate = false"
                        mode="date" />
  </view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue';
import { onShow } from '@dcloudio/uni-app';
import PageHeader from '@/components/PageHeader.vue';
import { getDeviceLedger } from '@/api/equipmentManagement/ledger';
import { addUpkeep, editUpkeep, getUpkeepById } from '@/api/equipmentManagement/upkeep';
import dayjs from "dayjs";
import { formatDateToYMD } from '@/utils/ruoyi';
  import { ref, computed, onMounted, onUnmounted } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import { getDeviceLedger } from "@/api/equipmentManagement/ledger";
  import {
    addUpkeep,
    editUpkeep,
    getUpkeepById,
  } from "@/api/equipmentManagement/upkeep";
  import dayjs from "dayjs";
  import { formatDateToYMD } from "@/utils/ruoyi";
defineOptions({
    name: "设备保养计划表单",
});
const showToast = (message) => {
  uni.showToast({
    title: message,
    icon: 'none'
  })
}
  defineOptions({
    name: "设备保养计划表单",
  });
  const showToast = message => {
    uni.showToast({
      title: message,
      icon: "none",
    });
  };
// è¡¨å•引用
const formRef = ref(null);
const operationType = ref('add');
const loading = ref(false);
const showDevice = ref(false);
const showDate = ref(false);
const pickerDateValue = ref(Date.now());
const currentDate = ref([new Date().getFullYear(), new Date().getMonth() + 1, new Date().getDate()]);
  // è¡¨å•引用
  const formRef = ref(null);
  const operationType = ref("add");
  const loading = ref(false);
  const showDevice = ref(false);
  const showDate = ref(false);
  const pickerDateValue = ref(Date.now());
  const currentDate = ref([
    new Date().getFullYear(),
    new Date().getMonth() + 1,
    new Date().getDate(),
  ]);
// è®¾å¤‡é€‰é¡¹
const deviceOptions = ref([]);
const deviceNameText = ref('');
// è½¬æ¢ä¸º action-sheet éœ€è¦çš„æ ¼å¼
const deviceActions = computed(() => {
    return deviceOptions.value.map(item => ({
        text: item.deviceName,
        value: item.id,
        data: item
    }));
});
  // è®¾å¤‡é€‰é¡¹
  const deviceOptions = ref([]);
  const deviceNameText = ref("");
  // è½¬æ¢ä¸º action-sheet éœ€è¦çš„æ ¼å¼
  const deviceActions = computed(() => {
    return deviceOptions.value.map(item => ({
      text: item.deviceName,
      value: item.id,
      data: item,
    }));
  });
// æ‰«ç ç›¸å…³çŠ¶æ€
const isScanning = ref(false);
const scanTimer = ref(null);
  // æ‰«ç ç›¸å…³çŠ¶æ€
  const isScanning = ref(false);
  const scanTimer = ref(null);
// è¡¨å•验证规则
const formRules = {
    deviceLedgerId: [{ required: true, trigger: "change", message: "请选择设备名称" }],
    maintenancePlanTime: [{ required: true, trigger: "change", message: "请选择计划保养日期" }],
};
  // è¡¨å•验证规则
  const formRules = {
    deviceLedgerId: [
      { required: true, trigger: "change", message: "请选择设备名称" },
    ],
    maintenancePlanTime: [
      { required: true, trigger: "change", message: "请选择计划保养日期" },
    ],
  };
// ä½¿ç”¨ ref å£°æ˜Žè¡¨å•数据
const form = ref({
    deviceLedgerId: undefined, // è®¾å¤‡ID
    deviceModel: undefined, // è§„格型号
    maintenancePlanTime: dayjs().format("YYYY-MM-DD"), // è®¡åˆ’保养日期
});
  // ä½¿ç”¨ ref å£°æ˜Žè¡¨å•数据
  const form = ref({
    deviceLedgerId: undefined, // è®¾å¤‡ID
    deviceModel: undefined, // è§„格型号
    maintenancePlanTime: dayjs().format("YYYY-MM-DD"), // è®¡åˆ’保养日期
    maintenancePerson: undefined, // ä¿å…»äºº
    machineryCategory: undefined, // ä¿å…»é¡¹ç›®
    storageBlobDTOs: [], // é™„件图片
  });
// åŠ è½½è®¾å¤‡åˆ—è¡¨
const loadDeviceName = async () => {
    try {
        const { data } = await getDeviceLedger();
        deviceOptions.value = data || [];
    } catch (e) {
        showToast('获取设备列表失败');
    }
};
  // åŠ è½½è®¾å¤‡åˆ—è¡¨
  const loadDeviceName = async () => {
    try {
      const { data } = await getDeviceLedger();
      deviceOptions.value = data || [];
    } catch (e) {
      showToast("获取设备列表失败");
    }
  };
// åŠ è½½è¡¨å•æ•°æ®ï¼ˆç¼–è¾‘æ¨¡å¼ï¼‰
const loadForm = async (id) => {
    if (id) {
        operationType.value = 'edit';
        try {
            const { code, data } = await getUpkeepById(id);
            if (code == 200) {
                form.value.deviceLedgerId = data.deviceLedgerId;
                form.value.deviceModel = data.deviceModel;
                form.value.maintenancePlanTime = dayjs(data.maintenancePlanTime).format("YYYY-MM-DD");
                // è®¾ç½®è®¾å¤‡åç§°æ˜¾ç¤º
                const device = deviceOptions.value.find(item => item.id === data.deviceLedgerId);
                if (device) {
                    form.value.deviceNameText = device.deviceName;
                }
            }
        } catch (e) {
            showToast('获取详情失败');
        }
    } else {
        // æ–°å¢žæ¨¡å¼
        operationType.value = 'add';
    }
};
  // åŠ è½½è¡¨å•æ•°æ®ï¼ˆç¼–è¾‘æ¨¡å¼ï¼‰
  const loadForm = async id => {
    if (id) {
      operationType.value = "edit";
      try {
        const { code, data } = await getUpkeepById(id);
        if (code == 200) {
          form.value.deviceLedgerId = data.deviceLedgerId;
          form.value.deviceModel = data.deviceModel;
          form.value.maintenancePlanTime = dayjs(data.maintenancePlanTime).format(
            "YYYY-MM-DD"
          );
          form.value.maintenancePerson = data.maintenancePerson;
          form.value.machineryCategory = data.machineryCategory;
          form.value.storageBlobDTOs = data.storageBlobVOs || [];
          // è®¾ç½®è®¾å¤‡åç§°æ˜¾ç¤º
          const device = deviceOptions.value.find(
            item => item.id === data.deviceLedgerId
          );
          if (device) {
            form.value.deviceNameText = device.deviceName;
          }
        }
      } catch (e) {
        showToast("获取详情失败");
      }
    } else {
      // æ–°å¢žæ¨¡å¼
      operationType.value = "add";
    }
  };
// æ‰«æäºŒç»´ç åŠŸèƒ½
const startScan = () => {
    if (isScanning.value) {
        showToast('正在扫描中,请稍候...');
        return;
    }
    // è°ƒç”¨uni-app的扫码API
    uni.scanCode({
        scanType: ['qrCode', 'barCode'],
        success: (res) => {
            handleScanResult(res.result);
        },
        fail: (err) => {
            console.error('扫码失败:', err);
            showToast('扫码失败,请重试');
        }
    });
};
  // æ‰«æäºŒç»´ç åŠŸèƒ½
  const startScan = () => {
    if (isScanning.value) {
      showToast("正在扫描中,请稍候...");
      return;
    }
// å¤„理扫码结果
const handleScanResult = (scanResult) => {
    if (!scanResult) {
        showToast('扫码结果为空');
        return;
    }
    isScanning.value = true;
    showToast('扫码成功');
    // 3秒后处理扫码结果
    scanTimer.value = setTimeout(() => {
        processScanResult(scanResult);
        isScanning.value = false;
    }, 1000);
};
function getDeviceIdByRegExp(url) {
    // åŒ¹é…deviceId=后面的数字
    const reg = /deviceId=(\d+)/;
    const match = url.match(reg);
    // å¦‚果匹配到结果,返回数字类型,否则返回null
    return match ? Number(match[1]) : null;
}
// å¤„理扫码结果并匹配设备
const processScanResult = (scanResult) => {
    const deviceId = getDeviceIdByRegExp(scanResult);
    const matchedDevice = deviceOptions.value.find(item => item.id == deviceId);
    if (matchedDevice) {
        // æ‰¾åˆ°åŒ¹é…çš„设备,自动填充
        form.value.deviceLedgerId = matchedDevice.id;
        form.value.deviceNameText = matchedDevice.deviceName;
        form.value.deviceModel = matchedDevice.deviceModel;
        showToast('设备信息已自动填充');
    } else {
        // æœªæ‰¾åˆ°åŒ¹é…çš„设备
        showToast('未找到匹配的设备,请手动选择');
    }
};
    // è°ƒç”¨uni-app的扫码API
    uni.scanCode({
      scanType: ["qrCode", "barCode"],
      success: res => {
        handleScanResult(res.result);
      },
      fail: err => {
        console.error("扫码失败:", err);
        showToast("扫码失败,请重试");
      },
    });
  };
// æ˜¾ç¤ºè®¾å¤‡é€‰æ‹©å™¨
const showDevicePicker = () => {
    showDevice.value = true;
};
  // å¤„理扫码结果
  const handleScanResult = scanResult => {
    if (!scanResult) {
      showToast("扫码结果为空");
      return;
    }
// ç¡®è®¤è®¾å¤‡é€‰æ‹©
const onDeviceConfirm = (selected) => {
    // selected è¿”回的是选中项
    form.value.deviceLedgerId = selected.value;
        form.value.deviceNameText = selected.name;
    const selectedDevice = deviceOptions.value.find(item => item.id === selected.value);
    if (selectedDevice) {
        form.value.deviceModel = selectedDevice.deviceModel;
    }
    showDevice.value = false;
};
    isScanning.value = true;
    showToast("扫码成功");
// æ˜¾ç¤ºæ—¥æœŸé€‰æ‹©å™¨
const showDatePicker = () => {
    showDate.value = true;
};
    // 3秒后处理扫码结果
    scanTimer.value = setTimeout(() => {
      processScanResult(scanResult);
      isScanning.value = false;
    }, 1000);
  };
  function getDeviceIdByRegExp(url) {
    // åŒ¹é…deviceId=后面的数字
    const reg = /deviceId=(\d+)/;
    const match = url.match(reg);
    // å¦‚果匹配到结果,返回数字类型,否则返回null
    return match ? Number(match[1]) : null;
  }
  // å¤„理扫码结果并匹配设备
  const processScanResult = scanResult => {
    const deviceId = getDeviceIdByRegExp(scanResult);
    const matchedDevice = deviceOptions.value.find(item => item.id == deviceId);
// ç¡®è®¤æ—¥æœŸé€‰æ‹©
const onDateConfirm = (e) => {
    form.value.maintenancePlanTime = formatDateToYMD(e.value);
    showDate.value = false;
};
    if (matchedDevice) {
      // æ‰¾åˆ°åŒ¹é…çš„设备,自动填充
      form.value.deviceLedgerId = matchedDevice.id;
      form.value.deviceNameText = matchedDevice.deviceName;
      form.value.deviceModel = matchedDevice.deviceModel;
      showToast("设备信息已自动填充");
    } else {
      // æœªæ‰¾åˆ°åŒ¹é…çš„设备
      showToast("未找到匹配的设备,请手动选择");
    }
  };
onShow(() => {
    // é¡µé¢æ˜¾ç¤ºæ—¶èŽ·å–å‚æ•°
    getPageParams();
});
  // æ˜¾ç¤ºè®¾å¤‡é€‰æ‹©å™¨
  const showDevicePicker = () => {
    showDevice.value = true;
  };
onMounted(() => {
    // é¡µé¢åŠ è½½æ—¶èŽ·å–è®¾å¤‡åˆ—è¡¨å’Œå‚æ•°
    loadDeviceName();
    getPageParams();
});
  // ç¡®è®¤è®¾å¤‡é€‰æ‹©
  const onDeviceConfirm = selected => {
    // selected è¿”回的是选中项
    form.value.deviceLedgerId = selected.value;
    form.value.deviceNameText = selected.name;
    const selectedDevice = deviceOptions.value.find(
      item => item.id === selected.value
    );
    if (selectedDevice) {
      form.value.deviceModel = selectedDevice.deviceModel;
    }
    showDevice.value = false;
  };
// ç»„件卸载时清理定时器
onUnmounted(() => {
    if (scanTimer.value) {
        clearTimeout(scanTimer.value);
    }
});
  // æ˜¾ç¤ºæ—¥æœŸé€‰æ‹©å™¨
  const showDatePicker = () => {
    showDate.value = true;
  };
// æäº¤è¡¨å•
const sendForm = async () => {
    try {
        // æ‰‹åŠ¨éªŒè¯è¡¨å•
        const valid = await formRef.value.validate();
        if (!valid) return;
        loading.value = true;
        const id = getPageId();
        // å‡†å¤‡æäº¤æ•°æ®
        const submitData = { ...form.value };
        // ç¡®ä¿æ—¥æœŸæ ¼å¼æ­£ç¡®
        if (submitData.maintenancePlanTime && !submitData.maintenancePlanTime.includes(':')) {
            submitData.maintenancePlanTime = submitData.maintenancePlanTime + ' 00:00:00';
        }
        const { code } = id
            ? await editUpkeep({ id: id, ...submitData })
            : await addUpkeep(submitData);
        if (code == 200) {
            showToast(`${id ? "编辑" : "新增"}计划成功`);
            setTimeout(() => {
                uni.navigateBack();
            }, 1500);
        } else {
            loading.value = false;
        }
    } catch (e) {
        loading.value = false;
        showToast('表单验证失败');
    }
};
  // ç¡®è®¤æ—¥æœŸé€‰æ‹©
  const onDateConfirm = e => {
    form.value.maintenancePlanTime = formatDateToYMD(e.value);
    showDate.value = false;
  };
// è¿”回上一页
const goBack = () => {
    // æ¸…除存储的id
    uni.removeStorageSync('repairId');
    uni.navigateBack();
};
  onShow(() => {
    // é¡µé¢æ˜¾ç¤ºæ—¶èŽ·å–å‚æ•°
    getPageParams();
  });
// èŽ·å–é¡µé¢å‚æ•°
const getPageParams = () => {
    // ä»Žæœ¬åœ°å­˜å‚¨èŽ·å–id
    const id = uni.getStorageSync('repairId');
    // æ ¹æ®æ˜¯å¦æœ‰id参数来判断是新增还是编辑
    if (id) {
        // ç¼–辑模式,获取详情
        loadForm(id);
    } else {
        // æ–°å¢žæ¨¡å¼
        loadForm();
    }
};
  onMounted(() => {
    // é¡µé¢åŠ è½½æ—¶èŽ·å–è®¾å¤‡åˆ—è¡¨å’Œå‚æ•°
    loadDeviceName();
    getPageParams();
  });
// èŽ·å–é¡µé¢ID
const getPageId = () => {
    // ä»Žæœ¬åœ°å­˜å‚¨èŽ·å–id
    return uni.getStorageSync('repairId');
};
  // ç»„件卸载时清理定时器
  onUnmounted(() => {
    if (scanTimer.value) {
      clearTimeout(scanTimer.value);
    }
  });
  // æäº¤è¡¨å•
  const sendForm = async () => {
    try {
      // æ‰‹åŠ¨éªŒè¯è¡¨å•
      const valid = await formRef.value.validate();
      if (!valid) return;
      loading.value = true;
      const id = getPageId();
      // å‡†å¤‡æäº¤æ•°æ®
      const submitData = { ...form.value, status: 0 };
      // ç¡®ä¿æ—¥æœŸæ ¼å¼æ­£ç¡®
      if (
        submitData.maintenancePlanTime &&
        !submitData.maintenancePlanTime.includes(":")
      ) {
        submitData.maintenancePlanTime =
          submitData.maintenancePlanTime + " 00:00:00";
      }
      const { code } = id
        ? await editUpkeep({ id: id, ...submitData })
        : await addUpkeep(submitData);
      if (code == 200) {
        showToast(`${id ? "编辑" : "新增"}计划成功`);
        setTimeout(() => {
          uni.navigateBack();
        }, 1500);
      } else {
        loading.value = false;
      }
    } catch (e) {
      loading.value = false;
      showToast("表单验证失败");
    }
  };
  // è¿”回上一页
  const goBack = () => {
    // æ¸…除存储的id
    uni.removeStorageSync("repairId");
    uni.navigateBack();
  };
  // èŽ·å–é¡µé¢å‚æ•°
  const getPageParams = () => {
    // ä»Žæœ¬åœ°å­˜å‚¨èŽ·å–id
    const id = uni.getStorageSync("repairId");
    // æ ¹æ®æ˜¯å¦æœ‰id参数来判断是新增还是编辑
    if (id) {
      // ç¼–辑模式,获取详情
      loadForm(id);
    } else {
      // æ–°å¢žæ¨¡å¼
      loadForm();
    }
  };
  // èŽ·å–é¡µé¢ID
  const getPageId = () => {
    // ä»Žæœ¬åœ°å­˜å‚¨èŽ·å–id
    return uni.getStorageSync("repairId");
  };
</script>
<style scoped lang="scss">
@import '@/static/scss/form-common.scss';
.upkeep-add {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 5rem;
}
  @import "@/static/scss/form-common.scss";
  .upkeep-add {
    min-height: 100vh;
    background: #f8f9fa;
    padding-bottom: 5rem;
  }
.footer-btns {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    background: #fff;
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 0.75rem 0;
    box-shadow: 0 -0.125rem 0.5rem rgba(0,0,0,0.05);
    z-index: 1000;
}
  .footer-btns {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    background: #fff;
    display: flex;
    justify-content: space-around;
    align-items: center;
    padding: 0.75rem 0;
    box-shadow: 0 -0.125rem 0.5rem rgba(0, 0, 0, 0.05);
    z-index: 1000;
  }
.cancel-btn {
    font-weight: 400;
    font-size: 1rem;
    color: #FFFFFF;
    width: 6.375rem;
    background: #C7C9CC;
    box-shadow: 0 0.25rem 0.625rem 0 rgba(3,88,185,0.2);
    border-radius: 2.5rem 2.5rem 2.5rem 2.5rem;
}
  .cancel-btn {
    font-weight: 400;
    font-size: 1rem;
    color: #ffffff;
    width: 6.375rem;
    background: #c7c9cc;
    box-shadow: 0 0.25rem 0.625rem 0 rgba(3, 88, 185, 0.2);
    border-radius: 2.5rem 2.5rem 2.5rem 2.5rem;
  }
.save-btn {
    font-weight: 400;
    font-size: 1rem;
    color: #FFFFFF;
    width: 14rem;
    background: linear-gradient( 140deg, #00BAFF 0%, #006CFB 100%);
    box-shadow: 0 0.25rem 0.625rem 0 rgba(3,88,185,0.2);
    border-radius: 2.5rem 2.5rem 2.5rem 2.5rem;
}
  .save-btn {
    font-weight: 400;
    font-size: 1rem;
    color: #ffffff;
    width: 14rem;
    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
    box-shadow: 0 0.25rem 0.625rem 0 rgba(3, 88, 185, 0.2);
    border-radius: 2.5rem 2.5rem 2.5rem 2.5rem;
  }
// å“åº”式调整
@media (max-width: 768px) {
    .submit-section {
        padding: 12px;
    }
}
  // å“åº”式调整
  @media (max-width: 768px) {
    .submit-section {
      padding: 12px;
    }
  }
.tip-text {
    padding: 4px 16px 0 16px;
    font-size: 12px;
    color: #888;
}
  .tip-text {
    padding: 4px 16px 0 16px;
    font-size: 12px;
    color: #888;
  }
.scan-icon {
    color: #1989fa;
    font-size: 18px;
    margin-left: 8px;
    cursor: pointer;
}
  .scan-icon {
    color: #1989fa;
    font-size: 18px;
    margin-left: 8px;
    cursor: pointer;
  }
</style>
src/pages/equipmentManagement/upkeep/fileList.vue
@@ -8,7 +8,7 @@
      <view v-if="fileList.length > 0"
            class="file-list">
        <view v-for="(file, index) in fileList"
              :key="file.id || index"
              :key="file.storageAttachmentId || file.id || index"
              class="file-item">
          <!-- æ–‡ä»¶å›¾æ ‡ -->
          <!-- <view class="file-icon"
@@ -19,7 +19,7 @@
          </view> -->
          <!-- æ–‡ä»¶ä¿¡æ¯ -->
          <view class="file-info">
            <text class="file-name">{{ file.name }}</text>
            <text class="file-name">{{ file.originalFilename || file.name }}</text>
            <!-- <text class="file-meta">{{ formatFileSize(file.fileSize) }} Â· {{ file.uploadTime || file.createTime }}</text> -->
          </view>
          <!-- æ“ä½œæŒ‰é’® -->
@@ -65,15 +65,16 @@
<script setup>
  import { ref, onMounted } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import config from "@/config";
  import { getToken } from "@/utils/auth";
  // import { saveAs } from "file-saver";
  import {
    listMaintenanceTaskFiles,
    addMaintenanceTaskFile,
    delMaintenanceTaskFile,
  } from "@/api/equipmentManagement/upkeep";
    attachmentList,
    createAttachment,
    deleteAttachment,
  } from "@/api/basicData/storageAttachment";
  import { blobValidate } from "@/utils/ruoyi";
  // é™„件列表
@@ -214,21 +215,27 @@
              // const fileType = fileName.split(".").pop();
              // 3. æž„造保存文件信息的参数
              const saveData = {
                name: fileName,
                deviceMaintenanceId: upkeepId.value,
                url: res.data.tempPath || "",
                application: "file",
                recordType: recordType.value,
                recordId: upkeepId.value,
                storageBlobDTOs: [
                  {
                    name: fileName,
                    url:
                      res.data.url ||
                      res.data.previewURL ||
                      res.data.tempPath ||
                      "",
                    ...res.data,
                  },
                ],
              };
              console.log(saveData, "保存文件信息参数");
              // 4. è°ƒç”¨ addRuleFile æŽ¥å£ä¿å­˜æ–‡ä»¶ä¿¡æ¯
              addMaintenanceTaskFile(saveData)
              // 4. è°ƒç”¨ createAttachment æŽ¥å£ä¿å­˜æ–‡ä»¶ä¿¡æ¯
              createAttachment(saveData)
                .then(addRes => {
                  if (addRes.code === 200) {
                    // 5. æ·»åŠ åˆ°æ–‡ä»¶åˆ—è¡¨
                    const newFile = {
                      ...addRes.data,
                      uploadTime: new Date().toLocaleString(),
                    };
                    // fileList.value.push(newFile);
                    // 5. åˆ·æ–°åˆ—表
                    getFileList();
                    showToast("上传成功");
                  } else {
@@ -257,20 +264,32 @@
  };
  // ä¸‹è½½æ–‡ä»¶
  const downloadFile = file => {
    var url =
      config.baseUrl +
      "/common/download?fileName=" +
      encodeURIComponent(file.url) +
      "&delete=true";
    console.log(url, "url");
    let url = file.downloadURL || file.previewURL || file.url;
    if (!url) {
      showToast("文件地址无效");
      return;
    }
    // å¦‚果不是完整的URL,则拼接
    if (!url.startsWith("http")) {
      url =
        config.baseUrl +
        "/common/download?fileName=" +
        encodeURIComponent(url) +
        "&delete=true";
    }
    console.log(url, "下载地址");
    uni.showLoading({ title: "正在下载...", mask: true });
    uni
      .downloadFile({
        url: url,
        responseType: "blob",
        header: { Authorization: "Bearer " + getToken() },
      })
      .then(res => {
        uni.hideLoading();
        let osType = uni.getStorageSync("deviceInfo").osName;
        let filePath = res.tempFilePath;
        if (osType === "ios") {
@@ -280,7 +299,6 @@
            success: res => {},
            fail: err => {
              console.log("uni.openDocument--fail");
              reject(err);
            },
          });
        } else {
@@ -290,10 +308,8 @@
              uni.showToast({
                icon: "none",
                mask: true,
                title:
                  "文件已保存:Android/data/uni.UNI720216F/apps/__UNI__720216F/" +
                  fileRes.savedFilePath, //保存路径
                duration: 3000,
                title: "文件已下载并尝试打开",
                duration: 2000,
              });
              setTimeout(() => {
                //打开文档查看
@@ -305,24 +321,12 @@
            },
            fail: err => {
              console.log("uni.save--fail");
              reject(err);
            },
          });
        }
        // const isBlob = blobValidate(res.data);
        // if (isBlob) {
        //   const blob = new Blob([res.data], { type: "text/plain" });
        //   const url = URL.createObjectURL(blob);
        //   const downloadLink = document.getElementById("downloadLink");
        //   downloadLink.href = url;
        //   downloadLink.download = file.name;
        //   downloadLink.click();
        //   showToast("下载成功");
        // } else {
        //   showToast("下载失败");
        // }
      })
      .catch(err => {
        uni.hideLoading();
        console.error("下载失败:", err);
        showToast("下载失败");
      });
@@ -335,7 +339,7 @@
      content: `确定要删除附件 "${file.name}" å—?`,
      success: res => {
        if (res.confirm) {
          deleteFile(file.id, index);
          deleteFile(file.storageAttachmentId || file.id, index);
        }
      },
    });
@@ -348,7 +352,7 @@
      mask: true,
    });
    delMaintenanceTaskFile([fileId])
    deleteAttachment([fileId])
      .then(res => {
        uni.hideLoading();
        if (res.code === 200) {
@@ -372,37 +376,48 @@
      icon: "none",
    });
  };
  const rulesRegulationsManagementId = ref("");
  const upkeepId = ref("");
  const recordType = ref("");
  // é¡µé¢åŠ è½½æ—¶èŽ·å–å‚æ•°
  onLoad(options => {
    if (options.recordId) {
      upkeepId.value = options.recordId;
    } else {
      upkeepId.value = uni.getStorageSync("upkeepId");
    }
    if (options.recordType) {
      recordType.value = options.recordType;
    } else {
      recordType.value = "device_maintenance"; // é»˜è®¤å…¼å®¹
    }
    getFileList();
  });
  // é¡µé¢åŠ è½½æ—¶
  onMounted(() => {
    // ä»Ž API èŽ·å–é™„ä»¶åˆ—è¡¨
    // ä»Žæœ¬åœ°å­˜å‚¨èŽ·å– rulesRegulationsManagementId
    rulesRegulationsManagementId.value = uni.getStorageSync(
      "rulesRegulationsManagement"
    );
    upkeepId.value = uni.getStorageSync("upkeepId");
    getFileList();
    // getFileList(); // onLoad ä¸­å·²ç»è°ƒç”¨äº†
  });
  // èŽ·å–é™„ä»¶åˆ—è¡¨
  const getFileList = () => {
    if (!upkeepId.value) return;
    uni.showLoading({
      title: "加载中...",
      mask: true,
    });
    listMaintenanceTaskFiles({
      current: 1,
      size: 100,
      deviceMaintenanceId: upkeepId.value,
      rulesRegulationsManagementId: upkeepId.value,
    attachmentList({
      recordType: recordType.value,
      recordId: upkeepId.value,
    })
      .then(res => {
        uni.hideLoading();
        if (res.code === 200) {
          fileList.value = res.data.records || [];
          fileList.value = res.data || [];
        } else {
          showToast("获取附件列表失败");
        }
src/pages/equipmentManagement/upkeep/index.vue
@@ -63,6 +63,14 @@
              <text class="detail-value">{{ formatDateTime(item.createTime) || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">保养人</text>
              <text class="detail-value">{{ item.maintenancePerson || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">保养项目</text>
              <text class="detail-value">{{ item.machineryCategory || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">实际保养人</text>
              <text class="detail-value">{{ item.maintenanceActuallyName || '-' }}</text>
            </view>
@@ -72,7 +80,8 @@
            </view>
            <view class="detail-row">
              <text class="detail-label">保养结果</text>
              <view class="detail-value">
              <text class="detail-value">{{ item.maintenanceResult || '-' }}</text>
              <!-- <view class="detail-value">
                <u-tag v-if="item.maintenanceResult === 1"
                       type="success"
                       size="mini">
@@ -84,7 +93,7 @@
                  ç»´ä¿®
                </u-tag>
                <text v-if="item.maintenanceResult === undefined || item.maintenanceResult === null">-</text>
              </view>
              </view> -->
            </view>
          </view>
          <!-- æŒ‰é’®åŒºåŸŸ -->
@@ -198,10 +207,8 @@
  };
  // æ–°å¢žé™„ä»¶ - è·³è½¬åˆ°é™„件页面
  const addFile = id => {
    // ä½¿ç”¨æœ¬åœ°å­˜å‚¨ä¼ é€’id
    uni.setStorageSync("upkeepId", id);
    uni.navigateTo({
      url: "/pages/equipmentManagement/upkeep/fileList",
      url: `/pages/equipmentManagement/upkeep/fileList?recordId=${id}&recordType=device_maintenance`,
    });
  };
src/pages/equipmentManagement/upkeep/maintain.vue
@@ -100,81 +100,9 @@
      <!-- ä¸Šä¼ é™„ä»¶ -->
      <u-form-item v-if="form.status == '1'"
                   label="保养附件"
                   prop="storageBlobDTOs"
                   border-bottom>
        <view class="simple-upload-area">
          <view class="upload-buttons">
            <u-button type="primary"
                      @click="chooseMedia('image')"
                      :loading="uploading"
                      :disabled="uploadFiles.length >= uploadConfig.limit"
                      :customStyle="{ marginRight: '10px', flex: 1 }">
              <u-icon name="camera"
                      size="18"
                      color="#fff"
                      style="margin-right: 5px;"></u-icon>
              {{ uploading ? '上传中...' : '拍照' }}
            </u-button>
            <!-- <u-button type="success"
                      @click="chooseMedia('video')"
                      :loading="uploading"
                      :disabled="uploadFiles.length >= uploadConfig.limit"
                      :customStyle="{ flex: 1 }">
              <uni-icons type="videocam"
                         name="videocam"
                         size="18"
                         color="#fff"
                         style="margin-right: 5px;"></uni-icons>
              {{ uploading ? '上传中...' : '拍视频' }}
            </u-button> -->
          </view>
          <!-- ä¸Šä¼ è¿›åº¦ -->
          <view v-if="uploading"
                class="upload-progress">
            <u-line-progress :percentage="uploadProgress"
                             :showText="true"
                             activeColor="#409eff"></u-line-progress>
          </view>
          <!-- ä¸Šä¼ çš„æ–‡ä»¶åˆ—表 -->
          <view v-if="uploadFiles.length > 0"
                class="file-list">
            <view v-for="(file, index) in uploadFiles"
                  :key="index"
                  class="file-item">
              <view class="file-preview-container">
                <!-- {{formatFileUrl(file.url)}} -->
                <image v-if="file.type === 'image' || isImageFile(file)"
                       :src="formatFileUrl(file.url || file.tempFilePath || file.path || file.downloadUrl)"
                       class="file-preview"
                       mode="aspectFill" />
                <view v-else-if="file.type === 'video'"
                      class="video-preview">
                  <uni-icons type="videocam"
                             name="videocam"
                             size="18"
                             color="#fff"
                             style="margin-right: 5px;"></uni-icons>
                  <text class="video-text">视频</text>
                </view>
                <!-- åˆ é™¤æŒ‰é’® -->
                <view class="delete-btn"
                      @click="removeFile(index)">
                  <u-icon name="close"
                          size="12"
                          color="#fff"></u-icon>
                </view>
              </view>
              <view class="file-info">
                <text class="file-name">{{ file.bucketFilename || file.name || (file.type === 'image' ? '图片' : '视频')
                  }}</text>
                <text class="file-size">{{ formatFileSize(file.size) }}</text>
              </view>
            </view>
          </view>
          <view v-if="uploadFiles.length === 0"
                class="empty-state">
            <text>请选择要上传的保养图片</text>
          </view>
        </view>
        <CommonUpload v-model="form.storageBlobDTOs" />
      </u-form-item>
      <!-- æäº¤æŒ‰é’® -->
      <view class="footer-btns">
@@ -235,6 +163,7 @@
  import { ref, onMounted, reactive } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import CommonUpload from "@/components/CommonUpload.vue";
  import { addMaintenance } from "@/api/equipmentManagement/upkeep";
  import { getSparePartsList } from "@/api/equipmentManagement/repair";
  import useUserStore from "@/store/modules/user";
@@ -275,7 +204,6 @@
  const sparePartsQtyRaw = ref("");
  // æ–‡ä»¶ä¸Šä¼ ç›¸å…³
  const uploadFiles = ref([]);
  const uploading = ref(false);
  const uploadProgress = ref(0);
  const number = ref(0);
@@ -316,6 +244,7 @@
    maintenanceResult: undefined, // ä¿å…»ç»“æžœ
    maintenanceActuallyTime: dayjs().format("YYYY-MM-DD HH:mm:ss"), // å®žé™…保养日期(只显示日期)
    sparePartsIds: undefined, // è®¾å¤‡å¤‡ä»¶ID
    storageBlobDTOs: [], // ä¿å…»é™„ä»¶
  });
  // æ¸…除表单校验状态
@@ -330,6 +259,7 @@
      maintenanceResult: undefined,
      maintenanceActuallyTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
      sparePartsIds: [],
      storageBlobDTOs: [],
    };
    maintenancestatusText.value = "";
    selectedSpareParts.value = [];
@@ -374,7 +304,11 @@
      } else if (form.value.maintenanceResult === undefined) {
        isValid = false;
        errorMessage = "请选择保养结果";
      } else if (uploadFiles.value.length === 0 && form.value.status == "1") {
      } else if (
        (!form.value.storageBlobDTOs ||
          form.value.storageBlobDTOs.length === 0) &&
        form.value.status == "1"
      ) {
        isValid = false;
        errorMessage = "请上传保养照片";
      }
@@ -436,7 +370,6 @@
      const submitData = {
        ...form.value,
        imagesFile: form.value.status == "1" ? uploadFiles.value : [],
        sparePartsIds: spareIds.length ? spareIds.join(",") : "",
        sparePartsQty: spareIds.length
          ? spareIds.map(pid => sparePartQtyMap?.[pid] ?? 1).join(",")
@@ -605,7 +538,7 @@
    // é‡ç½®é€‰æ‹©çš„备件
    selectedSpareParts.value = [];
    // é‡ç½®ä¸Šä¼ çš„æ–‡ä»¶
    uploadFiles.value = [];
    form.value.storageBlobDTOs = [];
    uploading.value = false;
    uploadProgress.value = 0;
    maintenancestatusText.value = "";
@@ -655,8 +588,10 @@
      sparePartsIds.value = itemData.sparePartsIds;
      // å¡«å……附件数据
      if (itemData.files && itemData.files.length > 0) {
        uploadFiles.value = itemData.files.map(file => ({
      if (itemData.storageBlobVOs && itemData.storageBlobVOs.length > 0) {
        form.value.storageBlobDTOs = itemData.storageBlobVOs;
      } else if (itemData.files && itemData.files.length > 0) {
        form.value.storageBlobDTOs = itemData.files.map(file => ({
          id: file.id,
          name: file.name || file.bucketFilename || file.originalFilename,
          url: file.url || file.downloadUrl,
@@ -668,7 +603,7 @@
          size: file.size || file.byteSize,
        }));
      } else if (itemData.uploadFiles && itemData.uploadFiles.length > 0) {
        uploadFiles.value = itemData.uploadFiles.map(file => ({
        form.value.storageBlobDTOs = itemData.uploadFiles.map(file => ({
          id: file.id,
          name: file.name || file.bucketFilename || file.originalFilename,
          url: file.url || file.downloadUrl || file.tempFilePath || file.path,
src/pages/fileManagement/borrow/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,333 @@
<template>
  <view class="borrow-edit">
    <PageHeader :title="pageTitle" @back="goBack" />
    <up-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-width="110"
    >
      <up-form-item label="借阅人" prop="borrower" required>
        <up-input
          v-model="form.borrower"
          placeholder="请输入借阅人"
          clearable
          :disabled="isReturned"
        />
      </up-form-item>
      <up-form-item label="借阅书籍" prop="documentationId" required>
        <up-input
          v-model="displayDocName"
          placeholder="请选择借阅书籍"
          readonly
          :disabled="isEdit"
          @click="!isEdit && (showDocPicker = true)"
        />
        <template #right>
          <up-icon v-if="!isEdit" name="arrow-right" @click="showDocPicker = true"></up-icon>
        </template>
      </up-form-item>
      <up-form-item label="借阅日期" prop="borrowDate" required>
        <up-input
          v-model="form.borrowDate"
          placeholder="请选择借阅日期"
          readonly
          @click="!isReturned && (showBorrowDatePicker = true)"
          :disabled="isReturned"
        />
        <template #right>
          <up-icon name="arrow-right" @click="!isReturned && (showBorrowDatePicker = true)"></up-icon>
        </template>
      </up-form-item>
      <up-form-item label="应归还日期" prop="dueReturnDate" required>
        <up-input
          v-model="form.dueReturnDate"
          placeholder="请选择应归还日期"
          readonly
          @click="!isReturned && (showDueDatePicker = true)"
          :disabled="isReturned"
        />
        <template #right>
          <up-icon name="arrow-right" @click="!isReturned && (showDueDatePicker = true)"></up-icon>
        </template>
      </up-form-item>
      <up-form-item label="借阅目的" prop="borrowPurpose" required>
        <up-input
          v-model="form.borrowPurpose"
          placeholder="请输入借阅目的"
          clearable
          :disabled="isReturned"
        />
      </up-form-item>
      <up-form-item label="备注">
        <up-textarea
          v-model="form.remark"
          placeholder="请输入备注信息"
          height="80"
          border="none"
          :disabled="isReturned"
        />
      </up-form-item>
    </up-form>
    <FooterButtons
      v-if="!isReturned"
      :loading="loading"
      :confirmText="isEdit ? '保存' : '新增'"
      @cancel="goBack"
      @confirm="handleSubmit"
    />
    <!-- å€Ÿé˜…日期选择器 -->
    <up-popup :show="showBorrowDatePicker" mode="bottom" @close="showBorrowDatePicker = false">
      <up-datetime-picker
        :show="true"
        v-model="borrowDateValue"
        @confirm="onBorrowDateConfirm"
        @cancel="showBorrowDatePicker = false"
        mode="date"
      />
    </up-popup>
    <!-- åº”归还日期选择器 -->
    <up-popup :show="showDueDatePicker" mode="bottom" @close="showDueDatePicker = false">
      <up-datetime-picker
        :show="true"
        v-model="dueReturnDateValue"
        @confirm="onDueDateConfirm"
        @cancel="showDueDatePicker = false"
        mode="date"
      />
    </up-popup>
    <!-- æ–‡æ¡£é€‰æ‹©å™¨ -->
    <up-action-sheet
      :show="showDocPicker"
      :actions="documentOptions"
      title="选择借阅书籍"
      @select="onDocSelect"
      @close="showDocPicker = false"
    />
  </view>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import FooterButtons from "@/components/FooterButtons.vue";
import { addBorrow, updateBorrow, getDocumentList } from "@/api/fileManagement/borrow";
const formRef = ref();
const loading = ref(false);
const borrowId = ref("");
const isEdit = ref(false);
// å¼¹çª—显示状态
const showDocPicker = ref(false);
const showBorrowDatePicker = ref(false);
const showDueDatePicker = ref(false);
// æ•°æ®
const documentList = ref([]);
const borrowDateValue = ref(Date.now());
const dueReturnDateValue = ref(Date.now());
const displayDocName = ref(""); // ç”¨äºŽæ˜¾ç¤ºçš„æ–‡æ¡£åç§°
const form = ref({
  id: "",
  documentationId: "",
  borrower: "",
  borrowPurpose: "",
  borrowDate: "",
  dueReturnDate: "",
  remark: "",
  borrowStatus: "",
});
const rules = {
  borrower: [{ required: true, message: "请输入借阅人", trigger: "blur" }],
  documentationId: [{ required: true, message: "请选择借阅书籍", trigger: "change" }],
  borrowPurpose: [{ required: true, message: "请输入借阅目的", trigger: "blur" }],
  borrowDate: [{ required: true, message: "请选择借阅日期", trigger: "change" }],
  dueReturnDate: [{ required: true, message: "请选择应归还日期", trigger: "change" }],
};
// é¡µé¢æ ‡é¢˜
const pageTitle = computed(() => {
  if (isEdit.value) {
    return form.value.borrowStatus === "归还" ? "借阅详情" : "编辑借阅";
  }
  return "新增借阅";
});
// æ˜¯å¦å·²å½’还
const isReturned = computed(() => {
  return form.value.borrowStatus === "归还";
});
// æ–‡æ¡£é€‰é¡¹ï¼ˆä»…新增模式使用)
const documentOptions = computed(() => {
  return documentList.value.map((item) => ({
    name: item.docName || item.name,
    id: item.id,
  }));
});
// è¿”回上一页
const goBack = () => {
  uni.removeStorageSync("borrowEditData");
  uni.navigateBack();
};
// åŠ è½½æ–‡æ¡£åˆ—è¡¨ï¼ˆä»…æ–°å¢žæ¨¡å¼éœ€è¦ï¼‰
const loadDocumentList = async () => {
  try {
    const res = await getDocumentList();
    if (res.code === 200) {
      documentList.value = res.data || [];
    }
  } catch (error) {
    console.error("获取文档列表失败", error);
  }
};
// æ–‡æ¡£é€‰æ‹©ç¡®è®¤
const onDocSelect = (e) => {
  form.value.documentationId = e.id;
  displayDocName.value = e.name;
  showDocPicker.value = false;
};
// å€Ÿé˜…日期确认
const onBorrowDateConfirm = (e) => {
  const date = new Date(e.value);
  form.value.borrowDate = formatDate(date);
  showBorrowDatePicker.value = false;
};
// åº”归还日期确认
const onDueDateConfirm = (e) => {
  const date = new Date(e.value);
  form.value.dueReturnDate = formatDate(date);
  showDueDatePicker.value = false;
};
// æ ¼å¼åŒ–日期
const formatDate = (date) => {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
};
// æäº¤è¡¨å•
const handleSubmit = () => {
  // å¦‚果已归还,禁止提交
  if (isReturned.value) {
    uni.showToast({ title: "已归还的借阅记录不能编辑", icon: "none" });
    return;
  }
  formRef.value
    .validate()
    .then(async () => {
      try {
        loading.value = true;
        if (isEdit.value) {
          // ç¼–辑模式
          const res = await updateBorrow({
            id: form.value.id,
            borrower: form.value.borrower,
            borrowPurpose: form.value.borrowPurpose,
            borrowDate: form.value.borrowDate,
            dueReturnDate: form.value.dueReturnDate,
            remark: form.value.remark,
          });
          if (res.code === 200) {
            uni.showToast({ title: "编辑成功", icon: "success" });
            setTimeout(() => {
              goBack();
            }, 1500);
          } else {
            uni.showToast({ title: res.msg || "编辑失败", icon: "none" });
          }
        } else {
          // æ–°å¢žæ¨¡å¼
          const res = await addBorrow({
            documentationId: form.value.documentationId,
            borrower: form.value.borrower,
            borrowPurpose: form.value.borrowPurpose,
            borrowDate: form.value.borrowDate,
            dueReturnDate: form.value.dueReturnDate,
            borrowStatus: "借阅",
            remark: form.value.remark,
          });
          if (res.code === 200) {
            uni.showToast({ title: "新增成功", icon: "success" });
            setTimeout(() => {
              goBack();
            }, 1500);
          } else {
            uni.showToast({ title: res.msg || "新增失败", icon: "none" });
          }
        }
      } catch (error) {
        uni.showToast({ title: "操作失败", icon: "none" });
      } finally {
        loading.value = false;
      }
    })
    .catch(() => {
      // éªŒè¯å¤±è´¥
    });
};
// é¡µé¢åŠ è½½
onLoad((options) => {
  if (options.id) {
    // ç¼–辑模式
    isEdit.value = true;
    borrowId.value = options.id;
    // ä»Ž storage èŽ·å–ç¼–è¾‘æ•°æ®
    const editDataStr = uni.getStorageSync("borrowEditData");
    if (editDataStr) {
      try {
        const data = JSON.parse(editDataStr);
        Object.assign(form.value, data);
        borrowDateValue.value = new Date(data.borrowDate).getTime();
        dueReturnDateValue.value = new Date(data.dueReturnDate).getTime();
        // ç›´æŽ¥ä½¿ç”¨ä¼ é€’的文档名称显示,尝试多个可能的字段名
        displayDocName.value = data.docName || data.documentationName || data.fileName || data.name || "";
      } catch (e) {
        console.error("解析编辑数据失败", e);
      }
    }
  } else {
    // æ–°å¢žæ¨¡å¼ï¼ŒåŠ è½½æ–‡æ¡£åˆ—è¡¨å¹¶è®¾ç½®é»˜è®¤æ—¥æœŸ
    loadDocumentList();
    const today = new Date();
    form.value.borrowDate = formatDate(today);
    borrowDateValue.value = today.getTime();
  }
});
</script>
<style lang="scss">
@import "@/static/scss/form-common.scss";
.borrow-edit {
  min-height: 100vh;
  background: #f5f5f5;
}
</style>
src/pages/fileManagement/borrow/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,262 @@
<template>
  <view class="sales-account">
    <!-- ä½¿ç”¨é€šç”¨é¡µé¢å¤´éƒ¨ç»„ä»¶ -->
    <PageHeader title="借阅管理" @back="goBack" />
    <!-- æœç´¢å’Œç­›é€‰åŒºåŸŸ -->
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input
            class="search-text"
            placeholder="请输入借阅人搜索"
            v-model="searchForm.borrower"
            @change="getList"
            clearable
          />
        </view>
        <view class="filter-button" @click="getList">
          <up-icon name="search" size="24" color="#999"></up-icon>
        </view>
      </view>
    </view>
    <!-- å€Ÿé˜…列表 -->
    <view class="ledger-list" v-if="borrowList.length > 0">
      <view v-for="(item, index) in borrowList" :key="index">
        <view class="ledger-item">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text" size="16" color="#ffffff"></up-icon>
              </view>
              <text class="item-id">{{ item.docName || '-' }}</text>
            </view>
            <view class="item-tag" :class="getStatusClass(item.borrowStatus)">
              <text class="tag-text">{{ item.borrowStatus }}</text>
            </view>
          </view>
          <up-divider></up-divider>
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">借阅人</text>
              <text class="detail-value">{{ item.borrower || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">借阅目的</text>
              <text class="detail-value">{{ item.borrowPurpose || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">借阅日期</text>
              <text class="detail-value">{{ item.borrowDate || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">应归还日期</text>
              <text class="detail-value">{{ item.dueReturnDate || '-' }}</text>
            </view>
            <view class="detail-row" v-if="item.remark">
              <text class="detail-label">备注</text>
              <text class="detail-value">{{ item.remark }}</text>
            </view>
          </view>
          <up-divider></up-divider>
          <view class="detail-buttons">
            <u-button
              v-if="item.borrowStatus !== '归还'"
              class="detail-button"
              size="small"
              type="primary"
              @click.stop="goEdit(item)"
            >
              ç¼–辑
            </u-button>
            <u-button
              v-if="item.borrowStatus !== '归还'"
              class="detail-button"
              size="small"
              type="error"
              plain
              @click.stop="handleDelete(item)"
            >
              åˆ é™¤
            </u-button>
            <u-button
              v-if="item.borrowStatus === '归还'"
              class="detail-button"
              size="small"
              type="primary"
              plain
              @click.stop="goView(item)"
            >
              æŸ¥çœ‹
            </u-button>
          </view>
        </view>
      </view>
    </view>
    <view v-else class="no-data">
      <text>暂无借阅记录</text>
    </view>
    <!-- æµ®åŠ¨æ“ä½œæŒ‰é’® -->
    <view class="fab-button" @click="goAdd">
      <up-icon name="plus" size="24" color="#ffffff"></up-icon>
    </view>
  </view>
</template>
<script setup>
import { ref, reactive } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import { getBorrowList, deleteBorrow } from "@/api/fileManagement/borrow";
// æŸ¥è¯¢è¡¨å•
const searchForm = reactive({
  borrower: "",
});
// å€Ÿé˜…列表数据
const borrowList = ref([]);
// åˆ†é¡µç›¸å…³
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
// è¿”回上一页
const goBack = () => {
  uni.navigateBack();
};
// èŽ·å–çŠ¶æ€æ ·å¼ç±»
const getStatusClass = (status) => {
  if (status === "归还") return "tag-success";
  if (status === "借阅") return "tag-warning";
  return "tag-default";
};
// åŠ è½½å€Ÿé˜…åˆ—è¡¨
const getList = async () => {
  uni.showLoading({ title: "加载中...", mask: true });
  const query = {
    page: -1,
    size: -1,
    borrower: searchForm.borrower || undefined,
  };
  try {
    const res = await getBorrowList(query);
    if (res.code === 200) {
      borrowList.value = res.data.records || [];
      pagination.total = res.data.total || 0;
    } else {
      uni.showToast({ title: res.msg || "获取借阅列表失败", icon: "none" });
      borrowList.value = [];
    }
  } catch (error) {
    uni.showToast({ title: "获取借阅列表失败", icon: "none" });
    borrowList.value = [];
  } finally {
    uni.hideLoading();
  }
};
// è·³è½¬åˆ°æ–°å¢žé¡µé¢
const goAdd = () => {
  uni.navigateTo({
    url: "/pages/fileManagement/borrow/edit",
  });
};
// è·³è½¬åˆ°ç¼–辑页面
const goEdit = (item) => {
  uni.setStorageSync("borrowEditData", JSON.stringify(item));
  uni.navigateTo({
    url: `/pages/fileManagement/borrow/edit?id=${item.id}`,
  });
};
// è·³è½¬åˆ°æŸ¥çœ‹é¡µé¢ï¼ˆå·²å½’还记录)
const goView = (item) => {
  uni.setStorageSync("borrowEditData", JSON.stringify(item));
  uni.navigateTo({
    url: `/pages/fileManagement/borrow/edit?id=${item.id}`,
  });
};
// åˆ é™¤
const handleDelete = (row) => {
  uni.showModal({
    title: "删除确认",
    content: "选中的内容将被删除,是否确认删除?",
    confirmText: "确认",
    cancelText: "取消",
    success: async (res) => {
      if (res.confirm) {
        try {
          uni.showLoading({ title: "删除中...", mask: true });
          const result = await deleteBorrow([row.id]);
          if (result.code === 200) {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          } else {
            uni.showToast({ title: result.msg || "删除失败", icon: "none" });
          }
        } catch (error) {
          uni.showToast({ title: "删除失败", icon: "none" });
        } finally {
          uni.hideLoading();
        }
      }
    },
  });
};
onShow(() => {
  getList();
});
</script>
<style scoped lang="scss">
@import "@/styles/sales-common.scss";
// æ ‡ç­¾æ ·å¼
.item-tag {
  border-radius: 4px;
  padding: 2px 8px;
  &.tag-success {
    background: #4caf50;
  }
  &.tag-warning {
    background: #ff9800;
  }
  &.tag-default {
    background: #9e9e9e;
  }
}
.tag-text {
  font-size: 11px;
  color: #ffffff;
  font-weight: 500;
}
// æŒ‰é’®æ ·å¼
.detail-buttons {
  padding: 12px 0;
  display: flex;
  gap: 12px;
}
.detail-button {
  flex: 1;
}
</style>
src/pages/fileManagement/return/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,313 @@
<template>
  <view class="return-edit">
    <PageHeader :title="pageTitle" @back="goBack" />
    <up-form
      ref="formRef"
      :model="form"
      :rules="rules"
      label-width="110"
    >
      <up-form-item label="文档" prop="borrowId" required>
        <up-input
          v-model="displayDocName"
          placeholder="请选择文档"
          readonly
          :disabled="isEdit"
          @click="!isEdit && (showDocPicker = true)"
        />
        <template #right>
          <up-icon v-if="!isEdit" name="arrow-right" @click="showDocPicker = true"></up-icon>
        </template>
      </up-form-item>
      <up-form-item label="借阅人" prop="borrower">
        <up-input
          v-model="form.borrower"
          placeholder="选择文档后自动带出"
          disabled
        />
      </up-form-item>
      <up-form-item label="归还人" prop="returner" required>
        <up-input
          v-model="form.returner"
          placeholder="请输入归还人"
          clearable
          :disabled="isReturned"
        />
      </up-form-item>
      <up-form-item label="归还日期" prop="returnDate" required>
        <up-input
          v-model="form.returnDate"
          placeholder="请选择归还日期"
          readonly
          @click="!isReturned && (showReturnDatePicker = true)"
          :disabled="isReturned"
        />
        <template #right>
          <up-icon name="arrow-right" @click="!isReturned && (showReturnDatePicker = true)"></up-icon>
        </template>
      </up-form-item>
      <up-form-item label="应归还日期" prop="dueReturnDate">
        <up-input
          v-model="form.dueReturnDate"
          placeholder="选择文档后自动带出"
          disabled
        />
      </up-form-item>
      <up-form-item label="备注说明" prop="remark">
        <up-textarea
          v-model="form.remark"
          placeholder="请输入备注说明"
          height="80"
          border="none"
          :disabled="isReturned"
        />
      </up-form-item>
    </up-form>
    <FooterButtons
      v-if="!isReturned"
      :loading="loading"
      :confirmText="isEdit ? '保存' : '新增'"
      @cancel="goBack"
      @confirm="handleSubmit"
    />
    <!-- æ–‡æ¡£é€‰æ‹©å™¨ -->
    <up-action-sheet
      :show="showDocPicker"
      :actions="documentOptions"
      title="选择文档"
      @select="onDocSelect"
      @close="showDocPicker = false"
    />
    <!-- å½’还日期选择器 -->
    <up-popup :show="showReturnDatePicker" mode="bottom" @close="showReturnDatePicker = false">
      <up-datetime-picker
        :show="true"
        v-model="returnDateValue"
        @confirm="onReturnDateConfirm"
        @cancel="showReturnDatePicker = false"
        mode="date"
      />
    </up-popup>
  </view>
</template>
<script setup>
import { ref, computed, onMounted } from "vue";
import { onLoad } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import FooterButtons from "@/components/FooterButtons.vue";
import { returnDocument, reventUpdate, getDocumentList } from "@/api/fileManagement/return";
const formRef = ref();
const loading = ref(false);
const returnId = ref("");
const isEdit = ref(false);
// å¼¹çª—显示状态
const showDocPicker = ref(false);
const showReturnDatePicker = ref(false);
// æ•°æ®
const documentList = ref([]);
const returnDateValue = ref(Date.now());
const displayDocName = ref(""); // ç”¨äºŽæ˜¾ç¤ºçš„æ–‡æ¡£åç§°
const form = ref({
  id: "",
  borrowId: "",
  documentationId: "",
  borrower: "",
  returner: "",
  borrowStatus: "",
  returnDate: "",
  dueReturnDate: "",
  remark: "",
});
const rules = {
  borrowId: [{ required: true, message: "请选择文档", trigger: "change" }],
  returner: [{ required: true, message: "请输入归还人", trigger: "blur" }],
  returnDate: [{ required: true, message: "请选择归还日期", trigger: "change" }],
};
// é¡µé¢æ ‡é¢˜
const pageTitle = computed(() => {
  if (isEdit.value) {
    return form.value.borrowStatus === "归还" ? "归还详情" : "编辑归还";
  }
  return "新增归还";
});
// æ˜¯å¦å·²å½’还
const isReturned = computed(() => {
  return form.value.borrowStatus === "归还";
});
// æ–‡æ¡£é€‰é¡¹ï¼ˆä»…新增模式使用)
const documentOptions = computed(() => {
  return documentList.value.map((item) => ({
    name: item.docName || item.name,
    id: item.id,
    borrower: item.borrower,
    dueReturnDate: item.dueReturnDate,
  }));
});
// è¿”回上一页
const goBack = () => {
  uni.removeStorageSync("returnEditData");
  uni.navigateBack();
};
// åŠ è½½æ–‡æ¡£åˆ—è¡¨ï¼ˆä»…æ–°å¢žæ¨¡å¼éœ€è¦ï¼‰
const loadDocumentList = async () => {
  try {
    const res = await getDocumentList();
    if (res.code === 200) {
      documentList.value = res.data || [];
    }
  } catch (error) {
    console.error("获取文档列表失败", error);
  }
};
// æ–‡æ¡£é€‰æ‹©ç¡®è®¤
const onDocSelect = (e) => {
  form.value.borrowId = e.id;
  form.value.documentationId = e.id;
  displayDocName.value = e.name;
  // è‡ªåŠ¨å¸¦å‡ºå€Ÿé˜…äººå’Œåº”å½’è¿˜æ—¥æœŸ
  form.value.borrower = e.borrower || "";
  form.value.dueReturnDate = e.dueReturnDate || "";
  showDocPicker.value = false;
};
// å½’还日期确认
const onReturnDateConfirm = (e) => {
  const date = new Date(e.value);
  form.value.returnDate = formatDate(date);
  showReturnDatePicker.value = false;
};
// æ ¼å¼åŒ–日期
const formatDate = (date) => {
  const year = date.getFullYear();
  const month = String(date.getMonth() + 1).padStart(2, "0");
  const day = String(date.getDate()).padStart(2, "0");
  return `${year}-${month}-${day}`;
};
// æäº¤è¡¨å•
const handleSubmit = () => {
  // å¦‚果已归还,禁止提交
  if (isReturned.value) {
    uni.showToast({ title: "已归还的记录不能编辑", icon: "none" });
    return;
  }
  formRef.value
    .validate()
    .then(async () => {
      try {
        loading.value = true;
        if (isEdit.value) {
          // ç¼–辑模式
          const res = await reventUpdate({
            id: form.value.id,
            documentationId: form.value.documentationId,
            borrower: form.value.borrower,
            returner: form.value.returner,
            borrowStatus: form.value.borrowStatus,
            returnDate: form.value.returnDate,
            dueReturnDate: form.value.dueReturnDate,
            remark: form.value.remark,
          });
          if (res.code === 200) {
            uni.showToast({ title: "编辑成功", icon: "success" });
            setTimeout(() => {
              goBack();
            }, 1500);
          } else {
            uni.showToast({ title: res.msg || "编辑失败", icon: "none" });
          }
        } else {
          // æ–°å¢žæ¨¡å¼
          const res = await returnDocument({
            borrowId: form.value.borrowId,
            borrower: form.value.borrower,
            returner: form.value.returner,
            borrowStatus: "归还",
            returnDate: form.value.returnDate,
            dueReturnDate: form.value.dueReturnDate,
            remark: form.value.remark,
          });
          if (res.code === 200) {
            uni.showToast({ title: "新增成功", icon: "success" });
            setTimeout(() => {
              goBack();
            }, 1500);
          } else {
            uni.showToast({ title: res.msg || "新增失败", icon: "none" });
          }
        }
      } catch (error) {
        uni.showToast({ title: "操作失败", icon: "none" });
      } finally {
        loading.value = false;
      }
    })
    .catch(() => {
      // éªŒè¯å¤±è´¥
    });
};
// é¡µé¢åŠ è½½
onLoad((options) => {
  if (options.id) {
    // ç¼–辑模式
    isEdit.value = true;
    returnId.value = options.id;
    // ä»Ž storage èŽ·å–ç¼–è¾‘æ•°æ®
    const editDataStr = uni.getStorageSync("returnEditData");
    if (editDataStr) {
      try {
        const data = JSON.parse(editDataStr);
        Object.assign(form.value, data);
        returnDateValue.value = new Date(data.returnDate).getTime();
        // ç›´æŽ¥ä½¿ç”¨ä¼ é€’çš„ docName æ˜¾ç¤º
        displayDocName.value = data.docName || "";
      } catch (e) {
        console.error("解析编辑数据失败", e);
      }
    }
  } else {
    // æ–°å¢žæ¨¡å¼ï¼ŒåŠ è½½æ–‡æ¡£åˆ—è¡¨å¹¶è®¾ç½®é»˜è®¤æ—¥æœŸ
    loadDocumentList();
    const today = new Date();
    form.value.returnDate = formatDate(today);
    returnDateValue.value = today.getTime();
  }
});
</script>
<style lang="scss">
@import "@/static/scss/form-common.scss";
.return-edit {
  min-height: 100vh;
  background: #f5f5f5;
}
</style>
src/pages/fileManagement/return/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,262 @@
<template>
  <view class="sales-account">
    <!-- ä½¿ç”¨é€šç”¨é¡µé¢å¤´éƒ¨ç»„ä»¶ -->
    <PageHeader title="归还管理" @back="goBack" />
    <!-- æœç´¢å’Œç­›é€‰åŒºåŸŸ -->
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input
            class="search-text"
            placeholder="请输入借阅人搜索"
            v-model="searchForm.borrower"
            @change="getList"
            clearable
          />
        </view>
        <view class="filter-button" @click="getList">
          <up-icon name="search" size="24" color="#999"></up-icon>
        </view>
      </view>
    </view>
    <!-- å½’还列表 -->
    <view class="ledger-list" v-if="returnList.length > 0">
      <view v-for="(item, index) in returnList" :key="index">
        <view class="ledger-item">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text" size="16" color="#ffffff"></up-icon>
              </view>
              <text class="item-id">{{ item.docName || '-' }}</text>
            </view>
            <view class="item-tag" :class="getStatusClass(item.borrowStatus)">
              <text class="tag-text">{{ item.borrowStatus }}</text>
            </view>
          </view>
          <up-divider></up-divider>
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">借阅人</text>
              <text class="detail-value">{{ item.borrower || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">归还人</text>
              <text class="detail-value">{{ item.returner || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">归还日期</text>
              <text class="detail-value">{{ item.returnDate || '-' }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">应归还日期</text>
              <text class="detail-value">{{ item.dueReturnDate || '-' }}</text>
            </view>
            <view class="detail-row" v-if="item.remark">
              <text class="detail-label">备注</text>
              <text class="detail-value">{{ item.remark }}</text>
            </view>
          </view>
          <up-divider></up-divider>
          <view class="detail-buttons">
            <u-button
              v-if="item.borrowStatus !== '归还'"
              class="detail-button"
              size="small"
              type="primary"
              @click.stop="goEdit(item)"
            >
              ç¼–辑
            </u-button>
            <u-button
              v-if="item.borrowStatus !== '归还'"
              class="detail-button"
              size="small"
              type="error"
              plain
              @click.stop="handleDelete(item)"
            >
              åˆ é™¤
            </u-button>
            <u-button
              v-if="item.borrowStatus === '归还'"
              class="detail-button"
              size="small"
              type="primary"
              plain
              @click.stop="goView(item)"
            >
              æŸ¥çœ‹
            </u-button>
          </view>
        </view>
      </view>
    </view>
    <view v-else class="no-data">
      <text>暂无归还记录</text>
    </view>
    <!-- æµ®åŠ¨æ“ä½œæŒ‰é’® -->
    <view class="fab-button" @click="goAdd">
      <up-icon name="plus" size="24" color="#ffffff"></up-icon>
    </view>
  </view>
</template>
<script setup>
import { ref, reactive } from "vue";
import { onShow } from "@dcloudio/uni-app";
import PageHeader from "@/components/PageHeader.vue";
import { getReturnListPage, deleteReturn } from "@/api/fileManagement/return";
// æŸ¥è¯¢è¡¨å•
const searchForm = reactive({
  borrower: "",
});
// å½’还列表数据
const returnList = ref([]);
// åˆ†é¡µç›¸å…³
const pagination = reactive({
  currentPage: 1,
  pageSize: 10,
  total: 0,
});
// è¿”回上一页
const goBack = () => {
  uni.navigateBack();
};
// èŽ·å–çŠ¶æ€æ ·å¼ç±»
const getStatusClass = (status) => {
  if (status === "归还") return "tag-success";
  if (status === "借阅") return "tag-warning";
  return "tag-default";
};
// åŠ è½½å½’è¿˜åˆ—è¡¨
const getList = async () => {
  uni.showLoading({ title: "加载中...", mask: true });
  const query = {
    page: -1,
    size: -1,
    borrower: searchForm.borrower || undefined,
  };
  try {
    const res = await getReturnListPage(query);
    if (res.code === 200) {
      returnList.value = res.data.records || [];
      pagination.total = res.data.total || 0;
    } else {
      uni.showToast({ title: res.msg || "获取归还列表失败", icon: "none" });
      returnList.value = [];
    }
  } catch (error) {
    uni.showToast({ title: "获取归还列表失败", icon: "none" });
    returnList.value = [];
  } finally {
    uni.hideLoading();
  }
};
// è·³è½¬åˆ°æ–°å¢žé¡µé¢
const goAdd = () => {
  uni.navigateTo({
    url: "/pages/fileManagement/return/edit",
  });
};
// è·³è½¬åˆ°ç¼–辑页面
const goEdit = (item) => {
  uni.setStorageSync("returnEditData", JSON.stringify(item));
  uni.navigateTo({
    url: `/pages/fileManagement/return/edit?id=${item.id}`,
  });
};
// è·³è½¬åˆ°æŸ¥çœ‹é¡µé¢ï¼ˆå·²å½’还记录)
const goView = (item) => {
  uni.setStorageSync("returnEditData", JSON.stringify(item));
  uni.navigateTo({
    url: `/pages/fileManagement/return/edit?id=${item.id}`,
  });
};
// åˆ é™¤
const handleDelete = (row) => {
  uni.showModal({
    title: "删除确认",
    content: "选中的内容将被删除,是否确认删除?",
    confirmText: "确认",
    cancelText: "取消",
    success: async (res) => {
      if (res.confirm) {
        try {
          uni.showLoading({ title: "删除中...", mask: true });
          const result = await deleteReturn([row.id]);
          if (result.code === 200) {
            uni.showToast({ title: "删除成功", icon: "success" });
            getList();
          } else {
            uni.showToast({ title: result.msg || "删除失败", icon: "none" });
          }
        } catch (error) {
          uni.showToast({ title: "删除失败", icon: "none" });
        } finally {
          uni.hideLoading();
        }
      }
    },
  });
};
onShow(() => {
  getList();
});
</script>
<style scoped lang="scss">
@import "@/styles/sales-common.scss";
// æ ‡ç­¾æ ·å¼
.item-tag {
  border-radius: 4px;
  padding: 2px 8px;
  &.tag-success {
    background: #4caf50;
  }
  &.tag-warning {
    background: #ff9800;
  }
  &.tag-default {
    background: #9e9e9e;
  }
}
.tag-text {
  font-size: 11px;
  color: #ffffff;
  font-weight: 500;
}
// æŒ‰é’®æ ·å¼
.detail-buttons {
  padding: 12px 0;
  display: flex;
  gap: 12px;
}
.detail-button {
  flex: 1;
}
</style>
src/pages/index.vue
@@ -3,23 +3,42 @@
    <scroll-view class="scroll" scroll-y>
      <!-- é¡¶éƒ¨ Banner:放入滚动区域,随页面一起滚动,不固定在顶部 -->
      <view class="hero-section">
        <view class="bg-img">
          <view class="hero-content">
            <view class="hero-ornaments">
              <view class="hero-glow glow-left" />
              <view class="hero-glow glow-right" />
              <view class="hero-mist mist-top" />
              <view class="hero-mist mist-bottom" />
              <view class="hero-curve curve-main" />
              <view class="hero-curve curve-sub" />
        <view class="hero-banner">
          <view class="hero-top">
            <view class="hero-copy">
              <view class="hero-badge">经营看板</view>
              <text class="hero-title">{{ heroTitle }}</text>
              <text class="hero-subtitle">{{ heroSubtitle }}</text>
              <view class="hero-meta">
                <text
                  v-for="item in heroMetaItems"
                  :key="item"
                  class="hero-meta-item"
                >
                  {{ item }}
                </text>
              </view>
            </view>
            <view class="hero-avatar">
              <text class="hero-avatar-text">{{ heroInitial }}</text>
            </view>
          </view>
          <view class="hero-wave"></view>
          <view class="hero-panels">
            <view
              v-for="item in heroMetrics"
              :key="item.label"
              class="hero-panel"
            >
              <text class="hero-panel-label">{{ item.label }}</text>
              <text class="hero-panel-value">{{ item.value }}</text>
              <text class="hero-panel-hint">{{ item.hint }}</text>
            </view>
          </view>
        </view>
      </view>
      <!-- å¿«æ·å…¥å£ -->
      <view class="quick-section">
      <view v-if="quickTools.length" class="quick-section">
        <up-grid :border="false" col="4">
          <up-grid-item
            v-for="item in quickTools"
@@ -35,7 +54,7 @@
      </view>
      <!-- æ•°æ®æ€»è§ˆ -->
      <view class="section">
      <view v-if="hasOverviewSection" class="section">
        <view class="section-header">
          <view class="section-title">
            <view class="title-bar" />
@@ -48,7 +67,7 @@
        </view>
        <view v-show="overviewExpanded" class="overview">
          <view class="overview-card sales">
          <view v-if="canShowSalesOverview" class="overview-card sales">
            <view class="card-left">
              <text class="card-title">销售数据</text>
              <view class="card-metrics">
@@ -64,7 +83,7 @@
            </view>
          </view>
          <view class="overview-card purchase">
          <view v-if="canShowPurchaseOverview" class="overview-card purchase">
            <view class="card-left">
              <text class="card-title">采购数据</text>
              <view class="card-metrics">
@@ -80,7 +99,7 @@
            </view>
          </view>
          <view class="overview-card stock">
          <view v-if="canShowStockOverview" class="overview-card stock">
            <view class="card-left">
              <text class="card-title">库存数据</text>
              <view class="card-metrics">
@@ -99,7 +118,7 @@
      </view>
      <!-- å®¢æˆ·åˆåŒé‡‘额分析 -->
      <view class="section">
      <view v-if="canShowContractAnalysis" class="section">
        <view class="section-header">
          <view class="section-title">
            <view class="title-bar" />
@@ -197,12 +216,15 @@
import { analysisCustomerContractAmounts, getBusiness } from "@/api/viewIndex";
import { createVersionUpgradeChecker } from "@/utils/versionUpgrade";
import DownloadProgressMask from "@/components/DownloadProgressMask.vue";
import useUserStore from "@/store/modules/user";
const imgNum1 = "/static/images/index/num1.png";
const imgNum2 = "/static/images/index/num2.png";
const imgNum3 = "/static/images/index/num3.png";
const quickTools = [
const userStore = useUserStore();
const quickToolSource = [
  {
    label: "生产报工",
    icon: "/static/images/icon/shengchanbaogong.svg",
@@ -224,6 +246,8 @@
  //   route: "/pages/equipmentManagement/repair/index",
  // },
];
const quickTools = ref([...quickToolSource]);
const allowedMenuTitles = ref(new Set());
const isCanvas2d = ref(false);
@@ -272,6 +296,125 @@
  uni.showToast({ title: "更多功能待接入", icon: "none" });
}
function filterQuickToolsByRoutes() {
  const routers = userStore.routers || [];
  if (!routers || routers.length === 0) {
    allowedMenuTitles.value = new Set();
    quickTools.value = [...quickToolSource];
    return;
  }
  const titles = new Set();
  const collectMenuTitles = (routes) => {
    if (!Array.isArray(routes)) return;
    routes.forEach((route) => {
      if (route.meta && route.meta.title) {
        titles.add(route.meta.title);
      }
      if (route.children && route.children.length > 0) {
        collectMenuTitles(route.children);
      }
    });
  };
  collectMenuTitles(routers);
  allowedMenuTitles.value = titles;
  quickTools.value = quickToolSource.filter((item) =>
    titles.has(item.label)
  );
}
function hasAnyPermission(titles) {
  const titleSet = allowedMenuTitles.value;
  if (!titleSet || titleSet.size === 0) return true;
  return titles.some((title) => titleSet.has(title));
}
const canShowSalesOverview = computed(() => hasAnyPermission(["销售台账"]));
const canShowPurchaseOverview = computed(() => hasAnyPermission(["采购台账"]));
const canShowStockOverview = computed(() => hasAnyPermission(["库存管理"]));
const hasOverviewSection = computed(
  () =>
    canShowSalesOverview.value ||
    canShowPurchaseOverview.value ||
    canShowStockOverview.value
);
const canShowContractAnalysis = computed(() =>
  hasAnyPermission(["销售台账", "客户档案", "客户往来"])
);
const userDisplayName = computed(
  () => userStore.nickName
);
const heroInitial = computed(() => userDisplayName.value.slice(0, 1).toUpperCase());
const heroTitle = computed(() => `你好,${userDisplayName.value}`);
const heroSubtitle = computed(
  () => userStore.currentFactoryName || "当前账号已进入业务首页"
);
const heroMetaItems = computed(() => {
  const items = [];
  if (userStore.roleName) items.push(userStore.roleName);
  if (userStore.currentLoginTime) items.push(`登录于 ${userStore.currentLoginTime}`);
  if (!items.length) items.push("当前业务概览");
  return items;
});
const visibleSectionCount = computed(() => {
  let count = 0;
  if (quickTools.value.length > 0) count += 1;
  if (hasOverviewSection.value) count += 1;
  if (canShowContractAnalysis.value) count += 1;
  return count;
});
const heroMetrics = computed(() => {
  const items = [];
  if (canShowSalesOverview.value) {
    items.push({
      label: "销售",
      value: overviewCards.value.sales.today,
      hint: "本月营业额",
    });
  }
  if (canShowPurchaseOverview.value) {
    items.push({
      label: "采购",
      value: overviewCards.value.purchase.today,
      hint: "本月采购额",
    });
  }
  if (canShowStockOverview.value) {
    items.push({
      label: "库存",
      value: overviewCards.value.stock.today,
      hint: "当前库存量",
    });
  }
  if (canShowContractAnalysis.value && items.length < 3) {
    items.push({
      label: "合同",
      value: contractSummaryView.value.sumText,
      hint: "客户合同额",
    });
  }
  if (items.length < 3) {
    items.push({
      label: "快捷",
      value: String(quickTools.value.length),
      hint: "可用入口",
    });
  }
  if (items.length < 3) {
    items.push({
      label: "模块",
      value: String(visibleSectionCount.value),
      hint: "可见板块",
    });
  }
  return items.slice(0, 3);
});
function getByPath(obj, path) {
  if (!obj || !path) return undefined;
@@ -423,7 +566,13 @@
async function loadHome() {
  chartReady.value = false;
  try {
    const [bRes, cRes] = await Promise.all([getBusiness(), analysisCustomerContractAmounts()]);
    const businessPromise = hasOverviewSection.value
      ? getBusiness()
      : Promise.resolve({ data: {} });
    const contractPromise = canShowContractAnalysis.value
      ? analysisCustomerContractAmounts()
      : Promise.resolve({ data: { item: [], sum: "0", chain: "0", yny: "0" } });
    const [bRes, cRes] = await Promise.all([businessPromise, contractPromise]);
    businessRaw.value = bRes?.data || {};
    const cData = cRes?.data || {};
    contractSummary.value = {
@@ -448,11 +597,21 @@
    isCanvas2d.value = false;
  }
  triggerVersionCheck("onMounted");
  loadHome();
  userStore
    .getRouters()
    .then(() => {
      filterQuickToolsByRoutes();
      loadHome();
    })
    .catch(() => {
      filterQuickToolsByRoutes();
      loadHome();
    });
});
onShow(() => {
  triggerVersionCheck("onShow");
  filterQuickToolsByRoutes();
});
</script>
@@ -508,146 +667,169 @@
    }
}
.hero-section {
    margin: 0 12px;
    margin-bottom: 12px;
    animation: fadeInUp 0.6s ease-out 0.1s both;
  margin: 0 14px 12px;
  animation: fadeInUp 0.6s ease-out 0.1s both;
}
.bg-img {
    width: 100%;
    height: 10.25rem;
    background:
        linear-gradient(135deg, rgba(234, 245, 255, 0.98) 0%, rgba(220, 239, 255, 0.94) 42%, rgba(244, 250, 255, 0.96) 100%),
        url("/static/images/banner/backview.png") center/cover no-repeat;
    border-radius: 18px;
    position: relative;
    overflow: hidden;
    box-shadow: 0 12px 30px rgba(118, 154, 186, 0.16);
    &::before {
        content: "";
        position: absolute;
        inset: 0;
        background:
            radial-gradient(circle at 14% 22%, rgba(255, 255, 255, 0.95) 0, rgba(255, 255, 255, 0) 28%),
            radial-gradient(circle at 84% 18%, rgba(191, 226, 255, 0.7) 0, rgba(191, 226, 255, 0) 26%),
            linear-gradient(180deg, rgba(255, 255, 255, 0.46) 0%, rgba(255, 255, 255, 0.16) 42%, rgba(206, 229, 247, 0.22) 100%);
        pointer-events: none;
    }
    &::after {
        content: "";
        position: absolute;
        left: 18%;
        bottom: -44px;
        width: 64%;
        height: 88px;
        background: radial-gradient(ellipse at center, rgba(255, 255, 255, 0.72) 0%, rgba(255, 255, 255, 0) 72%);
        border-radius: 50%;
        filter: blur(10px);
        pointer-events: none;
    }
.hero-banner {
  position: relative;
  overflow: hidden;
  border-radius: 18px;
  padding: 18px 16px 16px;
  min-height: 182px;
  background:
    linear-gradient(135deg, rgba(22, 74, 170, 0.92) 0%, rgba(33, 115, 185, 0.88) 48%, rgba(18, 156, 144, 0.82) 100%),
    url("/static/images/banner/backview.png") center right / cover no-repeat;
  box-shadow: 0 14px 34px rgba(29, 78, 137, 0.2);
  border: 1px solid rgba(255, 255, 255, 0.18);
  &::before {
    content: "";
    position: absolute;
    inset: 0;
    background:
      linear-gradient(120deg, rgba(255, 255, 255, 0.16) 0%, rgba(255, 255, 255, 0.02) 32%, rgba(255, 255, 255, 0) 60%),
      radial-gradient(circle at top right, rgba(255, 255, 255, 0.24) 0%, rgba(255, 255, 255, 0) 34%);
    pointer-events: none;
  }
  &::after {
    content: "";
    position: absolute;
    right: -28px;
    bottom: -34px;
    width: 156px;
    height: 156px;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(255, 255, 255, 0.18) 0%, rgba(255, 255, 255, 0) 72%);
    pointer-events: none;
  }
}
.hero-content {
    position: relative;
    z-index: 1;
    padding: 16px 16px 14px;
    height: 100%;
    backdrop-filter: blur(2px);
.hero-top {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 14px;
}
.hero-ornaments {
    position: relative;
    width: 100%;
    height: 100%;
.hero-copy {
  min-width: 0;
  flex: 1;
}
.hero-glow {
    position: absolute;
    border-radius: 50%;
    filter: blur(4px);
    background: radial-gradient(circle, rgba(255, 255, 255, 0.96) 0%, rgba(255, 255, 255, 0) 72%);
    opacity: 0.9;
.hero-badge {
  display: inline-flex;
  align-items: center;
  height: 26px;
  padding: 0 10px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.16);
  border: 1px solid rgba(255, 255, 255, 0.18);
  color: rgba(255, 255, 255, 0.92);
  font-size: 11px;
  font-weight: 600;
}
.hero-glow.glow-left {
    left: -10px;
    top: 8px;
    width: 120px;
    height: 120px;
.hero-title {
  display: block;
  margin-top: 12px;
  color: #ffffff;
  font-size: 24px;
  font-weight: 700;
  line-height: 1.2;
}
.hero-glow.glow-right {
    right: -20px;
    top: 4px;
    width: 144px;
    height: 144px;
    background: radial-gradient(circle, rgba(207, 234, 255, 0.92) 0%, rgba(207, 234, 255, 0) 74%);
.hero-subtitle {
  display: block;
  margin-top: 8px;
  color: rgba(255, 255, 255, 0.84);
  font-size: 13px;
  line-height: 1.45;
}
.hero-mist {
    position: absolute;
    border-radius: 999px;
    background: linear-gradient(90deg, rgba(255, 255, 255, 0.52), rgba(255, 255, 255, 0.08));
    border: 1px solid rgba(255, 255, 255, 0.34);
    backdrop-filter: blur(10px);
    box-shadow: 0 10px 24px rgba(154, 190, 219, 0.14);
.hero-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
  margin-top: 12px;
}
.hero-mist.mist-top {
    left: 18px;
    top: 20px;
    width: 112px;
    height: 18px;
.hero-meta-item {
  padding: 3px 10px;
  border-radius: 999px;
  background: rgba(255, 255, 255, 0.12);
  color: rgba(255, 255, 255, 0.86);
  font-size: 11px;
  line-height: 18px;
}
.hero-mist.mist-bottom {
    left: 18px;
    top: 48px;
    width: 72px;
    height: 10px;
    opacity: 0.82;
.hero-avatar {
  position: relative;
  z-index: 1;
  width: 52px;
  height: 52px;
  flex: 0 0 52px;
  border-radius: 16px;
  background: rgba(255, 255, 255, 0.18);
  border: 1px solid rgba(255, 255, 255, 0.22);
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.3);
}
.hero-curve {
    position: absolute;
    border-radius: 999px;
    border: 2px solid rgba(255, 255, 255, 0.72);
    background: linear-gradient(180deg, rgba(255, 255, 255, 0.34), rgba(255, 255, 255, 0.12));
    box-shadow:
        0 10px 26px rgba(154, 190, 219, 0.16),
        inset 0 1px 0 rgba(255, 255, 255, 0.8);
    backdrop-filter: blur(10px);
.hero-avatar-text {
  color: #ffffff;
  font-size: 20px;
  font-weight: 700;
}
.hero-curve.curve-main {
    right: 18px;
    bottom: 22px;
    width: 176px;
    height: 84px;
    transform: rotate(-9deg);
    border-top-left-radius: 90px;
    border-bottom-right-radius: 90px;
    opacity: 1;
.hero-panels {
  position: relative;
  z-index: 1;
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: 10px;
  margin-top: 18px;
}
.hero-curve.curve-sub {
    right: 96px;
    bottom: 60px;
    width: 104px;
    height: 40px;
    transform: rotate(-9deg);
    border-top-left-radius: 60px;
    border-bottom-right-radius: 60px;
    opacity: 0.9;
.hero-panel {
  min-width: 0;
  padding: 12px 10px;
  border-radius: 14px;
  background: rgba(11, 25, 48, 0.18);
  border: 1px solid rgba(255, 255, 255, 0.14);
  backdrop-filter: blur(10px);
}
.hero-wave {
    height: 1.1rem;
    background: linear-gradient(180deg, rgba(255, 255, 255, 0) 0%, rgba(244, 249, 253, 0.96) 100%);
    margin-top: -1px;
    position: relative;
.hero-panel-label {
  display: block;
  color: rgba(255, 255, 255, 0.7);
  font-size: 11px;
  line-height: 1.2;
}
.hero-panel-value {
  display: block;
  margin-top: 8px;
  color: #ffffff;
  font-size: 18px;
  font-weight: 700;
  line-height: 1.2;
  white-space: nowrap;
  overflow: hidden;
  text-overflow: ellipsis;
}
.hero-panel-hint {
  display: block;
  margin-top: 6px;
  color: rgba(255, 255, 255, 0.72);
  font-size: 11px;
  line-height: 1.2;
}
.safe-top {
src/pages/indexItem.vue
@@ -27,6 +27,7 @@
<script setup>
  import { onMounted, reactive, ref } from "vue";
  import { OA_WORKBENCH_ITEMS } from "@/config/oaWorkbench.js";
  import useUserStore from "@/store/modules/user";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
@@ -110,10 +111,15 @@
      { icon: "/static/images/icon/baojiaguanli.svg", label: "报价审批" },
      { icon: "/static/images/icon/fahuoguanli.svg", label: "发货审批" },
    ],
    "OA办公": OA_WORKBENCH_ITEMS.map(item => ({ ...item })),
  };
  // å¤„理常用功能点击
  const handleCommonItemClick = item => {
    if (item.path) {
      uni.navigateTo({ url: item.path });
      return;
    }
    const url = routeMapping[item.label];
    if (url) {
      uni.navigateTo({ url });
src/pages/inspectionUpload/attachment.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,485 @@
<template>
  <view class="attachment-page">
    <!-- é¡µé¢å¤´éƒ¨ -->
    <PageHeader :title="`查看附件 - ${taskInfo?.taskName || ''}`"
                @back="goBack" />
    <!-- é¡µé¢å†…容 -->
    <view class="attachment-content">
      <!-- åˆ†ç±»æ ‡ç­¾é¡µ -->
      <view class="attachment-tabs">
        <view class="tab-item"
              :class="{ active: currentViewType === 'before' }"
              @click="switchViewType('before')">
          ç”Ÿäº§å‰ ({{ getAttachmentsByType(0).length }})
        </view>
        <view class="tab-item"
              :class="{ active: currentViewType === 'after' }"
              @click="switchViewType('after')">
          ç”Ÿäº§ä¸­ ({{ getAttachmentsByType(1).length }})
        </view>
        <view class="tab-item"
              :class="{ active: currentViewType === 'issue' }"
              @click="switchViewType('issue')">
          ç”Ÿäº§åŽ ({{ getAttachmentsByType(2).length }})
        </view>
      </view>
      <!-- å½“前分类的附件列表 -->
      <view class="attachment-list-container">
        <view v-if="getCurrentViewAttachments().length > 0"
              class="attachment-list">
          <view v-for="(file, index) in getCurrentViewAttachments()"
                :key="index"
                class="attachment-item"
                @click="previewAttachment(file)">
            <view class="attachment-preview-container">
              <image v-if="isImageFile(file)"
                     :src="file.url || file.downloadUrl"
                     class="attachment-preview"
                     mode="aspectFill" />
              <view v-else
                    class="attachment-video-preview">
                <u-icon name="video"
                        size="40"
                        color="#409eff"></u-icon>
                <text class="video-text">视频</text>
              </view>
            </view>
            <view class="attachment-info">
              <text class="attachment-name">{{ file.originalFilename || file.bucketFilename || file.name || '附件' }}</text>
              <text class="attachment-size">{{ formatFileSize(file.byteSize || file.size) }}</text>
            </view>
          </view>
        </view>
        <view v-else
              class="attachment-empty">
          <text class="empty-text">该分类暂无附件</text>
        </view>
      </view>
    </view>
    <!-- è§†é¢‘预览弹窗 -->
    <view v-if="showVideoDialog"
          class="video-modal-overlay"
          @click="closeVideoPreview">
      <view class="video-modal-container"
            @click.stop>
        <view class="video-modal-header">
          <text class="video-modal-title">{{ currentVideoFile?.originalFilename || '视频预览' }}</text>
          <view class="close-btn-video"
                @click="closeVideoPreview">
            <u-icon name="close"
                    size="20"
                    color="#fff"></u-icon>
          </view>
        </view>
        <view class="video-modal-body">
          <video v-if="currentVideoFile"
                 :src="currentVideoFile.url || currentVideoFile.downloadUrl"
                 class="video-player"
                 controls
                 autoplay
                 @error="handleVideoError"></video>
        </view>
      </view>
    </view>
  </view>
</template>
<script setup>
  import { ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import config from "@/config";
  // ä»»åŠ¡ä¿¡æ¯
  const taskInfo = ref(null);
  // é™„件列表
  const attachmentList = ref([]);
  // å½“前查看类型
  const currentViewType = ref("before"); // 'before', 'after', 'issue'
  // è§†é¢‘预览相关状态
  const showVideoDialog = ref(false);
  const currentVideoFile = ref(null);
  // æ–‡ä»¶è®¿é—®åŸºç¡€åŸŸ
  const filePreviewBase = config.fileUrl;
  // é¡µé¢åŠ è½½
  onLoad(options => {
    if (options.taskInfo) {
      try {
        taskInfo.value = JSON.parse(decodeURIComponent(options.taskInfo));
        loadAttachments();
      } catch (e) {
        console.error("解析任务信息失败:", e);
        uni.showToast({
          title: "加载失败",
          icon: "error",
        });
      }
    }
  });
  // åŠ è½½é™„ä»¶æ•°æ®
  const loadAttachments = () => {
    const task = taskInfo.value;
    if (!task) return;
    attachmentList.value = [];
    // åŽç«¯åæ˜¾å­—段 (VO优先)
    const beforeList = Array.isArray(task?.commonFileListBeforeVO)
      ? task.commonFileListBeforeVO
      : Array.isArray(task?.commonFileListBefore)
      ? task.commonFileListBefore
      : [];
    const duringList = Array.isArray(task?.commonFileListVO)
      ? task.commonFileListVO
      : Array.isArray(task?.commonFileListAfter)
      ? task.commonFileListAfter
      : []; // å…¼å®¹æ—§é€»è¾‘或命名不一致
    const afterList = Array.isArray(task?.commonFileListAfterVO)
      ? task.commonFileListAfterVO
      : Array.isArray(task?.commonFileListIssue)
      ? task.commonFileListIssue
      : [];
    // å¦‚æžœ VO éƒ½æ²¡æœ‰ï¼Œå°è¯•从 commonFileList è¿‡æ»¤
    const allList = Array.isArray(task?.commonFileList)
      ? task.commonFileList
      : [];
    const finalBefore =
      beforeList.length > 0 ? beforeList : allList.filter(f => f?.type === 10);
    const finalDuring =
      duringList.length > 0 ? duringList : allList.filter(f => f?.type === 11);
    const finalAfter =
      afterList.length > 0 ? afterList : allList.filter(f => f?.type === 12);
    const mapToViewFile = (file, viewType) => {
      // å…¼å®¹ previewURL, previewUrl, url, downloadURL, downloadUrl
      const rawUrl =
        file?.previewURL ||
        file?.previewUrl ||
        file?.url ||
        file?.downloadURL ||
        file?.downloadUrl ||
        "";
      const u = normalizeFileUrl(rawUrl);
      return {
        ...file,
        type: viewType,
        name:
          file?.originalFilename || file?.bucketFilename || file?.name || "附件",
        bucketFilename: file?.bucketFilename || file?.name,
        originalFilename: file?.originalFilename || file?.name,
        url: u,
        downloadUrl: u,
        size: file?.byteSize || file?.size || 0,
      };
    };
    attachmentList.value.push(...finalBefore.map(f => mapToViewFile(f, 0)));
    attachmentList.value.push(...finalDuring.map(f => mapToViewFile(f, 1)));
    attachmentList.value.push(...finalAfter.map(f => mapToViewFile(f, 2)));
  };
  // å°†åŽç«¯è¿”回的文件地址规范成可访问URL
  const normalizeFileUrl = rawUrl => {
    try {
      if (!rawUrl || typeof rawUrl !== "string") return "";
      const url = rawUrl.trim();
      if (!url) return "";
      if (/^https?:\/\//i.test(url)) return url;
      if (url.startsWith("/")) return `${filePreviewBase}${url}`;
      // Windows path -> web path
      if (/^[a-zA-Z]:\\/.test(url)) {
        const normalized = url.replace(/\\/g, "/");
        const idx = normalized.indexOf("/prod/");
        if (idx >= 0) {
          const relative = normalized.slice(idx + "/prod/".length);
          return `${filePreviewBase}/${relative}`;
        }
        return normalized;
      }
      return `${filePreviewBase}/${url.replace(/^\//, "")}`;
    } catch (e) {
      return rawUrl || "";
    }
  };
  // è¿”回上一页
  const goBack = () => {
    uni.navigateBack();
  };
  // åˆ‡æ¢æŸ¥çœ‹ç±»åž‹
  const switchViewType = type => {
    currentViewType.value = type;
  };
  // æ ¹æ®type获取对应分类的附件
  const getAttachmentsByType = typeValue => {
    return attachmentList.value.filter(file => file.type === typeValue) || [];
  };
  // èŽ·å–å½“å‰æŸ¥çœ‹ç±»åž‹çš„é™„ä»¶
  const getCurrentViewAttachments = () => {
    switch (currentViewType.value) {
      case "before":
        return getAttachmentsByType(0);
      case "after":
        return getAttachmentsByType(1);
      case "issue":
        return getAttachmentsByType(2);
      default:
        return [];
    }
  };
  // åˆ¤æ–­æ˜¯å¦ä¸ºå›¾ç‰‡æ–‡ä»¶
  const isImageFile = file => {
    if (file.contentType && file.contentType.startsWith("image/")) {
      return true;
    }
    if (file.type === "image") return true;
    const name = file.bucketFilename || file.originalFilename || file.name || "";
    const ext = name.split(".").pop()?.toLowerCase();
    return ["jpg", "jpeg", "png", "gif", "webp"].includes(ext);
  };
  // é¢„览附件
  const previewAttachment = file => {
    if (isImageFile(file)) {
      const imageUrls = getCurrentViewAttachments()
        .filter(f => isImageFile(f))
        .map(f => f.url || f.downloadUrl);
      uni.previewImage({
        urls: imageUrls,
        current: file.url || file.downloadUrl,
      });
    } else {
      showVideoPreview(file);
    }
  };
  // æ˜¾ç¤ºè§†é¢‘预览
  const showVideoPreview = file => {
    currentVideoFile.value = file;
    showVideoDialog.value = true;
  };
  // å…³é—­è§†é¢‘预览
  const closeVideoPreview = () => {
    showVideoDialog.value = false;
    currentVideoFile.value = null;
  };
  // è§†é¢‘播放错误处理
  const handleVideoError = () => {
    uni.showToast({
      title: "视频播放失败",
      icon: "error",
    });
  };
  // æ ¼å¼åŒ–文件大小
  const formatFileSize = size => {
    if (!size) return "";
    if (size < 1024) return size + "B";
    if (size < 1024 * 1024) return (size / 1024).toFixed(1) + "KB";
    return (size / (1024 * 1024)).toFixed(1) + "MB";
  };
</script>
<style scoped>
  .attachment-page {
    min-height: 100vh;
    background-color: #f5f5f5;
  }
  .attachment-content {
    padding: 15px;
  }
  /* æ ‡ç­¾é¡µæ ·å¼ */
  .attachment-tabs {
    display: flex;
    background: #fff;
    border-radius: 12px;
    margin-bottom: 15px;
    padding: 4px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .tab-item {
    flex: 1;
    text-align: center;
    padding: 12px 8px;
    font-size: 14px;
    color: #666;
    border-radius: 8px;
    transition: all 0.3s ease;
  }
  .tab-item.active {
    background: #409eff;
    color: #fff;
    font-weight: 500;
  }
  /* é™„件列表样式 */
  .attachment-list-container {
    background: #fff;
    border-radius: 12px;
    padding: 15px;
    min-height: 400px;
  }
  .attachment-list {
    display: flex;
    flex-wrap: wrap;
    gap: 15px;
  }
  .attachment-item {
    width: calc(33.33% - 10px);
    background: #f8f9fa;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
    transition: all 0.3s ease;
  }
  .attachment-item:active {
    transform: scale(0.98);
  }
  .attachment-preview-container {
    width: 100%;
    height: 120px;
    background: #e9ecef;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .attachment-preview {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  .attachment-video-preview {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 8px;
  }
  .video-text {
    font-size: 12px;
    color: #666;
  }
  .attachment-info {
    padding: 10px;
  }
  .attachment-name {
    font-size: 12px;
    color: #333;
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    margin-bottom: 4px;
  }
  .attachment-size {
    font-size: 10px;
    color: #999;
  }
  /* ç©ºçŠ¶æ€æ ·å¼ */
  .attachment-empty {
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 80px 20px;
    color: #999;
  }
  .empty-text {
    margin-top: 15px;
    font-size: 14px;
  }
  /* è§†é¢‘弹窗样式 */
  .video-modal-overlay {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.9);
    z-index: 10000;
    display: flex;
    align-items: center;
    justify-content: center;
    padding: 20px;
  }
  .video-modal-container {
    width: 100%;
    max-width: 800px;
    background: #000;
    border-radius: 12px;
    overflow: hidden;
  }
  .video-modal-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 15px 20px;
    background: #1a1a1a;
  }
  .video-modal-title {
    font-size: 16px;
    color: #fff;
    font-weight: 500;
  }
  .close-btn-video {
    width: 32px;
    height: 32px;
    display: flex;
    align-items: center;
    justify-content: center;
    background: rgba(255, 255, 255, 0.1);
    border-radius: 50%;
  }
  .video-modal-body {
    padding: 20px;
  }
  .video-player {
    width: 100%;
    height: 400px;
    border-radius: 8px;
  }
</style>
src/pages/inspectionUpload/components/formDia.vue
@@ -8,53 +8,99 @@
  >
    <view class="popup-content">
      <view class="popup-header">
        <text class="popup-title">上传</text>
        <text class="popup-title">巡检记录上传</text>
      </view>
      
      <view class="upload-container">
        <!-- å¼‚常状态选择 -->
        <view class="form-container">
          <view class="title">生产前</view>
          <u-upload
            :fileList="beforeModelValue"
            @afterRead="afterRead"
            @delete="deleteFile"
            name="before"
            multiple
            :maxCount="10"
            :maxSize="5 * 1024 * 1024"
            accept="image/*"
            :previewFullImage="true"
          ></u-upload>
          <view class="title">巡检状态</view>
          <view class="exception-section">
            <view class="exception-options">
              <view
                class="exception-option"
                :class="{ active: hasException === false }"
                @click="setExceptionStatus(false)"
              >
                <u-icon name="checkmark-circle" size="20" color="#52c41a"></u-icon>
                <text class="option-text">正常</text>
              </view>
              <view
                class="exception-option"
                :class="{ active: hasException === true }"
                @click="setExceptionStatus(true)"
              >
                <u-icon name="close-circle" size="20" color="#ff4d4f"></u-icon>
                <text class="option-text">存在异常</text>
              </view>
            </view>
          </view>
        </view>
        <!-- å¼‚常描述(仅在异常时显示) -->
        <view class="form-container" v-if="hasException === true">
          <view class="title">异常描述</view>
          <u-input
            v-model="exceptionDescription"
            type="textarea"
            :maxlength="500"
            placeholder="请描述异常情况..."
            :customStyle="{ padding: '10px', backgroundColor: '#f5f5f5' }"
          />
        </view>
        
        <view class="form-container">
          <view class="title">生产后</view>
          <u-upload
            :fileList="afterModelValue"
            @afterRead="afterRead"
            @delete="deleteFile"
            name="after"
            multiple
            :maxCount="10"
            :maxSize="5 * 1024 * 1024"
            accept="image/*"
            :previewFullImage="true"
          ></u-upload>
        </view>
        <view class="form-container">
          <view class="title">生产问题</view>
          <u-upload
            :fileList="issueModelValue"
            @afterRead="afterRead"
            @delete="deleteFile"
            name="issue"
            multiple
            :maxCount="10"
            :maxSize="5 * 1024 * 1024"
            accept="image/*"
            :previewFullImage="true"
          ></u-upload>
        <!-- ä¸Šä¼ åŒºåŸŸï¼ˆä»…在异常时显示) -->
        <template v-if="hasException === true">
          <view class="form-container">
            <view class="title">生产前</view>
            <u-upload
              :fileList="beforeModelValue"
              @afterRead="afterRead"
              @delete="deleteFile"
              name="before"
              multiple
              :maxCount="10"
              :maxSize="5 * 1024 * 1024"
              accept="image/*"
              :previewFullImage="true"
            ></u-upload>
          </view>
          <view class="form-container">
            <view class="title">生产后</view>
            <u-upload
              :fileList="afterModelValue"
              @afterRead="afterRead"
              @delete="deleteFile"
              name="after"
              multiple
              :maxCount="10"
              :maxSize="5 * 1024 * 1024"
              accept="image/*"
              :previewFullImage="true"
            ></u-upload>
          </view>
          <view class="form-container">
            <view class="title">生产问题</view>
            <u-upload
              :fileList="issueModelValue"
              @afterRead="afterRead"
              @delete="deleteFile"
              name="issue"
              multiple
              :maxCount="10"
              :maxSize="5 * 1024 * 1024"
              accept="image/*"
              :previewFullImage="true"
            ></u-upload>
          </view>
        </template>
        <!-- æ­£å¸¸çŠ¶æ€æç¤º -->
        <view class="form-container normal-tip" v-if="hasException === false">
          <u-icon name="info-circle" size="40" color="#52c41a"></u-icon>
          <text class="tip-text">设备运行正常,无需上传照片</text>
        </view>
      </view>
      
@@ -79,6 +125,11 @@
const afterModelValue = ref([])
const issueModelValue = ref([])
const infoData = ref(null)
// å¼‚常状态:null=未选择, false=正常, true=异常
const hasException = ref(null)
// å¼‚常描述
const exceptionDescription = ref('')
// è®¡ç®—上传URL
const uploadFileUrl = computed(() => {
@@ -196,9 +247,43 @@
  }
}
// è®¾ç½®å¼‚常状态
const setExceptionStatus = (status) => {
  hasException.value = status
}
// æäº¤è¡¨å•
const submitForm = async () => {
  try {
    // æ£€æŸ¥æ˜¯å¦é€‰æ‹©äº†å·¡æ£€çŠ¶æ€
    if (hasException.value === null) {
      uni.showToast({
        title: '请选择巡检状态',
        icon: 'none'
      })
      return
    }
    // å¦‚果是异常状态,检查是否有上传文件
    if (hasException.value === true) {
      const totalFiles = beforeModelValue.value.length + afterModelValue.value.length + issueModelValue.value.length
      if (totalFiles === 0) {
        uni.showToast({
          title: '请上传异常照片',
          icon: 'none'
        })
        return
      }
      // æ£€æŸ¥æ˜¯å¦å¡«å†™äº†å¼‚常描述
      if (!exceptionDescription.value.trim()) {
        uni.showToast({
          title: '请填写异常描述',
          icon: 'none'
        })
        return
      }
    }
    let arr = []
    if (beforeModelValue.value.length > 0) {
      arr.push(...beforeModelValue.value.map(item => ({ ...item, statusType: 0 })))
@@ -212,6 +297,8 @@
    
    // æäº¤æ•°æ®
    infoData.value.storageBlobDTO = arr
    infoData.value.hasException = hasException.value
    infoData.value.exceptionDescription = exceptionDescription.value
    await submitInspectionRecord({ ...infoData.value })
    
    uni.showToast({
@@ -238,6 +325,8 @@
  beforeModelValue.value = []
  afterModelValue.value = []
  issueModelValue.value = []
  hasException.value = null
  exceptionDescription.value = ''
}
// å…³é—­å¼¹æ¡†
@@ -311,4 +400,61 @@
  border-top: 1px solid #f0f0f0;
  background-color: #fafafa;
}
// å¼‚常状态选择样式
.exception-section {
  padding: 10px 0;
}
.exception-options {
  display: flex;
  gap: 15px;
}
.exception-option {
  flex: 1;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  padding: 15px 20px;
  border: 2px solid #e0e0e0;
  border-radius: 8px;
  cursor: pointer;
  transition: all 0.3s;
  background-color: #fff;
  &.active {
    border-color: #1890ff;
    background-color: #e6f7ff;
  }
  &:active {
    opacity: 0.8;
  }
}
.option-text {
  font-size: 14px;
  color: #333;
  font-weight: 500;
}
// æ­£å¸¸çŠ¶æ€æç¤ºæ ·å¼
.normal-tip {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  padding: 40px 20px;
  background-color: #f6ffed;
  border: 1px dashed #b7eb8f;
  border-radius: 8px;
  .tip-text {
    margin-top: 15px;
    font-size: 14px;
    color: #52c41a;
  }
}
</style>
src/pages/inspectionUpload/index.vue
@@ -56,6 +56,10 @@
              <text class="detail-value">{{ item.taskId || item.id }}</text>
            </view>
            <view class="detail-item">
              <text class="detail-label">巡检项目</text>
              <text class="detail-value">{{ item.inspectionProject || '无' }}</text>
            </view>
            <view class="detail-item">
              <text class="detail-label">备注</text>
              <text class="detail-value">{{ item.remarks || '无' }}</text>
            </view>
@@ -80,7 +84,7 @@
                         size="small"
                         type="primary"
                         inverted></uni-tag>
                <uni-tag v-else=""
                <uni-tag v-else
                         text="未巡检"
                         size="small"
                         type="warning"
@@ -95,227 +99,6 @@
      <view v-if="taskTableData?.length === 0"
            class="no-data">
        <text>暂无数据</text>
      </view>
    </view>
    <!-- å›¾ç‰‡ä¸Šä¼ å¼¹çª— - åŽŸç”Ÿå®žçŽ° -->
    <view v-if="showUploadDialog"
          class="custom-modal-overlay"
          @click="closeUploadDialog">
      <view class="custom-modal-container"
            @click.stop>
        <view class="upload-popup-content">
          <view class="upload-popup-header">
            <text class="upload-popup-title">上传巡检记录</text>
          </view>
          <view class="upload-popup-body">
            <!-- åˆ†ç±»æ ‡ç­¾é¡µ -->
            <view class="upload-tabs">
              <view class="tab-item"
                    :class="{ active: currentUploadType === 'before' }"
                    @click="switchUploadType('before')">
                ç”Ÿäº§å‰
              </view>
              <view class="tab-item"
                    :class="{ active: currentUploadType === 'after' }"
                    @click="switchUploadType('after')">
                ç”Ÿäº§ä¸­
              </view>
              <view class="tab-item"
                    :class="{ active: currentUploadType === 'issue' }"
                    @click="switchUploadType('issue')">
                ç”Ÿäº§åŽ
              </view>
            </view>
            <!-- å¼‚常状态选择 -->
            <view class="exception-section">
              <text class="section-title">是否存在异常?</text>
              <view class="exception-options">
                <view class="exception-option"
                      :class="{ active: hasException === false }"
                      @click="setExceptionStatus(false)">
                  <u-icon name="checkmark-circle"
                          size="20"
                          color="#52c41a"></u-icon>
                  <text>正常</text>
                </view>
                <view class="exception-option"
                      :class="{ active: hasException === true }"
                      @click="setExceptionStatus(true)">
                  <u-icon name="close-circle"
                          size="20"
                          color="#ff4d4f"></u-icon>
                  <text>存在异常</text>
                </view>
              </view>
            </view>
            <!-- å½“前分类的上传区域 -->
            <view class="simple-upload-area">
              <view class="upload-buttons">
                <u-button type="primary"
                          @click="chooseMedia('image')"
                          :loading="uploading"
                          :disabled="getCurrentFiles().length >= uploadConfig.limit"
                          :customStyle="{ marginRight: '10px', flex: 1 }">
                  <u-icon name="camera"
                          size="18"
                          color="#fff"
                          style="margin-right: 5px;"></u-icon>
                  {{ uploading ? '上传中...' : '拍照' }}
                </u-button>
                <u-button type="success"
                          @click="chooseMedia('video')"
                          :loading="uploading"
                          :disabled="getCurrentFiles().length >= uploadConfig.limit"
                          :customStyle="{ flex: 1 }">
                  <uni-icons type="videocam"
                             name="videocam"
                             size="18"
                             color="#fff"
                             style="margin-right: 5px;"></uni-icons>
                  {{ uploading ? '上传中...' : '拍视频' }}
                </u-button>
              </view>
              <!-- ä¸Šä¼ è¿›åº¦ -->
              <view v-if="uploading"
                    class="upload-progress">
                <u-line-progress :percentage="uploadProgress"
                                 :showText="true"
                                 activeColor="#409eff"></u-line-progress>
              </view>
              <!-- å½“前分类的文件列表 -->
              <view v-if="getCurrentFiles().length > 0"
                    class="file-list">
                <view v-for="(file, index) in getCurrentFiles()"
                      :key="index"
                      class="file-item">
                  <view class="file-preview-container">
                    <image v-if="file.type === 'image' || (file.type !== 'video' && !file.type)"
                           :src="file.url || file.tempFilePath || file.path || file.downloadUrl"
                           class="file-preview"
                           mode="aspectFill" />
                    <view v-else-if="file.type === 'video'"
                          class="video-preview">
                      <uni-icons type="videocam"
                                 name="videocam"
                                 size="18"
                                 color="#fff"
                                 style="margin-right: 5px;"></uni-icons>
                      <text class="video-text">视频</text>
                    </view>
                    <!-- åˆ é™¤æŒ‰é’® -->
                    <view class="delete-btn"
                          @click="removeFile(index)">
                      <u-icon name="close"
                              size="12"
                              color="#fff"></u-icon>
                    </view>
                  </view>
                  <view class="file-info">
                    <text class="file-name">{{ file.bucketFilename || file.name || (file.type === 'image' ? '图片' : '视频')
                      }}</text>
                    <text class="file-size">{{ formatFileSize(file.size) }}</text>
                  </view>
                </view>
              </view>
              <view v-if="getCurrentFiles().length === 0"
                    class="empty-state">
                <text>请选择要上传的{{ getUploadTypeText() }}图片或视频</text>
              </view>
              <!-- ç»Ÿè®¡ä¿¡æ¯ -->
              <view class="upload-summary">
                <text class="summary-text">
                  ç”Ÿäº§å‰: {{ beforeModelValue.length }}个文件 |
                  ç”Ÿäº§ä¸­: {{ afterModelValue.length }}个文件 |
                  ç”Ÿäº§åŽ: {{ issueModelValue.length }}个文件
                </text>
              </view>
            </view>
          </view>
          <view class="upload-popup-footer">
            <u-button @click="closeUploadDialog"
                      :customStyle="{ marginRight: '10px' }">取消</u-button>
            <u-button v-if="hasException === true"
                      type="warning"
                      @click="goToRepair"
                      :customStyle="{ marginRight: '10px' }">
              æ–°å¢žæŠ¥ä¿®
            </u-button>
            <u-button type="primary"
                      @click="submitUpload">提交</u-button>
          </view>
        </view>
      </view>
    </view>
    <!-- æŸ¥çœ‹é™„件弹窗 -->
    <view v-if="showAttachmentDialog"
          class="custom-modal-overlay"
          @click="closeAttachmentDialog">
      <view class="custom-modal-container"
            @click.stop>
        <view class="attachment-popup-content">
          <view class="attachment-popup-header">
            <text class="attachment-popup-title">查看附件 - {{ currentViewTask?.taskName }}</text>
            <view class="close-btn-attachment"
                  @click="closeAttachmentDialog">
              <u-icon name="close"
                      size="16"
                      color="#666"></u-icon>
            </view>
          </view>
          <view class="attachment-popup-body">
            <!-- åˆ†ç±»æ ‡ç­¾é¡µ -->
            <view class="attachment-tabs">
              <view class="tab-item"
                    :class="{ active: currentViewType === 'before' }"
                    @click="switchViewType('before')">
                ç”Ÿäº§å‰ ({{ getAttachmentsByType(0).length }})
              </view>
              <view class="tab-item"
                    :class="{ active: currentViewType === 'after' }"
                    @click="switchViewType('after')">
                ç”Ÿäº§ä¸­ ({{ getAttachmentsByType(1).length }})
              </view>
              <view class="tab-item"
                    :class="{ active: currentViewType === 'issue' }"
                    @click="switchViewType('issue')">
                ç”Ÿäº§åŽ ({{ getAttachmentsByType(2).length }})
              </view>
            </view>
            <!-- å½“前分类的附件列表 -->
            <view class="attachment-content">
              <view v-if="getCurrentViewAttachments().length > 0"
                    class="attachment-list">
                <view v-for="(file, index) in getCurrentViewAttachments()"
                      :key="index"
                      class="attachment-item"
                      @click="previewAttachment(file)">
                  <view class="attachment-preview-container">
                    <image v-if="file.type === 'image' || isImageFile(file)"
                           :src="file.url || file.downloadUrl"
                           class="attachment-preview"
                           mode="aspectFill" />
                    <view v-else
                          class="attachment-video-preview">
                      <u-icon name="video"
                              size="24"
                              color="#409eff"></u-icon>
                      <text class="video-text">视频</text>
                    </view>
                  </view>
                  <view class="attachment-info">
                    <text class="attachment-name">{{ file.originalFilename || file.bucketFilename || file.name || '附件'
                      }}</text>
                    <text class="attachment-size">{{ formatFileSize(file.byteSize || file.size) }}</text>
                  </view>
                </view>
              </view>
              <view v-else
                    class="attachment-empty">
                <text>该分类暂无附件</text>
              </view>
            </view>
          </view>
        </view>
      </view>
    </view>
    <!-- è§†é¢‘预览弹窗 -->
@@ -378,57 +161,9 @@
  const currentScanningTask = ref(null);
  const infoData = ref(null);
  // ä¸Šä¼ ç›¸å…³çŠ¶æ€
  const showUploadDialog = ref(false);
  const uploadFiles = ref([]); // ä¿ç•™ç”¨äºŽå…¼å®¹æ€§
  const uploadStatusType = ref(0);
  const uploading = ref(false);
  const uploadProgress = ref(0);
  const number = ref(0);
  const uploadList = ref([]);
  // ä¸‰ä¸ªåˆ†ç±»çš„上传状态
  const beforeModelValue = ref([]); // ç”Ÿäº§å‰
  const afterModelValue = ref([]); // ç”Ÿäº§ä¸­
  const issueModelValue = ref([]); // ç”Ÿäº§åŽ
  // å½“前激活的上传类型
  const currentUploadType = ref("before"); // 'before', 'after', 'issue'
  // æŸ¥çœ‹é™„件相关状态
  const showAttachmentDialog = ref(false);
  const currentViewTask = ref(null);
  const currentViewType = ref("before"); // 'before', 'after', 'issue'
  const attachmentList = ref([]); // å½“前查看任务的附件列表
  // è§†é¢‘预览相关状态
  const showVideoDialog = ref(false);
  const currentVideoFile = ref(null);
  // å¼‚常状态
  const hasException = ref(null); // null: æœªé€‰æ‹©, true: å­˜åœ¨å¼‚常, false: æ­£å¸¸
  // ä¸Šä¼ é…ç½®
  const uploadConfig = {
    action: "/file/upload",
    limit: 10,
    fileSize: 50, // MB
    fileType: ["jpg", "jpeg", "png", "mp4", "mov"],
    maxVideoDuration: 60, // ç§’
  };
  // è®¡ç®—上传URL
  const uploadFileUrl = computed(() => {
    const baseUrl = config.baseUrl;
    return baseUrl + uploadConfig.action;
  });
  // è®¡ç®—请求头
  const headers = computed(() => {
    const token = getToken();
    return token ? { Authorization: "Bearer " + token } : {};
  });
  // è¯·æ±‚取消标志,用于取消正在进行的请求
  let isRequestCancelled = false;
@@ -489,11 +224,6 @@
  onUnmounted(() => {
    // è®¾ç½®å–消标志,阻止后续的异步操作
    isRequestCancelled = true;
    // å…³é—­ä¸Šä¼ å¼¹çª—
    if (showUploadDialog.value) {
      showUploadDialog.value = false;
    }
  });
  // è¿”回上一页
@@ -567,11 +297,15 @@
  const getFileStatus = record => {
    let _beforeProduction =
      record.beforeProduction && record.beforeProduction.length;
      (record.commonFileListBeforeVO && record.commonFileListBeforeVO.length) ||
      (record.commonFileListBefore && record.commonFileListBefore.length);
    let _afterProduction =
      record.afterProduction && record.afterProduction.length;
      (record.commonFileListVO && record.commonFileListVO.length) ||
      (record.commonFileListAfter && record.commonFileListAfter.length);
    let _productionIssues =
      record.productionIssues && record.productionIssues.length;
      (record.commonFileListAfterVO && record.commonFileListAfterVO.length) ||
      (record.commonFileListIssue && record.commonFileListIssue.length);
    if (_beforeProduction && _afterProduction && _productionIssues) {
      return 2;
    } else if (_beforeProduction || _afterProduction || _productionIssues) {
@@ -653,322 +387,27 @@
    }
  };
  // æ‰“开上传弹窗
  // æ‰“开上传页面
  const openUploadDialog = task => {
    // è®¾ç½®ä»»åŠ¡ä¿¡æ¯åˆ°infoData
    if (task) {
      infoData.value = {
        ...task,
        taskId: task.taskId || task.id,
        storageBlobDTO: [], // åˆå§‹åŒ–文件列表
      };
    }
    // è®¾ç½®ä¸Šä¼ çŠ¶æ€ç±»åž‹ï¼ˆå¯ä»¥æ ¹æ®ä»»åŠ¡ç±»åž‹è®¾ç½®ä¸åŒçš„çŠ¶æ€ï¼‰
    uploadStatusType.value = 0; // é»˜è®¤çŠ¶æ€
    // æ¸…空之前的文件
    uploadFiles.value = [];
    // æ˜¾ç¤ºä¸Šä¼ å¼¹çª—
    showUploadDialog.value = true;
  };
  // å…³é—­ä¸Šä¼ å¼¹çª—
  const closeUploadDialog = () => {
    showUploadDialog.value = false;
    uploadFiles.value = [];
    // æ¸…理三个分类的数据
    beforeModelValue.value = [];
    afterModelValue.value = [];
    issueModelValue.value = [];
    currentUploadType.value = "before";
    hasException.value = null; // é‡ç½®å¼‚常状态
    infoData.value = null; // æ¸…理任务数据
  };
  // åˆ‡æ¢ä¸Šä¼ ç±»åž‹
  const switchUploadType = type => {
    currentUploadType.value = type;
  };
  // èŽ·å–å½“å‰åˆ†ç±»çš„æ–‡ä»¶åˆ—è¡¨
  const getCurrentFiles = () => {
    switch (currentUploadType.value) {
      case "before":
        return beforeModelValue.value || [];
      case "after":
        return afterModelValue.value || [];
      case "issue":
        return issueModelValue.value || [];
      default:
        return [];
    }
  };
  // èŽ·å–ä¸Šä¼ ç±»åž‹æ–‡æœ¬
  const getUploadTypeText = () => {
    switch (currentUploadType.value) {
      case "before":
        return "生产前";
      case "after":
        return "生产中";
      case "issue":
        return "生产后";
      default:
        return "";
    }
  };
  // å¤„理上传文件更新
  const handleUploadUpdate = files => {
    uploadFiles.value = files;
  };
  // è®¾ç½®å¼‚常状态
  const setExceptionStatus = status => {
    hasException.value = status;
  };
  // è·³è½¬åˆ°æ–°å¢žæŠ¥ä¿®é¡µé¢
  const goToRepair = () => {
    try {
      // å­˜å‚¨å½“前任务信息到本地存储,供报修页面使用
      const taskInfo = {
        taskId: infoData.value?.taskId || infoData.value?.id,
        taskName: infoData.value?.taskName,
        inspectionLocation: infoData.value?.inspectionLocation,
        inspector: infoData.value?.inspector,
        // ä¼ é€’当前上传的文件信息
        uploadedFiles: {
          before: beforeModelValue.value,
          after: afterModelValue.value,
          issue: issueModelValue.value,
        },
      };
      uni.setStorageSync("repairTaskInfo", JSON.stringify(taskInfo));
      // è·³è½¬åˆ°æ–°å¢žæŠ¥ä¿®é¡µé¢
      uni.navigateTo({
        url: "/pages/equipmentManagement/repair/add",
      });
      // å…³é—­ä¸Šä¼ å¼¹çª—
      closeUploadDialog();
    } catch (error) {
      console.error("跳转报修页面失败:", error);
      uni.showToast({
        title: "跳转失败,请重试",
        icon: "error",
      });
    }
  };
  // æäº¤ä¸Šä¼ 
  const submitUpload = async () => {
    try {
      // æ£€æŸ¥æ˜¯å¦é€‰æ‹©äº†å¼‚常状态
      if (hasException.value === null) {
        uni.showToast({
          title: "请选择是否存在异常",
          icon: "none",
        });
        return;
      }
      // æ£€æŸ¥æ˜¯å¦æœ‰ä»»ä½•文件上传
      const totalFiles =
        beforeModelValue.value.length +
        afterModelValue.value.length +
        issueModelValue.value.length;
      if (totalFiles === 0) {
        uni.showToast({
          title: "请先上传文件",
          icon: "none",
        });
        return;
      }
      // æ˜¾ç¤ºæäº¤ä¸­çš„加载提示
      showLoadingToast("提交中...");
      // æŒ‰ç…§æ‚¨çš„逻辑合并所有分类的文件
      let arr = [];
      if (beforeModelValue.value.length > 0) {
        arr.push(...beforeModelValue.value);
      }
      if (afterModelValue.value.length > 0) {
        arr.push(...afterModelValue.value);
      }
      if (issueModelValue.value.length > 0) {
        arr.push(...issueModelValue.value);
      }
      // ä¼ ç»™åŽç«¯çš„临时文件ID列表(tempFileIds)
      // å…¼å®¹ï¼šæœ‰äº›æŽ¥å£å¯èƒ½è¿”回 tempId / tempFileId / id
      let tempFileIds = [];
      if (arr !== null && arr.length > 0) {
        tempFileIds = arr
          .map(item => item?.tempId ?? item?.tempFileId ?? item?.id)
          .filter(v => v !== undefined && v !== null && v !== "");
      }
      // æäº¤æ•°æ®
      infoData.value.storageBlobDTO = arr;
      // æ·»åŠ å¼‚å¸¸çŠ¶æ€ä¿¡æ¯
      infoData.value.hasException = hasException.value;
      infoData.value.tempFileIds = tempFileIds;
      const result = await uploadInspectionTask({ ...infoData.value });
      // æ£€æŸ¥æäº¤ç»“æžœ
      if (result && (result.code === 200 || result.success)) {
        // æäº¤æˆåŠŸ
        closeToast(); // å…³é—­åŠ è½½æç¤º
        uni.showToast({
          title: "提交成功",
          icon: "success",
        });
        // å…³é—­å¼¹çª—
        closeUploadDialog();
        // åˆ·æ–°åˆ—表
        setTimeout(() => {
          reloadPage();
        }, 500);
      } else {
        // æäº¤å¤±è´¥
        closeToast();
        uni.showToast({
          title: result?.msg || result?.message || "提交失败",
          icon: "error",
        });
      }
    } catch (error) {
      console.error("提交上传失败:", error);
      closeToast(); // å…³é—­åŠ è½½æç¤º
      let errorMessage = "提交失败";
      if (error.message) {
        errorMessage = error.message;
      } else if (error.msg) {
        errorMessage = error.msg;
      } else if (typeof error === "string") {
        errorMessage = error;
      }
      uni.showToast({
        title: errorMessage,
        icon: "error",
      });
    }
    // å°†ä»»åŠ¡ä¿¡æ¯ä¼ é€’åˆ°ä¸Šä¼ é¡µé¢
    const taskData = encodeURIComponent(JSON.stringify(task));
    uni.navigateTo({
      url: `/pages/inspectionUpload/upload?taskInfo=${taskData}`,
    });
  };
  // å›¾ç‰‡ä¸Šä¼ (可选择图片上传或者是相机拍照)
  const startUploadForTask = async (task, type) => {
    // ç›´æŽ¥æ‰“开上传弹窗
    // æ‰“开上传页面
    openUploadDialog(task);
  };
  // æŸ¥çœ‹é™„ä»¶
  // æŸ¥çœ‹é™„ä»¶ - è·³è½¬åˆ°é™„件页面
  const viewAttachments = async task => {
    try {
      currentViewTask.value = task;
      currentViewType.value = "before";
      // è§£æžæ–°çš„æ•°æ®ç»“æž„
      attachmentList.value = [];
      // åŽç«¯åæ˜¾å­—段(你提供的数据结构):
      // - commonFileListBefore:生产前(通常 type=10)
      // - commonFileListAfter:生产中(通常 type=11)
      // - commonFileList:可能是全部/兜底(若包含生产后,一般 type=12)
      const allList = Array.isArray(task?.commonFileList)
        ? task.commonFileList
        : [];
      const beforeList = Array.isArray(task?.commonFileListBefore)
        ? task.commonFileListBefore
        : allList.filter(f => f?.type === 10);
      const afterList = Array.isArray(task?.commonFileListAfter)
        ? task.commonFileListAfter
        : allList.filter(f => f?.type === 11);
      // å¦‚果后端后续补了 commonFileListIssue,则优先用;否则从 commonFileList é‡ŒæŒ‰ type=12 å…œåº•
      const issueList = Array.isArray(task?.commonFileListIssue)
        ? task.commonFileListIssue
        : allList.filter(f => f?.type === 12);
      const mapToViewFile = (file, viewType) => {
        const u = normalizeFileUrl(file?.url || file?.downloadUrl || "");
        return {
          ...file,
          // ç”¨äºŽä¸‰æ ‡ç­¾é¡µåˆ†ç»„:0=生产前 1=生产中 2=生产后
          type: viewType,
          name: file?.name || file?.originalFilename || file?.bucketFilename,
          bucketFilename: file?.bucketFilename || file?.name,
          originalFilename: file?.originalFilename || file?.name,
          url: u,
          downloadUrl: u,
          size: file?.size || file?.byteSize,
        };
      };
      attachmentList.value.push(...beforeList.map(f => mapToViewFile(f, 0)));
      attachmentList.value.push(...afterList.map(f => mapToViewFile(f, 1)));
      attachmentList.value.push(...issueList.map(f => mapToViewFile(f, 2)));
      showAttachmentDialog.value = true;
    } catch (error) {
      uni.showToast({
        title: "获取附件失败",
        icon: "error",
      });
    }
  };
  // å…³é—­é™„件查看弹窗
  const closeAttachmentDialog = () => {
    showAttachmentDialog.value = false;
    currentViewTask.value = null;
    attachmentList.value = [];
    currentViewType.value = "before";
  };
  // åˆ‡æ¢æŸ¥çœ‹ç±»åž‹
  const switchViewType = type => {
    currentViewType.value = type;
  };
  // æ ¹æ®type获取对应分类的附件
  const getAttachmentsByType = typeValue => {
    return attachmentList.value.filter(file => file.type === typeValue) || [];
  };
  // èŽ·å–type值
  const getTabType = () => {
    switch (currentUploadType.value) {
      case "before":
        return 10;
      case "after":
        return 11;
      case "issue":
        return 12;
      default:
        return 10;
    }
  };
  // èŽ·å–å½“å‰æŸ¥çœ‹ç±»åž‹çš„é™„ä»¶
  const getCurrentViewAttachments = () => {
    switch (currentViewType.value) {
      case "before":
        return getAttachmentsByType(0);
      case "after":
        return getAttachmentsByType(1);
      case "issue":
        return getAttachmentsByType(2);
      default:
        return [];
    }
    const taskData = encodeURIComponent(JSON.stringify(task));
    uni.navigateTo({
      url: `/pages/inspectionUpload/attachment?taskInfo=${taskData}`,
    });
  };
  // åˆ¤æ–­æ˜¯å¦ä¸ºå›¾ç‰‡æ–‡ä»¶
@@ -1058,475 +497,6 @@
      title: "视频播放失败",
      icon: "error",
    });
  };
  // æ‹ç…§/拍视频(真机优先用 chooseMedia;不支持则降级)
  const chooseMedia = type => {
    if (getCurrentFiles().length >= uploadConfig.limit) {
      uni.showToast({
        title: `最多只能选择${uploadConfig.limit}个文件`,
        icon: "none",
      });
      return;
    }
    const remaining = uploadConfig.limit - getCurrentFiles().length;
    // ä¼˜å…ˆï¼šchooseMedia(支持 image/video)
    if (typeof uni.chooseMedia === "function") {
      uni.chooseMedia({
        count: Math.min(remaining, 1),
        mediaType: [type || "image"],
        sizeType: ["compressed", "original"],
        sourceType: ["camera"],
        success: res => {
          try {
            const files = res?.tempFiles || [];
            if (!files.length) throw new Error("未获取到文件");
            files.forEach((tf, idx) => {
              const filePath = tf.tempFilePath || tf.path || "";
              const fileType = tf.fileType || type || "image";
              const ext = fileType === "video" ? "mp4" : "jpg";
              const file = {
                tempFilePath: filePath,
                path: filePath,
                type: fileType,
                name: `${fileType}_${Date.now()}_${idx}.${ext}`,
                size: tf.size || 0,
                duration: tf.duration || 0,
                createTime: Date.now(),
                uid: Date.now() + Math.random() + idx,
              };
              handleBeforeUpload(file);
            });
          } catch (e) {
            console.error("处理拍摄结果失败:", e);
            uni.showToast({ title: "处理文件失败", icon: "error" });
          }
        },
        fail: err => {
          console.error("拍摄失败:", err);
          uni.showToast({ title: "拍摄失败", icon: "error" });
        },
      });
      return;
    }
    // é™çº§ï¼šchooseImage / chooseVideo
    if (type === "video") {
      chooseVideo();
    } else {
      uni.chooseImage({
        count: 1,
        sizeType: ["compressed", "original"],
        sourceType: ["camera"],
        success: res => {
          const tempFilePath = res?.tempFilePaths?.[0];
          const tempFile = res?.tempFiles?.[0] || {};
          if (!tempFilePath) return;
          handleBeforeUpload({
            tempFilePath,
            path: tempFilePath,
            type: "image",
            name: `photo_${Date.now()}.jpg`,
            size: tempFile.size || 0,
            createTime: Date.now(),
            uid: Date.now() + Math.random(),
          });
        },
      });
    }
  };
  // æ‹ç…§
  const chooseImage = () => {
    if (uploadFiles.value.length >= uploadConfig.limit) {
      uni.showToast({
        title: `最多只能拍摄${uploadConfig.limit}个文件`,
        icon: "none",
      });
      return;
    }
    uni.chooseMedia({
      count: 1,
      mediaType: ["image", "video"],
      sizeType: ["compressed", "original"],
      sourceType: ["camera"],
      success: res => {
        try {
          if (!res.tempFiles || res.tempFiles.length === 0) {
            throw new Error("未获取到图片文件");
          }
          const tempFilePath = res.tempFiles[0];
          const tempFile =
            res.tempFiles && res.tempFiles[0] ? res.tempFiles[0] : {};
          const file = {
            tempFilePath: tempFilePath,
            path: tempFilePath, // ä¿æŒå…¼å®¹æ€§
            type: "image",
            name: `photo_${Date.now()}.jpg`,
            size: tempFile.size || 0,
            createTime: new Date().getTime(),
            uid: Date.now() + Math.random(),
          };
          handleBeforeUpload(file);
        } catch (error) {
          console.error("处理拍照结果失败:", error);
          uni.showToast({
            title: "处理图片失败",
            icon: "error",
          });
        }
      },
      fail: err => {
        console.error("拍照失败:", err);
        uni.showToast({
          title: "拍照失败: " + (err.errMsg || "未知错误"),
          icon: "error",
        });
      },
    });
  };
  // æ‹è§†é¢‘
  const chooseVideo = () => {
    if (uploadFiles.value.length >= uploadConfig.limit) {
      uni.showToast({
        title: `最多只能拍摄${uploadConfig.limit}个文件`,
        icon: "none",
      });
      return;
    }
    uni.chooseVideo({
      sourceType: ["camera"],
      maxDuration: uploadConfig.maxVideoDuration,
      camera: "back",
      success: res => {
        try {
          if (!res.tempFilePath) {
            throw new Error("未获取到视频文件");
          }
          const file = {
            tempFilePath: res.tempFilePath,
            path: res.tempFilePath, // ä¿æŒå…¼å®¹æ€§
            type: "video",
            name: `video_${Date.now()}.mp4`,
            size: res.size || 0,
            duration: res.duration || 0,
            createTime: new Date().getTime(),
            uid: Date.now() + Math.random(),
          };
          handleBeforeUpload(file);
        } catch (error) {
          console.error("处理拍视频结果失败:", error);
          uni.showToast({
            title: "处理视频失败",
            icon: "error",
          });
        }
      },
      fail: err => {
        console.error("拍视频失败:", err);
        uni.showToast({
          title: "拍视频失败: " + (err.errMsg || "未知错误"),
          icon: "error",
        });
      },
    });
  };
  // åˆ é™¤æ–‡ä»¶
  const removeFile = index => {
    uni.showModal({
      title: "确认删除",
      content: "确定要删除这个文件吗?",
      success: res => {
        if (res.confirm) {
          // æ ¹æ®å½“前上传类型删除对应分类的文件
          switch (currentUploadType.value) {
            case "before":
              beforeModelValue.value.splice(index, 1);
              break;
            case "after":
              afterModelValue.value.splice(index, 1);
              break;
            case "issue":
              issueModelValue.value.splice(index, 1);
              break;
          }
          uni.showToast({
            title: "删除成功",
            icon: "success",
          });
        }
      },
    });
  };
  // æ£€æŸ¥ç½‘络连接
  const checkNetworkConnection = () => {
    return new Promise(resolve => {
      uni.getNetworkType({
        success: res => {
          if (res.networkType === "none") {
            resolve(false);
          } else {
            resolve(true);
          }
        },
        fail: () => {
          resolve(false);
        },
      });
    });
  };
  // ä¸Šä¼ å‰æ ¡éªŒ
  const handleBeforeUpload = async file => {
    // æ ¡éªŒæ–‡ä»¶ç±»åž‹
    if (
      uploadConfig.fileType &&
      Array.isArray(uploadConfig.fileType) &&
      uploadConfig.fileType.length > 0
    ) {
      const fileName = file.name || "";
      const fileExtension = fileName
        ? fileName.split(".").pop().toLowerCase()
        : "";
      // æ ¹æ®æ–‡ä»¶ç±»åž‹ç¡®å®šæœŸæœ›çš„æ‰©å±•名
      let expectedTypes = [];
      if (file.type === "image") {
        expectedTypes = ["jpg", "jpeg", "png", "gif", "webp"];
      } else if (file.type === "video") {
        expectedTypes = ["mp4", "mov", "avi", "wmv"];
      }
      // æ£€æŸ¥æ–‡ä»¶æ‰©å±•名是否在允许的类型中
      if (fileExtension && expectedTypes.length > 0) {
        const isAllowed = expectedTypes.some(
          type => uploadConfig.fileType.includes(type) && type === fileExtension
        );
        if (!isAllowed) {
          uni.showToast({
            title: `文件格式不支持,请拍摄 ${expectedTypes.join("/")} æ ¼å¼çš„æ–‡ä»¶`,
            icon: "none",
          });
          return false;
        }
      }
    }
    // æ ¡éªŒé€šè¿‡ï¼Œå¼€å§‹ä¸Šä¼ 
    uploadFile(file);
    return true;
  };
  // æ–‡ä»¶ä¸Šä¼ å¤„理(真机走 uni.uploadFile)
  const uploadFile = async file => {
    uploading.value = true;
    uploadProgress.value = 0;
    number.value++; // å¢žåŠ ä¸Šä¼ è®¡æ•°
    // ç¡®ä¿token存在
    const token = getToken();
    if (!token) {
      handleUploadError("用户未登录");
      return;
    }
    const typeValue = getTabType(); // ç”Ÿäº§å‰:10, ç”Ÿäº§ä¸­:11, ç”Ÿäº§åŽ:12
    uploadWithUniUploadFile(
      file,
      file.tempFilePath || file.path || "",
      typeValue,
      token
    );
  };
  // ä½¿ç”¨uni.uploadFile上传(非H5环境或H5回退方案)
  const uploadWithUniUploadFile = (file, filePath, typeValue, token) => {
    if (!filePath) {
      handleUploadError("文件路径不存在");
      return;
    }
    const uploadTask = uni.uploadFile({
      url: uploadFileUrl.value,
      filePath: filePath,
      name: "file",
      formData: {
        type: typeValue,
      },
      header: {
        Authorization: `Bearer ${token}`,
      },
      success: res => {
        try {
          if (res.statusCode === 200) {
            const response = JSON.parse(res.data);
            if (response.code === 200) {
              handleUploadSuccess(response, file);
              uni.showToast({
                title: "上传成功",
                icon: "success",
              });
            } else {
              handleUploadError(response.msg || "服务器返回错误");
            }
          } else {
            handleUploadError(`服务器错误,状态码: ${res.statusCode}`);
          }
        } catch (e) {
          console.error("解析响应失败:", e);
          console.error("原始响应数据:", res.data);
          handleUploadError("响应数据解析失败: " + e.message);
        }
      },
      fail: err => {
        console.error("上传失败:", err.errMsg || err);
        number.value--; // ä¸Šä¼ å¤±è´¥æ—¶å‡å°‘计数
        let errorMessage = "上传失败";
        if (err.errMsg) {
          if (err.errMsg.includes("statusCode: null")) {
            errorMessage = "网络连接失败,请检查网络设置";
          } else if (err.errMsg.includes("timeout")) {
            errorMessage = "上传超时,请重试";
          } else if (err.errMsg.includes("fail")) {
            errorMessage = "上传失败,请检查网络连接";
          } else {
            errorMessage = err.errMsg;
          }
        }
        handleUploadError(errorMessage);
      },
      complete: () => {
        uploading.value = false;
        uploadProgress.value = 0;
      },
    });
    // ç›‘听上传进度
    if (uploadTask && uploadTask.onProgressUpdate) {
      uploadTask.onProgressUpdate(res => {
        uploadProgress.value = res.progress;
      });
    }
  };
  // ä¸Šä¼ å¤±è´¥å¤„理
  const handleUploadError = (message = "上传文件失败", showRetry = false) => {
    uploading.value = false;
    uploadProgress.value = 0;
    if (showRetry) {
      uni.showModal({
        title: "上传失败",
        content: message + ",是否重试?",
        success: res => {
          if (res.confirm) {
            // ç”¨æˆ·é€‰æ‹©é‡è¯•,这里可以重新触发上传
          }
        },
      });
    } else {
      uni.showToast({
        title: message,
        icon: "error",
      });
    }
  };
  // ä¸Šä¼ æˆåŠŸå›žè°ƒ
  const handleUploadSuccess = (res, file) => {
    console.log("上传成功响应:", res);
    // å¤„理不同的数据结构:可能是数组,也可能是单个对象
    let uploadedFile = null;
    uploadedFile = res.data;
    if (!uploadedFile) {
      console.error("无法解析上传响应数据:", res);
      number.value--; // ä¸Šä¼ å¤±è´¥æ—¶å‡å°‘计数
      handleUploadError("上传响应数据格式错误", false);
      return;
    }
    // æ ¹æ®å½“前上传类型设置type字段
    let typeValue = 0; // é»˜è®¤ä¸ºç”Ÿäº§å‰
    switch (currentUploadType.value) {
      case "before":
        typeValue = 0;
        break;
      case "after":
        typeValue = 1;
        break;
      case "issue":
        typeValue = 2;
        break;
    }
    // ç¡®ä¿ä¸Šä¼ çš„æ–‡ä»¶æ•°æ®å®Œæ•´ï¼ŒåŒ…含id和type
    const fileData = {
      ...file,
      id: uploadedFile.id, // æ·»åŠ æœåŠ¡å™¨è¿”å›žçš„id
      tempId: uploadedFile.tempId ?? uploadedFile.tempFileId ?? uploadedFile.id,
      url:
        uploadedFile.url ||
        uploadedFile.downloadUrl ||
        file.tempFilePath ||
        file.path,
      bucketFilename:
        uploadedFile.bucketFilename || uploadedFile.originalFilename || file.name,
      downloadUrl: uploadedFile.downloadUrl || uploadedFile.url,
      size: uploadedFile.size || uploadedFile.byteSize || file.size,
      createTime: uploadedFile.createTime || new Date().getTime(),
      type: typeValue, // æ·»åŠ ç±»åž‹å­—æ®µï¼š0=生产前, 1=生产中, 2=生产后
    };
    uploadList.value.push(fileData);
    // ç«‹å³æ·»åŠ åˆ°å¯¹åº”çš„åˆ†ç±»ï¼Œä¸ç­‰å¾…æ‰€æœ‰æ–‡ä»¶ä¸Šä¼ å®Œæˆ
    switch (currentUploadType.value) {
      case "before":
        beforeModelValue.value.push(fileData);
        break;
      case "after":
        afterModelValue.value.push(fileData);
        break;
      case "issue":
        issueModelValue.value.push(fileData);
        break;
    }
    // é‡ç½®ä¸Šä¼ åˆ—表(因为已经添加到对应分类了)
    uploadList.value = [];
    number.value = 0;
  };
  // ä¸Šä¼ ç»“束处理(已废弃,现在在handleUploadSuccess中直接处理)
  const uploadedSuccessfully = () => {
    // æ­¤å‡½æ•°å·²ä¸å†ä½¿ç”¨ï¼Œæ–‡ä»¶ä¸Šä¼ æˆåŠŸåŽç«‹å³æ·»åŠ åˆ°å¯¹åº”åˆ†ç±»
  };
  // æ ¼å¼åŒ–文件大小
  const formatFileSize = size => {
    if (!size) return "";
    if (size < 1024) return size + "B";
    if (size < 1024 * 1024) return (size / 1024).toFixed(1) + "KB";
    return (size / (1024 * 1024)).toFixed(1) + "MB";
  };
</script>
@@ -1725,416 +695,6 @@
    display: flex;
    align-items: center;
    justify-content: center;
  }
  /* ä¸Šä¼ å¼¹çª—样式 */
  .upload-popup-content {
    background: #fff;
    border-radius: 12px;
    width: 100%;
    min-height: 300px;
    max-height: 70vh;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  }
  .upload-popup-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 15px 20px;
    border-bottom: 1px solid #eee;
  }
  .upload-popup-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
  .upload-popup-body {
    flex: 1;
    padding: 20px;
    overflow-y: auto;
  }
  .upload-popup-footer {
    display: flex;
    justify-content: flex-end;
    padding: 15px 20px;
    border-top: 1px solid #eee;
    gap: 10px;
  }
  /* ç®€åŒ–上传组件样式 */
  .simple-upload-area {
    padding: 15px;
  }
  .upload-buttons {
    display: flex;
    gap: 10px;
    margin-bottom: 15px;
  }
  .file-list {
    margin-top: 15px;
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
  }
  .file-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    background: #fff;
    border-radius: 12px;
    padding: 8px;
    border: 1px solid #e9ecef;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
    transition: all 0.3s ease;
    width: calc(50% - 6px);
    min-width: 120px;
  }
  .file-item:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    transform: translateY(-2px);
  }
  .file-preview-container {
    position: relative;
    margin-bottom: 8px;
  }
  .file-preview {
    width: 80px;
    height: 80px;
    border-radius: 8px;
    object-fit: cover;
    border: 2px solid #f0f0f0;
  }
  .video-preview {
    width: 80px;
    height: 80px;
    background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
    border-radius: 8px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    border: 2px solid #f0f0f0;
  }
  .video-text {
    font-size: 12px;
    color: #666;
    margin-top: 4px;
  }
  .delete-btn {
    position: absolute;
    top: -6px;
    right: -6px;
    width: 20px;
    height: 20px;
    background: #ff4757;
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    box-shadow: 0 2px 4px rgba(255, 71, 87, 0.3);
    transition: all 0.3s ease;
  }
  .delete-btn:hover {
    background: #ff3742;
    transform: scale(1.1);
  }
  .file-info {
    text-align: center;
    width: 100%;
  }
  .file-name {
    font-size: 12px;
    color: #333;
    font-weight: 500;
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 100px;
  }
  .file-size {
    font-size: 10px;
    color: #999;
    margin-top: 2px;
    display: block;
  }
  .empty-state {
    text-align: center;
    padding: 40px 20px;
    color: #999;
    font-size: 14px;
    background: #f8f9fa;
    border-radius: 8px;
    border: 2px dashed #ddd;
  }
  .upload-progress {
    margin: 15px 0;
    padding: 0 10px;
  }
  /* ä¸Šä¼ æ ‡ç­¾é¡µæ ·å¼ */
  .upload-tabs {
    display: flex;
    background: #f8f9fa;
    border-radius: 8px;
    margin-bottom: 15px;
    padding: 4px;
  }
  .tab-item {
    flex: 1;
    text-align: center;
    padding: 8px 12px;
    font-size: 14px;
    color: #666;
    border-radius: 6px;
    transition: all 0.3s ease;
    cursor: pointer;
  }
  .tab-item.active {
    background: #409eff;
    color: #fff;
    font-weight: 500;
  }
  .tab-item:hover:not(.active) {
    background: #e9ecef;
    color: #333;
  }
  /* å¼‚常状态选择样式 */
  .exception-section {
    margin-bottom: 20px;
    padding: 15px;
    background: #f8f9fa;
    border-radius: 8px;
    border: 1px solid #e9ecef;
  }
  .section-title {
    display: block;
    font-size: 14px;
    font-weight: 600;
    color: #333;
    margin-bottom: 12px;
  }
  .exception-options {
    display: flex;
    gap: 12px;
  }
  .exception-option {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 12px 16px;
    background: #fff;
    border: 2px solid #e9ecef;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.3s ease;
    font-size: 14px;
    color: #666;
  }
  .exception-option.active {
    border-color: #409eff;
    background: #f0f8ff;
    color: #409eff;
    font-weight: 500;
  }
  .exception-option:hover:not(.active) {
    border-color: #d9d9d9;
    background: #fafafa;
  }
  /* ç»Ÿè®¡ä¿¡æ¯æ ·å¼ */
  .upload-summary {
    margin-top: 15px;
    padding: 10px;
    background: #f8f9fa;
    border-radius: 6px;
    border-left: 3px solid #409eff;
  }
  .summary-text {
    font-size: 12px;
    color: #666;
    line-height: 1.4;
  }
  /* æŸ¥çœ‹é™„件弹窗样式 */
  .attachment-popup-content {
    background: #fff;
    border-radius: 12px;
    width: 100%;
    min-height: 400px;
    max-height: 70vh;
    overflow: hidden;
    display: flex;
    flex-direction: column;
    box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3);
  }
  .attachment-popup-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding: 15px 20px;
    border-bottom: 1px solid #eee;
    background: #f8f9fa;
  }
  .attachment-popup-title {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
  .close-btn-attachment {
    width: 28px;
    height: 28px;
    border-radius: 50%;
    background: #f5f5f5;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: all 0.3s ease;
  }
  .close-btn-attachment:hover {
    background: #e9ecef;
    transform: scale(1.1);
  }
  .attachment-popup-body {
    flex: 1;
    padding: 15px 20px;
    overflow-y: auto;
  }
  .attachment-tabs {
    display: flex;
    background: #f8f9fa;
    border-radius: 8px;
    margin-bottom: 15px;
    padding: 4px;
  }
  .attachment-content {
    min-height: 200px;
  }
  .attachment-list {
    display: flex;
    flex-wrap: wrap;
    gap: 12px;
  }
  .attachment-item {
    display: flex;
    flex-direction: column;
    align-items: center;
    background: #fff;
    border-radius: 12px;
    padding: 8px;
    border: 1px solid #e9ecef;
    box-shadow: 0 2px 4px rgba(0, 0, 0, 0.05);
    transition: all 0.3s ease;
    width: calc(33.33% - 8px);
    min-width: 100px;
    cursor: pointer;
  }
  .attachment-item:hover {
    box-shadow: 0 4px 12px rgba(0, 0, 0, 0.1);
    transform: translateY(-2px);
  }
  .attachment-preview-container {
    margin-bottom: 8px;
  }
  .attachment-preview {
    width: 80px;
    height: 80px;
    border-radius: 8px;
    object-fit: cover;
    border: 2px solid #f0f0f0;
  }
  .attachment-video-preview {
    width: 80px;
    height: 80px;
    background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
    border-radius: 8px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    border: 2px solid #f0f0f0;
  }
  .attachment-info {
    text-align: center;
    width: 100%;
  }
  .attachment-name {
    font-size: 12px;
    color: #333;
    font-weight: 500;
    display: block;
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
    max-width: 80px;
  }
  .attachment-size {
    font-size: 10px;
    color: #999;
    margin-top: 2px;
    display: block;
  }
  .attachment-empty {
    text-align: center;
    padding: 60px 20px;
    color: #999;
    font-size: 14px;
    background: #f8f9fa;
    border-radius: 8px;
    border: 2px dashed #ddd;
  }
  /* è§†é¢‘预览弹窗样式 */
src/pages/inspectionUpload/upload.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,982 @@
<template>
  <view class="inspection-upload-page">
    <!-- é¡µé¢å¤´éƒ¨ -->
    <PageHeader title="上传巡检记录"
                @back="goBack" />
    <!-- é¡µé¢å†…容 -->
    <view class="upload-content">
      <!-- ä»»åŠ¡ä¿¡æ¯å¡ç‰‡ -->
      <view class="task-info-card"
            v-if="taskInfo">
        <view class="task-info-header">
          <text class="task-name">{{ taskInfo.taskName }}</text>
        </view>
        <view class="task-info-body">
          <view class="info-item">
            <text class="info-label">任务ID</text>
            <text class="info-value">{{ taskInfo.taskId || taskInfo.id }}</text>
          </view>
          <view class="info-item">
            <text class="info-label">巡检位置</text>
            <text class="info-value">{{ taskInfo.inspectionLocation || '-' }}</text>
          </view>
          <view class="info-item">
            <text class="info-label">执行人</text>
            <text class="info-value">{{ taskInfo.inspector || '-' }}</text>
          </view>
        </view>
      </view>
      <!-- å¼‚常状态选择 -->
      <view class="section-card">
        <view class="section-title">巡检状态</view>
        <view class="exception-options">
          <view class="exception-option"
                :class="{ active: hasException === false }"
                @click="setExceptionStatus(false)">
            <u-icon name="checkmark-circle"
                    size="20"
                    color="#52c41a"></u-icon>
            <text class="option-text">正常</text>
          </view>
          <view class="exception-option"
                :class="{ active: hasException === true }"
                @click="setExceptionStatus(true)">
            <u-icon name="close-circle"
                    size="20"
                    color="#ff4d4f"></u-icon>
            <text class="option-text">存在异常</text>
          </view>
        </view>
      </view>
      <!-- å¼‚常描述(仅在异常时显示) -->
      <view class="section-card"
            v-if="hasException === true">
        <view class="section-title">异常描述</view>
        <textarea v-model="abnormalDescription"
                  class="exception-textarea"
                  maxlength="500"
                  placeholder="请描述异常情况..." />
      </view>
      <!-- åˆ†ç±»æ ‡ç­¾é¡µï¼ˆä»…在异常时显示) -->
      <view class="section-card"
            v-if="hasException === true">
        <view class="upload-tabs">
          <view class="tab-item"
                :class="{ active: currentUploadType === 'before' }"
                @click="switchUploadType('before')">
            ç”Ÿäº§å‰
          </view>
          <view class="tab-item"
                :class="{ active: currentUploadType === 'after' }"
                @click="switchUploadType('after')">
            ç”Ÿäº§ä¸­
          </view>
          <view class="tab-item"
                :class="{ active: currentUploadType === 'issue' }"
                @click="switchUploadType('issue')">
            ç”Ÿäº§åŽ
          </view>
        </view>
        <!-- å½“前分类的上传区域 -->
        <view class="upload-area">
          <view class="upload-buttons">
            <u-button type="primary"
                      @click="chooseMedia('image')"
                      :loading="uploading"
                      :disabled="getCurrentFiles().length >= uploadConfig.limit"
                      :customStyle="{ marginRight: '10px', flex: 1 }">
              <u-icon name="camera"
                      size="18"
                      color="#fff"
                      style="margin-right: 5px"></u-icon>
              {{ uploading ? '上传中...' : '拍照' }}
            </u-button>
            <u-button type="success"
                      @click="chooseMedia('video')"
                      :loading="uploading"
                      :disabled="getCurrentFiles().length >= uploadConfig.limit"
                      :customStyle="{ flex: 1 }">
              <uni-icons type="videocam"
                         size="18"
                         color="#fff"
                         style="margin-right: 5px"></uni-icons>
              {{ uploading ? '上传中...' : '拍视频' }}
            </u-button>
          </view>
          <!-- ä¸Šä¼ è¿›åº¦ -->
          <view v-if="uploading"
                class="upload-progress">
            <u-line-progress :percentage="uploadProgress"
                             :showText="true"
                             activeColor="#409eff"></u-line-progress>
          </view>
          <!-- å½“前分类的文件列表 -->
          <view v-if="getCurrentFiles().length > 0"
                class="file-list">
            <view v-for="(file, index) in getCurrentFiles()"
                  :key="index"
                  class="file-item">
              <view class="file-preview-container">
                <image v-if="file.type === 'image' || (file.type !== 'video' && !file.type)"
                       :src="file.url || file.tempFilePath || file.path || file.downloadUrl"
                       class="file-preview"
                       mode="aspectFill" />
                <view v-else-if="file.type === 'video'"
                      class="video-preview">
                  <uni-icons type="videocam"
                             size="18"
                             color="#fff"
                             style="margin-right: 5px"></uni-icons>
                  <text class="video-text">视频</text>
                </view>
                <!-- åˆ é™¤æŒ‰é’® -->
                <view class="delete-btn"
                      @click="removeFile(index)">
                  <u-icon name="close"
                          size="12"
                          color="#fff"></u-icon>
                </view>
              </view>
              <view class="file-info">
                <text class="file-name">{{ file.bucketFilename || file.name || (file.type === 'image' ? '图片' : '视频') }}</text>
                <text class="file-size">{{ formatFileSize(file.size) }}</text>
              </view>
            </view>
          </view>
          <view v-if="getCurrentFiles().length === 0"
                class="empty-state">
            <text>请选择要上传的{{ getUploadTypeText() }}图片或视频</text>
          </view>
        </view>
        <!-- ç»Ÿè®¡ä¿¡æ¯ -->
        <view class="upload-summary">
          <text class="summary-text">
            ç”Ÿäº§å‰: {{ beforeModelValue.length }}个文件 |
            ç”Ÿäº§ä¸­: {{ afterModelValue.length }}个文件 |
            ç”Ÿäº§åŽ: {{ issueModelValue.length }}个文件
          </text>
        </view>
      </view>
      <!-- æ­£å¸¸çŠ¶æ€æç¤º -->
      <view class="normal-tip-card"
            v-if="hasException === false">
        <u-icon name="info-circle"
                size="60"
                color="#52c41a"></u-icon>
        <text class="tip-text">设备运行正常,无需上传照片</text>
      </view>
    </view>
    <!-- åº•部按钮 -->
    <view class="footer-buttons">
      <u-button @click="goBack"
                :customStyle="{ marginRight: '10px' }">取消</u-button>
      <u-button v-if="hasException === true"
                type="warning"
                @click="goToRepair"
                :customStyle="{ marginRight: '10px' }">
        æ–°å¢žæŠ¥ä¿®
      </u-button>
      <u-button type="primary"
                @click="submitUpload">提交</u-button>
    </view>
  </view>
</template>
<script setup>
  import { ref, computed, onMounted } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { uploadInspectionTask } from "@/api/inspectionManagement";
  import { getToken } from "@/utils/auth";
  import config from "@/config";
  // ä»»åŠ¡ä¿¡æ¯
  const taskInfo = ref(null);
  // ä¸Šä¼ ç›¸å…³çŠ¶æ€
  const uploading = ref(false);
  const uploadProgress = ref(0);
  // ä¸‰ä¸ªåˆ†ç±»çš„上传状态
  const beforeModelValue = ref([]); // ç”Ÿäº§å‰
  const afterModelValue = ref([]); // ç”Ÿäº§ä¸­
  const issueModelValue = ref([]); // ç”Ÿäº§åŽ
  // å½“前激活的上传类型
  const currentUploadType = ref("before"); // 'before', 'after', 'issue'
  // å¼‚常状态
  const hasException = ref(null); // null: æœªé€‰æ‹©, true: å­˜åœ¨å¼‚常, false: æ­£å¸¸
  // å¼‚常描述
  const abnormalDescription = ref("");
  // ä¸Šä¼ é…ç½®
  const uploadConfig = {
    action: "/common/upload",
    limit: 10,
    fileSize: 50, // MB
    fileType: ["jpg", "jpeg", "png", "mp4", "mov"],
    maxVideoDuration: 60, // ç§’
  };
  // è®¡ç®—上传URL
  const uploadFileUrl = computed(() => {
    const baseUrl = config.baseUrl;
    return baseUrl + uploadConfig.action;
  });
  // é¡µé¢åŠ è½½
  onLoad(options => {
    if (options.taskInfo) {
      try {
        const info = JSON.parse(decodeURIComponent(options.taskInfo));
        taskInfo.value = info;
        // å›žæ˜¾é€»è¾‘:从 taskInfo ä¸­æ¢å¤å·²ä¸Šä¼ çš„æ–‡ä»¶
        const mapFiles = list => {
          if (!list || !Array.isArray(list)) return [];
          return list.map(item => {
            // å¤„理 URL,去除可能的空格
            const finalUrl = (item.url || item.previewURL || "").trim();
            // è‡ªåŠ¨æŽ¨æ–­æ–‡ä»¶ç±»åž‹
            let fileType = item.type;
            if (!fileType && item.contentType) {
              fileType = item.contentType.startsWith("video") ? "video" : "image";
            } else if (!fileType) {
              fileType = "image"; // é»˜è®¤å›¾ç‰‡
            }
            return {
              ...item,
              url: finalUrl,
              name: item.name || item.originalFilename,
              tempId: item.tempId || item.id || item.tempFileId,
              size: item.size || item.byteSize || 0, // æ˜ å°„大小字段
              type: fileType,
              status: "success",
            };
          });
        };
        // ä¿®æ­£å­—段映射:BeforeVO(生产前), VO(生产中), AfterVO(生产后)
        if (
          info.commonFileListBeforeVO &&
          Array.isArray(info.commonFileListBeforeVO)
        ) {
          beforeModelValue.value = mapFiles(info.commonFileListBeforeVO);
        }
        console.log(beforeModelValue.value, "beforeModelValue");
        if (info.commonFileListVO && Array.isArray(info.commonFileListVO)) {
          afterModelValue.value = mapFiles(info.commonFileListVO);
        }
        if (
          info.commonFileListAfterVO &&
          Array.isArray(info.commonFileListAfterVO)
        ) {
          issueModelValue.value = mapFiles(info.commonFileListAfterVO);
        }
        // å¦‚果有异常描述,也恢复
        if (info.abnormalDescription) {
          abnormalDescription.value = info.abnormalDescription;
        }
        // å¦‚果有异常状态,也恢复
        if (info.hasException !== undefined && info.hasException !== null) {
          hasException.value = info.hasException;
        } else if (
          info.inspectionResult !== undefined &&
          info.inspectionResult !== null
        ) {
          // 0-异常,1-正常
          hasException.value = String(info.inspectionResult) === "0";
        }
        // è‡ªåŠ¨å…œåº•ï¼šå¦‚æžœå­˜åœ¨å·²ä¸Šä¼ æ–‡ä»¶ï¼Œåˆ™å¿…ç„¶æ˜¯å¼‚å¸¸çŠ¶æ€ï¼Œç¡®ä¿ UI æ­£å¸¸æ˜¾ç¤º
        if (
          !hasException.value &&
          (beforeModelValue.value.length > 0 ||
            afterModelValue.value.length > 0 ||
            issueModelValue.value.length > 0)
        ) {
          hasException.value = true;
        }
      } catch (e) {
        console.error("解析任务信息失败:", e);
      }
    }
  });
  // è¿”回上一页
  const goBack = () => {
    uni.navigateBack();
  };
  // åˆ‡æ¢ä¸Šä¼ ç±»åž‹
  const switchUploadType = type => {
    currentUploadType.value = type;
  };
  // èŽ·å–å½“å‰åˆ†ç±»çš„æ–‡ä»¶åˆ—è¡¨
  const getCurrentFiles = () => {
    switch (currentUploadType.value) {
      case "before":
        return beforeModelValue.value || [];
      case "after":
        return afterModelValue.value || [];
      case "issue":
        return issueModelValue.value || [];
      default:
        return [];
    }
  };
  // èŽ·å–ä¸Šä¼ ç±»åž‹æ–‡æœ¬
  const getUploadTypeText = () => {
    switch (currentUploadType.value) {
      case "before":
        return "生产前";
      case "after":
        return "生产中";
      case "issue":
        return "生产后";
      default:
        return "";
    }
  };
  // è®¾ç½®å¼‚常状态
  const setExceptionStatus = status => {
    hasException.value = status;
  };
  // è·³è½¬åˆ°æ–°å¢žæŠ¥ä¿®é¡µé¢
  const goToRepair = () => {
    try {
      const taskData = {
        taskId: taskInfo.value?.taskId || taskInfo.value?.id,
        taskName: taskInfo.value?.taskName,
        inspectionLocation: taskInfo.value?.inspectionLocation,
        inspector: taskInfo.value?.inspector,
        hasException: hasException.value,
        inspectionResult: hasException.value ? 0 : 1, // 0-异常,1-正常
        commonFileListBeforeDTO: beforeModelValue.value,
        commonFileListDTO: afterModelValue.value,
        commonFileListAfterDTO: issueModelValue.value,
        uploadedFiles: {
          before: beforeModelValue.value,
          after: afterModelValue.value,
          issue: issueModelValue.value,
        },
      };
      uni.setStorageSync("repairTaskInfo", JSON.stringify(taskData));
      uni.navigateTo({
        url: "/pages/equipmentManagement/repair/add",
      });
    } catch (error) {
      console.error("跳转报修页面失败:", error);
      uni.showToast({
        title: "跳转失败,请重试",
        icon: "error",
      });
    }
  };
  // æäº¤ä¸Šä¼ 
  const submitUpload = async () => {
    try {
      // æ£€æŸ¥æ˜¯å¦é€‰æ‹©äº†å¼‚常状态
      if (hasException.value === null) {
        uni.showToast({
          title: "请选择巡检状态",
          icon: "none",
        });
        return;
      }
      // å¦‚果是异常状态,检查是否有上传文件和描述
      if (hasException.value === true) {
        const totalFiles =
          beforeModelValue.value.length +
          afterModelValue.value.length +
          issueModelValue.value.length;
        if (totalFiles === 0) {
          uni.showToast({
            title: "请上传异常照片",
            icon: "none",
          });
          return;
        }
        // æ£€æŸ¥æ˜¯å¦å¡«å†™äº†å¼‚常描述
        if (!abnormalDescription.value.trim()) {
          uni.showToast({
            title: "请填写异常描述",
            icon: "none",
          });
          return;
        }
      }
      // æ˜¾ç¤ºæäº¤ä¸­çš„加载提示
      uni.showLoading({
        title: "提交中...",
        mask: true,
      });
      // æŒ‰ç…§é€»è¾‘合并所有分类的文件用于提取ID
      const allFiles = [
        ...beforeModelValue.value,
        ...afterModelValue.value,
        ...issueModelValue.value,
      ];
      // ä¼ ç»™åŽç«¯çš„临时文件ID列表
      let tempFileIds = [];
      if (allFiles.length > 0) {
        tempFileIds = allFiles
          .map(item => item?.tempId ?? item?.tempFileId ?? item?.id)
          .filter(v => v !== undefined && v !== null && v !== "");
      }
      // æäº¤æ•°æ®
      const submitData = {
        ...taskInfo.value,
        commonFileListBeforeDTO: beforeModelValue.value, // ç”Ÿäº§å‰
        commonFileListDTO: afterModelValue.value, // ç”Ÿäº§ä¸­
        commonFileListAfterDTO: issueModelValue.value, // ç”Ÿäº§åŽ
        hasException: hasException.value,
        inspectionResult: hasException.value ? 0 : 1, // 0-异常,1-正常
        abnormalDescription: abnormalDescription.value,
        tempFileIds: tempFileIds,
      };
      const result = await uploadInspectionTask(submitData);
      // æ£€æŸ¥æäº¤ç»“æžœ
      if (result && (result.code === 200 || result.success)) {
        uni.hideLoading();
        uni.showToast({
          title: "提交成功",
          icon: "success",
        });
        // è¿”回列表页并刷新
        setTimeout(() => {
          uni.navigateBack();
        }, 500);
      } else {
        uni.hideLoading();
        uni.showToast({
          title: result?.msg || result?.message || "提交失败",
          icon: "error",
        });
      }
    } catch (error) {
      console.error("提交上传失败:", error);
      uni.hideLoading();
      uni.showToast({
        title: error?.message || "提交失败",
        icon: "error",
      });
    }
  };
  // æ ¼å¼åŒ–文件大小
  const formatFileSize = size => {
    if (!size) return "0 B";
    const units = ["B", "KB", "MB", "GB"];
    let index = 0;
    let fileSize = size;
    while (fileSize >= 1024 && index < units.length - 1) {
      fileSize /= 1024;
      index++;
    }
    return `${fileSize.toFixed(2)} ${units[index]}`;
  };
  // æ‹ç…§/拍视频
  const chooseMedia = type => {
    if (getCurrentFiles().length >= uploadConfig.limit) {
      uni.showToast({
        title: `最多只能选择${uploadConfig.limit}个文件`,
        icon: "none",
      });
      return;
    }
    const remaining = uploadConfig.limit - getCurrentFiles().length;
    // ä¼˜å…ˆä½¿ç”¨ chooseMedia
    if (typeof uni.chooseMedia === "function") {
      uni.chooseMedia({
        count: Math.min(remaining, 1),
        mediaType: [type || "image"],
        sizeType: ["compressed", "original"],
        sourceType: ["camera"],
        success: res => {
          try {
            const files = res?.tempFiles || [];
            if (!files.length) throw new Error("未获取到文件");
            files.forEach((tf, idx) => {
              const filePath = tf.tempFilePath || tf.path || "";
              const fileType = tf.fileType || type || "image";
              const ext = fileType === "video" ? "mp4" : "jpg";
              const file = {
                tempFilePath: filePath,
                path: filePath,
                type: fileType,
                name: `${fileType}_${Date.now()}_${idx}.${ext}`,
                size: tf.size || 0,
                duration: tf.duration || 0,
                createTime: Date.now(),
              };
              uploadFile(file);
            });
          } catch (err) {
            uni.showToast({ title: err.message || "处理文件失败", icon: "none" });
          }
        },
        fail: err => {
          console.error("选择媒体失败:", err);
          uni.showToast({ title: "选择失败", icon: "none" });
        },
      });
    } else {
      // é™çº§æ–¹æ¡ˆ
      if (type === "video") {
        uni.chooseVideo({
          sourceType: ["camera"],
          success: res => {
            const file = {
              tempFilePath: res.tempFilePath,
              path: res.tempFilePath,
              type: "video",
              name: `video_${Date.now()}.mp4`,
              size: res.size || 0,
              duration: res.duration || 0,
              createTime: Date.now(),
            };
            uploadFile(file);
          },
          fail: () => {
            uni.showToast({ title: "选择视频失败", icon: "none" });
          },
        });
      } else {
        uni.chooseImage({
          count: Math.min(remaining, 9),
          sizeType: ["compressed"],
          sourceType: ["camera"],
          success: res => {
            const list = res.tempFilePaths || res.tempFiles || [];
            list.forEach((src, idx) => {
              const path = typeof src === "string" ? src : src.path;
              const file = {
                tempFilePath: path,
                path: path,
                type: "image",
                name: `image_${Date.now()}_${idx}.jpg`,
                size: 0,
                createTime: Date.now(),
              };
              uploadFile(file);
            });
          },
          fail: () => {
            uni.showToast({ title: "选择图片失败", icon: "none" });
          },
        });
      }
    }
  };
  // ä¸Šä¼ å•个文件
  const uploadFile = file => {
    const token = getToken();
    if (!token) {
      uni.showToast({ title: "用户未登录", icon: "none" });
      return;
    }
    uploading.value = true;
    uploadProgress.value = 0;
    const uploadTask = uni.uploadFile({
      url: uploadFileUrl.value,
      filePath: file.tempFilePath,
      name: "files",
      header: {
        Authorization: `Bearer ${token}`,
      },
      formData: {
        type: getTabType(),
      },
      success: res => {
        try {
          const data = JSON.parse(res.data);
          if (data.code === 200) {
            // å…¼å®¹ CommonUpload.vue çš„处理逻辑
            const resultData = Array.isArray(data.data)
              ? data.data[0]
              : data.data;
            // å¤„理 url å’Œ name èµ‹å€¼
            const finalUrl = resultData.url || resultData.previewURL;
            const finalName = resultData.name || resultData.originalFilename;
            const finalId =
              resultData.tempId || resultData.id || resultData.tempFileId;
            const uploadedFile = {
              ...file,
              ...resultData, // åŒ…含后端返回的所有字段
              url: finalUrl,
              name: finalName,
              tempId: finalId,
              status: "success",
            };
            // æ ¹æ®å½“前类型添加到对应数组
            if (currentUploadType.value === "before") {
              beforeModelValue.value.push(uploadedFile);
            } else if (currentUploadType.value === "after") {
              afterModelValue.value.push(uploadedFile);
            } else if (currentUploadType.value === "issue") {
              issueModelValue.value.push(uploadedFile);
            }
            uni.showToast({ title: "上传成功", icon: "success" });
          } else {
            uni.showToast({ title: data.msg || "上传失败", icon: "none" });
          }
        } catch (e) {
          uni.showToast({ title: "解析响应失败", icon: "none" });
        }
      },
      fail: err => {
        console.error("上传失败:", err);
        uni.showToast({ title: "上传失败", icon: "none" });
      },
      complete: () => {
        uploading.value = false;
      },
    });
    // ç›‘听上传进度
    uploadTask.onProgressUpdate(res => {
      uploadProgress.value = res.progress;
    });
  };
  // èŽ·å–type值
  const getTabType = () => {
    switch (currentUploadType.value) {
      case "before":
        return 10;
      case "after":
        return 11;
      case "issue":
        return 12;
      default:
        return 10;
    }
  };
  // åˆ é™¤æ–‡ä»¶
  const removeFile = index => {
    const files = getCurrentFiles();
    files.splice(index, 1);
  };
</script>
<style scoped>
  .inspection-upload-page {
    min-height: 100vh;
    background-color: #f5f5f5;
    padding-bottom: 80px;
  }
  .upload-content {
    padding: 15px;
  }
  /* ä»»åŠ¡ä¿¡æ¯å¡ç‰‡ */
  .task-info-card {
    background: #fff;
    border-radius: 12px;
    padding: 15px;
    margin-bottom: 15px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .task-info-header {
    margin-bottom: 12px;
    padding-bottom: 12px;
    border-bottom: 1px solid #f0f0f0;
  }
  .task-name {
    font-size: 16px;
    font-weight: 600;
    color: #333;
  }
  .task-info-body {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .info-item {
    display: flex;
    justify-content: space-between;
    align-items: center;
  }
  .info-label {
    font-size: 13px;
    color: #999;
  }
  .info-value {
    font-size: 13px;
    color: #666;
  }
  /* é€šç”¨å¡ç‰‡æ ·å¼ */
  .section-card {
    background: #fff;
    border-radius: 12px;
    padding: 15px;
    margin-bottom: 15px;
    box-shadow: 0 2px 8px rgba(0, 0, 0, 0.05);
  }
  .section-title {
    font-size: 14px;
    font-weight: 600;
    color: #333;
    margin-bottom: 12px;
  }
  /* å¼‚常状态选择 */
  .exception-options {
    display: flex;
    gap: 12px;
  }
  .exception-option {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 8px;
    padding: 14px 16px;
    background: #f8f9fa;
    border: 2px solid #e9ecef;
    border-radius: 8px;
    cursor: pointer;
    transition: all 0.3s ease;
  }
  .exception-option.active {
    border-color: #409eff;
    background: #f0f8ff;
  }
  .option-text {
    font-size: 14px;
    color: #333;
    font-weight: 500;
  }
  /* å¼‚常描述 */
  .exception-textarea {
    width: 100%;
    min-height: 100px;
    padding: 12px;
    background: #f8f9fa;
    border: 1px solid #e9ecef;
    border-radius: 8px;
    font-size: 14px;
    color: #333;
    resize: none;
    box-sizing: border-box;
  }
  .exception-textarea:focus {
    outline: none;
    border-color: #409eff;
    background: #fff;
  }
  /* åˆ†ç±»æ ‡ç­¾é¡µ */
  .upload-tabs {
    display: flex;
    gap: 10px;
    margin-bottom: 15px;
  }
  .tab-item {
    flex: 1;
    padding: 10px;
    text-align: center;
    background: #f5f5f5;
    border-radius: 6px;
    font-size: 13px;
    color: #666;
    cursor: pointer;
    transition: all 0.3s;
  }
  .tab-item.active {
    background: #409eff;
    color: #fff;
  }
  /* ä¸Šä¼ åŒºåŸŸ */
  .upload-area {
    padding: 10px 0;
  }
  .upload-buttons {
    display: flex;
    gap: 10px;
    margin-bottom: 15px;
  }
  .upload-progress {
    margin-bottom: 15px;
  }
  /* æ–‡ä»¶åˆ—表 */
  .file-list {
    display: flex;
    flex-wrap: wrap;
    gap: 10px;
  }
  .file-item {
    width: calc(33.33% - 7px);
  }
  .file-preview-container {
    position: relative;
    width: 100%;
    aspect-ratio: 1;
    border-radius: 8px;
    overflow: hidden;
    background: #f5f5f5;
  }
  .file-preview {
    width: 100%;
    height: 100%;
    object-fit: cover;
  }
  .video-preview {
    width: 100%;
    height: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    background: #333;
  }
  .video-text {
    font-size: 12px;
    color: #fff;
    margin-top: 5px;
  }
  .delete-btn {
    position: absolute;
    top: 5px;
    right: 5px;
    width: 22px;
    height: 22px;
    background: rgba(0, 0, 0, 0.5);
    border-radius: 50%;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .file-info {
    margin-top: 5px;
  }
  .file-name {
    display: block;
    font-size: 11px;
    color: #666;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .file-size {
    display: block;
    font-size: 10px;
    color: #999;
    margin-top: 2px;
  }
  .empty-state {
    text-align: center;
    padding: 30px;
    color: #999;
    font-size: 13px;
  }
  /* ç»Ÿè®¡ä¿¡æ¯ */
  .upload-summary {
    margin-top: 15px;
    padding: 10px;
    background: #f8f9fa;
    border-radius: 6px;
    border-left: 3px solid #409eff;
  }
  .summary-text {
    font-size: 12px;
    color: #666;
  }
  /* æ­£å¸¸çŠ¶æ€æç¤º */
  .normal-tip-card {
    background: #f6ffed;
    border: 2px dashed #b7eb8f;
    border-radius: 12px;
    padding: 50px 20px;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    margin-bottom: 15px;
  }
  .normal-tip-card .tip-text {
    margin-top: 15px;
    font-size: 16px;
    color: #52c41a;
    font-weight: 500;
  }
  /* åº•部按钮 */
  .footer-buttons {
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    display: flex;
    padding: 15px;
    background: #fff;
    border-top: 1px solid #f0f0f0;
    box-shadow: 0 -2px 10px rgba(0, 0, 0, 0.05);
  }
</style>
src/pages/inventoryManagement/stockManagement/Qualified.vue
ÎļþÒÑɾ³ý
src/pages/inventoryManagement/stockManagement/Record.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,443 @@
<template>
  <view class="record-container">
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input class="search-text"
                    placeholder="请输入产品大类"
                    v-model="searchForm.productName"
                    @confirm="handleQuery"
                    clearable />
        </view>
        <view class="filter-button"
              @click="handleQuery">
          <up-icon name="search"
                   size="24"
                   color="#999"></up-icon>
        </view>
      </view>
    </view>
    <scroll-view scroll-y
                 class="ledger-list"
                 v-if="tableData.length > 0"
                 @scrolltolower="loadMore">
      <view v-for="item in tableData"
            :key="item.id"
            class="ledger-item">
        <view class="item-header">
          <view class="item-left">
            <view class="document-icon">
              <up-icon name="file-text"
                       size="16"
                       color="#ffffff"></up-icon>
            </view>
            <text class="item-id">{{ item.productName }}</text>
          </view>
        </view>
        <up-divider></up-divider>
        <view class="item-details">
          <view class="detail-row">
            <text class="detail-label">规格型号</text>
            <text class="detail-value">{{ item.model }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">单位</text>
            <text class="detail-value">{{ item.unit }}</text>
          </view>
          <view class="detail-row"
                @click="handleShowBatch(item.batchNo)">
            <text class="detail-label">批号</text>
            <view class="detail-value batch-no-wrapper">
              <text class="batch-no-text"
                    :class="{ 'clickable': isBatchClickable(item.batchNo) }">
                {{ formatBatchNo(item.batchNo) }}
              </text>
              <up-icon v-if="isBatchClickable(item.batchNo)"
                       name="arrow-right"
                       size="14"
                       color="#2979ff"></up-icon>
            </view>
          </view>
          <view class="quantity-section">
            <view class="quantity-box qualified">
              <text class="q-label">合格库存</text>
              <text class="q-value">{{ item.qualifiedQuantity }}</text>
            </view>
            <view class="quantity-box unqualified">
              <text class="q-label">不合格库存</text>
              <text class="q-value">{{ item.unQualifiedQuantity }}</text>
            </view>
          </view>
          <view class="quantity-section">
            <view class="quantity-box locked">
              <text class="q-label">合格冻结</text>
              <text class="q-value">{{ item.qualifiedLockedQuantity }}</text>
            </view>
            <view class="quantity-box locked">
              <text class="q-label">不合格冻结</text>
              <text class="q-value">{{ item.unQualifiedLockedQuantity }}</text>
            </view>
          </view>
          <view class="detail-row">
            <text class="detail-label">库存预警</text>
            <text class="detail-value">{{ item.warnNum }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">备注</text>
            <text class="detail-value">{{ item.remark || '-' }}</text>
          </view>
          <view class="detail-row">
            <text class="detail-label">更新时间</text>
            <text class="detail-value">{{ item.updateTime }}</text>
          </view>
        </view>
      </view>
      <up-loadmore :status="loadStatus" />
    </scroll-view>
    <view v-else-if="!loading"
          class="no-data">
      <up-empty mode="data"
                text="暂无库存数据"></up-empty>
    </view>
    <!-- æ‰¹å·åˆ—表弹窗 -->
    <up-popup v-model:show="showBatchPopup"
              @close="showBatchPopup = false"
              mode="bottom"
              round="20"
              closeable>
      <view class="batch-popup-content">
        <view class="popup-header">
          <text class="popup-title">批号详情</text>
        </view>
        <scroll-view scroll-y
                     class="batch-list-scroll">
          <view class="batch-list">
            <view v-for="(batch, index) in currentBatchList"
                  :key="index"
                  class="batch-item">
              <view class="batch-index-box">
                <text class="batch-index">{{ index + 1 }}</text>
              </view>
              <text class="batch-text">{{ batch }}</text>
            </view>
          </view>
        </scroll-view>
      </view>
    </up-popup>
  </view>
</template>
<script setup>
  import { ref, reactive, onMounted } from "vue";
  import { getStockInventoryListPageCombined } from "@/api/inventoryManagement/stockInventory.js";
  const props = defineProps({
    productId: {
      type: Number,
      required: true,
    },
  });
  const tableData = ref([]);
  const loading = ref(false);
  const loadStatus = ref("loadmore");
  const page = reactive({ current: 1, size: 10 });
  const total = ref(0);
  const searchForm = reactive({
    productName: "",
    topParentProductId: props.productId,
  });
  const showBatchPopup = ref(false);
  const currentBatchList = ref([]);
  const handleQuery = () => {
    page.current = 1;
    tableData.value = [];
    getList();
  };
  const getList = () => {
    if (loading.value) return;
    loading.value = true;
    loadStatus.value = "loading";
    getStockInventoryListPageCombined({
      ...searchForm,
      current: page.current,
      size: page.size,
    })
      .then(res => {
        loading.value = false;
        const records = res.data.records || [];
        tableData.value =
          page.current === 1 ? records : [...tableData.value, ...records];
        total.value = res.data.total;
        loadStatus.value =
          tableData.value.length >= total.value ? "nomore" : "loadmore";
      })
      .catch(() => {
        loading.value = false;
        loadStatus.value = "loadmore";
      });
  };
  const loadMore = () => {
    if (loadStatus.value === "loadmore") {
      page.current++;
      getList();
    }
  };
  const handleShowBatch = batchNo => {
    if (!batchNo) return;
    // æ”¯æŒé€—号、空格或换行分隔
    currentBatchList.value = batchNo
      .split(/[,,\s\n]+/)
      .filter(item => item.trim() !== "");
    if (currentBatchList.value.length > 0) {
      showBatchPopup.value = true;
    }
  };
  const formatBatchNo = batchNo => {
    if (!batchNo) return "-";
    if (batchNo.length > 25) {
      return batchNo.substring(0, 25) + "...";
    }
    return batchNo;
  };
  const isBatchClickable = batchNo => {
    if (!batchNo) return false;
    return batchNo.length > 25 || batchNo.includes(",") || batchNo.includes(",");
  };
  onMounted(() => {
    getList();
  });
</script>
<style scoped lang="scss">
  .record-container {
    height: 100%;
    display: flex;
    flex-direction: column;
    background-color: #f5f7fa;
  }
  .search-section {
    padding: 20rpx;
    background-color: #ffffff;
    position: sticky;
    top: 0;
    z-index: 10;
  }
  .search-bar {
    display: flex;
    align-items: center;
    background-color: #f2f2f2;
    border-radius: 40rpx;
    padding: 0 30rpx;
    height: 80rpx;
  }
  .search-input {
    flex: 1;
  }
  .search-text {
    font-size: 28rpx;
  }
  .filter-button {
    padding-left: 20rpx;
  }
  .ledger-list {
    padding: 20rpx;
    box-sizing: border-box;
    overflow: auto;
  }
  .ledger-item {
    background-color: #ffffff;
    border-radius: 16rpx;
    padding: 30rpx;
    margin-bottom: 20rpx;
    box-shadow: 0 2rpx 12rpx rgba(0, 0, 0, 0.05);
  }
  .item-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-bottom: 20rpx;
  }
  .item-left {
    display: flex;
    align-items: center;
  }
  .document-icon {
    width: 40rpx;
    height: 40rpx;
    background: linear-gradient(135deg, #2979ff, #1565c0);
    border-radius: 8rpx;
    display: flex;
    align-items: center;
    justify-content: center;
    margin-right: 16rpx;
  }
  .item-id {
    font-size: 30rpx;
    font-weight: bold;
    color: #303133;
  }
  .item-details {
    .detail-row {
      display: flex;
      justify-content: space-between;
      margin-bottom: 16rpx;
      font-size: 26rpx;
      .detail-label {
        color: #909399;
      }
      .detail-value {
        color: #303133;
        font-weight: 500;
      }
      .batch-no-wrapper {
        display: flex;
        align-items: center;
        max-width: 70%;
        .batch-no-text {
          overflow: hidden;
          text-overflow: ellipsis;
          white-space: nowrap;
          &.clickable {
            color: #2979ff;
            text-decoration: underline;
          }
        }
      }
    }
  }
  .batch-popup-content {
    background-color: #fff;
    padding: 30rpx;
    max-height: 70vh;
    display: flex;
    flex-direction: column;
    .popup-header {
      padding-bottom: 30rpx;
      border-bottom: 1rpx solid #ebeef5;
      margin-bottom: 20rpx;
      text-align: center;
      .popup-title {
        font-size: 32rpx;
        font-weight: bold;
        color: #303133;
      }
    }
    .batch-list-scroll {
      flex: 1;
      overflow: hidden;
    }
    .batch-list {
      padding: 10rpx 0;
      .batch-item {
        display: flex;
        align-items: center;
        padding: 24rpx 0;
        border-bottom: 1rpx solid #f2f6fc;
        &:last-child {
          border-bottom: none;
        }
        .batch-index-box {
          width: 40rpx;
          height: 40rpx;
          background-color: #f0f2f5;
          border-radius: 50%;
          display: flex;
          align-items: center;
          justify-content: center;
          margin-right: 20rpx;
          .batch-index {
            font-size: 20rpx;
            color: #909399;
          }
        }
        .batch-text {
          font-size: 28rpx;
          color: #303133;
          flex: 1;
          word-break: break-all;
        }
      }
    }
  }
  .quantity-section {
    display: flex;
    gap: 20rpx;
    margin: 20rpx 0;
    .quantity-box {
      flex: 1;
      padding: 16rpx;
      border-radius: 8rpx;
      display: flex;
      flex-direction: column;
      align-items: center;
      .q-label {
        font-size: 22rpx;
        margin-bottom: 8rpx;
      }
      .q-value {
        font-size: 32rpx;
        font-weight: bold;
      }
      &.qualified {
        background-color: #ecf5ff;
        color: #409eff;
      }
      &.unqualified {
        background-color: #fef0f0;
        color: #f56c6c;
      }
      &.locked {
        background-color: #f4f4f5;
        color: #909399;
      }
    }
  }
  .no-data {
    padding-top: 200rpx;
  }
</style>
src/pages/inventoryManagement/stockManagement/Unqualified.vue
ÎļþÒÑɾ³ý
src/pages/inventoryManagement/stockManagement/index.vue
@@ -1,57 +1,104 @@
<template>
  <view class="app-container">
    <PageHeader title="库存管理" @back="goBack" />
    <up-tabs :list="tabs" @click="handleTabClick" :current="activeTab"/>
    <swiper class="swiper-box" :current="activeTab" @change="handleSwiperChange">
      <swiper-item class="swiper-item">
        <qualified-record />
      </swiper-item>
      <swiper-item class="swiper-item">
        <unqualified-record />
      </swiper-item>
    </swiper>
    <PageHeader title="库存管理"
                @back="goBack" />
    <view v-if="loading"
          class="loading-state">
      <up-loading-icon text="加载中..."></up-loading-icon>
    </view>
    <template v-else>
      <up-tabs :list="tabs"
               @click="handleTabClick"
               :current="activeTab" />
      <swiper class="swiper-box"
              :current="activeTab"
              @change="handleSwiperChange">
        <swiper-item class="swiper-item"
                     v-for="tab in products"
                     :key="tab.id">
          <record :product-id="tab.id"
                  v-if="activeTab === products.indexOf(tab) || initializedTabs.includes(tab.id)" />
        </swiper-item>
      </swiper>
    </template>
  </view>
</template>
<script setup>
import { ref } from 'vue';
import PageHeader from "@/components/PageHeader.vue";
import QualifiedRecord from "./Qualified.vue";
import UnqualifiedRecord from "./Unqualified.vue";
  import { ref, onMounted } from "vue";
  import PageHeader from "@/components/PageHeader.vue";
  import Record from "./Record.vue";
  import { productTreeList } from "@/api/basicData/product.js";
const activeTab = ref(0);
const tabs = ref([
  { name: '合格库存' },
  { name: '不合格库存' }
]);
  const activeTab = ref(0);
  const tabs = ref([]);
  const products = ref([]);
  const loading = ref(false);
  const initializedTabs = ref([]);
const handleTabClick = (item) => {
  activeTab.value = item.index;
};
  const handleTabClick = item => {
    activeTab.value = item.index;
    if (!initializedTabs.value.includes(products.value[item.index].id)) {
      initializedTabs.value.push(products.value[item.index].id);
    }
  };
const handleSwiperChange = (e) => {
  activeTab.value = e.detail.current;
};
  const handleSwiperChange = e => {
    const index = e.detail.current;
    activeTab.value = index;
    if (!initializedTabs.value.includes(products.value[index].id)) {
      initializedTabs.value.push(products.value[index].id);
    }
  };
const goBack = () => {
  uni.navigateBack();
};
  const fetchProducts = async () => {
    loading.value = true;
    try {
      const res = await productTreeList();
      // è¿‡æ»¤æ ¹èŠ‚ç‚¹äº§å“
      products.value = res
        .filter(item => item.parentId === null)
        .map(({ id, productName }) => ({ id, productName }));
      tabs.value = products.value.map(p => ({ name: p.productName }));
      if (products.value.length > 0) {
        activeTab.value = 0;
        initializedTabs.value = [products.value[0].id];
      }
    } finally {
      loading.value = false;
    }
  };
  const goBack = () => {
    uni.navigateBack();
  };
  onMounted(() => {
    fetchProducts();
  });
</script>
<style scoped lang="scss">
.app-container {
  display: flex;
  flex-direction: column;
  height: 100vh;
  background-color: #f8f9fa;
}
.swiper-box {
  flex: 1;
}
.swiper-item {
  height: 100%;
}
:deep(.up-tabs) {
  background-color: #fff;
}
  .app-container {
    display: flex;
    flex-direction: column;
    height: 100vh;
    background-color: #f8f9fa;
  }
  .loading-state {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .swiper-box {
    flex: 1;
  }
  .swiper-item {
    height: 100%;
  }
  :deep(.up-tabs) {
    background-color: #fff;
  }
</style>
src/pages/login.vue
@@ -22,6 +22,21 @@
                  clearable
                  type="password"></up-input>
      </view>
      <!-- <view class="input-item flex align-center"
            v-if="factoryList.length > 0">
        <up-input prefixIcon="home"
                  placeholder="请选择工厂"
                  border="bottom"
                  readonly
                  @click="showFactorySelect = true"
                  v-model="selectedFactoryName"
                  clearable></up-input>
        <up-action-sheet :show="showFactorySelect"
                         :actions="factoryList"
                         title="请选择工厂"
                         @close="showFactorySelect = false"
                         @select="handleFactorySelect"></up-action-sheet>
      </view> -->
      <view>
        <button @click="handleLogin"
                class="login-btn cu-btn block bg-blue lg round">登录</button>
@@ -70,9 +85,17 @@
  const loginForm = ref({
    userName: "",
    password: "",
    currentFatoryName: "",
    factoryId: "",
  });
  const factoryList = ref([]); // å…¬å¸åˆ—表
  const showFactorySelect = ref(false);
  const selectedFactoryName = ref("");
  const handleFactorySelect = e => {
    loginForm.value.factoryId = e.id;
    selectedFactoryName.value = e.name;
    showFactorySelect.value = false;
  };
  // ä¿å­˜å¯†ç åˆ°æœ¬åœ°å­˜å‚¨
  function savePassword() {
@@ -127,17 +150,28 @@
              id: item.deptId,
              name: item.deptName,
            }));
            // å¦‚果只有一个工厂,默认选中
            if (factoryList.value.length === 1) {
              loginForm.value.factoryId = factoryList.value[0].id;
              selectedFactoryName.value = factoryList.value[0].name;
            }
          } else {
            // å¦‚æžœres.data不是数组,设置为空数组
            factoryList.value = [];
            loginForm.value.factoryId = "";
            selectedFactoryName.value = "";
          }
        })
        .catch(error => {
          showToast("获取公司列表失败:", error);
          factoryList.value = [];
          loginForm.value.factoryId = "";
          selectedFactoryName.value = "";
        });
    } else {
      factoryList.value = [];
      loginForm.value.factoryId = "";
      selectedFactoryName.value = "";
    }
  }
@@ -146,6 +180,8 @@
      showToast("请输入您的账号");
    } else if (loginForm.value.password === "") {
      showToast("请输入您的密码");
    } else if (factoryList.value.length > 0 && loginForm.value.factoryId === "") {
      showToast("请选择工厂");
    } else {
      showToast("登录中,请耐心等待...");
      pwdLogin();
@@ -254,7 +290,10 @@
      const accountInfo = uni.getAccountInfoSync();
      if (accountInfo?.miniProgram?.version) {
        versionName.value = accountInfo.miniProgram.version;
        console.log("[login-version] å½“前环境=MP-WEIXIN,版本=", versionName.value);
        console.log(
          "[login-version] å½“前环境=MP-WEIXIN,版本=",
          versionName.value
        );
      }
    } catch (e) {
      // èŽ·å–å¤±è´¥æ—¶ä½¿ç”¨é»˜è®¤å€¼
@@ -270,18 +309,27 @@
        // @ts-ignore
        const appid = plus.runtime.appid;
        // @ts-ignore
        plus.runtime.getProperty(appid, (info) => {
        plus.runtime.getProperty(appid, info => {
          const v = info?.version || info?.versionName || "";
          if (v) {
            versionName.value = String(v);
            console.log("[login-version] å½“前环境=APP-PLUS,版本=", versionName.value);
            console.log(
              "[login-version] å½“前环境=APP-PLUS,版本=",
              versionName.value
            );
          } else {
            console.log("[login-version] APP-PLUS èŽ·å–åˆ°çš„ç‰ˆæœ¬å­—æ®µä¸ºç©ºï¼Œä½¿ç”¨é»˜è®¤å€¼:", versionName.value);
            console.log(
              "[login-version] APP-PLUS èŽ·å–åˆ°çš„ç‰ˆæœ¬å­—æ®µä¸ºç©ºï¼Œä½¿ç”¨é»˜è®¤å€¼:",
              versionName.value
            );
          }
          console.log("[login-version] æœ€ç»ˆç‰ˆæœ¬å·:", versionName.value);
        });
      } else {
        console.log("[login-version] APP-PLUS çŽ¯å¢ƒä¸‹ç¼ºå°‘ getProperty,使用默认值:", versionName.value);
        console.log(
          "[login-version] APP-PLUS çŽ¯å¢ƒä¸‹ç¼ºå°‘ getProperty,使用默认值:",
          versionName.value
        );
        console.log("[login-version] æœ€ç»ˆç‰ˆæœ¬å·:", versionName.value);
      }
      // #endif
src/pages/message.vue
@@ -310,7 +310,7 @@
  const handleTabChange = val => {
    console.log(val);
    activeTab.value = val.id;
    page.current = 2;
    page.current = 1;
    loadMessages(false);
  };
src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,413 @@
<!--
  å®¡æ‰¹å®žä¾‹è¯¦æƒ…展示:基本信息 + å¡«æŠ¥ + æµç¨‹ + å®¡æ‰¹è®°å½•
-->
<template>
  <view class="detail-body">
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">基本信息</text>
      </view>
      <view class="info-rows">
        <view class="info-row">
          <text class="info-label">业务单号</text>
          <text class="info-value">{{ row.instanceNo || row.id || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">审批状态</text>
          <u-tag :type="statusTagType(row.status)"
                 :text="statusLabel(row.status)"
                 size="mini" />
        </view>
        <view class="info-row">
          <text class="info-label">模板名称</text>
          <text class="info-value">{{ row.templateName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">业务名称</text>
          <text class="info-value">{{ row.businessName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请人</text>
          <text class="info-value">{{ row.applicantName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请标题</text>
          <text class="info-value">{{ row.title || "—" }}</text>
        </view>
        <view v-if="rejectReason"
              class="info-row">
          <text class="info-label">驳回原因</text>
          <text class="info-value reject-text">{{ rejectReason }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请时间</text>
          <text class="info-value">{{ formatDateTime(row.applyTime || row.createTime) }}</text>
        </view>
        <view v-if="row.finishTime"
              class="info-row">
          <text class="info-label">完成时间</text>
          <text class="info-value">{{ formatDateTime(row.finishTime) }}</text>
        </view>
      </view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">填报内容</text>
      </view>
      <view v-if="displayFields.length"
            class="info-rows">
        <view v-for="field in displayFields"
              :key="field.key"
              class="info-row">
          <text class="info-label">{{ field.label }}</text>
          <text class="info-value">{{ displayFieldValue(field) }}</text>
        </view>
        <view v-for="(extra, idx) in moduleExtraRows"
              :key="`extra-${idx}`"
              class="info-row">
          <text class="info-label">{{ extra.label }}</text>
          <text class="info-value">{{ extra.value }}</text>
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无填报内容</view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">审批流程({{ flowNodes.length }} é¡¹ï¼‰</text>
      </view>
      <view v-if="flowNodes.length"
            class="flow-wrap">
        <view v-for="(node, nodeIndex) in flowNodes"
              :key="nodeIndex"
              class="flow-node-block">
          <view class="flow-node-card">
            <view class="node-header">
              <view class="node-level-badge">{{ node.levelNo }}</view>
              <text class="node-level-text">第{{ levelLabel(node.levelNo) }}级</text>
              <u-tag size="mini"
                     :type="node.approveType === 'OR' ? 'warning' : 'primary'"
                     :text="node.approveType === 'OR' ? '或签' : '会签'"
                     plain />
            </view>
            <view class="approver-list">
              <view v-for="(a, aIdx) in node.approvers"
                    :key="aIdx"
                    class="approver-row">
                <text class="approver-name">{{ a.approverName }}</text>
                <u-tag v-if="a.taskStatus"
                       size="mini"
                       :type="taskStatusTagType(a.taskStatus)"
                       :text="taskStatusText(a.taskStatus)"
                       plain />
              </view>
            </view>
          </view>
          <view v-if="nodeIndex < flowNodes.length - 1"
                class="flow-connector-line" />
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无流程节点</view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">审批记录</text>
      </view>
      <view v-if="approvalRecords.length"
            class="record-list">
        <view v-for="(rec, index) in approvalRecords"
              :key="rec.id ?? index"
              class="record-item">
          <view class="record-head">
            <text class="record-operator">{{ rec.operatorName }}</text>
            <u-tag size="mini"
                   :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'error' : 'info'"
                   :text="recordActionLabel(rec.result)"
                   plain />
          </view>
          <text class="record-time">{{ rec.time }}</text>
          <text class="record-opinion">{{ rec.opinion || "无意见" }}</text>
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无审批记录</view>
    </view>
  </view>
</template>
<script setup>
  import { computed } from "vue";
  import { APPROVAL_MODULE_KEYS } from "../../../_utils/approvalModuleRegistry.js";
  import {
    computeLeaveDurationDisplay,
    computeOvertimeHoursDisplay,
  } from "../../../_utils/approvalModuleApplyExtras.js";
  import { resolveInstanceFormPayload } from "../../../_utils/approvalModuleListSearch.js";
  import {
    businessStatusTagType,
    businessStatusText,
    displayFieldValue,
    formatDateTime,
    getRejectReasonFromRecords,
    instanceStatusTagType,
    instanceStatusText,
    mapApprovalRecords,
    mapTasksToFlowNodes,
    recordActionLabel,
    resolveInstanceDisplayFields,
    taskStatusTagType,
    taskStatusText,
  } from "../../../_utils/approveListUtils.js";
  const props = defineProps({
    row: { type: Object, default: () => ({}) },
    moduleKey: { type: String, default: "" },
  });
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const isBusinessModule = computed(() =>
    [
      APPROVAL_MODULE_KEYS.LEAVE,
      APPROVAL_MODULE_KEYS.OVERTIME,
      APPROVAL_MODULE_KEYS.TRANSFER,
      APPROVAL_MODULE_KEYS.REGULAR,
      APPROVAL_MODULE_KEYS.WORK_HANDOVER,
    ].includes(props.moduleKey)
  );
  const statusLabel = status =>
    isBusinessModule.value ? businessStatusText(status) : instanceStatusText(status);
  const statusTagType = status =>
    isBusinessModule.value ? businessStatusTagType(status) : instanceStatusTagType(status);
  const displayFields = computed(() => resolveInstanceDisplayFields(props.row));
  const moduleExtraRows = computed(() => {
    const rows = [];
    const { fields, formPayload } = resolveInstanceFormPayload(props.row);
    const payload = { ...formPayload };
    (fields || []).forEach(f => {
      if (f?.key && payload[f.key] == null) payload[f.key] = f.value ?? "";
    });
    if (props.moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
      const balance = payload.leaveBalanceDays;
      if (balance != null && balance !== "") {
        rows.push({ label: "假期余额", value: `${balance} å¤©` });
      }
      const days = computeLeaveDurationDisplay(fields, payload);
      if (days) rows.push({ label: "请假时长", value: `${days} å¤©` });
    }
    if (props.moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
      const hours = computeOvertimeHoursDisplay(fields, payload);
      if (hours) rows.push({ label: "加班时长", value: `${hours} å°æ—¶` });
    }
    if (props.moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
      const post = payload.originalPostName || payload.originalPost;
      if (post) rows.push({ label: "原岗位", value: post });
    }
    return rows;
  });
  const flowNodes = computed(() => mapTasksToFlowNodes(props.row?.tasks));
  const approvalRecords = computed(() =>
    mapApprovalRecords(props.row?.records)
  );
  const rejectReason = computed(() =>
    getRejectReasonFromRecords(props.row?.records)
  );
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
</script>
<style scoped lang="scss">
  $primary: #2979ff;
  $text: #1f2d3d;
  $text-muted: #909399;
  .detail-body {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .section-card {
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .info-rows {
    padding: 4px 16px 12px;
  }
  .info-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 0;
    border-bottom: 1px solid #f5f6f8;
    &:last-child {
      border-bottom: none;
    }
  }
  .info-label {
    flex-shrink: 0;
    font-size: 14px;
    color: $text-muted;
    min-width: 72px;
  }
  .info-value {
    flex: 1;
    font-size: 14px;
    color: $text;
    text-align: right;
    word-break: break-all;
  }
  .reject-text {
    color: #f56c6c;
  }
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-node-block {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  .flow-node-card {
    background: #fafbfd;
    border: 1px solid #e8eef5;
    border-radius: 10px;
    padding: 12px;
  }
  .node-header {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    background: $primary;
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .node-level-text {
    flex: 1;
    font-size: 14px;
    font-weight: 600;
    color: $text;
  }
  .approver-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .approver-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .approver-name {
    font-size: 13px;
    color: #606266;
  }
  .flow-connector-line {
    width: 2px;
    height: 12px;
    background: #d0dff0;
    margin: 4px auto;
  }
  .record-list {
    padding: 8px 16px 14px;
  }
  .record-item {
    padding: 10px 0;
    border-bottom: 1px solid #f0f2f5;
    &:last-child {
      border-bottom: none;
    }
  }
  .record-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .record-operator {
    font-size: 14px;
    font-weight: 600;
    color: $text;
  }
  .record-time {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .record-opinion {
    display: block;
    margin-top: 6px;
    font-size: 13px;
    color: #606266;
    line-height: 1.5;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: $text-muted;
  }
</style>
src/pages/oa/ApproveManage/approve-list/apply.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1274 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å‘起审批
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/apply
-->
<template>
  <view class="approve-apply-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view class="form-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            class="loading-wrap">
        <up-loading-icon mode="circle" />
        <text class="loading-text">加载中...</text>
      </view>
      <template v-else-if="detail">
        <up-form :model="form"
                 label-width="100"
                 input-align="right">
          <u-cell-group title="基本信息"
                        class="form-section">
            <up-form-item label="审批标题"
                          required
                          class="form-item-name">
              <up-input v-model="form.title"
                        class="name-input-inline"
                        placeholder="请输入审批标题"
                        maxlength="100"
                        clearable />
            </up-form-item>
            <up-form-item label="审批模板"
                          class="form-item-readonly">
              <up-input :model-value="templateName"
                        readonly />
            </up-form-item>
            <up-form-item label="申请人"
                          class="form-item-readonly">
              <up-input :model-value="displayApplicantName"
                        readonly />
            </up-form-item>
          </u-cell-group>
        </up-form>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">填报内容</text>
          </view>
          <view v-if="formConfigData.prompt"
                class="form-prompt">
            {{ formConfigData.prompt }}
          </view>
          <up-form v-if="formConfigData.fields.length"
                   :model="formValues"
                   label-width="100"
                   input-align="right"
                   class="dynamic-form">
            <up-form-item v-for="field in displayTemplateFields"
                          :key="field.key"
                          :label="field.label"
                          :required="!!field.required"
                          :label-position="formItemLabelPosition(field)"
                          :class="formItemClass(field)">
              <up-textarea v-if="isTextareaField(field)"
                           v-model="formValues[field.key]"
                           :placeholder="`请输入${field.label}`"
                           maxlength="500"
                           border="surround"
                           height="80" />
              <view v-else-if="isDatetimerangeField(field)"
                    class="daterange-fill">
                <view class="range-fill-row"
                      @click="openRangePicker(field, 'start')">
                  <text class="range-fill-label">开始</text>
                  <up-input :model-value="getRangePartDisplay(field, 'start')"
                            placeholder="开始时间"
                            readonly />
                  <up-icon name="calendar"
                           size="16"
                           color="#909399" />
                </view>
                <text class="range-fill-sep">至</text>
                <view class="range-fill-row"
                      @click="openRangePicker(field, 'end')">
                  <text class="range-fill-label">结束</text>
                  <up-input :model-value="getRangePartDisplay(field, 'end')"
                            placeholder="结束时间"
                            readonly />
                  <up-icon name="calendar"
                           size="16"
                           color="#909399" />
                </view>
              </view>
              <view v-else-if="isDateLikeField(field)"
                    class="field-trigger"
                    @click="openDatePicker(field)">
                <up-input :model-value="formatFieldDisplayValue(field, formValues[field.key])"
                          :placeholder="`请选择${field.label}`"
                          readonly />
                <up-icon :name="getDatePickerMode(field) === 'time' ? 'clock' : 'calendar'"
                         size="18"
                         color="#909399" />
              </view>
              <view v-else-if="isSelectField(field)"
                    class="field-trigger"
                    @click="openSelectPicker(field)">
                <up-input :model-value="getSelectDisplayText(field)"
                          :placeholder="`请选择${field.label}`"
                          readonly />
                <up-icon name="arrow-right"
                         size="16"
                         color="#c0c4cc" />
              </view>
              <up-input v-else
                        v-model="formValues[field.key]"
                        :type="isNumberField(field) ? 'digit' : 'text'"
                        :placeholder="`请输入${field.label}`"
                        clearable />
            </up-form-item>
          </up-form>
          <view v-else
                class="empty-hint">该模板暂无填报项</view>
          <!-- è¯·å‡ï¼šå‡æœŸä½™é¢ + æ—¶é•¿è‡ªåŠ¨è®¡ç®— -->
          <view v-if="isLeaveModule"
                class="module-extra-block">
            <up-form :model="extraForm"
                     label-width="100"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="假期余额"
                            required
                            class="form-item-inline">
                <up-input v-model="extraForm.leaveBalanceDays"
                          type="digit"
                          placeholder="请输入天数"
                          clearable />
              </up-form-item>
              <up-form-item label="请假时长"
                            class="form-item-inline">
                <view class="readonly-with-unit">
                  <up-input :model-value="leaveDurationText"
                            readonly
                            placeholder="根据请假时间自动计算" />
                  <text class="unit-text">天</text>
                </view>
              </up-form-item>
            </up-form>
          </view>
          <!-- åŠ ç­ï¼šæ—¶é•¿è‡ªåŠ¨è®¡ç®— -->
          <view v-if="isOvertimeModule"
                class="module-extra-block">
            <up-form label-width="100"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="加班时长"
                            class="form-item-inline">
                <view class="readonly-with-unit">
                  <up-input :model-value="overtimeHoursText"
                            readonly
                            placeholder="根据加班时间自动计算" />
                  <text class="unit-text">小时</text>
                </view>
              </up-form-item>
            </up-form>
          </view>
          <!-- è°ƒå²—:原岗位自动带出 -->
          <view v-if="isTransferModule"
                class="module-extra-block">
            <up-form label-width="100"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="原岗位"
                            class="form-item-readonly">
                <up-input :model-value="extraForm.originalPostName"
                          readonly
                          placeholder="选择申请人后自动带出" />
              </up-form-item>
            </up-form>
          </view>
        </view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">审批流程</text>
          </view>
          <view v-if="detail.nodes?.length"
                class="flow-wrap">
            <view v-for="(node, nodeIndex) in detail.nodes"
                  :key="node.id || nodeIndex"
                  class="flow-node-block">
              <view class="flow-node-card">
                <view class="node-header">
                  <view class="node-level-badge">{{ node.levelNo || nodeIndex + 1 }}</view>
                  <text class="node-level-text">第{{ levelLabel(node.levelNo || nodeIndex + 1) }}级</text>
                </view>
                <view class="approve-type-row approve-type-row--readonly">
                  <view class="type-btn"
                        :class="{ active: node.approveType !== 'OR' }">
                    ä¼šç­¾
                  </view>
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'OR' }">
                    æˆ–ç­¾
                  </view>
                </view>
                <view class="approver-list">
                  <view v-for="(approver, aIdx) in node.approvers || []"
                        :key="approver.id || aIdx"
                        class="approver-chip">
                    <view class="approver-avatar">{{ (approver.approverName || "?").charAt(0) }}</view>
                    <text class="approver-name">{{ approver.approverName || "-" }}</text>
                  </view>
                  <text v-if="!(node.approvers || []).length"
                        class="empty-hint inline">暂无审批人</text>
                </view>
              </view>
              <view v-if="nodeIndex < detail.nodes.length - 1"
                    class="flow-connector">
                <view class="flow-connector-line" />
              </view>
            </view>
          </view>
          <view v-else
                class="empty-hint">暂无审批节点</view>
        </view>
      </template>
      <view v-else
            class="empty-wrap">
        <up-empty mode="data"
                  text="未获取到模板详情" />
      </view>
    </scroll-view>
    <FooterButtons v-if="!loading && detail"
                   cancel-text="取消"
                   :confirm-text="confirmText"
                   :loading="submitting"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-popup :show="showDatePicker"
              mode="bottom"
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          :mode="datePickerMode"
                          @confirm="onDateConfirm"
                          @cancel="onDatePickerCancel" />
    </up-popup>
    <up-action-sheet :show="showSelectSheet"
                     :title="selectSheetTitle"
                     :actions="selectSheetActions"
                     @select="onSelectOption"
                     @close="showSelectSheet = false" />
  </view>
</template>
<script setup>
  import { computed, reactive, ref, watch } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js";
  import {
    saveApprovalInstance,
    updateApprovalInstance,
  } from "@/api/oa/approvalInstance.js";
  import useUserStore from "@/store/modules/user";
  import { parseTime } from "@/utils/ruoyi";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { findPostOptions } from "@/api/system/post.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
  import {
    computeLeaveDurationDisplay,
    computeOvertimeHoursDisplay,
    displayTemplateFieldsByModule,
    findApplicantTemplateField,
    findLeaveTimeTemplateField,
    findOvertimeTimeTemplateField,
    inferModuleKeyFromRow,
    loadModuleExtrasFromRow,
    resolveOriginalPostName,
    syncModuleExtrasToFormValues,
    unwrapUserArray,
    userById,
    validateModuleExtras,
    buildPostIdToNameMap,
  } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    formatDatetimerangeDisplay,
    formatFieldDateValue,
    formatFieldDisplayValue,
    getDatePickerMode,
    getFieldInitialValue,
    getFieldOptionLabel,
    isDatetimerangeField,
    isDateLikeField,
    isNumberField,
    isSelectField,
    isTextareaField,
    joinDatetimerangeValue,
    mergeFormConfigForEdit,
    parseDatetimerangeValue,
    resolveFieldOptions,
    parseApprovalFormConfig,
    parseFieldDateToTs,
  } from "../../_utils/approvalFormField.js";
  import { EDIT_STORAGE_KEY } from "../../_utils/approveListUtils.js";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const userStore = useUserStore();
  const moduleKey = ref("");
  const templateId = ref("");
  const instanceId = ref("");
  const instanceRow = ref(null);
  const detail = ref(null);
  const loading = ref(false);
  const submitting = ref(false);
  const formValues = reactive({});
  const form = reactive({ title: "" });
  const extraForm = reactive({
    leaveBalanceDays: undefined,
    originalPostName: "",
  });
  const postIdToName = ref({});
  const transferUserPool = ref([]);
  const isLeaveModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.LEAVE
  );
  const isOvertimeModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.OVERTIME
  );
  const isTransferModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.TRANSFER
  );
  const showDatePicker = ref(false);
  const datePickerTs = ref(Date.now());
  const activeDateField = ref(null);
  const activeRangePart = ref("start");
  const datePickerMode = computed(() => {
    const field = activeDateField.value;
    if (!field) return "date";
    if (isDatetimerangeField(field)) return "datetime";
    return getDatePickerMode(field);
  });
  const showSelectSheet = ref(false);
  const activeSelectField = ref(null);
  const pickerUserList = ref([]);
  const pickerDeptList = ref([]);
  const isEditMode = computed(() => !!instanceId.value);
  const pageTitle = computed(() => (isEditMode.value ? "修改审批" : "发起审批"));
  const confirmText = computed(() => (isEditMode.value ? "保存修改" : "提交审批"));
  const applicantName = computed(
    () => userStore.nickName || userStore.name || "-"
  );
  const displayApplicantName = computed(
    () => instanceRow.value?.applicantName || applicantName.value
  );
  const templateName = computed(
    () => detail.value?.templateName || instanceRow.value?.templateName || "-"
  );
  const formConfigData = computed(() => {
    if (isEditMode.value) {
      return mergeFormConfigForEdit(
        detail.value?.formConfig,
        instanceRow.value?.formConfig
      );
    }
    return parseApprovalFormConfig(detail.value?.formConfig);
  });
  const displayTemplateFields = computed(() =>
    displayTemplateFieldsByModule(moduleKey.value, formConfigData.value.fields)
  );
  const leaveDurationText = computed(() => {
    if (!isLeaveModule.value) return "";
    const fields = formConfigData.value.fields;
    const timeField = findLeaveTimeTemplateField(fields);
    if (timeField?.key) void formValues[timeField.key];
    return computeLeaveDurationDisplay(fields, formValues);
  });
  const overtimeHoursText = computed(() => {
    if (!isOvertimeModule.value) return "";
    const fields = formConfigData.value.fields;
    const timeField = findOvertimeTimeTemplateField(fields);
    if (timeField?.key) void formValues[timeField.key];
    return computeOvertimeHoursDisplay(fields, formValues);
  });
  const applicantPickerValue = computed(() => {
    const f = findApplicantTemplateField(formConfigData.value.fields);
    return f?.key ? formValues[f.key] : undefined;
  });
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const selectSheetTitle = computed(
    () => (activeSelectField.value?.label ? `选择${activeSelectField.value.label}` : "请选择")
  );
  const selectSheetActions = computed(() => {
    const field = activeSelectField.value;
    if (!field) return [];
    return resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    }).map(opt => ({
      name: opt.label,
      value: opt.value,
    }));
  });
  const formItemClass = field => {
    if (isTextareaField(field)) return "form-item-textarea";
    if (isDatetimerangeField(field)) return "form-item-daterange";
    if (isSelectField(field) || isDateLikeField(field)) return "form-item-select";
    return "form-item-inline";
  };
  /** å¤šè¡Œæ–‡æœ¬ã€æ—¥æœŸèŒƒå›´ï¼šæ ‡ç­¾ç½®é¡¶ï¼Œé¿å…é•¿æ–‡æ¡ˆåœ¨çª„列内断行 */
  const formItemLabelPosition = field => {
    if (isTextareaField(field) || isDatetimerangeField(field)) return "top";
    return "left";
  };
  const getRangePartDisplay = (field, part) => {
    const parts = parseDatetimerangeValue(formValues[field.key]);
    const val = part === "start" ? parts.start : parts.end;
    return val ? formatFieldDisplayValue({ type: "datetime" }, val) : "";
  };
  const openRangePicker = (field, part) => {
    activeDateField.value = field;
    activeRangePart.value = part;
    const parts = parseDatetimerangeValue(formValues[field.key]);
    const val = part === "start" ? parts.start : parts.end;
    datePickerTs.value = parseFieldDateToTs(val) ?? Date.now();
    showDatePicker.value = true;
  };
  const getSelectDisplayText = field => {
    const stored = formValues[field.key];
    const options = resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    });
    const matched = options.find(
      opt =>
        String(opt.value) === String(stored) || String(opt.label) === String(stored)
    );
    return (
      matched?.label ||
      getFieldOptionLabel(field, stored) ||
      (stored !== undefined && stored !== null ? String(stored) : "")
    );
  };
  const initFormValues = fields => {
    Object.keys(formValues).forEach(key => {
      delete formValues[key];
    });
    fields.forEach(field => {
      if (!field?.key) return;
      formValues[field.key] = getFieldInitialValue(field);
    });
  };
  const openSelectPicker = field => {
    const options = resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    });
    if (!options.length) {
      uni.showToast({ title: "该字段未配置下拉选项", icon: "none" });
      return;
    }
    activeSelectField.value = field;
    showSelectSheet.value = true;
  };
  const onSelectOption = action => {
    const key = activeSelectField.value?.key;
    if (key) {
      formValues[key] = action.value;
    }
    showSelectSheet.value = false;
    activeSelectField.value = null;
  };
  const openDatePicker = field => {
    activeDateField.value = field;
    const current = formValues[field.key];
    datePickerTs.value = parseFieldDateToTs(current) ?? Date.now();
    showDatePicker.value = true;
  };
  const onDatePickerCancel = () => {
    showDatePicker.value = false;
    activeDateField.value = null;
  };
  const onDateConfirm = e => {
    const ts = e?.value ?? datePickerTs.value;
    const field = activeDateField.value;
    if (field?.key) {
      if (isDatetimerangeField(field)) {
        const parts = parseDatetimerangeValue(formValues[field.key]);
        const formatted = formatFieldDateValue({ type: "datetime" }, ts);
        formValues[field.key] = joinDatetimerangeValue(
          activeRangePart.value === "start" ? formatted : parts.start,
          activeRangePart.value === "end" ? formatted : parts.end
        );
      } else {
        formValues[field.key] = formatFieldDateValue(field, ts);
      }
    }
    onDatePickerCancel();
  };
  const validateForm = () => {
    if (!form.title?.trim()) {
      uni.showToast({ title: "请输入审批标题", icon: "none" });
      return false;
    }
    for (const field of displayTemplateFields.value) {
      if (!field.required) continue;
      const val = formValues[field.key];
      if (val === undefined || val === null || String(val).trim() === "") {
        const action =
          isSelectField(field) || isDateLikeField(field) || isDatetimerangeField(field)
            ? "请选择"
            : "请填写";
        uni.showToast({ title: `${action}${field.label}`, icon: "none" });
        return false;
      }
      if (isDatetimerangeField(field)) {
        const { start, end } = parseDatetimerangeValue(val);
        if (!start || !end) {
          uni.showToast({ title: `请完整选择${field.label}`, icon: "none" });
          return false;
        }
      }
      if (isSelectField(field)) {
        const options = resolveFieldOptions(field, {
          users: pickerUserList.value,
          depts: pickerDeptList.value,
        });
        if (
          options.length &&
          !options.some(
            opt =>
              String(opt.value) === String(val) || String(opt.label) === String(val)
          )
        ) {
          uni.showToast({ title: `${field.label}选项无效`, icon: "none" });
          return false;
        }
      }
    }
    if (!detail.value?.nodes?.length) {
      uni.showToast({ title: "模板未配置审批流程", icon: "none" });
      return false;
    }
    const moduleMsg = validateModuleExtras(
      moduleKey.value,
      formConfigData.value.fields,
      formValues,
      extraForm
    );
    if (moduleMsg) {
      uni.showToast({ title: moduleMsg, icon: "none" });
      return false;
    }
    return true;
  };
  const buildFormConfigPayload = () => {
    syncModuleExtrasToFormValues(
      moduleKey.value,
      formValues,
      extraForm,
      formConfigData.value.fields
    );
    const allFields = formConfigData.value.fields || [];
    return JSON.stringify({
      prompt: formConfigData.value.prompt,
      fields: allFields.map(field => ({
        ...field,
        value: formValues[field.key] ?? "",
      })),
    });
  };
  const buildSavePayload = () => ({
    templateId: detail.value.id,
    templateName: detail.value.templateName,
    businessType: detail.value.businessType,
    title: form.title.trim(),
    status: "PENDING",
    currentLevel: 1,
    applicantId: userStore.id,
    applicantName: applicantName.value,
    applyTime: parseTime(new Date()),
    deptId: userStore.currentDeptId || undefined,
    formConfig: buildFormConfigPayload(),
  });
  const buildUpdatePayload = () => {
    const row = instanceRow.value || {};
    return {
      id: instanceId.value,
      instanceNo: row.instanceNo,
      templateId: row.templateId ?? detail.value?.id,
      templateName: row.templateName ?? detail.value?.templateName,
      businessId: row.businessId,
      businessType: row.businessType ?? detail.value?.businessType,
      title: form.title.trim(),
      status: row.status || "PENDING",
      currentLevel: row.currentLevel,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      applyTime: row.applyTime,
      finishTime: row.finishTime,
      createUser: row.createUser,
      createTime: row.createTime,
      updateUser: row.updateUser,
      updateTime: row.updateTime,
      deptId: row.deptId,
      deleted: row.deleted,
      formConfig: buildFormConfigPayload(),
      approveAction: row.approveAction,
      approveComment: row.approveComment,
    };
  };
  const handleSubmit = () => {
    if (!validateForm() || submitting.value) return;
    submitting.value = true;
    const submitApi = isEditMode.value
      ? updateApprovalInstance
      : saveApprovalInstance;
    const payload = isEditMode.value ? buildUpdatePayload() : buildSavePayload();
    submitApi(payload)
      .then(() => {
        uni.showToast({
          title: isEditMode.value ? "修改成功" : "提交成功",
          icon: "success",
        });
        if (isEditMode.value) {
          uni.removeStorageSync(EDIT_STORAGE_KEY);
        }
        setTimeout(() => {
          uni.navigateBack({ delta: isEditMode.value ? 1 : 2 });
        }, 300);
      })
      .catch(() => {
        uni.showToast({
          title: isEditMode.value ? "修改失败" : "提交失败",
          icon: "none",
        });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  const loadTemplateDetail = () => {
    if (!templateId.value) return Promise.resolve();
    return getApprovalTemplateDetail(templateId.value)
      .then(res => {
        detail.value = res?.data || null;
        if (!detail.value) {
          uni.showToast({ title: "未获取到模板详情", icon: "none" });
        }
        return detail.value;
      })
      .catch(() => {
        uni.showToast({ title: "获取模板详情失败", icon: "none" });
        return null;
      });
  };
  const loadForCreate = async () => {
    loading.value = true;
    detail.value = null;
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(displayTemplateFields.value);
      resetModuleExtras();
      if (!form.title && detail.value.templateName) {
        form.title = `${detail.value.templateName}申请`;
      }
    } finally {
      loading.value = false;
    }
  };
  const loadForEdit = async () => {
    const row = uni.getStorageSync(EDIT_STORAGE_KEY);
    if (!row || String(row.id) !== String(instanceId.value)) {
      uni.showToast({ title: "未获取到审批数据", icon: "none" });
      setTimeout(() => uni.navigateBack(), 500);
      return;
    }
    instanceRow.value = row;
    if (!moduleKey.value) {
      moduleKey.value = inferModuleKeyFromRow(row);
    }
    templateId.value = row.templateId;
    form.title = row.title || "";
    loading.value = true;
    detail.value = null;
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(displayTemplateFields.value);
      applyModuleExtrasFromRow();
      if (isTransferModule.value) {
        await ensureTransferLookupData();
        syncOriginalPostFromApplicant(applicantPickerValue.value);
      }
    } finally {
      loading.value = false;
    }
  };
  function resetModuleExtras() {
    extraForm.leaveBalanceDays = undefined;
    extraForm.originalPostName = "";
  }
  function applyModuleExtrasFromRow() {
    const loaded = loadModuleExtrasFromRow(
      moduleKey.value,
      instanceRow.value,
      formValues
    );
    if (loaded.leaveBalanceDays != null) {
      extraForm.leaveBalanceDays = loaded.leaveBalanceDays;
    }
    if (loaded.originalPostName) {
      extraForm.originalPostName = loaded.originalPostName;
    }
  }
  async function ensureTransferLookupData() {
    if (!transferUserPool.value.length) {
      try {
        const res = await userListNoPageByTenantId();
        transferUserPool.value = unwrapUserArray(res);
      } catch {
        transferUserPool.value = [];
      }
    }
    if (!Object.keys(postIdToName.value).length) {
      try {
        const res = await findPostOptions();
        const rows = res?.data ?? res?.rows ?? [];
        postIdToName.value = buildPostIdToNameMap(Array.isArray(rows) ? rows : []);
      } catch {
        postIdToName.value = {};
      }
    }
  }
  function syncOriginalPostFromApplicant(uid) {
    if (!isTransferModule.value) return;
    if (!uid) {
      extraForm.originalPostName = "";
      return;
    }
    const user = userById(transferUserPool.value, uid);
    extraForm.originalPostName = resolveOriginalPostName(user, postIdToName.value);
  }
  const goBack = () => {
    uni.navigateBack();
  };
  const loadPickerSourceData = () => {
    userListNoPageByTenantId()
      .then(res => {
        pickerUserList.value = res?.data || [];
      })
      .catch(() => {
        pickerUserList.value = [];
      });
    getDept()
      .then(res => {
        pickerDeptList.value = res?.data || [];
      })
      .catch(() => {
        pickerDeptList.value = [];
      });
  };
  watch(applicantPickerValue, async uid => {
    if (!isTransferModule.value) return;
    await ensureTransferLookupData();
    syncOriginalPostFromApplicant(uid);
  });
  onLoad(options => {
    moduleKey.value = options?.moduleKey || "";
    loadPickerSourceData();
    if (isTransferModule.value) {
      ensureTransferLookupData();
    }
    if (options?.id) {
      instanceId.value = options.id;
      loadForEdit();
      return;
    }
    if (options?.templateId) {
      templateId.value = options.templateId;
      loadForCreate();
      return;
    }
    uni.showToast({ title: "缺少页面参数", icon: "none" });
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  $primary: #2979ff;
  $text: #1f2d3d;
  $text-secondary: #606266;
  $text-muted: #909399;
  $bg-page: #f0f3f8;
  $radius-lg: 12px;
  $radius-md: 10px;
  $shadow-card: 0 2px 12px rgba(31, 45, 61, 0.05);
  .approve-apply-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: $bg-page;
  }
  .form-scroll {
    flex: 1;
    height: 0;
    padding: 10px 12px calc(96px + env(safe-area-inset-bottom));
  }
  .loading-wrap {
    padding: 48px 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
  }
  .loading-text {
    font-size: 14px;
    color: $text-muted;
  }
  .form-section {
    margin-bottom: 10px;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  :deep(.form-section .u-cell-group__title) {
    padding: 12px 16px 8px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: $text !important;
    background: #fff !important;
  }
  :deep(.form-section .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.form-section .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-name .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-name .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.name-input-inline),
  :deep(.name-input-inline .u-input__content) {
    width: 100% !important;
    flex: 1 !important;
  }
  :deep(.name-input-inline input),
  :deep(.name-input-inline .u-input__content__field-wrapper__field) {
    width: 100% !important;
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-readonly .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-readonly .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-readonly .u-input__content__field-wrapper__field) {
    text-align: right !important;
    color: #303133 !important;
  }
  .dynamic-form {
    padding: 0 0 4px;
  }
  :deep(.dynamic-form .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.dynamic-form .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-inline .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-inline .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-inline input),
  :deep(.form-item-inline .u-input__content__field-wrapper__field) {
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-select .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-select .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-textarea .u-form-item__body),
  :deep(.form-item-daterange .u-form-item__body) {
    flex-direction: column !important;
    align-items: stretch !important;
    padding: 10px 0 12px !important;
  }
  :deep(.form-item-textarea .u-form-item__body__left),
  :deep(.form-item-daterange .u-form-item__body__left) {
    width: 100% !important;
    max-width: 100% !important;
    margin-bottom: 8px !important;
    padding-right: 0 !important;
  }
  :deep(.form-item-textarea .u-form-item__body__left__content__label),
  :deep(.form-item-daterange .u-form-item__body__left__content__label) {
    white-space: normal !important;
    line-height: 1.45 !important;
    font-size: 14px !important;
  }
  :deep(.form-item-textarea .u-form-item__body__right),
  :deep(.form-item-daterange .u-form-item__body__right) {
    width: 100% !important;
    flex: none !important;
  }
  :deep(.form-item-textarea .u-form-item__content),
  :deep(.form-item-daterange .u-form-item__content) {
    width: 100% !important;
    justify-content: stretch !important;
  }
  :deep(.dynamic-form .u-form-item__body__left__content__label) {
    white-space: nowrap;
  }
  .field-trigger {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 6px;
    width: 100%;
    min-width: 0;
  }
  :deep(.field-trigger .u-input) {
    flex: 1 !important;
    min-width: 0 !important;
  }
  :deep(.field-trigger .u-input__content__field-wrapper__field) {
    text-align: right !important;
    font-size: 15px !important;
  }
  .daterange-fill {
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .range-fill-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    background: #f7f9fc;
    border: 1px solid #eef1f6;
    border-radius: 8px;
  }
  .range-fill-label {
    flex-shrink: 0;
    width: 36px;
    font-size: 13px;
    color: #909399;
  }
  .range-fill-sep {
    font-size: 12px;
    color: #c0c4cc;
    text-align: center;
  }
  :deep(.range-fill-row .u-input) {
    flex: 1 !important;
    min-width: 0 !important;
  }
  .section-card {
    margin-bottom: 10px;
    background: #fff;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .form-prompt {
    margin: 12px 16px 0;
    padding: 10px 12px;
    font-size: 13px;
    color: $text-secondary;
    background: #f8fafc;
    border-radius: 8px;
    line-height: 1.5;
  }
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-node-block {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  .flow-node-card {
    background: #fafbfd;
    border: 1px solid #e8eef5;
    border-radius: $radius-md;
    padding: 12px;
  }
  .node-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    background: $primary;
    color: #fff;
    font-size: 14px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .node-level-text {
    flex: 1;
    font-size: 15px;
    font-weight: 600;
    color: $text;
  }
  .approve-type-row {
    display: flex;
    background: #f0f3f8;
    border-radius: 8px;
    padding: 3px;
    margin-bottom: 10px;
    &--readonly {
      pointer-events: none;
    }
  }
  .type-btn {
    flex: 1;
    text-align: center;
    padding: 8px 0;
    font-size: 14px;
    color: $text-secondary;
    border-radius: 6px;
    &.active {
      background: #fff;
      color: $primary;
      font-weight: 500;
    }
  }
  .approver-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-items: center;
  }
  .approver-chip {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px 6px 6px;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 24px;
    box-shadow: 0 2px 6px rgba(41, 121, 255, 0.06);
  }
  .approver-avatar {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 12px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .approver-name {
    font-size: 13px;
    color: $text;
    max-width: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 4px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #d0dff0;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: $text-muted;
    &.inline {
      padding: 0;
    }
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .module-extra-block {
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px dashed #e8ecf0;
  }
  .readonly-with-unit {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    justify-content: flex-end;
  }
  .readonly-with-unit :deep(.u-input) {
    flex: 1;
    min-width: 0;
  }
  .unit-text {
    flex-shrink: 0;
    font-size: 14px;
    color: $text-muted;
  }
</style>
src/pages/oa/ApproveManage/approve-list/approve.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,180 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹å¤„理
  å·®æ—…/费用报销使用报销详情 + å®¡æ‰¹åˆ—表 approve æŽ¥å£
-->
<template>
  <view class="oa-detail-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view v-if="displayReady"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ReimburseInstanceDetailBody v-if="isReimburse"
                                 :reimburse-row="reimburseRow"
                                 :module-key="detailModuleKey" />
      <ApproveInstanceDetailBody v-else
                                 :row="row"
                                 :module-key="detailModuleKey" />
      <view class="section-card opinion-card">
        <view class="section-head">
          <text class="section-title">审批意见</text>
        </view>
        <view class="opinion-wrap">
          <up-textarea v-model="approveOpinion"
                       placeholder="通过可留空;驳回请填写具体原因"
                       maxlength="500"
                       count
                       height="100"
                       border="surround" />
        </view>
      </view>
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                :text="loading ? '加载中' : '未获取到审批数据'" />
    </view>
    <view v-if="displayReady"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            :class="{ 'is-disabled': submitting }"
            @click="goBack">取消</text>
      <text class="oa-footer-btn btn-danger"
            :class="{ 'is-disabled': submitting }"
            @click="submitApprove('rejected')">驳回</text>
      <text class="oa-footer-btn btn-success"
            :class="{ 'is-disabled': submitting }"
            @click="submitApprove('approved')">通过</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
  import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue";
  import { approveApprovalInstance } from "@/api/oa/approvalInstance.js";
  import {
    buildApproveInstanceDto,
    canApproveInstance,
    loadInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    inferReimburseModuleKeyFromInstance,
    isReimburseApprovalInstance,
    loadReimburseDetailForInstance,
  } from "../../_utils/reimburseApproveBridge.js";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  const row = ref(null);
  const reimburseRow = ref(null);
  const loading = ref(false);
  const approveOpinion = ref("");
  const submitting = ref(false);
  const isReimburse = computed(() => isReimburseApprovalInstance(row.value));
  const detailModuleKey = computed(() => {
    if (isReimburse.value) {
      return (
        reimburseRow.value?.moduleKey ||
        inferReimburseModuleKeyFromInstance(row.value)
      );
    }
    return inferModuleKeyFromRow(row.value);
  });
  const pageTitle = computed(() => {
    if (isReimburse.value) {
      const label = getApprovalModuleConfig(detailModuleKey.value)?.label || "报销";
      return `${label}审批`;
    }
    return "审批处理";
  });
  const displayReady = computed(() =>
    isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value)
  );
  const goBack = () => uni.navigateBack();
  const submitApprove = uiResult => {
    if (!row.value?.id || submitting.value) return;
    if (uiResult === "rejected" && !(approveOpinion.value || "").trim()) {
      uni.showToast({ title: "驳回时请填写审批意见", icon: "none" });
      return;
    }
    submitting.value = true;
    const dto = buildApproveInstanceDto(
      row.value.id,
      uiResult,
      approveOpinion.value
    );
    approveApprovalInstance(dto)
      .then(() => {
        uni.showToast({
          title: uiResult === "approved" ? "已通过" : "已驳回",
          icon: "success",
        });
        setTimeout(() => {
          const pages = getCurrentPages();
          const prevRoute = pages[pages.length - 2]?.route || "";
          const delta = prevRoute.includes("approve-list/detail") ? 2 : 1;
          uni.navigateBack({ delta });
        }, 400);
      })
      .catch(() => {
        uni.showToast({ title: "审批操作失败", icon: "none" });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  onLoad(async options => {
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    if (!canApproveInstance(cached)) {
      uni.showToast({ title: "当前审批无需您处理", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    row.value = cached;
    if (isReimburseApprovalInstance(cached)) {
      loading.value = true;
      try {
        const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached);
        reimburseRow.value = mapped;
      } catch {
        uni.showToast({ title: "加载报销详情失败", icon: "none" });
      } finally {
        loading.value = false;
      }
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
</style>
src/pages/oa/ApproveManage/approve-list/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹è¯¦æƒ…
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/detail
-->
<template>
  <view class="oa-detail-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view v-if="displayReady"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ReimburseInstanceDetailBody v-if="isReimburse"
                                 :reimburse-row="reimburseRow"
                                 :module-key="detailModuleKey" />
      <ApproveInstanceDetailBody v-else
                                 :row="row"
                                 :module-key="detailModuleKey" />
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                :text="loading ? '加载中' : '未获取到审批数据'" />
    </view>
    <view v-if="displayReady"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">返回</text>
      <text v-if="showEdit"
            class="oa-footer-btn btn-warn"
            @click="goEdit">修改</text>
      <text v-if="showApprove && !fromBusiness"
            class="oa-footer-btn btn-primary"
            @click="goApprove">去审批</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
  import ReimburseInstanceDetailBody from "../../ReimburseManage/_components/ReimburseInstanceDetailBody.vue";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import {
    canApproveInstance,
    canEditBusinessInstanceRow,
    canModifyInstance,
    loadInstanceRow,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    inferReimburseModuleKeyFromInstance,
    isReimburseApprovalInstance,
    loadReimburseDetailForInstance,
    resolveFinReimbursementIdFromInstance,
    stashReimburseEditFromApprove,
  } from "../../_utils/reimburseApproveBridge.js";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  import { canEditReimbursementRow } from "../../_utils/finReimbursementMappers.js";
  const userStore = useUserStore();
  const fromBusiness = ref(false);
  const row = ref(null);
  const reimburseRow = ref(null);
  const loading = ref(false);
  const detailModuleKey = computed(() => {
    if (isReimburse.value) {
      return (
        reimburseRow.value?.moduleKey ||
        inferReimburseModuleKeyFromInstance(row.value)
      );
    }
    return inferModuleKeyFromRow(row.value);
  });
  const isReimburse = computed(() => isReimburseApprovalInstance(row.value));
  const pageTitle = computed(() => {
    if (isReimburse.value) {
      return getApprovalModuleConfig(detailModuleKey.value)?.label
        ? `${getApprovalModuleConfig(detailModuleKey.value).label}详情`
        : "报销详情";
    }
    return "审批详情";
  });
  const displayReady = computed(() =>
    isReimburse.value ? Boolean(reimburseRow.value) : Boolean(row.value)
  );
  const showEdit = computed(() => {
    if (isReimburse.value) {
      return canEditReimbursementRow(reimburseRow.value);
    }
    if (fromBusiness.value) {
      return canEditBusinessInstanceRow(row.value);
    }
    return canModifyInstance(row.value, userStore);
  });
  const showApprove = computed(() => canApproveInstance(row.value));
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    if (!showEdit.value) return;
    if (isReimburse.value) {
      const mk = detailModuleKey.value;
      const rid = resolveFinReimbursementIdFromInstance(row.value);
      if (rid == null) {
        uni.showToast({ title: "无法修改:缺少报销单 ID", icon: "none" });
        return;
      }
      stashReimburseEditFromApprove(mk, rid);
      uni.navigateTo({
        url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`,
      });
      return;
    }
    if (!row.value?.id) return;
    const mk = detailModuleKey.value;
    const q = mk ? `&moduleKey=${mk}` : "";
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${row.value.id}${q}`,
    });
  };
  const goApprove = () => {
    if (!row.value?.id) return;
    stashInstanceRow(row.value);
    uni.navigateTo({
      url: `${OA_NAV.approveListApprove}?id=${row.value.id}`,
    });
  };
  onLoad(async options => {
    fromBusiness.value = options?.from === "business";
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入详情", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    row.value = cached;
    if (isReimburseApprovalInstance(cached)) {
      loading.value = true;
      try {
        const { reimburseRow: mapped } = await loadReimburseDetailForInstance(cached);
        reimburseRow.value = mapped;
      } catch {
        uni.showToast({ title: "加载报销详情失败", icon: "none" });
      } finally {
        loading.value = false;
      }
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
</style>
src/pages/oa/ApproveManage/approve-list/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,282 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹åˆ—表
-->
<template>
  <view class="oa-approval-page">
    <PageHeader title="审批列表"
                @back="goBack" />
    <view class="oa-toolbar">
      <view class="oa-filter-chip active-search">
        <up-icon name="search"
                 size="18"
                 color="#666" />
        <up-input v-model="queryParams.keyword"
                  class="chip-input"
                  placeholder="审批标题 / å®¡æ‰¹ç¼–号"
                  clearable
                  border="none"
                  @confirm="handleSearch" />
      </view>
      <view class="oa-icon-btn"
            @click="handleSearch">
        <up-icon name="search"
                 size="20"
                 color="#2979ff" />
      </view>
    </view>
    <scroll-view class="oa-list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 :style="{ height: listScrollHeight + 'px' }"
                 @scrolltolower="loadMore">
      <view v-if="list.length"
            class="oa-card-list">
        <view v-for="item in list"
              :key="item.id"
              class="oa-card"
              @click="openDetail(item)">
          <view class="oa-card-head">
            <view class="oa-card-title-wrap">
              <text class="oa-card-title">{{ item.title || item.instanceNo || "-" }}</text>
              <text v-if="item.templateName"
                    class="oa-card-sub">{{ item.templateName }}</text>
            </view>
            <text :class="['oa-status', businessStatusClass(item.status)]">
              {{ statusText(item.status) }}
            </text>
          </view>
          <view class="oa-card-body">
            <view class="oa-info-grid">
              <view class="oa-info-row">
                <text class="oa-info-label">审批编号</text>
                <text class="oa-info-value">{{ item.instanceNo || "-" }}</text>
              </view>
              <view v-if="item.businessName"
                    class="oa-info-row">
                <text class="oa-info-label">业务名称</text>
                <text class="oa-info-value">{{ item.businessName }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请人</text>
                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">当前审批人</text>
                <text class="oa-info-value">{{ currentApproverName(item) }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请时间</text>
                <text class="oa-info-value">{{ formatDateTime(item.applyTime) }}</text>
              </view>
            </view>
          </view>
          <view v-if="canModify(item) || item.isApprove"
                class="oa-card-foot"
                @click.stop>
            <text v-if="canModify(item)"
                  class="oa-foot-btn btn-edit"
                  @click="goModify(item)">编辑</text>
            <text v-if="item.isApprove"
                  class="oa-foot-btn btn-approve"
                  @click="handleApprove(item)">审批</text>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else
            class="oa-empty">
        <up-empty mode="list"
                  text="暂无审批数据" />
      </view>
    </scroll-view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff" />
    </view>
  </view>
</template>
<script setup>
  import { onMounted, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { listApprovalInstancePage } from "@/api/oa/approvalInstance.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import { parseTime } from "@/utils/ruoyi";
  import {
    businessStatusClass,
    businessStatusText,
    canModifyInstance,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import {
    inferReimburseModuleKeyFromInstance,
    resolveFinReimbursementIdFromInstance,
    stashReimburseEditFromApprove,
  } from "../../_utils/reimburseApproveBridge.js";
  const userStore = useUserStore();
  const queryParams = reactive({ keyword: "" });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({ current: 1, size: 10, total: 0 });
  const listScrollHeight = ref(400);
  function calcListScrollHeight() {
    const sys = uni.getSystemInfoSync();
    const statusBar = sys.statusBarHeight || 0;
    const navBar = 44;
    const toolbar = 56;
    const fabGap = 16;
    listScrollHeight.value = Math.max(
      200,
      sys.windowHeight - statusBar - navBar - toolbar - fabGap
    );
  }
  const statusText = status => businessStatusText(status);
  const formatDateTime = val => {
    if (!val) return "-";
    return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
  };
  const canModify = item => canModifyInstance(item, userStore);
  const currentApproverName = item => {
    const tasks = item?.tasks;
    if (!Array.isArray(tasks) || !tasks.length) return "-";
    const pending = tasks.find(t => t.taskStatus === "PENDING");
    if (pending?.approverName) return pending.approverName;
    const names = [...new Set(tasks.map(t => t.approverName).filter(Boolean))];
    return names.length ? names.join("、") : "-";
  };
  const buildListParams = () => {
    const keyword = queryParams.keyword?.trim();
    const dto = {};
    if (keyword) {
      if (/[\u4e00-\u9fa5]/.test(keyword)) {
        dto.title = keyword;
      } else {
        dto.instanceNo = keyword;
      }
    }
    return { current: page.current, size: page.size, ...dto };
  };
  const getList = () => {
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    listApprovalInstancePage(buildListParams())
      .then(res => {
        const pageData = res?.data || {};
        const records = pageData.records || [];
        const total = pageData.total ?? 0;
        if (page.current === 1) {
          list.value = records;
        } else {
          list.value = [...list.value, ...records];
        }
        page.total = total;
        if (list.value.length >= total || records.length < page.size) {
          pageStatus.value = "nomore";
        } else {
          pageStatus.value = "loadmore";
          page.current += 1;
        }
      })
      .catch(() => {
        if (page.current === 1) list.value = [];
        pageStatus.value = "loadmore";
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const handleSearch = () => {
    page.current = 1;
    pageStatus.value = "loadmore";
    list.value = [];
    getList();
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") getList();
  };
  const goBack = () => uni.navigateBack();
  const goAdd = () => uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  const openDetail = item => {
    if (!item?.id) return;
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListDetail}?id=${item.id}` });
  };
  const goModify = item => {
    if (!canModify(item)) {
      uni.showToast({ title: "仅进行中的本人申请可编辑", icon: "none" });
      return;
    }
    const mk = inferReimburseModuleKeyFromInstance(item);
    if (mk) {
      const rid = resolveFinReimbursementIdFromInstance(item);
      if (rid == null) {
        uni.showToast({ title: "无法修改:缺少报销单 ID", icon: "none" });
        return;
      }
      stashReimburseEditFromApprove(mk, rid);
      uni.navigateTo({
        url: `${OA_NAV.reimburseForm}?moduleKey=${mk}&mode=edit&reimbursementId=${rid}`,
      });
      return;
    }
    if (!item?.id) return;
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` });
  };
  const handleApprove = item => {
    if (!item?.id) return;
    if (!item.isApprove) {
      uni.showToast({ title: "当前审批无需您处理", icon: "none" });
      return;
    }
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApprove}?id=${item.id}` });
  };
  onMounted(() => calcListScrollHeight());
  onShow(() => {
    calcListScrollHeight();
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  @import "../../_styles/oa-approval-list.scss";
  .active-search {
    padding-right: 4px;
  }
  .chip-input {
    flex: 1;
    font-size: 14px;
  }
  :deep(.chip-input .u-input__content) {
    background: transparent !important;
    padding: 0 !important;
  }
</style>
src/pages/oa/ApproveManage/approve-list/template-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,378 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / é€‰æ‹©å®¡æ‰¹æ¨¡æ¿
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/template-select
  Tab:TypeEnums â†’ businessType;列表:GET /approvalTemplate/list/1(自定义已启用)后按 businessType ç­›é€‰
-->
<template>
  <view class="template-select-page sales-account">
    <PageHeader :title="pageHeaderTitle"
                @back="goBack" />
    <view v-if="typeOptions.length && !moduleKey"
          class="step-section">
      <view class="tabs-wrap">
        <up-tabs :list="tabList"
                 :current="activeTab"
                 line-color="#2979ff"
                 @click="onTabClick" />
      </view>
    </view>
    <view v-if="useAllTemplatesFallback && allTemplates.length"
          class="fallback-hint">
      <text>当前类型下无匹配模板,已显示全部 {{ allTemplates.length }} ä¸ªå¯ç”¨æ¨¡æ¿</text>
    </view>
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="keyword"
                    class="search-text"
                    placeholder="请输入模板名称"
                    clearable />
        </view>
        <view class="filter-button">
          <up-icon name="search"
                   size="24"
                   color="#999" />
        </view>
      </view>
    </view>
    <scroll-view class="list-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            class="loading-wrap">
        <up-loading-icon mode="circle" />
        <text class="loading-text">加载中...</text>
      </view>
      <view v-else-if="displayList.length"
            class="ledger-list">
        <view v-for="item in displayList"
              :key="item.id"
              class="ledger-item ledger-item--clickable"
              @click="selectTemplate(item)">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text"
                         size="16"
                         color="#ffffff" />
              </view>
              <text class="item-id">{{ item.templateName || "-" }}</text>
            </view>
            <u-tag :type="enabledTagType(item.enabled)"
                   :text="enabledText(item.enabled)" />
          </view>
          <up-divider />
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">审批类型</text>
              <text class="detail-value">{{ businessTypeText(item.businessType) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">审批节点</text>
              <text class="detail-value">{{ nodeCount(item) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">模板说明</text>
              <text class="detail-value">{{ item.description || "-" }}</text>
            </view>
          </view>
        </view>
      </view>
      <view v-else
            class="empty-wrap">
        <up-empty mode="list"
                  :text="emptyText" />
      </view>
    </scroll-view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { OA_NAV } from "@/config/oaPaths.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    buildTypeOptionsFromTemplates,
    FALLBACK_BUSINESS_TYPE_OPTIONS,
    fetchEnabledApprovalTemplates,
    filterTemplatesByBusinessType,
    filterTemplatesByBusinessTypes,
    getBusinessTypeLabel,
    pickTabIndexWithTemplates,
  } from "../../_utils/approvalTemplateType.js";
  import {
    getApprovalModuleConfig,
    getModuleMatchingBusinessTypes,
    resolveModuleBusinessType,
  } from "../../_utils/approvalModuleRegistry.js";
  const moduleKey = ref("");
  const typeOptions = ref([]);
  const typeLabelMap = ref({});
  /** å…¨éƒ¨è‡ªå®šä¹‰å·²å¯ç”¨æ¨¡æ¿ï¼ˆlist/1 ä¸€æ¬¡æ‹‰å–) */
  const allTemplates = ref([]);
  const activeTab = ref(0);
  const keyword = ref("");
  const loading = ref(false);
  const tabList = computed(() =>
    typeOptions.value.map(opt => ({ name: opt.name }))
  );
  const moduleConfig = computed(() =>
    moduleKey.value ? getApprovalModuleConfig(moduleKey.value) : null
  );
  const pageHeaderTitle = computed(() => {
    if (moduleConfig.value?.label) {
      return `选择${moduleConfig.value.label}模板`;
    }
    return "选择审批模板";
  });
  const moduleBusinessTypes = computed(() => {
    if (!moduleKey.value) return [];
    return getModuleMatchingBusinessTypes(moduleKey.value, typeOptions.value);
  });
  const currentTypeOption = computed(() => typeOptions.value[activeTab.value]);
  /** æ—  moduleKey ä¸”当前 Tab ç­›ä¸åˆ°æ—¶ï¼Œå±•示全部模板避免「有数据却空白」 */
  const useAllTemplatesFallback = computed(() => {
    if (moduleKey.value) return false;
    if (!allTemplates.value.length) return false;
    const businessType = currentTypeOption.value?.value;
    if (businessType == null || businessType === "") return true;
    return filterTemplatesByBusinessType(allTemplates.value, businessType).length === 0;
  });
  const currentSource = computed(() => {
    if (moduleKey.value && moduleBusinessTypes.value.length) {
      const filtered = filterTemplatesByBusinessTypes(
        allTemplates.value,
        moduleBusinessTypes.value
      );
      if (filtered.length) return filtered;
      return allTemplates.value;
    }
    if (useAllTemplatesFallback.value) {
      return allTemplates.value;
    }
    const businessType = currentTypeOption.value?.value;
    return filterTemplatesByBusinessType(allTemplates.value, businessType);
  });
  const displayList = computed(() => {
    const kw = keyword.value?.trim().toLowerCase();
    if (!kw) return currentSource.value;
    return currentSource.value.filter(item =>
      (item.templateName || "").toLowerCase().includes(kw)
    );
  });
  const emptyText = computed(() => {
    if (allTemplates.value.length === 0) {
      return "暂无已启用的审批模板,请先在「审批模板」中创建并启用";
    }
    if (moduleConfig.value?.label) {
      return `暂无${moduleConfig.value.label}可用模板`;
    }
    if (useAllTemplatesFallback.value) {
      return "当前类型下无匹配模板";
    }
    const typeName = currentTypeOption.value?.name || "该审批类型";
    return `暂无${typeName}下的模板(可切换上方类型)`;
  });
  const businessTypeText = type =>
    getBusinessTypeLabel(type, typeLabelMap.value);
  const enabledText = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "启用";
    if (val === "0") return "停用";
    return "-";
  };
  const enabledTagType = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "success";
    if (val === "0") return "info";
    return "info";
  };
  const nodeCount = item => {
    const count = item?.nodes?.length;
    return count != null ? `${count} ä¸ª` : "-";
  };
  const initPage = async () => {
    loading.value = true;
    keyword.value = "";
    allTemplates.value = [];
    try {
      const [opts, templates] = await Promise.all([
        fetchApprovalTemplateTypes(),
        fetchEnabledApprovalTemplates(),
      ]);
      let resolvedOpts = opts?.length ? opts : buildTypeOptionsFromTemplates(templates);
      if (!resolvedOpts.length) {
        resolvedOpts = [...FALLBACK_BUSINESS_TYPE_OPTIONS];
      }
      typeOptions.value = resolvedOpts;
      typeLabelMap.value = buildTypeLabelMap(resolvedOpts);
      allTemplates.value = templates;
      if (!templates.length) {
        uni.showToast({
          title: "未获取到已启用模板",
          icon: "none",
          duration: 2500,
        });
      }
      if (moduleKey.value) {
        const resolved = resolveModuleBusinessType(moduleKey.value, opts);
        const idx = opts.findIndex(
          opt => String(opt.value) === String(resolved)
        );
        activeTab.value =
          idx >= 0 ? idx : pickTabIndexWithTemplates(resolvedOpts, templates);
      } else {
        activeTab.value = pickTabIndexWithTemplates(resolvedOpts, templates);
      }
    } catch {
      typeOptions.value = [];
      typeLabelMap.value = {};
      allTemplates.value = [];
      uni.showToast({ title: "加载模板失败", icon: "none" });
    } finally {
      loading.value = false;
    }
  };
  const onTabClick = item => {
    activeTab.value = item?.index ?? 0;
    keyword.value = "";
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const selectTemplate = item => {
    if (!item?.id) return;
    if (String(item.enabled) === "0") {
      uni.showToast({ title: "该模板已停用", icon: "none" });
      return;
    }
    const base = `${OA_NAV.approveListApply}?templateId=${item.id}`;
    uni.navigateTo({
      url: moduleKey.value ? `${base}&moduleKey=${moduleKey.value}` : base,
    });
  };
  onLoad(options => {
    moduleKey.value = options?.moduleKey || "";
    initPage();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .template-select-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  .fallback-hint {
    margin: 8px 12px 0;
    padding: 8px 12px;
    font-size: 12px;
    color: #e6a23c;
    background: #fdf6ec;
    border-radius: 6px;
    line-height: 1.5;
  }
  .step-section {
    background: #fff;
    border-bottom: 1px solid #f0f0f0;
  }
  .step-label {
    display: block;
    padding: 10px 16px 0;
    font-size: 13px;
    font-weight: 600;
    color: #303133;
  }
  .step-hint {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    padding: 10px 16px 4px;
    gap: 8px;
  }
  .step-desc {
    flex-shrink: 0;
    font-size: 12px;
    color: #909399;
  }
  .tabs-wrap {
    padding: 0 12px 4px;
  }
  .list-scroll {
    flex: 1;
    height: 0;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .loading-wrap {
    padding: 48px 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
  }
  .loading-text {
    font-size: 14px;
    color: #909399;
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .ledger-item--clickable:active {
    opacity: 0.92;
  }
  .card-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px dashed #e8ecf0;
  }
  .card-footer-tip {
    font-size: 13px;
    color: #2979ff;
  }
</style>
src/pages/oa/ApproveManage/approve-template/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,419 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹æ¨¡æ¿è¯¦æƒ…
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-template/detail
-->
<template>
  <view class="template-detail-page">
    <PageHeader title="模板详情"
                @back="goBack" />
    <scroll-view class="detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            class="loading-wrap">
        <up-loading-icon mode="circle" />
        <text class="loading-text">加载中...</text>
      </view>
      <template v-else-if="detail">
        <view class="section">
          <view class="section-title">基本信息</view>
          <view class="info-list">
            <view class="info-item">
              <text class="info-label">模板名称</text>
              <text class="info-value">{{ detail.templateName || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">审批类型</text>
              <text class="info-value">{{ businessTypeText(detail.businessType) }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">启用状态</text>
              <text class="info-value"
                    :class="enabledClass(detail.enabled)">
                {{ enabledText(detail.enabled) }}
              </text>
            </view>
            <view class="info-item">
              <text class="info-label">模板说明</text>
              <text class="info-value">{{ detail.description || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">创建人</text>
              <text class="info-value">{{ detail.createdUserName || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">创建时间</text>
              <text class="info-value">{{ detail.createTime || "-" }}</text>
            </view>
          </view>
        </view>
        <view class="section">
          <view class="section-title">填报配置</view>
          <view class="info-list">
            <view class="info-item">
              <text class="info-label">填报提示</text>
              <text class="info-value">{{ formConfigData.prompt || "-" }}</text>
            </view>
          </view>
          <view v-if="formConfigData.fields.length"
                class="field-block">
            <view v-for="(field, index) in formConfigData.fields"
                  :key="field.key || index"
                  class="field-card">
              <view class="field-card-head">
                <text class="field-card-name">{{ field.label }}</text>
                <text class="field-tag">{{ fieldTypeLabel(field.type) }}</text>
                <text v-if="field.required"
                      class="field-tag field-tag--req">必填</text>
              </view>
              <text v-if="field.defaultValue"
                    class="field-card-default">
                é»˜è®¤å€¼ï¼š{{ field.defaultValue }}
              </text>
            </view>
          </view>
          <view v-else
                class="empty-hint">暂无填报项</view>
        </view>
        <view class="section">
          <view class="section-title">审批流程</view>
          <view v-if="detail.nodes?.length"
                class="flow-list">
            <view v-for="(node, index) in detail.nodes"
                  :key="node.id || index"
                  class="flow-card">
              <view class="flow-card-head">
                <text class="flow-level">第{{ levelLabel(node.levelNo || index + 1) }}级</text>
                <text class="flow-type">{{ approveTypeText(node.approveType) }}</text>
              </view>
              <view class="approver-tags">
                <text v-for="(approver, aIdx) in node.approvers || []"
                      :key="approver.id || aIdx"
                      class="approver-tag">
                  {{ approver.approverName || "-" }}
                </text>
                <text v-if="!(node.approvers || []).length"
                      class="empty-hint inline">暂无审批人</text>
              </view>
            </view>
          </view>
          <view v-else
                class="empty-hint">暂无审批节点</view>
        </view>
      </template>
      <view v-else
            class="empty-wrap">
        <up-empty mode="data"
                  text="未获取到模板详情" />
      </view>
    </scroll-view>
    <FooterButtons v-if="!loading && detail"
                   cancel-text="返回"
                   confirm-text="编辑"
                   @cancel="goBack"
                   @confirm="goEdit" />
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js";
  import { getFieldEditorTypeLabel } from "../../_utils/approvalFormField.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const templateId = ref("");
  const detail = ref(null);
  const loading = ref(false);
  const typeLabelMap = ref({});
  const formConfigData = computed(() => {
    const raw = detail.value?.formConfig;
    if (!raw) return { prompt: "", fields: [] };
    try {
      const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
      return {
        prompt: obj?.prompt || "",
        fields: Array.isArray(obj?.fields) ? obj.fields : [],
      };
    } catch {
      return { prompt: "", fields: [] };
    }
  });
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const enabledText = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "启用";
    if (val === "0") return "停用";
    return "-";
  };
  const enabledClass = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "status-on";
    if (val === "0") return "status-off";
    return "";
  };
  const fieldTypeLabel = type => getFieldEditorTypeLabel(type);
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
  const goBack = () => {
    uni.navigateBack();
  };
  const goEdit = () => {
    if (!templateId.value || !detail.value) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, detail.value);
    uni.navigateTo({
      url: `/pages/oa/ApproveManage/approve-template/edit?id=${templateId.value}`,
    });
  };
  const loadDetail = () => {
    if (!templateId.value) return;
    loading.value = true;
    detail.value = null;
    getApprovalTemplateDetail(templateId.value)
      .then(res => {
        detail.value = res?.data || null;
        if (!detail.value) {
          uni.showToast({ title: "未获取到详情", icon: "none" });
        }
      })
      .catch(() => {
        uni.showToast({ title: "获取详情失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    fetchApprovalTemplateTypes()
      .then(opts => {
        typeLabelMap.value = buildTypeLabelMap(opts);
      })
      .catch(() => {});
    if (options?.id) {
      templateId.value = options.id;
      loadDetail();
    }
  });
</script>
<style scoped lang="scss">
  .template-detail-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: #f0f3f8;
  }
  .detail-scroll {
    flex: 1;
    height: 0;
    padding: 10px 12px calc(96px + env(safe-area-inset-bottom));
  }
  .loading-wrap {
    padding: 48px 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
  }
  .loading-text {
    font-size: 14px;
    color: #909399;
  }
  .section {
    background: #fff;
    border-radius: 12px;
    margin-bottom: 10px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
  }
  .section-title {
    padding: 12px 16px;
    font-size: 15px;
    font-weight: 600;
    color: #1f2d3d;
    border-bottom: 1px solid #f2f4f7;
    border-left: 3px solid #2979ff;
    padding-left: 13px;
  }
  .info-list {
    padding: 4px 0;
  }
  .info-item {
    display: flex;
    align-items: flex-start;
    padding: 11px 16px;
    border-bottom: 1px solid #f5f7fa;
    gap: 12px;
    &:last-child {
      border-bottom: none;
    }
  }
  .info-label {
    flex-shrink: 0;
    width: 88px;
    font-size: 14px;
    color: #606266;
  }
  .info-value {
    flex: 1;
    font-size: 14px;
    color: #303133;
    text-align: right;
    word-break: break-all;
  }
  .status-on {
    color: #18a058;
  }
  .status-off {
    color: #909399;
  }
  .field-block {
    padding: 0 12px 12px;
  }
  .field-card {
    padding: 10px 12px;
    margin-bottom: 8px;
    background: #f8fafc;
    border-radius: 8px;
    border: 1px solid #eef2f6;
    &:last-child {
      margin-bottom: 0;
    }
  }
  .field-card-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
  }
  .field-card-name {
    font-size: 14px;
    font-weight: 500;
    color: #303133;
  }
  .field-tag {
    font-size: 11px;
    padding: 2px 8px;
    border-radius: 4px;
    color: #2979ff;
    background: #ecf5ff;
    &--req {
      color: #f56c6c;
      background: #fef0f0;
    }
  }
  .field-card-default {
    display: block;
    margin-top: 6px;
    font-size: 12px;
    color: #909399;
  }
  .flow-list {
    padding: 12px;
  }
  .flow-card {
    padding: 12px;
    margin-bottom: 8px;
    background: #f8fafc;
    border-radius: 8px;
    border: 1px solid #eef2f6;
    &:last-child {
      margin-bottom: 0;
    }
  }
  .flow-card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }
  .flow-level {
    font-size: 14px;
    font-weight: 600;
    color: #303133;
  }
  .flow-type {
    font-size: 13px;
    color: #2979ff;
  }
  .approver-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }
  .approver-tag {
    padding: 4px 10px;
    font-size: 13px;
    color: #303133;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 16px;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: #909399;
    &.inline {
      padding: 0;
    }
  }
  .empty-wrap {
    padding: 48px 20px;
  }
</style>
src/pages/oa/ApproveManage/approve-template/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,2634 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / æ–°å»ºå®¡æ‰¹æ¨¡æ¿
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-template/edit
-->
<template>
  <view class="template-edit-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view class="form-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="88"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="基本信息"
                      class="form-section">
          <up-form-item label="模板名称"
                        prop="templateName"
                        required
                        class="form-item-name">
            <up-input v-model="form.templateName"
                      class="name-input-inline"
                      placeholder="请输入模板名称"
                      maxlength="50"
                      :disabled="isSystemTemplate"
                      :clearable="!isSystemTemplate" />
          </up-form-item>
          <up-form-item label="审批类型"
                        prop="businessType"
                        required
                        class="form-item-select"
                        :class="{ 'form-item-select--disabled': isSystemTemplate }"
                        @click="openBusinessTypeSheet">
            <up-input :model-value="businessTypeText"
                      placeholder="请选择审批类型"
                      readonly
                      :disabled="isSystemTemplate" />
            <template v-if="!isSystemTemplate"
                      #right>
              <up-icon name="arrow-right"
                       @click.stop="openBusinessTypeSheet" />
            </template>
          </up-form-item>
          <up-form-item label="启用状态"
                        class="form-item-switch">
            <view class="switch-wrap">
              <up-switch v-model="enabledBool" />
            </view>
          </up-form-item>
          <up-form-item label="模板说明"
                        class="form-item-desc"
                        label-position="top">
            <view class="desc-input-shell">
              <up-textarea v-model="form.description"
                           placeholder="选填"
                           maxlength="200"
                           border="none"
                           height="72" />
            </view>
          </up-form-item>
        </u-cell-group>
        <view class="section-card">
          <view class="section-head section-head--between">
            <view class="section-head-left">
              <text class="section-title">填报项配置</text>
              <text class="section-count">共 {{ formConfig.fields.length }} é¡¹</text>
            </view>
            <view class="head-actions">
              <text class="head-link head-link--import"
                    :class="{ 'head-link--disabled': !canImportTemplate }"
                    @click="openTemplateImport">从已有模板导入</text>
              <text class="head-link head-link--primary"
                    @click="openFieldEditor()">+ æ·»åŠ å¡«æŠ¥é¡¹</text>
            </view>
          </view>
          <view class="section-body">
            <view class="prompt-row">
              <text class="prompt-label">填报提示</text>
              <up-input v-model="formConfig.prompt"
                        class="prompt-input"
                        placeholder="选填"
                        maxlength="200"
                        clearable />
            </view>
            <view v-if="formConfig.fields.length"
                  class="field-list">
              <view v-for="(field, index) in formConfig.fields"
                    :key="field.key"
                    class="field-item"
                    :class="{ 'field-item--locked': isFieldLocked(field) }"
                    @click="onFieldItemClick(field, index)">
                <view class="field-order">{{ index + 1 }}</view>
                <view class="field-main">
                  <view class="field-title-row">
                    <text class="field-name">{{ field.label }}</text>
                    <view class="field-tags">
                      <text class="type-tag"
                            :class="fieldTypeTagClass(field.type)">
                        {{ fieldTypeLabel(field.type) }}
                      </text>
                      <text v-if="field.required"
                            class="req-tag">必填</text>
                    </view>
                  </view>
                  <text class="field-key">{{ field.key }}</text>
                  <text v-if="field.defaultValue"
                        class="field-default">
                    é»˜è®¤ï¼š{{ formatFieldDefaultPreview(field) }}
                  </text>
                </view>
                <view v-if="!isFieldLocked(field)"
                      class="field-actions"
                      @click.stop>
                  <view class="icon-btn icon-btn--edit"
                        @click.stop="openFieldEditor(field, index)">
                    <up-icon name="edit-pen"
                             size="16"
                             color="#2979ff" />
                  </view>
                  <view class="icon-btn icon-btn--del"
                        @click.stop="removeField(index)">
                    <up-icon name="trash"
                             size="16"
                             color="#f56c6c" />
                  </view>
                </view>
                <view v-else
                      class="field-lock-tag">内置</view>
              </view>
            </view>
            <view v-else
                  class="empty-mini">
              <text>暂无填报项</text>
            </view>
          </view>
        </view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">审批流程</text>
          </view>
          <view class="flow-wrap">
            <view v-for="(node, nodeIndex) in flowNodes"
                  :key="node._key"
                  class="flow-node-block">
              <view class="flow-node-card">
                <view class="node-header">
                  <view class="node-level-badge">{{ nodeIndex + 1 }}</view>
                  <text class="node-level-text">第{{ levelLabel(nodeIndex + 1) }}级</text>
                  <view v-if="flowNodes.length > 1"
                        class="node-delete"
                        @click="removeNode(nodeIndex)">
                    <up-icon name="trash"
                             size="16"
                             color="#f56c6c" />
                  </view>
                </view>
                <view class="approve-type-row">
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'AND' }"
                        @click="node.approveType = 'AND'">
                    ä¼šç­¾
                  </view>
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'OR' }"
                        @click="node.approveType = 'OR'">
                    æˆ–ç­¾
                  </view>
                </view>
                <view class="approver-list">
                  <view v-for="(approver, approverIndex) in node.approvers"
                        :key="`${node._key}-${approver.approverId}-${approverIndex}`"
                        class="approver-chip">
                    <view class="approver-avatar">{{ (approver.approverName || "?").charAt(0) }}</view>
                    <text class="approver-name">{{ approver.approverName }}</text>
                    <view class="approver-remove"
                          hover-class="approver-remove--active"
                          @tap.stop="removeApprover(nodeIndex, approverIndex)"
                          @click.stop="removeApprover(nodeIndex, approverIndex)">
                      <text class="remove-icon">×</text>
                    </view>
                  </view>
                  <view class="add-approver"
                        @click="openUserPicker(nodeIndex)">
                    <up-icon name="plus"
                             size="14"
                             color="#2979ff" />
                    <text>添加</text>
                  </view>
                </view>
              </view>
              <view v-if="nodeIndex < flowNodes.length - 1"
                    class="flow-connector">
                <view class="flow-connector-line" />
              </view>
            </view>
            <view class="add-node-bar"
                  @click="addNode">
              <up-icon name="plus-circle"
                       size="20"
                       color="#2979ff" />
              <text>添加级次</text>
            </view>
          </view>
        </view>
      </up-form>
    </scroll-view>
    <FooterButtons :loading="submitting"
                   confirm-text="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-action-sheet :show="showTemplateImportSheet"
                     title="从已有模板导入"
                     :actions="templateImportActions"
                     @select="onSelectImportTemplate"
                     @close="showTemplateImportSheet = false" />
    <up-popup :show="showFieldEditor"
              mode="bottom"
              round="16"
              @close="closeFieldEditor">
      <view class="field-editor">
        <view class="sheet-handle" />
        <view class="editor-header">
          <text class="editor-title">{{ editingFieldIndex >= 0 ? "编辑填报项" : "添加填报项" }}</text>
          <text class="editor-subtitle">配置字段属性、校验与默认值</text>
        </view>
        <scroll-view class="editor-scroll"
                     scroll-y
                     :show-scrollbar="false">
          <view class="editor-form">
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">基础信息</text>
              </view>
              <view class="editor-cell">
                <text class="editor-label required">显示名称</text>
                <view class="editor-input-box">
                  <up-input v-model="fieldDraft.label"
                            placeholder="如:报销说明"
                            border="none"
                            clearable />
                </view>
              </view>
              <view class="editor-cell">
                <text class="editor-label required">字段标识</text>
                <view class="editor-input-box">
                  <up-input v-model="fieldDraft.key"
                            placeholder="如:summary"
                            border="none"
                            clearable />
                </view>
              </view>
              <view class="editor-cell editor-cell--tap"
                    @click="openFieldTypePicker">
                <text class="editor-label required">控件类型</text>
                <view class="picker-value-row">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.type }">
                    {{ fieldDraftTypeText || "请选择" }}
                  </text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
              </view>
            </view>
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">校验与格式</text>
              </view>
              <view class="editor-cell editor-cell--switch">
                <view class="switch-label-wrap">
                  <text class="editor-label">是否必填</text>
                  <text class="switch-hint">提交审批时须填写该项</text>
                </view>
                <up-switch v-model="fieldDraft.required"
                           active-color="#2979ff" />
              </view>
            </view>
            <view v-if="isSelectDraft"
                  class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">下拉选项</text>
              </view>
              <view class="editor-cell editor-cell--tap"
                    @click="openOptionSourcePicker">
                <text class="editor-label">选项来源</text>
                <view class="picker-value-row">
                  <text class="picker-value">{{ fieldDraftOptionSourceText }}</text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
              </view>
              <view v-if="fieldDraft.optionSource === 'manual'"
                    class="manual-options">
                <text class="manual-options-title">手动选项</text>
                <view class="manual-options-table">
                  <view class="option-table-head">
                    <text class="option-col option-col--idx" />
                    <text class="option-col option-col--label">显示文本</text>
                    <text class="option-col option-col--value">选项值</text>
                    <text class="option-col option-col--action" />
                  </view>
                  <view v-for="(opt, optIndex) in fieldDraft.options"
                        :key="optIndex"
                        class="option-card">
                    <text class="option-idx">{{ optIndex + 1 }}</text>
                    <view class="option-input-wrap">
                      <up-input v-model="opt.label"
                                placeholder="如:工作日加班"
                                border="none"
                                clearable />
                    </view>
                    <view class="option-input-wrap option-input-wrap--value">
                      <up-input v-model="opt.value"
                                placeholder="如:0"
                                border="none"
                                clearable />
                    </view>
                    <view class="option-del"
                          hover-class="option-del--active"
                          @click.stop="removeDraftOption(optIndex)">
                      <up-icon name="trash"
                               size="16"
                               color="#f56c6c" />
                    </view>
                  </view>
                </view>
                <view class="add-option-btn"
                      hover-class="add-option-btn--active"
                      @click="addDraftOption">
                  <up-icon name="plus-circle"
                           size="16"
                           color="#2979ff" />
                  <text>添加选项</text>
                </view>
              </view>
              <view v-else
                    class="option-source-tip">
                <up-icon name="info-circle"
                         size="14"
                         color="#909399" />
                <text>发起审批时将自动加载{{ fieldDraftOptionSourceText }}</text>
              </view>
            </view>
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">默认值</text>
              </view>
              <text class="default-hint">
                é€‰æ‹©è¯¥æ¨¡æ¿æäº¤å®¡æ‰¹æ—¶è‡ªåŠ¨é¢„å¡«ï¼Œç”¨æˆ·ä»å¯ä¿®æ”¹
              </text>
              <view class="editor-cell editor-cell--value">
                <up-textarea v-if="fieldDraft.type === 'textarea'"
                             v-model="fieldDraft.defaultValue"
                             placeholder="选填"
                             maxlength="500"
                             border="surround"
                             height="72" />
                <view v-else-if="fieldDraft.type === 'date'"
                      class="picker-value-row picker-value-row--tap"
                      @click="openDefaultDatePicker">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }">
                    {{ fieldDraft.defaultValue || "选择日期" }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#909399" />
                </view>
                <view v-else-if="isDatetimerangeDraft"
                      class="daterange-default-wrap">
                  <view class="daterange-default-item"
                        @click="openDefaultRangePicker('start')">
                    <text class="daterange-default-label">开始时间</text>
                    <view class="picker-value-row picker-value-row--tap">
                      <text class="picker-value"
                            :class="{ 'picker-value--placeholder': !defaultRangeStart }">
                        {{ defaultRangeStart || "选择开始时间" }}
                      </text>
                      <up-icon name="calendar"
                               size="18"
                               color="#909399" />
                    </view>
                  </view>
                  <view class="daterange-default-item"
                        @click="openDefaultRangePicker('end')">
                    <text class="daterange-default-label">结束时间</text>
                    <view class="picker-value-row picker-value-row--tap">
                      <text class="picker-value"
                            :class="{ 'picker-value--placeholder': !defaultRangeEnd }">
                        {{ defaultRangeEnd || "选择结束时间" }}
                      </text>
                      <up-icon name="calendar"
                               size="18"
                               color="#909399" />
                    </view>
                  </view>
                </view>
                <view v-else-if="isSelectDraft"
                      class="picker-value-row picker-value-row--tap"
                      @click="openDefaultSelectSheet">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }">
                    {{ defaultSelectDisplayText || "选填" }}
                  </text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
                <view v-else
                      class="editor-input-box">
                  <up-input v-model="fieldDraft.defaultValue"
                            :type="fieldDraft.type === 'number' ? 'digit' : 'text'"
                            placeholder="选填"
                            border="none"
                            clearable />
                </view>
              </view>
            </view>
          </view>
        </scroll-view>
        <view class="editor-footer">
          <view class="editor-btn editor-btn--cancel"
                @click="closeFieldEditor">取消</view>
          <view class="editor-btn editor-btn--confirm"
                @click="confirmFieldEditor">确定</view>
        </view>
        <view v-if="inlinePickerShow"
              class="editor-picker-layer">
          <view class="editor-picker-mask"
                @click="closeInlinePicker" />
          <view class="editor-picker-panel">
            <view class="editor-picker-head">
              <text class="editor-picker-cancel"
                    @click="closeInlinePicker">取消</text>
              <text class="editor-picker-title">{{ inlinePickerTitle }}</text>
              <text class="editor-picker-placeholder" />
            </view>
            <scroll-view class="editor-picker-scroll"
                         scroll-y>
              <view v-for="(item, pickerIndex) in inlinePickerOptions"
                    :key="`${inlinePickerMode}-${pickerIndex}-${item.value}`"
                    class="editor-picker-item"
                    :class="{ 'editor-picker-item--active': isInlinePickerItemActive(item) }"
                    @click="onInlinePickerSelect(item)">
                <text>{{ item.name }}</text>
                <up-icon v-if="isInlinePickerItemActive(item)"
                         name="checkmark"
                         size="18"
                         color="#2979ff" />
              </view>
            </scroll-view>
          </view>
        </view>
      </view>
    </up-popup>
    <up-popup :show="showDefaultDatePicker"
              mode="bottom"
              @close="closeDefaultDatePicker">
      <up-datetime-picker :show="true"
                          v-model="defaultDateTs"
                          :mode="defaultDatePickerMode"
                          @confirm="onDefaultDatePickerConfirm"
                          @cancel="closeDefaultDatePicker" />
    </up-popup>
    <up-popup :show="showUserPicker"
              mode="bottom"
              round="16"
              @close="closeUserPicker">
      <view class="user-picker">
        <view class="sheet-handle" />
        <view class="picker-head">
          <text class="picker-cancel"
                @click="closeUserPicker">取消</text>
          <text class="picker-title">选择审批人</text>
          <text class="picker-confirm"
                @click="confirmUserPicker">
            ç¡®å®š{{ pickerSelectedIds.length ? `(${pickerSelectedIds.length})` : "" }}
          </text>
        </view>
        <scroll-view class="user-scroll"
                     scroll-y>
          <view v-for="user in availableUsers"
                :key="user.userId"
                class="user-item"
                :class="{ selected: isUserSelected(user.userId) }"
                @click="toggleUser(user)">
            <view class="user-avatar">{{ (user.nickName || "?").charAt(0) }}</view>
            <text class="user-name">{{ user.nickName }}</text>
            <view class="user-check"
                  :class="{ checked: isUserSelected(user.userId) }">
              <up-icon v-if="isUserSelected(user.userId)"
                       name="checkmark"
                       size="14"
                       color="#fff" />
            </view>
          </view>
        </scroll-view>
      </view>
    </up-popup>
    <up-action-sheet :show="showBusinessTypeSheet"
                     title="选择审批类型"
                     :actions="businessTypeActions"
                     @select="onSelectBusinessType"
                     @close="showBusinessTypeSheet = false" />
  </view>
</template>
<script setup>
  import { computed, onMounted, reactive, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import {
    addApprovalTemplate,
    getApprovalTemplateDetail,
    listApprovalTemplatePage,
    updateApprovalTemplate,
  } from "@/api/oa/approvalTemplate.js";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import {
    buildFieldConfigPayload,
    createEmptyFieldOption,
    parseApprovalFormConfig,
    FIELD_EDITOR_TYPE_OPTIONS,
    FIELD_OPTION_SOURCE_OPTIONS,
    getFieldEditorTypeLabel,
    getFieldOptionLabel,
    getFieldOptionSource,
    getFieldOptionSourceLabel,
    isDatetimerangeField,
    isSelectField,
    formatDatetimerangeDisplay,
    formatFieldDateValue,
    joinDatetimerangeValue,
    parseDatetimerangeValue,
    parseFieldDateToTs,
    resolveFieldOptions,
  } from "../../_utils/approvalFormField.js";
  import {
    fetchApprovalTemplateTypes,
    isSystemApprovalTemplate,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const formRef = ref();
  const submitting = ref(false);
  const userList = ref([]);
  const templateId = ref(null);
  const showTemplateImportSheet = ref(false);
  const importTemplateList = ref([]);
  const showFieldEditor = ref(false);
  const inlinePickerShow = ref(false);
  const inlinePickerTitle = ref("");
  const inlinePickerOptions = ref([]);
  const inlinePickerMode = ref("");
  const showUserPicker = ref(false);
  const showDefaultDatePicker = ref(false);
  const defaultDatePickerMode = ref("date");
  const defaultRangePickerPart = ref("start");
  const defaultDateTs = ref(Date.now());
  const deptList = ref([]);
  const editingFieldIndex = ref(-1);
  const editingNodeIndex = ref(-1);
  const pickerSelectedIds = ref([]);
  /** ç³»ç»Ÿæ¨¡æ¿åŠ è½½æ—¶é”å®šçš„å¡«æŠ¥é¡¹ key,不可编辑/删除 */
  const lockedFieldKeys = ref(new Set());
  const form = reactive({
    templateName: "",
    businessType: null,
    templateType: 1,
    enabled: "1",
    description: "",
  });
  const formConfig = reactive({
    prompt: "",
    fields: [],
  });
  const fieldDraft = reactive({
    label: "",
    key: "",
    type: "text",
    defaultValue: "",
    required: true,
    optionSource: "manual",
    options: [createEmptyFieldOption()],
  });
  let nodeKeySeed = 1;
  const createNode = () => ({
    _key: `node_${nodeKeySeed++}`,
    approveType: "AND",
    approvers: [],
  });
  const flowNodes = ref([createNode()]);
  const rules = {
    templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
    businessType: [
      {
        validator: (_rule, value, callback) => {
          if (value === "" || value === null || value === undefined) {
            callback(new Error("请选择审批类型"));
            return;
          }
          callback();
        },
        trigger: "change",
      },
    ],
  };
  const businessTypeOptions = ref([]);
  const showBusinessTypeSheet = ref(false);
  const businessTypeActions = computed(() =>
    businessTypeOptions.value.map(opt => ({
      name: opt.name,
      value: opt.value,
    }))
  );
  const businessTypeText = computed(() => {
    const matched = businessTypeOptions.value.find(
      opt => String(opt.value) === String(form.businessType)
    );
    return matched?.name || "";
  });
  const canImportTemplate = computed(() => !isSystemTemplate.value);
  const templateImportActions = computed(() =>
    importTemplateList.value.map(item => {
      const typeTag = isSystemApprovalTemplate(item) ? "系统" : "自定义";
      return {
        name: `【${typeTag}】${item.templateName || `模板${item.id}`}`,
        value: String(item.id),
      };
    })
  );
  const isSelectDraft = computed(() => isSelectField(fieldDraft));
  const isDatetimerangeDraft = computed(() => isDatetimerangeField(fieldDraft));
  const defaultRangeParts = computed(() =>
    parseDatetimerangeValue(fieldDraft.defaultValue)
  );
  const defaultRangeStart = computed(() => defaultRangeParts.value.start);
  const defaultRangeEnd = computed(() => defaultRangeParts.value.end);
  const fieldDraftTypeText = computed(() => getFieldEditorTypeLabel(fieldDraft.type));
  const fieldDraftOptionSourceText = computed(() =>
    getFieldOptionSourceLabel(fieldDraft.optionSource)
  );
  const defaultSelectActions = computed(() => {
    const options = resolveFieldOptions(fieldDraft, {
      users: userList.value,
      depts: deptList.value,
    });
    return [
      { name: "不设置", value: "" },
      ...options.map(opt => ({
        name: opt.label,
        value: opt.value,
      })),
    ];
  });
  const defaultSelectDisplayText = computed(() => {
    if (!fieldDraft.defaultValue) return "";
    return (
      getFieldOptionLabel(fieldDraft, fieldDraft.defaultValue) ||
      String(fieldDraft.defaultValue)
    );
  });
  const enabledBool = computed({
    get: () => form.enabled === "1",
    set: val => {
      form.enabled = val ? "1" : "0";
    },
  });
  const isEditMode = computed(() => templateId.value != null && templateId.value !== "");
  const isSystemTemplate = computed(() => isSystemApprovalTemplate(form));
  const isFieldLocked = field =>
    isSystemTemplate.value && lockedFieldKeys.value.has(field?.key);
  const pageTitle = computed(() =>
    isEditMode.value ? "编辑审批模板" : "新建审批模板"
  );
  const mapNodesFromRow = nodes => {
    if (!Array.isArray(nodes) || !nodes.length) {
      return [createNode()];
    }
    return nodes.map(node => ({
      _key: `node_${nodeKeySeed++}`,
      id: node.id,
      templateId: node.templateId,
      approveType: node.approveType || "AND",
      approvers: (node.approvers || []).map((approver, idx) => ({
        id: approver.id,
        nodeId: approver.nodeId,
        templateId: approver.templateId,
        approverId: approver.approverId,
        approverName: approver.approverName,
        sortNo: approver.sortNo ?? idx + 1,
      })),
    }));
  };
  const fillFormFromRow = row => {
    if (!row) return;
    templateId.value = row.id;
    form.templateName = row.templateName || "";
    const parsedBusiness = Number(row.businessType);
    form.businessType = Number.isNaN(parsedBusiness)
      ? row.businessType
      : parsedBusiness;
    const parsedTemplateType = Number(row.templateType);
    form.templateType = Number.isNaN(parsedTemplateType) ? 1 : parsedTemplateType;
    form.enabled = String(row.enabled ?? "1");
    form.description = row.description || "";
    const config = parseApprovalFormConfig(row.formConfig);
    formConfig.prompt = config.prompt;
    formConfig.fields = config.fields;
    lockedFieldKeys.value = isSystemApprovalTemplate(row)
      ? new Set(config.fields.map(f => f.key).filter(Boolean))
      : new Set();
    flowNodes.value = mapNodesFromRow(row.nodes);
  };
  const availableUsers = computed(() => {
    const node = flowNodes.value[editingNodeIndex.value];
    if (!node) return userList.value;
    const selectedIds = new Set(node.approvers.map(a => a.approverId));
    return userList.value.filter(user => !selectedIds.has(user.userId));
  });
  const levelLabel = n => LEVEL_TEXT[n] || String(n);
  const fieldTypeLabel = type => getFieldEditorTypeLabel(type);
  const formatFieldDefaultPreview = field => {
    if (isDatetimerangeField(field)) {
      return formatDatetimerangeDisplay(field.defaultValue) || field.defaultValue;
    }
    return field.defaultValue;
  };
  const fieldTypeTagClass = type => {
    const map = {
      text: "type-tag--text",
      textarea: "type-tag--area",
      number: "type-tag--num",
      date: "type-tag--date",
      datetimerange: "type-tag--date",
      select: "type-tag--select",
    };
    return map[type] || "type-tag--text";
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const openBusinessTypeSheet = () => {
    if (isSystemTemplate.value) return;
    if (!businessTypeOptions.value.length) {
      uni.showToast({ title: "审批类型加载中", icon: "none" });
      return;
    }
    showBusinessTypeSheet.value = true;
  };
  const onSelectBusinessType = action => {
    form.businessType = action.value;
    showBusinessTypeSheet.value = false;
    formRef.value?.validateField?.("businessType");
  };
  const applyImportedFormConfig = (config, sourceName = "") => {
    const parsed = {
      prompt: config?.prompt || "",
      fields: (config?.fields || []).map(field => ({ ...field })),
    };
    formConfig.prompt = parsed.prompt;
    formConfig.fields = parsed.fields;
    const tip = sourceName ? `已导入「${sourceName}」` : "已导入填报配置";
    uni.showToast({ title: tip, icon: "success" });
  };
  const doImportFormConfig = (config, sourceName) => {
    const hasExisting =
      !!formConfig.prompt?.trim() || formConfig.fields.length > 0;
    if (!hasExisting) {
      applyImportedFormConfig(config, sourceName);
      return;
    }
    uni.showModal({
      title: "导入确认",
      content: `将使用「${sourceName}」的填报配置覆盖当前内容,是否继续?`,
      success: res => {
        if (res.confirm) {
          applyImportedFormConfig(config, sourceName);
        }
      },
    });
  };
  const applyTemplateImport = templateIdValue => {
    const row = importTemplateList.value.find(
      item => String(item.id) === String(templateIdValue)
    );
    const sourceName = row?.templateName || "所选模板";
    const applyFromDetail = detail => {
      const config = parseApprovalFormConfig(detail?.formConfig);
      if (!config.fields.length && !config.prompt) {
        uni.showToast({ title: "该模板无填报配置", icon: "none" });
        return;
      }
      doImportFormConfig(config, sourceName);
    };
    if (row?.formConfig) {
      applyFromDetail(row);
      return;
    }
    uni.showLoading({ title: "加载配置...", mask: true });
    getApprovalTemplateDetail(templateIdValue)
      .then(res => applyFromDetail(res?.data))
      .catch(() => {
        uni.showToast({ title: "获取模板配置失败", icon: "none" });
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  const openTemplateImport = () => {
    if (!canImportTemplate.value) {
      uni.showToast({ title: "系统内置模板不可导入", icon: "none" });
      return;
    }
    uni.showLoading({ title: "加载中...", mask: true });
    listApprovalTemplatePage({
      page: { current: 1, size: 200 },
      approvalTemplateDto: {},
    })
      .then(res => {
        const records = res?.data?.records || [];
        importTemplateList.value = records.filter(
          item =>
            item?.id != null && String(item.id) !== String(templateId.value)
        );
        if (!importTemplateList.value.length) {
          uni.showToast({ title: "暂无可导入的模板", icon: "none" });
          return;
        }
        showTemplateImportSheet.value = true;
      })
      .catch(() => {
        uni.showToast({ title: "加载模板列表失败", icon: "none" });
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  const onSelectImportTemplate = action => {
    showTemplateImportSheet.value = false;
    const value = String(action?.value ?? "");
    if (!value) return;
    applyTemplateImport(value);
  };
  const resetFieldDraft = () => {
    fieldDraft.label = "";
    fieldDraft.key = "";
    fieldDraft.type = "text";
    fieldDraft.defaultValue = "";
    fieldDraft.required = true;
    fieldDraft.optionSource = "manual";
    fieldDraft.options = [createEmptyFieldOption()];
  };
  const resolveActionValue = (action, options) => {
    if (action?.value !== undefined && action?.value !== null) {
      return action.value;
    }
    const name = action?.name;
    if (name == null) return undefined;
    return options.find(opt => opt.name === name)?.value;
  };
  const onSelectFieldType = action => {
    const nextType = resolveActionValue(action, FIELD_EDITOR_TYPE_OPTIONS);
    if (!nextType || fieldDraft.type === nextType) return;
    fieldDraft.type = nextType;
    fieldDraft.defaultValue = "";
    if (!isSelectField(fieldDraft)) {
      fieldDraft.optionSource = "manual";
      fieldDraft.options = [createEmptyFieldOption()];
    } else if (!fieldDraft.options?.length) {
      fieldDraft.options = [createEmptyFieldOption()];
    }
  };
  const openInlinePicker = (title, options, mode) => {
    inlinePickerTitle.value = title;
    inlinePickerOptions.value = options;
    inlinePickerMode.value = mode;
    inlinePickerShow.value = true;
  };
  const closeInlinePicker = () => {
    inlinePickerShow.value = false;
    inlinePickerMode.value = "";
    inlinePickerOptions.value = [];
  };
  const isInlinePickerItemActive = item => {
    if (inlinePickerMode.value === "fieldType") {
      return String(fieldDraft.type) === String(item.value);
    }
    if (inlinePickerMode.value === "optionSource") {
      return String(fieldDraft.optionSource) === String(item.value);
    }
    if (inlinePickerMode.value === "defaultValue") {
      const val = fieldDraft.defaultValue;
      if (val === "" || val === undefined || val === null) {
        return item.value === "" || item.value === undefined || item.value === null;
      }
      return String(val) === String(item.value);
    }
    return false;
  };
  const onInlinePickerSelect = item => {
    if (inlinePickerMode.value === "fieldType") {
      onSelectFieldType(item);
    } else if (inlinePickerMode.value === "optionSource") {
      onSelectOptionSource(item);
    } else if (inlinePickerMode.value === "defaultValue") {
      onSelectDefaultOption(item);
    }
    closeInlinePicker();
  };
  const openFieldTypePicker = () => {
    openInlinePicker(
      "控件类型",
      FIELD_EDITOR_TYPE_OPTIONS.map(item => ({
        name: item.name,
        value: item.value,
      })),
      "fieldType"
    );
  };
  const onSelectOptionSource = action => {
    const nextSource = resolveActionValue(action, FIELD_OPTION_SOURCE_OPTIONS);
    if (!nextSource) return;
    fieldDraft.optionSource = nextSource;
    fieldDraft.defaultValue = "";
    if (nextSource === "manual" && !fieldDraft.options?.length) {
      fieldDraft.options = [createEmptyFieldOption()];
    }
  };
  const openOptionSourcePicker = () => {
    openInlinePicker(
      "选项来源",
      FIELD_OPTION_SOURCE_OPTIONS.map(item => ({
        name: item.name,
        value: item.value,
      })),
      "optionSource"
    );
  };
  const addDraftOption = () => {
    fieldDraft.options.push(createEmptyFieldOption());
  };
  const removeDraftOption = index => {
    if (fieldDraft.options.length <= 1) {
      fieldDraft.options[0] = createEmptyFieldOption();
      return;
    }
    fieldDraft.options.splice(index, 1);
  };
  const openDefaultSelectSheet = () => {
    const options = resolveFieldOptions(fieldDraft, {
      users: userList.value,
      depts: deptList.value,
    });
    if (!options.length) {
      uni.showToast({ title: "请先配置下拉选项", icon: "none" });
      return;
    }
    openInlinePicker("默认值", defaultSelectActions.value, "defaultValue");
  };
  const onSelectDefaultOption = action => {
    fieldDraft.defaultValue =
      action.value === undefined || action.value === null
        ? ""
        : String(action.value);
  };
  const closeDefaultDatePicker = () => {
    showDefaultDatePicker.value = false;
    defaultDatePickerMode.value = "date";
    defaultRangePickerPart.value = "start";
  };
  const openDefaultDatePicker = () => {
    defaultDatePickerMode.value = "date";
    const parsed = Date.parse(fieldDraft.defaultValue);
    defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed;
    showDefaultDatePicker.value = true;
  };
  const openDefaultRangePicker = part => {
    defaultDatePickerMode.value = "datetime";
    defaultRangePickerPart.value = part;
    const parts = parseDatetimerangeValue(fieldDraft.defaultValue);
    const val = part === "start" ? parts.start : parts.end;
    defaultDateTs.value = parseFieldDateToTs(val) ?? Date.now();
    showDefaultDatePicker.value = true;
  };
  const onDefaultDatePickerConfirm = e => {
    const ts = e?.value ?? defaultDateTs.value;
    if (defaultDatePickerMode.value === "datetime") {
      const parts = parseDatetimerangeValue(fieldDraft.defaultValue);
      const formatted = formatFieldDateValue({ type: "datetime" }, ts);
      fieldDraft.defaultValue = joinDatetimerangeValue(
        defaultRangePickerPart.value === "start" ? formatted : parts.start,
        defaultRangePickerPart.value === "end" ? formatted : parts.end
      );
    } else {
      fieldDraft.defaultValue = formatDateToYMD(ts);
    }
    closeDefaultDatePicker();
  };
  const onFieldItemClick = (field, index) => {
    if (isFieldLocked(field)) return;
    openFieldEditor(field, index);
  };
  const openFieldEditor = (field, index = -1) => {
    if (field && isFieldLocked(field)) {
      uni.showToast({ title: "系统内置填报项不可修改", icon: "none" });
      return;
    }
    editingFieldIndex.value = index;
    if (field) {
      fieldDraft.label = field.label || "";
      fieldDraft.key = field.key || "";
      fieldDraft.type = field.type || "text";
      fieldDraft.defaultValue = field.defaultValue ?? "";
      fieldDraft.required = !!field.required;
      fieldDraft.optionSource = getFieldOptionSource(field);
      fieldDraft.options = normalizeDraftOptions(field);
    } else {
      resetFieldDraft();
    }
    defaultDateTs.value = Date.now();
    showFieldEditor.value = true;
  };
  const closeFieldEditor = () => {
    closeInlinePicker();
    showFieldEditor.value = false;
    editingFieldIndex.value = -1;
  };
  const normalizeDraftOptions = field => {
    const options = field?.options;
    if (!Array.isArray(options) || !options.length) {
      return [createEmptyFieldOption()];
    }
    return options.map(opt => ({
      label: opt?.label ?? "",
      value: opt?.value != null ? String(opt.value) : "",
    }));
  };
  const buildFieldKey = label => {
    const base = (label || "field")
      .trim()
      .replace(/\s+/g, "_")
      .replace(/[^\w\u4e00-\u9fa5]/g, "");
    let key = base || "field";
    let index = 1;
    while (formConfig.fields.some((item, idx) => item.key === key && idx !== editingFieldIndex.value)) {
      key = `${base}_${index++}`;
    }
    return key;
  };
  const confirmFieldEditor = () => {
    if (
      editingFieldIndex.value >= 0 &&
      isFieldLocked(formConfig.fields[editingFieldIndex.value])
    ) {
      uni.showToast({ title: "系统内置填报项不可修改", icon: "none" });
      return;
    }
    if (!fieldDraft.label?.trim()) {
      uni.showToast({ title: "请输入显示名称", icon: "none" });
      return;
    }
    const existingKey =
      editingFieldIndex.value >= 0
        ? formConfig.fields[editingFieldIndex.value]?.key
        : null;
    const draftKey = fieldDraft.key?.trim() || existingKey || buildFieldKey(fieldDraft.label);
    if (!draftKey) {
      uni.showToast({ title: "请输入字段标识", icon: "none" });
      return;
    }
    const duplicateKey = formConfig.fields.some(
      (item, idx) => item.key === draftKey && idx !== editingFieldIndex.value
    );
    if (duplicateKey) {
      uni.showToast({ title: "字段标识已存在", icon: "none" });
      return;
    }
    if (isSelectField(fieldDraft) && fieldDraft.optionSource === "manual") {
      const validOptions = (fieldDraft.options || []).filter(
        opt => opt.label?.trim() && opt.value?.trim()
      );
      if (!validOptions.length) {
        uni.showToast({ title: "请至少配置一个下拉选项", icon: "none" });
        return;
      }
    }
    const payload = buildFieldConfigPayload(
      { ...fieldDraft, key: draftKey },
      existingKey
    );
    if (editingFieldIndex.value >= 0) {
      formConfig.fields.splice(editingFieldIndex.value, 1, payload);
    } else {
      formConfig.fields.push(payload);
    }
    closeFieldEditor();
  };
  const removeField = index => {
    const field = formConfig.fields[index];
    if (isFieldLocked(field)) {
      uni.showToast({ title: "系统内置填报项不可删除", icon: "none" });
      return;
    }
    formConfig.fields.splice(index, 1);
  };
  const addNode = () => {
    flowNodes.value.push(createNode());
  };
  const removeNode = index => {
    if (flowNodes.value.length <= 1) {
      uni.showToast({ title: "至少保留一个审批节点", icon: "none" });
      return;
    }
    flowNodes.value.splice(index, 1);
  };
  const openUserPicker = nodeIndex => {
    editingNodeIndex.value = nodeIndex;
    pickerSelectedIds.value = [];
    showUserPicker.value = true;
  };
  const closeUserPicker = () => {
    showUserPicker.value = false;
    editingNodeIndex.value = -1;
    pickerSelectedIds.value = [];
  };
  const isUserSelected = userId => pickerSelectedIds.value.includes(userId);
  const toggleUser = user => {
    const ids = pickerSelectedIds.value;
    const index = ids.indexOf(user.userId);
    if (index >= 0) {
      ids.splice(index, 1);
    } else {
      ids.push(user.userId);
    }
  };
  const confirmUserPicker = () => {
    const node = flowNodes.value[editingNodeIndex.value];
    if (!node) {
      closeUserPicker();
      return;
    }
    const selectedUsers = userList.value.filter(user =>
      pickerSelectedIds.value.includes(user.userId)
    );
    if (!selectedUsers.length) {
      uni.showToast({ title: "请选择审批人", icon: "none" });
      return;
    }
    const startSort = node.approvers.length;
    selectedUsers.forEach((user, idx) => {
      node.approvers.push({
        approverId: user.userId,
        approverName: user.nickName,
        sortNo: startSort + idx + 1,
      });
    });
    closeUserPicker();
  };
  const removeApprover = (nodeIndex, approverIndex) => {
    const node = flowNodes.value[nodeIndex];
    if (!node?.approvers?.length) return;
    const next = node.approvers
      .filter((_, idx) => idx !== approverIndex)
      .map((item, idx) => ({
        ...item,
        sortNo: idx + 1,
      }));
    node.approvers = next;
  };
  const validateFlow = () => {
    if (!flowNodes.value.length) {
      uni.showToast({ title: "请配置审批流程", icon: "none" });
      return false;
    }
    const emptyNode = flowNodes.value.find(node => !node.approvers.length);
    if (emptyNode) {
      uni.showToast({ title: "请为每个审批节点添加审批人", icon: "none" });
      return false;
    }
    return true;
  };
  const buildSubmitPayload = () => {
    const tid = templateId.value;
    const payload = {
      templateName: form.templateName.trim(),
      enabled: form.enabled,
      description: form.description?.trim() || "",
      businessType: form.businessType,
      templateType: form.templateType,
      formConfig: JSON.stringify({
        prompt: formConfig.prompt?.trim() || "",
        fields: formConfig.fields,
      }),
      nodes: flowNodes.value.map((node, index) => {
        const nodePayload = {
          levelNo: index + 1,
          approveType: node.approveType,
          approvers: node.approvers.map((approver, approverIndex) => {
            const approverPayload = {
              approverId: approver.approverId,
              approverName: approver.approverName,
              sortNo: approverIndex + 1,
            };
            if (isEditMode.value) {
              if (approver.id != null) approverPayload.id = approver.id;
              if (approver.nodeId != null) approverPayload.nodeId = approver.nodeId;
              else if (node.id != null) approverPayload.nodeId = node.id;
              if (approver.templateId != null) approverPayload.templateId = approver.templateId;
              else if (tid != null) approverPayload.templateId = tid;
            }
            return approverPayload;
          }),
        };
        if (isEditMode.value) {
          if (node.id != null) nodePayload.id = node.id;
          if (node.templateId != null) nodePayload.templateId = node.templateId;
          else if (tid != null) nodePayload.templateId = tid;
        }
        return nodePayload;
      }),
    };
    if (isEditMode.value) {
      payload.id = tid;
    }
    return payload;
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid || !validateFlow()) return;
    submitting.value = true;
    const submitApi = isEditMode.value ? updateApprovalTemplate : addApprovalTemplate;
    submitApi(buildSubmitPayload())
      .then(() => {
        uni.showToast({
          title: isEditMode.value ? "修改成功" : "保存成功",
          icon: "success",
        });
        uni.removeStorageSync(EDIT_STORAGE_KEY);
        setTimeout(() => {
          uni.navigateBack();
        }, 300);
      })
      .catch(() => {
        uni.showToast({
          title: isEditMode.value ? "修改失败" : "保存失败",
          icon: "none",
        });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      const row = uni.getStorageSync(EDIT_STORAGE_KEY);
      if (row && String(row.id) === String(options.id)) {
        fillFormFromRow(row);
      } else {
        templateId.value = options.id;
        uni.showToast({ title: "未获取到模板数据", icon: "none" });
      }
      uni.removeStorageSync(EDIT_STORAGE_KEY);
    }
  });
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
      .then(opts => {
        businessTypeOptions.value = opts;
        if (!templateId.value && opts.length) {
          const matched = opts.some(
            opt => String(opt.value) === String(form.businessType)
          );
          if (!matched) {
            form.businessType = opts[0].value;
          }
        }
      })
      .catch(() => {
        uni.showToast({ title: "获取审批类型失败", icon: "none" });
      });
  onMounted(() => {
    loadTemplateTypes();
    userListNoPageByTenantId()
      .then(res => {
        userList.value = res?.data || [];
      })
      .catch(() => {
        userList.value = [];
      });
    getDept()
      .then(res => {
        deptList.value = res?.data || [];
      })
      .catch(() => {
        deptList.value = [];
      });
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  $primary: #2979ff;
  $primary-light: #ecf5ff;
  $text: #1f2d3d;
  $text-secondary: #606266;
  $text-muted: #909399;
  $border: #ebeef5;
  $bg-page: #f0f3f8;
  $radius-lg: 12px;
  $radius-md: 10px;
  $shadow-card: 0 2px 12px rgba(31, 45, 61, 0.05);
  .template-edit-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: $bg-page;
  }
  .form-scroll {
    flex: 1;
    height: 0;
    padding: 10px 12px calc(96px + env(safe-area-inset-bottom));
  }
  .section-card {
    margin-bottom: 10px;
    background: #fff;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
    &--between {
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
  }
  .section-head-left {
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .section-count {
    font-size: 12px;
    color: $text-muted;
    padding-left: 13px;
  }
  .head-actions {
    display: flex;
    align-items: center;
    gap: 16px;
  }
  .head-link {
    font-size: 14px;
    color: $text-secondary;
    &--import {
      color: $text-secondary;
      padding: 6px 12px;
      border: 1px solid #dce3ed;
      border-radius: 8px;
      background: #fff;
      font-size: 13px;
    }
    &--disabled {
      color: #c0c4cc;
      border-color: #ebeef5;
      background: #f5f7fa;
    }
    &--primary {
      color: #fff;
      font-weight: 500;
      padding: 6px 14px;
      border: none;
      border-radius: 8px;
      background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%);
      box-shadow: 0 2px 8px rgba(41, 121, 255, 0.25);
    }
  }
  :deep(.form-item-select--disabled .u-form-item__body) {
    opacity: 0.65;
  }
  .section-body {
    padding: 2px 16px 14px;
  }
  .form-section {
    margin-bottom: 10px;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  :deep(.form-section .u-cell-group__title) {
    padding: 12px 16px 8px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: $text !important;
    background: #fff !important;
  }
  :deep(.form-section .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.form-section .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-name .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-name .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.name-input-inline),
  :deep(.name-input-inline .u-input__content) {
    width: 100% !important;
    flex: 1 !important;
  }
  :deep(.name-input-inline input),
  :deep(.name-input-inline .u-input__content__field-wrapper__field) {
    width: 100% !important;
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-select .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-select .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-select .u-input__content__field-wrapper__field) {
    text-align: right !important;
  }
  :deep(.form-item-switch .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-switch .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    display: flex !important;
    justify-content: flex-end !important;
  }
  .switch-wrap {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    width: 100%;
  }
  :deep(.form-item-desc .u-form-item__body) {
    flex-direction: column !important;
    align-items: stretch !important;
    padding: 10px 0 12px !important;
  }
  :deep(.form-item-desc .u-form-item__content) {
    justify-content: stretch !important;
    width: 100% !important;
  }
  .desc-input-shell {
    width: 100%;
    box-sizing: border-box;
    padding: 8px 12px;
    background: #fff;
    border: 1px solid #dcdfe6;
    border-radius: 6px;
  }
  :deep(.desc-input-shell .u-textarea),
  :deep(.desc-input-shell textarea) {
    width: 100% !important;
    font-size: 15px !important;
    text-align: left !important;
  }
  .form-row-item {
    margin: 0 !important;
    padding: 0 !important;
  }
  :deep(.form-row-item .u-form-item__body) {
    padding: 0;
  }
  :deep(.form-row-item .u-form-item__body__right__message) {
    margin-top: 4px;
    padding-left: 0;
  }
  .form-row {
    padding: 10px 0;
    border-bottom: 1px solid #f5f7fa;
    &:last-child {
      border-bottom: none;
    }
    &--column {
      flex-direction: column;
      align-items: stretch;
      gap: 8px;
    }
    &--compact {
      padding-top: 8px;
    }
  }
  .form-row-label {
    display: block;
    font-size: 14px;
    color: $text-secondary;
    margin-bottom: 8px;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 3px;
    }
  }
  .form-row--column .form-row-label {
    margin-bottom: 0;
  }
  .prompt-row {
    display: flex;
    align-items: center;
    padding: 12px 0;
    margin-bottom: 4px;
    border-bottom: 1px solid #f5f7fa;
    gap: 8px;
  }
  .prompt-label {
    flex-shrink: 0;
    width: 88px;
    font-size: 14px;
    color: $text-secondary;
  }
  .prompt-input {
    flex: 1;
    min-width: 0;
  }
  :deep(.prompt-input),
  :deep(.prompt-input .u-input__content) {
    width: 100% !important;
  }
  :deep(.prompt-input input),
  :deep(.prompt-input .u-input__content__field-wrapper__field) {
    width: 100% !important;
    text-align: right !important;
    font-size: 15px !important;
  }
  .input-box,
  .textarea-box {
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
    overflow: hidden;
  }
  .textarea-box {
    padding: 4px 0;
  }
  .field-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-top: 8px;
  }
  .field-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 14px;
    background: #fff;
    border-radius: $radius-md;
    border: 1px solid #e8eef5;
    box-shadow: 0 1px 4px rgba(31, 45, 61, 0.04);
    transition: border-color 0.2s, box-shadow 0.2s;
    &:active:not(.field-item--locked) {
      border-color: #c6daf5;
      box-shadow: 0 2px 8px rgba(41, 121, 255, 0.08);
    }
    &--locked {
      background: #fafbfd;
    }
  }
  .field-order {
    width: 28px;
    height: 28px;
    border-radius: 8px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .field-main {
    flex: 1;
    min-width: 0;
  }
  .field-title-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 4px;
  }
  .field-name {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    flex: 1;
    min-width: 0;
  }
  .field-tags {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-shrink: 0;
  }
  .field-key {
    display: block;
    font-size: 12px;
    color: $text-muted;
    font-family: ui-monospace, monospace;
  }
  .field-lock-tag {
    flex-shrink: 0;
    font-size: 11px;
    color: #909399;
    padding: 4px 8px;
    background: #f0f2f5;
    border-radius: 4px;
  }
  .type-tag {
    font-size: 11px;
    padding: 2px 8px;
    border-radius: 4px;
    &--text {
      color: #2979ff;
      background: #ecf5ff;
    }
    &--area {
      color: #7c5cfc;
      background: #f3efff;
    }
    &--num {
      color: #e6a23c;
      background: #fdf6ec;
    }
    &--date {
      color: #18a058;
      background: #e8faf0;
    }
    &--select {
      color: #9c27b0;
      background: #f6edfc;
    }
  }
  .req-tag {
    font-size: 11px;
    padding: 2px 6px;
    color: #f56c6c;
    background: #fef0f0;
    border-radius: 4px;
  }
  .field-default {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .field-actions {
    display: flex;
    gap: 6px;
    flex-shrink: 0;
  }
  .icon-btn {
    width: 32px;
    height: 32px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    &--edit {
      background: #ecf5ff;
    }
    &--del {
      background: #fef0f0;
    }
  }
  .empty-mini {
    padding: 32px 16px;
    text-align: center;
    font-size: 13px;
    color: $text-muted;
    background: #fafbfd;
    border: 1px dashed #dce8f5;
    border-radius: 10px;
  }
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-node-block {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  .flow-node-card {
    background: #fafbfd;
    border: 1px solid #e8eef5;
    border-radius: $radius-md;
    padding: 12px;
  }
  .node-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    background: $primary;
    color: #fff;
    font-size: 14px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .node-level-text {
    flex: 1;
    font-size: 15px;
    font-weight: 600;
    color: $text;
  }
  .node-delete {
    padding: 6px;
    flex-shrink: 0;
  }
  .approve-type-row {
    display: flex;
    background: #f0f3f8;
    border-radius: 8px;
    padding: 3px;
    margin-bottom: 10px;
  }
  .type-btn {
    flex: 1;
    text-align: center;
    padding: 8px 0;
    font-size: 14px;
    color: $text-secondary;
    border-radius: 6px;
    &.active {
      background: #fff;
      color: $primary;
      font-weight: 500;
    }
  }
  .approver-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }
  .approver-chip {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px 6px 6px;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 24px;
    box-shadow: 0 2px 6px rgba(41, 121, 255, 0.06);
  }
  .approver-avatar {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 12px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .approver-name {
    font-size: 13px;
    color: $text;
    max-width: 80px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .approver-remove {
    flex-shrink: 0;
    width: 22px;
    height: 22px;
    margin-left: 2px;
    border-radius: 50%;
    background: #f2f3f5;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .approver-remove--active {
    background: #fde2e2;
  }
  .remove-icon {
    font-size: 16px;
    line-height: 1;
    color: #909399;
    font-weight: 300;
  }
  .add-approver {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 8px 14px;
    border: 1.5px dashed #a8cfff;
    border-radius: 24px;
    background: $primary-light;
    color: $primary;
    font-size: 13px;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 4px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #d0dff0;
  }
  .add-node-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin-top: 8px;
    padding: 11px;
    border: 1px dashed #c6daf5;
    border-radius: $radius-md;
    color: $primary;
    font-size: 14px;
  }
  .sheet-handle {
    width: 36px;
    height: 4px;
    margin: 10px auto 4px;
    background: #d8dde6;
    border-radius: 2px;
  }
  .field-editor .sheet-handle {
    background: #c8ced8;
  }
  .field-editor {
    position: relative;
    display: flex;
    flex-direction: column;
    max-height: 88vh;
    background: #f5f7fb;
    border-radius: 16px 16px 0 0;
    overflow: hidden;
  }
  .user-picker {
    position: relative;
    padding: 0 18px calc(18px + env(safe-area-inset-bottom));
    background: #fff;
    max-height: 85vh;
  }
  .editor-header {
    padding: 4px 20px 12px;
    background: #fff;
    text-align: center;
    border-bottom: 1px solid #f0f2f5;
  }
  .editor-subtitle {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .editor-picker-layer {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
  }
  .editor-picker-mask {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.45);
  }
  .editor-picker-panel {
    position: relative;
    z-index: 1;
    background: #fff;
    border-radius: 16px 16px 0 0;
    max-height: 55vh;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .editor-picker-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    border-bottom: 1px solid #f0f0f0;
  }
  .editor-picker-cancel {
    font-size: 15px;
    color: #909399;
    min-width: 48px;
  }
  .editor-picker-title {
    font-size: 16px;
    font-weight: 600;
    color: $text;
  }
  .editor-picker-placeholder {
    min-width: 48px;
  }
  .editor-picker-scroll {
    max-height: calc(55vh - 52px);
  }
  .editor-picker-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 18px;
    font-size: 16px;
    color: $text;
    border-bottom: 1px solid #f5f7fa;
    &--active {
      color: $primary;
      background: #f5f9ff;
    }
    &:last-child {
      border-bottom: none;
    }
  }
  .editor-scroll {
    flex: 1;
    height: 0;
    max-height: 62vh;
  }
  .editor-form {
    display: flex;
    flex-direction: column;
    gap: 10px;
    padding: 12px 16px 16px;
  }
  .editor-section-card {
    background: #fff;
    border-radius: 12px;
    padding: 14px 14px 4px;
    box-shadow: 0 1px 6px rgba(31, 45, 61, 0.05);
  }
  .editor-section-head {
    margin-bottom: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid #f2f4f7;
  }
  .editor-section-title {
    font-size: 14px;
    font-weight: 600;
    color: $text;
    padding-left: 8px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .editor-cell {
    margin-bottom: 14px;
    &--tap:active .picker-value-row {
      background: #eef4ff;
      border-color: #c6daf5;
    }
    &--switch {
      display: flex;
      align-items: center;
      justify-content: space-between;
      gap: 12px;
      padding: 4px 0 10px;
      margin-bottom: 4px;
    }
    &--value {
      margin-bottom: 10px;
    }
  }
  .switch-label-wrap {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .switch-hint {
    font-size: 12px;
    color: $text-muted;
  }
  .editor-input-box {
    background: #f7f9fc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
    overflow: hidden;
  }
  :deep(.editor-input-box .u-input) {
    background: transparent !important;
  }
  .default-hint {
    display: block;
    font-size: 12px;
    color: $text-muted;
    line-height: 1.5;
    margin: -4px 0 10px;
    padding: 0 2px;
  }
  .manual-options {
    margin: 4px 0 12px;
    padding-top: 4px;
  }
  .manual-options-title {
    display: block;
    font-size: 12px;
    color: $text-muted;
    margin-bottom: 10px;
  }
  .manual-options-table {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .option-table-head {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 0 4px 4px;
  }
  .option-col {
    font-size: 12px;
    color: $text-muted;
    font-weight: 500;
    &--idx {
      width: 22px;
      flex-shrink: 0;
    }
    &--label {
      flex: 1.4;
      min-width: 0;
    }
    &--value {
      flex: 0.9;
      min-width: 72px;
    }
    &--action {
      width: 32px;
      flex-shrink: 0;
    }
  }
  .option-card {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    background: #f8fafc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
  }
  .option-idx {
    width: 22px;
    height: 22px;
    flex-shrink: 0;
    border-radius: 6px;
    background: #eef2f8;
    color: $text-muted;
    font-size: 12px;
    font-weight: 600;
    line-height: 22px;
    text-align: center;
  }
  .option-input-wrap {
    flex: 1.4;
    min-width: 0;
    background: #fff;
    border: 1px solid #e4e8ef;
    border-radius: 8px;
    overflow: hidden;
    &--value {
      flex: 0.9;
      min-width: 72px;
    }
  }
  :deep(.option-input-wrap .u-input) {
    background: transparent !important;
  }
  :deep(.option-input-wrap input),
  :deep(.option-input-wrap .u-input__content__field-wrapper__field) {
    font-size: 14px !important;
    height: 36px !important;
    min-height: 36px !important;
    padding: 0 10px !important;
  }
  .option-del {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
    border-radius: 8px;
    background: #fff;
    border: 1px solid #fde2e2;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .option-del--active {
    background: #fef0f0;
  }
  .add-option-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin-top: 10px;
    padding: 11px;
    border: 1.5px dashed #b8d4ff;
    border-radius: 10px;
    background: linear-gradient(180deg, #f8fbff 0%, #f0f6ff 100%);
    color: $primary;
    font-size: 14px;
    font-weight: 500;
  }
  .add-option-btn--active {
    background: #e8f2ff;
    border-color: $primary;
  }
  .option-source-tip {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    padding: 10px 12px;
    margin-bottom: 10px;
    background: #f5f7fa;
    border-radius: 8px;
    font-size: 12px;
    color: $text-muted;
    line-height: 1.5;
  }
  .editor-title {
    display: block;
    font-size: 17px;
    font-weight: 600;
    color: $text;
  }
  .picker-value-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    min-height: 44px;
    padding: 0 14px;
    background: #f7f9fc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
    gap: 8px;
    transition: background 0.15s, border-color 0.15s;
    &--tap:active {
      background: #eef4ff;
      border-color: #c6daf5;
    }
  }
  .picker-value {
    flex: 1;
    min-width: 0;
    font-size: 15px;
    color: $text;
    text-align: left;
    line-height: 1.4;
    &--placeholder {
      color: #c0c4cc;
    }
  }
  .editor-label {
    display: block;
    font-size: 13px;
    font-weight: 500;
    color: $text-secondary;
    margin-bottom: 8px;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 3px;
    }
  }
  .editor-cell--switch .editor-label {
    margin-bottom: 0;
  }
  .daterange-default-wrap {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .daterange-default-item {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .daterange-default-label {
    font-size: 13px;
    color: $text-secondary;
  }
  .type-chip-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
  }
  .type-chip {
    text-align: center;
    padding: 10px 6px;
    font-size: 13px;
    color: $text-secondary;
    background: #f7f9fc;
    border: 1px solid #eef1f6;
    border-radius: 8px;
    &.active {
      color: $primary;
      background: $primary-light;
      border-color: $primary;
      font-weight: 500;
    }
  }
  .editor-footer {
    display: flex;
    gap: 12px;
    padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
    background: #fff;
    border-top: 1px solid #eef0f4;
    box-shadow: 0 -4px 12px rgba(31, 45, 61, 0.06);
  }
  .editor-btn {
    flex: 1;
    text-align: center;
    padding: 12px 0;
    border-radius: 10px;
    font-size: 15px;
    font-weight: 500;
    &--cancel {
      color: $text-secondary;
      background: #f5f7fa;
      border: 1px solid #e4e7ed;
    }
    &--confirm {
      color: #fff;
      background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%);
      box-shadow: 0 4px 12px rgba(41, 121, 255, 0.35);
    }
    &--confirm:active {
      opacity: 0.9;
    }
  }
  .picker-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 14px;
    border-bottom: 1px solid #f5f7fa;
    margin-bottom: 8px;
  }
  .picker-title {
    font-size: 16px;
    font-weight: 600;
    color: $text;
  }
  .picker-cancel {
    font-size: 15px;
    color: $text-muted;
    min-width: 48px;
  }
  .picker-confirm {
    font-size: 15px;
    color: $primary;
    font-weight: 600;
    min-width: 48px;
    text-align: right;
  }
  .user-scroll {
    max-height: 52vh;
  }
  .user-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 14px 4px;
    border-bottom: 1px solid #f5f7fa;
    border-radius: 10px;
    margin-bottom: 4px;
    transition: background 0.2s;
    &.selected {
      background: #f5f9ff;
    }
  }
  .user-avatar {
    width: 40px;
    height: 40px;
    border-radius: 12px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 16px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .user-name {
    flex: 1;
    font-size: 15px;
    color: $text;
  }
  .user-check {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    border: 2px solid #dcdfe6;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    &.checked {
      background: $primary;
      border-color: $primary;
    }
  }
</style>
src/pages/oa/ApproveManage/approve-template/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,322 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹æ¨¡æ¿
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-template/index
-->
<template>
  <view class="approve-template-page sales-account">
    <PageHeader title="审批模板"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="queryParams.templateName"
                    class="search-text"
                    placeholder="请输入模板名称"
                    clearable
                    @confirm="handleSearch" />
        </view>
        <view class="filter-button"
              @click="handleSearch">
          <up-icon name="search"
                   size="24"
                   color="#999" />
        </view>
      </view>
    </view>
    <scroll-view class="list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 @scrolltolower="loadMore">
      <view v-if="list.length"
            class="ledger-list">
        <view v-for="item in list"
              :key="item.id"
              class="ledger-item">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text"
                         size="16"
                         color="#ffffff" />
              </view>
              <text class="item-id">{{ item.templateName || "-" }}</text>
            </view>
            <u-tag :type="enabledTagType(item.enabled)"
                   :text="enabledText(item.enabled)" />
          </view>
          <up-divider />
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">审批类型</text>
              <text class="detail-value">{{ businessTypeText(item.businessType) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">审批节点</text>
              <text class="detail-value">{{ nodeCount(item) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">模板说明</text>
              <text class="detail-value">{{ item.description || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">创建人</text>
              <text class="detail-value">{{ item.createdUserName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">创建时间</text>
              <text class="detail-value">{{ item.createTime || "-" }}</text>
            </view>
          </view>
          <view class="action-buttons">
            <up-button class="action-btn"
                       size="small"
                       @click.stop="goDetail(item)">
              è¯¦æƒ…
            </up-button>
            <up-button class="action-btn"
                       size="small"
                       type="primary"
                       @click.stop="goEdit(item)">
              ç¼–辑
            </up-button>
            <up-button v-if="!isSystemTemplate(item)"
                       class="action-btn"
                       size="small"
                       type="error"
                       plain
                       @click.stop="handleDelete(item)">
              åˆ é™¤
            </up-button>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else
            class="empty-wrap">
        <up-empty mode="list"
                  text="暂无审批模板数据" />
      </view>
    </scroll-view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff" />
    </view>
  </view>
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import {
    deleteApprovalTemplate,
    listApprovalTemplatePage,
  } from "@/api/oa/approvalTemplate.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
    isSystemApprovalTemplate,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const typeLabelMap = ref({});
  const queryParams = reactive({
    templateName: "",
  });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const buildListParams = () => ({
    page: {
      current: page.current,
      size: page.size,
    },
    approvalTemplateDto: {
      templateName: queryParams.templateName?.trim() || undefined,
    },
  });
  const enabledText = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "启用";
    if (val === "0") return "停用";
    return "-";
  };
  const enabledTagType = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "success";
    if (val === "0") return "info";
    return "info";
  };
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const isSystemTemplate = isSystemApprovalTemplate;
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
      .then(opts => {
        typeLabelMap.value = buildTypeLabelMap(opts);
      })
      .catch(() => {});
  const nodeCount = item => {
    const count = item?.nodes?.length;
    return count != null ? `${count} ä¸ª` : "-";
  };
  const getList = () => {
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    listApprovalTemplatePage(buildListParams())
      .then(res => {
        const pageData = res?.data || {};
        const records = pageData.records || [];
        const total = pageData.total ?? 0;
        if (page.current === 1) {
          list.value = records;
        } else {
          list.value = [...list.value, ...records];
        }
        page.total = total;
        if (list.value.length >= total || records.length < page.size) {
          pageStatus.value = "nomore";
        } else {
          pageStatus.value = "loadmore";
          page.current += 1;
        }
      })
      .catch(() => {
        if (page.current === 1) {
          list.value = [];
        }
        pageStatus.value = "loadmore";
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const handleSearch = () => {
    page.current = 1;
    pageStatus.value = "loadmore";
    list.value = [];
    getList();
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") {
      getList();
    }
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const goAdd = () => {
    uni.removeStorageSync(EDIT_STORAGE_KEY);
    uni.navigateTo({
      url: "/pages/oa/ApproveManage/approve-template/edit",
    });
  };
  const goDetail = item => {
    if (!item?.id) return;
    uni.navigateTo({
      url: `/pages/oa/ApproveManage/approve-template/detail?id=${item.id}`,
    });
  };
  const goEdit = item => {
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    uni.navigateTo({
      url: `/pages/oa/ApproveManage/approve-template/edit?id=${item.id}`,
    });
  };
  const handleDelete = item => {
    if (!item?.id) return;
    if (isSystemTemplate(item)) {
      uni.showToast({ title: "系统内置模板不可删除", icon: "none" });
      return;
    }
    const name = item.templateName || "该模板";
    uni.showModal({
      title: "删除确认",
      content: `确定删除「${name}」吗?删除后无法恢复。`,
      confirmText: "删除",
      confirmColor: "#f56c6c",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "删除中...", mask: true });
        deleteApprovalTemplate([item.id])
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            handleSearch();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    loadTemplateTypes();
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .approve-template-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  .list-scroll {
    flex: 1;
    height: 0;
    padding-bottom: calc(80px + env(safe-area-inset-bottom));
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .action-buttons {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid #f0f0f0;
  }
  .action-btn {
    min-width: 72px;
  }
</style>
src/pages/oa/AttendManage/leave-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  OA / å‡å‹¤ç®¡ç† / è¯·å‡ç”³è¯·
  è·¯ç”±ï¼š/pages/oa/AttendManage/leave-apply/index
-->
<template>
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.LEAVE" />
</template>
<script setup>
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/AttendManage/overtime-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  OA / å‡å‹¤ç®¡ç† / åŠ ç­ç”³è¯·
  è·¯ç”±ï¼š/pages/oa/AttendManage/overtime-apply/index
-->
<template>
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.OVERTIME" />
</template>
<script setup>
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/ContractManage/purchase-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
<!--
  OA / åˆåŒç®¡ç† / é‡‡è´­åˆåŒ
  è·¯ç”±ï¼š/pages/oa/ContractManage/purchase-contract/index
  è¯´æ˜Žï¼šè·³è½¬è‡³é‡‡è´­å°è´¦ /pages/procurementManagement/procurementLedger/index
-->
<template>
  <view class="redirect-page" />
</template>
<script setup>
  /** OA - åˆåŒç®¡ç† - é‡‡è´­åˆåŒï¼ˆè·³è½¬é‡‡è´­å°è´¦ï¼‰ */
  import { onLoad } from "@dcloudio/uni-app";
  onLoad(() => {
    uni.redirectTo({
      url: "/pages/procurementManagement/procurementLedger/index",
    });
  });
</script>
<style scoped>
  .redirect-page {
    min-height: 100vh;
    background: #f8f9fa;
  }
</style>
src/pages/oa/ContractManage/sale-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
<!--
  OA / åˆåŒç®¡ç† / é”€å”®åˆåŒ
  è·¯ç”±ï¼š/pages/oa/ContractManage/sale-contract/index
  è¯´æ˜Žï¼šè·³è½¬è‡³é”€å”®å°è´¦ /pages/sales/salesAccount/index
-->
<template>
  <view class="redirect-page" />
</template>
<script setup>
  /** OA - åˆåŒç®¡ç† - é”€å”®åˆåŒï¼ˆè·³è½¬é”€å”®å°è´¦ï¼‰ */
  import { onLoad } from "@dcloudio/uni-app";
  onLoad(() => {
    uni.redirectTo({
      url: "/pages/sales/salesAccount/index",
    });
  });
</script>
<style scoped>
  .redirect-page {
    min-height: 100vh;
    background: #f8f9fa;
  }
</style>
src/pages/oa/EnterpriseNews/news-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / ä¼ä¸šæ–°é—»
  è·¯ç”±ï¼š/pages/oa/EnterpriseNews/news-manage/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - ä¼ä¸šæ–°é—» */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "EnterpriseNews/news-manage";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/post-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å²—位管理
  è·¯ç”±ï¼š/pages/oa/HrManage/post-manage/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å²—位管理 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/post-manage";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/regular-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  OA / äººäº‹ç®¡ç† / è½¬æ­£ç”³è¯·
  è·¯ç”±ï¼š/pages/oa/HrManage/regular-apply/index
-->
<template>
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.REGULAR" />
</template>
<script setup>
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/resign-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / ç¦»èŒç”³è¯·
  è·¯ç”±ï¼š/pages/oa/HrManage/resign-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - ç¦»èŒç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/resign-apply";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/staff-archive/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å‘˜å·¥æ¡£æ¡ˆ
  è·¯ç”±ï¼š/pages/oa/HrManage/staff-archive/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å‘˜å·¥æ¡£æ¡ˆ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/staff-archive";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/staff-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å‘˜å·¥åˆåŒ
  è·¯ç”±ï¼š/pages/oa/HrManage/staff-contract/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å‘˜å·¥åˆåŒ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/staff-contract";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/transfer-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  OA / äººäº‹ç®¡ç† / è°ƒå²—申请
  è·¯ç”±ï¼š/pages/oa/HrManage/transfer-apply/index
-->
<template>
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.TRANSFER" />
</template>
<script setup>
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/work-handover/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  OA / äººäº‹ç®¡ç† / å·¥ä½œäº¤æŽ¥
  è·¯ç”±ï¼š/pages/oa/HrManage/work-handover/index
-->
<template>
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER" />
</template>
<script setup>
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/NoticeAnnouncement/notice-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / å…¬å‘Šé€šçŸ¥
  è·¯ç”±ï¼š/pages/oa/NoticeAnnouncement/notice-manage/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - å…¬å‘Šé€šçŸ¥ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "NoticeAnnouncement/notice-manage";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/ReimburseManage/_components/ReimburseApprovalFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,245 @@
<!--
  æŠ¥é”€å®¡æ‰¹æµç¨‹ï¼ˆå¯æœç´¢é€‰äººï¼Œç‚¹é€‰å³ç¡®è®¤ï¼‰
-->
<template>
  <view class="flow-wrap">
    <view v-for="(item, index) in innerList"
          :key="item._uid"
          class="flow-node-block">
      <view class="flow-node-card">
        <view class="node-header">
          <view class="node-level-badge">{{ index + 1 }}</view>
          <text class="node-level-text">第{{ levelLabel(index + 1) }}级审批</text>
          <view v-if="innerList.length > 1"
                class="node-delete"
                @click="remove(index)">
            <up-icon name="trash"
                     size="16"
                     color="#f56c6c" />
          </view>
        </view>
        <view class="approver-row"
              @click="openPicker(index)">
          <view class="approver-avatar"
                :style="{ backgroundColor: avatarColor(item.approverName) }">
            {{ (item.approverName || '+').charAt(0) }}
          </view>
          <view class="approver-meta">
            <text class="approver-name">{{ item.approverName || '点击选择审批人' }}</text>
            <text class="approver-hint">支持搜索姓名或工号</text>
          </view>
          <up-icon name="arrow-right"
                   size="14"
                   color="#c0c4cc" />
        </view>
      </view>
      <view v-if="index < innerList.length - 1"
            class="flow-connector">
        <view class="flow-connector-line" />
      </view>
    </view>
    <view class="add-node-bar"
          @click="addNode">
      <up-icon name="plus-circle"
               size="18"
               color="#2979ff" />
      <text>添加审批级次</text>
    </view>
    <OaUserSearchPicker v-model:show="pickerShow"
                        v-model="pickerUserId"
                        title="选择审批人"
                        :users="userOptions"
                        :show-self-quick="false"
                        @select="onUserSelected" />
  </view>
</template>
<script setup>
  import { ref, watch } from "vue";
  import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue";
  import { userAvatarColor } from "../../_utils/userPickerUtils.js";
  const props = defineProps({
    modelValue: { type: Array, default: () => [] },
    userOptions: { type: Array, default: () => [] },
  });
  const emit = defineEmits(["update:modelValue"]);
  const innerList = ref([]);
  const pickerShow = ref(false);
  const pickerUserId = ref("");
  const editingIndex = ref(-1);
  function newUid() {
    return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
  }
  function levelLabel(n) {
    const t = ["一", "二", "三", "四", "五", "六", "七", "八"];
    return t[n - 1] || String(n);
  }
  function avatarColor(name) {
    return userAvatarColor(name);
  }
  function mapIn(rows) {
    return (rows || []).map((n, i) => ({
      _uid: n._uid || newUid(),
      nodeOrder: n.nodeOrder ?? i + 1,
      signMode: n.signMode || "countersign",
      approverId: n.approverId ?? "",
      approverName: n.approverName || "",
      id: n.id,
      templateId: n.templateId,
    }));
  }
  function mapOut() {
    return innerList.value.map((n, i) => ({
      nodeOrder: i + 1,
      signMode: n.signMode || "countersign",
      approverId: n.approverId,
      approverName: n.approverName,
      id: n.id,
      templateId: n.templateId,
    }));
  }
  function syncEmit() {
    emit("update:modelValue", mapOut());
  }
  watch(
    () => props.modelValue,
    v => {
      innerList.value = mapIn(v);
      if (!innerList.value.length) {
        innerList.value = [
          { _uid: newUid(), nodeOrder: 1, signMode: "countersign", approverId: "", approverName: "" },
        ];
      }
    },
    { immediate: true, deep: true }
  );
  function addNode() {
    innerList.value.push({
      _uid: newUid(),
      nodeOrder: innerList.value.length + 1,
      signMode: "countersign",
      approverId: "",
      approverName: "",
    });
    syncEmit();
  }
  function remove(index) {
    if (innerList.value.length <= 1) {
      uni.showToast({ title: "至少保留一个审批节点", icon: "none" });
      return;
    }
    innerList.value.splice(index, 1);
    syncEmit();
  }
  function openPicker(index) {
    editingIndex.value = index;
    pickerUserId.value = innerList.value[index]?.approverId || "";
    pickerShow.value = true;
  }
  function onUserSelected(u) {
    const node = innerList.value[editingIndex.value];
    if (!node) return;
    node.approverId = u.userId ?? u.id;
    node.approverName = u.nickName || u.userName || "";
    syncEmit();
  }
</script>
<style scoped lang="scss">
  .flow-node-card {
    background: #f8f9fb;
    border-radius: 10px;
    padding: 12px;
    border: 1px solid #eef0f3;
  }
  .node-header {
    display: flex;
    align-items: center;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    background: #2979ff;
    color: #fff;
    font-size: 12px;
    text-align: center;
    line-height: 22px;
    margin-right: 8px;
  }
  .node-level-text {
    flex: 1;
    font-size: 14px;
    color: #303133;
    font-weight: 500;
  }
  .approver-row {
    display: flex;
    align-items: center;
    padding: 10px 12px;
    background: #fff;
    border-radius: 8px;
  }
  .approver-avatar {
    width: 36px;
    height: 36px;
    border-radius: 50%;
    color: #fff;
    font-size: 15px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .approver-meta {
    flex: 1;
    margin-left: 10px;
    min-width: 0;
  }
  .approver-name {
    display: block;
    font-size: 15px;
    color: #303133;
  }
  .approver-hint {
    display: block;
    font-size: 12px;
    color: #c0c4cc;
    margin-top: 2px;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 6px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #dcdfe6;
  }
  .add-node-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    padding: 14px 0 4px;
    color: #2979ff;
    font-size: 14px;
  }
</style>
src/pages/oa/ReimburseManage/_components/ReimburseExpenseDetailSheet.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,315 @@
<!--
  æŠ¥é”€æ˜Žç»†å•条编辑(底部弹层)
-->
<template>
  <up-popup :show="show"
            mode="bottom"
            round="16"
            :safe-area-inset-bottom="true"
            @close="close">
    <view class="detail-sheet">
      <view class="sheet-handle" />
      <view class="sheet-head">
        <text class="sheet-cancel"
              @click="close">取消</text>
        <text class="sheet-title">{{ title }}</text>
        <text class="sheet-confirm"
              @click="confirm">保存</text>
      </view>
      <scroll-view scroll-y
                   class="sheet-body"
                   :show-scrollbar="false">
        <view class="sheet-group">
          <view class="sheet-cell sheet-cell--tap"
                @click="showDatePicker = true">
            <text class="sheet-label required">发票日期</text>
            <view class="sheet-value-wrap">
              <text class="sheet-value"
                    :class="{ placeholder: !draft.invoiceDate }">
                {{ draft.invoiceDate || '请选择' }}
              </text>
              <up-icon name="calendar"
                       size="18"
                       color="#c0c4cc" />
            </view>
          </view>
          <view class="sheet-cell sheet-cell--tap"
                @click="showSubjectSheet = true">
            <text class="sheet-label required">费用科目</text>
            <view class="sheet-value-wrap">
              <text class="sheet-value"
                    :class="{ placeholder: !draft.expenseSubject }">
                {{ subjectText }}
              </text>
              <up-icon name="arrow-right"
                       size="14"
                       color="#c0c4cc" />
            </view>
          </view>
          <view class="sheet-cell">
            <text class="sheet-label required">金额</text>
            <view class="sheet-input-wrap">
              <up-input v-model="draft.amount"
                        type="digit"
                        placeholder="0.00"
                        border="none"
                        input-align="right" />
              <text class="sheet-unit">元</text>
            </view>
          </view>
          <view class="sheet-cell sheet-cell--col">
            <text class="sheet-label">描述</text>
            <view class="sheet-textarea-wrap">
              <up-textarea v-model="draft.description"
                           placeholder="费用说明(选填)"
                           maxlength="200"
                           border="none"
                           height="64" />
            </view>
          </view>
        </view>
        <view v-if="showDelete"
              class="sheet-delete"
              @click="emit('delete')">
          åˆ é™¤æœ¬æ¡æ˜Žç»†
        </view>
      </scroll-view>
    </view>
    <up-action-sheet :show="showSubjectSheet"
                     title="费用科目"
                     :actions="subjectActions"
                     @select="onSubjectSelect"
                     @close="showSubjectSheet = false" />
    <up-popup :show="showDatePicker"
              mode="bottom"
              round="16"
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="date"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
    </up-popup>
  </up-popup>
</template>
<script setup>
  import { computed, reactive, ref, watch } from "vue";
  import { parseTime } from "@/utils/ruoyi";
  import { expenseSubjectLabel as costSubjectLabel } from "../_utils/costReimburseUtils.js";
  import { expenseSubjectLabel as travelSubjectLabel } from "../_utils/travelReimburseUtils.js";
  const props = defineProps({
    show: { type: Boolean, default: false },
    modelValue: { type: Object, default: () => ({}) },
    index: { type: Number, default: 0 },
    isTravel: { type: Boolean, default: true },
    subjectOptions: { type: Array, default: () => [] },
    showDelete: { type: Boolean, default: true },
  });
  const emit = defineEmits(["update:show", "update:modelValue", "confirm", "delete"]);
  const draft = reactive({
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  });
  const showDatePicker = ref(false);
  const showSubjectSheet = ref(false);
  const datePickerTs = ref(Date.now());
  const title = computed(() => `明细 ${props.index + 1}`);
  const subjectActions = computed(() =>
    (props.subjectOptions || []).map(x => ({ name: x.label, value: x.value }))
  );
  const subjectText = computed(() => resolveSubjectLabel(draft.expenseSubject));
  function resolveSubjectLabel(v) {
    if (!v) return "请选择";
    const labelFn = props.isTravel ? travelSubjectLabel : costSubjectLabel;
    const t = labelFn(v);
    if (t && t !== "—") return t;
    const hit = (props.subjectOptions || []).find(x => x.value === v || x.label === v);
    return hit?.label || v;
  }
  watch(
    () => props.show,
    v => {
      if (v && props.modelValue) {
        Object.assign(draft, {
          invoiceDate: "",
          expenseSubject: "",
          amount: "",
          description: "",
          ...JSON.parse(JSON.stringify(props.modelValue)),
        });
      }
    }
  );
  function close() {
    emit("update:show", false);
  }
  function confirm() {
    if (!draft.invoiceDate) {
      uni.showToast({ title: "请选择发票日期", icon: "none" });
      return;
    }
    if (!draft.expenseSubject) {
      uni.showToast({ title: "请选择费用科目", icon: "none" });
      return;
    }
    if (draft.amount === "" || draft.amount == null) {
      uni.showToast({ title: "请填写金额", icon: "none" });
      return;
    }
    emit("update:modelValue", { ...draft });
    emit("confirm", { ...draft });
    emit("update:show", false);
  }
  function onSubjectSelect(action) {
    draft.expenseSubject = action.value;
    showSubjectSheet.value = false;
  }
  function onDateConfirm(e) {
    const ts = e?.value ?? datePickerTs.value;
    draft.invoiceDate = parseTime(ts, "{y}-{m}-{d}");
    showDatePicker.value = false;
  }
</script>
<style scoped lang="scss">
  .detail-sheet {
    background: #fff;
    border-radius: 16px 16px 0 0;
    max-height: 85vh;
    display: flex;
    flex-direction: column;
  }
  .sheet-handle {
    width: 36px;
    height: 4px;
    background: #e4e7ed;
    border-radius: 2px;
    margin: 8px auto 4px;
  }
  .sheet-head {
    display: flex;
    align-items: center;
    padding: 8px 16px 12px;
    border-bottom: 1px solid #f0f2f5;
  }
  .sheet-cancel {
    font-size: 15px;
    color: #909399;
    min-width: 48px;
  }
  .sheet-title {
    flex: 1;
    text-align: center;
    font-size: 16px;
    font-weight: 600;
    color: #303133;
  }
  .sheet-confirm {
    font-size: 15px;
    color: #2979ff;
    font-weight: 600;
    min-width: 48px;
    text-align: right;
  }
  .sheet-body {
    max-height: 70vh;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .sheet-group {
    margin: 12px 16px;
    background: #f8f9fb;
    border-radius: 12px;
    overflow: hidden;
  }
  .sheet-cell {
    display: flex;
    align-items: center;
    min-height: 52px;
    padding: 12px 14px;
    background: #fff;
    border-bottom: 1px solid #f5f6f8;
    &--col {
      flex-direction: column;
      align-items: stretch;
    }
    &--tap:active {
      background: #fafbfc;
    }
    &:last-child {
      border-bottom: none;
    }
  }
  .sheet-label {
    width: 80px;
    font-size: 15px;
    color: #303133;
    flex-shrink: 0;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 2px;
    }
  }
  .sheet-value-wrap {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 4px;
  }
  .sheet-value {
    font-size: 15px;
    color: #303133;
    &.placeholder {
      color: #c0c4cc;
    }
  }
  .sheet-input-wrap {
    flex: 1;
    display: flex;
    align-items: center;
    justify-content: flex-end;
  }
  .sheet-unit {
    font-size: 14px;
    color: #909399;
    margin-left: 4px;
  }
  .sheet-textarea-wrap {
    width: 100%;
    margin-top: 8px;
    background: #f5f7fa;
    border-radius: 8px;
    padding: 4px 8px;
  }
  .sheet-delete {
    margin: 16px;
    text-align: center;
    font-size: 15px;
    color: #f56c6c;
    padding: 14px;
    background: #fff;
    border-radius: 12px;
    border: 1px solid #fde2e2;
  }
</style>
src/pages/oa/ReimburseManage/_components/ReimburseInstanceDetailBody.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,426 @@
<!--
  å·®æ—…/费用报销详情展示(列表详情 / å®¡æ‰¹è¯¦æƒ…共用)
-->
<template>
  <view class="rd-body">
    <!-- æ¦‚要 -->
    <view class="rd-hero">
      <view class="rd-hero-top">
        <text class="rd-bill-no">{{ billNo }}</text>
        <text :class="['rd-status', statusCssClass]">{{ statusText }}</text>
      </view>
      <text class="rd-reason">{{ reasonText }}</text>
      <view class="rd-amount-row">
        <text class="rd-amount-label">申请金额</text>
        <text class="rd-amount">{{ amountText }}</text>
      </view>
    </view>
    <!-- ç”³è¯·äºº -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">申请人</text>
      </view>
      <view class="rd-group">
        <view class="rd-cell">
          <text class="rd-label">姓名</text>
          <text class="rd-value">{{ r.applicantName || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">员工编号</text>
          <text class="rd-value">{{ r.applicantCode || r.applicantNo || "—" }}</text>
        </view>
        <view v-if="r.applicantDeptName || r.deptName"
              class="rd-cell">
          <text class="rd-label">部门</text>
          <text class="rd-value">{{ r.applicantDeptName || r.deptName }}</text>
        </view>
      </view>
    </view>
    <!-- å‡ºå·® / è´¹ç”¨ -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">{{ isTravel ? "出差信息" : "费用信息" }}</text>
      </view>
      <view class="rd-group">
        <template v-if="isTravel">
          <view class="rd-cell">
            <text class="rd-label">出差开始</text>
            <text class="rd-value">{{ formatTime(r.travelStartTime) }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">出差结束</text>
            <text class="rd-value">{{ formatTime(r.travelEndTime) }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">出差天数</text>
            <text class="rd-value">{{ travelDaysText }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">出差地</text>
            <text class="rd-value">{{ r.departurePlace || "—" }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">目的地</text>
            <text class="rd-value">{{ r.destination || "—" }}</text>
          </view>
        </template>
        <view v-else
              class="rd-cell">
          <text class="rd-label">费用类型</text>
          <text class="rd-value">{{ expenseTypeText }}</text>
        </view>
      </view>
    </view>
    <!-- å·®æ—…标准 -->
    <view v-if="isTravel && hasTravelStandard"
          class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">差旅标准</text>
      </view>
      <view class="rd-group">
        <view v-if="r.hotelStandard != null"
              class="rd-cell">
          <text class="rd-label">酒店标准</text>
          <text class="rd-value">{{ r.hotelStandard }} å…ƒ/晚</text>
        </view>
        <view v-if="r.hotelDays != null"
              class="rd-cell">
          <text class="rd-label">住宿天数</text>
          <text class="rd-value">{{ r.hotelDays }} å¤©</text>
        </view>
        <view v-if="r.livingSubsidy != null"
              class="rd-cell">
          <text class="rd-label">生活补贴</text>
          <text class="rd-value">{{ r.livingSubsidy }} å…ƒ</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">标准标记</text>
          <text class="rd-value">{{ r.standardTag || (r.needSpecialApproval ? "超支需特批" : "在标准内") }}</text>
        </view>
      </view>
    </view>
    <!-- æ”¶æ¬¾ -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">收款信息</text>
      </view>
      <view class="rd-group">
        <view class="rd-cell">
          <text class="rd-label">收款人</text>
          <text class="rd-value">{{ r.payeeName || r.payee || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">收款账号</text>
          <text class="rd-value">{{ r.payeeAccount || "—" }}</text>
        </view>
        <view class="rd-cell">
          <text class="rd-label">开户支行</text>
          <text class="rd-value">{{ r.payeeBank || r.bankBranch || "—" }}</text>
        </view>
      </view>
    </view>
    <!-- æŠ¥é”€æ˜Žç»† -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">报销明细</text>
        <text class="rd-section-count">共 {{ detailRows.length }} æ¡</text>
      </view>
      <view v-if="detailRows.length"
            class="rd-group">
        <view v-for="(d, idx) in detailRows"
              :key="'d-' + idx"
              class="rd-detail-item">
          <view class="rd-detail-head">
            <text class="rd-detail-badge">{{ idx + 1 }}</text>
            <text class="rd-detail-title">{{ detailSubject(d) }}</text>
            <text class="rd-detail-amount">{{ detailAmount(d) }}</text>
          </view>
          <view class="rd-cell">
            <text class="rd-label">发票日期</text>
            <text class="rd-value">{{ d.invoiceDate || "—" }}</text>
          </view>
          <view v-if="d.description"
                class="rd-cell">
            <text class="rd-label">描述</text>
            <text class="rd-value">{{ d.description }}</text>
          </view>
          <view v-if="d.invoiceNo"
                class="rd-cell">
            <text class="rd-label">发票号</text>
            <text class="rd-value">{{ d.invoiceNo }}</text>
          </view>
        </view>
      </view>
      <view v-else
            class="rd-group">
        <view class="rd-empty">暂无报销明细</view>
      </view>
    </view>
    <!-- é™„ä»¶ -->
    <view v-if="attachmentList.length"
          class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">发票附件</text>
      </view>
      <view class="rd-group">
        <view v-for="(f, i) in attachmentList"
              :key="i"
              class="rd-attach"
              @click="openAttachment(f)">
          {{ f.name || "附件" }}
        </view>
      </view>
    </view>
    <!-- å®¡æ‰¹æµç¨‹ï¼ˆtasks) -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">审批流程</text>
        <text class="rd-section-count">{{ flowNodesList.length }} çº§</text>
      </view>
      <view v-if="flowNodesList.length"
            class="rd-group">
        <view v-for="(node, nodeIndex) in flowNodesList"
              :key="nodeIndex"
              class="rd-flow-node">
          <view class="rd-flow-line">
            <view class="rd-flow-dot" />
            <view v-if="nodeIndex < flowNodesList.length - 1"
                  class="rd-flow-bar" />
          </view>
          <view class="rd-flow-body">
            <text class="rd-flow-level">第{{ node.levelNo }}级 Â· {{ node.approveType === 'OR' ? '或签' : '会签' }}</text>
            <view v-for="(a, ai) in node.approvers"
                  :key="ai"
                  class="rd-flow-approver">
              <view class="rd-flow-avatar"
                    :style="{ backgroundColor: avatarColor(a.approverName) }">
                {{ (a.approverName || "?").charAt(0) }}
              </view>
              <view class="rd-flow-approver-meta">
                <text class="rd-flow-name">{{ a.approverName || "—" }}</text>
                <text v-if="a.taskStatus"
                      class="rd-flow-status">{{ taskStatusLabel(a.taskStatus) }}</text>
              </view>
            </view>
          </view>
        </view>
      </view>
      <view v-else
            class="rd-group">
        <view class="rd-empty">暂无审批节点</view>
      </view>
    </view>
    <!-- å®¡æ‰¹è®°å½•(tasks ç•™ç—•) -->
    <view class="rd-section">
      <view class="rd-section-hd">
        <text class="rd-section-title">审批记录</text>
        <text class="rd-section-count">{{ approvalRecords.length }} æ¡</text>
      </view>
      <view v-if="approvalRecords.length"
            class="rd-group">
        <view v-for="(rec, index) in approvalRecords"
              :key="rec.id ?? index"
              class="rd-record-item">
          <view class="rd-record-head">
            <text class="rd-record-operator">{{ rec.operatorName }}</text>
            <text class="rd-record-tag"
                  :class="'rd-record-tag--' + rec.result">{{ recordLabel(rec.result) }}</text>
          </view>
          <text v-if="rec.time"
                class="rd-record-time">{{ rec.time }}</text>
          <text class="rd-record-opinion">{{ rec.opinion || "无意见" }}</text>
        </view>
      </view>
      <view v-else
            class="rd-group">
        <view class="rd-empty">暂无审批记录</view>
      </view>
    </view>
    <view class="rd-section">
      <view class="rd-group">
        <view class="rd-cell">
          <text class="rd-label">创建时间</text>
          <text class="rd-value">{{ formatTime(r.createTime) }}</text>
        </view>
      </view>
    </view>
  </view>
</template>
<script setup>
  import { computed } from "vue";
  import { parseTime } from "@/utils/ruoyi";
  import { isTravelReimbursementType } from "../../_utils/finReimbursementMappers.js";
  import {
    billStatusCssClass,
    billStatusLabel,
  } from "../../_utils/finReimbursementMappers.js";
  import { expenseCategoryLabel, EXPENSE_SUBJECT_OPTIONS as COST_SUBJECTS } from "../_utils/costReimburseUtils.js";
  import { EXPENSE_SUBJECT_OPTIONS as TRAVEL_SUBJECTS } from "../_utils/travelReimburseUtils.js";
  import {
    resolveExpenseSubjectLabel,
    formatDetailAmount,
  } from "../_utils/expenseDetailDisplay.js";
  import { userAvatarColor } from "../../_utils/userPickerUtils.js";
  import {
    mapTasksToFlowNodes,
    recordActionLabel,
    taskStatusText,
  } from "../../_utils/approveListUtils.js";
  import config from "@/config.js";
  const props = defineProps({
    reimburseRow: { type: Object, default: () => ({}) },
    moduleKey: { type: String, default: "" },
  });
  const r = computed(() => props.reimburseRow || {});
  const isTravel = computed(() =>
    isTravelReimbursementType(r.value.reimbursementType ?? props.moduleKey)
  );
  const billNo = computed(() => r.value.billNo || r.value.reimburseNo || "—");
  const statusText = computed(() =>
    billStatusLabel(r.value.billStatus ?? r.value.status)
  );
  const statusCssClass = computed(() =>
    billStatusCssClass(r.value)
  );
  const reasonText = computed(
    () => r.value.reason || r.value.reimburseReason || "—"
  );
  const amountText = computed(() =>
    r.value.applyAmount != null ? String(r.value.applyAmount) : "—"
  );
  const expenseTypeText = computed(() =>
    expenseCategoryLabel(r.value.expenseCategory) || r.value.expenseType || "—"
  );
  const travelDaysText = computed(() => {
    const d = r.value.travelDays ?? r.value.travel?.travelDays;
    return d != null ? `${d} å¤©` : "—";
  });
  const hasTravelStandard = computed(() => {
    const row = r.value;
    return (
      row.hotelStandard != null ||
      row.hotelDays != null ||
      row.livingSubsidy != null ||
      row.standardTag ||
      row.needSpecialApproval
    );
  });
  const subjectOptions = computed(() =>
    isTravel.value ? TRAVEL_SUBJECTS : COST_SUBJECTS
  );
  const detailRows = computed(() => {
    const list = r.value.expenseDetails || r.value.details || [];
    return Array.isArray(list) ? list : [];
  });
  const attachmentList = computed(() => {
    const list =
      r.value.attachmentList ||
      r.value.storageBlobVOList ||
      r.value.invoiceAttachments ||
      [];
    return Array.isArray(list) ? list : [];
  });
  const approvalRecords = computed(() => {
    const list = r.value.approvalRecords || [];
    return Array.isArray(list) ? list : [];
  });
  /** æµç¨‹å±•示优先用 enrichment åŽçš„ flowNodes(来自 tasks) */
  const flowNodesList = computed(() => {
    const row = r.value;
    if (Array.isArray(row.flowNodes) && row.flowNodes.length) {
      return row.flowNodes;
    }
    if (Array.isArray(row.tasks) && row.tasks.length) {
      return mapTasksToFlowNodes(row.tasks);
    }
    return [];
  });
  function taskStatusLabel(status) {
    return taskStatusText(status);
  }
  function recordLabel(result) {
    return recordActionLabel(result);
  }
  function formatTime(t) {
    if (!t) return "—";
    const s = parseTime(t, "{y}-{m}-{d} {h}:{i}");
    return s || String(t).replace("T", " ").slice(0, 16);
  }
  function detailSubject(d) {
    return (
      resolveExpenseSubjectLabel(d.expenseSubject || d.expenseCategory, {
        isTravel: isTravel.value,
        subjectOptions: subjectOptions.value,
      }) || "未选科目"
    );
  }
  function detailAmount(d) {
    return formatDetailAmount(d.amount) || "—";
  }
  function avatarColor(name) {
    return userAvatarColor(name);
  }
  function resolveFileUrl(f) {
    let url = f?.url || f?.downloadURL || f?.previewURL || f?.fileUrl || "";
    if (!url) return "";
    if (/^https?:\/\//i.test(url)) return url;
    const base = (config.baseUrl || "").replace(/\/+$/, "");
    const path = url.startsWith("/") ? url : `/${url}`;
    return `${base}${path}`;
  }
  function openAttachment(f) {
    const url = resolveFileUrl(f);
    if (!url) {
      uni.showToast({ title: "无法打开附件", icon: "none" });
      return;
    }
    // #ifdef H5
    window.open(url, "_blank");
    // #endif
    // #ifndef H5
    uni.downloadFile({
      url,
      success: res => {
        if (res.statusCode === 200) {
          uni.openDocument({ filePath: res.tempFilePath, showMenu: true });
        }
      },
      fail: () => uni.showToast({ title: "附件打开失败", icon: "none" }),
    });
    // #endif
  }
</script>
<style scoped lang="scss">
  @import "../reimburse-detail/reimburse-detail.scss";
</style>
src/pages/oa/ReimburseManage/_utils/costReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
import dayjs from "dayjs";
export const EXPENSE_CATEGORY_OPTIONS = [
  { label: "差旅", value: "travel" },
  { label: "办公采购", value: "office_procurement" },
  { label: "业务招待", value: "business_entertainment" },
  { label: "交通费", value: "transport" },
  { label: "通讯费", value: "communication" },
  { label: "其他", value: "other" },
];
export const EXPENSE_SUBJECT_OPTIONS = [
  { label: "交通费", value: "transport" },
  { label: "住宿费", value: "hotel" },
  { label: "餐饮费", value: "meal" },
  { label: "办公用品", value: "office_supply" },
  { label: "招待费", value: "entertainment" },
  { label: "通讯费", value: "phone" },
  { label: "其他", value: "other" },
];
export const CATEGORY_TEMPLATES = {
  travel: {
    label: "差旅费用",
    reason: "因公出差产生的交通、住宿、餐饮等费用报销。",
    details: [
      { expenseSubject: "transport", description: "往返交通费" },
      { expenseSubject: "hotel", description: "住宿费" },
      { expenseSubject: "meal", description: "出差餐饮" },
    ],
  },
  office_procurement: {
    label: "办公采购",
    reason: "部门日常办公用品、耗材采购报销。",
    details: [
      { expenseSubject: "office_supply", description: "办公用品采购" },
      { expenseSubject: "office_supply", description: "打印耗材" },
    ],
  },
  business_entertainment: {
    label: "业务招待",
    reason: "客户接待、商务宴请等费用报销。",
    details: [
      { expenseSubject: "entertainment", description: "客户接待餐费" },
      { expenseSubject: "entertainment", description: "商务礼品" },
    ],
  },
  transport: {
    label: "交通费",
    reason: "市内通勤、打车、停车等交通费用报销。",
    details: [{ expenseSubject: "transport", description: "市内交通" }],
  },
  communication: {
    label: "通讯费",
    reason: "因公通讯、流量、话费补贴报销。",
    details: [{ expenseSubject: "phone", description: "话费/流量" }],
  },
  other: {
    label: "其他费用",
    reason: "其他因公支出费用报销。",
    details: [{ expenseSubject: "other", description: "其他费用" }],
  },
};
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "—";
}
export function expenseCategoryLabel(v) {
  return EXPENSE_CATEGORY_OPTIONS.find(x => x.value === v)?.label || v || "—";
}
export function expenseTypeToCategory(expenseType) {
  const t = (expenseType || "").trim();
  const hit = EXPENSE_CATEGORY_OPTIONS.find(x => x.label === t || x.value === t);
  return hit?.value || "other";
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  };
}
export function createEmptyCostForm() {
  return {
    reimbursementId: undefined,
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    expenseCategory: "other",
    reimburseReason: "",
    applyAmount: "",
    payee: "",
    payeeAccount: "",
    bankBranch: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }],
    deptId: "",
    deptName: "",
  };
}
export function applyCategoryTemplate(form, category) {
  const tpl = CATEGORY_TEMPLATES[category];
  if (!tpl) return;
  form.expenseCategory = category;
  if (!form.reimburseReason?.trim()) form.reimburseReason = tpl.reason;
  form.expenseDetails = (tpl.details || []).map(d => ({
    ...createEmptyExpenseDetail(),
    expenseSubject: d.expenseSubject,
    description: d.description,
    invoiceDate: dayjs().format("YYYY-MM-DD"),
  }));
}
src/pages/oa/ReimburseManage/_utils/expenseDetailDisplay.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,33 @@
import { expenseSubjectLabel as costSubjectLabel } from "./costReimburseUtils.js";
import { expenseSubjectLabel as travelSubjectLabel } from "./travelReimburseUtils.js";
/** è´¹ç”¨ç§‘目展示(兼容 value / ä¸­æ–‡ label / API expenseCategory) */
export function resolveExpenseSubjectLabel(v, { isTravel = true, subjectOptions = [] } = {}) {
  if (!v) return "";
  const labelFn = isTravel ? travelSubjectLabel : costSubjectLabel;
  const t = labelFn(v);
  if (t && t !== "—") return t;
  const hit = subjectOptions.find(x => x.value === v || x.label === v);
  return hit?.label || String(v);
}
export function formatDetailAmount(amount) {
  if (amount === "" || amount == null) return null;
  const n = Number(amount);
  if (Number.isNaN(n)) return String(amount);
  return `${n} å…ƒ`;
}
/** åˆ—表行摘要 */
export function buildExpenseDetailSummary(row, opts = {}) {
  const subject = resolveExpenseSubjectLabel(row?.expenseSubject, opts) || "未选科目";
  const amount = formatDetailAmount(row?.amount);
  const date = row?.invoiceDate || "";
  const desc = (row?.description || "").trim();
  const parts = [];
  if (date) parts.push(date);
  if (desc) parts.push(desc);
  const sub = parts.length ? parts.join(" Â· ") : "点击详情完善信息";
  const incomplete = !row?.invoiceDate || !row?.expenseSubject || row?.amount === "" || row?.amount == null;
  return { subject, amount: amount || "金额未填", sub, incomplete };
}
src/pages/oa/ReimburseManage/_utils/finReimbursementDetailExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,159 @@
import { parseTime } from "@/utils/ruoyi";
import {
  mapApprovalRecords,
  mapRecordResult,
  mapTasksToFlowNodes,
} from "../../_utils/approveListUtils.js";
function formatDisplayTime(val) {
  if (!val) return "";
  const s = parseTime(val, "{y}-{m}-{d} {h}:{i}");
  return s || String(val).replace("T", " ").slice(0, 16);
}
function taskStatusToNodeStatus(taskStatus) {
  const s = String(taskStatus ?? "").toUpperCase();
  if (["APPROVED", "COMPLETED", "FINISHED", "PASSED", "AGREE"].includes(s)) {
    return "finish";
  }
  if (["REJECTED", "REJECT", "REFUSE", "REFUSED"].includes(s)) {
    return "error";
  }
  if (["PENDING", "IN_APPROVAL", "PROCESS", "PROCESSING"].includes(s)) {
    return "process";
  }
  return "wait";
}
/** storageBlobVOList â†’ é¡µé¢é™„ä»¶ */
export function mapReimbursementAttachments(source = {}) {
  const list =
    source.storageBlobVOList ||
    source.storageBlobDTOs ||
    source.storageBlobDTOS ||
    source.attachmentList ||
    source.invoiceAttachments ||
    [];
  if (!Array.isArray(list)) return [];
  return list.map((b, i) => ({
    ...b,
    id: b.id ?? b.blobId ?? `att_${i}`,
    name:
      b.fileName ||
      b.originalFilename ||
      b.originalFileName ||
      b.blobName ||
      b.name ||
      "附件",
    url:
      b.url ||
      b.fileUrl ||
      b.downloadUrl ||
      b.downloadURL ||
      b.previewUrl ||
      b.previewURL ||
      b.link ||
      "",
  }));
}
/** å®¡æ‰¹è®°å½•在 tasks */
export function mapTasksToApprovalRecords(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  return list
    .map((t, index) => ({
      id: t.id ?? index,
      operatorName: t.approverName || t.operatorName || t.createUserName || "—",
      result: mapRecordResult(t.approveAction ?? t.taskStatus ?? t.status),
      opinion: t.approveComment || t.comment || t.opinion || "",
      time: formatDisplayTime(
        t.approveTime || t.finishTime || t.updateTime || t.createTime || ""
      ),
      levelNo: t.levelNo ?? t.taskLevel,
    }))
    .sort((a, b) => {
      const la = Number(a.levelNo ?? 0);
      const lb = Number(b.levelNo ?? 0);
      if (la !== lb) return la - lb;
      return String(a.time).localeCompare(String(b.time));
    });
}
export function mapTasksToApprovalFlowNodes(tasks) {
  const grouped = mapTasksToFlowNodes(tasks);
  return grouped.map((node, i) => {
    const approvers = node.approvers || [];
    const statuses = approvers.map(a =>
      taskStatusToNodeStatus(a.taskStatus ?? a.status)
    );
    let nodeStatus = "wait";
    if (statuses.includes("error")) nodeStatus = "error";
    else if (statuses.length && statuses.every(s => s === "finish")) {
      nodeStatus = "finish";
    } else if (statuses.includes("process")) nodeStatus = "process";
    const names = approvers.map(a => a.approverName).filter(Boolean).join("、");
    const opinions = approvers
      .map(a => a.approveComment)
      .filter(Boolean)
      .join(";");
    return {
      nodeOrder: node.levelNo ?? i + 1,
      levelNo: node.levelNo ?? i + 1,
      approveType: node.approveType || "AND",
      approveTypeLabel: node.approveType === "OR" ? "或签" : "会签",
      approvers,
      approverName: names || "—",
      approveOpinion: opinions,
      nodeStatus,
    };
  });
}
export function computeApprovalFlowCurrentIndex(approvalFlowNodes = []) {
  const list = approvalFlowNodes || [];
  const processing = list.findIndex(n => n.nodeStatus === "process");
  if (processing >= 0) return processing;
  const errorIdx = list.findIndex(n => n.nodeStatus === "error");
  if (errorIdx >= 0) return errorIdx;
  return list.filter(n => n.nodeStatus === "finish").length;
}
export function applyFinReimbursementDetailEnrichment(mapped, raw = {}) {
  if (!mapped || typeof mapped !== "object") return mapped;
  const source = { ...raw, ...mapped };
  const tasks = Array.isArray(source.tasks) ? source.tasks : [];
  const attachments = mapReimbursementAttachments(source);
  const approvalRecords = tasks.length
    ? mapTasksToApprovalRecords(tasks)
    : mapApprovalRecords(source.records || source.approvalRecords);
  const approvalFlowNodes = Array.isArray(mapped.approvalFlowNodes)
    ? mapped.approvalFlowNodes
    : [];
  const approvalFlowProgressNodes = tasks.length
    ? mapTasksToApprovalFlowNodes(tasks)
    : approvalFlowNodes;
  const flowNodes = tasks.length
    ? mapTasksToFlowNodes(tasks)
    : mapped.flowNodes || mapped.nodes || [];
  return {
    ...mapped,
    tasks,
    storageBlobVOList: attachments,
    attachmentList: attachments,
    invoiceAttachments: attachments,
    approvalRecords,
    approvalFlowNodes,
    approvalFlowProgressNodes,
    currentNodeIndex: computeApprovalFlowCurrentIndex(
      approvalFlowProgressNodes.length ? approvalFlowProgressNodes : approvalFlowNodes
    ),
    rejectReason:
      approvalRecords.find(r => r.result === "rejected")?.opinion ||
      source.rejectReason ||
      "",
    flowNodes,
  };
}
src/pages/oa/ReimburseManage/_utils/travelReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
import dayjs from "dayjs";
export const EXPENSE_SUBJECT_OPTIONS = [
  { label: "交通费", value: "transport" },
  { label: "住宿费", value: "hotel" },
  { label: "餐饮费", value: "meal" },
  { label: "其他", value: "other" },
];
const TIER1_CITIES = ["北京", "上海", "广州", "深圳"];
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find(x => x.value === v)?.label || "—";
}
export function detectTravelTier(destination) {
  const city = (destination || "").trim();
  if (!city) return "tier3";
  if (TIER1_CITIES.some(c => city.includes(c))) return "tier1";
  const tier2Keywords = ["杭州", "南京", "武汉", "成都", "重庆", "西安", "天津", "苏州", "长沙", "郑州"];
  if (tier2Keywords.some(c => city.includes(c))) return "tier2";
  return "tier3";
}
export function getTravelStandardByTier(tier) {
  const map = {
    tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "一线城市" },
    tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "二线城市" },
    tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "其他城市" },
  };
  return map[tier] || map.tier3;
}
export function computeTravelDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  return Math.max(1, Math.ceil(t1.diff(t0, "day", true)));
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  };
}
export function createEmptyTravelForm() {
  return {
    reimbursementId: undefined,
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    reimburseReason: "",
    travelStartTime: "",
    travelEndTime: "",
    travelDays: undefined,
    departurePlace: "",
    destination: "",
    hotelStandard: undefined,
    hotelDays: undefined,
    livingSubsidy: undefined,
    transportSubsidy: undefined,
    lodgingLimit: undefined,
    applyAmount: "",
    payee: "",
    payeeAccount: "",
    payeeBank: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [{ approverId: "", approverName: "", nodeOrder: 1, signMode: "countersign" }],
    needSpecialApproval: false,
    deptId: "",
    deptName: "",
    travelTier: "tier3",
    standardTag: "",
  };
}
src/pages/oa/ReimburseManage/cost-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
<!--
  OA / æŠ¥é”€ç®¡ç† / è´¹ç”¨æŠ¥é”€ï¼ˆ/finReimbursement/listPage,reimbursementType=2)
-->
<template>
  <FinReimbursementListPage :module-key="APPROVAL_MODULE_KEYS.COST_REIMBURSE" />
</template>
<script setup>
  import FinReimbursementListPage from "../../_components/FinReimbursementListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/ReimburseManage/reimburse-detail/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,120 @@
<!--
  å·®æ—…/费用报销详情页
-->
<template>
  <view class="oa-detail-page reimburse-detail-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view v-if="loading"
          class="rd-loading-wrap">
      <up-loading-icon mode="circle" />
      <text class="rd-loading-text">加载中...</text>
    </view>
    <scroll-view v-else-if="reimburseRow"
                 class="oa-detail-scroll reimburse-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ReimburseInstanceDetailBody :reimburse-row="reimburseRow"
                                 :module-key="moduleKey" />
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                text="未获取到报销数据" />
    </view>
    <view v-if="reimburseRow && canEdit"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">返回</text>
      <text class="oa-footer-btn btn-primary"
            @click="goEdit">修改</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ReimburseInstanceDetailBody from "../_components/ReimburseInstanceDetailBody.vue";
  import { OA_NAV } from "@/config/oaPaths.js";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  import {
    canEditReimbursementRow,
    fetchFinReimbursementListItemDetail,
    resolveReimbursementDeleteId,
  } from "../../_utils/finReimbursementMappers.js";
  const moduleKey = ref("");
  const reimbursementId = ref("");
  const reimburseRow = ref(null);
  const loading = ref(false);
  const pageTitle = computed(
    () => `${getApprovalModuleConfig(moduleKey.value)?.label || "报销"}详情`
  );
  const canEdit = computed(() =>
    reimburseRow.value ? canEditReimbursementRow(reimburseRow.value) : false
  );
  const goBack = () => uni.navigateBack();
  const goEdit = () => {
    const rid = resolveReimbursementDeleteId(reimburseRow.value);
    if (rid == null) {
      uni.showToast({ title: "无法修改", icon: "none" });
      return;
    }
    uni.navigateTo({
      url: `${OA_NAV.reimburseForm}?moduleKey=${moduleKey.value}&mode=edit&reimbursementId=${rid}`,
    });
  };
  onLoad(async options => {
    moduleKey.value = options?.moduleKey || "";
    reimbursementId.value = options?.reimbursementId || "";
    if (!moduleKey.value || !reimbursementId.value) {
      uni.showToast({ title: "参数不完整", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    loading.value = true;
    try {
      reimburseRow.value = await fetchFinReimbursementListItemDetail(
        { reimbursementId: reimbursementId.value },
        moduleKey.value
      );
      if (reimburseRow.value?.moduleKey) {
        moduleKey.value = reimburseRow.value.moduleKey;
      }
    } catch {
      uni.showToast({ title: "加载详情失败", icon: "none" });
    } finally {
      loading.value = false;
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
  @import "./reimburse-detail.scss";
  .rd-loading-wrap {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    padding: 80px 0;
  }
  .rd-loading-text {
    margin-top: 12px;
    font-size: 14px;
    color: #909399;
  }
</style>
src/pages/oa/ReimburseManage/reimburse-detail/reimburse-detail.scss
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,344 @@
.reimburse-detail-page {
  min-height: 100vh;
  background: #f2f4f7;
}
.reimburse-detail-scroll {
  padding-bottom: calc(72px + env(safe-area-inset-bottom));
}
.rd-hero {
  margin: 12px 16px 0;
  padding: 16px;
  background: linear-gradient(135deg, #f0f7ff 0%, #fff 55%);
  border-radius: 12px;
  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
  border: 1px solid #e8f0fe;
}
.rd-hero-top {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
}
.rd-bill-no {
  font-size: 13px;
  color: #8c8c8c;
  flex: 1;
  word-break: break-all;
}
.rd-status {
  flex-shrink: 0;
  font-size: 11px;
  padding: 5px 8px;
  border-radius: 4px;
  font-weight: 500;
  &.status-pending {
    color: #d46b08;
    background: #fff7e6;
  }
  &.status-approved {
    color: #389e0d;
    background: #f6ffed;
  }
  &.status-rejected {
    color: #cf1322;
    background: #fff1f0;
  }
  &.status-draft {
    color: #595959;
    background: #f5f5f5;
  }
  &.status-cancelled {
    color: #8c8c8c;
    background: #fafafa;
  }
}
.rd-reason {
  display: block;
  margin-top: 10px;
  font-size: 17px;
  font-weight: 600;
  color: #1a1a1a;
  line-height: 1.45;
}
.rd-amount-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  margin-top: 14px;
  padding-top: 12px;
  border-top: 1px dashed #e8ecf0;
}
.rd-amount-label {
  font-size: 14px;
  color: #8c8c8c;
}
.rd-amount {
  font-size: 22px;
  font-weight: 700;
  color: #2979ff;
}
.rd-section {
  margin: 12px 16px 0;
}
.rd-section-hd {
  padding: 4px 4px 8px;
  display: flex;
  align-items: center;
  justify-content: space-between;
}
.rd-section-title {
  font-size: 13px;
  font-weight: 600;
  color: #909399;
}
.rd-section-count {
  font-size: 12px;
  color: #c0c4cc;
}
.rd-group {
  background: #fff;
  border-radius: 12px;
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.04);
}
.rd-cell {
  display: flex;
  align-items: flex-start;
  padding: 13px 16px;
  border-bottom: 1px solid #f5f6f8;
  font-size: 14px;
  line-height: 1.45;
  &:last-child {
    border-bottom: none;
  }
}
.rd-label {
  width: 88px;
  flex-shrink: 0;
  color: #8c8c8c;
}
.rd-value {
  flex: 1;
  color: #303133;
  text-align: right;
  word-break: break-all;
}
.rd-detail-item {
  padding: 14px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
}
.rd-detail-head {
  display: flex;
  align-items: center;
  margin-bottom: 10px;
}
.rd-detail-badge {
  width: 24px;
  height: 24px;
  border-radius: 6px;
  background: #ecf5ff;
  color: #2979ff;
  font-size: 13px;
  font-weight: 600;
  text-align: center;
  line-height: 24px;
  margin-right: 8px;
}
.rd-detail-title {
  font-size: 15px;
  font-weight: 600;
  color: #303133;
}
.rd-detail-amount {
  margin-left: auto;
  font-size: 15px;
  font-weight: 600;
  color: #2979ff;
}
.rd-flow-node {
  display: flex;
  padding: 12px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
}
.rd-flow-line {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin-right: 12px;
  width: 20px;
}
.rd-flow-dot {
  width: 10px;
  height: 10px;
  border-radius: 50%;
  background: #2979ff;
  flex-shrink: 0;
}
.rd-flow-bar {
  flex: 1;
  width: 2px;
  min-height: 20px;
  background: #e4e7ed;
  margin-top: 4px;
}
.rd-flow-body {
  flex: 1;
  min-width: 0;
}
.rd-flow-level {
  font-size: 14px;
  font-weight: 500;
  color: #303133;
}
.rd-flow-type {
  font-size: 12px;
  color: #909399;
  margin-top: 2px;
}
.rd-flow-approver {
  display: flex;
  align-items: center;
  margin-top: 8px;
}
.rd-flow-avatar {
  width: 28px;
  height: 28px;
  border-radius: 50%;
  color: #fff;
  font-size: 12px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 8px;
}
.rd-flow-approver-meta {
  flex: 1;
  min-width: 0;
}
.rd-flow-name {
  display: block;
  font-size: 14px;
  color: #303133;
}
.rd-flow-status {
  display: block;
  font-size: 12px;
  color: #909399;
  margin-top: 2px;
}
.rd-record-item {
  padding: 14px 16px;
  border-bottom: 1px solid #f5f6f8;
  &:last-child {
    border-bottom: none;
  }
}
.rd-record-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
}
.rd-record-operator {
  font-size: 15px;
  font-weight: 500;
  color: #303133;
}
.rd-record-tag {
  font-size: 11px;
  padding: 2px 8px;
  border-radius: 4px;
  flex-shrink: 0;
  &--approved {
    color: #389e0d;
    background: #f6ffed;
  }
  &--rejected {
    color: #cf1322;
    background: #fff1f0;
  }
  &--pending {
    color: #d46b08;
    background: #fff7e6;
  }
}
.rd-record-time {
  display: block;
  font-size: 12px;
  color: #c0c4cc;
  margin-top: 4px;
}
.rd-record-opinion {
  display: block;
  font-size: 13px;
  color: #606266;
  margin-top: 6px;
  line-height: 1.45;
}
.rd-empty {
  padding: 20px;
  text-align: center;
  font-size: 13px;
  color: #c0c4cc;
}
.rd-attach {
  padding: 12px 16px;
  font-size: 14px;
  color: #2979ff;
  border-bottom: 1px solid #f5f6f8;
}
src/pages/oa/ReimburseManage/reimburse-form/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,564 @@
<!--
  å·®æ—…/费用报销新增/编辑(与 Web å­—段一致,移动端优化选人/布局)
-->
<template>
  <view class="oa-detail-page reimburse-form-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view class="oa-detail-scroll reimburse-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            class="rf-loading">加载中...</view>
      <view v-else>
        <!-- ç”³è¯·äºº -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">申请人</text>
          </view>
          <view class="rf-group">
            <view class="rf-applicant-card"
                  :class="{ 'is-empty': !form.applicantId }"
                  @click="showApplicantPicker = true">
              <view class="rf-applicant-avatar"
                    :style="{ backgroundColor: applicantAvatarColor }">
                {{ (form.employeeName || '选').charAt(0) }}
              </view>
              <view class="rf-applicant-meta">
                <text class="rf-applicant-name">{{ form.employeeName || '请选择员工' }}</text>
                <text class="rf-applicant-sub">{{ applicantDisplaySub }}</text>
              </view>
              <text class="rf-applicant-action">{{ form.applicantId ? '更换' : '选择' }}</text>
            </view>
          </view>
        </view>
        <!-- åŸºæœ¬ä¿¡æ¯ -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">基本信息</text>
          </view>
          <view class="rf-group">
            <view class="rf-cell rf-cell--col">
              <text class="rf-label required">报销原因</text>
              <view class="rf-textarea-wrap">
                <up-textarea v-model="form.reimburseReason"
                             placeholder="请填写出差及报销原因"
                             maxlength="2000"
                             border="none"
                             height="80" />
              </view>
            </view>
            <template v-if="isTravel">
              <view class="rf-cell rf-cell--tap"
                    @click="openDatePicker('travelStartTime')">
                <text class="rf-label required">出差开始</text>
                <view class="rf-value-wrap">
                  <text class="rf-value"
                        :class="{ placeholder: !form.travelStartTime }">
                    {{ form.travelStartTime || '请选择' }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#c0c4cc" />
                </view>
              </view>
              <view class="rf-cell rf-cell--tap"
                    @click="openDatePicker('travelEndTime')">
                <text class="rf-label required">出差结束</text>
                <view class="rf-value-wrap">
                  <text class="rf-value"
                        :class="{ placeholder: !form.travelEndTime }">
                    {{ form.travelEndTime || '请选择' }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#c0c4cc" />
                </view>
              </view>
              <view class="rf-cell">
                <text class="rf-label">出差天数</text>
                <view class="rf-value-wrap">
                  <text class="rf-value">{{ travelDaysDisplay || '—' }}</text>
                  <text class="rf-value"
                        style="color:#909399;margin-left:4px">天</text>
                </view>
              </view>
              <view class="rf-cell">
                <text class="rf-label required">出差地</text>
                <view class="rf-input-body">
                  <up-input v-model="form.departurePlace"
                            placeholder="出发城市"
                            border="none"
                            input-align="right"
                            @blur="recalcTravelStandards" />
                </view>
              </view>
              <view class="rf-cell">
                <text class="rf-label required">目的地</text>
                <view class="rf-input-body">
                  <up-input v-model="form.destination"
                            placeholder="目的城市"
                            border="none"
                            input-align="right"
                            @blur="recalcTravelStandards" />
                </view>
              </view>
            </template>
            <template v-else>
              <view class="rf-cell rf-cell--tap"
                    @click="showCategorySheet = true">
                <text class="rf-label required">费用类型</text>
                <view class="rf-value-wrap">
                  <text class="rf-value"
                        :class="{ placeholder: !form.expenseCategory }">{{ categoryLabel }}</text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#c0c4cc" />
                </view>
              </view>
              <view class="rf-chips">
                <text v-for="cat in quickCategories"
                      :key="cat.value"
                      class="rf-chip"
                      :class="{ active: form.expenseCategory === cat.value }"
                      @click="applyTemplate(cat.value)">{{ cat.label }}</text>
              </view>
            </template>
          </view>
        </view>
        <!-- å·®æ—…标准 -->
        <view v-if="isTravel"
              class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">差旅标准</text>
            <text class="rf-section-extra">{{ travelTierLabel }}</text>
          </view>
          <view v-if="overBudgetWarnings.length"
                class="rf-warn-box">
            <text v-for="(w, i) in overBudgetWarnings"
                  :key="i"
                  class="rf-warn-line">{{ w }}</text>
          </view>
          <view class="rf-group">
            <view class="rf-cell">
              <text class="rf-label">酒店标准</text>
              <view class="rf-input-body">
                <up-input v-model="form.hotelStandard"
                          type="digit"
                          placeholder="元/晚"
                          border="none"
                          input-align="right"
                          @blur="recalcTravelStandards" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">住宿天数</text>
              <view class="rf-input-body">
                <up-input v-model="form.hotelDays"
                          type="number"
                          border="none"
                          input-align="right"
                          @blur="recalcTravelStandards" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">生活补贴</text>
              <view class="rf-input-body">
                <up-input v-model="form.livingSubsidy"
                          type="digit"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">交通补贴</text>
              <view class="rf-value-wrap">
                <text class="rf-value">建议 {{ suggestedTransportSubsidy }} å…ƒ</text>
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">住宿限额</text>
              <view class="rf-value-wrap">
                <text class="rf-value">建议 {{ suggestedHotelLimit }} å…ƒ</text>
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">特批标记</text>
              <text class="rf-tag"
                    :class="form.needSpecialApproval ? 'rf-tag--danger' : 'rf-tag--ok'">
                {{ form.needSpecialApproval ? '超支需特批' : '在标准内' }}
              </text>
            </view>
          </view>
        </view>
        <!-- é‡‘额与收款 -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">金额与收款</text>
            <text class="rf-section-extra"
                  @click="syncApplyAmountFromDetails">按明细 {{ detailTotalAmount }} å…ƒ</text>
          </view>
          <view class="rf-group">
            <view class="rf-cell">
              <text class="rf-label required">申请金额</text>
              <view class="rf-input-body">
                <up-input v-model="form.applyAmount"
                          type="digit"
                          placeholder="元"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label required">收款人</text>
              <view class="rf-input-body">
                <up-input v-model="form.payee"
                          placeholder="收款人"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">收款账号</text>
              <view class="rf-input-body">
                <up-input v-model="form.payeeAccount"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
              </view>
            </view>
            <view class="rf-cell">
              <text class="rf-label">开户支行</text>
              <view class="rf-input-body">
                <up-input v-if="isTravel"
                          v-model="form.payeeBank"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
                <up-input v-else
                          v-model="form.bankBranch"
                          placeholder="选填"
                          border="none"
                          input-align="right" />
              </view>
            </view>
          </view>
        </view>
        <!-- æŠ¥é”€æ˜Žç»†ï¼šåˆ—表摘要 + è¯¦æƒ…按钮 -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">报销明细</text>
            <text class="rf-section-extra"
                  @click="addAndOpenDetail">+ æ–°å¢ž</text>
          </view>
          <view class="rf-group"
                v-if="form.expenseDetails.length">
            <view v-for="(row, idx) in form.expenseDetails"
                  :key="row.id || idx"
                  class="rf-detail-row"
                  :class="{ 'rf-detail-row--warn': detailSummary(row).incomplete }"
                  @click="openDetailEditor(idx)">
              <view class="rf-detail-index">{{ idx + 1 }}</view>
              <view class="rf-detail-body">
                <view class="rf-detail-line1">
                  <text class="rf-detail-subject">{{ detailSummary(row).subject }}</text>
                  <text class="rf-detail-amount">{{ detailSummary(row).amount }}</text>
                </view>
                <text class="rf-detail-line2">{{ detailSummary(row).sub }}</text>
              </view>
              <text class="rf-detail-action"
                    @click.stop="openDetailEditor(idx)">详情</text>
            </view>
          </view>
          <view v-else
                class="rf-group">
            <view class="rf-empty"
                  @click="addAndOpenDetail">点击添加报销明细</view>
          </view>
        </view>
        <!-- é™„ä»¶ -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">附件(发票)</text>
          </view>
          <view class="rf-group">
            <view v-for="(f, i) in form.attachmentList"
                  :key="i"
                  class="rf-attach-item">
              <text>{{ f.name || '附件' }}</text>
              <text class="rf-detail-del"
                    @click="removeAttachment(i)">删除</text>
            </view>
            <view class="rf-upload-zone"
                  @click="chooseAttachment">
              <up-icon name="plus-circle"
                       size="22"
                       color="#2979ff" />
              <text>上传发票/附件</text>
            </view>
          </view>
        </view>
        <!-- å®¡æ‰¹æµç¨‹ -->
        <view class="rf-section">
          <view class="rf-section-hd">
            <text class="rf-section-title">审批流程</text>
          </view>
          <view class="rf-group"
                style="padding:12px">
            <ReimburseApprovalFlowEditor v-model="form.approvalFlowNodes"
                                         :user-options="flowUserOptions" />
            <text class="rf-hint-row">每级须指定审批人,支持搜索姓名或工号</text>
          </view>
        </view>
      </view>
    </scroll-view>
    <view class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">取消</text>
      <text class="oa-footer-btn btn-primary"
            :class="{ 'is-disabled': submitting }"
            @click="onSubmit">提交</text>
    </view>
    <OaUserSearchPicker v-model:show="showApplicantPicker"
                        v-model="form.applicantId"
                        title="选择申请人"
                        :users="flowUserOptions"
                        @select="onApplicantPicked" />
    <up-action-sheet :show="showCategorySheet"
                     title="费用类型"
                     :actions="categoryActions"
                     @select="onCategorySelect"
                     @close="showCategorySheet = false" />
    <ReimburseExpenseDetailSheet v-model:show="showDetailSheet"
                                 v-model="detailDraft"
                                 :index="editingDetailIndex"
                                 :is-travel="isTravel"
                                 :subject-options="expenseSubjectOptions"
                                 @confirm="onDetailSheetConfirm"
                                 @delete="onDetailSheetDelete" />
    <up-popup :show="showDatePicker"
              mode="bottom"
              round="16"
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="datetime"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
    </up-popup>
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import OaUserSearchPicker from "../../_components/OaUserSearchPicker.vue";
  import ReimburseExpenseDetailSheet from "../_components/ReimburseExpenseDetailSheet.vue";
  import config from "@/config.js";
  import { getToken } from "@/utils/auth";
  import { parseTime } from "@/utils/ruoyi";
  import { getApprovalModuleConfig } from "../../_utils/approvalModuleRegistry.js";
  import { consumeReimburseEditFromApprove } from "../../_utils/reimburseApproveBridge.js";
  import { EXPENSE_CATEGORY_OPTIONS } from "../_utils/costReimburseUtils.js";
  import { buildExpenseDetailSummary } from "../_utils/expenseDetailDisplay.js";
  import ReimburseApprovalFlowEditor from "../_components/ReimburseApprovalFlowEditor.vue";
  import { useFinReimburseForm } from "./useFinReimburseForm.js";
  const moduleKey = ref("");
  const mode = ref("add");
  const reimbursementId = ref("");
  const {
    form,
    isTravel,
    submitting,
    loading,
    flowUserOptions,
    travelDaysDisplay,
    travelTierLabel,
    suggestedTransportSubsidy,
    suggestedHotelLimit,
    detailTotalAmount,
    overBudgetWarnings,
    expenseSubjectOptions,
    categoryActions,
    categoryLabel,
    showApplicantPicker,
    applicantDisplaySub,
    applicantAvatarColor,
    showCategorySheet,
    loadUserPool,
    onApplicantPicked,
    recalcTravelStandards,
    syncApplyAmountFromDetails,
    addExpenseDetail,
    removeExpenseDetail,
    applyTemplate,
    initForm,
    loadEdit,
    submitForm,
  } = useFinReimburseForm(moduleKey, mode);
  const showDatePicker = ref(false);
  const datePickerField = ref("");
  const datePickerTs = ref(Date.now());
  const showDetailSheet = ref(false);
  const editingDetailIndex = ref(0);
  const detailDraft = reactive({
    invoiceDate: "",
    expenseSubject: "",
    amount: "",
    description: "",
  });
  const quickCategories = EXPENSE_CATEGORY_OPTIONS.slice(0, 4);
  const pageTitle = computed(() => {
    const label = getApprovalModuleConfig(moduleKey.value)?.label || "报销";
    return mode.value === "edit" ? `编辑${label}` : `新增${label}`;
  });
  const goBack = () => uni.navigateBack();
  function detailSummary(row) {
    return buildExpenseDetailSummary(row, {
      isTravel: isTravel.value,
      subjectOptions: expenseSubjectOptions.value,
    });
  }
  function openDetailEditor(idx) {
    editingDetailIndex.value = idx;
    const row = form.expenseDetails[idx];
    if (!row) return;
    Object.assign(detailDraft, JSON.parse(JSON.stringify(row)));
    showDetailSheet.value = true;
  }
  function addAndOpenDetail() {
    addExpenseDetail();
    openDetailEditor(form.expenseDetails.length - 1);
  }
  function onDetailSheetConfirm(data) {
    const idx = editingDetailIndex.value;
    if (form.expenseDetails[idx]) {
      Object.assign(form.expenseDetails[idx], data);
    }
    recalcTravelStandards();
  }
  function onDetailSheetDelete() {
    const idx = editingDetailIndex.value;
    removeExpenseDetail(idx);
    showDetailSheet.value = false;
  }
  function onCategorySelect(action) {
    form.expenseCategory = action.value;
    applyTemplate(action.value);
    showCategorySheet.value = false;
  }
  function openDatePicker(field) {
    datePickerField.value = field;
    detailDateIndex.value = -1;
    datePickerTs.value = Date.now();
    showDatePicker.value = true;
  }
  function onDateConfirm(e) {
    const ts = e?.value ?? datePickerTs.value;
    if (datePickerField.value) {
      form[datePickerField.value] = parseTime(ts, "{y}-{m}-{d} {h}:{i}:{s}");
      recalcTravelStandards();
    }
    showDatePicker.value = false;
  }
  function chooseAttachment() {
    uni.chooseImage({
      count: 9,
      success: res => {
        (res.tempFilePaths || []).forEach(path => uploadOne(path));
      },
    });
  }
  function uploadOne(filePath) {
    uni.uploadFile({
      url: `${config.baseUrl}/file/upload`,
      filePath,
      name: "file",
      header: { Authorization: "Bearer " + getToken() },
      success: res => {
        try {
          const data = JSON.parse(res.data || "{}");
          const url = data.url || data.data?.url || "";
          const name = data.originalFilename || data.fileName || "附件";
          if (!form.attachmentList) form.attachmentList = [];
          form.attachmentList.push({ name, url });
        } catch {
          uni.showToast({ title: "上传解析失败", icon: "none" });
        }
      },
      fail: () => uni.showToast({ title: "上传失败", icon: "none" }),
    });
  }
  function removeAttachment(i) {
    form.attachmentList.splice(i, 1);
  }
  async function onSubmit() {
    const ok = await submitForm();
    if (ok) setTimeout(goBack, 400);
  }
  onLoad(async options => {
    moduleKey.value = options?.moduleKey || "";
    mode.value = options?.mode === "edit" ? "edit" : "add";
    reimbursementId.value = options?.reimbursementId || "";
    const fromApprove = consumeReimburseEditFromApprove();
    if (fromApprove?.moduleKey) {
      moduleKey.value = fromApprove.moduleKey;
      mode.value = "edit";
      reimbursementId.value = String(fromApprove.reimbursementId ?? "");
    }
    if (!moduleKey.value) {
      uni.showToast({ title: "缺少模块类型", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    await loadUserPool();
    await initForm();
    if (mode.value === "edit" && reimbursementId.value) {
      try {
        await loadEdit(reimbursementId.value);
      } catch {
        uni.showToast({ title: "加载失败", icon: "none" });
      }
    }
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
  @import "./reimburse-form.scss";
</style>
在上述文件截断后对比
src/pages/oa/ReimburseManage/reimburse-form/reimburse-form.scss src/pages/oa/ReimburseManage/reimburse-form/useFinReimburseForm.js src/pages/oa/ReimburseManage/travel-reimburse/index.vue src/pages/oa/_components/ApprovalInstanceListPage.vue src/pages/oa/_components/ApprovalModuleSearchPopup.vue src/pages/oa/_components/FinReimbursementListPage.vue src/pages/oa/_components/OaListPage.vue src/pages/oa/_components/OaUserSearchPicker.vue src/pages/oa/_styles/oa-approval-list.scss src/pages/oa/_utils/approvalFormField.js src/pages/oa/_utils/approvalModuleApplyExtras.js src/pages/oa/_utils/approvalModuleListSearch.js src/pages/oa/_utils/approvalModuleRegistry.js src/pages/oa/_utils/approvalTemplateType.js src/pages/oa/_utils/approveListUtils.js src/pages/oa/_utils/finReimbursementMappers.js src/pages/oa/_utils/oaPageRegistry.js src/pages/oa/_utils/oaStorage.js src/pages/oa/_utils/oaUi.js src/pages/oa/_utils/reimburseApproveBridge.js src/pages/oa/_utils/useOaPage.js src/pages/oa/_utils/userPickerUtils.js src/pages/procurementManagement/procurementLedger/detail.vue src/pages/productionDesign/basicParameters/edit.vue src/pages/productionDesign/basicParameters/index.vue src/pages/productionDesign/bom/BomStructureItem.vue src/pages/productionDesign/bom/index.vue src/pages/productionDesign/bom/structure.vue src/pages/productionDesign/processManagement/edit.vue src/pages/productionDesign/processManagement/index.vue src/pages/productionDesign/processManagement/params.vue src/pages/productionManagement/mainProductionPlan/detail.vue src/pages/productionManagement/mainProductionPlan/index.vue src/pages/productionManagement/processRoute/index.vue src/pages/productionManagement/processRoute/items.vue src/pages/productionManagement/processStatistics/index.vue src/pages/productionManagement/productionAccounting/index.vue src/pages/productionManagement/productionDispatching/components/DispatchModal.vue src/pages/productionManagement/productionDispatching/components/formDia.vue src/pages/productionManagement/productionDispatching/index.vue src/pages/productionManagement/productionOrder/index.vue src/pages/productionManagement/productionOrder/pickingDetail.vue src/pages/productionManagement/productionOrder/source.vue src/pages/productionManagement/productionReport/index.vue src/pages/productionManagement/productionReporting/ledger.vue src/pages/productionManagement/productionScheduling/index.vue src/pages/productionManagement/productionTraceability/index.vue src/pages/qualityManagement/finalInspection/add.vue src/pages/qualityManagement/finalInspection/detail.vue src/pages/qualityManagement/finalInspection/index.vue src/pages/qualityManagement/materialInspection/add.vue src/pages/qualityManagement/materialInspection/detail.vue src/pages/qualityManagement/materialInspection/index.vue src/pages/qualityManagement/processInspection/add.vue src/pages/qualityManagement/processInspection/detail.vue src/pages/qualityManagement/processInspection/index.vue src/pages/sales/salesAccount/goOut.vue src/pages/sales/salesQuotation/detail.vue src/pages/sales/salesQuotation/edit.vue src/pages/sales/salesQuotation/index.vue src/pages/works.vue src/static/images/icon/baogongtaizhang.svg src/static/images/icon/bom.svg src/static/images/icon/gongxuguanli.svg src/static/images/icon/gongyiluxian.svg src/static/images/icon/guihuandengji.svg src/static/images/icon/jichucanshu.svg src/static/images/icon/jieyuedengji.svg src/static/images/icon/kucunguanli.svg src/static/images/icon/shengchandingdan.svg src/static/images/icon/shengchanhesuan.svg src/static/images/icon/shengchanjihua.svg src/static/images/icon/shengchanpaichan.svg src/static/images/icon/shengchanshikuang.svg src/static/images/icon/shengchanzhuisu.svg src/store/modules/user.ts src/utils/versionUpgrade.js