yyb
5 天以前 61b9452f138841d453bf4b2503d78c2aaf2e4394
Merge branch 'dev-new_pro_OA' into dev_NEW_pro
已添加83个文件
已修改3个文件
20115 ■■■■■ 文件已修改
src/api/basicData/enum.js 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/approvalInstance.js 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/approvalTemplate.js 58 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/enterpriseNews.js 38 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/finReimbursement.js 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/financialManagement/receivable/invoiceApply.vue 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 671 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 613 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 628 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js 148 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js 136 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js 154 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js 408 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js 259 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 355 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue 857 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue 399 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js 301 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 819 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 334 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue 325 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsMappers.js 221 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js 207 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue 566 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNewsList.js 55 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/post-manage/index.vue 292 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue 174 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/resign-apply/index.vue 220 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/index.vue 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-contract/index.vue 296 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/work-handover/index.vue 249 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue 550 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js 634 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js 904 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue 614 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js 189 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js 696 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue 291 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue 550 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/personnelManagement/contractManagement/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basicData/enum.js
@@ -1,5 +1,13 @@
import request from "@/utils/request.js";
/** å®¡æ‰¹æ¨¡æ¿ç±»åž‹ç­‰é€šç”¨æžšä¸¾ï¼ˆTypeEnums) */
export function getTypeEnums() {
    return request({
        url: '/basic/enum/TypeEnums',
        method: 'get'
    })
}
export function findAllStockRecordTypeOptions() {
    return request({
        url: '/basic/enum/stockRecordType',
src/api/officeProcessAutomation/approvalInstance.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,47 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢å®¡æ‰¹å®žä¾‹ */
export function listApprovalInstancePage(params) {
  return request({
    url: "/approvalInstance/listPage",
    method: "get",
    params,
  });
}
/** æäº¤/保存审批实例 */
export function saveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/save",
    method: "post",
    data: approvalInstanceDto,
  });
}
/** æ›´æ–°å®¡æ‰¹å®žä¾‹ */
export function updateApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/update",
    method: "put",
    data: approvalInstanceDto,
  });
}
/** å®¡æ‰¹ï¼ˆé€šè¿‡/驳回) */
export function approveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/approve",
    method: "post",
    data: approvalInstanceDto,
  });
}
/** åˆ é™¤å®¡æ‰¹å®žä¾‹ï¼ˆbody ä¸º ID æ•°ç»„) */
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/officeProcessAutomation/approvalTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,58 @@
import request from "@/utils/request";
/** æ¨¡æ¿ç±»åž‹ï¼š0 ç³»ç»Ÿå†…置,1 è‡ªå®šä¹‰ï¼ˆä¸ŽåŽç«¯ templateType ä¸€è‡´ï¼‰ */
export const TEMPLATE_TYPE_BUILTIN = 0;
export const TEMPLATE_TYPE_CUSTOM = 1;
/** æŸ¥è¯¢æ‰€æœ‰å®¡æ‰¹æ¨¡æ¿ */
export function listApprovalTemplate(type) {
  return request({
    url: `/approvalTemplate/list/${type}`,
    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",
  });
}
/** æ–°å¢žå®¡æ‰¹æ¨¡æ¿ï¼ˆbody ä¸º ApprovalTemplateDto) */
export function addApprovalTemplate(approvalTemplateDto) {
  return request({
    url: "/approvalTemplate/add",
    method: "post",
    data: approvalTemplateDto,
  });
}
/** ä¿®æ”¹å®¡æ‰¹æ¨¡æ¿ï¼ˆbody ä¸º ApprovalTemplateDto) */
export function updateApprovalTemplate(approvalTemplateDto) {
  return request({
    url: "/approvalTemplate/update",
    method: "put",
    data: approvalTemplateDto,
  });
}
/** åˆ é™¤å®¡æ‰¹æ¨¡æ¿ï¼ˆbody ä¸ºæ¨¡æ¿ ID æ•°ç»„) */
export function deleteApprovalTemplate(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter((id) => id != null && id !== "");
  return request({
    url: "/approvalTemplate/delete",
    method: "post",
    data: idList,
  });
}
src/api/officeProcessAutomation/enterpriseNews.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,38 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢ä¼ä¸šæ–°é—» */
export function listEnterpriseNewsPage(params) {
  return request({
    url: "/enterpriseNews/listPage",
    method: "get",
    params,
  });
}
/** æ–°å¢žä¼ä¸šæ–°é—» */
export function saveEnterpriseNews(enterpriseNewsDto) {
  return request({
    url: "/enterpriseNews/save",
    method: "post",
    data: enterpriseNewsDto,
  });
}
/** ä¿®æ”¹ä¼ä¸šæ–°é—» */
export function updateEnterpriseNews(enterpriseNewsDto) {
  return request({
    url: "/enterpriseNews/update",
    method: "put",
    data: enterpriseNewsDto,
  });
}
/** åˆ é™¤ä¼ä¸šæ–°é—»ï¼ˆbody ä¸º ID æ•°ç»„) */
export function deleteEnterpriseNews(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter((id) => id != null && id !== "");
  return request({
    url: "/enterpriseNews/delete",
    method: "delete",
    data: idList,
  });
}
src/api/officeProcessAutomation/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/views/financialManagement/receivable/invoiceApply.vue
@@ -766,6 +766,7 @@
  dialogTitle.value = "编辑开票申请";
  fillFormFromRow(row);
  dialogVisible.value = true;
  loadOutboundBatches(form.customerId, true);
};
const view = (row) => {
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,671 @@
import {
  createEmptyNode,
  formatDisplayTime,
  mapNodesFromApi,
  mapSignModeFromApi,
  mapSignModeToApi,
  normalizeFlowNodes,
  nodeSignModeLabel,
} from "../approve-template/approveTemplateConstants.js";
import { buildFormPayloadFromFields, parseFormConfigToData } from "../approve-template/formConfigUtils.js";
import {
  isDynamicOptionSource,
  resolveSelectDisplayLabel,
} from "../approve-template/selectOptionSource.js";
import {
  appendDotNotationQuery,
  buildApprovalInstanceSearchDto,
} from "../approve-shared/approvalInstanceListSearch.js";
/** å®¡æ‰¹ç±»åž‹ï¼ˆä¸ŽåŽç«¯å­—段 approvalType å¯¹é½ï¼ŒåŽæœŸå¯åŒæ­¥ï¼‰ */
export const APPROVAL_TYPE_OPTIONS = [
  { value: "cost_reimburse", label: "费用报销申请", cellBg: "#e8f8ef", cellColor: "#1a7f4b" },
  { value: "travel_reimburse", label: "差旅报销申请", cellBg: "#f0f2f5", cellColor: "#606266" },
  { value: "overtime", label: "加班申请", cellBg: "#fdf3e8", cellColor: "#c45c26" },
  { value: "leave", label: "请假申请", cellBg: "#fce8f0", cellColor: "#b84d7a" },
  { value: "work_handover", label: "工作交接申请", cellBg: "#f0e8fc", cellColor: "#6b4d9e" },
  { value: "regular", label: "转正申请", cellBg: "#e8f4fc", cellColor: "#2b6cb0" },
  { value: "resign", label: "离职申请", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" },
  { value: "transfer", label: "调岗申请", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" },
  { value: "out_office", label: "公出申请", cellBg: "#e8f4ff", cellColor: "#409eff" },
  { value: "business_trip", label: "出差申请", cellBg: "#fdf6ec", cellColor: "#e6a23c" },
  { value: "procurement", label: "采购审批", cellBg: "#f4f4f5", cellColor: "#909399" },
  { value: "quotation", label: "报价审批", cellBg: "#f4ecfc", cellColor: "#9b59b6" },
  { value: "shipment", label: "发货审批", cellBg: "#e8faf6", cellColor: "#1abc9c" },
  { value: "enterprise_news", label: "企业新闻", cellBg: "#ecf5ff", cellColor: "#409eff" },
];
/** åˆ—表查询:审批状态(与后端 status æžšä¸¾ä¸€è‡´ï¼‰ */
export const APPROVAL_STATUS_SEARCH_OPTIONS = [
  { value: "DRAFT", label: "草稿" },
  { value: "PENDING", label: "待审批" },
  { value: "APPROVED", label: "已通过" },
  { value: "REJECTED", label: "已驳回" },
];
/**
 * å®¡æ‰¹çŠ¶æ€å±•ç¤ºï¼ˆä¸ŽåŽç«¯ status æžšä¸¾ä¸€è‡´ï¼‰
 * DRAFT→草稿 PENDING→待审批/进行中 APPROVED→已通过/已完成 REJECTED→已驳回
 */
export const APPROVAL_STATUS_OPTIONS = [
  { value: "draft", api: "DRAFT", label: "草稿" },
  { value: "pending", api: "PENDING", label: "待审批" },
  { value: "approved", api: "APPROVED", label: "已通过" },
  { value: "rejected", api: "REJECTED", label: "已驳回" },
  { value: "cancelled", api: "CANCELLED", label: "已撤销" },
];
/** æ•°å­—状态码(部分后端用 0/1/2) */
const STATUS_NUMERIC_MAP = {
  0: "pending",
  1: "approved",
  2: "rejected",
  3: "cancelled",
  4: "cancelled",
};
/** åŽç«¯ status / é¡µé¢ approvalStatus â†’ ç»Ÿä¸€é¡µé¢ key(pending | approved | rejected | cancelled) */
export function normalizeApprovalStatusKey(v) {
  if (v == null || v === "") return "pending";
  if (typeof v === "number" || (typeof v === "string" && /^\d+$/.test(v.trim()))) {
    const numKey = STATUS_NUMERIC_MAP[Number(v)];
    if (numKey) return numKey;
  }
  const s = String(v).trim();
  if (!s) return "pending";
  const upper = s.toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "PUBLISHED") return "approved";
  if (upper === "OFFLINE") return "cancelled";
  if (upper === "APPROVED" || upper === "APPROVE" || upper === "PASS" || upper === "AGREE") {
    return "approved";
  }
  if (
    upper === "REJECTED" ||
    upper === "REJECT" ||
    upper === "REFUSE" ||
    upper === "REFUSED" ||
    upper === "DENIED"
  ) {
    return "rejected";
  }
  if (upper === "CANCELLED" || upper === "CANCEL" || upper === "REVOKED") return "cancelled";
  if (
    upper === "PENDING" ||
    upper === "IN_PROGRESS" ||
    upper === "PROCESSING" ||
    upper === "RUNNING" ||
    upper === "WAIT" ||
    upper === "WAITING"
  ) {
    return "pending";
  }
  if (s.includes("草稿")) return "draft";
  if (s.includes("驳回") || s.includes("拒绝")) return "rejected";
  if (s.includes("下线")) return "cancelled";
  if (s.includes("撤销")) return "cancelled";
  if (s.includes("发布") || s.includes("通过") || s.includes("完成")) return "approved";
  if (s.includes("待审") || s.includes("进行中") || s.includes("审批中")) return "pending";
  const lower = s.toLowerCase();
  if (["draft", "pending", "approved", "rejected", "cancelled"].includes(lower)) return lower;
  return "pending";
}
/** ä»Žåˆ—表/详情行解析后端原始状态(兼容多字段命名) */
export function resolveInstanceStatusRaw(row) {
  if (!row || typeof row !== "object") return "";
  const candidates = [
    row.status,
    row.statusRaw,
    row.approvalStatus,
    row.statusName,
    row.statusLabel,
    row.approvalStatusName,
    row.statusDesc,
    row.instanceStatus,
    row.approvalInstanceStatus,
    row.approveStatus,
    row.auditStatus,
    row.approvalInstance?.status,
    row.approvalInstanceVo?.status,
  ];
  for (const c of candidates) {
    if (c != null && c !== "") return c;
  }
  const tasks = row.tasks;
  if (Array.isArray(tasks) && tasks.length) {
    const rejected = tasks.some((t) =>
      normalizeApprovalStatusKey(t?.status ?? t?.taskStatus) === "rejected"
    );
    if (rejected) return "REJECTED";
    const allApproved = tasks.every((t) =>
      normalizeApprovalStatusKey(t?.status ?? t?.taskStatus) === "approved"
    );
    if (allApproved) return "APPROVED";
  }
  return "";
}
/** æäº¤å¼¹çª—:模板卡片(来自后端列表) */
export function mapSubmitTemplateCard(row) {
  const cfg = parseFormConfigToData(row?.formConfig);
  return {
    id: row?.id,
    key: String(row?.id ?? ""),
    businessType: row?.businessType ?? cfg.approvalType ?? row?.approvalType ?? "",
    approvalType: cfg.approvalType || row?.approvalType || "",
    label: row?.templateName || "—",
    summaryPlaceholder: (row?.description || "").trim() || cfg.summaryPlaceholder || "点击填写并提交",
  };
}
export function matchBusinessTypeValue(a, b) {
  if (a == null || a === "" || b == null || b === "") return false;
  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
}
/** å®¡æ‰¹è®°å½• approveAction â†’ é¡µé¢ result */
export function mapRecordResultFromApi(action) {
  const s = String(action || "").toUpperCase();
  if (s === "APPROVED" || s === "APPROVE" || s === "PASS") return "approved";
  if (s === "REJECTED" || s === "REJECT" || s === "REFUSE") return "rejected";
  return "pending";
}
/** åŽç«¯ records â†’ æ—¶é—´çº¿å±•示结构 */
export function mapRecordsFromApi(records) {
  const list = Array.isArray(records) ? records : [];
  return list.map((r) => ({
    id: r.id,
    operatorName: r.approverName || r.operatorName || r.createUserName || "",
    result: mapRecordResultFromApi(r.approveAction ?? r.action ?? r.status),
    opinion: r.approveComment || r.comment || r.opinion || "",
    time: formatDisplayTime(r.approveTime || r.createTime || r.time || ""),
    raw: r,
  }));
}
export function mapTaskStatusLabel(status) {
  return approvalStatusLabel(status);
}
export function mapTaskStatusTagType(status) {
  return approvalStatusTagType(status);
}
/** åŽç«¯ tasks â†’ é¡µé¢ flowNodes(按 levelNo åˆ†ç»„,供流程编辑/展示) */
export function mapTasksToFlowNodes(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  if (!list.length) return [];
  const byLevel = new Map();
  list.forEach((t) => {
    const level = Number(t.levelNo ?? t.taskLevel ?? t.nodeOrder ?? 1);
    if (!byLevel.has(level)) {
      byLevel.set(level, {
        id: t.nodeId,
        templateId: t.templateId,
        nodeOrder: level,
        signMode: mapSignModeFromApi(t.approveType),
        approvers: [],
        tasks: [],
      });
    }
    const node = byLevel.get(level);
    node.approvers.push({
      id: t.id,
      nodeId: t.nodeId,
      templateId: t.templateId,
      approverId: t.approverId,
      approverName: t.approverName || "",
      status: t.status,
      approveComment: t.approveComment,
      approveTime: t.approveTime,
    });
    node.tasks.push(t);
    if (t.approveType != null) {
      node.signMode = mapSignModeFromApi(t.approveType);
    }
  });
  return [...byLevel.entries()]
    .sort(([a], [b]) => a - b)
    .map(([, node]) => node);
}
/** é¡µé¢ flowNodes â†’ åŽç«¯ tasks */
export function mapFlowNodesToTasks(flowNodes, { instanceId, templateId } = {}) {
  const nodes = normalizeFlowNodes(flowNodes);
  const tasks = [];
  nodes.forEach((n) => {
    const levelNo = n.nodeOrder ?? 1;
    const approveType = mapSignModeToApi(n.signMode);
    n.approvers.forEach((a, idx) => {
      const task = {
        levelNo,
        approveType,
        approverId: a.approverId,
        approverName: a.approverName || "",
        sortNo: a.sortNo ?? idx + 1,
      };
      if (a.id != null) task.id = a.id;
      if (a.nodeId != null) task.nodeId = a.nodeId;
      if (a.templateId != null) task.templateId = a.templateId;
      else if (templateId) task.templateId = templateId;
      if (instanceId) task.instanceId = instanceId;
      if (a.status != null) task.status = a.status;
      tasks.push(task);
    });
  });
  return tasks;
}
function guessFieldTypeFromValue(val) {
  if (Array.isArray(val) && val.length === 2) return "datetimerange";
  if (typeof val === "number") return "number";
  if (typeof val === "string" && /^\d{4}-\d{2}-\d{2}$/.test(val)) return "date";
  if (typeof val === "string" && val.length > 100) return "textarea";
  return "text";
}
/**
 * å•字段展示值(详情只读、列表主表)
 * @param {object} [caches] äººå‘˜/部门下拉缓存,用于解析「人员列表」类字段为姓名
 */
export function formatFieldDisplayValue(field, val, caches) {
  if (val == null || val === "" || (Array.isArray(val) && !val.length)) return "—";
  if (field?.type === "select" && isDynamicOptionSource(field.optionSource)) {
    const label = resolveSelectDisplayLabel(field, val, caches || {});
    if (label && label !== "—") return label;
    return String(val);
  }
  if (field?.type === "select" && field.options?.length) {
    const hit = field.options.find((o) => String(o.value) === String(val));
    return hit?.label || String(val);
  }
  if (Array.isArray(val)) return val.join(" è‡³ ");
  return String(val);
}
/**
 * ä»Žè¡Œæ•°æ® / formConfig è§£æžå¡«æŠ¥å­—段定义与 formPayload(与新增提交结构一致)
 */
export function resolveInstanceFormFields(row) {
  const cfg = parseInstanceFormConfig(row?.formConfig);
  let fields = (row?.formFieldDefs?.length ? row.formFieldDefs : cfg.fields) || [];
  const formPayload = {
    ...(fields.length ? buildFormPayloadFromFields(fields) : {}),
    ...cfg.formPayload,
    ...(row?.formPayload || {}),
  };
  if (!fields.length && Object.keys(formPayload).length) {
    fields = Object.keys(formPayload)
      .filter((k) => k && k !== "summary")
      .map((k) => ({
        key: k,
        label: k,
        type: guessFieldTypeFromValue(formPayload[k]),
        required: false,
        rows: 3,
        min: 0,
        precision: 0,
        options: [],
      }));
  }
  const templateSnapshot = {
    label: row?.templateName || row?.title || "审批",
    approvalType: cfg.approvalType || row?.approvalType || "",
    summaryPlaceholder: cfg.summaryPlaceholder || "",
    templateId: row?.templateId,
    fields,
  };
  return { fields, formPayload, templateSnapshot, formConfigData: cfg };
}
/** è§£æžå®žä¾‹ formConfig */
export function parseInstanceFormConfig(formConfig) {
  let raw = {};
  if (formConfig) {
    if (typeof formConfig === "object") raw = formConfig;
    else {
      try {
        raw = JSON.parse(formConfig);
      } catch {
        raw = {};
      }
    }
  }
  const data = parseFormConfigToData(formConfig);
  const payload = raw.formPayload;
  return {
    summaryPlaceholder: raw.summaryPlaceholder || data.summaryPlaceholder || "",
    approvalType: raw.approvalType || "",
    fields: data.fields || [],
    formPayload: payload && typeof payload === "object" ? payload : {},
  };
}
export function unwrapInstanceDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.id != null || data.instanceNo) return data;
  if (data.approvalInstanceVo) return data.approvalInstanceVo;
  return data;
}
/** å¡«æŠ¥å†…容 + æ¨¡æ¿å­—段定义 â†’ formConfig JSON */
export function buildInstanceFormConfigJson(templateSnapshot, formPayload) {
  const payload = formPayload || {};
  return JSON.stringify({
    summaryPlaceholder: templateSnapshot?.summaryPlaceholder || "",
    approvalType: templateSnapshot?.approvalType || "",
    fields: templateSnapshot?.fields || [],
    formPayload: payload,
  });
}
/** ç»„装保存/更新审批 DTO */
export function buildInstanceDto({ submitForm, activeTemplate, userStore, flowNodes, existingRow }) {
  const payload = submitForm?.formPayload || {};
  const tpl = activeTemplate || {};
  const title =
    String(payload.summary || payload.title || "").trim() ||
    tpl.label ||
    submitForm?.templateName ||
    "审批申请";
  const templateId = submitForm?.templateId || tpl.templateId;
  const instanceId = existingRow?.id ?? submitForm?.instanceId;
  const taskList = mapFlowNodesToTasks(flowNodes || submitForm?.flowNodes, {
    instanceId,
    templateId,
  });
  const isUpdate = Boolean(instanceId);
  const dto = {
    templateId,
    templateName: submitForm?.templateName || tpl.label || "",
    businessType: tpl.businessType ?? submitForm?.businessType ?? "",
    title,
    formConfig: buildInstanceFormConfigJson({ ...tpl, fields: tpl.fields || submitForm?.formFieldDefs }, payload),
    tasks: taskList,
  };
  const attachments =
    (Array.isArray(submitForm?.storageBlobDTOs) && submitForm.storageBlobDTOs.length
      ? submitForm.storageBlobDTOs
      : null) || tpl.storageBlobDTOs;
  if (attachments?.length) dto.storageBlobDTOs = attachments;
  if (isUpdate) {
    dto.id = existingRow?.id ?? submitForm?.instanceId;
    dto.instanceNo = existingRow?.instanceNo ?? submitForm?.instanceNo ?? "";
    dto.status =
      submitForm?.saveStatusApi ||
      existingRow?.statusRaw ||
      mapInstanceStatusToApi(existingRow?.approvalStatus) ||
      "PENDING";
    dto.currentLevel = existingRow?.currentLevel ?? submitForm?.currentLevel ?? 1;
    dto.applicantId = existingRow?.applicantId ?? existingRow?.applicantNo;
    dto.applicantName = existingRow?.applicantName || "";
  } else {
    dto.status = submitForm?.saveStatusApi || "PENDING";
    dto.currentLevel = 1;
    dto.applicantId = userStore?.id;
    dto.applicantName = userStore?.nickName || userStore?.name || "";
  }
  return dto;
}
/** æ ¡éªŒæäº¤å®¡æ‰¹æµç¨‹ï¼ˆä¸Žæ¨¡æ¿é¡µè§„则一致) */
export function validateSubmitFlowNodes(flowNodes) {
  const nodes = normalizeFlowNodes(flowNodes);
  if (!nodes.length) return { ok: false, message: "请至少配置一个审批节点" };
  for (let i = 0; i < nodes.length; i++) {
    if (!nodes[i].approvers.length) {
      return { ok: false, message: `请为第 ${i + 1} ä¸ªèŠ‚ç‚¹é€‰æ‹©è‡³å°‘ä¸€åå®¡æ‰¹äºº` };
    }
  }
  return { ok: true, nodes };
}
/** åŽç«¯ status â†’ é¡µé¢ approvalStatus */
export function mapInstanceStatusFromApi(status) {
  return normalizeApprovalStatusKey(status);
}
/** åˆ—表/详情行 â†’ é¡µé¢ approvalStatus key */
export function mapInstanceApprovalStatusFromRow(row) {
  const raw = resolveInstanceStatusRaw(row);
  return normalizeApprovalStatusKey(raw);
}
/** é¡µé¢ approvalStatus â†’ åŽç«¯ status */
export function mapInstanceStatusToApi(approvalStatus) {
  const key = normalizeApprovalStatusKey(approvalStatus);
  const hit = APPROVAL_STATUS_OPTIONS.find((x) => x.value === key);
  return hit?.api || "PENDING";
}
export function unwrapInstancePage(res) {
  const data = res?.data ?? res;
  return {
    records: Array.isArray(data?.records) ? data.records : [],
    total: Number(data?.total ?? 0),
  };
}
/** åˆ†é¡µåˆ—表项 â†’ è¡¨æ ¼è¡Œ */
export function mapInstanceFromApi(row) {
  if (!row) return {};
  const statusRaw = resolveInstanceStatusRaw(row);
  const approvalStatus = normalizeApprovalStatusKey(statusRaw);
  const createTime = formatDisplayTime(row.createTime ?? row.applyTime ?? "");
  const applyTime = formatDisplayTime(row.applyTime ?? "");
  const finishTime = formatDisplayTime(row.finishTime ?? "");
  const resolved = resolveInstanceFormFields(row);
  const { fields, formPayload, templateSnapshot } = resolved;
  const tasks = Array.isArray(row.tasks) ? row.tasks : [];
  const flowNodes = tasks.length
    ? mapTasksToFlowNodes(tasks)
    : mapNodesFromApi(row.nodes || row.flowNodes);
  const approvalRecords = mapRecordsFromApi(row.records);
  return {
    id: row.id,
    bizId: row.instanceNo || String(row.id ?? ""),
    instanceNo: row.instanceNo || "",
    templateId: row.templateId,
    templateName: row.templateName || "",
    businessId: row.businessId,
    businessType: row.businessType,
    businessName: row.businessName || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantId != null ? String(row.applicantId) : "",
    applicantName: row.applicantName || "",
    approvalType: row.approvalType || row.templateName || "",
    unread: Boolean(row.isApprove) && approvalStatus === "pending",
    isApprove: Boolean(row.isApprove),
    approvalStatus,
    statusRaw: statusRaw || row.status,
    createTime,
    applyTime: applyTime === "—" ? "" : applyTime,
    finishTime: finishTime === "—" ? "" : finishTime,
    title: row.title || "",
    summary: row.title || row.templateName || "",
    currentLevel: row.currentLevel,
    formConfig: row.formConfig,
    formPayload,
    formFieldDefs: fields,
    templateSnapshot,
    tasks,
    records: Array.isArray(row.records) ? row.records : [],
    flowNodes,
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    approvalRecords,
    rejectReason:
      approvalRecords.find((r) => r.result === "rejected")?.opinion || "",
  };
}
/** å®¡æ‰¹æ“ä½œï¼šä¸ŽåŽç«¯ status æžšä¸¾ä¸€è‡´ */
export const APPROVE_ACTION_APPROVED = "APPROVED";
export const APPROVE_ACTION_REJECTED = "REJECTED";
/** é¡µé¢æ“ä½œ â†’ approveAction */
export function mapApproveActionToApi(uiResult) {
  return uiResult === "rejected" ? APPROVE_ACTION_REJECTED : APPROVE_ACTION_APPROVED;
}
/** ç»„装审批提交 DTO */
export function buildApproveInstanceDto(row, uiResult, comment) {
  const opinion = (comment || "").trim();
  return {
    id: row?.id,
    approveAction: mapApproveActionToApi(uiResult),
    approveComment: opinion || (uiResult === "approved" ? "同意" : ""),
  };
}
export function buildApprovalInstanceListParams({
  page,
  searchForm,
  businessType,
  extraParams,
}) {
  const dto = buildApprovalInstanceSearchDto(searchForm, extraParams);
  const bizType = businessType ?? searchForm?.businessType;
  if (bizType != null && bizType !== "") {
    dto.businessType = bizType;
  }
  const params = {
    current: page.current,
    size: page.size,
    "page.current": page.current,
    "page.size": page.size,
    ...dto,
  };
  appendDotNotationQuery(params, "approvalInstanceDto", dto);
  return params;
}
export function approvalTypeLabel(v) {
  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function approvalTypeStyle(v) {
  const hit = APPROVAL_TYPE_OPTIONS.find((x) => x.value === v);
  if (!hit) return {};
  return {
    backgroundColor: hit.cellBg,
    color: hit.cellColor,
    border: hit.border || "none",
  };
}
export function approvalStatusLabel(v) {
  const key = normalizeApprovalStatusKey(v);
  return APPROVAL_STATUS_OPTIONS.find((x) => x.value === key)?.label || "—";
}
/** ä¸šåŠ¡ç”³è¯·é¡µçŠ¶æ€æ–‡æ¡ˆï¼šPENDING→进行中 APPROVED→已完成 REJECTED→已驳回 */
export function businessApprovalStatusLabel(v) {
  const key = normalizeApprovalStatusKey(v);
  if (key === "draft") return "草稿";
  if (key === "pending") return "进行中";
  if (key === "approved") return "已完成";
  if (key === "rejected") return "已驳回";
  if (key === "cancelled") return "已撤销";
  return "—";
}
/**
 * ä¸šåŠ¡ç”³è¯·é¡µæ˜¯å¦å…è®¸ä¿®æ”¹ï¼ˆäº”ä¸ªç”³è¯·é¡µï¼‰
 * è¿›è¡Œä¸­(PENDING)、已完成(APPROVED) ä¸å¯ä¿®æ”¹ï¼›å·²é©³å›žã€å·²æ’¤é”€ç­‰å¯ä¿®æ”¹
 */
export function canEditBusinessInstanceRow(row) {
  const key = normalizeApprovalStatusKey(
    row?.approvalStatus ?? row?.statusRaw ?? row?.status
  );
  return key !== "pending" && key !== "approved";
}
export function businessApprovalStatusTagType(v) {
  const key = normalizeApprovalStatusKey(v);
  if (key === "draft") return "info";
  if (key === "approved") return "success";
  if (key === "rejected") return "danger";
  if (key === "cancelled") return "info";
  return "warning";
}
export function approvalStatusTagType(v) {
  const key = normalizeApprovalStatusKey(v);
  if (key === "draft") return "info";
  if (key === "approved") return "success";
  if (key === "rejected") return "danger";
  if (key === "cancelled") return "info";
  return "warning";
}
/** åˆ—表行 â†’ ç¼–辑表单(仅用行数据回显) */
export function buildEditFormFromInstanceRow(row) {
  const { fields, formPayload, templateSnapshot } = resolveInstanceFormFields(row);
  const normalized = normalizeFlowNodes(
    row?.flowNodes?.length ? row.flowNodes : mapTasksToFlowNodes(row?.tasks)
  );
  const flowNodes = normalized.length
    ? JSON.parse(JSON.stringify(normalized))
    : [createEmptyNode(1)];
  return {
    templateKey: String(row?.templateId || ""),
    templateId: row?.templateId,
    templateName: row?.templateName || templateSnapshot.label,
    instanceId: row?.id,
    instanceNo: row?.instanceNo || "",
    statusRaw: row?.statusRaw || row?.status || "PENDING",
    currentLevel: row?.currentLevel ?? 1,
    applicantId: row?.applicantId,
    applicantName: row?.applicantName || "",
    templateSnapshot,
    formFieldDefs: fields,
    formPayload,
    flowNodes,
    templateAttachments: initTemplateAttachmentsFromSnapshot(templateSnapshot),
    storageBlobDTOs: row?.storageBlobDTOs?.length
      ? JSON.parse(JSON.stringify(row.storageBlobDTOs))
      : [],
  };
}
export function createEmptySubmitForm(templateKey, templateOverride, flowNodesOverride) {
  const tpl = templateOverride || null;
  const payload = tpl?.fields?.length ? buildFormPayloadFromFields(tpl.fields) : { summary: "" };
  const normalized = normalizeFlowNodes(flowNodesOverride);
  const flowNodes = normalized.length
    ? JSON.parse(JSON.stringify(normalized))
    : [createEmptyNode(1)];
  return {
    templateKey: templateKey || "",
    templateId: tpl?.templateId || "",
    templateName: tpl?.label || "",
    instanceId: "",
    instanceNo: "",
    statusRaw: "",
    currentLevel: 1,
    applicantId: null,
    applicantName: "",
    templateSnapshot: templateOverride || null,
    formFieldDefs: tpl?.fields || [],
    formPayload: payload,
    flowNodes,
    templateAttachments: tpl?.storageBlobDTOs
      ? JSON.parse(JSON.stringify(tpl.storageBlobDTOs))
      : [],
    storageBlobDTOs: [],
  };
}
export function initTemplateAttachmentsFromSnapshot(templateSnapshot) {
  const list = templateSnapshot?.storageBlobDTOs;
  return list?.length ? JSON.parse(JSON.stringify(list)) : [];
}
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
<!-- å®¡æ‰¹è¯¦æƒ…:基础信息 + å¡«æŠ¥å†…容 -->
<template>
  <div class="approve-detail-panel">
    <div class="detail-block">
      <div class="detail-block-title">基本信息</div>
      <el-descriptions :column="2" border>
        <el-descriptions-item label="业务单号">{{ row.bizId || row.id || "—" }}</el-descriptions-item>
        <el-descriptions-item label="审批状态">
          <el-tag :type="approvalStatusTagType(row.approvalStatus)" size="small" effect="plain">
            {{ approvalStatusLabel(row.approvalStatus) }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="审批类型">
          <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
            {{ approvalTypeLabel(row.approvalType) }}
          </span>
        </el-descriptions-item>
        <el-descriptions-item label="申请人编号">{{ row.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人名称">{{ row.applicantName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请摘要">{{ row.summary || "—" }}</el-descriptions-item>
        <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
          <span class="reject-text">{{ row.rejectReason }}</span>
        </el-descriptions-item>
        <el-descriptions-item label="创建时间" :span="2">
          {{ formatDisplayTime(row.createTime) }}
        </el-descriptions-item>
      </el-descriptions>
    </div>
    <div class="detail-block">
      <div class="detail-block-title">填报内容</div>
      <FormPayloadFields
        :fields="formResolved.fields"
        :form-payload="formResolved.formPayload"
        readonly
      />
    </div>
  </div>
</template>
<script setup>
import { computed } from "vue";
import { formatDisplayTime } from "../../approve-template/approveTemplateConstants.js";
import {
  approvalTypeLabel,
  approvalTypeStyle,
  approvalStatusLabel,
  approvalStatusTagType,
  resolveInstanceFormFields,
} from "../approveListConstants.js";
import FormPayloadFields from "./FormPayloadFields.vue";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const formResolved = computed(() => resolveInstanceFormFields(props.row));
</script>
<style scoped>
.approve-detail-panel {
  display: flex;
  flex-direction: column;
  gap: 20px;
}
.detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.reject-text {
  color: var(--el-color-danger);
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
<!-- å¡«æŠ¥é¡¹ï¼šç¼–辑为表单控件,详情为 descriptions è¡¨æ ¼ï¼ˆä¸Žä¸Šæ–¹åŸºç¡€ä¿¡æ¯ä¸€è‡´ï¼‰ -->
<template>
  <template v-if="fields?.length">
    <el-descriptions
      v-if="readonly"
      :column="2"
      border
      class="form-payload-desc"
    >
      <el-descriptions-item
        v-for="field in fields"
        :key="field.key"
        :label="field.label"
        :span="field.type === 'textarea' || field.type === 'datetimerange' ? 2 : 1"
      >
        <span class="field-value">{{ displayValue(field) }}</span>
      </el-descriptions-item>
    </el-descriptions>
    <div
      v-else
      class="form-payload-edit"
      v-loading="optionSourceLoading"
    >
      <el-form-item
        v-for="field in fields"
        :key="field.key"
        :label="field.label"
        :prop="`formPayload.${field.key}`"
        :required="Boolean(field.required)"
      >
        <el-input
          v-if="field.type === 'text'"
          v-model="formPayload[field.key]"
          :placeholder="`请输入${field.label}`"
          maxlength="200"
        />
        <el-input
          v-else-if="field.type === 'textarea'"
          v-model="formPayload[field.key]"
          type="textarea"
          :rows="field.rows || 3"
          :placeholder="`请填写${field.label}`"
          maxlength="2000"
          show-word-limit
        />
        <el-input-number
          v-else-if="field.type === 'number'"
          v-model="formPayload[field.key]"
          :min="field.min ?? 0"
          :precision="field.precision ?? 0"
          controls-position="right"
          style="width: 100%"
        />
        <el-date-picker
          v-else-if="field.type === 'date'"
          v-model="formPayload[field.key]"
          type="date"
          :placeholder="`请选择${field.label}`"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 100%"
        />
        <el-date-picker
          v-else-if="field.type === 'datetimerange'"
          v-model="formPayload[field.key]"
          type="datetimerange"
          range-separator="至"
          start-placeholder="开始时间"
          end-placeholder="结束时间"
          format="YYYY-MM-DD HH:mm:ss"
          value-format="YYYY-MM-DD HH:mm:ss"
          style="width: 100%"
        />
        <el-select
          v-else-if="field.type === 'select'"
          v-model="formPayload[field.key]"
          :placeholder="`请选择${field.label}`"
          style="width: 100%"
          clearable
          filterable
        >
          <el-option
            v-for="o in getOptions(field)"
            :key="String(o.value)"
            :label="o.label"
            :value="o.value"
          />
        </el-select>
        <span v-else class="field-value">{{ displayValue(field) }}</span>
      </el-form-item>
    </div>
  </template>
  <el-empty v-else description="暂无填报项" :image-size="48" />
</template>
<script setup>
import { onMounted, watch } from "vue";
import { useSelectOptionSources } from "../../approve-template/useSelectOptionSources.js";
import { formatFieldDisplayValue } from "../approveListConstants.js";
const props = defineProps({
  fields: { type: Array, default: () => [] },
  formPayload: { type: Object, default: () => ({}) },
  readonly: { type: Boolean, default: false },
});
const { loading: optionSourceLoading, ensureForFields, getOptions, getDisplayLabel } =
  useSelectOptionSources();
async function loadOptionCaches() {
  await ensureForFields(props.fields);
}
onMounted(() => {
  loadOptionCaches();
});
watch(
  () => props.fields,
  () => {
    loadOptionCaches();
  },
  { deep: true }
);
function displayValue(field) {
  const val = props.formPayload?.[field.key];
  if (field.type === "select" && field.optionSource && field.optionSource !== "static") {
    return getDisplayLabel(field, val);
  }
  return formatFieldDisplayValue(field, val);
}
</script>
<style scoped>
.form-payload-desc {
  width: 100%;
}
.form-payload-desc :deep(.el-descriptions__label) {
  width: 120px;
  font-weight: 500;
}
.field-value {
  color: var(--el-text-color-primary);
  line-height: 1.6;
  word-break: break-word;
}
.form-payload-edit {
  width: 100%;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,147 @@
<!-- å®¡æ‰¹å®žä¾‹ï¼štasks å®¡æ‰¹æµç¨‹å±•示(横向步骤条) -->
<template>
  <div v-if="displayNodes.length" class="flow-track">
    <div
      v-for="(node, index) in displayNodes"
      :key="index"
      class="flow-step"
      :class="{ 'is-last': index === displayNodes.length - 1 }"
    >
      <div class="flow-step-card">
        <div class="flow-step-badge">{{ index + 1 }}</div>
        <div class="flow-step-main">
          <div class="flow-step-head">
            <span class="flow-step-name">节点 {{ index + 1 }}</span>
            <el-tag size="small" :type="node.signMode === 'or_sign' ? 'warning' : 'primary'" effect="plain">
              {{ nodeSignModeLabel(node.signMode) }}
            </el-tag>
          </div>
          <div class="flow-approvers">
            <div
              v-for="a in node.approvers"
              :key="String(a.approverId ?? a.id)"
              class="flow-approver"
            >
              <span class="flow-approver-name">{{ a.approverName || "—" }}</span>
              <el-tag
                v-if="a.status"
                size="small"
                :type="mapTaskStatusTagType(a.status)"
                effect="plain"
              >
                {{ mapTaskStatusLabel(a.status) }}
              </el-tag>
            </div>
            <span v-if="!node.approvers?.length" class="flow-empty">未配置审批人</span>
          </div>
        </div>
      </div>
      <div v-if="index < displayNodes.length - 1" class="flow-connector" aria-hidden="true">
        <el-icon><ArrowRight /></el-icon>
      </div>
    </div>
  </div>
  <el-empty v-else description="暂无流程节点" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import { ArrowRight } from "@element-plus/icons-vue";
import { nodeSignModeLabel } from "../../approve-template/approveTemplateConstants.js";
import {
  mapTaskStatusLabel,
  mapTaskStatusTagType,
  mapTasksToFlowNodes,
} from "../approveListConstants.js";
const props = defineProps({
  tasks: { type: Array, default: () => [] },
  nodes: { type: Array, default: () => [] },
});
const displayNodes = computed(() => {
  if (props.tasks?.length) return mapTasksToFlowNodes(props.tasks);
  return props.nodes || [];
});
</script>
<style scoped>
.flow-track {
  display: flex;
  align-items: stretch;
  gap: 0;
  overflow-x: auto;
  padding: 4px 2px 8px;
}
.flow-step {
  display: flex;
  align-items: center;
  flex: 0 0 auto;
}
.flow-step-card {
  display: flex;
  gap: 12px;
  min-width: 200px;
  max-width: 260px;
  padding: 14px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 8px;
  background: var(--el-bg-color);
  box-shadow: 0 1px 2px rgba(0, 0, 0, 0.04);
}
.flow-step-badge {
  flex-shrink: 0;
  width: 28px;
  height: 28px;
  border-radius: 50%;
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
  font-size: 13px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
}
.flow-step-main {
  flex: 1;
  min-width: 0;
}
.flow-step-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin-bottom: 10px;
}
.flow-step-name {
  font-weight: 600;
  font-size: 13px;
  color: var(--el-text-color-primary);
}
.flow-approvers {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.flow-approver {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px;
}
.flow-approver-name {
  font-size: 13px;
  color: var(--el-text-color-regular);
}
.flow-empty {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
}
.flow-connector {
  display: flex;
  align-items: center;
  padding: 0 6px;
  color: var(--el-text-color-placeholder);
  font-size: 16px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,613 @@
<!--OA模块:审批列表-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">模板类型:</span>
        <el-select
          v-model="searchForm.businessType"
          placeholder="请选择模板类型"
          clearable
          filterable
          style="width: 200px"
        >
          <el-option
            v-for="opt in searchBusinessTypeOptions"
            :key="`search-biz-type-${opt.value}`"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">审批状态:</span>
        <el-select
          v-model="searchForm.status"
          placeholder="请选择审批状态"
          clearable
          style="width: 140px"
        >
          <el-option
            v-for="opt in APPROVAL_STATUS_SEARCH_OPTIONS"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">创建时间:</span>
        <el-date-picker
          v-model="searchForm.createTimeRange"
          type="daterange"
          range-separator="-"
          start-placeholder="开始日期"
          end-placeholder="结束日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 260px"
          clearable
        />
        <el-button type="primary" :icon="Search" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button :icon="RefreshRight" @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" :icon="Plus" @click="openSubmitDialog">提交审批</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      >
        <template #approveType="{ row }">
          <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
            {{ approvalTypeLabel(row.approvalType) }}
          </span>
        </template>
      </PIMTable>
    </div>
    <!-- æäº¤å®¡æ‰¹ï¼ˆæŒ‰æ¨¡æ¿ï¼‰ -->
    <el-dialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      width="720px"
      append-to-body
      destroy-on-close
      class="approve-submit-dialog"
      @closed="resetSubmitDialogState"
    >
      <template v-if="submitDialog.step === 1 && !isSubmitEdit">
        <p class="template-hint">请先选择模板类型,再选择该类型下已启用的审批模板。</p>
        <div v-loading="submitTemplatesLoading" class="template-grid">
          <div
            v-for="opt in submitBusinessTypeOptions"
            :key="`biz-type-${opt.value}`"
            class="template-card"
            :class="{ 'is-disabled': !countTemplatesByBusinessType(opt.value) }"
            @click="onBusinessTypePick(opt.value)"
          >
            <span class="template-card-type">{{ opt.label }}</span>
            <span class="template-card-desc">
              {{ countTemplatesByBusinessType(opt.value) }} ä¸ªå¯ç”¨æ¨¡æ¿
            </span>
          </div>
          <el-empty
            v-if="!submitTemplatesLoading && !submitBusinessTypeOptions.length"
            description="暂无模板类型"
            :image-size="80"
            class="template-empty"
          />
        </div>
      </template>
      <template v-else-if="submitDialog.step === 2 && !isSubmitEdit">
        <p class="template-hint">
          å½“前类型:{{ selectedBusinessTypeLabel || "—" }},请选择具体审批模板。
          <el-button type="primary" link class="ml8" @click="backToBusinessTypePick">更换类型</el-button>
        </p>
        <ApprovalTemplatePicker
          :cards="submitTemplateCards"
          :loading="submitTemplatesLoading"
          @pick="onTemplatePick"
        />
      </template>
      <template v-else>
        <div v-loading="submitTemplatesLoading && !isSubmitEdit">
        <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
          <el-form-item v-if="isSubmitEdit" label="审批类型">
            <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
              {{ activeTemplate.label }}
            </span>
          </el-form-item>
          <ApprovalTemplateFormSection
            :active-template="activeTemplate"
            :fields="submitFormFields"
            :form-payload="submitForm.formPayload"
            v-model:flow-nodes="submitForm.flowNodes"
            v-model:attachments="submitForm.storageBlobDTOs"
            :template-attachments="submitForm.templateAttachments"
            :user-options="flowUserOptions"
            :show-template-name="!isSubmitEdit"
            :allow-change-template="!isSubmitEdit"
            @change-template="backToTemplatePick"
          />
        </el-form>
        </div>
      </template>
      <template #footer>
        <el-button
          v-if="submitDialog.step === 3 || isSubmitEdit"
          type="primary"
          :loading="submitSaving"
          @click="onSubmitInstance"
        >
          {{ isSubmitEdit ? "保 å­˜" : "提 äº¤" }}
        </el-button>
        <el-button
          v-if="submitDialog.step === 2 && !isSubmitEdit"
          @click="backToBusinessTypePick"
        >
          ä¸Šä¸€æ­¥
        </el-button>
        <el-button @click="submitDialog.visible = false">
          {{ submitDialog.step === 1 && !isSubmitEdit ? "取 æ¶ˆ" : "关 é—­" }}
        </el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog
      v-model="detailDialog.visible"
      title="审批详情"
      width="920px"
      append-to-body
      destroy-on-close
      class="approve-detail-dialog"
    >
      <div class="approve-detail-body">
        <ApproveDetailPanel :row="detailRow" />
        <div class="detail-block">
          <div class="detail-block-title">
            å®¡æ‰¹æµç¨‹ï¼ˆ{{ detailRow.tasks?.length || detailRow.flowNodes?.length || 0 }} é¡¹ï¼‰
          </div>
          <InstanceFlowDisplay :tasks="detailRow.tasks" :nodes="detailRow.flowNodes" />
        </div>
        <div class="detail-block">
          <div class="detail-block-title">审批记录</div>
          <el-timeline v-if="detailRow.approvalRecords?.length" class="approve-record-timeline">
            <el-timeline-item
              v-for="(rec, i) in detailRow.approvalRecords"
              :key="rec.id ?? i"
              :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
              :timestamp="formatRecordTime(rec.time)"
              placement="top"
            >
              <div class="record-item">
                <span class="record-operator">{{ rec.operatorName || "—" }}</span>
                <el-tag
                  size="small"
                  :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'info'"
                  effect="plain"
                >
                  {{ approvalActionLabel(rec.result) }}
                </el-tag>
                <p class="record-opinion">{{ rec.opinion || "无意见" }}</p>
              </div>
            </el-timeline-item>
          </el-timeline>
          <el-empty v-else description="暂无审批记录" :image-size="48" />
        </div>
      </div>
      <template #footer>
        <el-button
          v-if="detailRow.approvalStatus === 'pending'"
          @click="openEditFromDetail"
        >
          ä¿® æ”¹
        </el-button>
        <el-button
          v-if="detailRow.approvalStatus === 'pending' && detailRow.isApprove"
          type="primary"
          @click="openApproveFromDetail"
        >
          åŽ»å®¡æ‰¹
        </el-button>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å·®æ—…/费用报销详情(审批列表) -->
    <el-dialog
      v-model="reimburseDialog.visible"
      :title="reimburseDialog.mode === 'approve' ? reimburseApproveTitle : reimburseDetailTitle"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <FinReimburseApprovePanel
        :mode="reimburseDialog.mode"
        :module-key="reimburseDialog.moduleKey"
        :reimburse-row="reimburseDialog.reimburseRow"
        :loading="reimburseDialog.loading"
        v-model:approve-opinion="approveOpinion"
      />
      <template #footer>
        <template v-if="reimburseDialog.mode === 'approve'">
          <el-button
            type="success"
            :loading="approveSubmitting"
            @click="onReimburseApprove('approved')"
          >
            é€š è¿‡
          </el-button>
          <el-button
            type="danger"
            :loading="approveSubmitting"
            @click="onReimburseApprove('rejected')"
          >
            é©³ å›ž
          </el-button>
          <el-button :disabled="approveSubmitting" @click="reimburseDialog.visible = false">
            å– æ¶ˆ
          </el-button>
        </template>
        <template v-else>
          <el-button
            v-if="reimburseDialog.instanceRow?.approvalStatus === 'pending'"
            @click="openEditFromReimburseDetail"
          >
            ä¿® æ”¹
          </el-button>
          <el-button
            v-if="
              reimburseDialog.instanceRow?.approvalStatus === 'pending' &&
              reimburseDialog.instanceRow?.isApprove
            "
            type="primary"
            @click="openReimburseApproveFromDetail"
          >
            åŽ»å®¡æ‰¹
          </el-button>
          <el-button type="primary" @click="reimburseDialog.visible = false">关 é—­</el-button>
        </template>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹æ“ä½œ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="审批处理"
      width="960px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <ApproveDetailPanel :row="approveDialog.row" />
      <div class="detail-block mt16">
        <div class="detail-block-title">
          å®¡æ‰¹æµç¨‹ï¼ˆ{{ approveDialog.row?.tasks?.length || approveDialog.row?.flowNodes?.length || 0 }} é¡¹ï¼‰
        </div>
        <InstanceFlowDisplay :tasks="approveDialog.row?.tasks" :nodes="approveDialog.row?.flowNodes" />
      </div>
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见" required>
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写具体原因"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button
          type="success"
          :loading="approveSubmitting"
          @click="onApprove('approved')"
        >
          é€š è¿‡
        </el-button>
        <el-button
          type="danger"
          :loading="approveSubmitting"
          @click="onApprove('rejected')"
        >
          é©³ å›ž
        </el-button>
        <el-button :disabled="approveSubmitting" @click="approveDialog.visible = false">
          å– æ¶ˆ
        </el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, ref } from "vue";
import { APPROVAL_MODULE_KEYS } from "../approve-shared/approvalModuleRegistry.js";
import FinReimburseApprovePanel from "../../ReimburseManage/shared/components/FinReimburseApprovePanel.vue";
import ApprovalTemplateFormSection from "../approve-shared/components/ApprovalTemplateFormSection.vue";
import ApprovalTemplatePicker from "../approve-shared/components/ApprovalTemplatePicker.vue";
import { useFlowUserOptions } from "../approve-shared/useFlowUserOptions.js";
import { formatDisplayTime } from "../approve-template/approveTemplateConstants.js";
import { approvalTypeStyle } from "./approveListConstants.js";
import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
import InstanceFlowDisplay from "./components/InstanceFlowDisplay.vue";
import { useApproveList } from "./useApproveList.js";
const al = useApproveList();
const {
  Search,
  APPROVAL_STATUS_SEARCH_OPTIONS,
  searchBusinessTypeOptions,
  loadSearchBusinessTypeOptions,
  submitBusinessTypeOptions,
  submitTemplateCards,
  selectedBusinessTypeLabel,
  countTemplatesByBusinessType,
  submitTemplatesLoading,
  onBusinessTypePick,
  backToBusinessTypePick,
  approvalTypeLabel,
  approvalActionLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  detailDialog,
  detailRow,
  reimburseDialog,
  approveDialog,
  approveOpinion,
  approveSubmitting,
  submitReimburseApprove,
  submitDialog,
  isSubmitEdit,
  submitDialogTitle,
  submitForm,
  submitFormRef,
  submitSaving,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  handleQuery,
  resetSearch,
  pagination,
  resetSubmitDialogState,
  openSubmitDialog,
  openEditDialog,
  onTemplatePick,
  backToTemplatePick,
  submitInstanceForm,
  submitApprove,
  openDetail,
  openApprove,
} = al;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
async function onSubmitInstance() {
  const ok = await submitInstanceForm();
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "审批已提交");
}
const reimburseDetailTitle = computed(() =>
  reimburseDialog.moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
    ? "费用报销详情"
    : "差旅报销详情"
);
const reimburseApproveTitle = computed(() =>
  reimburseDialog.moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
    ? "费用报销审批"
    : "差旅报销审批"
);
async function onApprove(result) {
  const ret = await submitApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
  }
  if (ret?.ok) {
    ElMessage.success(result === "approved" ? "已通过" : "已驳回");
  }
}
async function onReimburseApprove(result) {
  const ret = await submitReimburseApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
  }
  if (ret?.ok) {
    ElMessage.success(result === "approved" ? "已通过" : "已驳回");
  }
}
function formatRecordTime(time) {
  return formatDisplayTime(time) || "—";
}
async function openApproveFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  await openApprove(row);
}
function openEditFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openEditDialog(row);
}
function openEditFromReimburseDetail() {
  const row = reimburseDialog.instanceRow;
  reimburseDialog.visible = false;
  if (row) openEditDialog(row);
}
async function openReimburseApproveFromDetail() {
  const row = reimburseDialog.instanceRow;
  if (!row) return;
  reimburseDialog.mode = "approve";
  approveOpinion.value = "";
}
onMounted(() => {
  loadFlowUsers();
  loadSearchBusinessTypeOptions();
  handleQuery();
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.ml12 {
  margin-left: 12px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.template-hint {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  margin: 0 0 16px;
}
.template-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
  min-height: 120px;
}
.template-empty {
  grid-column: 1 / -1;
}
.template-card {
  padding: 14px 16px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: var(--radius-md, 8px);
  cursor: pointer;
  transition: border-color 0.2s, box-shadow 0.2s;
  background: var(--el-fill-color-blank);
}
.template-card:hover {
  border-color: var(--el-color-primary);
  box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
}
.template-card.is-disabled {
  opacity: 0.5;
  cursor: not-allowed;
}
.template-card.is-disabled:hover {
  border-color: var(--el-border-color-lighter);
  box-shadow: none;
}
.ml8 {
  margin-left: 8px;
}
.template-card-type {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 8px;
}
.template-card-desc {
  display: block;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.approve-submit-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.approve-detail-dialog :deep(.el-dialog__body) {
  padding-top: 16px;
  max-height: 70vh;
  overflow-y: auto;
}
.approve-detail-body .detail-block {
  margin-top: 20px;
}
.approve-detail-body .detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
.approve-record-timeline {
  padding-left: 4px;
}
.record-item {
  padding: 4px 0 2px;
}
.record-operator {
  font-weight: 600;
  margin-right: 8px;
  color: var(--el-text-color-primary);
}
.record-opinion {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--el-text-color-regular);
  line-height: 1.5;
}
.detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,628 @@
import {
  getApprovalTemplateDetail,
  listApprovalTemplate,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import {
  approveApprovalInstance,
  deleteApprovalInstance,
  listApprovalInstancePage,
  saveApprovalInstance,
  updateApprovalInstance,
} from "@/api/officeProcessAutomation/approvalInstance.js";
import useUserStore from "@/store/modules/user";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, getCurrentInstance, reactive, ref } from "vue";
import {
  inferReimburseModuleKeyFromInstance,
  loadReimburseDetailForInstance,
  navigateToReimburseManageForEdit,
  resolveFinReimbursementIdFromInstance,
} from "../../ReimburseManage/shared/reimburseApproveBridge.js";
import {
  fetchBusinessTypeOptions,
  formatDisplayTime,
  mapEnabledFromApi,
  unwrapTemplateList,
} from "../approve-template/approveTemplateConstants.js";
import {
  buildFormPayloadRules,
  buildTemplateBindingFromDetail,
  validateTemplateBinding,
} from "../approve-shared/approvalTemplateBindingUtils.js";
import {
  APPROVAL_STATUS_SEARCH_OPTIONS,
  APPROVAL_TYPE_OPTIONS,
  approvalStatusLabel,
  approvalStatusTagType,
  approvalTypeLabel,
  buildApprovalInstanceListParams,
  buildApproveInstanceDto,
  buildEditFormFromInstanceRow,
  buildInstanceDto,
  createEmptySubmitForm,
  mapInstanceFromApi,
  mapSubmitTemplateCard,
  matchBusinessTypeValue,
  unwrapInstancePage,
} from "./approveListConstants.js";
export function useApproveList() {
  const { proxy } = getCurrentInstance() || {};
  const userStore = useUserStore();
  const tableData = ref([]);
  const searchBusinessTypeOptions = ref([]);
  const submitBusinessTypeOptions = ref([]);
  const allSubmitTemplates = ref([]);
  const selectedBusinessType = ref("");
  const submitTemplatesLoading = ref(false);
  const submitTemplateCards = computed(() => {
    if (selectedBusinessType.value == null || selectedBusinessType.value === "") return [];
    return allSubmitTemplates.value.filter((card) =>
      matchBusinessTypeValue(card.businessType, selectedBusinessType.value)
    );
  });
  const searchForm = reactive({
    businessType: "",
    status: "",
    createTimeRange: [],
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const approveSubmitting = ref(false);
  /** å·®æ—…/费用报销专用详情、审批弹窗 */
  const reimburseDialog = reactive({
    visible: false,
    mode: "detail",
    moduleKey: "",
    loading: false,
    reimburseRow: {},
    instanceRow: null,
  });
  const submitDialog = reactive({ visible: false, step: 1, mode: "add" });
  const submitEditRow = ref(null);
  const submitForm = reactive(createEmptySubmitForm(""));
  const submitFormRef = ref();
  const submitSaving = ref(false);
  const isSubmitEdit = computed(() => submitDialog.mode === "edit");
  const submitDialogTitle = computed(() => {
    if (submitDialog.mode === "edit") {
      return `修改${activeTemplate.value?.label || submitForm.templateName || "审批"}`;
    }
    if (submitDialog.step === 1) return "选择模板类型";
    if (submitDialog.step === 2) return `选择审批模板${businessTypeLabel(selectedBusinessType.value) ? `(${businessTypeLabel(selectedBusinessType.value)})` : ""}`;
    return `提交${activeTemplate.value?.label || "审批"}`;
  });
  const selectedBusinessTypeLabel = computed(() => businessTypeLabel(selectedBusinessType.value));
  function businessTypeLabel(type) {
    if (type == null || type === "") return "";
    const hit = submitBusinessTypeOptions.value.find((x) => matchBusinessTypeValue(x.value, type));
    return hit?.label || "";
  }
  function countTemplatesByBusinessType(type) {
    return allSubmitTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type)).length;
  }
  const activeTemplate = computed(() => submitForm.templateSnapshot || null);
  /** å¡«æŠ¥é¡¹å®šä¹‰ï¼ˆæ–°å¢ž/修改与 formConfig ä¸€è‡´ï¼‰ */
  const submitFormFields = computed(() => {
    const tplFields = activeTemplate.value?.fields;
    if (tplFields?.length) return tplFields;
    return submitForm.formFieldDefs || [];
  });
  const submitFormRules = computed(() => ({
    templateKey: [{ required: true, message: "请选择审批类型", trigger: "change" }],
    ...buildFormPayloadRules(submitFormFields.value),
  }));
  const tableColumn = ref([
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人名称", prop: "applicantName", minWidth: 100 },
    { label: "模板类型", prop: "businessName", minWidth: 120 },
    {
      label: "审批类型",
      prop: "approvalType",
      minWidth: 140,
      dataType: "slot",
      slot: "approveType",
    },
    {
      label: "待我审批",
      prop: "unread",
      width: 90,
      align: "center",
      formatData: (v) => (v ? "是" : "否"),
    },
    {
      label: "审批状态",
      prop: "approvalStatus",
      width: 100,
      dataType: "tag",
      formatData: (v) => approvalStatusLabel(v),
      formatType: (v) => approvalStatusTagType(v),
    },
    {
      label: "创建时间",
      prop: "createTime",
      width: 170,
      formatData: (v) => formatDisplayTime(v),
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 240,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "修改",
          type: "text",
          disabled: (row) => row.approvalStatus !== "pending",
          clickFun: (row) => openEditDialog(row),
        },
        {
          name: "审批",
          type: "text",
          disabled: (row) => row.approvalStatus !== "pending" || !row.isApprove,
          clickFun: (row) => openApprove(row),
        },
        {
          name: "删除",
          type: "danger",
          clickFun: (row) => removeInstance(row),
        },
      ],
    },
  ]);
  async function fetchApprovalList() {
    tableLoading.value = true;
    try {
      const res = await listApprovalInstancePage(
        buildApprovalInstanceListParams({ page, searchForm })
      );
      const { records, total } = unwrapInstancePage(res);
      tableData.value = records.map(mapInstanceFromApi);
      page.total = total;
    } catch {
      tableData.value = [];
      page.total = 0;
      ElMessage.error("审批列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  async function loadSubmitTemplates() {
    submitTemplatesLoading.value = true;
    try {
      const [typeOptions, customRes] = await Promise.all([
        fetchBusinessTypeOptions(),
        listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      ]);
      submitBusinessTypeOptions.value = typeOptions;
      allSubmitTemplates.value = unwrapTemplateList(customRes)
        .filter((row) => mapEnabledFromApi(row.enabled))
        .map(mapSubmitTemplateCard);
    } catch {
      submitBusinessTypeOptions.value = [];
      allSubmitTemplates.value = [];
      ElMessage.error("加载审批模板失败");
    } finally {
      submitTemplatesLoading.value = false;
    }
  }
  function handleQuery() {
    page.current = 1;
    fetchApprovalList();
  }
  function resetSearch() {
    searchForm.businessType = "";
    searchForm.status = "";
    searchForm.createTimeRange = [];
    handleQuery();
  }
  async function loadSearchBusinessTypeOptions() {
    try {
      searchBusinessTypeOptions.value = await fetchBusinessTypeOptions();
    } catch {
      searchBusinessTypeOptions.value = [];
    }
  }
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
    fetchApprovalList();
  }
  async function openReimburseDetail(row, mode) {
    const moduleKey = inferReimburseModuleKeyFromInstance(row);
    if (!moduleKey) return false;
    reimburseDialog.mode = mode;
    reimburseDialog.moduleKey = moduleKey;
    reimburseDialog.instanceRow = row;
    reimburseDialog.visible = true;
    reimburseDialog.loading = true;
    reimburseDialog.reimburseRow = {};
    try {
      const { reimburseRow, moduleKey: resolvedMk } =
        await loadReimburseDetailForInstance(row, moduleKey);
      reimburseDialog.moduleKey = resolvedMk || moduleKey;
      reimburseDialog.reimburseRow = reimburseRow;
      return true;
    } catch {
      ElMessage.error("加载报销详情失败");
      reimburseDialog.visible = false;
      return false;
    } finally {
      reimburseDialog.loading = false;
    }
  }
  async function openDetail(row) {
    if (isReimburseApprovalInstance(row)) {
      await openReimburseDetail(row, "detail");
      return;
    }
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  async function openApprove(row) {
    if (inferReimburseModuleKeyFromInstance(row)) {
      approveOpinion.value = "";
      await openReimburseDetail(row, "approve");
      return;
    }
    approveDialog.row = { ...row };
    approveOpinion.value = "";
    approveDialog.visible = true;
  }
  function isReimburseApprovalInstance(row) {
    return Boolean(inferReimburseModuleKeyFromInstance(row));
  }
  function resetSubmitDialogState() {
    submitDialog.mode = "add";
    submitDialog.step = 1;
    selectedBusinessType.value = "";
    submitEditRow.value = null;
    Object.assign(submitForm, createEmptySubmitForm(""));
  }
  function openSubmitDialog() {
    resetSubmitDialogState();
    submitDialog.visible = true;
    loadSubmitTemplates();
  }
  async function openEditDialog(row) {
    if (row?.approvalStatus !== "pending") {
      ElMessage.warning("仅审核中的审批可修改");
      return;
    }
    const moduleKey = inferReimburseModuleKeyFromInstance(row);
    if (moduleKey) {
      const rid = resolveFinReimbursementIdFromInstance(row);
      if (rid == null) {
        ElMessage.warning("无法修改:缺少报销单 ID");
        return;
      }
      try {
        await navigateToReimburseManageForEdit(proxy?.$router, moduleKey, rid);
      } catch {
        ElMessage.warning("未找到差旅/费用报销菜单路由,请从左侧菜单进入后再编辑");
      }
      return;
    }
    if (!row?.id) {
      ElMessage.warning("无法修改:缺少审批实例 ID");
      return;
    }
    submitDialog.mode = "edit";
    submitDialog.step = 3;
    submitEditRow.value = { ...row };
    Object.assign(submitForm, buildEditFormFromInstanceRow(row));
    submitDialog.visible = true;
  }
  async function onTemplatePick(card) {
    if (!card?.id) return;
    submitTemplatesLoading.value = true;
    try {
      const res = await getApprovalTemplateDetail(card.id);
      const applied = buildTemplateBindingFromDetail(res);
      Object.assign(submitForm, {
        templateKey: String(card.id),
        ...applied,
        businessType:
          applied.businessType ?? card.businessType ?? selectedBusinessType.value,
      });
      submitDialog.step = 3;
    } catch {
      ElMessage.error("加载模板详情失败");
    } finally {
      submitTemplatesLoading.value = false;
    }
  }
  function onBusinessTypePick(type) {
    if (!countTemplatesByBusinessType(type)) {
      ElMessage.warning("该类型下暂无可用审批模板");
      return;
    }
    selectedBusinessType.value = type;
    submitDialog.step = 2;
  }
  function backToBusinessTypePick() {
    selectedBusinessType.value = "";
    submitDialog.step = 1;
  }
  function backToTemplatePick() {
    submitDialog.step = 2;
  }
  async function submitInstanceForm() {
    if (submitDialog.mode === "edit") return submitEditApproval();
    return submitNewApproval();
  }
  async function submitNewApproval() {
    if (!submitFormRef.value) return false;
    try {
      await submitFormRef.value.validate();
    } catch {
      return false;
    }
    if (!activeTemplate.value) return false;
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      return false;
    }
    if (!submitForm.templateId) {
      ElMessage.warning("缺少模板 ID,无法提交");
      return false;
    }
    if (submitSaving.value) return false;
    submitSaving.value = true;
    try {
      await saveApprovalInstance(
        buildInstanceDto({
          submitForm,
          activeTemplate: activeTemplate.value,
          userStore,
          flowNodes: bindingCheck.nodes,
        })
      );
      submitDialog.visible = false;
      page.current = 1;
      await fetchApprovalList();
      return true;
    } catch {
      return false;
    } finally {
      submitSaving.value = false;
    }
  }
  async function submitEditApproval() {
    if (!submitFormRef.value) return false;
    try {
      await submitFormRef.value.validate();
    } catch {
      return false;
    }
    if (!activeTemplate.value) return false;
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      return false;
    }
    if (!submitForm.instanceId) {
      ElMessage.warning("缺少审批实例 ID,无法保存");
      return false;
    }
    if (submitSaving.value) return false;
    submitSaving.value = true;
    try {
      await updateApprovalInstance(
        buildInstanceDto({
          submitForm,
          activeTemplate: activeTemplate.value,
          flowNodes: bindingCheck.nodes,
          existingRow: submitEditRow.value,
        })
      );
      submitDialog.visible = false;
      await fetchApprovalList();
      if (detailDialog.visible && detailRow.value?.id === submitForm.instanceId) {
        const hit = tableData.value.find((r) => r.id === submitForm.instanceId);
        if (hit) detailRow.value = { ...hit };
        else detailDialog.visible = false;
      }
      return true;
    } catch {
      return false;
    } finally {
      submitSaving.value = false;
    }
  }
  async function removeInstance(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少审批实例 ID");
      return;
    }
    const title = row.title || row.templateName || row.instanceNo || "该审批";
    try {
      await ElMessageBox.confirm(
        `确定要删除审批「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteApprovalInstance([row.id]);
      ElMessage.success("删除成功");
      if (detailDialog.visible && detailRow.value?.id === row.id) {
        detailDialog.visible = false;
      }
      if (approveDialog.visible && approveDialog.row?.id === row.id) {
        approveDialog.visible = false;
      }
      await fetchApprovalList();
    } catch {
      /* é”™è¯¯ç”±æ‹¦æˆªå™¨æç¤º */
    }
  }
  async function submitReimburseApprove(result) {
    const row = reimburseDialog.instanceRow;
    if (!row?.id) return { ok: false };
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      return { needOpinion: true };
    }
    if (approveSubmitting.value) return { ok: false };
    approveSubmitting.value = true;
    try {
      await approveApprovalInstance(
        buildApproveInstanceDto(row, result, approveOpinion.value)
      );
      reimburseDialog.visible = false;
      await fetchApprovalList();
      return { ok: true, result };
    } catch {
      ElMessage.error("审批操作失败");
      return { ok: false };
    } finally {
      approveSubmitting.value = false;
    }
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row?.id) return { ok: false };
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      return { needOpinion: true };
    }
    if (approveSubmitting.value) return { ok: false };
    approveSubmitting.value = true;
    try {
      await approveApprovalInstance(
        buildApproveInstanceDto(row, result, approveOpinion.value)
      );
      approveDialog.visible = false;
      await fetchApprovalList();
      if (detailDialog.visible && detailRow.value?.id === row.id) {
        const hit = tableData.value.find((r) => r.id === row.id);
        if (hit) detailRow.value = { ...hit };
        else detailDialog.visible = false;
      }
      return { ok: true, result };
    } catch {
      ElMessage.error("审批操作失败");
      return { ok: false };
    } finally {
      approveSubmitting.value = false;
    }
  }
  function approvalActionLabel(result) {
    if (result === "approved") return "通过";
    if (result === "rejected") return "驳回";
    return "待处理";
  }
  return {
    Search,
    APPROVAL_TYPE_OPTIONS,
    APPROVAL_STATUS_SEARCH_OPTIONS,
    searchBusinessTypeOptions,
    loadSearchBusinessTypeOptions,
    approvalTypeLabel,
    approvalStatusLabel,
    approvalStatusTagType,
    approvalActionLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    detailDialog,
    detailRow,
    reimburseDialog,
    approveDialog,
    approveOpinion,
    approveSubmitting,
    submitReimburseApprove,
    isReimburseApprovalInstance,
    submitDialog,
    isSubmitEdit,
    submitDialogTitle,
    submitForm,
    submitFormRef,
    submitSaving,
    activeTemplate,
    submitFormFields,
    submitFormRules,
    submitBusinessTypeOptions,
    submitTemplateCards,
    selectedBusinessType,
    selectedBusinessTypeLabel,
    businessTypeLabel,
    countTemplatesByBusinessType,
    submitTemplatesLoading,
    handleQuery,
    resetSearch,
    pagination,
    resetSubmitDialogState,
    openSubmitDialog,
    openEditDialog,
    onBusinessTypePick,
    onTemplatePick,
    backToBusinessTypePick,
    backToTemplatePick,
    submitInstanceForm,
    submitNewApproval,
    submitApprove,
    openDetail,
    openApprove,
    fetchApprovalList,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,148 @@
import { computed } from "vue";
import {
  businessApprovalStatusLabel,
  businessApprovalStatusTagType,
  formatFieldDisplayValue,
  resolveInstanceFormFields,
} from "../approve-list/approveListConstants.js";
import {
  INSTANCE_NO_SEARCH_MODULE_KEYS,
  INSTANCE_NO_TABLE_COLUMN,
} from "./approvalInstanceListSearch.js";
/** åˆ—表/详情不回显为独立列的填报项 key(避免覆盖实例系统字段) */
const DEFAULT_EXCLUDE_KEYS = new Set([
  "summary",
  "status",
  "approvalStatus",
  "approvalstatus",
  "instanceStatus",
  "publishStatus",
  "newsStatus",
]);
/** enrich åŽå¿…须保留的实例字段(不被 formConfig é“ºå¹³è¦†ç›–) */
const PRESERVE_INSTANCE_FIELDS = [
  "id",
  "approvalStatus",
  "statusRaw",
  "status",
  "instanceNo",
  "templateId",
  "templateName",
  "businessType",
  "businessId",
  "businessName",
  "applicantId",
  "applicantNo",
  "applicantName",
  "createTime",
  "applyTime",
  "finishTime",
  "title",
  "isApprove",
  "unread",
  "currentLevel",
  "newsStatus",
];
/**
 * ä»Žè¡Œæ•°æ® formConfig è§£æžå­—段定义与填报值,并铺平到行上供主表 prop ç»‘定(展示用格式化值)
 */
export function enrichInstanceRowFromFormConfig(row, caches) {
  const { fields, formPayload, templateSnapshot } = resolveInstanceFormFields(row);
  const formDisplay = {};
  const displayRow = {
    ...row,
    formFieldDefs: fields,
    formPayload,
    templateSnapshot: row.templateSnapshot || templateSnapshot,
    formDisplay,
  };
  for (const f of fields) {
    if (!f?.key || DEFAULT_EXCLUDE_KEYS.has(f.key)) continue;
    const val = formPayload[f.key];
    let text = formatFieldDisplayValue(f, val, caches);
    if (
      text === String(val) &&
      row?.applicantName &&
      (f.label === "申请人" || f.key === "applicant" || f.key === "applicantName")
    ) {
      const idMatch =
        String(val) === String(row.applicantId) ||
        String(val) === String(row.applicantNo);
      if (idMatch) text = row.applicantName;
    }
    formDisplay[f.key] = text;
    displayRow[f.key] = text;
  }
  for (const key of PRESERVE_INSTANCE_FIELDS) {
    if (row[key] !== undefined) displayRow[key] = row[key];
  }
  return displayRow;
}
/**
 * ä»Žåˆ—表首行 formConfig ç”Ÿæˆä¸»è¡¨åŠ¨æ€åˆ—ï¼ˆlabel å–自模板字段 label)
 */
export function getFormConfigFieldColumns(firstRow, { excludeKeys = DEFAULT_EXCLUDE_KEYS } = {}) {
  const fields = (firstRow?.formFieldDefs || []).filter(
    (f) => f?.key && !excludeKeys.has(f.key)
  );
  return fields.map((f) => ({
    label: f.label || f.key,
    prop: f.key,
    minWidth: f.type === "textarea" ? 200 : f.type === "datetimerange" ? 160 : 120,
    showOverflowTooltip: true,
  }));
}
/**
 * ä¸šåŠ¡ç”³è¯·ä¸»è¡¨åˆ—ï¼šå›ºå®šåˆ— + formConfig åŠ¨æ€åˆ— + å®¡æ‰¹çŠ¶æ€ + æ“ä½œ
 */
export function buildInstanceTableColumns(tableDataRef, buildTableActions, options = {}) {
  const {
    moduleKey,
    excludeKeys = DEFAULT_EXCLUDE_KEYS,
    beforeFormColumns = [],
    extraColumns = [],
    afterFormColumns = [],
    actionWidth = 260,
  } = options;
  const leadingCols =
    moduleKey && INSTANCE_NO_SEARCH_MODULE_KEYS.has(moduleKey)
      ? [INSTANCE_NO_TABLE_COLUMN]
      : [];
  return computed(() => {
    const formCols = getFormConfigFieldColumns(tableDataRef.value?.[0], { excludeKeys });
    return [
      ...leadingCols,
      ...beforeFormColumns,
      ...formCols,
      ...extraColumns,
      ...afterFormColumns,
      { label: "创建时间", prop: "createTime", width: 170 },
      {
        label: "审批状态",
        prop: "approvalStatus",
        width: 110,
        dataType: "tag",
        formatData: (v) => businessApprovalStatusLabel(v),
        formatType: (v) => businessApprovalStatusTagType(v),
      },
      {
        dataType: "action",
        label: "操作",
        align: "center",
        fixed: "right",
        width: actionWidth,
        operation: buildTableActions(),
      },
    ];
  });
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,136 @@
import { APPROVAL_MODULE_KEYS } from "./approvalModuleRegistry.js";
/** æ”¯æŒå®¡æ‰¹å•号查询/主表展示的审批申请模块 */
export const INSTANCE_NO_SEARCH_MODULE_KEYS = new Set([
  APPROVAL_MODULE_KEYS.REGULAR,
  APPROVAL_MODULE_KEYS.TRANSFER,
  APPROVAL_MODULE_KEYS.WORK_HANDOVER,
  APPROVAL_MODULE_KEYS.LEAVE,
  APPROVAL_MODULE_KEYS.OVERTIME,
]);
export const INSTANCE_NO_TABLE_COLUMN = {
  label: "审批单号",
  prop: "instanceNo",
  width: 170,
  showOverflowTooltip: true,
};
/** æ‰å¹³åŒ–为 Spring GET å¯ç»‘定的 query(approvalInstanceDto.xxx,勿用方括号) */
export function appendDotNotationQuery(target, prefix, fields) {
  if (!fields || typeof fields !== "object") return;
  for (const [key, value] of Object.entries(fields)) {
    if (value == null || value === "") continue;
    target[`${prefix}.${key}`] = value;
  }
}
function pickApplicantFromSearchForm(searchForm = {}) {
  const out = {};
  const sf = searchForm || {};
  const name = (sf.applicantName || "").trim();
  const kw = (sf.applicantKeyword || "").trim();
  const id = sf.applicantId;
  if (name) out.applicantName = name;
  if (kw) {
    out.applicantName = kw;
    if (/^\d+$/.test(kw)) out.applicantId = Number(kw);
  }
  if (id != null && id !== "") {
    out.applicantId = typeof id === "number" ? id : Number(id) || id;
  }
  return out;
}
function pickInstanceNoFromSearchForm(searchForm = {}) {
  const no = (searchForm?.instanceNo || "").trim();
  return no ? { instanceNo: no } : {};
}
/** ç»„装 approvalInstanceDto æŸ¥è¯¢ç‰‡æ®µï¼ˆç”³è¯·äºº + å®¡æ‰¹å•号) */
export function buildApprovalInstanceSearchDto(searchForm = {}, extraParams = {}) {
  const dto = {
    ...(extraParams && typeof extraParams === "object" ? extraParams : {}),
  };
  Object.assign(dto, pickApplicantFromSearchForm(searchForm));
  Object.assign(dto, pickInstanceNoFromSearchForm(searchForm));
  delete dto.createTime;
  delete dto.createTimeStart;
  delete dto.createTimeEnd;
  return dto;
}
function getRowPayloadValue(row, keys) {
  const keyList = Array.isArray(keys) ? keys : [keys];
  const payload = row?.formPayload || {};
  for (const k of keyList) {
    if (row?.[k] != null && row[k] !== "") return row[k];
    if (payload[k] != null && payload[k] !== "") return payload[k];
  }
  return "";
}
function matchApplicantKeyword(row, keyword) {
  const kw = (keyword || "").trim().toLowerCase();
  if (!kw) return true;
  const parts = [
    row?.applicantName,
    row?.applicantNo,
    row?.applicantId,
    getRowPayloadValue(row, ["applicant", "applicantName", "applicantId"]),
  ]
    .filter((v) => v != null && v !== "")
    .map((v) => String(v).toLowerCase());
  return parts.some((p) => p.includes(kw));
}
function matchApplicantId(row, applicantId) {
  if (applicantId == null || applicantId === "") return true;
  const id = String(applicantId);
  if (row?.applicantId != null && String(row.applicantId) === id) return true;
  const payloadApplicant = getRowPayloadValue(row, [
    "applicant",
    "applicantId",
    "applicantUserId",
  ]);
  return String(payloadApplicant) === id;
}
function matchSelectValue(row, keys, expected) {
  if (!expected) return true;
  const raw = getRowPayloadValue(row, keys);
  return String(raw) === String(expected);
}
function matchInstanceNo(row, instanceNo) {
  const kw = (instanceNo || "").trim().toLowerCase();
  if (!kw) return true;
  const parts = [row?.instanceNo, row?.bizId]
    .filter((v) => v != null && v !== "")
    .map((v) => String(v).toLowerCase());
  return parts.some((p) => p.includes(kw));
}
/** æ˜¯å¦å­˜åœ¨åˆ—表筛选条件(申请人 / å®¡æ‰¹å•号) */
export function hasActiveModuleSearch(moduleKey, searchForm = {}) {
  const sf = searchForm || {};
  if ((sf.instanceNo || "").trim()) return true;
  if ((sf.applicantKeyword || "").trim()) return true;
  if ((sf.applicantName || "").trim()) return true;
  return sf.applicantId != null && sf.applicantId !== "";
}
/** æŒ‰ç”³è¯·äººã€å®¡æ‰¹å•号做前端兜底筛选 */
export function filterInstanceRowsByModuleSearch(moduleKey, rows, searchForm = {}) {
  const sf = searchForm || {};
  const list = Array.isArray(rows) ? rows : [];
  if (!hasActiveModuleSearch(moduleKey, sf)) return list;
  return list.filter(
    (row) =>
      matchInstanceNo(row, sf.instanceNo) &&
      matchApplicantId(row, sf.applicantId) &&
      matchApplicantKeyword(row, sf.applicantKeyword || sf.applicantName)
  );
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,154 @@
import { matchBusinessTypeValue } from "../approve-list/approveListConstants.js";
/**
 * å„业务模块与审批模板类型的映射(配置化入口)
 * businessType ä¸ŽåŽç«¯ TypeEnums / listPage çº¦å®šä¸€è‡´ï¼ˆå†™æ­»æžšä¸¾å€¼ï¼‰
 */
export const APPROVAL_MODULE_KEYS = {
  REGULAR: "regular",
  TRANSFER: "transfer",
  RESIGN: "resign",
  WORK_HANDOVER: "work_handover",
  LEAVE: "leave",
  OVERTIME: "overtime",
  TRAVEL_REIMBURSE: "travel_reimburse",
  COST_REIMBURSE: "cost_reimburse",
  ENTERPRISE_NEWS: "enterprise_news",
};
/** å®¡æ‰¹å®žä¾‹ listPage / ä¿å­˜ ä½¿ç”¨çš„ businessType æžšä¸¾ */
export const APPROVAL_BUSINESS_TYPE = {
  [APPROVAL_MODULE_KEYS.REGULAR]: 10,
  [APPROVAL_MODULE_KEYS.TRANSFER]: 11,
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: 13,
  [APPROVAL_MODULE_KEYS.LEAVE]: 14,
  [APPROVAL_MODULE_KEYS.OVERTIME]: 15,
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: 16,
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: 17,
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: 18,
};
/** @type {Record<string, import('./approvalModuleRegistry.js').ApprovalModuleConfig>} */
export const APPROVAL_MODULE_REGISTRY = {
  [APPROVAL_MODULE_KEYS.REGULAR]: {
    label: "转正申请",
    approvalType: "regular",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.REGULAR],
    typeLabels: ["转正", "转正申请"],
  },
  [APPROVAL_MODULE_KEYS.TRANSFER]: {
    label: "调岗申请",
    approvalType: "transfer",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRANSFER],
    typeLabels: ["调岗", "调动", "调岗申请", "调动申请"],
  },
  [APPROVAL_MODULE_KEYS.RESIGN]: {
    label: "离职申请",
    approvalType: "resign",
    typeLabels: ["离职", "离职申请", "离职审批"],
  },
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
    label: "工作交接",
    approvalType: "work_handover",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.WORK_HANDOVER],
    typeLabels: ["工作交接", "交接", "工作交接审批"],
  },
  [APPROVAL_MODULE_KEYS.LEAVE]: {
    label: "请假申请",
    approvalType: "leave",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.LEAVE],
    typeLabels: ["请假", "请假申请", "请假审批"],
  },
  [APPROVAL_MODULE_KEYS.OVERTIME]: {
    label: "加班申请",
    approvalType: "overtime",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OVERTIME],
    typeLabels: ["加班", "加班申请", "加班审批"],
  },
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
    label: "差旅报销",
    approvalType: "travel_reimburse",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE],
    typeLabels: ["差旅", "差旅报销", "出差报销"],
  },
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: {
    label: "费用报销",
    approvalType: "cost_reimburse",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.COST_REIMBURSE],
    typeLabels: ["费用", "费用报销"],
  },
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: {
    label: "企业新闻",
    approvalType: "enterprise_news",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS],
    typeLabels: ["企业新闻", "新闻", "新闻发布"],
  },
};
/**
 * @typedef {object} ApprovalModuleConfig
 * @property {string} label
 * @property {string} [approvalType]
 * @property {string|number} [businessType]
 * @property {string[]} [typeLabels]
 */
export function getApprovalModuleConfig(moduleKey) {
  if (!moduleKey) return null;
  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
}
/** åˆ—表查询 businessType(优先配置枚举,不再回退 approvalType å­—符串) */
export function getModuleListBusinessType(moduleKey) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return "";
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  return APPROVAL_BUSINESS_TYPE[moduleKey] ?? "";
}
/** ä»Ž TypeEnums è§£æžæœ¬æ¨¡å— businessType;已配置枚举时直接返回 */
export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return null;
  const fixed = getModuleListBusinessType(moduleKey);
  if (fixed != null && fixed !== "") return fixed;
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  const hitByLabel = (typeOptions || []).find((opt) => {
    const optLabel = String(opt?.label || opt?.name || "").trim();
    if (!optLabel) return false;
    return labels.some(
      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
  });
  if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value;
  return cfg.approvalType || null;
}
/** åˆ—表/模板过滤用的 businessType é›†åˆ */
export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return [];
  const fixed = getModuleListBusinessType(moduleKey);
  if (fixed != null && fixed !== "") return [fixed];
  const values = new Set();
  const primary = resolveModuleBusinessType(moduleKey, typeOptions);
  if (primary != null && primary !== "") values.add(primary);
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  for (const opt of typeOptions || []) {
    const optLabel = String(opt?.label || opt?.name || "").trim();
    if (!optLabel) continue;
    const matched = labels.some(
      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
    if (matched && opt.value != null && opt.value !== "") {
      values.add(opt.value);
    }
  }
  return [...values];
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
import {
  mapAttachmentsFromApi,
  mapTemplateFromApi,
  unwrapTemplateDetail,
} from "../approve-template/approveTemplateConstants.js";
import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
import {
  createEmptySubmitForm,
  validateSubmitFlowNodes,
} from "../approve-list/approveListConstants.js";
export function attachmentDisplayName(file) {
  return (
    file?.fileName ||
    file?.originalFilename ||
    file?.name ||
    file?.blobName ||
    "附件"
  );
}
/** æŽ¥å£è¯¦æƒ… â†’ æäº¤ç»‘定快照(含流程、附件、填报项) */
export function buildTemplateBindingFromDetail(detailRow) {
  const mapped = mapTemplateFromApi(unwrapTemplateDetail(detailRow));
  const templateAttachments = mapAttachmentsFromApi(mapped);
  const tpl = {
    ...buildSubmitTemplateFromRow(mapped),
    templateId: mapped.id,
    businessType: mapped.businessType,
    storageBlobDTOs: templateAttachments,
  };
  const base = createEmptySubmitForm(String(mapped.id ?? ""), tpl, mapped.flowNodes);
  return {
    templateId: mapped.id,
    templateName: mapped.templateName || tpl.label || "",
    businessType: mapped.businessType ?? "",
    templateSnapshot: tpl,
    formFieldDefs: tpl.fields || [],
    formPayload: base.formPayload,
    flowNodes: base.flowNodes,
    templateAttachments: JSON.parse(JSON.stringify(templateAttachments)),
    storageBlobDTOs: [],
  };
}
/** æ ¹æ®æ¨¡æ¿ fields ç”Ÿæˆ el-form rules(prop ä¸º formPayload.xxx) */
export function buildFormPayloadRules(fields = []) {
  const rules = {};
  (fields || []).forEach((f) => {
    if (!f.required || !f.key) return;
    const prop = `formPayload.${f.key}`;
    if (f.type === "number") {
      rules[prop] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
    } else if (f.type === "datetimerange" || f.type === "date" || f.type === "select") {
      rules[prop] = [{ required: true, message: `请选择${f.label}`, trigger: "change" }];
    } else {
      rules[prop] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
    }
  });
  return rules;
}
/** æ ¡éªŒæ¨¡æ¿ç»‘定:审批流程(附件选填,由用户自行上传) */
export function validateTemplateBinding({ flowNodes }) {
  const flowCheck = validateSubmitFlowNodes(flowNodes);
  if (!flowCheck.ok) return flowCheck;
  return { ok: true, nodes: flowCheck.nodes };
}
/** åˆå¹¶ç»‘定结果到业务表单对象(字段名可按业务覆盖) */
export function applyBindingToForm(target, binding, fieldMap = {}) {
  if (!target || !binding) return target;
  const map = {
    templateId: "templateId",
    templateName: "templateName",
    businessType: "businessType",
    templateSnapshot: "templateSnapshot",
    formFieldDefs: "formFieldDefs",
    formPayload: "formPayload",
    flowNodes: "flowNodes",
    templateAttachments: "templateAttachments",
    storageBlobDTOs: "storageBlobDTOs",
    ...fieldMap,
  };
  Object.entries(map).forEach(([srcKey, destKey]) => {
    if (binding[srcKey] !== undefined) {
      target[destKey] = binding[srcKey];
    }
  });
  return target;
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<!-- ä¸Žå®¡æ‰¹åˆ—表详情弹窗一致 -->
<template>
  <el-dialog
    v-model="visible"
    :title="title"
    width="920px"
    append-to-body
    destroy-on-close
    class="approve-detail-dialog"
    @closed="emit('closed')"
  >
    <div class="approve-detail-body">
      <ApproveDetailPanel :row="row" />
      <div class="detail-block">
        <div class="detail-block-title">
          å®¡æ‰¹æµç¨‹ï¼ˆ{{ row?.tasks?.length || row?.flowNodes?.length || 0 }} é¡¹ï¼‰
        </div>
        <InstanceFlowDisplay :tasks="row?.tasks" :nodes="row?.flowNodes" />
      </div>
      <div class="detail-block">
        <div class="detail-block-title">审批记录</div>
        <el-timeline v-if="row?.approvalRecords?.length" class="approve-record-timeline">
          <el-timeline-item
            v-for="(rec, i) in row.approvalRecords"
            :key="rec.id ?? i"
            :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
            :timestamp="formatRecordTime(rec.time)"
            placement="top"
          >
            <div class="record-item">
              <span class="record-operator">{{ rec.operatorName || "—" }}</span>
              <el-tag
                size="small"
                :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'info'"
                effect="plain"
              >
                {{ approvalActionLabel(rec.result) }}
              </el-tag>
              <p class="record-opinion">{{ rec.opinion || "无意见" }}</p>
            </div>
          </el-timeline-item>
        </el-timeline>
        <el-empty v-else description="暂无审批记录" :image-size="48" />
      </div>
    </div>
    <template #footer>
      <el-button v-if="canEditRow(row)" @click="emit('edit', row)">ä¿® æ”¹</el-button>
      <el-button @click="visible = false">关 é—­</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed } from "vue";
import { canEditBusinessInstanceRow } from "../../approve-list/approveListConstants.js";
import { formatDisplayTime } from "../../approve-template/approveTemplateConstants.js";
import ApproveDetailPanel from "../../approve-list/components/ApproveDetailPanel.vue";
import InstanceFlowDisplay from "../../approve-list/components/InstanceFlowDisplay.vue";
function canEditRow(row) {
  return canEditBusinessInstanceRow(row);
}
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  row: { type: Object, default: () => ({}) },
  title: { type: String, default: "审批详情" },
});
const emit = defineEmits(["update:modelValue", "edit", "closed"]);
const visible = computed({
  get: () => props.modelValue,
  set: (v) => emit("update:modelValue", v),
});
function approvalActionLabel(result) {
  if (result === "approved") return "通过";
  if (result === "rejected") return "驳回";
  return "待处理";
}
function formatRecordTime(time) {
  return formatDisplayTime(time) || "—";
}
</script>
<style scoped>
.approve-detail-dialog :deep(.el-dialog__body) {
  padding-top: 16px;
  max-height: 70vh;
  overflow-y: auto;
}
.approve-detail-body .detail-block {
  margin-top: 20px;
}
.detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
.approve-record-timeline {
  padding-left: 4px;
}
.record-item {
  padding: 4px 0 2px;
}
.record-operator {
  font-weight: 600;
  margin-right: 8px;
  color: var(--el-text-color-primary);
}
.record-opinion {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--el-text-color-regular);
  line-height: 1.5;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
<!-- ä¸Žå®¡æ‰¹åˆ—表提交/修改弹窗(第三步)一致 -->
<template>
  <el-dialog
    v-model="visible"
    :title="title"
    width="720px"
    append-to-body
    destroy-on-close
    class="approve-submit-dialog"
    @closed="emit('closed')"
  >
    <el-form ref="innerFormRef" :model="form" :rules="rules" label-width="120px">
      <el-form-item v-if="isEdit" label="审批类型">
        <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate?.approvalType)">
          {{ activeTemplate?.label || form.templateName || "—" }}
        </span>
      </el-form-item>
      <slot name="before" :form="form" :fields="fields" />
      <ApprovalTemplateFormSection
        :active-template="activeTemplate"
        :fields="fields"
        :form-payload="form.formPayload"
        v-model:flow-nodes="form.flowNodes"
        v-model:attachments="form.storageBlobDTOs"
        :template-attachments="form.templateAttachments"
        :user-options="userOptions"
        :show-template-name="!isEdit"
        :allow-change-template="false"
        :flow-attachments-only="flowAttachmentsOnly"
        :flow-only="flowOnly"
      />
      <slot name="after" :form="form" :fields="fields" />
    </el-form>
    <template #footer>
      <el-button type="primary" :loading="saving" @click="handleSubmitClick">
        {{ isEdit ? "保 å­˜" : "提 äº¤" }}
      </el-button>
      <el-button @click="visible = false">取 æ¶ˆ</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { approvalTypeStyle } from "../../approve-list/approveListConstants.js";
import ApprovalTemplateFormSection from "./ApprovalTemplateFormSection.vue";
const innerFormRef = ref(null);
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  title: { type: String, default: "" },
  form: { type: Object, required: true },
  rules: { type: Object, default: () => ({}) },
  fields: { type: Array, default: () => [] },
  activeTemplate: { type: Object, default: null },
  userOptions: { type: Array, default: () => [] },
  isEdit: { type: Boolean, default: false },
  saving: { type: Boolean, default: false },
  formRef: { type: Object, default: null },
  /** å¡«æŠ¥é¡¹ç”± before æ’槽单独渲染时设为 true */
  flowAttachmentsOnly: { type: Boolean, default: false },
  flowOnly: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "submit", "closed"]);
const visible = computed({
  get: () => props.modelValue,
  set: (v) => emit("update:modelValue", v),
});
watch(
  innerFormRef,
  (el) => {
    if (props.formRef) props.formRef.value = el;
  },
  { flush: "post" }
);
watch(visible, (v) => {
  if (!v && props.formRef) props.formRef.value = null;
});
async function handleSubmitClick() {
  if (!innerFormRef.value) {
    ElMessage.warning("表单未就绪,请稍后再试");
    return;
  }
  try {
    await innerFormRef.value.validate();
  } catch {
    ElMessage.warning("请完善表单必填项后再保存");
    return;
  }
  emit("submit");
}
</script>
<style scoped>
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.approve-submit-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,176 @@
<!--
  ä¸šåŠ¡æ¨¡å—ã€Œæ–°å¢žã€æ—¶å¯¼å…¥å®¡æ‰¹æ¨¡æ¿ï¼ˆå›ºå®š moduleKey,仅展示该类型下模板)
  ç”¨æ³•:
  <ApprovalTemplateBindDialog
    v-model:visible="visible"
    module-key="regular"
    @confirm="onTemplateBound"
  />
-->
<template>
  <el-dialog
    v-model="dialogVisible"
    :title="dialogTitle"
    :width="step === formStep ? 720 : 640"
    append-to-body
    class="approval-template-bind-dialog"
    @closed="onClosed"
  >
    <template v-if="step === 1">
      <div v-loading="templatesLoading || confirming">
        <ApprovalTemplatePicker
          :cards="templateCards"
          :loading="false"
          :hint="pickerHint"
          @pick="onPickTemplate"
        />
      </div>
    </template>
    <template v-else>
      <div v-loading="templatesLoading">
        <el-form
          ref="formRef"
          :model="bindingForm"
          :rules="mergedRules"
          label-width="120px"
        >
          <ApprovalTemplateFormSection
            :active-template="activeTemplate"
            :fields="formFields"
            :form-payload="bindingForm.formPayload"
            v-model:flow-nodes="bindingForm.flowNodes"
            v-model:attachments="bindingForm.storageBlobDTOs"
            :template-attachments="bindingForm.templateAttachments"
            :user-options="flowUserOptions"
            allow-change-template
            @change-template="step = 1"
          />
        </el-form>
      </div>
    </template>
    <template #footer>
      <el-button v-if="step === formStep" type="primary" :loading="confirming" @click="onConfirm">
        ç¡® å®š
      </el-button>
      <el-button v-if="step === formStep" @click="step = 1">重选模板</el-button>
      <el-button @click="dialogVisible = false">{{ step === 1 ? "取 æ¶ˆ" : "关 é—­" }}</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import ApprovalTemplatePicker from "./ApprovalTemplatePicker.vue";
import ApprovalTemplateFormSection from "./ApprovalTemplateFormSection.vue";
import { useApprovalTemplateBinding } from "../useApprovalTemplateBinding.js";
import { useFlowUserOptions } from "../useFlowUserOptions.js";
import { getApprovalModuleConfig } from "../approvalModuleRegistry.js";
const props = defineProps({
  visible: { type: Boolean, default: false },
  /** approvalModuleRegistry ä¸­çš„ moduleKey */
  moduleKey: { type: String, required: true },
  /** ä¸º true æ—¶é€‰æ¨¡æ¿åŽç›´æŽ¥ç¡®è®¤ï¼Œè·³è¿‡ã€Œç¡®è®¤å®¡æ‰¹ä¿¡æ¯ã€å¡«æŠ¥æ­¥éª¤ */
  skipFormConfirm: { type: Boolean, default: false },
});
const emit = defineEmits(["update:visible", "confirm", "closed"]);
const dialogVisible = computed({
  get: () => props.visible,
  set: (v) => emit("update:visible", v),
});
const {
  step,
  bindingForm,
  templateCards,
  activeTemplate,
  formFields,
  formRules,
  templatesLoading,
  loadTemplates,
  resetBinding,
  pickTemplate,
  validateBinding,
  getBindingPayload,
  moduleConfig,
} = useApprovalTemplateBinding({ moduleKey: props.moduleKey, mode: "module" });
const formStep = 2;
const formRef = ref();
const confirming = ref(false);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const mergedRules = computed(() => ({ ...formRules.value }));
const dialogTitle = computed(() => {
  const label = moduleConfig.value?.label || "审批";
  return step.value === 1 ? `选择${label}模板` : `确认${label}审批信息`;
});
const pickerHint = computed(
  () => `请选择「${moduleConfig.value?.label || "—"}」类型下已启用的审批模板,审批流程将自动带入。`
);
watch(
  () => props.visible,
  async (v) => {
    if (!v) return;
    resetBinding();
    step.value = 1;
    await Promise.all([loadTemplates(), loadFlowUsers()]);
    const cfg = getApprovalModuleConfig(props.moduleKey);
    if (!cfg) {
      ElMessage.warning(`未配置模块「${props.moduleKey}」,请检查 approvalModuleRegistry`);
      return;
    }
    if (!templateCards.value.length) {
      ElMessage.warning(
        `「${cfg.label}」下暂无已启用的审批模板,请先在审批模板管理中创建并启用对应类型的模板`
      );
    }
  }
);
async function onPickTemplate(card) {
  const ok = await pickTemplate(card);
  if (!ok) return;
  if (props.skipFormConfirm) {
    step.value = 1;
    await onConfirm();
    return;
  }
  step.value = formStep;
}
async function onConfirm() {
  confirming.value = true;
  try {
    const check = await validateBinding(props.skipFormConfirm ? null : formRef.value);
    if (!check.ok) {
      if (check.message) ElMessage.warning(check.message);
      return;
    }
    emit("confirm", { ...getBindingPayload(), flowNodes: check.nodes });
    dialogVisible.value = false;
  } finally {
    confirming.value = false;
  }
}
function onClosed() {
  resetBinding();
  emit("closed");
}
</script>
<style scoped>
.approval-template-bind-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
<!-- æ¨¡æ¿ç»‘定表单区:填报项 + å®¡æ‰¹æµç¨‹ + é™„件(须挂在外层 el-form ä¸‹ï¼‰ -->
<template>
  <template v-if="activeTemplate">
    <el-form-item
      v-if="showTemplateName && !hideTemplateName && !flowAttachmentsOnly && !flowOnly"
      label="审批模板"
    >
      <span class="template-name">{{ activeTemplate.label }}</span>
      <el-button v-if="allowChangeTemplate" type="primary" link class="ml12" @click="emit('change-template')">
        æ›´æ¢æ¨¡æ¿
      </el-button>
    </el-form-item>
    <FormPayloadFields
      v-if="!hideFormFields && !flowAttachmentsOnly && !flowOnly"
      :fields="fields"
      :form-payload="formPayload"
    />
    <el-form-item label="审批流程" required>
      <TemplateFlowEditor
        v-model="flowNodesModel"
        :user-options="userOptions"
        :readonly="!flowEditable"
      />
      <p class="section-tip">
        {{
          flowEditable
            ? "流程与审批人由模板预置,可按需微调节点审批人。"
            : "流程与审批人由所选模板固定,不可修改。"
        }}
      </p>
    </el-form-item>
    <el-form-item v-if="!flowOnly && templateAttachments.length" label="模板参考">
      <el-tag
        v-for="(f, i) in templateAttachments"
        :key="`tpl-${i}`"
        class="attachment-tag"
        type="info"
        effect="plain"
      >
        {{ attachmentDisplayName(f) }}
      </el-tag>
      <p class="section-tip">以上为模板附带文件,仅供参考;提交附件请在下方上传。</p>
    </el-form-item>
    <el-form-item v-if="!flowOnly" label="附件">
      <FileUpload
        v-model:file-list="attachmentsModel"
        :limit="uploadLimit"
        button-text="点击选择文件"
      />
      <p class="section-tip">选填,可上传与申请相关的说明材料。</p>
    </el-form-item>
  </template>
  <el-empty v-else description="请先选择审批模板" :image-size="64" />
</template>
<script setup>
import { computed } from "vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import TemplateFlowEditor from "../../approve-template/components/TemplateFlowEditor.vue";
import FormPayloadFields from "../../approve-list/components/FormPayloadFields.vue";
import { attachmentDisplayName } from "../approvalTemplateBindingUtils.js";
const props = defineProps({
  activeTemplate: { type: Object, default: null },
  fields: { type: Array, default: () => [] },
  formPayload: { type: Object, required: true },
  flowNodes: { type: Array, default: () => [] },
  /** ç”¨æˆ·è‡ªè¡Œä¸Šä¼ çš„附件 */
  attachments: { type: Array, default: () => [] },
  /** æ¨¡æ¿é¢„置附件(只读展示) */
  templateAttachments: { type: Array, default: () => [] },
  userOptions: { type: Array, default: () => [] },
  showTemplateName: { type: Boolean, default: true },
  allowChangeTemplate: { type: Boolean, default: true },
  /** ä¸º true æ—¶ä¸å±•示模板自定义填报项(仅保留审批流程与附件) */
  hideFormFields: { type: Boolean, default: false },
  /** ä¸º true æ—¶ä¸å±•示审批模板名称行(由父级置顶展示) */
  hideTemplateName: { type: Boolean, default: false },
  /** ä¸º true æ—¶ä»…展示审批流程与附件(填报项由父级单独渲染) */
  flowAttachmentsOnly: { type: Boolean, default: false },
  /** ä¸º true æ—¶ä»…展示审批流程(不展示模板填报项、附件等) */
  flowOnly: { type: Boolean, default: false },
  uploadLimit: { type: Number, default: 10 },
  /** ä¸º true æ—¶å¯ç¼–辑模板预置的审批人(仅审批模板管理页使用) */
  flowEditable: { type: Boolean, default: false },
});
const emit = defineEmits(["update:flowNodes", "update:attachments", "change-template"]);
const flowNodesModel = computed({
  get: () => props.flowNodes,
  set: (v) => emit("update:flowNodes", v),
});
const attachmentsModel = computed({
  get: () => props.attachments,
  set: (v) => emit("update:attachments", v),
});
</script>
<style scoped>
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
.section-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin: 8px 0 0;
  line-height: 1.5;
}
.attachment-tag {
  margin: 0 8px 8px 0;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
<!-- å®¡æ‰¹æ¨¡æ¿å¡ç‰‡é€‰æ‹©ï¼ˆæŒ‰ businessType è¿‡æ»¤ï¼‰ -->
<template>
  <div class="approval-template-picker">
    <p v-if="hint" class="picker-hint">{{ hint }}</p>
    <div v-loading="loading" class="template-grid">
      <div
        v-for="card in cards"
        :key="card.key || card.id"
        class="template-card"
        @click="emit('pick', card)"
      >
        <span class="template-card-type" :style="typeStyle(card.approvalType)">
          {{ card.label }}
        </span>
        <span class="template-card-desc">{{ card.summaryPlaceholder }}</span>
      </div>
      <el-empty
        v-if="!loading && !cards.length"
        :description="emptyText"
        :image-size="80"
        class="template-empty"
      />
    </div>
  </div>
</template>
<script setup>
import { approvalTypeStyle } from "../../approve-list/approveListConstants.js";
defineProps({
  cards: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false },
  hint: { type: String, default: "" },
  emptyText: { type: String, default: "该类型下暂无可用审批模板" },
});
const emit = defineEmits(["pick"]);
function typeStyle(approvalType) {
  return approvalTypeStyle(approvalType);
}
</script>
<style scoped>
.picker-hint {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  margin: 0 0 16px;
}
.template-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
  min-height: 120px;
}
.template-empty {
  grid-column: 1 / -1;
}
.template-card {
  padding: 14px 16px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: var(--radius-md, 8px);
  cursor: pointer;
  transition: border-color 0.2s, box-shadow 0.2s;
  background: var(--el-fill-color-blank);
}
.template-card:hover {
  border-color: var(--el-color-primary);
  box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
}
.template-card-type {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 8px;
}
.template-card-desc {
  display: block;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,408 @@
import {
  deleteApprovalInstance,
  listApprovalInstancePage,
  saveApprovalInstance,
  updateApprovalInstance,
} from "@/api/officeProcessAutomation/approvalInstance.js";
import useUserStore from "@/store/modules/user";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref } from "vue";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "./approvalTemplateBindingUtils.js";
import {
  buildApprovalInstanceListParams,
  buildEditFormFromInstanceRow,
  buildInstanceDto,
  canEditBusinessInstanceRow,
  createEmptySubmitForm,
  mapInstanceFromApi,
  resolveInstanceFormFields,
  unwrapInstancePage,
} from "../approve-list/approveListConstants.js";
import { fetchBusinessTypeOptions } from "../approve-template/approveTemplateConstants.js";
import {
  collectOptionSourcesFromFields,
  fetchSelectOptionCaches,
} from "../approve-template/selectOptionSource.js";
import { enrichInstanceRowFromFormConfig } from "./approvalInstanceFormConfigTable.js";
import {
  filterInstanceRowsByModuleSearch,
  hasActiveModuleSearch,
} from "./approvalInstanceListSearch.js";
import {
  getApprovalModuleConfig,
  getModuleListBusinessType,
  resolveModuleBusinessType,
} from "./approvalModuleRegistry.js";
/**
 * ä¸šåŠ¡ç”³è¯·é¡µå…±ç”¨ï¼šå®¡æ‰¹å®žä¾‹åˆ—è¡¨æŸ¥è¯¢ã€æ–°å¢ž/修改保存、详情/编辑弹窗(与审批列表一致)
 *
 * @param {object} options
 * @param {string} options.moduleKey approvalModuleRegistry ä¸­çš„ key
 * @param {(row: object) => object} [options.enrichListRow] åˆ—表行增强(从 formPayload è§£æžå±•示字段)
 * @param {(base: object) => object} [options.buildExtraListParams] è¿½åŠ æŸ¥è¯¢å‚æ•°
 * @param {() => void} [options.beforeSave] ä¿å­˜å‰é’©å­ï¼ˆå¦‚同步业务字段到 formPayload)
 * @param {import('vue').ComputedRef|object} [options.extraFormRules] é¢å¤–表单校验
 */
export function useApprovalInstanceModule(options = {}) {
  const {
    moduleKey,
    enrichListRow,
    buildExtraListParams,
    beforeSave,
    extraFormRules,
  } = options;
  const userStore = useUserStore();
  const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey));
  const businessTypeOptions = ref([]);
  /** åˆ—表查询 businessType:优先 registry å†™æ­»æžšä¸¾ï¼Œå†å›žé€€ TypeEnums */
  const defaultListBusinessType = computed(() => {
    const fixed = getModuleListBusinessType(moduleKey);
    if (fixed != null && fixed !== "") return fixed;
    const resolved = resolveModuleBusinessType(moduleKey, businessTypeOptions.value);
    if (resolved != null && resolved !== "") return resolved;
    return "";
  });
  async function loadBusinessTypeOptions() {
    if (businessTypeOptions.value.length) return;
    try {
      businessTypeOptions.value = await fetchBusinessTypeOptions();
    } catch {
      businessTypeOptions.value = [];
    }
  }
  const tableData = ref([]);
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const submitDialog = reactive({ visible: false, mode: "add" });
  const submitEditRow = ref(null);
  const submitForm = reactive(createEmptySubmitForm(""));
  const submitFormRef = ref();
  const submitSaving = ref(false);
  const templateBindVisible = ref(false);
  const pendingTemplateBinding = ref(null);
  /** æœ€è¿‘一次列表查询条件(保存后刷新列表时沿用) */
  let lastListSearchForm = null;
  const isSubmitEdit = computed(() => submitDialog.mode === "edit");
  const activeTemplate = computed(() => submitForm.templateSnapshot || null);
  const submitFormFields = computed(() => {
    const tplFields = activeTemplate.value?.fields;
    if (tplFields?.length) return tplFields;
    return submitForm.formFieldDefs || [];
  });
  const submitFormRules = computed(() => ({
    ...buildFormPayloadRules(submitFormFields.value),
    ...(extraFormRules?.value ?? extraFormRules ?? {}),
  }));
  const submitDialogTitle = computed(() => {
    const label = moduleConfig.value?.label || "申请";
    if (submitDialog.mode === "edit") {
      return `修改${activeTemplate.value?.label || submitForm.templateName || label}`;
    }
    return `新增${label}`;
  });
  function mapListRow(row, caches) {
    const mapped = mapInstanceFromApi(row);
    const fromFormConfig = enrichInstanceRowFromFormConfig(mapped, caches);
    return enrichListRow ? enrichListRow(fromFormConfig) : fromFormConfig;
  }
  async function fetchList(searchForm = {}) {
    await loadBusinessTypeOptions();
    tableLoading.value = true;
    try {
      let extraParams = {};
      if (buildExtraListParams) {
        extraParams = buildExtraListParams(searchForm) || {};
      }
      const res = await listApprovalInstancePage(
        buildApprovalInstanceListParams({
          page,
          searchForm,
          businessType: defaultListBusinessType.value,
          extraParams,
        })
      );
      const { records, total } = unwrapInstancePage(res);
      const mapped = records.map(mapInstanceFromApi);
      const allFields = [];
      for (const row of mapped) {
        const { fields } = resolveInstanceFormFields(row);
        allFields.push(...fields);
      }
      const caches = await fetchSelectOptionCaches(
        collectOptionSourcesFromFields(allFields)
      );
      let rows = mapped.map((row) => mapListRow(row, caches));
      if (hasActiveModuleSearch(moduleKey, searchForm)) {
        rows = filterInstanceRowsByModuleSearch(moduleKey, rows, searchForm);
      }
      tableData.value = rows;
      page.total = hasActiveModuleSearch(moduleKey, searchForm)
        ? rows.length
        : total;
    } catch {
      tableData.value = [];
      page.total = 0;
      ElMessage.error(`${moduleConfig.value?.label || "申请"}列表加载失败`);
    } finally {
      tableLoading.value = false;
    }
  }
  function handleQuery(searchForm) {
    lastListSearchForm = searchForm;
    page.current = 1;
    return fetchList(searchForm);
  }
  /** è¿›å…¥é¡µé¢ï¼šå…ˆæ‹‰ TypeEnums è§£æž businessType,再查列表 */
  async function initModuleList(searchForm) {
    await loadBusinessTypeOptions();
    return handleQuery(searchForm);
  }
  function pagination({ page: p, limit }, searchForm) {
    page.current = p;
    page.size = limit;
    return fetchList(searchForm);
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openEdit(row) {
    if (!canEditBusinessInstanceRow(row)) {
      ElMessage.warning("进行中或已完成的审批不可修改");
      return;
    }
    if (!row?.id) {
      ElMessage.warning("无法修改:缺少审批实例 ID");
      return;
    }
    submitDialog.mode = "edit";
    submitEditRow.value = { ...row };
    Object.assign(submitForm, buildEditFormFromInstanceRow(row));
    submitDialog.visible = true;
  }
  function openEditFromDetail() {
    const row = detailRow.value;
    detailDialog.visible = false;
    openEdit(row);
  }
  function resetSubmitForm() {
    Object.assign(submitForm, createEmptySubmitForm(""));
    submitEditRow.value = null;
  }
  function openAddWithTemplate() {
    submitDialog.visible = false;
    pendingTemplateBinding.value = null;
    templateBindVisible.value = true;
  }
  function onTemplateBound(binding) {
    pendingTemplateBinding.value = binding;
  }
  function onTemplateBindClosed() {
    const binding = pendingTemplateBinding.value;
    if (!binding) return;
    pendingTemplateBinding.value = null;
    openAddFromBinding(binding);
  }
  function openAddFromBinding(binding) {
    resetSubmitForm();
    applyBindingToForm(submitForm, binding);
    submitDialog.mode = "add";
    submitEditRow.value = null;
    submitDialog.visible = true;
  }
  function closeSubmitDialog() {
    submitDialog.visible = false;
  }
  async function submitInstanceForm(options = {}) {
    const { skipValidate = false } = options;
    if (!skipValidate) {
      if (!submitFormRef.value?.validate) {
        ElMessage.warning("表单未就绪,请关闭弹窗后重试");
        return false;
      }
      try {
        await submitFormRef.value.validate();
      } catch {
        ElMessage.warning("请完善表单必填项后再保存");
        return false;
      }
    }
    if (!activeTemplate.value) {
      ElMessage.warning("未加载审批模板,无法保存");
      return false;
    }
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      return false;
    }
    if (!submitForm.templateId) {
      ElMessage.warning("缺少模板 ID,无法提交");
      return false;
    }
    if (beforeSave) {
      try {
        await beforeSave(submitForm, { isEdit: isSubmitEdit.value, editRow: submitEditRow.value });
      } catch {
        return false;
      }
    }
    if (submitSaving.value) return false;
    submitSaving.value = true;
    try {
      const dto = buildInstanceDto({
        submitForm,
        activeTemplate: activeTemplate.value,
        userStore,
        flowNodes: bindingCheck.nodes,
        existingRow: isSubmitEdit.value ? submitEditRow.value : null,
      });
      if (isSubmitEdit.value) {
        await updateApprovalInstance(dto);
      } else {
        await saveApprovalInstance(dto);
      }
      submitDialog.visible = false;
      if (!isSubmitEdit.value) page.current = 1;
      await fetchList(lastListSearchForm ?? {});
      if (detailDialog.visible && detailRow.value?.id === submitForm.instanceId) {
        const hit = tableData.value.find((r) => r.id === submitForm.instanceId);
        if (hit) detailRow.value = { ...hit };
        else detailDialog.visible = false;
      }
      return true;
    } catch {
      ElMessage.error(isSubmitEdit.value ? "保存失败" : "提交失败");
      return false;
    } finally {
      submitSaving.value = false;
    }
  }
  async function removeInstance(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少审批实例 ID");
      return;
    }
    const title = row.title || row.templateName || row.instanceNo || "该审批";
    try {
      await ElMessageBox.confirm(
        `确定要删除审批「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteApprovalInstance([row.id]);
      ElMessage.success("删除成功");
      if (detailDialog.visible && detailRow.value?.id === row.id) {
        detailDialog.visible = false;
      }
      if (submitDialog.visible && submitEditRow.value?.id === row.id) {
        submitDialog.visible = false;
      }
      await fetchList(lastListSearchForm ?? {});
    } catch {
      /* é”™è¯¯ç”±æ‹¦æˆªå™¨æç¤º */
    }
  }
  /** æž„建标准操作列:详情、修改、删除(与审批列表一致) */
  function buildTableActions(extraOperations = []) {
    return [
      { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
      {
        name: "修改",
        type: "text",
        disabled: (row) => !canEditBusinessInstanceRow(row),
        clickFun: (row) => openEdit(row),
      },
      {
        name: "删除",
        type: "danger",
        clickFun: (row) => removeInstance(row),
      },
      ...extraOperations,
    ];
  }
  return {
    moduleConfig,
    defaultListBusinessType,
    tableData,
    tableLoading,
    page,
    detailDialog,
    detailRow,
    submitDialog,
    submitEditRow,
    submitForm,
    submitFormRef,
    submitSaving,
    isSubmitEdit,
    activeTemplate,
    submitFormFields,
    submitFormRules,
    submitDialogTitle,
    templateBindVisible,
    pendingTemplateBinding,
    fetchList,
    handleQuery,
    initModuleList,
    pagination,
    openDetail,
    openEdit,
    openEditFromDetail,
    openAddWithTemplate,
    onTemplateBound,
    onTemplateBindClosed,
    openAddFromBinding,
    closeSubmitDialog,
    resetSubmitForm,
    submitInstanceForm,
    removeInstance,
    buildTableActions,
    loadBusinessTypeOptions,
    canEditBusinessInstanceRow,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,259 @@
import {
  getApprovalTemplateDetail,
  listApprovalTemplate,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import {
  fetchBusinessTypeOptions,
  mapEnabledFromApi,
  unwrapTemplateList,
} from "../approve-template/approveTemplateConstants.js";
import {
  createEmptySubmitForm,
  mapSubmitTemplateCard,
  matchBusinessTypeValue,
} from "../approve-list/approveListConstants.js";
import {
  getApprovalModuleConfig,
  getModuleMatchingBusinessTypes,
  resolveModuleBusinessType,
} from "./approvalModuleRegistry.js";
import {
  buildFormPayloadRules,
  buildTemplateBindingFromDetail,
  validateTemplateBinding,
} from "./approvalTemplateBindingUtils.js";
/**
 * å®¡æ‰¹æ¨¡æ¿ç»‘定(业务模块固定类型 / å®¡æ‰¹åˆ—表通用)
 *
 * @param {object} options
 * @param {string} [options.moduleKey] ä¸šåŠ¡æ¨¡å— key,见 approvalModuleRegistry
 * @param {string|number} [options.businessType] ç›´æŽ¥æŒ‡å®šç±»åž‹ï¼ˆä¼˜å…ˆçº§é«˜äºŽ moduleKey)
 * @param {'module'|'universal'} [options.mode] module=仅本类型模板;universal=需先选类型
 */
export function useApprovalTemplateBinding(options = {}) {
  const { moduleKey = null, businessType: fixedBusinessType = null, mode = moduleKey ? "module" : "universal" } =
    options;
  const isUniversal = mode === "universal" && !moduleKey && fixedBusinessType == null;
  const allTemplates = ref([]);
  const businessTypeOptions = ref([]);
  const selectedBusinessType = ref(fixedBusinessType ?? "");
  const templatesLoading = ref(false);
  const step = ref(isUniversal ? 1 : 1);
  const bindingForm = reactive(createEmptySubmitForm(""));
  const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey));
  const resolvedBusinessType = computed(() => {
    if (fixedBusinessType != null && fixedBusinessType !== "") return fixedBusinessType;
    if (selectedBusinessType.value != null && selectedBusinessType.value !== "") {
      return selectedBusinessType.value;
    }
    if (moduleKey) {
      return resolveModuleBusinessType(moduleKey, businessTypeOptions.value);
    }
    return "";
  });
  const matchingBusinessTypes = computed(() => {
    if (fixedBusinessType != null && fixedBusinessType !== "") return [fixedBusinessType];
    if (isUniversal) {
      const t = selectedBusinessType.value;
      return t != null && t !== "" ? [t] : [];
    }
    if (moduleKey) {
      return getModuleMatchingBusinessTypes(moduleKey, businessTypeOptions.value);
    }
    const t = resolvedBusinessType.value;
    return t != null && t !== "" ? [t] : [];
  });
  const templateCards = computed(() => {
    const types = matchingBusinessTypes.value;
    if (!types.length) return [];
    return allTemplates.value.filter((card) =>
      types.some(
        (t) =>
          matchBusinessTypeValue(card.businessType, t) ||
          matchBusinessTypeValue(card.approvalType, t)
      )
    );
  });
  const activeTemplate = computed(() => bindingForm.templateSnapshot || null);
  const formFields = computed(() => {
    const tplFields = activeTemplate.value?.fields;
    if (tplFields?.length) return tplFields;
    return bindingForm.formFieldDefs || [];
  });
  const formRules = computed(() => buildFormPayloadRules(formFields.value));
  const hasTemplateBound = computed(() => Boolean(activeTemplate.value?.templateId || bindingForm.templateId));
  function businessTypeLabel(type) {
    if (type == null || type === "") return "";
    const hit = businessTypeOptions.value.find((x) => matchBusinessTypeValue(x.value, type));
    return hit?.label || moduleConfig.value?.label || "";
  }
  const selectedBusinessTypeLabel = computed(() => businessTypeLabel(resolvedBusinessType.value));
  function countTemplatesByBusinessType(type) {
    const types =
      moduleKey && !fixedBusinessType
        ? getModuleMatchingBusinessTypes(moduleKey, businessTypeOptions.value)
        : [type];
    return allTemplates.value.filter((card) =>
      types.some(
        (t) =>
          matchBusinessTypeValue(card.businessType, t) ||
          matchBusinessTypeValue(card.approvalType, t)
      )
    ).length;
  }
  async function loadTemplates() {
    templatesLoading.value = true;
    try {
      const [typeOptions, customRes] = await Promise.all([
        fetchBusinessTypeOptions(),
        listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      ]);
      businessTypeOptions.value = typeOptions;
      allTemplates.value = unwrapTemplateList(customRes)
        .filter((row) => mapEnabledFromApi(row.enabled))
        .map(mapSubmitTemplateCard);
      if (moduleKey && !fixedBusinessType) {
        const resolved = resolveModuleBusinessType(moduleKey, typeOptions);
        if (resolved != null && resolved !== "") selectedBusinessType.value = resolved;
      }
    } catch {
      businessTypeOptions.value = [];
      allTemplates.value = [];
      ElMessage.error("加载审批模板失败");
    } finally {
      templatesLoading.value = false;
    }
  }
  function resetBinding() {
    step.value = isUniversal ? 1 : 1;
    if (!fixedBusinessType && !moduleKey) selectedBusinessType.value = "";
    else if (moduleKey) {
      selectedBusinessType.value =
        fixedBusinessType ?? resolveModuleBusinessType(moduleKey, businessTypeOptions.value) ?? "";
    }
    Object.assign(bindingForm, createEmptySubmitForm(""));
  }
  function pickBusinessType(type) {
    if (!countTemplatesByBusinessType(type)) {
      ElMessage.warning("该类型下暂无可用审批模板");
      return;
    }
    selectedBusinessType.value = type;
    step.value = 2;
  }
  function backToBusinessTypePick() {
    selectedBusinessType.value = "";
    step.value = 1;
  }
  function backToTemplatePick() {
    step.value = isUniversal ? 2 : 1;
  }
  async function pickTemplate(card) {
    if (!card?.id) return false;
    templatesLoading.value = true;
    try {
      const res = await getApprovalTemplateDetail(card.id);
      const applied = buildTemplateBindingFromDetail(res);
      Object.assign(bindingForm, {
        templateKey: String(card.id),
        ...applied,
      });
      step.value = isUniversal ? 3 : 2;
      return true;
    } catch {
      ElMessage.error("加载模板详情失败");
      return false;
    } finally {
      templatesLoading.value = false;
    }
  }
  /** ç›´æŽ¥ä»¥è¯¦æƒ…行绑定(编辑回显) */
  function applyBindingState(state) {
    if (!state) return;
    Object.assign(bindingForm, createEmptySubmitForm(""), state);
    step.value = isUniversal ? 3 : 2;
  }
  async function validateBinding(formRef) {
    if (formRef?.validate) {
      try {
        await formRef.validate();
      } catch {
        return { ok: false };
      }
    }
    if (!hasTemplateBound.value) {
      return { ok: false, message: "请选择审批模板" };
    }
    return validateTemplateBinding({ flowNodes: bindingForm.flowNodes });
  }
  function getBindingPayload() {
    return {
      templateId: bindingForm.templateId,
      templateName: bindingForm.templateName,
      businessType: bindingForm.businessType ?? resolvedBusinessType.value,
      templateSnapshot: bindingForm.templateSnapshot,
      formFieldDefs: bindingForm.formFieldDefs,
      formPayload: bindingForm.formPayload,
      flowNodes: bindingForm.flowNodes,
      templateAttachments: bindingForm.templateAttachments,
      storageBlobDTOs: bindingForm.storageBlobDTOs,
    };
  }
  return {
    isUniversal,
    moduleConfig,
    step,
    bindingForm,
    allTemplates,
    businessTypeOptions,
    selectedBusinessType,
    resolvedBusinessType,
    selectedBusinessTypeLabel,
    templateCards,
    activeTemplate,
    formFields,
    formRules,
    hasTemplateBound,
    templatesLoading,
    loadTemplates,
    resetBinding,
    pickBusinessType,
    backToBusinessTypePick,
    backToTemplatePick,
    pickTemplate,
    applyBindingState,
    validateBinding,
    getBindingPayload,
    countTemplatesByBusinessType,
    businessTypeLabel,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
import { ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
/** å®¡æ‰¹æµç¨‹é€‰äººä¸‹æ‹‰ï¼ˆæ¨¡æ¿/实例共用) */
export function useFlowUserOptions() {
  const flowUserOptions = ref([]);
  const loading = ref(false);
  async function loadFlowUsers() {
    loading.value = true;
    try {
      const res = await userListNoPageByTenantId();
      flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
    } catch {
      flowUserOptions.value = [];
    } finally {
      loading.value = false;
    }
  }
  return { flowUserOptions, loading, loadFlowUsers };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,355 @@
import dayjs from "dayjs";
import { getTypeEnums } from "@/api/basicData/enum.js";
import {
  TEMPLATE_TYPE_BUILTIN,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { APPROVAL_TYPE_OPTIONS } from "../approve-list/approveListConstants.js";
import {
  buildFormConfigJson,
  createEmptyFormConfigData,
  parseFormConfigToData,
  validateFormConfigData,
} from "./formConfigUtils.js";
export function unwrapEnumList(data) {
  if (Array.isArray(data)) return data;
  if (!data || typeof data !== "object") return [];
  if (Array.isArray(data.TypeEnums)) return data.TypeEnums;
  if (Array.isArray(data.typeEnums)) return data.typeEnums;
  const nested = Object.values(data).find((v) => Array.isArray(v));
  return nested || [];
}
export function normalizeBusinessTypeOptions(data) {
  return unwrapEnumList(data)
    .map((item) => {
      const rawValue = item?.value ?? item?.code ?? item?.businessType ?? item?.dictValue ?? item?.key;
      if (rawValue == null || rawValue === "") return null;
      const num = Number(rawValue);
      const value =
        typeof rawValue === "number" || (Number.isFinite(num) && String(rawValue).trim() !== "")
          ? num
          : rawValue;
      const label =
        item?.label ?? item?.name ?? item?.desc ?? item?.dictLabel ?? item?.text ?? String(value);
      return { label, value };
    })
    .filter(Boolean);
}
export async function fetchBusinessTypeOptions() {
  try {
    const res = await getTypeEnums();
    return normalizeBusinessTypeOptions(res?.data);
  } catch {
    return [];
  }
}
/** æ˜¯å¦ä¸ºç³»ç»Ÿå†…置模板(templateType === 0) */
export function isBuiltinTemplate(row) {
  return Number(row?.templateType) === TEMPLATE_TYPE_BUILTIN;
}
/** èŠ‚ç‚¹å†…å®¡æ‰¹æ–¹å¼ï¼šä¼šç­¾ / æˆ–ç­¾ */
export const NODE_SIGN_MODE_OPTIONS = [
  { value: "countersign", label: "会签", desc: "本节点所有审批人均需通过" },
  { value: "or_sign", label: "或签", desc: "本节点任一审批人通过即可" },
];
function parseFormConfig(formConfig) {
  if (!formConfig) return {};
  if (typeof formConfig === "object") return formConfig;
  try {
    return JSON.parse(formConfig);
  } catch {
    return {};
  }
}
function resolveDefaultMode(row, cfg, nodes) {
  let mode = cfg.approvalMode || cfg.defaultMode;
  if (!mode && nodes.length) {
    const t = String(nodes[0]?.approveType || "").toUpperCase();
    mode = t === "OR" ? "or_sign" : "parallel";
  }
  const m = String(mode || "").toLowerCase();
  if (m === "or" || m === "or_sign") return "or_sign";
  return "parallel";
}
/** å°†æŽ¥å£è¿”回的模板转为「系统常用审批」卡片数据 */
export function mapBuiltinCardFromApi(row) {
  const cfg = parseFormConfig(row?.formConfig);
  const fields = cfg.fields || cfg.formFields || [];
  const nodes = row?.nodes || row?.flowNodes || [];
  return {
    key: String(row?.id ?? row?.templateName ?? ""),
    id: row?.id,
    approvalType: cfg.approvalType || row?.approvalType || "",
    label: row?.templateName || row?.name || "—",
    summary: (row?.description || "").trim() || cfg.summaryPlaceholder || "系统预置填报字段",
    fieldCount: fields.length,
    defaultMode: resolveDefaultMode(row, cfg, nodes),
  };
}
export function unwrapTemplateList(payload) {
  const data = payload?.data ?? payload;
  if (Array.isArray(data)) return data;
  if (Array.isArray(data?.records)) return data.records;
  if (Array.isArray(data?.list)) return data.list;
  return [];
}
/** åŽç«¯ approveType â†’ é¡µé¢ signMode */
export function mapSignModeFromApi(approveType) {
  const t = String(approveType || "").toUpperCase();
  return t === "OR" ? "or_sign" : "countersign";
}
/** é¡µé¢ signMode â†’ åŽç«¯ approveType */
export function mapSignModeToApi(signMode) {
  return signMode === "or_sign" ? "OR" : "AND";
}
/** é¡µé¢ enabled â†’ åŽç«¯ enabled(1 å¯ç”¨ï¼Œ0 åœç”¨ï¼‰ */
export function mapEnabledToApi(enabled) {
  return enabled !== false ? "1" : "0";
}
/** åŽç«¯ nodes â†’ é¡µé¢ flowNodes(保留 id ä¾›ä¿®æ”¹æäº¤ï¼‰ */
export function mapNodesFromApi(nodes) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list.map((n, i) => ({
    id: n.id,
    templateId: n.templateId,
    nodeOrder: n.levelNo ?? i + 1,
    signMode: mapSignModeFromApi(n.approveType ?? n.signMode),
    approvers: (n.approvers || [])
      .filter((a) => a?.approverId != null && a.approverId !== "")
      .map((a) => ({
        id: a.id,
        nodeId: a.nodeId,
        templateId: a.templateId,
        approverId: a.approverId,
        approverName: a.approverName || "",
      })),
  }));
}
/** enabled:1 å¯ç”¨ï¼Œ0 åœç”¨ */
export function mapEnabledFromApi(enabled) {
  return enabled === "1" || enabled === 1 || enabled === true;
}
/** å…¼å®¹å¤šç§åŽç«¯æ—¶é—´å­—段名并格式化展示 */
export function pickTemplateTimes(row) {
  const rawCreated =
    row?.createdTime ?? row?.createTime ?? row?.gmtCreate ?? row?.created_at ?? "";
  const rawUpdated =
    row?.updatedTime ?? row?.updateTime ?? row?.gmtModified ?? row?.modifyTime ?? row?.updated_at ?? "";
  const createdTime = normalizeTimeValue(rawCreated);
  const updatedTime = normalizeTimeValue(rawUpdated);
  return { createdTime, updatedTime, createTime: createdTime, updateTime: updatedTime };
}
function normalizeTimeValue(val) {
  if (val == null || val === "") return "";
  if (Array.isArray(val) && val.length >= 3) {
    const [y, m, d, h = 0, min = 0, s = 0] = val;
    return dayjs(new Date(y, m - 1, d, h, min, s)).format("YYYY-MM-DD HH:mm:ss");
  }
  if (typeof val === "number") {
    const d = val > 1e12 ? dayjs(val) : dayjs.unix(val);
    return d.isValid() ? d.format("YYYY-MM-DD HH:mm:ss") : "";
  }
  const s = String(val).trim();
  if (!s) return "";
  const parsed = dayjs(s.includes("T") ? s : s.replace(/-/g, "/"));
  return parsed.isValid() ? parsed.format("YYYY-MM-DD HH:mm:ss") : s;
}
export function formatDisplayTime(val) {
  const t = normalizeTimeValue(val);
  return t || "—";
}
/** è¯¦æƒ…接口 data è§£åŒ… */
export function unwrapTemplateDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.templateName != null || data.id != null) return data;
  if (data.approvalTemplateVo) return data.approvalTemplateVo;
  if (data.records && data.records[0]) return data.records[0];
  return data;
}
/** åŽç«¯é™„件字段 â†’ é¡µé¢ storageBlobDTOs */
export function mapAttachmentsFromApi(row) {
  const list =
    row?.storageBlobDTOs ||
    row?.storageBlobDTOS ||
    row?.storageBlobVOS ||
    row?.storageBlobVOList ||
    row?.attachmentList ||
    [];
  return Array.isArray(list) ? list : [];
}
/** åˆ†é¡µåˆ—表项 â†’ é¡µé¢è¡Œæ•°æ®ï¼ˆä¸»è¡¨ + èŠ‚ç‚¹ï¼‰ */
export function mapTemplateFromApi(row) {
  if (!row) return {};
  const flowNodes = mapNodesFromApi(row.nodes || row.flowNodes);
  const times = pickTemplateTimes(row);
  return {
    id: row.id,
    templateName: row.templateName || "",
    description: row.description || "",
    enabled: mapEnabledFromApi(row.enabled),
    enabledRaw: row.enabled,
    templateType: row.templateType != null ? Number(row.templateType) : undefined,
    businessType: row.businessType ?? "",
    formConfig: row.formConfig,
    formConfigData: parseFormConfigToData(row.formConfig),
    storageBlobDTOs: mapAttachmentsFromApi(row),
    createdUser: row.createdUser,
    createdUserName: row.createdUserName,
    ...times,
    flowNodes,
    nodes: row.nodes || row.flowNodes,
  };
}
/** è¡¨å•数据 â†’ æäº¤ DTO(ApprovalTemplateDto) */
export function mapTemplateToApi(form) {
  const nodes = normalizeFlowNodes(form.flowNodes);
  const templateId = form.id || null;
  const dto = {
    templateName: (form.templateName || "").trim(),
    description: (form.description || "").trim(),
    enabled: mapEnabledToApi(form.enabled),
    templateType:
      form.templateType != null ? Number(form.templateType) : TEMPLATE_TYPE_CUSTOM,
    businessType: form.businessType ?? "",
    formConfig: buildFormConfigJson(form.formConfigData),
    nodes: nodes.map((n, i) => {
      const node = {
        levelNo: n.nodeOrder ?? i + 1,
        approveType: mapSignModeToApi(n.signMode),
        approvers: n.approvers.map((a, idx) => {
          const approver = {
            approverId: a.approverId,
            approverName: a.approverName || "",
            sortNo: idx + 1,
          };
          if (a.id != null) approver.id = a.id;
          if (a.nodeId != null) approver.nodeId = a.nodeId;
          if (a.templateId != null) approver.templateId = a.templateId;
          else if (templateId) approver.templateId = templateId;
          return approver;
        }),
      };
      if (n.id != null) node.id = n.id;
      if (n.templateId != null) node.templateId = n.templateId;
      else if (templateId) node.templateId = templateId;
      return node;
    }),
  };
  if (templateId) dto.id = templateId;
  const attachments = Array.isArray(form.storageBlobDTOs) ? form.storageBlobDTOs : [];
  if (attachments.length) dto.storageBlobDTOs = attachments;
  return dto;
}
export function buildApprovalTemplateListParams({ page, searchForm }) {
  const params = {
    current: page.current,
    size: page.size,
  };
  const kw = (searchForm?.keyword || "").trim();
  if (kw) params.templateName = kw;
  if (searchForm?.enabledOnly) params.enabled = "1";
  return params;
}
export function nodeSignModeLabel(mode) {
  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.label || "—";
}
export function approvalTypeLabel(type) {
  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === type)?.label || type || "—";
}
export function createEmptyNode(order = 1) {
  return {
    nodeOrder: order,
    signMode: "countersign",
    approvers: [],
  };
}
export function createEmptyTemplateForm() {
  return {
    id: "",
    templateName: "",
    description: "",
    templateType: TEMPLATE_TYPE_CUSTOM,
    lockedFormFieldUids: [],
    businessType: "",
    formConfig: "",
    formConfigData: createEmptyFormConfigData(),
    enabled: true,
    flowNodes: [createEmptyNode(1)],
    storageBlobDTOs: [],
  };
}
export function normalizeFlowNodes(nodes) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list.map((n, i) => ({
    id: n.id,
    templateId: n.templateId,
    nodeOrder: i + 1,
    signMode: n.signMode === "or_sign" ? "or_sign" : "countersign",
    approvers: (n.approvers || [])
      .filter((a) => a?.approverId != null && a.approverId !== "")
      .map((a) => ({
        id: a.id,
        nodeId: a.nodeId,
        templateId: a.templateId,
        approverId: a.approverId,
        approverName: a.approverName || "",
      })),
  }));
}
export function validateTemplateForm(form) {
  const name = (form.templateName || "").trim();
  if (!name) return { ok: false, message: "请填写模板名称" };
  if (form.businessType == null || form.businessType === "") {
    return { ok: false, message: "请选择模板类型" };
  }
  const nodes = normalizeFlowNodes(form.flowNodes);
  if (!nodes.length) return { ok: false, message: "请至少配置一个审批节点" };
  for (let i = 0; i < nodes.length; i++) {
    if (!nodes[i].approvers.length) {
      return { ok: false, message: `请为第 ${i + 1} ä¸ªèŠ‚ç‚¹é€‰æ‹©è‡³å°‘ä¸€åå®¡æ‰¹äºº` };
    }
  }
  const cfgCheck = validateFormConfigData(form.formConfigData);
  if (!cfgCheck.ok) return cfgCheck;
  return { ok: true, nodes, name };
}
export function flowNodesSummary(nodes) {
  const list = normalizeFlowNodes(nodes);
  if (!list.length) return "—";
  return list
    .map((n, i) => {
      const names = n.approvers.map((a) => a.approverName || "未命名").join("、") || "未配置";
      return `节点${i + 1}(${nodeSignModeLabel(n.signMode)}:${names})`;
    })
    .join(" â†’ ");
}
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,857 @@
<!-- å®¡æ‰¹æ¨¡æ¿ï¼šå¯é…ç½®å¡«æŠ¥é¡¹ï¼Œåºåˆ—化到 formConfig -->
<template>
  <div class="fce">
    <div class="fce-hint">
      <span class="fce-hint-label">填报提示</span>
      <el-input
        v-model="inner.summaryPlaceholder"
        placeholder="如:请填写报销事由、金额等"
        maxlength="200"
        show-word-limit
        @input="emitOut"
      />
    </div>
    <div class="fce-panel">
      <div class="fce-toolbar">
        <div class="fce-toolbar-left">
          <span class="fce-title">填报项配置</span>
          <el-tag v-if="inner.fields.length" size="small" type="info" effect="plain">
            å…± {{ inner.fields.length }} é¡¹
          </el-tag>
        </div>
        <div class="fce-toolbar-actions">
          <el-dropdown
            trigger="click"
            :disabled="disableImport"
            @visible-change="onImportDropdownVisible"
            @command="importFromTemplate"
          >
            <el-button size="small" :loading="templateImportLoading" :disabled="disableImport">
              ä»Žå·²æœ‰æ¨¡æ¿å¯¼å…¥
            </el-button>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item v-if="!templateImportOptions.length" disabled>
                  æš‚无其他审批模板
                </el-dropdown-item>
                <el-dropdown-item
                  v-for="t in templateImportOptions"
                  :key="t.id"
                  :command="t.id"
                >
                  <span>{{ t.label }}</span>
                  <el-tag v-if="!t.enabled" size="small" type="info" class="import-tag">已停用</el-tag>
                </el-dropdown-item>
              </el-dropdown-menu>
            </template>
          </el-dropdown>
          <el-button type="primary" size="small" :icon="Plus" @click="addField">添加填报项</el-button>
        </div>
      </div>
      <el-empty
        v-if="!inner.fields.length"
        class="fce-empty"
        description="暂无填报项,可添加或从已有审批模板导入"
        :image-size="72"
      />
      <div v-else class="fce-list">
        <div
          v-for="(field, index) in inner.fields"
          :key="field._uid"
          class="fce-card"
          :class="{
            'fce-card--required': field.required,
            'fce-card--locked': isFieldLocked(field),
          }"
        >
          <div class="fce-card-badge">{{ index + 1 }}</div>
          <div class="fce-card-head">
            <div class="fce-card-title">
              <span class="fce-card-name">{{ field.label || `填报项 ${index + 1}` }}</span>
              <el-tag size="small" effect="light" type="primary">{{ typeLabel(field.type) }}</el-tag>
              <el-tag v-if="field.required" size="small" type="danger" effect="plain">必填</el-tag>
              <el-tag v-if="isFieldLocked(field)" size="small" type="info" effect="plain">内置项</el-tag>
            </div>
            <div v-if="!isFieldLocked(field)" class="fce-card-btns">
              <el-tooltip content="上移" placement="top">
                <el-button circle size="small" :disabled="index === 0" @click="moveField(index, -1)">
                  <el-icon><Top /></el-icon>
                </el-button>
              </el-tooltip>
              <el-tooltip content="下移" placement="top">
                <el-button
                  circle
                  size="small"
                  :disabled="index >= inner.fields.length - 1"
                  @click="moveField(index, 1)"
                >
                  <el-icon><Bottom /></el-icon>
                </el-button>
              </el-tooltip>
              <el-tooltip content="删除" placement="top">
                <el-button circle size="small" type="danger" plain @click="removeField(index)">
                  <el-icon><Delete /></el-icon>
                </el-button>
              </el-tooltip>
            </div>
          </div>
          <div class="fce-section">
            <span class="fce-section-title">基础信息</span>
            <el-row :gutter="16">
              <el-col :span="8">
                <el-form-item label="显示名称" required class="fce-field-item">
                  <el-input
                    v-model="field.label"
                    placeholder="如:报销说明"
                    maxlength="50"
                    :disabled="isFieldLocked(field)"
                    @input="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item label="字段标识" required class="fce-field-item">
                  <el-input
                    v-model="field.key"
                    placeholder="如:summary"
                    maxlength="50"
                    :disabled="isFieldLocked(field)"
                    @input="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item label="控件类型" class="fce-field-item">
                  <el-select
                    v-model="field.type"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="onTypeChange(field)"
                  >
                    <el-option
                      v-for="t in FORM_FIELD_TYPE_OPTIONS"
                      :key="t.value"
                      :label="t.label"
                      :value="t.value"
                    />
                  </el-select>
                </el-form-item>
              </el-col>
            </el-row>
          </div>
          <div class="fce-section">
            <span class="fce-section-title">校验与格式</span>
            <el-row :gutter="16" align="middle">
              <el-col :span="8">
                <el-form-item label="是否必填" class="fce-field-item fce-field-item--switch">
                  <el-switch
                    v-model="field.required"
                    inline-prompt
                    active-text="必填"
                    inactive-text="选填"
                    :disabled="isFieldLocked(field)"
                    @change="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col v-if="field.type === 'textarea'" :span="8">
                <el-form-item label="行数" class="fce-field-item">
                  <el-input-number
                    v-model="field.rows"
                    :min="1"
                    :max="10"
                    controls-position="right"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="emitOut"
                  />
                </el-form-item>
              </el-col>
              <template v-if="field.type === 'number'">
                <el-col :span="8">
                  <el-form-item label="最小值" class="fce-field-item">
                    <el-input-number
                      v-model="field.min"
                      controls-position="right"
                      style="width: 100%"
                      :disabled="isFieldLocked(field)"
                      @change="emitOut"
                    />
                  </el-form-item>
                </el-col>
                <el-col :span="8">
                  <el-form-item label="小数位" class="fce-field-item">
                    <el-input-number
                      v-model="field.precision"
                      :min="0"
                      :max="4"
                      controls-position="right"
                      style="width: 100%"
                      :disabled="isFieldLocked(field)"
                      @change="emitOut"
                    />
                  </el-form-item>
                </el-col>
              </template>
            </el-row>
          </div>
          <div class="fce-section fce-section--default">
            <span class="fce-section-title">默认值</span>
            <p class="fce-section-desc">选择该模板提交审批时,将自动预填以下内容(用户仍可修改)</p>
            <el-input
              v-if="field.type === 'text' || field.type === 'textarea'"
              v-model="field.defaultValue"
              :type="field.type === 'textarea' ? 'textarea' : 'text'"
              :rows="field.type === 'textarea' ? 2 : undefined"
              :placeholder="defaultPlaceholder(field)"
              :disabled="isFieldLocked(field)"
              clearable
              @input="emitOut"
            />
            <el-input-number
              v-else-if="field.type === 'number'"
              v-model="field.defaultValue"
              :min="field.min"
              :precision="field.precision ?? 0"
              controls-position="right"
              placeholder="选填"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              @change="emitOut"
            />
            <el-date-picker
              v-else-if="field.type === 'date'"
              v-model="field.defaultValue"
              type="date"
              placeholder="选填"
              format="YYYY-MM-DD"
              value-format="YYYY-MM-DD"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              clearable
              @change="emitOut"
            />
            <el-date-picker
              v-else-if="field.type === 'datetimerange'"
              v-model="field.defaultValue"
              type="datetimerange"
              range-separator="至"
              start-placeholder="开始时间"
              end-placeholder="结束时间"
              format="YYYY-MM-DD HH:mm:ss"
              value-format="YYYY-MM-DD HH:mm:ss"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              clearable
              @change="emitOut"
            />
            <el-select
              v-else-if="field.type === 'select'"
              v-model="field.defaultValue"
              placeholder="选填"
              style="width: 100%"
              clearable
              filterable
              :loading="optionSourceLoading"
              :disabled="isFieldLocked(field)"
              @change="emitOut"
            >
              <el-option
                v-for="o in resolvedSelectOptions(field)"
                :key="String(o.value)"
                :label="o.label || o.value"
                :value="o.value"
              />
            </el-select>
          </div>
          <div v-if="field.type === 'select'" class="fce-section fce-section--options">
            <span class="fce-section-title">下拉选项</span>
            <el-row :gutter="16" class="fce-source-row">
              <el-col :span="12">
                <el-form-item label="选项来源" class="fce-field-item">
                  <el-select
                    v-model="field.optionSource"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="onOptionSourceChange(field)"
                  >
                    <el-option
                      v-for="s in SELECT_OPTION_SOURCE_OPTIONS"
                      :key="s.value"
                      :label="s.label"
                      :value="s.value"
                    />
                  </el-select>
                </el-form-item>
              </el-col>
            </el-row>
            <p v-if="isDynamicOptionSource(field.optionSource)" class="fce-source-tip">
              {{ optionSourceDesc(field.optionSource) }}。提交审批时将自动加载最新数据,无需手动维护选项。
            </p>
            <template v-if="!isDynamicOptionSource(field.optionSource)">
              <div class="fce-options-head">
                <span class="fce-section-subtitle">手动选项</span>
                <el-button
                  type="primary"
                  link
                  size="small"
                  :icon="Plus"
                  :disabled="isFieldLocked(field)"
                  @click="addOption(field)"
                >
                  æ·»åР选项
                </el-button>
              </div>
              <div
                v-for="(opt, oi) in field.options"
                :key="oi"
                class="fce-option-row"
              >
                <span class="fce-option-index">{{ oi + 1 }}</span>
                <el-input
                  v-model="opt.label"
                  placeholder="显示文本"
                  :disabled="isFieldLocked(field)"
                  @input="emitOut"
                />
                <el-input
                  v-model="opt.value"
                  placeholder="选项值"
                  class="fce-option-value"
                  :disabled="isFieldLocked(field)"
                  @input="emitOut"
                />
                <el-button
                  type="danger"
                  link
                  :icon="Delete"
                  :disabled="isFieldLocked(field) || field.options.length <= 1"
                  @click="removeOption(field, oi)"
                />
              </div>
            </template>
          </div>
        </div>
      </div>
    </div>
  </div>
</template>
<script setup>
import { Bottom, Delete, Plus, Top } from "@element-plus/icons-vue";
import {
  getApprovalTemplateDetail,
  listApprovalTemplate,
  TEMPLATE_TYPE_BUILTIN,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import {
  mapEnabledFromApi,
  unwrapTemplateDetail,
  unwrapTemplateList,
} from "../approveTemplateConstants.js";
import {
  FORM_FIELD_TYPE_OPTIONS,
  createEmptyFormConfigData,
  createEmptyFormField,
  formFieldTypeLabel,
  parseFormConfigToData,
} from "../formConfigUtils.js";
import {
  SELECT_OPTION_SOURCE,
  SELECT_OPTION_SOURCE_OPTIONS,
  isDynamicOptionSource,
} from "../selectOptionSource.js";
import { useSelectOptionSources } from "../useSelectOptionSources.js";
const props = defineProps({
  modelValue: { type: Object, default: () => createEmptyFormConfigData() },
  /** ç¼–辑当前模板时排除自身,避免从自己导入 */
  excludeTemplateId: { type: [String, Number], default: null },
  /** ç¦ç”¨ã€Œä»Žå·²æœ‰æ¨¡æ¿å¯¼å…¥ã€ */
  disableImport: { type: Boolean, default: false },
  /** ç³»ç»Ÿå†…置模板编辑时,打开弹窗即存在的填报项 _uid,不可改删 */
  lockedFieldUids: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue"]);
const inner = reactive(createEmptyFormConfigData());
const { loading: optionSourceLoading, ensureForFields, getOptions } = useSelectOptionSources();
const templateImportOptions = ref([]);
const templateImportLoading = ref(false);
const lockedUidSet = computed(
  () => new Set((props.lockedFieldUids || []).filter(Boolean))
);
function isFieldLocked(field) {
  return field?._uid != null && lockedUidSet.value.has(field._uid);
}
function typeLabel(type) {
  return formFieldTypeLabel(type);
}
function defaultPlaceholder(field) {
  const name = field.label || "该字段";
  return `选填,选择模板时将预填${name}`;
}
function optionSourceDesc(source) {
  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.desc || "";
}
function resolvedSelectOptions(field) {
  if (field.type !== "select") return [];
  return getOptions(field);
}
function syncFromProps(v) {
  const src = v || createEmptyFormConfigData();
  inner.summaryPlaceholder = src.summaryPlaceholder || "";
  inner.fields = (src.fields || []).map((f) => ({
    ...createEmptyFormField(),
    ...f,
    _uid: f._uid || createEmptyFormField()._uid,
    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
    options: (f.options || [{ label: "", value: "" }]).map((o) => ({ ...o })),
  }));
  ensureForFields(inner.fields);
}
function emitOut() {
  emit("update:modelValue", {
    summaryPlaceholder: inner.summaryPlaceholder,
    fields: inner.fields.map((f) => ({
      _uid: f._uid,
      key: f.key,
      label: f.label,
      type: f.type,
      required: f.required,
      rows: f.rows,
      min: f.min,
      precision: f.precision,
      defaultValue: cloneDefaultValue(f),
      optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
      options: (f.options || []).map((o) => ({ label: o.label, value: o.value })),
    })),
  });
}
function cloneDefaultValue(f) {
  if (f.type === "datetimerange" && Array.isArray(f.defaultValue)) {
    return [...f.defaultValue];
  }
  return f.defaultValue;
}
watch(
  () => props.modelValue,
  (v) => syncFromProps(v),
  { deep: true, immediate: true }
);
function addField() {
  inner.fields.push(createEmptyFormField());
  ensureForFields(inner.fields);
  emitOut();
}
function removeField(index) {
  if (isFieldLocked(inner.fields[index])) return;
  inner.fields.splice(index, 1);
  emitOut();
}
function moveField(index, delta) {
  if (isFieldLocked(inner.fields[index])) return;
  const next = index + delta;
  if (next < 0 || next >= inner.fields.length) return;
  if (isFieldLocked(inner.fields[next])) return;
  const t = inner.fields[index];
  inner.fields[index] = inner.fields[next];
  inner.fields[next] = t;
  emitOut();
}
function resetDefaultValueForType(field) {
  if (field.type === "number") field.defaultValue = undefined;
  else if (field.type === "datetimerange") field.defaultValue = [];
  else field.defaultValue = "";
}
function onTypeChange(field) {
  if (field.type === "select") {
    if (!field.optionSource) field.optionSource = SELECT_OPTION_SOURCE.STATIC;
    if (!field.options || !field.options.length) {
      field.options = [{ label: "", value: "" }];
    }
    ensureForFields(inner.fields);
  }
  resetDefaultValueForType(field);
  emitOut();
}
function onOptionSourceChange(field) {
  field.defaultValue = "";
  if (!isDynamicOptionSource(field.optionSource) && (!field.options || !field.options.length)) {
    field.options = [{ label: "", value: "" }];
  }
  ensureForFields(inner.fields);
  emitOut();
}
function addOption(field) {
  field.options.push({ label: "", value: "" });
  emitOut();
}
function removeOption(field, oi) {
  if (field.options.length <= 1) return;
  field.options.splice(oi, 1);
  emitOut();
}
async function loadTemplateImportOptions() {
  templateImportLoading.value = true;
  try {
    const [customRes, builtinRes] = await Promise.all([
      listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      listApprovalTemplate(TEMPLATE_TYPE_BUILTIN),
    ]);
    const excludeId =
      props.excludeTemplateId != null && props.excludeTemplateId !== ""
        ? String(props.excludeTemplateId)
        : "";
    templateImportOptions.value = [...unwrapTemplateList(customRes), ...unwrapTemplateList(builtinRes)]
      .filter((row) => row?.id != null && String(row.id) !== excludeId)
      .map((row) => ({
        id: row.id,
        label: row.templateName || `模板 #${row.id}`,
        enabled: mapEnabledFromApi(row.enabled),
      }));
  } catch {
    templateImportOptions.value = [];
    ElMessage.error("加载审批模板列表失败");
  } finally {
    templateImportLoading.value = false;
  }
}
function onImportDropdownVisible(visible) {
  if (props.disableImport) return;
  if (visible) loadTemplateImportOptions();
}
async function importFromTemplate(templateId) {
  if (!templateId) return;
  if (inner.fields.length) {
    try {
      await ElMessageBox.confirm("将覆盖当前填报项配置,是否继续?", "从模板导入", {
        type: "warning",
        confirmButtonText: "继续导入",
        cancelButtonText: "取消",
      });
    } catch {
      return;
    }
  }
  templateImportLoading.value = true;
  try {
    const res = await getApprovalTemplateDetail(templateId);
    const row = unwrapTemplateDetail(res);
    const data = parseFormConfigToData(row?.formConfig);
    if (!data.fields?.length) {
      ElMessage.warning("该模板未配置填报项");
      return;
    }
    syncFromProps(data);
    emitOut();
    ElMessage.success(`已导入「${row.templateName || "模板"}」的填报项`);
  } catch {
    ElMessage.error("加载模板详情失败");
  } finally {
    templateImportLoading.value = false;
  }
}
</script>
<style scoped>
.fce {
  width: 100%;
}
.fce-hint {
  padding: 14px 16px;
  margin-bottom: 14px;
  border-radius: 10px;
  background: linear-gradient(135deg, var(--el-color-primary-light-9) 0%, var(--el-fill-color-blank) 100%);
  border: 1px solid var(--el-color-primary-light-7);
}
.fce-hint-label {
  display: block;
  font-size: 13px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin-bottom: 8px;
}
.fce-panel {
  padding: 16px;
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
  border: 1px solid var(--el-border-color-lighter);
}
.fce-toolbar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 12px;
  margin-bottom: 16px;
}
.fce-toolbar-left {
  display: flex;
  align-items: center;
  gap: 10px;
}
.fce-title {
  font-size: 15px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.fce-toolbar-actions {
  display: flex;
  align-items: center;
  gap: 8px;
}
.import-tag {
  margin-left: 8px;
  vertical-align: middle;
}
.fce-empty {
  padding: 24px 0;
}
.fce-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}
.fce-card {
  position: relative;
  padding: 16px 16px 12px;
  border-radius: 12px;
  background: var(--el-bg-color);
  border: 1px solid var(--el-border-color-lighter);
  box-shadow: 0 1px 4px rgba(0, 0, 0, 0.04);
  transition: border-color 0.2s, box-shadow 0.2s;
}
.fce-card:hover {
  border-color: var(--el-color-primary-light-5);
  box-shadow: 0 4px 12px rgba(0, 0, 0, 0.06);
}
.fce-card--required {
  border-left: 3px solid var(--el-color-danger-light-3);
}
.fce-card--locked {
  background: var(--el-fill-color-light);
}
.fce-card-badge {
  position: absolute;
  top: -10px;
  left: 16px;
  min-width: 22px;
  height: 22px;
  padding: 0 6px;
  border-radius: 11px;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
  box-shadow: 0 2px 6px rgba(var(--el-color-primary-rgb), 0.35);
}
.fce-card-head {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 12px;
  margin-bottom: 14px;
  padding-top: 4px;
}
.fce-card-title {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  min-width: 0;
}
.fce-card-name {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.fce-card-btns {
  display: flex;
  align-items: center;
  gap: 4px;
  flex-shrink: 0;
}
.fce-section {
  margin-bottom: 12px;
  padding-bottom: 12px;
  border-bottom: 1px dashed var(--el-border-color-extra-light);
}
.fce-section:last-child {
  margin-bottom: 0;
  padding-bottom: 0;
  border-bottom: none;
}
.fce-section-title {
  display: block;
  font-size: 12px;
  font-weight: 600;
  color: var(--el-text-color-secondary);
  text-transform: uppercase;
  letter-spacing: 0.5px;
  margin-bottom: 10px;
}
.fce-section-desc {
  margin: -6px 0 10px;
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  line-height: 1.5;
}
.fce-section--default {
  padding: 12px 14px;
  border-radius: 8px;
  background: var(--el-fill-color-lighter);
  border-bottom: none;
  margin-bottom: 0;
}
.fce-section--default .fce-section-title {
  margin-bottom: 4px;
  color: var(--el-color-primary);
  text-transform: none;
  letter-spacing: 0;
  font-size: 13px;
}
.fce-section--options {
  padding-top: 4px;
  border-bottom: none;
  margin-bottom: 0;
}
.fce-field-item {
  margin-bottom: 0;
}
.fce-field-item :deep(.el-form-item__label) {
  font-size: 13px;
  color: var(--el-text-color-regular);
}
.fce-field-item--switch :deep(.el-form-item__content) {
  line-height: 32px;
}
.fce-options-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 10px;
}
.fce-options-head .fce-section-title,
.fce-options-head .fce-section-subtitle {
  margin-bottom: 0;
}
.fce-section-subtitle {
  font-size: 12px;
  font-weight: 600;
  color: var(--el-text-color-secondary);
}
.fce-source-row {
  margin-bottom: 4px;
}
.fce-source-tip {
  margin: 0 0 10px;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
.fce-option-row {
  display: flex;
  align-items: center;
  gap: 10px;
  margin-bottom: 8px;
  padding: 8px 10px;
  border-radius: 8px;
  background: var(--el-fill-color-lighter);
}
.fce-option-row:last-child {
  margin-bottom: 0;
}
.fce-option-index {
  flex-shrink: 0;
  width: 20px;
  height: 20px;
  border-radius: 50%;
  background: var(--el-color-info-light-8);
  color: var(--el-text-color-secondary);
  font-size: 11px;
  font-weight: 600;
  display: flex;
  align-items: center;
  justify-content: center;
}
.fce-option-value {
  width: 140px;
  flex-shrink: 0;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,399 @@
<!-- å®¡æ‰¹æ¨¡æ¿ï¼šå¯é…ç½®èŠ‚ç‚¹æ•°ï¼Œæ¯èŠ‚ç‚¹å¤šäºº + ä¼šç­¾/或签 -->
<template>
  <div class="tfe">
    <div v-if="innerList.length" class="tfe-flow">
      <div v-for="(item, index) in innerList" :key="item._uid" class="tfe-flow-item">
        <div class="tfe-card" :class="{ 'tfe-card--empty': !item.approvers?.length }">
          <div class="tfe-badge">{{ index + 1 }}</div>
          <div class="tfe-head">
            <span class="tfe-level">{{ levelText(index) }}</span>
            <el-radio-group
              v-if="!readonly"
              v-model="item.signMode"
              size="small"
              @change="emitOut"
            >
              <el-radio-button value="countersign">会签</el-radio-button>
              <el-radio-button value="or_sign">或签</el-radio-button>
            </el-radio-group>
            <el-tag v-else size="small" type="info" effect="plain">
              {{ signModeLabel(item.signMode) }}
            </el-tag>
          </div>
          <p class="tfe-mode-tip">{{ signModeTip(item.signMode) }}</p>
          <div v-if="!readonly" class="tfe-select">
            <el-select
              v-model="item.approverIds"
              multiple
              collapse-tags
              collapse-tags-tooltip
              :max-collapse-tags="2"
              filterable
              placeholder="请选择审批人(可多选)"
              style="width: 100%"
              @change="(ids) => onApproversChange(ids, item)"
            >
              <el-option
                v-for="u in userOptions"
                :key="String(u.userId ?? u.id)"
                :label="optionLabel(u)"
                :value="u.userId ?? u.id"
              />
            </el-select>
          </div>
          <div v-if="item.approvers?.length" class="tfe-chips" :class="{ 'tfe-chips--readonly': readonly }">
            <el-tag
              v-for="a in item.approvers"
              :key="String(a.approverId)"
              size="small"
              type="info"
              effect="plain"
            >
              {{ a.approverName || "—" }}
            </el-tag>
          </div>
          <div v-if="!readonly" class="tfe-actions">
            <el-button type="primary" circle size="small" :disabled="index === 0" title="前移" @click="moveLeft(index)">
              <el-icon><ArrowLeft /></el-icon>
            </el-button>
            <el-button
              type="primary"
              circle
              size="small"
              :disabled="index === innerList.length - 1"
              title="后移"
              @click="moveRight(index)"
            >
              <el-icon><ArrowRight /></el-icon>
            </el-button>
            <el-button type="danger" circle size="small" title="删除节点" @click="remove(index)">
              <el-icon><Delete /></el-icon>
            </el-button>
          </div>
          <p v-else-if="!item.approvers?.length" class="tfe-empty-approver">暂无审批人</p>
        </div>
        <div v-if="index < innerList.length - 1" class="tfe-conn">
          <div class="tfe-conn-line"></div>
          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
        </div>
      </div>
      <div v-if="!readonly" class="tfe-add-wrap">
        <div v-if="innerList.length" class="tfe-conn">
          <div class="tfe-conn-line"></div>
          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
        </div>
        <div class="tfe-add-card" @click="addNode">
          <div class="tfe-add-icon"><el-icon :size="26"><Plus /></el-icon></div>
          <span>新增节点</span>
        </div>
      </div>
    </div>
    <div v-else class="tfe-empty">
      <el-icon :size="44" color="#c0c4cc"><User /></el-icon>
      <p>暂无审批节点</p>
      <el-button v-if="!readonly" type="primary" @click="addNode">添加第一个节点</el-button>
    </div>
  </div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue";
import { ref, watch } from "vue";
import { NODE_SIGN_MODE_OPTIONS, normalizeFlowNodes } from "../approveTemplateConstants.js";
const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  userOptions: { type: Array, default: () => [] },
  /** é€‰æ‹©æ¨¡æ¿åŽç”³è¯·åœºæ™¯ï¼šä»…展示,不可改审批人/节点 */
  readonly: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue"]);
const innerList = ref([]);
function signModeTip(mode) {
  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.desc || "";
}
function signModeLabel(mode) {
  return (
    NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.label ||
    (mode === "or_sign" ? "或签" : "会签")
  );
}
function levelText(i) {
  const t = ["第一级", "第二级", "第三级", "第四级", "第五级", "第六级", "第七级", "第八级"];
  return t[i] || `第${i + 1}级`;
}
function optionLabel(u) {
  const nick = u.nickName || "";
  const un = u.userName || "";
  if (nick && un && nick !== un) return `${nick}(${un})`;
  return nick || un || `用户${u.userId ?? u.id ?? ""}`;
}
function newUid() {
  return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
function mapIn(rows) {
  const normalized = normalizeFlowNodes(rows);
  return normalized.map((n) => ({
    _uid: newUid(),
    id: n.id,
    templateId: n.templateId,
    nodeOrder: n.nodeOrder,
    signMode: n.signMode,
    approverIds: n.approvers.map((a) => a.approverId),
    approvers: [...n.approvers],
  }));
}
function publicShape(rows) {
  return normalizeFlowNodes(
    (rows || []).map((r) => ({
      id: r.id,
      templateId: r.templateId,
      nodeOrder: r.nodeOrder,
      signMode: r.signMode,
      approvers: r.approvers || [],
    }))
  );
}
function emitOut() {
  emit("update:modelValue", publicShape(innerList.value));
}
watch(
  () => props.modelValue,
  (v) => {
    const next = publicShape(v || []);
    if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return;
    innerList.value = mapIn(v || []);
  },
  { deep: true, immediate: true }
);
function findUser(id) {
  if (id == null || id === "") return null;
  return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null;
}
function onApproversChange(ids, row) {
  const idList = Array.isArray(ids) ? ids : [];
  const prevById = new Map((row.approvers || []).map((a) => [String(a.approverId), a]));
  row.approverIds = idList;
  row.approvers = idList.map((id) => {
    const prev = prevById.get(String(id));
    const u = findUser(id);
    const item = {
      approverId: id,
      approverName: u ? u.nickName || u.userName || "" : prev?.approverName || "",
    };
    if (prev?.id != null) item.id = prev.id;
    if (prev?.nodeId != null) item.nodeId = prev.nodeId;
    else if (row.id != null) item.nodeId = row.id;
    if (prev?.templateId != null) item.templateId = prev.templateId;
    else if (row.templateId != null) item.templateId = row.templateId;
    return item;
  });
  emitOut();
}
function addNode() {
  if (props.readonly) return;
  innerList.value.push({
    _uid: newUid(),
    nodeOrder: innerList.value.length + 1,
    signMode: "countersign",
    approverIds: [],
    approvers: [],
  });
  emitOut();
}
function remove(index) {
  if (props.readonly) return;
  innerList.value.splice(index, 1);
  emitOut();
}
function moveLeft(index) {
  if (props.readonly) return;
  if (index < 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index - 1];
  innerList.value[index - 1] = t;
  emitOut();
}
function moveRight(index) {
  if (props.readonly) return;
  if (index >= innerList.value.length - 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index + 1];
  innerList.value[index + 1] = t;
  emitOut();
}
</script>
<style scoped>
.tfe {
  width: 100%;
}
.tfe-flow {
  display: flex;
  align-items: flex-start;
  flex-wrap: nowrap;
  overflow-x: auto;
  padding: 6px 0 10px;
}
.tfe-flow-item {
  display: flex;
  align-items: center;
}
.tfe-card {
  width: 248px;
  flex-shrink: 0;
  border: 2px solid var(--el-border-color);
  border-radius: 12px;
  padding: 14px 12px 12px;
  position: relative;
  background: var(--el-bg-color);
}
.tfe-card--empty {
  border-style: dashed;
  background: var(--el-fill-color-lighter);
}
.tfe-badge {
  position: absolute;
  top: -8px;
  left: 12px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tfe-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin: 8px 0 4px;
}
.tfe-level {
  font-size: 13px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.tfe-mode-tip {
  font-size: 11px;
  color: var(--el-text-color-secondary);
  margin: 0 0 10px;
  line-height: 1.4;
  min-height: 30px;
}
.tfe-select {
  margin-bottom: 8px;
}
.tfe-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-bottom: 8px;
  min-height: 24px;
}
.tfe-chips--readonly {
  margin-top: 4px;
  margin-bottom: 0;
}
.tfe-empty-approver {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
  margin: 4px 0 0;
}
.tfe-actions {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--el-border-color-lighter);
}
.tfe-conn {
  display: flex;
  align-items: center;
  width: 40px;
  flex-shrink: 0;
  align-self: center;
}
.tfe-conn-line {
  flex: 1;
  height: 2px;
  background: var(--el-border-color);
}
.tfe-conn-icon {
  font-size: 14px;
  color: var(--el-text-color-placeholder);
  margin-left: -2px;
}
.tfe-add-wrap {
  display: flex;
  align-items: center;
}
.tfe-add-card {
  width: 120px;
  min-height: 200px;
  flex-shrink: 0;
  border: 2px dashed var(--el-border-color);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  cursor: pointer;
  color: var(--el-text-color-regular);
  font-size: 13px;
  background: var(--el-fill-color-lighter);
  transition: border-color 0.2s, background 0.2s;
}
.tfe-add-card:hover {
  border-color: var(--el-color-primary);
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
}
.tfe-add-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tfe-empty {
  text-align: center;
  padding: 28px 16px;
  border: 1px dashed var(--el-border-color);
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
}
.tfe-empty p {
  margin: 10px 0 14px;
  color: var(--el-text-color-secondary);
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,301 @@
import { mapAttachmentsFromApi } from "./approveTemplateConstants.js";
import {
  isDynamicOptionSource,
  SELECT_OPTION_SOURCE,
  selectOptionSourceLabel,
} from "./selectOptionSource.js";
export { selectOptionSourceLabel };
/** å¡«æŠ¥é¡¹ç±»åž‹ï¼ˆä¸Žå®¡æ‰¹æäº¤é¡µ field.type ä¸€è‡´ï¼‰ */
export const FORM_FIELD_TYPE_OPTIONS = [
  { value: "text", label: "单行文本" },
  { value: "textarea", label: "多行文本" },
  { value: "number", label: "数字" },
  { value: "date", label: "日期" },
  { value: "datetimerange", label: "日期时间范围" },
  { value: "select", label: "下拉选择" },
];
/** å¸¸ç”¨é¢„设(如费用报销) */
export const FORM_CONFIG_PRESETS = [
  {
    key: "cost_reimburse",
    label: "费用报销",
    summaryPlaceholder: "请填写报销事由、金额等",
    fields: [
      { key: "summary", label: "报销说明", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
    ],
  },
  {
    key: "travel_reimburse",
    label: "差旅报销",
    summaryPlaceholder: "出差行程与费用说明",
    fields: [
      { key: "summary", label: "差旅说明", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
      { key: "tripDays", label: "出差天数", type: "number", required: false, min: 0, precision: 0 },
    ],
  },
  {
    key: "leave",
    label: "请假申请",
    summaryPlaceholder: "请填写请假类型与时间",
    fields: [
      {
        key: "leaveType",
        label: "请假类型",
        type: "select",
        required: true,
        options: [
          { label: "年假", value: "annual" },
          { label: "病假", value: "sick" },
          { label: "事假", value: "personal" },
          { label: "调休", value: "compensatory" },
        ],
      },
      { key: "summary", label: "请假事由", type: "textarea", required: true, rows: 2 },
      { key: "dateRange", label: "请假时间", type: "datetimerange", required: true },
    ],
  },
];
function newFieldUid() {
  return `f_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
}
export function createEmptyFormField() {
  return {
    _uid: newFieldUid(),
    key: "",
    label: "",
    type: "text",
    required: true,
    rows: 3,
    min: 0,
    precision: 0,
    defaultValue: "",
    optionSource: SELECT_OPTION_SOURCE.STATIC,
    options: [{ label: "", value: "" }],
  };
}
/** è§£æžå•项默认值(供提交页 formPayload åˆå§‹åŒ–) */
export function resolveFieldDefaultValue(field) {
  const type = field?.type || "text";
  const dv = field?.defaultValue;
  if (dv === undefined || dv === null || dv === "") {
    if (type === "number") return undefined;
    if (type === "datetimerange") return [];
    return "";
  }
  if (type === "number") {
    const n = Number(dv);
    return Number.isNaN(n) ? undefined : n;
  }
  if (type === "datetimerange") {
    return Array.isArray(dv) ? [...dv] : [];
  }
  return dv;
}
function hasDefaultValue(field) {
  const type = field?.type || "text";
  const dv = field?.defaultValue;
  if (dv === undefined || dv === null) return false;
  if (type === "number") return dv !== "" && !Number.isNaN(Number(dv));
  if (type === "datetimerange") return Array.isArray(dv) && dv.length === 2;
  if (type === "select") return dv !== "";
  return String(dv).trim() !== "";
}
/** æ ¹æ®å­—段定义生成 formPayload åˆå§‹å€¼ï¼ˆå«é»˜è®¤å€¼ï¼‰ */
export function buildFormPayloadFromFields(fields) {
  const payload = {};
  (fields || []).forEach((f) => {
    const key = (f.key || "").trim();
    if (!key) return;
    payload[key] = resolveFieldDefaultValue(f);
  });
  return payload;
}
export function createEmptyFormConfigData() {
  return {
    summaryPlaceholder: "",
    fields: [],
  };
}
function parseFormConfigRaw(formConfig) {
  if (!formConfig) return {};
  if (typeof formConfig === "object") return formConfig;
  try {
    return JSON.parse(formConfig);
  } catch {
    return {};
  }
}
function normalizeDefaultValueFromApi(f) {
  const type = f.type || "text";
  if (f.defaultValue === undefined || f.defaultValue === null) {
    if (type === "number") return undefined;
    if (type === "datetimerange") return [];
    return "";
  }
  if (type === "datetimerange" && Array.isArray(f.defaultValue)) {
    return [...f.defaultValue];
  }
  return f.defaultValue;
}
/** æŽ¥å£ formConfig â†’ ç¼–辑器数据 */
export function parseFormConfigToData(formConfig) {
  const raw = parseFormConfigRaw(formConfig);
  const fields = (raw.fields || raw.formFields || []).map((f) => ({
    _uid: newFieldUid(),
    key: f.key || "",
    label: f.label || "",
    type: f.type || "text",
    required: f.required !== false,
    rows: f.rows ?? 3,
    min: f.min ?? 0,
    precision: f.precision ?? 0,
    defaultValue: normalizeDefaultValueFromApi(f),
    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
    options: (f.options || []).length
      ? f.options.map((o) => ({ label: o.label || "", value: o.value ?? "" }))
      : [{ label: "", value: "" }],
  }));
  return {
    summaryPlaceholder: raw.summaryPlaceholder || "",
    fields,
  };
}
/** ç¼–辑器数据 â†’ æäº¤ç”¨ JSON å­—符串 */
export function buildFormConfigJson(formConfigData) {
  const data = formConfigData || createEmptyFormConfigData();
  const fields = (data.fields || []).map((f) => {
    const item = {
      key: (f.key || "").trim(),
      label: (f.label || "").trim(),
      type: f.type || "text",
      required: f.required !== false,
    };
    if (item.type === "textarea") item.rows = Number(f.rows) || 3;
    if (item.type === "number") {
      item.min = f.min ?? 0;
      item.precision = f.precision ?? 0;
    }
    if (item.type === "select") {
      const source = f.optionSource || SELECT_OPTION_SOURCE.STATIC;
      item.optionSource = source;
      if (!isDynamicOptionSource(source)) {
        item.options = (f.options || [])
          .filter((o) => (o.label || "").trim() || (o.value !== "" && o.value != null))
          .map((o) => ({ label: (o.label || "").trim(), value: o.value }));
      }
    }
    if (hasDefaultValue(f)) {
      item.defaultValue =
        f.type === "datetimerange" && Array.isArray(f.defaultValue)
          ? f.defaultValue
          : f.defaultValue;
    }
    return item;
  });
  const payload = {
    summaryPlaceholder: (data.summaryPlaceholder || "").trim(),
    fields,
  };
  return JSON.stringify(payload);
}
export function applyFormConfigPreset(presetKey) {
  const preset = FORM_CONFIG_PRESETS.find((p) => p.key === presetKey);
  if (!preset) return createEmptyFormConfigData();
  return parseFormConfigToData({
    summaryPlaceholder: preset.summaryPlaceholder,
    fields: preset.fields,
  });
}
export function validateFormConfigData(formConfigData) {
  const fields = formConfigData?.fields || [];
  if (!fields.length) {
    return { ok: true };
  }
  const keys = new Set();
  for (let i = 0; i < fields.length; i++) {
    const f = fields[i];
    const key = (f.key || "").trim();
    const label = (f.label || "").trim();
    if (!key) return { ok: false, message: `请填写第 ${i + 1} ä¸ªå¡«æŠ¥é¡¹çš„字段标识` };
    if (!label) return { ok: false, message: `请填写第 ${i + 1} ä¸ªå¡«æŠ¥é¡¹çš„æ˜¾ç¤ºåç§°` };
    if (keys.has(key)) return { ok: false, message: `字段标识「${key}」重复,请修改` };
    keys.add(key);
    if (f.type === "select") {
      const source = f.optionSource || SELECT_OPTION_SOURCE.STATIC;
      if (isDynamicOptionSource(source)) continue;
      const opts = (f.options || []).filter((o) => (o.label || "").trim() && o.value !== "" && o.value != null);
      if (!opts.length) return { ok: false, message: `请为「${label}」配置至少一个下拉选项` };
    }
  }
  return { ok: true };
}
export function formFieldTypeLabel(type) {
  return FORM_FIELD_TYPE_OPTIONS.find((x) => x.value === type)?.label || type || "—";
}
export function formatDefaultValueDisplay(field) {
  const dv = field?.defaultValue;
  if (dv === undefined || dv === null || dv === "") return "—";
  if (field?.type === "datetimerange" && Array.isArray(dv)) {
    return dv.length === 2 ? `${dv[0]} ~ ${dv[1]}` : "—";
  }
  if (field?.type === "select") {
    if (isDynamicOptionSource(field.optionSource)) {
      return `${selectOptionSourceLabel(field.optionSource)}:${String(dv)}`;
    }
    const opt = (field.options || []).find((o) => String(o.value) === String(dv));
    return opt?.label || String(dv);
  }
  return String(dv);
}
/** å°†åŽç«¯æ¨¡æ¿è¡Œè½¬ä¸ºæäº¤é¡µæ¨¡æ¿ç»“构(含 fields é»˜è®¤å€¼ã€é™„件) */
export function buildSubmitTemplateFromRow(row) {
  const cfg = parseFormConfigToData(row?.formConfig);
  const fields = (cfg.fields || []).map(({ _uid, ...rest }) => ({
    ...rest,
    key: rest.key,
    label: rest.label,
    type: rest.type,
    required: rest.required,
    rows: rest.rows,
    min: rest.min,
    precision: rest.precision,
    defaultValue: rest.defaultValue,
    optionSource: rest.optionSource,
    options: rest.options,
  }));
  return {
    label: row?.templateName || "审批",
    businessType: row?.businessType ?? cfg.approvalType ?? "",
    approvalType: cfg.approvalType || "",
    summaryPlaceholder: cfg.summaryPlaceholder || "",
    approvalMode: cfg.approvalMode || "parallel",
    fields,
    storageBlobDTOs: mapAttachmentsFromApi(row),
  };
}
export function formConfigFieldsSummary(formConfigData) {
  const fields = formConfigData?.fields || [];
  if (!fields.length) return "—";
  return fields.map((f) => f.label || f.key || "未命名").join("、");
}
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,819 @@
<!--OA模块:审批模板-->
<template>
  <div class="app-container approve-template-page">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">模板名称:</span>
        <el-input
          v-model="searchForm.keyword"
          style="width: 220px"
          placeholder="搜索名称或说明"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <el-checkbox v-model="searchForm.enabledOnly" class="ml12" @change="handleQuery">
          ä»…显示启用
        </el-checkbox>
        <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">搜索</el-button>
        <el-button :icon="RefreshRight" @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">新建模板</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      />
    </div>
    <!-- æ–°å»º / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1020px"
      append-to-body
      destroy-on-close
      class="template-form-dialog"
      @closed="onFormDialogClosed"
    >
      <el-form
        v-if="formDialog.visible"
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="100px"
      >
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="模板名称" prop="templateName">
              <el-input
                v-model="form.templateName"
                placeholder="如:项目立项审批"
                maxlength="50"
                show-word-limit
                :disabled="isEditingBuiltin"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="模板类型" prop="businessType">
              <el-select
                v-model="form.businessType"
                placeholder="请选择"
                style="width: 100%"
                :disabled="isEditingBuiltin"
              >
                <el-option
                  v-for="opt in templateTypeOptions"
                  :key="`tpl-type-${opt.value}`"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="启用状态">
              <el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="模板说明">
          <el-input
            v-model="form.description"
            type="textarea"
            :rows="2"
            placeholder="简要说明该模板的适用场景"
            maxlength="200"
            show-word-limit
          />
        </el-form-item>
        <el-form-item label="填报配置">
          <FormConfigEditor
            v-model="form.formConfigData"
            :exclude-template-id="form.id"
            :disable-import="isEditingBuiltin"
            :locked-field-uids="isEditingBuiltin ? form.lockedFormFieldUids : []"
          />
          <p class="flow-tip">配置提交审批时需填写的表单项,保存后写入 formConfig(JSON)。</p>
        </el-form-item>
        <el-form-item label="审批流程" required>
          <TemplateFlowEditor v-model="form.flowNodes" :user-options="flowUserOptions" />
          <p class="flow-tip">
            æŒ‰é¡ºåºæµè½¬ï¼šå¯ä¸ºæ¯ä¸ªèŠ‚ç‚¹æ·»åŠ å¤šåå®¡æ‰¹äººï¼›ä¼šç­¾éœ€å…¨éƒ¨é€šè¿‡ï¼Œæˆ–ç­¾ä»»ä¸€äººé€šè¿‡å³å¯è¿›å…¥ä¸‹ä¸€èŠ‚ç‚¹ã€‚
          </p>
        </el-form-item>
        <el-form-item label="附件">
          <div class="upload-block">
            <FileUpload v-model:file-list="form.storageBlobDTOs" :limit="10" button-text="点击选择文件" />
          </div>
          <p class="flow-tip">可上传模板说明文档、制度文件等(选填)。</p>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="primary" @click="onSubmitForm">保 å­˜</el-button>
        <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="模板详情" width="880px" append-to-body destroy-on-close>
      <div v-loading="detailLoading" class="detail-dialog-body">
      <el-descriptions :column="2" border>
        <el-descriptions-item label="模板名称">{{ detailRow.templateName }}</el-descriptions-item>
        <el-descriptions-item label="模板类型">{{ templateTypeLabel(detailRow.businessType) }}</el-descriptions-item>
        <el-descriptions-item label="状态">
          <el-tag :type="detailRow.enabled !== false ? 'success' : 'info'" size="small">
            {{ detailRow.enabled !== false ? "启用" : "停用" }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="说明" :span="2">{{ detailRow.description || "—" }}</el-descriptions-item>
        <el-descriptions-item label="填报提示" :span="2">
          {{ detailFormConfig.summaryPlaceholder || "—" }}
        </el-descriptions-item>
        <el-descriptions-item label="创建人">{{ detailRow.createdUserName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ formatDisplayTime(detailRow.createdTime) }}</el-descriptions-item>
        <el-descriptions-item label="更新时间">{{ formatDisplayTime(detailRow.updatedTime) }}</el-descriptions-item>
      </el-descriptions>
      <el-divider content-position="left">填报项({{ detailFormConfig.fields?.length || 0 }} é¡¹ï¼‰</el-divider>
      <el-table
        v-if="detailFormConfig.fields?.length"
        :data="detailFormConfig.fields"
        border
        size="small"
        class="mb16"
      >
        <el-table-column prop="label" label="显示名称" min-width="120" />
        <el-table-column prop="key" label="字段标识" min-width="100" />
        <el-table-column label="类型" width="100">
          <template #default="{ row }">{{ formFieldTypeLabel(row.type) }}</template>
        </el-table-column>
        <el-table-column label="选项来源" width="100">
          <template #default="{ row }">
            {{ row.type === 'select' ? selectOptionSourceLabel(row.optionSource) : '—' }}
          </template>
        </el-table-column>
        <el-table-column label="必填" width="70" align="center">
          <template #default="{ row }">{{ row.required !== false ? "是" : "否" }}</template>
        </el-table-column>
        <el-table-column label="默认值" min-width="120" show-overflow-tooltip>
          <template #default="{ row }">{{ formatDefaultValueDisplay(row) }}</template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="未配置填报项" :image-size="48" class="mb16" />
      <el-divider content-position="left">审批流程({{ detailRow.flowNodes?.length || 0 }} ä¸ªèŠ‚ç‚¹ï¼‰</el-divider>
      <div v-if="detailRow.flowNodes?.length" class="detail-flow">
        <div v-for="(node, index) in detailRow.flowNodes" :key="index" class="detail-node">
          <div class="detail-node-head">
            <span class="detail-node-order">节点 {{ index + 1 }}</span>
            <el-tag size="small" :type="node.signMode === 'or_sign' ? 'warning' : 'primary'">
              {{ nodeSignModeLabel(node.signMode) }}
            </el-tag>
          </div>
          <div class="detail-approvers">
            <el-tag
              v-for="a in node.approvers"
              :key="String(a.approverId)"
              class="detail-approver-tag"
              effect="plain"
            >
              {{ a.approverName || "—" }}
            </el-tag>
            <span v-if="!node.approvers?.length" class="text-muted">未配置审批人</span>
          </div>
          <el-icon v-if="index < detailRow.flowNodes.length - 1" class="detail-arrow"><ArrowRight /></el-icon>
        </div>
      </div>
      <el-empty v-else description="暂无流程节点" :image-size="60" />
      <el-divider content-position="left">附件({{ detailAttachments.length }} ä¸ªï¼‰</el-divider>
      <template v-if="detailAttachments.length">
        <el-tag
          v-for="(f, i) in detailAttachments"
          :key="i"
          class="detail-attachment-tag"
          type="info"
          effect="plain"
        >
          {{ attachmentDisplayName(f) }}
        </el-tag>
      </template>
      <el-empty v-else description="暂无附件" :image-size="48" />
      </div>
      <template #footer>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
        <el-button type="primary" @click="editFromDetail">编 è¾‘</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ArrowRight, Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, nextTick, onMounted, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import FormConfigEditor from "./components/FormConfigEditor.vue";
import TemplateFlowEditor from "./components/TemplateFlowEditor.vue";
import { formatDisplayTime, mapAttachmentsFromApi } from "./approveTemplateConstants.js";
import { formatDefaultValueDisplay, formFieldTypeLabel, parseFormConfigToData } from "./formConfigUtils.js";
import { selectOptionSourceLabel } from "./selectOptionSource.js";
import { useApproveTemplate } from "./useApproveTemplate.js";
const {
  Search,
  templateTypeOptions,
  loadTemplateTypeOptions,
  templateTypeLabel,
  nodeSignModeLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  formDialog,
  form,
  formRef,
  formRules,
  isEditingBuiltin,
  detailDialog,
  detailRow,
  detailLoading,
  fetchTemplateList,
  handleQuery,
  resetSearch,
  pagination,
  openFormDialog,
  openDetail,
  submitForm,
} = useApproveTemplate();
const flowUserOptions = ref([]);
const detailFormConfig = computed(() =>
  parseFormConfigToData(detailRow.value?.formConfigData ?? detailRow.value?.formConfig)
);
const detailAttachments = computed(() => mapAttachmentsFromApi(detailRow.value));
function attachmentDisplayName(file) {
  if (!file) return "未命名";
  return file.name || file.originalFilename || file.fileName || "未命名";
}
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
async function loadUsers() {
  try {
    const res = await userListNoPageByTenantId();
    flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
  } catch {
    flowUserOptions.value = [];
  }
}
async function onSubmitForm() {
  const ret = await submitForm();
  if (ret?.message) {
    ElMessage.warning(ret.message);
    return;
  }
  if (ret?.ok) ElMessage.success("保存成功");
}
function onFormDialogClosed() {
  formRef.value?.resetFields?.();
}
async function editFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  await nextTick();
  openFormDialog("edit", row);
}
onMounted(() => {
  loadUsers();
  loadTemplateTypeOptions();
  fetchTemplateList();
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.mb16.el-empty {
  padding: 8px 0;
}
.ml10 {
  margin-left: 10px;
}
.ml12 {
  margin-left: 12px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  display: flex;
  gap: 8px;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin: 8px 0 0;
  line-height: 1.5;
}
.detail-flow {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 8px;
}
.detail-node {
  position: relative;
  min-width: 180px;
  max-width: 240px;
  padding: 12px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 8px;
  background: var(--el-fill-color-lighter);
}
.detail-node-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}
.detail-node-order {
  font-weight: 600;
  font-size: 13px;
}
.detail-approvers {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.detail-approver-tag {
  margin: 0;
}
.detail-arrow {
  position: absolute;
  right: -20px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--el-text-color-placeholder);
}
.detail-dialog-body {
  min-height: 120px;
}
.upload-block {
  width: 100%;
}
.detail-attachment-tag {
  margin: 0 8px 8px 0;
}
.text-muted {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
}
.template-form-dialog :deep(.el-dialog__body) {
  padding-top: 8px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
/** ä¸‹æ‹‰é€‰é¡¹æ¥æºï¼ˆå†™å…¥ formConfig,提交页按来源拉取数据) */
export const SELECT_OPTION_SOURCE = {
  STATIC: "static",
  USER: "user",
  DEPT: "dept",
};
export const SELECT_OPTION_SOURCE_OPTIONS = [
  { value: SELECT_OPTION_SOURCE.STATIC, label: "手动配置", desc: "在模板中自定义选项文本与值" },
  { value: SELECT_OPTION_SOURCE.USER, label: "人员列表", desc: "从系统用户中选择,值为用户 ID" },
  { value: SELECT_OPTION_SOURCE.DEPT, label: "部门列表", desc: "从组织架构中选择,值为部门 ID" },
];
export function selectOptionSourceLabel(source) {
  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.label || "—";
}
export function isDynamicOptionSource(source) {
  return source === SELECT_OPTION_SOURCE.USER || source === SELECT_OPTION_SOURCE.DEPT;
}
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
/** ç”¨æˆ· â†’ ä¸‹æ‹‰ option */
export function mapUserToSelectOption(u) {
  const value = u.userId ?? u.id;
  return {
    label: u.nickName || u.userName || `用户${value}`,
    value,
  };
}
/** éƒ¨é—¨æ ‘拍平为下拉 option */
export function flattenDeptToSelectOptions(nodes, result = []) {
  (nodes || []).forEach((node) => {
    const value = node.id ?? node.deptId ?? node.value;
    if (value != null && value !== "") {
      result.push({
        label: node.label ?? node.deptName ?? node.name ?? String(value),
        value,
      });
    }
    if (node.children?.length) flattenDeptToSelectOptions(node.children, result);
  });
  return result;
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
/** æŒ‰å­—段配置解析下拉 options(需传入已加载的缓存) */
export function resolveFieldSelectOptions(field, caches = {}) {
  const source = field?.optionSource || SELECT_OPTION_SOURCE.STATIC;
  if (source === SELECT_OPTION_SOURCE.USER) {
    return (caches.users || []).map(mapUserToSelectOption);
  }
  if (source === SELECT_OPTION_SOURCE.DEPT) {
    return caches.deptOptions || [];
  }
  return (field?.options || []).filter((o) => o.value !== "" && o.value != null);
}
/** æ ¹æ®å·²è§£æžçš„ options åæŸ¥å±•示文本 */
export function resolveSelectDisplayLabel(field, val, caches = {}) {
  if (val == null || val === "") return "—";
  const options = resolveFieldSelectOptions(field, caches);
  const hit = options.find((o) => String(o.value) === String(val));
  return hit?.label || String(val);
}
/** åŠ è½½äººå‘˜ / éƒ¨é—¨ç¼“存(多处复用) */
export async function fetchSelectOptionCaches(sources = []) {
  const needUser = sources.includes(SELECT_OPTION_SOURCE.USER);
  const needDept = sources.includes(SELECT_OPTION_SOURCE.DEPT);
  const caches = { users: [], deptOptions: [] };
  if (!needUser && !needDept) return caches;
  const tasks = [];
  if (needUser) {
    tasks.push(
      userListNoPageByTenantId()
        .then((res) => {
          caches.users = unwrapArray(res).filter(isActiveUser);
        })
        .catch(() => {
          caches.users = [];
        })
    );
  }
  if (needDept) {
    tasks.push(
      deptTreeSelect()
        .then((res) => {
          let tree = unwrapArray(res);
          tree = tree.length ? filterDisabledDept(JSON.parse(JSON.stringify(tree))) : [];
          if (!tree.length) tree = unwrapArray(res);
          caches.deptOptions = flattenDeptToSelectOptions(tree);
        })
        .catch(() => {
          caches.deptOptions = [];
        })
    );
  }
  await Promise.all(tasks);
  return caches;
}
/** ä»Žå­—段列表收集需要预加载的动态来源 */
export function collectOptionSourcesFromFields(fields) {
  const set = new Set();
  (fields || []).forEach((f) => {
    if (f?.type === "select" && isDynamicOptionSource(f.optionSource)) {
      set.add(f.optionSource);
    }
  });
  return [...set];
}
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,334 @@
import {
  addApprovalTemplate,
  deleteApprovalTemplate,
  getApprovalTemplateDetail,
  listApprovalTemplatePage,
  TEMPLATE_TYPE_BUILTIN,
  updateApprovalTemplate,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref } from "vue";
import {
  buildApprovalTemplateListParams,
  createEmptyTemplateForm,
  fetchBusinessTypeOptions,
  flowNodesSummary,
  isBuiltinTemplate,
  mapTemplateFromApi,
  mapTemplateToApi,
  nodeSignModeLabel,
  formatDisplayTime,
  unwrapTemplateDetail,
  validateTemplateForm,
} from "./approveTemplateConstants.js";
import { parseFormConfigToData } from "./formConfigUtils.js";
const FALLBACK_TEMPLATE_TYPE_OPTIONS = [
  { value: 0, label: "系统内置" },
  { value: 1, label: "自定义" },
];
function matchTemplateTypeValue(options, type) {
  if (type == null || type === "") return false;
  return options.some(
    (x) => x.value === type || x.value === Number(type) || String(x.value) === String(type)
  );
}
export function useApproveTemplate() {
  const templateTypeOptions = ref([...FALLBACK_TEMPLATE_TYPE_OPTIONS]);
  function templateTypeLabel(type) {
    if (type == null || type === "") return "—";
    const hit = templateTypeOptions.value.find(
      (x) => x.value === type || x.value === Number(type) || String(x.value) === String(type)
    );
    return hit?.label || "—";
  }
  const searchForm = reactive({
    keyword: "",
    enabledOnly: false,
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const tableData = ref([]);
  const formDialog = reactive({ visible: false, title: "", mode: "add" });
  const form = reactive(createEmptyTemplateForm());
  const formRef = ref();
  const isEditingBuiltin = computed(
    () => formDialog.mode === "edit" && Number(form.templateType) === TEMPLATE_TYPE_BUILTIN
  );
  async function loadTemplateTypeOptions() {
    try {
      const list = await fetchBusinessTypeOptions();
      templateTypeOptions.value = list.length ? list : [...FALLBACK_TEMPLATE_TYPE_OPTIONS];
    } catch {
      templateTypeOptions.value = [...FALLBACK_TEMPLATE_TYPE_OPTIONS];
    }
    if (!matchTemplateTypeValue(templateTypeOptions.value, form.businessType)) {
      form.businessType = templateTypeOptions.value[0]?.value ?? "";
    }
  }
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const detailLoading = ref(false);
  const formRules = {
    templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
    businessType: [{ required: true, message: "请选择模板类型", trigger: "change" }],
  };
  const tableColumn = ref([
    { label: "模板名称", prop: "templateName", minWidth: 140 },
    {
      label: "模板类型",
      prop: "businessType",
      width: 100,
      align: "center",
      formatData: (v) => templateTypeLabel(v),
    },
    { label: "说明", prop: "description", minWidth: 160, showOverflowTooltip: true },
    {
      label: "节点数",
      prop: "flowNodes",
      width: 80,
      align: "center",
      formatData: (v) => (Array.isArray(v) ? v.length : 0),
    },
    {
      label: "流程概要",
      prop: "flowNodes",
      minWidth: 220,
      showOverflowTooltip: true,
      formatData: (v) => flowNodesSummary(v),
    },
    {
      label: "状态",
      prop: "enabled",
      width: 90,
      align: "center",
      dataType: "tag",
      formatData: (v) => (v !== false ? "启用" : "停用"),
      formatType: (v) => (v !== false ? "success" : "info"),
    },
    {
      label: "创建时间",
      prop: "createdTime",
      width: 170,
      showOverflowTooltip: true,
      formatData: (v) => formatDisplayTime(v),
    },
    {
      label: "更新时间",
      prop: "updatedTime",
      width: 170,
      showOverflowTooltip: true,
      formatData: (v) => formatDisplayTime(v),
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
        {
          name: "删除",
          type: "danger",
          link: true,
          disabled: (row) => isBuiltinTemplate(row),
          clickFun: (row) => removeTemplate(row),
        },
      ],
    },
  ]);
  async function fetchTemplateList() {
    tableLoading.value = true;
    try {
      const res = await listApprovalTemplatePage(
        buildApprovalTemplateListParams({ page, searchForm })
      );
      const data = res?.data || {};
      tableData.value = (data.records || []).map(mapTemplateFromApi);
      page.total = Number(data.total || 0);
    } catch {
      tableData.value = [];
      page.total = 0;
    } finally {
      tableLoading.value = false;
    }
  }
  function handleQuery() {
    page.current = 1;
    fetchTemplateList();
  }
  function resetSearch() {
    searchForm.keyword = "";
    searchForm.enabledOnly = false;
    handleQuery();
  }
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
    fetchTemplateList();
  }
  function resetForm(row) {
    const base = createEmptyTemplateForm();
    if (!row) {
      Object.assign(form, base);
      return;
    }
    const formConfigData = JSON.parse(
      JSON.stringify(row.formConfigData || parseFormConfigToData(row.formConfig))
    );
    const builtin = isBuiltinTemplate(row);
    Object.assign(form, {
      ...base,
      id: row.id,
      templateName: row.templateName || "",
      description: row.description || "",
      templateType: row.templateType != null ? Number(row.templateType) : base.templateType,
      businessType: row.businessType ?? "",
      formConfig: row.formConfig || "",
      formConfigData,
      lockedFormFieldUids: builtin
        ? (formConfigData.fields || []).map((f) => f._uid).filter(Boolean)
        : [],
      enabled: row.enabled !== false,
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
  }
  function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.title = mode === "add" ? "新建审批模板" : "编辑审批模板";
    resetForm(mode === "edit" ? row : null);
    formDialog.visible = true;
  }
  async function openDetail(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法查看详情:缺少模板 ID");
      return;
    }
    detailDialog.visible = true;
    detailLoading.value = true;
    detailRow.value = {};
    try {
      const res = await getApprovalTemplateDetail(row.id);
      detailRow.value = mapTemplateFromApi(unwrapTemplateDetail(res));
    } catch {
      detailDialog.visible = false;
    } finally {
      detailLoading.value = false;
    }
  }
  async function submitForm() {
    if (!formRef.value) return false;
    try {
      await formRef.value.validate();
    } catch {
      return false;
    }
    const validated = validateTemplateForm(form);
    if (!validated.ok) {
      return { message: validated.message };
    }
    if (formDialog.mode === "edit" && !form.id) {
      return { message: "缺少模板 ID,无法保存修改" };
    }
    const dto = mapTemplateToApi(form);
    try {
      if (formDialog.mode === "add") {
        await addApprovalTemplate(dto);
      } else {
        await updateApprovalTemplate(dto);
      }
    } catch {
      return false;
    }
    formDialog.visible = false;
    page.current = 1;
    await fetchTemplateList();
    return { ok: true };
  }
  async function removeTemplate(row) {
    if (isBuiltinTemplate(row)) {
      ElMessage.warning("系统内置模板不允许删除");
      return;
    }
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少模板 ID");
      return;
    }
    const name = row.templateName || "未命名模板";
    try {
      await ElMessageBox.confirm(
        `确定要删除审批模板「${name}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteApprovalTemplate([row.id]);
      ElMessage.success("删除成功");
      await fetchTemplateList();
    } catch {
      /* é”™è¯¯ç”±æ‹¦æˆªå™¨æç¤º */
    }
  }
  return {
    Search,
    templateTypeOptions,
    loadTemplateTypeOptions,
    templateTypeLabel,
    fetchTemplateList,
    nodeSignModeLabel,
    flowNodesSummary,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    formDialog,
    form,
    formRef,
    formRules,
    isEditingBuiltin,
    detailDialog,
    detailRow,
    detailLoading,
    handleQuery,
    resetSearch,
    pagination,
    openFormDialog,
    openDetail,
    submitForm,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import { reactive, ref } from "vue";
import {
  collectOptionSourcesFromFields,
  fetchSelectOptionCaches,
  resolveFieldSelectOptions,
  resolveSelectDisplayLabel,
} from "./selectOptionSource.js";
/** ä¸‹æ‹‰åŠ¨æ€é€‰é¡¹ï¼šäººå‘˜ / éƒ¨é—¨ç¼“存与解析 */
export function useSelectOptionSources() {
  const loading = ref(false);
  const caches = reactive({
    users: [],
    deptOptions: [],
  });
  async function ensureForFields(fields) {
    const sources = collectOptionSourcesFromFields(fields);
    if (!sources.length) return;
    loading.value = true;
    try {
      const next = await fetchSelectOptionCaches(sources);
      caches.users = next.users;
      caches.deptOptions = next.deptOptions;
    } finally {
      loading.value = false;
    }
  }
  function getOptions(field) {
    return resolveFieldSelectOptions(field, caches);
  }
  function getDisplayLabel(field, val) {
    return resolveSelectDisplayLabel(field, val, caches);
  }
  return {
    loading,
    caches,
    ensureForFields,
    getOptions,
    getDisplayLabel,
  };
}
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,325 @@
<!--OA模块:请假申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">审批单号:</span>
        <el-input
          v-model="searchForm.instanceNo"
          style="width: 220px"
          placeholder="请输入审批单号"
          clearable
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="onSearch"
        />
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openAddWithTemplate">新增请假申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="假期余额" prop="leaveBalanceDays">
              <el-input-number
                v-model="form.leaveBalanceDays"
                :min="0"
                :max="999"
                :precision="2"
                :step="0.5"
                controls-position="right"
                placeholder="天"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="请假时长">
              <el-input :model-value="leaveDurationDisplay(form)" readonly placeholder="根据模板中请假时间自动计算">
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
      </template>
    </ApprovalInstanceSubmitDialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.LEAVE"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="请假申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
const LEAVE_TYPE_OPTIONS = [
  { label: "年假", value: "annual" },
  { label: "病假", value: "sick" },
  { label: "事假", value: "personal" },
  { label: "婚假", value: "marriage" },
  { label: "产假", value: "maternity" },
  { label: "哺乳假", value: "nursing" },
  { label: "慰唁假", value: "condolence" },
  { label: "调休", value: "compensatory" },
];
function isLeaveBalanceField(field) {
  const label = String(field?.label || "");
  return label.includes("假期余额") || field?.key === "leaveBalanceDays";
}
function isLeaveDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("请假时长") || field?.key === "leaveDurationDays";
}
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isLeaveBalanceField(f) && !isLeaveDurationField(f));
}
function findLeaveTimeTemplateField(fields = []) {
  return (
    fields.find((f) => f?.type === "datetimerange" && String(f?.label || "").includes("请假时间")) ||
    fields.find((f) => f?.type === "datetimerange" && f?.key === "dateRange") ||
    fields.find((f) => f?.type === "datetimerange") ||
    null
  );
}
function findApplicantTemplateField(fields = []) {
  return (
    fields.find((f) => String(f?.label || "").includes("申请人")) ||
    fields.find((f) => f?.type === "select" && f?.optionSource === SELECT_OPTION_SOURCE.USER) ||
    null
  );
}
function resolveLeaveTimeRange(payload, leaveTimeField) {
  if (!leaveTimeField?.key) return { start: "", end: "" };
  const val = payload?.[leaveTimeField.key];
  if (!Array.isArray(val) || val.length < 2) return { start: "", end: "" };
  return { start: val[0] || "", end: val[1] || "" };
}
function computeLeaveDays(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;
  const days = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000);
  return Math.round(days * 100) / 100;
}
function leaveDurationDisplay(form) {
  const leaveTimeField = findLeaveTimeTemplateField(form.formFieldDefs);
  const { start, end } = resolveLeaveTimeRange(form.formPayload, leaveTimeField);
  const d = computeLeaveDays(start, end);
  return d == null ? "" : String(d);
}
const searchForm = reactive({
  instanceNo: "",
  applicantKeyword: "",
});
function validateLeaveBeforeSave() {
  const leaveTimeField = findLeaveTimeTemplateField(submitForm.formFieldDefs);
  const { start, end } = resolveLeaveTimeRange(submitForm.formPayload, leaveTimeField);
  if (computeLeaveDays(start, end) == null) {
    ElMessage.warning("请检查模板中的请假时间,结束时间须晚于开始时间");
    throw new Error("invalid leave time");
  }
}
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.LEAVE,
  beforeSave: validateLeaveBeforeSave,
  extraFormRules: {
    leaveBalanceDays: [{ required: true, message: "请填写假期余额", trigger: "blur" }],
  },
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitEditRow,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const allUsersCache = ref([]);
const applicantTemplateField = computed(() =>
  findApplicantTemplateField(submitForm.formFieldDefs)
);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  return [];
}
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
watch(
  () => submitDialog.visible,
  (v) => {
    if (!v) return;
    if (submitForm.leaveBalanceDays == null && isSubmitEdit.value) {
      submitForm.leaveBalanceDays =
        submitEditRow.value?.formPayload?.leaveBalanceDays ??
        submitEditRow.value?.leaveBalanceDays;
    }
    if (submitForm.leaveBalanceDays == null && !isSubmitEdit.value) {
      submitForm.leaveBalanceDays = undefined;
    }
  }
);
watch(
  () => {
    const key = applicantTemplateField.value?.key;
    return key ? submitForm.formPayload[key] : undefined;
  },
  async (uid) => {
    if (!applicantTemplateField.value || !uid) return;
    if (!allUsersCache.value.length) await loadUserPool();
  }
);
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions, {
  moduleKey: APPROVAL_MODULE_KEYS.LEAVE,
});
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.instanceNo = "";
  searchForm.applicantKeyword = "";
  onSearch();
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
</style>
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,360 @@
<!-- åŠ ç­ç”³è¯·æ¨¡å—å†…ï¼šå¯å¢žåˆ å®¡æ‰¹èŠ‚ç‚¹ï¼Œæ¯èŠ‚ç‚¹å¿…é€‰ 1 äºº -->
<template>
  <div class="afe">
    <div v-if="innerList.length" class="afe-flow">
      <div v-for="(item, index) in innerList" :key="item._uid" class="afe-flow-item">
        <div class="afe-card" :class="{ 'afe-card--empty': !item.approverId }">
          <div class="afe-badge">{{ index + 1 }}</div>
          <div class="afe-avatar-wrap">
            <div
              class="afe-avatar"
              :class="{ 'afe-avatar--on': item.approverId }"
              :style="item.approverId ? { backgroundColor: avatarColor(item.approverName) } : {}"
            >
              <span v-if="item.approverId">{{ (item.approverName || '?').charAt(0) }}</span>
              <el-icon v-else :size="22"><User /></el-icon>
            </div>
            <div class="afe-level">{{ levelText(index) }}</div>
          </div>
          <div class="afe-select">
            <el-select
              v-model="item.approverId"
              placeholder="请选择审批人"
              filterable
              clearable
              style="width: 100%"
              @change="(v) => onPick(v, item)"
            >
              <el-option
                v-for="u in userOptions"
                :key="String(u.userId ?? u.id)"
                :label="optionLabel(u)"
                :value="u.userId ?? u.id"
              />
            </el-select>
          </div>
          <div class="afe-actions">
            <el-button type="primary" circle size="small" :disabled="index === 0" title="前移" @click="moveLeft(index)">
              <el-icon><ArrowLeft /></el-icon>
            </el-button>
            <el-button
              type="primary"
              circle
              size="small"
              :disabled="index === innerList.length - 1"
              title="后移"
              @click="moveRight(index)"
            >
              <el-icon><ArrowRight /></el-icon>
            </el-button>
            <el-button type="danger" circle size="small" title="删除节点" @click="remove(index)">
              <el-icon><Delete /></el-icon>
            </el-button>
          </div>
        </div>
        <div v-if="index < innerList.length - 1" class="afe-conn">
          <div class="afe-conn-line"></div>
          <el-icon class="afe-conn-icon"><ArrowRight /></el-icon>
        </div>
      </div>
      <div class="afe-add-wrap">
        <div class="afe-conn" v-if="innerList.length">
          <div class="afe-conn-line"></div>
          <el-icon class="afe-conn-icon"><ArrowRight /></el-icon>
        </div>
        <div class="afe-add-card" @click="addNode">
          <div class="afe-add-icon"><el-icon :size="26"><Plus /></el-icon></div>
          <span>新增节点</span>
        </div>
      </div>
    </div>
    <div v-else class="afe-empty">
      <el-icon :size="44" color="#c0c4cc"><User /></el-icon>
      <p>暂无审批节点</p>
      <el-button type="primary" @click="addNode">添加第一个节点</el-button>
    </div>
  </div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue";
import { ref, watch } from "vue";
const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  /** ä¸Žçˆ¶é¡µ userList ç»“构一致:userId / id、nickName、userName */
  userOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue"]);
const innerList = ref([]);
const palette = ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#9B59B6", "#1ABC9C"];
function avatarColor(name) {
  if (!name) return "#c0c4cc";
  let h = 0;
  for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
  return palette[Math.abs(h) % palette.length];
}
function levelText(i) {
  const t = ["第一级", "第二级", "第三级", "第四级", "第五级", "第六级", "第七级", "第八级"];
  return t[i] || `第${i + 1}级`;
}
function optionLabel(u) {
  const nick = u.nickName || "";
  const un = u.userName || "";
  if (nick && un && nick !== un) return `${nick}(${un})`;
  return nick || un || `用户${u.userId ?? u.id ?? ""}`;
}
function newUid() {
  return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
function mapIn(rows) {
  if (!Array.isArray(rows)) return [];
  return rows.map((r, i) => ({
    _uid: newUid(),
    approverId: r.approverId ?? r.approver_id ?? null,
    approverName: r.approverName ?? r.approver_name ?? "",
    sortOrder: r.sortOrder ?? r.nodeOrder ?? i + 1,
    nodeOrder: r.nodeOrder ?? r.sortOrder ?? i + 1,
    roleName: r.roleName ?? "",
    roleCode: r.roleCode ?? "",
  }));
}
function publicShape(rows) {
  const arr = Array.isArray(rows) ? rows : [];
  return arr.map((r, i) => ({
    approverId: r.approverId ?? null,
    approverName: r.approverName ?? "",
    roleName: r.roleName ?? "",
    roleCode: r.roleCode ?? "",
    sortOrder: i + 1,
  }));
}
function emitOut() {
  const out = innerList.value.map((r, i) => ({
    approverId: r.approverId ?? null,
    approverName: r.approverName ?? "",
    sortOrder: i + 1,
    nodeOrder: i + 1,
    roleName: r.roleName ?? "",
    roleCode: r.roleCode ?? "",
  }));
  emit("update:modelValue", out);
}
watch(
  () => props.modelValue,
  (v) => {
    const next = publicShape(v || []);
    if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return;
    innerList.value = mapIn(v || []);
  },
  { deep: true, immediate: true }
);
function findUser(id) {
  if (id == null || id === "") return null;
  return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null;
}
function onPick(userId, row) {
  if (!userId) {
    row.approverName = "";
    emitOut();
    return;
  }
  const u = findUser(userId);
  row.approverName = u ? u.nickName || u.userName || "" : "";
  emitOut();
}
function addNode() {
  innerList.value.push({
    _uid: newUid(),
    approverId: null,
    approverName: "",
    roleName: "",
    roleCode: "",
  });
  emitOut();
}
function remove(index) {
  innerList.value.splice(index, 1);
  emitOut();
}
function moveLeft(index) {
  if (index < 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index - 1];
  innerList.value[index - 1] = t;
  emitOut();
}
function moveRight(index) {
  if (index >= innerList.value.length - 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index + 1];
  innerList.value[index + 1] = t;
  emitOut();
}
</script>
<style scoped>
.afe {
  width: 100%;
}
.afe-flow {
  display: flex;
  align-items: flex-start;
  flex-wrap: nowrap;
  overflow-x: auto;
  padding: 6px 0 10px;
  gap: 0;
}
.afe-flow-item {
  display: flex;
  align-items: center;
}
.afe-card {
  width: 200px;
  flex-shrink: 0;
  border: 2px solid var(--el-border-color);
  border-radius: 12px;
  padding: 14px 12px 12px;
  position: relative;
  background: var(--el-bg-color);
}
.afe-card--empty {
  border-style: dashed;
  background: var(--el-fill-color-lighter);
}
.afe-badge {
  position: absolute;
  top: -8px;
  left: 12px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.afe-avatar-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 6px 0 10px;
}
.afe-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--el-fill-color);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--el-text-color-placeholder);
  margin-bottom: 6px;
  font-size: 18px;
  font-weight: 600;
}
.afe-avatar--on {
  color: #fff;
}
.afe-level {
  font-size: 12px;
  color: var(--el-text-color-secondary);
}
.afe-select {
  margin-bottom: 10px;
}
.afe-actions {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--el-border-color-lighter);
}
.afe-conn {
  display: flex;
  align-items: center;
  width: 40px;
  flex-shrink: 0;
  align-self: center;
}
.afe-conn-line {
  flex: 1;
  height: 2px;
  background: var(--el-border-color);
}
.afe-conn-icon {
  font-size: 14px;
  color: var(--el-text-color-placeholder);
  margin-left: -2px;
}
.afe-add-wrap {
  display: flex;
  align-items: center;
}
.afe-add-card {
  width: 120px;
  min-height: 168px;
  flex-shrink: 0;
  border: 2px dashed var(--el-border-color);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  cursor: pointer;
  color: var(--el-text-color-regular);
  font-size: 13px;
  background: var(--el-fill-color-lighter);
  transition: border-color 0.2s, background 0.2s;
}
.afe-add-card:hover {
  border-color: var(--el-color-primary);
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
}
.afe-add-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
}
.afe-empty {
  text-align: center;
  padding: 28px 16px;
  border: 1px dashed var(--el-border-color);
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
}
.afe-empty p {
  margin: 10px 0 14px;
  color: var(--el-text-color-secondary);
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,260 @@
<!--OA模块:加班申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">审批单号:</span>
        <el-input
          v-model="searchForm.instanceNo"
          style="width: 220px"
          placeholder="请输入审批单号"
          clearable
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="onSearch"
        />
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增加班申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班时长">
              <el-input :model-value="overtimeHoursDisplay(form)" readonly placeholder="根据模板中加班时间自动计算">
                <template #append>小时</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
      </template>
    </ApprovalInstanceSubmitDialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.OVERTIME"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="加班申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessage } from "element-plus";
import { getCurrentInstance, onMounted, reactive, ref } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
const OVERTIME_TYPE_OPTIONS = [
  { label: "工作日加班", value: "weekday" },
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
function isOvertimeDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("加班时长") || field?.key === "overtimeHours";
}
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isOvertimeDurationField(f));
}
function findOvertimeTimeTemplateField(fields = []) {
  return (
    fields.find((f) => f?.type === "datetimerange" && String(f?.label || "").includes("加班时间")) ||
    fields.find((f) => f?.type === "datetimerange") ||
    null
  );
}
function resolveOvertimeTimeRange(payload, overtimeTimeField) {
  if (!overtimeTimeField?.key) return { start: "", end: "" };
  const val = payload?.[overtimeTimeField.key];
  if (!Array.isArray(val) || val.length < 2) return { start: "", end: "" };
  return { start: val[0] || "", end: val[1] || "" };
}
function computeOvertimeHours(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.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100;
}
function overtimeHoursDisplay(form) {
  const field = findOvertimeTimeTemplateField(form.formFieldDefs);
  const { start, end } = resolveOvertimeTimeRange(form.formPayload, field);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
}
const { proxy } = getCurrentInstance();
const searchForm = reactive({
  instanceNo: "",
  applicantKeyword: "",
});
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.OVERTIME,
  beforeSave: validateOvertimeBeforeSave,
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
function validateOvertimeBeforeSave() {
  const field = findOvertimeTimeTemplateField(submitForm.formFieldDefs);
  const { start, end } = resolveOvertimeTimeRange(submitForm.formPayload, field);
  if (computeOvertimeHours(start, end) == null) {
    ElMessage.warning("请检查模板中的加班时间,结束时间须晚于开始时间");
    throw new Error("invalid overtime time");
  }
}
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions, {
  moduleKey: APPROVAL_MODULE_KEYS.OVERTIME,
});
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.instanceNo = "";
  searchForm.applicantKeyword = "";
  onSearch();
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
function handleExport() {
  const data = tableData.value;
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
  a.href = url;
  a.download = `加班申请导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
  a.click();
  URL.revokeObjectURL(url);
  proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡ï¼ˆå½“前页列表数据)`);
}
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
</style>
src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  æ¨¡å—中文名:采购合同
  ç›®å½•标识:ContractManage/purchase-contract(purchase-contract â†’ ä¸­æ–‡ï¼šé‡‡è´­åˆåŒï¼‰
  å¤ç”¨é¡µé¢ï¼š@/views/procurementManagement/procurementLedger/index.vue(采购台账;文件名 index.vue â†’ å…¥å£é¡µï¼‰
-->
<template>
  <ProcurementLedger />
</template>
<script setup>
import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
</script>
src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  æ¨¡å—中文名:销售合同
  ç›®å½•标识:ContractManage/sale-contract(sale-contract â†’ ä¸­æ–‡ï¼šé”€å”®åˆåŒï¼‰
  å¤ç”¨é¡µé¢ï¼š@/views/procurementManagement/procurementLedger/index.vue(采购台账;文件名 index.vue â†’ å…¥å£é¡µï¼‰
-->
<template>
  <ProcurementLedger />
</template>
<script setup>
import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
</script>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
<!-- EnterpriseNews:详情只读面板(含互动) -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="新闻编号">{{ row.newsNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="状态">
      <el-tag :type="publishStatusTag(row.newsStatus ?? row.publishStatus)" size="small">
        {{ publishStatusLabel(row.newsStatus ?? row.publishStatus) }}
      </el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="新闻分类">
      <span class="type-badge" :style="{ color: newsTypeColor(row.newsType) }">
        {{ newsTypeLabel(row.newsType) }}
      </span>
    </el-descriptions-item>
    <el-descriptions-item label="排版模板">{{ layoutTemplateLabel(row.layoutTemplate) }}</el-descriptions-item>
    <el-descriptions-item label="标题" :span="2">{{ row.title || "—" }}</el-descriptions-item>
    <el-descriptions-item label="摘要" :span="2">{{ row.summary || "—" }}</el-descriptions-item>
    <el-descriptions-item label="阅读范围">{{ readScopeLabel(row.readScope) }}</el-descriptions-item>
    <el-descriptions-item label="阅读率">
      {{ readRate(row) }}%(未读 {{ unreadCount }} äººï¼‰
    </el-descriptions-item>
    <el-descriptions-item label="编辑权限">{{ publishRoleLabel(row.editorRole) }}</el-descriptions-item>
    <el-descriptions-item label="审核角色">{{ publishRoleLabel(row.reviewerRole) }}</el-descriptions-item>
    <el-descriptions-item label="发布人">{{ row.publisherName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="发布时间">{{ row.publishTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="当前版本">v{{ row.versionNo || 1 }}</el-descriptions-item>
    <el-descriptions-item label="需阅读确认">
      {{ row.requireReadConfirm ? "是" : "否" }}
    </el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">正文内容</el-divider>
  <div v-if="row.contentHtml" class="news-html-body" v-html="row.contentHtml" />
  <el-empty v-else description="暂无正文" :image-size="48" />
  <template v-if="row.mediaList?.length">
    <el-divider content-position="left">图集 / è§†é¢‘</el-divider>
    <div class="media-grid">
      <div v-for="(m, i) in row.mediaList" :key="i" class="media-item">
        <el-tag size="small" type="info">{{ m.type === "video" ? "视频" : "图片" }}</el-tag>
        <span class="media-name">{{ m.name }}</span>
      </div>
    </div>
  </template>
  <el-divider content-position="left">附件</el-divider>
  <template v-if="row.attachmentList?.length">
    <el-tag
      v-for="(f, i) in row.attachmentList"
      :key="i"
      class="file-tag"
      type="info"
      @click="openFile(f)"
    >
      {{ f.name }}
    </el-tag>
  </template>
  <el-empty v-else description="暂无附件" :image-size="48" />
  <template v-if="row.newsType === 'culture' && (row.publishStatus === 'PUBLISHED' || row.publishStatus === 'published')">
    <el-divider content-position="left">互动(点赞 {{ likeCount }} Â· è¯„论 {{ commentCount }})</el-divider>
    <div class="interaction-bar">
      <el-button type="primary" plain size="small" @click="$emit('like')">
        {{ likedByMe ? "取消点赞" : "点赞" }}
      </el-button>
    </div>
    <el-input
      v-model="commentDraft"
      type="textarea"
      :rows="2"
      maxlength="300"
      show-word-limit
      placeholder="写下你的评论…"
      class="mb8"
    />
    <el-button type="primary" size="small" @click="submitComment">发表评论</el-button>
    <el-timeline v-if="row.comments?.length" class="comment-timeline mt12">
      <el-timeline-item v-for="c in row.comments" :key="c.id" :timestamp="c.time">
        <strong>{{ c.name }}</strong>:{{ c.content }}
      </el-timeline-item>
    </el-timeline>
    <el-empty v-else description="暂无评论" :image-size="40" />
  </template>
</template>
<script setup>
import { computed, ref } from "vue";
import {
  newsTypeLabel,
  newsTypeColor,
  publishStatusLabel,
  publishStatusTag,
  layoutTemplateLabel,
  readScopeLabel,
  publishRoleLabel,
  readRate,
  getUnreadEmployees,
} from "../enterpriseNewsUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["like", "comment"]);
const commentDraft = ref("");
const unreadCount = computed(() => getUnreadEmployees(props.row).length);
const likeCount = computed(() => props.row?.likes?.length || 0);
const commentCount = computed(() => props.row?.comments?.length || 0);
const likedByMe = computed(() => (props.row?.likes || []).some((l) => l.userId === "u1"));
function openFile(f) {
  const url = f?.url || f?.downloadURL;
  if (url) window.open(url, "_blank");
}
function submitComment() {
  emit("comment", commentDraft.value);
  commentDraft.value = "";
}
</script>
<style scoped>
.type-badge {
  font-weight: 600;
}
.news-html-body {
  padding: 12px 16px;
  background: var(--el-fill-color-light);
  border-radius: 6px;
  line-height: 1.7;
  max-height: 320px;
  overflow-y: auto;
}
.media-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.media-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: var(--el-fill-color-lighter);
  border-radius: 4px;
}
.media-name {
  font-size: 13px;
}
.file-tag {
  margin: 0 8px 8px 0;
  cursor: pointer;
}
.interaction-bar {
  margin-bottom: 8px;
}
.comment-timeline {
  max-height: 200px;
  overflow-y: auto;
}
.mb8 {
  margin-bottom: 8px;
}
.mt12 {
  margin-top: 12px;
}
</style>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,11 @@
/** @deprecated è¯·ä½¿ç”¨ enterpriseNewsMappers.js */
export {
  ENTERPRISE_NEWS_PAYLOAD_KEY,
  buildEnterpriseNewsSaveDto,
  buildEnterpriseNewsTableColumns,
  canEditEnterpriseNewsRow,
  extractEnterpriseNewsFromRow,
  mapApiRowToNewsForm,
  mapEnterpriseNewsFromApi,
  syncNewsFormToSubmitPayload,
} from "./enterpriseNewsMappers.js";
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsMappers.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,221 @@
import {
  createEmptyForm,
  normalizeEnterpriseNewsStatus,
  publishStatusLabel,
  publishStatusTag,
} from "./enterpriseNewsUtils.js";
/** formPayload ä¸­å­˜æ”¾å®Œæ•´ä¼ä¸šæ–°é—»ä¸šåŠ¡æ•°æ®çš„é”®ï¼ˆå®¡æ‰¹å®žä¾‹ä¿å­˜ç”¨ï¼‰ */
export const ENTERPRISE_NEWS_PAYLOAD_KEY = "enterpriseNews";
const READ_SCOPE_FROM_API = {
  all: "all",
  dept: "department",
  department: "department",
  custom: "custom",
  management: "management",
};
const READ_SCOPE_TO_API = {
  all: "all",
  department: "dept",
  dept: "dept",
  custom: "custom",
  management: "all",
};
export function mapReadScopeFromApi(scope) {
  const key = String(scope ?? "").trim().toLowerCase();
  return READ_SCOPE_FROM_API[key] || key || "all";
}
export function mapReadScopeToApi(scope) {
  return READ_SCOPE_TO_API[scope] || scope || "all";
}
export function unwrapEnterpriseNewsPage(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") {
    return { records: [], total: 0 };
  }
  if (Array.isArray(data.records)) {
    return { records: data.records, total: Number(data.total ?? 0) };
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && Array.isArray(nested.records)) {
    return { records: nested.records, total: Number(nested.total ?? 0) };
  }
  return { records: [], total: 0 };
}
/** ç»„装 listPage æŸ¥è¯¢å‚æ•° */
export function buildEnterpriseNewsListParams({ page, searchForm }) {
  const params = {
    current: page.current,
    size: page.size,
  };
  const kw = (searchForm?.keyword || "").trim();
  if (kw) params.title = kw;
  if (searchForm?.newsType) params.category = searchForm.newsType;
  if (searchForm?.status) params.status = searchForm.status;
  const range = searchForm?.createTimeRange;
  if (Array.isArray(range) && range[0]) {
    params.createTimeStart = range[0];
  }
  if (Array.isArray(range) && range[1]) {
    params.createTimeEnd = range[1];
  }
  return params;
}
/** æŽ¥å£ EnterpriseNewsVo â†’ åˆ—表行 */
export function mapEnterpriseNewsFromApi(row) {
  if (!row) return {};
  const newsStatus = normalizeEnterpriseNewsStatus(row.status);
  return {
    ...row,
    newsNo: row.id != null ? String(row.id) : "—",
    newsType: row.category || "",
    contentHtml: row.content || "",
    publisherName: row.createUserName || "—",
    publishTime: row.createTime || "",
    updateTime: row.updateTime || "",
    newsStatus,
    requireReadConfirm: row.isRequired === "1" || row.isRequired === 1,
    readScope: mapReadScopeFromApi(row.readScope),
    readCount: row.readCount ?? 0,
    requiredReadCount: row.requiredReadCount ?? 0,
  };
}
/** æ˜¯å¦å…è®¸ä¿®æ”¹ï¼ˆè‰ç¨¿ã€é©³å›žå¯æ”¹ï¼‰ */
export function canEditEnterpriseNewsRow(row) {
  const status = normalizeEnterpriseNewsStatus(row?.newsStatus ?? row?.status);
  return status === "DRAFT" || status === "REJECTED";
}
/** æŽ¥å£è¡Œ / è¯¦æƒ… â†’ è¡¨å• */
export function mapApiRowToNewsForm(row) {
  if (!row) return createEmptyForm();
  return {
    ...createEmptyForm(),
    id: row.id != null ? String(row.id) : "",
    newsNo: row.id != null ? String(row.id) : "",
    title: row.title || "",
    summary: row.summary || "",
    contentHtml: row.content || row.contentHtml || "",
    newsType: row.newsType || row.category || "announcement",
    readScope: mapReadScopeFromApi(row.readScope),
    requireReadConfirm: Boolean(row.requireReadConfirm ?? row.isRequired === "1"),
    publisherName: row.createUserName || row.publisherName || "",
    publishStatus: normalizeEnterpriseNewsStatus(row.newsStatus ?? row.status),
    templateId: row.templateId,
    templateName: row.templateName || "",
    targetDeptIds: [...(row.deptIds || row.targetDeptIds || [])],
    targetUserIds: [...(row.userIds || row.targetUserIds || [])],
  };
}
/** å®¡æ‰¹å®žä¾‹è¡Œ formPayload â†’ è¡¨å•(兼容旧数据) */
export function extractEnterpriseNewsFromRow(row) {
  if (!row?.formPayload && !row?.formFieldDefs && !row?.instanceNo) {
    return mapApiRowToNewsForm(row);
  }
  const payload = row?.formPayload || {};
  const raw = payload[ENTERPRISE_NEWS_PAYLOAD_KEY];
  if (raw && typeof raw === "object") {
    return { ...createEmptyForm(), ...raw };
  }
  return {
    ...createEmptyForm(),
    title: payload.title || row?.title || "",
    summary: payload.summary || "",
    newsType: payload.newsType || row?.category || "announcement",
    contentHtml: payload.contentHtml || row?.content || "",
  };
}
export function syncNewsFormToSubmitPayload(newsForm, submitForm) {
  const snapshot = JSON.parse(JSON.stringify(newsForm));
  submitForm.formPayload = {
    ...(submitForm.formPayload || {}),
    [ENTERPRISE_NEWS_PAYLOAD_KEY]: snapshot,
    title: snapshot.title,
    summary: snapshot.summary,
  };
}
function toIdList(ids) {
  if (!Array.isArray(ids) || !ids.length) return undefined;
  const list = ids
    .map((id) => (typeof id === "number" ? id : Number(id)))
    .filter((n) => !Number.isNaN(n));
  return list.length ? list : undefined;
}
/** è¡¨å• â†’ POST /enterpriseNews/save è¯·æ±‚体 */
export function buildEnterpriseNewsSaveDto(newsForm, { status } = {}) {
  const dto = {
    title: (newsForm.title || "").trim(),
    summary: newsForm.summary || "",
    content: newsForm.contentHtml || "",
    category: newsForm.newsType || "",
    readScope: mapReadScopeToApi(newsForm.readScope),
    isRequired: newsForm.requireReadConfirm ? "1" : "0",
    status: normalizeEnterpriseNewsStatus(status ?? newsForm.publishStatus),
  };
  const rawId = newsForm.id;
  if (rawId != null && rawId !== "") {
    const id = Number(rawId);
    if (!Number.isNaN(id)) dto.id = id;
  }
  const deptIds = toIdList(newsForm.targetDeptIds);
  if (deptIds) dto.deptIds = deptIds;
  const userIds = toIdList(newsForm.targetUserIds);
  if (userIds) dto.userIds = userIds;
  const templateId = newsForm.templateId;
  if (templateId != null && templateId !== "") {
    const tid = Number(templateId);
    if (!Number.isNaN(tid)) dto.templateId = tid;
  }
  if (newsForm.templateName) dto.templateName = newsForm.templateName;
  return dto;
}
export function buildEnterpriseNewsTableColumns(buildTableActions) {
  return [
    { label: "编号", prop: "newsNo", width: 120 },
    { label: "标题", prop: "title", minWidth: 180, showOverflowTooltip: true },
    {
      label: "分类",
      prop: "newsType",
      width: 100,
      dataType: "slot",
      slot: "newsType",
    },
    {
      label: "状态",
      prop: "newsStatus",
      width: 100,
      dataType: "tag",
      formatData: (v) => publishStatusLabel(v),
      formatType: (v) => publishStatusTag(v),
    },
    { label: "创建人", prop: "publisherName", width: 110 },
    { label: "创建时间", prop: "createTime", width: 170 },
    { label: "更新时间", prop: "updateTime", width: 170 },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: buildTableActions(),
    },
  ];
}
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,207 @@
import dayjs from "dayjs";
/** æ–°é—»åˆ†ç±»ï¼šç»Ÿä¸€ä¿¡æ¯å‡ºå£ */
export const NEWS_TYPE_OPTIONS = [
  { value: "announcement", label: "企业公告", color: "#409eff" },
  { value: "policy", label: "政策解读", color: "#e6a23c" },
  { value: "industry", label: "行业动态", color: "#909399" },
  { value: "culture", label: "文化活动", color: "#67c23a" },
];
/** ä¼ä¸šæ–°é—»çŠ¶æ€ï¼ˆä¸ŽåŽç«¯æžšä¸¾ä¸€è‡´ï¼‰ */
export const PUBLISH_STATUS_OPTIONS = [
  { value: "DRAFT", label: "草稿", tag: "info" },
  { value: "PENDING", label: "待审批", tag: "warning" },
  { value: "PUBLISHED", label: "已发布", tag: "success" },
  { value: "REJECTED", label: "驳回", tag: "danger" },
  { value: "OFFLINE", label: "已下线", tag: "info" },
];
/** ä¼ä¸šæ–°é—»åˆ—表筛选 */
export const ENTERPRISE_NEWS_STATUS_SEARCH_OPTIONS = [...PUBLISH_STATUS_OPTIONS];
const LEGACY_PUBLISH_STATUS_MAP = {
  draft: "DRAFT",
  pending_review: "PENDING",
  published: "PUBLISHED",
  archived: "OFFLINE",
};
/** åŽç«¯æ•°å­—状态码 â†’ æžšä¸¾ï¼ˆ0 è‰ç¨¿ 1 å¾…审批 2 å·²å‘布 3 é©³å›ž 4 å·²ä¸‹çº¿ï¼‰ */
const ENTERPRISE_NEWS_STATUS_NUMERIC_MAP = {
  0: "DRAFT",
  1: "PENDING",
  2: "PUBLISHED",
  3: "REJECTED",
  4: "OFFLINE",
};
const ENTERPRISE_NEWS_STATUS_LABEL_MAP = {
  è‰ç¨¿: "DRAFT",
  å¾…审批: "PENDING",
  å·²å‘布: "PUBLISHED",
  é©³å›ž: "REJECTED",
  å·²é©³å›ž: "REJECTED",
  å·²ä¸‹çº¿: "OFFLINE",
};
/** ç»Ÿä¸€ä¸ºåŽç«¯çŠ¶æ€æžšä¸¾å€¼ */
export function normalizeEnterpriseNewsStatus(v) {
  if (v == null || v === "") return "DRAFT";
  if (typeof v === "number" || (typeof v === "string" && /^\d+$/.test(v.trim()))) {
    const numKey = ENTERPRISE_NEWS_STATUS_NUMERIC_MAP[Number(v)];
    if (numKey) return numKey;
  }
  const raw = String(v).trim();
  if (ENTERPRISE_NEWS_STATUS_LABEL_MAP[raw]) {
    return ENTERPRISE_NEWS_STATUS_LABEL_MAP[raw];
  }
  const upper = raw.toUpperCase();
  if (upper === "APPROVED") return "PUBLISHED";
  const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === upper);
  if (hit) return hit.value;
  const legacy = LEGACY_PUBLISH_STATUS_MAP[raw.toLowerCase()];
  if (legacy) return legacy;
  return upper;
}
/** æŽ’版模板 */
export const LAYOUT_TEMPLATE_OPTIONS = [
  { value: "standard", label: "标准图文" },
  { value: "policy", label: "政策条文" },
  { value: "gallery", label: "图集相册" },
  { value: "briefing", label: "简报摘要" },
];
/** é˜…读可见范围 */
export const READ_SCOPE_OPTIONS = [
  { value: "all", label: "全员可见" },
  { value: "management", label: "管理层" },
  { value: "department", label: "指定部门" },
  { value: "custom", label: "自定义名单" },
];
/** ç¼–辑/审核角色(发布权限) */
export const PUBLISH_ROLE_OPTIONS = [
  { value: "hr", label: "HR(人事政策)" },
  { value: "admin", label: "管理员(外部新闻审核)" },
  { value: "dept_manager", label: "部门负责人" },
  { value: "editor", label: "内容编辑" },
];
/** ç›®æ ‡å—众(对接组织架构 API å‰ä¸ºç©ºï¼‰ */
export const MOCK_AUDIENCE = [];
const DEPT_OPTIONS = [
  { value: "101", label: "研发部" },
  { value: "102", label: "销售部" },
  { value: "103", label: "行政部" },
  { value: "104", label: "财务部" },
  { value: "105", label: "总经办" },
  { value: "106", label: "人力资源部" },
];
export { DEPT_OPTIONS };
export function newsTypeLabel(v) {
  return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function newsTypeColor(v) {
  return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399";
}
export function publishStatusLabel(v) {
  const key = normalizeEnterpriseNewsStatus(v);
  return PUBLISH_STATUS_OPTIONS.find((x) => x.value === key)?.label || v || "—";
}
export function publishStatusTag(v) {
  const key = normalizeEnterpriseNewsStatus(v);
  return PUBLISH_STATUS_OPTIONS.find((x) => x.value === key)?.tag || "info";
}
export function layoutTemplateLabel(v) {
  return LAYOUT_TEMPLATE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function readScopeLabel(v) {
  return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function publishRoleLabel(v) {
  return PUBLISH_ROLE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function createEmptyForm() {
  return {
    id: "",
    newsNo: "",
    title: "",
    summary: "",
    newsType: "announcement",
    layoutTemplate: "standard",
    contentHtml: "",
    coverImage: "",
    mediaList: [],
    attachmentList: [],
    editorRole: "hr",
    reviewerRole: "admin",
    readScope: "all",
    targetDeptIds: [],
    targetUserIds: [],
    publishStatus: "DRAFT",
    publisherName: "",
    publishTime: "",
    readRecords: [],
    remindLogs: [],
    likes: [],
    comments: [],
    versions: [],
    versionNo: 1,
    requireReadConfirm: false,
    templateId: null,
    templateName: "",
  };
}
/** æŒ‰é˜…读范围解析目标受众 */
export function resolveTargetAudience(row) {
  const scope = row.readScope || "all";
  if (scope === "management") {
    return MOCK_AUDIENCE.filter((u) => u.isManagement);
  }
  if (scope === "department" && row.targetDeptIds?.length) {
    const names = DEPT_OPTIONS.filter((d) => row.targetDeptIds.includes(d.value)).map((d) => d.label);
    return MOCK_AUDIENCE.filter((u) => names.includes(u.deptName));
  }
  if (scope === "custom" && row.targetUserIds?.length) {
    return MOCK_AUDIENCE.filter((u) => row.targetUserIds.includes(u.userId));
  }
  return [...MOCK_AUDIENCE];
}
export function getUnreadEmployees(row) {
  const audience = resolveTargetAudience(row);
  const readSet = new Set(
    (row.readRecords || []).filter((r) => r.readAt).map((r) => r.userId)
  );
  return audience.filter((u) => !readSet.has(u.userId));
}
export function readRate(row) {
  const audience = resolveTargetAudience(row);
  if (!audience.length) return 0;
  const readCount = (row.readRecords || []).filter((r) => r.readAt).length;
  return Math.round((readCount / audience.length) * 100);
}
export function validateNewsForm(form) {
  const title = (form.title || "").trim();
  if (!title) return { ok: false, message: "请填写新闻标题" };
  if (!form.newsType) return { ok: false, message: "请选择新闻分类" };
  if (form.readScope === "department" && !(form.targetDeptIds || []).length) {
    return { ok: false, message: "请选择可见部门" };
  }
  return { ok: true, title };
}
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,566 @@
<!--OA模块:EnterpriseNews ä¼ä¸šæ–°é—»ï¼ˆlistPage|save|update|delete,新建保留审批模板)-->
<template>
  <div class="app-container enterprise-news-page">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">关键词:</span>
        <el-input
          v-model="searchForm.keyword"
          style="width: 200px"
          placeholder="标题"
          clearable
          :prefix-icon="Search"
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">分类:</span>
        <el-select v-model="searchForm.newsType" placeholder="全部" clearable style="width: 140px">
          <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">状态:</span>
        <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 120px">
          <el-option
            v-for="opt in ENTERPRISE_NEWS_STATUS_SEARCH_OPTIONS"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">创建时间:</span>
        <el-date-picker
          v-model="searchForm.createTimeRange"
          type="daterange"
          range-separator="-"
          start-placeholder="开始"
          end-placeholder="结束"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 260px"
          clearable
        />
        <el-button type="primary" :icon="Search" class="ml10" @click="onSearch">搜索</el-button>
        <el-button :icon="RefreshRight" @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" :icon="Plus" @click="openAddWithTemplate">新建新闻</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="onPagination"
      >
        <template #newsType="{ row }">
          <span class="news-type-tag" :style="{ color: newsTypeColor(row.newsType) }">
            {{ newsTypeLabel(row.newsType) }}
          </span>
        </template>
      </PIMTable>
    </div>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <el-dialog
      v-model="newsFormDialog.visible"
      :title="newsFormDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="news-form-dialog"
      @closed="onNewsFormClosed"
    >
      <el-form
        ref="newsFormRef"
        :model="newsForm"
        :rules="newsFormRules"
        label-width="110px"
        :disabled="newsFormDialog.readonly"
      >
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="新闻分类" prop="newsType">
              <el-select v-model="newsForm.newsType" placeholder="请选择" style="width: 100%">
                <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="排版模板">
              <el-select v-model="newsForm.layoutTemplate" style="width: 100%">
                <el-option
                  v-for="opt in LAYOUT_TEMPLATE_OPTIONS"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="标题" prop="title">
          <el-input v-model="newsForm.title" placeholder="新闻标题" maxlength="100" show-word-limit />
        </el-form-item>
        <el-form-item label="摘要">
          <el-input v-model="newsForm.summary" type="textarea" :rows="2" maxlength="300" show-word-limit />
        </el-form-item>
        <el-form-item label="正文" prop="contentHtml">
          <Editor v-model="newsForm.contentHtml" :min-height="280" />
        </el-form-item>
        <el-form-item label="附件">
          <FileUpload v-model:file-list="newsForm.attachmentList" :limit="10" button-text="上传 PDF / æ–‡æ¡£" />
        </el-form-item>
        <el-form-item v-if="newsForm.layoutTemplate === 'gallery'" label="图集/视频">
          <el-input
            v-model="galleryInput"
            placeholder="输入资源名称后回车添加(演示)"
            @keyup.enter="addGalleryItem"
          />
          <el-tag
            v-for="(m, i) in newsForm.mediaList"
            :key="i"
            closable
            class="media-tag"
            @close="newsForm.mediaList.splice(i, 1)"
          >
            {{ m.name }}
          </el-tag>
        </el-form-item>
        <el-divider content-position="left">权限管控</el-divider>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="编辑角色">
              <el-select v-model="newsForm.editorRole" style="width: 100%">
                <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="审核角色">
              <el-select v-model="newsForm.reviewerRole" style="width: 100%">
                <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="阅读范围" prop="readScope">
          <el-radio-group v-model="newsForm.readScope">
            <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value">
              {{ opt.label }}
            </el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item v-if="newsForm.readScope === 'department'" label="可见部门">
          <el-select v-model="newsForm.targetDeptIds" multiple placeholder="选择部门" style="width: 100%">
            <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" />
          </el-select>
        </el-form-item>
        <el-form-item label="政策类必读">
          <el-switch v-model="newsForm.requireReadConfirm" active-text="需阅读确认(便于统计未读)" />
        </el-form-item>
        <template v-if="hasApprovalTemplate">
          <el-divider content-position="left">审批流程</el-divider>
          <el-form-item label="审批模板">
            <span class="template-name">{{ approvalTemplateLabel }}</span>
          </el-form-item>
          <el-form-item v-if="activeTemplate" label="审批流程" required>
            <TemplateFlowEditor v-model="submitForm.flowNodes" :user-options="flowUserOptions" />
            <p class="section-tip">流程与审批人由模板预置,可按需微调节点审批人。</p>
          </el-form-item>
        </template>
        <el-alert
          v-else-if="!isNewsEdit"
          type="warning"
          show-icon
          :closable="false"
          title="请先通过「新建新闻」选择审批模板"
        />
      </el-form>
      <template v-if="!newsFormDialog.readonly" #footer>
        <el-button @click="newsFormDialog.visible = false">取 æ¶ˆ</el-button>
        <el-button :loading="newsSaving" @click="onNewsSave('draft')">存草稿</el-button>
        <el-button type="warning" :loading="newsSaving" @click="onNewsSave('submit_review')">
          æäº¤å®¡æ ¸
        </el-button>
        <el-button type="primary" :loading="newsSaving" @click="onNewsSave('submit_review')">
          ä¿ å­˜
        </el-button>
      </template>
    </el-dialog>
    <el-dialog v-model="detailDialog.visible" title="新闻详情" width="880px" append-to-body destroy-on-close>
      <NewsDetailPanel :row="detailNewsRow" />
      <template #footer>
        <el-button v-if="canEditEnterpriseNewsRow(detailRow)" type="primary" @click="openNewsEditFromDetail">
          ä¿®æ”¹
        </el-button>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Plus, RefreshRight, Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import {
  deleteEnterpriseNews,
  saveEnterpriseNews,
  updateEnterpriseNews,
} from "@/api/officeProcessAutomation/enterpriseNews.js";
import { computed, onMounted, reactive, ref } from "vue";
import Editor from "@/components/Editor/index.vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import TemplateFlowEditor from "../../ApproveManage/approve-template/components/TemplateFlowEditor.vue";
import {
  applyBindingToForm,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { createEmptySubmitForm } from "../../ApproveManage/approve-list/approveListConstants.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import NewsDetailPanel from "./components/NewsDetailPanel.vue";
import {
  NEWS_TYPE_OPTIONS,
  LAYOUT_TEMPLATE_OPTIONS,
  READ_SCOPE_OPTIONS,
  PUBLISH_ROLE_OPTIONS,
  DEPT_OPTIONS,
  createEmptyForm,
  ENTERPRISE_NEWS_STATUS_SEARCH_OPTIONS,
  newsTypeColor,
  newsTypeLabel,
  validateNewsForm,
} from "./enterpriseNewsUtils.js";
import {
  buildEnterpriseNewsSaveDto,
  buildEnterpriseNewsTableColumns,
  canEditEnterpriseNewsRow,
  mapApiRowToNewsForm,
} from "./enterpriseNewsMappers.js";
import { useEnterpriseNewsList } from "./useEnterpriseNewsList.js";
const searchForm = reactive({
  keyword: "",
  newsType: "",
  status: "",
  createTimeRange: null,
});
const newsFormDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const newsForm = reactive(createEmptyForm());
const newsFormRef = ref();
const galleryInput = ref("");
const newsFormRules = {
  title: [{ required: true, message: "请输入新闻标题", trigger: "blur" }],
  newsType: [{ required: true, message: "请选择新闻分类", trigger: "change" }],
  readScope: [{ required: true, message: "请选择阅读范围", trigger: "change" }],
};
const newsList = useEnterpriseNewsList();
const { tableData, tableLoading, page, handleQuery: fetchNewsList, pagination: paginateNewsList } =
  newsList;
const submitForm = reactive(createEmptySubmitForm(""));
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const newsSaving = ref(false);
const isNewsEdit = computed(() => newsFormDialog.mode === "edit");
const activeTemplate = computed(() => submitForm.templateSnapshot || null);
const hasApprovalTemplate = computed(
  () => Boolean(activeTemplate.value || newsForm.templateId)
);
const approvalTemplateLabel = computed(
  () =>
    activeTemplate.value?.label ||
    newsForm.templateName ||
    submitForm.templateName ||
    "—"
);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
function openAddWithTemplate() {
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  pendingTemplateBinding.value = binding;
}
function resetSubmitForm() {
  Object.assign(submitForm, createEmptySubmitForm(""));
}
const detailNewsRow = computed(() => mapApiRowToNewsForm(detailRow.value));
const tableColumn = ref(
  buildEnterpriseNewsTableColumns(() => [
    { name: "详情", type: "text", clickFun: (row) => openNewsDetail(row) },
    {
      name: "修改",
      type: "text",
      disabled: (row) => !canEditEnterpriseNewsRow(row),
      clickFun: (row) => openNewsEdit(row),
    },
    {
      name: "删除",
      type: "danger",
      disabled: (row) => !canEditEnterpriseNewsRow(row),
      clickFun: (row) => handleNewsDelete(row),
    },
  ])
);
function resetNewsForm(target = createEmptyForm()) {
  Object.assign(newsForm, createEmptyForm(), target);
}
function openNewsFormDialog(mode, row) {
  newsFormDialog.mode = mode;
  newsFormDialog.readonly = mode === "view";
  newsFormDialog.title =
    mode === "add" ? "新建企业新闻" : mode === "edit" ? "编辑企业新闻" : "查看企业新闻";
  if (mode === "add") {
    resetNewsForm();
  } else if (row) {
    resetNewsForm(mapApiRowToNewsForm(row));
  }
  newsFormDialog.visible = true;
}
function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  resetSubmitForm();
  applyBindingToForm(submitForm, binding);
  if (binding.templateId) {
    newsForm.templateId = binding.templateId;
    newsForm.templateName = binding.templateName || "";
  }
  openNewsFormDialog("add");
}
function openNewsEdit(row) {
  if (!canEditEnterpriseNewsRow(row)) {
    ElMessage.warning("当前状态不可修改");
    return;
  }
  resetSubmitForm();
  if (row?.templateId != null) {
    submitForm.templateId = row.templateId;
    submitForm.templateName = row.templateName || "";
  }
  openNewsFormDialog("edit", row);
}
function openNewsDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openNewsEditFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openNewsEdit(row);
}
async function handleNewsDelete(row) {
  if (!canEditEnterpriseNewsRow(row)) {
    ElMessage.warning("当前状态不可删除");
    return;
  }
  if (row?.id == null || row.id === "") {
    ElMessage.warning("无法删除:缺少新闻 ID");
    return;
  }
  const title = (row.title || "").trim() || "该条新闻";
  try {
    await ElMessageBox.confirm(
      `确定要删除「${title}」吗?删除后不可恢复。`,
      "删除确认",
      {
        type: "warning",
        confirmButtonText: "确定删除",
        cancelButtonText: "取消",
        distinguishCancelAndClose: true,
        autofocus: false,
      }
    );
  } catch {
    return;
  }
  try {
    await deleteEnterpriseNews([row.id]);
    ElMessage.success("删除成功");
    await fetchNewsList(searchForm);
  } catch {
    /* é”™è¯¯ç”±è¯·æ±‚拦截器提示 */
  }
}
function onNewsFormClosed() {
  newsFormRef.value?.resetFields?.();
}
function addGalleryItem() {
  const name = (galleryInput.value || "").trim();
  if (!name) return;
  newsForm.mediaList = newsForm.mediaList || [];
  newsForm.mediaList.push({ type: "image", name, url: "" });
  galleryInput.value = "";
}
async function onNewsSave(action = "submit_review") {
  try {
    await newsFormRef.value?.validate();
  } catch {
    ElMessage.warning("请完善表单必填项后再保存");
    return;
  }
  const v = validateNewsForm(newsForm);
  if (!v.ok) {
    ElMessage.warning(v.message);
    return;
  }
  const status = action === "draft" ? "DRAFT" : "PENDING";
  newsForm.publishStatus = status;
  if (!isNewsEdit.value) {
    const templateId = newsForm.templateId || submitForm.templateId;
    if (!templateId) {
      ElMessage.warning("请先选择审批模板");
      return;
    }
    if (!newsForm.templateId) newsForm.templateId = templateId;
    if (!newsForm.templateName && submitForm.templateName) {
      newsForm.templateName = submitForm.templateName;
    }
    if (action !== "draft") {
      const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
      if (!bindingCheck.ok) {
        ElMessage.warning(bindingCheck.message);
        return;
      }
    }
  } else if (!newsForm.templateId && submitForm.templateId) {
    newsForm.templateId = submitForm.templateId;
    newsForm.templateName = submitForm.templateName || newsForm.templateName;
  }
  const dto = buildEnterpriseNewsSaveDto(newsForm, { status });
  if (isNewsEdit.value) {
    if (dto.id == null) {
      ElMessage.warning("无法修改:缺少新闻 ID");
      return;
    }
  }
  if (newsSaving.value) return;
  newsSaving.value = true;
  try {
    if (isNewsEdit.value) {
      await updateEnterpriseNews(dto);
    } else {
      await saveEnterpriseNews(dto);
    }
    newsFormDialog.visible = false;
    const msg =
      action === "draft" ? "已保存草稿" : isNewsEdit.value ? "修改成功" : "已提交审核";
    ElMessage.success(msg);
    if (!isNewsEdit.value) page.current = 1;
    await fetchNewsList(searchForm);
  } catch {
    /* é”™è¯¯ç”±è¯·æ±‚拦截器提示 */
  } finally {
    newsSaving.value = false;
  }
}
function onSearch() {
  fetchNewsList(searchForm);
}
function resetSearch() {
  searchForm.keyword = "";
  searchForm.newsType = "";
  searchForm.status = "";
  searchForm.createTimeRange = null;
  onSearch();
}
function onPagination(obj) {
  paginateNewsList(obj, searchForm);
}
onMounted(() => {
  loadFlowUsers();
  fetchNewsList(searchForm);
});
</script>
<style scoped>
.enterprise-news-page .search_form {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  flex-shrink: 0;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.news-type-tag {
  font-weight: 600;
  font-size: 13px;
}
.media-tag {
  margin: 6px 8px 0 0;
}
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.section-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin: 8px 0 0;
  line-height: 1.5;
}
.mb20 {
  margin-bottom: 20px;
}
.ml10 {
  margin-left: 10px;
}
</style>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNewsList.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,55 @@
import { listEnterpriseNewsPage } from "@/api/officeProcessAutomation/enterpriseNews.js";
import { ElMessage } from "element-plus";
import { reactive, ref } from "vue";
import {
  buildEnterpriseNewsListParams,
  mapEnterpriseNewsFromApi,
  unwrapEnterpriseNewsPage,
} from "./enterpriseNewsMappers.js";
/** ä¼ä¸šæ–°é—»åˆ—表:分页查询 /enterpriseNews/listPage */
export function useEnterpriseNewsList() {
  const tableData = ref([]);
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  let lastSearchForm = null;
  async function fetchList(searchForm = {}) {
    tableLoading.value = true;
    try {
      const res = await listEnterpriseNewsPage(
        buildEnterpriseNewsListParams({ page, searchForm })
      );
      const { records, total } = unwrapEnterpriseNewsPage(res);
      tableData.value = records.map(mapEnterpriseNewsFromApi);
      page.total = total;
    } catch {
      tableData.value = [];
      page.total = 0;
      ElMessage.error("企业新闻列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  function handleQuery(searchForm) {
    lastSearchForm = searchForm;
    page.current = 1;
    return fetchList(searchForm);
  }
  function pagination({ page: p, limit }, searchForm) {
    page.current = p;
    page.size = limit;
    return fetchList(searchForm ?? lastSearchForm ?? {});
  }
  return {
    tableData,
    tableLoading,
    page,
    fetchList,
    handleQuery,
    pagination,
  };
}
src/views/officeProcessAutomation/HrManage/post-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,292 @@
<!--OA模块:岗位管理-->
<template>
  <div class="app-container">
     <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch">
        <el-form-item label="岗位编码" prop="postCode">
           <el-input
              v-model="queryParams.postCode"
              placeholder="请输入岗位编码"
              clearable
              style="width: 200px"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="岗位名称" prop="postName">
           <el-input
              v-model="queryParams.postName"
              placeholder="请输入岗位名称"
              clearable
              style="width: 200px"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="状态" prop="status">
           <el-select v-model="queryParams.status" placeholder="岗位状态" clearable style="width: 200px">
              <el-option
                 v-for="dict in sys_normal_disable"
                 :key="dict.value"
                 :label="dict.label"
                 :value="dict.value"
              />
           </el-select>
        </el-form-item>
        <el-form-item>
           <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
           <el-button icon="Refresh" @click="resetQuery">重置</el-button>
        </el-form-item>
     </el-form>
     <el-row :gutter="10" class="mb8">
        <el-col :span="1.5">
           <el-button
              type="primary"
              plain
              icon="Plus"
              @click="handleAdd"
              v-hasPermi="['system:post:add']"
           >新增</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="success"
              plain
              icon="Edit"
              :disabled="single"
              @click="handleUpdate"
              v-hasPermi="['system:post:edit']"
           >修改</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="danger"
              plain
              icon="Delete"
              :disabled="multiple"
              @click="handleDelete"
              v-hasPermi="['system:post:remove']"
           >删除</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="warning"
              plain
              icon="Download"
              @click="handleExport"
              v-hasPermi="['system:post:export']"
           >导出</el-button>
        </el-col>
        <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
     <el-table v-loading="loading" :data="postList" @selection-change="handleSelectionChange">
        <el-table-column type="selection" width="55" align="center" />
        <el-table-column label="岗位编号" align="center" prop="postId" />
        <el-table-column label="岗位编码" align="center" prop="postCode" />
        <el-table-column label="岗位名称" align="center" prop="postName" />
        <el-table-column label="岗位排序" align="center" prop="postSort" />
        <el-table-column label="状态" align="center" prop="status">
           <template #default="scope">
              <dict-tag :options="sys_normal_disable" :value="scope.row.status" />
           </template>
        </el-table-column>
        <el-table-column label="创建时间" align="center" prop="createTime" width="180">
           <template #default="scope">
              <span>{{ parseTime(scope.row.createTime) }}</span>
           </template>
        </el-table-column>
        <el-table-column label="操作" width="180" align="center" class-name="small-padding fixed-width">
           <template #default="scope">
              <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:post:edit']">修改</el-button>
              <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:post:remove']">删除</el-button>
           </template>
        </el-table-column>
     </el-table>
     <pagination
        v-show="total > 0"
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
     />
     <!-- æ·»åŠ æˆ–ä¿®æ”¹å²—ä½å¯¹è¯æ¡† -->
     <el-dialog :title="title" v-model="open" width="500px" append-to-body>
        <el-form ref="postRef" :model="form" :rules="rules" label-width="80px">
           <el-form-item label="岗位名称" prop="postName">
              <el-input v-model="form.postName" placeholder="请输入岗位名称" />
           </el-form-item>
           <el-form-item label="岗位编码" prop="postCode">
              <el-input v-model="form.postCode" placeholder="请输入编码名称" />
           </el-form-item>
           <el-form-item label="岗位顺序" prop="postSort">
              <el-input-number v-model="form.postSort" controls-position="right" :min="0" />
           </el-form-item>
           <el-form-item label="岗位状态" prop="status">
              <el-radio-group v-model="form.status">
                 <el-radio
                    v-for="dict in sys_normal_disable"
                    :key="dict.value"
                    :value="dict.value"
                 >{{ dict.label }}</el-radio>
              </el-radio-group>
           </el-form-item>
           <el-form-item label="备注" prop="remark">
              <el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
           </el-form-item>
        </el-form>
        <template #footer>
           <div class="dialog-footer">
              <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
              <el-button @click="cancel">取 æ¶ˆ</el-button>
           </div>
        </template>
     </el-dialog>
  </div>
</template>
<script setup name="Post">
import { listPost, addPost, delPost, getPost, updatePost } from "@/api/system/post"
import {onMounted} from "vue";
const { proxy } = getCurrentInstance()
const { sys_normal_disable } = proxy.useDict("sys_normal_disable")
const postList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const data = reactive({
 form: {},
 queryParams: {
   pageNum: 1,
   pageSize: 10,
   postCode: undefined,
   postName: undefined,
   status: undefined
 },
 rules: {
   postName: [{ required: true, message: "岗位名称不能为空", trigger: "blur" }],
   postCode: [{ required: true, message: "岗位编码不能为空", trigger: "blur" }],
   postSort: [{ required: true, message: "岗位顺序不能为空", trigger: "blur" }],
 }
})
const { queryParams, form, rules } = toRefs(data)
/** æŸ¥è¯¢å²—位列表 */
function getList() {
 loading.value = true
 listPost(queryParams.value).then(response => {
   postList.value = response.rows
   total.value = response.total
   loading.value = false
 })
}
/** å–消按钮 */
function cancel() {
 open.value = false
 reset()
}
/** è¡¨å•重置 */
function reset() {
 form.value = {
   postId: undefined,
   postCode: undefined,
   postName: undefined,
   postSort: 0,
   status: "0",
   remark: undefined
 }
 proxy.resetForm("postRef")
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
 queryParams.value.pageNum = 1
 getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
 proxy.resetForm("queryRef")
 handleQuery()
}
/** å¤šé€‰æ¡†é€‰ä¸­æ•°æ® */
function handleSelectionChange(selection) {
 ids.value = selection.map(item => item.postId)
 single.value = selection.length != 1
 multiple.value = !selection.length
}
/** æ–°å¢žæŒ‰é’®æ“ä½œ */
function handleAdd() {
 reset()
 open.value = true
 title.value = "添加岗位"
}
/** ä¿®æ”¹æŒ‰é’®æ“ä½œ */
function handleUpdate(row) {
 reset()
 const postId = row.postId || ids.value
 getPost(postId).then(response => {
   form.value = response.data
   open.value = true
   title.value = "修改岗位"
 })
}
/** æäº¤æŒ‰é’® */
function submitForm() {
 proxy.$refs["postRef"].validate(valid => {
   if (valid) {
     if (form.value.postId != undefined) {
       updatePost(form.value).then(response => {
         proxy.$modal.msgSuccess("修改成功")
         open.value = false
         getList()
       })
     } else {
       addPost(form.value).then(response => {
         proxy.$modal.msgSuccess("新增成功")
         open.value = false
         getList()
       })
     }
   }
 })
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
 const postIds = row.postId || ids.value
 proxy.$modal.confirm('是否确认删除岗位编号为"' + postIds + '"的数据项?').then(function() {
   return delPost(postIds)
 }).then(() => {
   getList()
   proxy.$modal.msgSuccess("删除成功")
 }).catch(() => {})
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
function handleExport() {
 proxy.download("system/post/export", {
   ...queryParams.value
 }, `post_${new Date().getTime()}.xlsx`)
}
onMounted(() => {
 getList();
});
</script>
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,174 @@
<!--OA模块:转正申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">审批单号:</span>
        <el-input
          v-model="searchForm.instanceNo"
          style="width: 220px"
          placeholder="请输入审批单号"
          clearable
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请人:</span>
        <el-input
          v-model="searchForm.applicantName"
          style="width: 220px"
          placeholder="请输入申请人"
          clearable
          :prefix-icon="Search"
          @keyup.enter="onSearch"
        />
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openAddWithTemplate">新增转正申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      @submit="onSubmit"
    />
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.REGULAR"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="转正申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, reactive } from "vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
const searchForm = reactive({
  instanceNo: "",
  applicantName: "",
});
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.REGULAR,
  buildExtraListParams(sf) {
    const extra = {};
    const name = (sf?.applicantName || "").trim();
    if (name) extra.applicantName = name;
    return extra;
  },
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions, {
  moduleKey: APPROVAL_MODULE_KEYS.REGULAR,
});
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.instanceNo = "";
  searchForm.applicantName = "";
  onSearch();
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
</style>
src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,347 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        :title="operationType === 'add' ? '新增离职' : '编辑离职'"
        width="70%"
        @close="closeDia"
    >
      <!-- å‘˜å·¥ä¿¡æ¯å±•示区域 -->
      <div class="info-section">
        <div class="info-title">员工信息</div>
        <el-form :model="form" label-width="200px" label-position="left" :rules="rules" ref="formRef" style="margin-top: 20px">
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="姓名:" prop="staffOnJobId">
                <el-select v-model="form.staffOnJobId"
                           placeholder="请选择人员"
                           style="width: 100%"
                           :disabled="operationType === 'edit'"
                           @change="handleSelect">
                  <el-option
                      v-for="item in personList"
                      :key="item.id"
                      :label="item.staffName"
                      :value="item.id"
                  />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="员工编号:">
                {{ currentStaffRecord.staffNo || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="性别:">
                {{ currentStaffRecord.sex || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="户籍住址:">
                {{ currentStaffRecord.nativePlace || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="岗位:">
                {{ currentStaffRecord.postName || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="现住址:">
                {{ currentStaffRecord.adress || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="第一学历:">
                {{ currentStaffRecord.firstStudy || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="专业:">
                {{ currentStaffRecord.profession || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="年龄:">
                {{ currentStaffRecord.age || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="联系电话:">
                {{ currentStaffRecord.phone || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="紧急联系人:">
                {{ currentStaffRecord.emergencyContact || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="紧急联系人联系电话:">
                {{ currentStaffRecord.emergencyContactPhone || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="离职日期:" prop="leaveDate">
                <el-date-picker
                    v-model="form.leaveDate"
                    type="date"
                    :disabled="operationType === 'edit'"
                    :disabled-date="disabledFutureDate"
                    placeholder="请选择离职日期"
                    value-format="YYYY-MM-DD"
                    format="YYYY-MM-DD"
                    style="width: 100%"
                />
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="离职原因:" prop="reason">
                <el-select v-model="form.reason" placeholder="请选择离职原因" style="width: 100%" @change="handleSelectDimissionReason">
                  <el-option
                      v-for="(item, index) in dimissionReasonOptions"
                      :key="index"
                      :label="item.label"
                      :value="item.value"
                  />
                </el-select>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="备注:" prop="remark" v-if="form.reason === 'other'">
                <el-input
                    v-model="form.remark"
                    type="textarea"
                    :rows="3"
                    placeholder="备注"
                    maxlength="500"
                    show-word-limit
                />
              </el-form-item>
            </el-col>
          </el-row>
        </el-form>
<!--        <el-row :gutter="30">-->
<!--          <el-col :span="12">-->
<!--            <div class="info-item">-->
<!--              <span class="info-label">离职原因:</span>-->
<!--              <el-select v-model="form.reason" placeholder="请选择人员" style="width: 100%" @change="handleSelect">-->
<!--                <el-option-->
<!--                    v-for="(item, index) in dimissionReasonOptions"-->
<!--                    :key="index"-->
<!--                    :label="item.label"-->
<!--                    :value="item.value"-->
<!--                />-->
<!--              </el-select>-->
<!--            </div>-->
<!--          </el-col>-->
<!--          <el-col :span="12">-->
<!--            <div class="info-item">-->
<!--              <span class="info-label">员工编号:</span>-->
<!--              <span class="info-value">{{ form.staffNo || '-' }}</span>-->
<!--            </div>-->
<!--          </el-col>-->
<!--        </el-row>-->
      </div>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">确认</el-button>
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import {ref, reactive, toRefs, getCurrentInstance} from "vue";
import {staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js";
import {createStaffLeave, updateStaffLeave} from "@/api/personnelManagement/staffLeave.js";
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const dialogFormVisible = ref(false);
const operationType = ref('')
const getTodayDate = () => {
  const now = new Date();
  const year = now.getFullYear();
  const month = `${now.getMonth() + 1}`.padStart(2, '0');
  const day = `${now.getDate()}`.padStart(2, '0');
  return `${year}-${month}-${day}`;
};
const disabledFutureDate = (time) => {
  const todayEnd = new Date();
  todayEnd.setHours(23, 59, 59, 999);
  return time.getTime() > todayEnd.getTime();
};
const data = reactive({
  form: {
    staffOnJobId: undefined,
    leaveDate: "",
    reason: "",
    remark: "",
  },
  rules: {
    staffName: [{ required: true, message: "请选择人员" }],
    leaveDate: [{ required: true, message: "请选择离职日期", trigger: "change" }],
    reason: [{ required: true, message: "请选择离职原因"}],
  },
  dimissionReasonOptions: [
      {label: '薪资待遇', value: 'salary'},
      {label: '职业发展', value: 'career_development'},
      {label: '工作环境', value: 'work_environment'},
      {label: '个人原因', value: 'personal_reason'},
      {label: '其他', value: 'other'},
  ],
  currentStaffRecord: {},
});
const { form, rules, dimissionReasonOptions, currentStaffRecord } = toRefs(data);
// æ‰“开弹框
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  if (operationType.value === 'edit') {
    currentStaffRecord.value = row
    form.value.staffOnJobId = row.staffOnJobId
    form.value.leaveDate = row.leaveDate
    form.value.reason = row.reason
    form.value.remark = row.remark
    personList.value = [
      {
        staffName: row.staffName,
        id: row.staffOnJobId,
      }
    ]
  } else {
    form.value.leaveDate = getTodayDate()
    getList()
  }
}
const handleSelectDimissionReason = (val) => {
  if (val === 'other') {
    form.value.remark = ''
  }
}
// æäº¤äº§å“è¡¨å•
const submitForm = () => {
  form.value.staffState = 0
  if (form.value.reason !== 'other') {
    form.value.remark = ''
  }
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      if (operationType.value === "add") {
        createStaffLeave(form.value).then(res => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        })
      } else {
        updateStaffLeave(currentStaffRecord.value.id, form.value).then(res => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        })
      }
    }
  })
}
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  // è¡¨å•已注释,手动重置表单数据
  form.value = {
    staffOnJobId: undefined,
    leaveDate: "",
    reason: "",
    remark: "",
  };
  dialogFormVisible.value = false;
  emit('close')
};
const personList = ref([]);
/**
 * èŽ·å–å½“å‰åœ¨èŒäººå‘˜åˆ—è¡¨
 */
const getList = () => {
  staffOnJobListPage({
    current: -1,
    size: -1,
        staffState: 1
  }).then(res => {
    personList.value = res.data.records || []
  })
};
const handleSelect = (val) => {
  let obj = personList.value.find(item => item.id === val)
  currentStaffRecord.value = {}
  if (obj) {
    // ä¿ç•™ç¦»èŒæ—¥æœŸå’Œç¦»èŒåŽŸå› ï¼Œåªæ›´æ–°å‘˜å·¥ä¿¡æ¯
    currentStaffRecord.value = obj
  }
}
defineExpose({
  openDialog,
});
</script>
<style scoped>
.info-section {
  background: #f5f7fa;
  padding: 20px;
  border-radius: 8px;
  margin-bottom: 20px;
}
.info-title {
  font-size: 16px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 1px solid #e4e7ed;
}
.info-item {
  display: flex;
  align-items: center;
  margin-bottom: 16px;
  min-height: 32px;
}
.info-label {
  min-width: 140px;
  color: #606266;
  font-size: 14px;
  font-weight: 500;
}
.info-value {
  flex: 1;
  color: #303133;
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/HrManage/resign-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,220 @@
<!--OA模块:离职申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">姓名:</span>
        <el-input
            v-model="searchForm.staffName"
            style="width: 240px"
            placeholder="请输入姓名搜索"
            @change="handleQuery"
            clearable
            :prefix-icon="Search"
        />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px"
        >搜索</el-button
        >
      </div>
      <div>
        <el-button type="primary" @click="openForm('add')">新增离职</el-button>
        <el-button @click="handleOut">导出</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :page="page"
          :isSelection="true"
          @selection-change="handleSelectionChange"
          :tableLoading="tableLoading"
          @pagination="pagination"
          :total="page.total"
      ></PIMTable>
    </div>
    <form-dia ref="formDia" @close="handleQuery"></form-dia>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import {onMounted, ref} from "vue";
import FormDia from "@/views/personnelManagement/dimission/components/formDia.vue";
import { findStaffLeaveListPage } from "@/api/personnelManagement/staffLeave.js";
import {ElMessageBox} from "element-plus";
const data = reactive({
  searchForm: {
    staffName: "",
  },
});
const { searchForm } = toRefs(data);
const tableColumn = ref([
  {
    label: "状态",
    prop: "staffState",
    dataType: "tag",
    formatData: (params) => {
      if (params == 0) {
        return "离职";
      } else if (params == 1) {
        return "在职";
      } else {
        return null;
      }
    },
    formatType: (params) => {
      if (params == 0) {
        return "danger";
      } else if (params == 1) {
        return "primary";
      } else {
        return null;
      }
    },
  },
  {
    label: "离职日期",
    prop: "leaveDate",
  },
  {
    label: "员工编号",
    prop: "staffNo",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
  {
    label: "性别",
    prop: "sex",
  },
  {
    label: "户籍住址",
    prop: "nativePlace",
  },
  {
    label: "部门",
    prop: "deptName",
  },
  {
    label: "岗位",
    prop: "postName",
  },
  {
    label: "现住址",
    prop: "adress",
    width:200
  },
  {
    label: "第一学历",
    prop: "firstStudy",
  },
  {
    label: "专业",
    prop: "profession",
    width:100
  },
  {
    label: "年龄",
    prop: "age",
  },
  {
    label: "联系电话",
    prop: "phone",
    width:150
  },
  {
    label: "紧急联系人",
    prop: "emergencyContact",
    width: 120
  },
  {
    label: "紧急联系人电话",
    prop: "emergencyContactPhone",
    width:150
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        },
      },
    ],
  },
]);
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
  total: 0,
});
const formDia = ref()
const { proxy } = getCurrentInstance()
// æŸ¥è¯¢åˆ—表
/** æœç´¢æŒ‰é’®æ“ä½œ */
const handleQuery = () => {
  page.current = 1;
  getList();
};
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  findStaffLeaveListPage({...page, ...searchForm.value}).then(res => {
    tableLoading.value = false;
    tableData.value = res.data.records
    page.total = res.data.total;
  }).catch(err => {
    tableLoading.value = false;
  })
};
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// æ‰“开弹框
const openForm = (type, row) => {
  nextTick(() => {
    formDia.value?.openDialog(type, row)
  })
};
// å¯¼å‡º
const handleOut = () => {
  ElMessageBox.confirm("选中的内容将被导出,是否确认导出?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
      .then(() => {
        proxy.download("/staff/staffLeave/export", {}, "人员离职.xlsx");
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
};
onMounted(() => {
  getList();
});
</script>
<style scoped></style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,181 @@
<template>
  <el-card class="form-card" shadow="never">
    <template #header>
      <span class="card-title">
        <span class="card-title-line">|</span>
        åŸºæœ¬ä¿¡æ¯
      </span>
    </template>
    <el-row :gutter="24">
      <el-col :span="5">
        <el-form-item label="员工编号" prop="staffNo">
          <el-input
            v-model="form.staffNo"
            placeholder="请输入"
            clearable
            maxlength="20"
            show-word-limit
            :disabled="operationType !== 'add'"
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="姓名" prop="staffName">
          <el-input
            v-model="form.staffName"
            placeholder="请输入"
            clearable
            maxlength="50"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="别名" prop="alias">
          <el-input
            v-model="form.alias"
            placeholder="请输入"
            clearable
            maxlength="50"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="手机" prop="phone">
          <el-input
            v-model="form.phone"
            placeholder="请输入"
            clearable
            maxlength="11"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="4">
        <el-form-item label="性别" prop="sex">
          <el-select
            v-model="form.sex"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option label="男" value="男" />
            <el-option label="女" value="女" />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>
    <el-row :gutter="24">
      <el-col :span="5">
        <el-form-item label="出生日期" prop="birthDate">
          <el-date-picker
            v-model="form.birthDate"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="年龄" prop="age">
          <el-input-number
            v-model="form.age"
            :min="0"
            :max="150"
            :precision="0"
            :step="1"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="籍贯" prop="nativePlace">
          <el-input
            v-model="form.nativePlace"
            placeholder="请输入"
            clearable
            maxlength="50"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="民族" prop="nation">
          <el-input
            v-model="form.nation"
            placeholder="请输入"
            clearable
            maxlength="20"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="4">
        <el-form-item label="婚姻状况" prop="maritalStatus">
          <el-select
            v-model="form.maritalStatus"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option label="未婚" value="未婚" />
            <el-option label="已婚" value="已婚" />
            <el-option label="离异" value="离异" />
            <el-option label="丧偶" value="丧偶" />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>
    <el-row :gutter="24">
      <el-col :span="10">
        <el-form-item label="角色" prop="roleId">
          <el-select
            v-model="form.roleId"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option
              v-for="item in roleOptions"
              :key="item.roleId"
              :label="item.roleName"
              :value="item.roleId"
              :disabled="item.status == 1"
            />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>
  </el-card>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true },
  operationType: { type: String, default: "add" },
  roleOptions: { type: Array, default: () => [] },
});
const { form, operationType, roleOptions } = toRefs(props);
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,263 @@
<template>
  <div>
    <!-- æ•™è‚²ç»åކ -->
    <el-card class="form-card" shadow="never">
      <template #header>
        <span class="card-title">
          <span class="card-title-line">|</span>
          æ•™è‚²ç»åކ
        </span>
      </template>
      <el-table :data="form.staffEducationList" border>
        <el-table-column label="学历" prop="education" width="120">
          <template #default="{ row }">
            <el-select
              v-model="row.education"
              placeholder="请选择"
              clearable
              style="width: 100%"
            >
              <el-option label="中专及以下" value="secondary" />
              <el-option label="大专" value="junior_college" />
              <el-option label="本科" value="bachelor" />
              <el-option label="硕士" value="master" />
              <el-option label="博士及以上" value="doctor" />
            </el-select>
          </template>
        </el-table-column>
        <el-table-column label="毕业院校" prop="schoolName" min-width="160">
          <template #default="{ row }">
            <el-input
              v-model="row.schoolName"
              placeholder="请输入"
              clearable
              maxlength="30"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="入学时间" prop="enrollTime" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.enrollTime"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="毕业时间" prop="graduateTime" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.graduateTime"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="专业" prop="major" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.major"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="学位" prop="degree" width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.degree"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center">
          <template #default="scope">
            <el-button
              v-if="form.staffEducationList.length > 1"
              type="primary"
              link
              @click="removeEducationRow(scope.$index)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="table-add-row" @click="addEducationRow">新建一行</div>
    </el-card>
    <!-- å·¥ä½œç»åކ -->
    <el-card class="form-card" shadow="never">
      <template #header>
        <span class="card-title">
          <span class="card-title-line">|</span>
          å·¥ä½œç»åކ
        </span>
      </template>
      <el-table :data="form.staffWorkExperienceList" border>
        <el-table-column label="前公司" prop="formerCompany" min-width="180">
          <template #default="{ row }">
            <el-input
              v-model="row.formerCompany"
              placeholder="请输入"
              clearable
              maxlength="30"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="前公司部门" prop="formerDept" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.formerDept"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="前公司职位" prop="formerPosition" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.formerPosition"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="开始日期" prop="startDate" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.startDate"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="结束日期" prop="endDate" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.endDate"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="工作描述" prop="workDesc" min-width="220">
          <template #default="{ row }">
            <el-input
              v-model="row.workDesc"
              type="textarea"
              :rows="2"
              placeholder="请输入"
              clearable
              maxlength="500"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center">
          <template #default="scope">
            <el-button
              v-if="form.staffWorkExperienceList.length > 1"
              type="primary"
              link
              @click="removeWorkRow(scope.$index)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="table-add-row" @click="addWorkRow">新建一行</div>
    </el-card>
  </div>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true },
});
const emit = defineEmits(["update:form"]);
const { form } = toRefs(props);
const addEducationRow = () => {
  form.value.staffEducationList.push({
    education: "",
    schoolName: "",
    enrollTime: "",
    graduateTime: "",
    major: "",
    degree: "",
  });
};
const removeEducationRow = (index) => {
  if (form.value.staffEducationList.length <= 1) return;
  form.value.staffEducationList.splice(index, 1);
};
const addWorkRow = () => {
  form.value.staffWorkExperienceList.push({
    formerCompany: "",
    formerDept: "",
    formerPosition: "",
    startDate: "",
    endDate: "",
    workDesc: "",
  });
};
const removeWorkRow = (index) => {
  if (form.value.staffWorkExperienceList.length <= 1) return;
  form.value.staffWorkExperienceList.splice(index, 1);
};
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.table-add-row {
  margin-top: 8px;
  color: #409eff;
  cursor: pointer;
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,115 @@
<template>
  <div>
    <!-- ç´§æ€¥è”系人 -->
    <el-card class="form-card" shadow="never">
      <template #header>
        <span class="card-title">
          <span class="card-title-line">|</span>
          ç´§æ€¥è”系人
        </span>
      </template>
      <el-table :data="form.staffEmergencyContactList" border>
        <el-table-column label="紧急联系人姓名" prop="contactName" min-width="160">
          <template #default="{ row }">
            <el-input
              v-model="row.contactName"
              placeholder="请输入"
              clearable
              maxlength="50"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="紧急联系人关系" prop="contactRelation" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.contactRelation"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="紧急联系人手机" prop="contactPhone" width="160">
          <template #default="{ row }">
            <el-input
              v-model="row.contactPhone"
              placeholder="请输入"
              clearable
              maxlength="11"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="紧急联系人住址" prop="contactAddress" min-width="220">
          <template #default="{ row }">
            <el-input
              v-model="row.contactAddress"
              placeholder="请输入"
              clearable
              maxlength="50"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center">
          <template #default="scope">
            <el-button
              v-if="form.staffEmergencyContactList.length > 1"
              type="primary"
              link
              @click="removeEmergencyRow(scope.$index)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="table-add-row" @click="addEmergencyRow">新建一行</div>
    </el-card>
  </div>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true }
});
const { form } = toRefs(props);
const addEmergencyRow = () => {
  form.value.staffEmergencyContactList.push({
    contactName: "",
    contactRelation: "",
    contactPhone: "",
    contactAddress: "",
  });
};
const removeEmergencyRow = (index) => {
  if (form.value.staffEmergencyContactList.length <= 1) return;
  form.value.staffEmergencyContactList.splice(index, 1);
};
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.table-add-row {
  margin-top: 8px;
  color: #409eff;
  cursor: pointer;
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,176 @@
<template>
  <el-card class="form-card" shadow="never">
    <template #header>
      <span class="card-title">
        <span class="card-title-line">|</span>
        åœ¨èŒä¿¡æ¯
      </span>
    </template>
    <!-- ç¬¬ä¸€è¡Œï¼šåˆåŒå¼€å§‹ / åˆåŒç»“束 / è¯•用期 / è½¬æ­£ -->
    <el-row :gutter="24">
      <el-col :span="6">
        <el-form-item label="入职日期" prop="contractStartTime">
          <el-date-picker
            v-model="form.contractStartTime"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
            @change="calculateContractTerm"
          />
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item
          label="合同结束日期"
          prop="contractEndTime"
          required
          :rules="[
            {
              required: true,
              message: '请选择合同结束日期',
              trigger: 'change',
            },
          ]"
        >
          <el-date-picker
            v-model="form.contractEndTime"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
            @change="calculateContractTerm"
          />
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item label="试用期(月)" prop="probationPeriod">
          <el-input-number
            v-model="form.proTerm"
            :min="0"
            :max="24"
            :precision="0"
            :step="1"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item label="转正日期" prop="positiveDate">
          <el-date-picker
            v-model="form.positiveDate"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
          />
        </el-form-item>
      </el-col>
    </el-row>
    <!-- ç¬¬äºŒè¡Œï¼šéƒ¨é—¨ / å²—位 / åŸºæœ¬å·¥èµ„ -->
    <el-row :gutter="24">
      <el-col :span="8">
        <el-form-item label="部门" prop="sysDeptId">
          <el-tree-select
            v-model="form.sysDeptId"
            :data="deptOptions"
            check-strictly
            :render-after-expand="false"
            placeholder="请选择"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="8">
        <el-form-item label="岗位" prop="sysPostId">
          <el-select
            v-model="form.sysPostId"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option
              v-for="item in postOptions"
              :key="item.postId"
              :label="item.postName"
              :value="item.postId"
              :disabled="item.status === '1'"
            />
          </el-select>
        </el-form-item>
      </el-col>
      <el-col :span="8">
        <el-form-item label="基本工资" prop="basicSalary">
          <el-input-number
            v-model="form.basicSalary"
            :min="0"
            :max="999999"
            :precision="2"
            :step="100"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-row>
  </el-card>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true },
  postOptions: { type: Array, default: () => [] },
  deptOptions: { type: Array, default: () => [] },
});
const { form, postOptions, deptOptions } = toRefs(props);
// è®¡ç®—合同年限
const calculateContractTerm = () => {
  if (form.value.contractStartTime && form.value.contractEndTime) {
    const startDate = new Date(form.value.contractStartTime);
    const endDate = new Date(form.value.contractEndTime);
    if (endDate > startDate) {
      // è®¡ç®—年份差
      const yearDiff = endDate.getFullYear() - startDate.getFullYear();
      const monthDiff = endDate.getMonth() - startDate.getMonth();
      const dayDiff = endDate.getDate() - startDate.getDate();
      let years = yearDiff;
      // å¦‚果结束日期的月日小于开始日期的月日,则减去1å¹´
      if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
        years = yearDiff - 1;
      }
      form.value.contractTerm = Math.max(0, years);
    } else {
      form.value.contractTerm = 0;
    }
  } else {
    form.value.contractTerm = 0;
  }
};
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,304 @@
<template>
  <FormDialog
    v-model="dialogFormVisible"
    :operation-type="operationType"
    :title="dialogTitle"
    width="90%"
    @close="closeDia"
    @confirm="submitForm"
    @cancel="closeDia"
  >
    <div class="form-dia-body">
      <el-form
        ref="formRef"
        :model="form"
        :rules="rules"
        label-position="top"
      >
        <BasicInfoSection
          :form="form"
          :operation-type="operationType"
          :role-options="roleOptions"
        />
        <JobInfoSection
          :form="form"
          :post-options="postOptions"
          :dept-options="deptOptions"
        />
        <EducationWorkSection :form="form" />
        <EmergencyAndAttachmentSection :form="form" />
      </el-form>
    </div>
  </FormDialog>
</template>
<script setup>
import {
  ref,
  reactive,
  toRefs,
  onMounted,
  getCurrentInstance,
  nextTick,
} from "vue";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { findPostOptions } from "@/api/system/post.js";
import { deptTreeSelect, getUser } from "@/api/system/user.js";
import {
  staffOnJobInfo,
  createStaffOnJob,
  updateStaffOnJob,
} from "@/api/personnelManagement/staffOnJob.js";
import BasicInfoSection from "./BasicInfoSection.vue";
import JobInfoSection from "./JobInfoSection.vue";
import EducationWorkSection from "./EducationWorkSection.vue";
import EmergencyAndAttachmentSection from "./EmergencyAndAttachmentSection.vue";
const { proxy } = getCurrentInstance();
const emit = defineEmits(["close"]);
const dialogFormVisible = ref(false);
const operationType = ref("add");
const id = ref(0);
const formRef = ref(null);
const dialogTitle = () =>
  operationType.value === "add" ? "新增入职" : "编辑人员";
const createEmptyEducation = () => ({
  education: "",
  schoolName: "",
  enrollTime: "",
  graduateTime: "",
  major: "",
  degree: "",
});
const createEmptyWork = () => ({
  formerCompany: "",
  formerDept: "",
  formerPosition: "",
  startDate: "",
  endDate: "",
  workDesc: "",
});
const createEmptyEmergency = () => ({
  contactName: "",
  contactRelation: "",
  contactPhone: "",
  contactAddress: "",
});
const createDefaultForm = () => ({
  id: undefined,
  // åŸºæœ¬ä¿¡æ¯
  staffNo: "",
  staffName: "",
  alias: "",
  phone: "",
  sex: "",
  birthDate: "",
  age: undefined,
  nativePlace: "",
  nation: "",
  maritalStatus: "",
  politicalStatus: "",
  firstWorkDate: "",
  workingYears: undefined,
  idCardNo: "",
  hukouType: "",
  email: "",
  currentAddress: "",
  // åœ¨èŒä¿¡æ¯
  contractStartTime: "",
  contractEndTime: "",
  proTerm: undefined,
  positiveDate: "",
  sysDeptId: undefined,
  sysPostId: undefined,
  basicSalary: undefined,
  // é“¶è¡Œå¡ä¿¡æ¯
  bankName: "",
  bankCardNo: "",
  // æ•™è‚²ç»åކ
  staffEducationList: [createEmptyEducation()],
  // å·¥ä½œç»åކ
  staffWorkExperienceList: [createEmptyWork()],
  // ç´§æ€¥è”系人
  staffEmergencyContactList: [createEmptyEmergency()],
  // è§’色(单选)
  roleId: undefined,
});
const state = reactive({
  form: createDefaultForm(),
  rules: {
    staffNo: [{ required: true, message: "请输入员工编号", trigger: "blur" }],
    staffName: [{ required: true, message: "请输入姓名", trigger: "blur" }],
    phone: [{ required: true, message: "请输入手机", trigger: "blur" }],
    sex: [{ required: true, message: "请选择性别", trigger: "change" }],
    birthDate: [
      { required: true, message: "请选择出生日期", trigger: "change" },
    ],
    contractStartTime: [
      { required: true, message: "请选择入职日期", trigger: "change" },
    ],
    contractEndTime: [
      { required: true, message: "请选择合同结束日期", trigger: "change" },
    ],
    sysDeptId: [
      { required: true, message: "请选择部门", trigger: "change" },
    ],
    roleId: [{ required: true, message: "请选择角色", trigger: "change" }],
  },
  postOptions: [],
  deptOptions: [],
});
const { form, rules, postOptions, deptOptions } = toRefs(state);
const roleOptions = ref([]);
const resetForm = () => {
  Object.assign(form.value, createDefaultForm());
  nextTick(() => {
    formRef.value?.clearValidate();
  });
};
const fetchPostOptions = () => {
  findPostOptions().then((res) => {
    postOptions.value = res.data || [];
  });
};
const fetchDeptOptions = () => {
  deptTreeSelect().then((response) => {
    deptOptions.value = filterDisabledDept(
      JSON.parse(JSON.stringify(response.data || []))
    );
  });
};
const fetchRoleOptions = () => {
  getUser().then((res) => {
    roleOptions.value = res.roles || [];
  });
};
function filterDisabledDept(deptList) {
  return deptList.filter((dept) => {
    if (dept.disabled) {
      return false;
    }
    if (dept.children && dept.children.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  fetchPostOptions();
  fetchDeptOptions();
  fetchRoleOptions();
  resetForm();
  if (type === "edit" && row?.id) {
    id.value = row.id;
    staffOnJobInfo(id.value, {}).then((res) => {
      const d = res.data || {};
      Object.assign(form.value, {
        ...form.value,
        ...d,
      });
      if (
        !Array.isArray(form.value.staffEducationList) ||
        !form.value.staffEducationList.length
      ) {
        form.value.staffEducationList = [createEmptyEducation()];
      }
      if (
        !Array.isArray(form.value.staffWorkExperienceList) ||
        !form.value.staffWorkExperienceList.length
      ) {
        form.value.staffWorkExperienceList = [createEmptyWork()];
      }
      if (
        !Array.isArray(form.value.staffEmergencyContactList) ||
        !form.value.staffEmergencyContactList.length
      ) {
        form.value.staffEmergencyContactList = [createEmptyEmergency()];
      }
      if (form.value.sysPostId === 0) {
        form.value.sysPostId = undefined;
      }
      if (form.value.sysDeptId === 0) {
        form.value.sysDeptId = undefined;
      }
    });
  }
};
onMounted(() => {
  fetchPostOptions();
  fetchDeptOptions();
});
const submitForm = () => {
  if (!form.value.sysPostId) {
    form.value.sysPostId = undefined;
  }
  if (!form.value.sysDeptId) {
    form.value.sysDeptId = undefined;
  }
  // å…¼å®¹åŽç«¯å¯èƒ½ä»ä½¿ç”¨ roleIds æ•°ç»„
  form.value.roleIds = form.value.roleId ? [form.value.roleId] : [];
  formRef.value?.validate((valid) => {
    if (valid) {
      if (operationType.value === "add") {
        createStaffOnJob(form.value).then(() => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        });
      } else {
        updateStaffOnJob(id.value, form.value).then(() => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        });
      }
    }
  });
};
const closeDia = () => {
  formRef.value?.resetFields();
  dialogFormVisible.value = false;
  emit("close");
};
defineExpose({
  openDialog,
});
</script>
<style scoped>
.form-dia-body {
  padding: 0;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.form-card {
  margin-bottom: 16px;
}
.dialog-footer {
  text-align: right;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
<template>
  <el-dialog
      v-model="isShow"
      title="续签合同"
      width="800px"
      @close="closeModal"
  >
    <el-form :model="form" label-width="140px" label-position="top" :rules="rules" ref="formRef">
      <el-form-item label="合同开始日期:" prop="contractStartTime">
        <el-date-picker
            v-model="form.contractStartTime"
            type="date"
            placeholder="请选择日期"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            clearable
            style="width: 100%"
            @change="calculateContractTerm"
        />
      </el-form-item>
      <el-form-item label="合同结束日期:" prop="contractEndTime">
        <el-date-picker
            v-model="form.contractEndTime"
            type="date"
            placeholder="请选择日期"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            clearable
            style="width: 100%"
            @change="calculateContractTerm"
        />
      </el-form-item>
      <el-form-item label="合同年限:" prop="contractTerm">
        <el-input-number v-model="form.contractTerm" :precision="0" :step="1" style="width: 100%" :disabled="true"/>
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button type="primary" @click="submitForm">确认</el-button>
        <el-button @click="closeModal">取消</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
// ç»­ç­¾åˆåŒ
import { renewContract } from "@/api/personnelManagement/staffOnJob.js";
import {computed, getCurrentInstance,} from "vue";
const emit = defineEmits(['update:visible', 'completed']);
const data = reactive({
  form: {
    contractTerm: 0,
    contractStartTime: "",
    contractEndTime: "",
  },
  rules: {
    contractTerm: [{ required: true, message: "请输入", trigger: "blur" }],
    contractStartTime: [{ required: true, message: "请输入", trigger: "blur" }],
    contractEndTime: [{ required: true, message: "请输入", trigger: "blur" }],
  }
});
const { form, rules } = toRefs(data);
let { proxy } = getCurrentInstance()
const props = defineProps({
  id: {
    type: Number,
    default: 0,
  },
  visible: {
    type: Boolean,
    required: true,
  },
})
const isShow = computed({
  get() {
    return props.visible;
  },
  set(val) {
    emit('update:visible', val);
  },
});
// è®¡ç®—合同年限
const calculateContractTerm = () => {
  if (form.value.contractStartTime && form.value.contractEndTime) {
    const startDate = new Date(form.value.contractStartTime);
    const endDate = new Date(form.value.contractEndTime);
    if (endDate > startDate) {
      // è®¡ç®—年份差
      const yearDiff = endDate.getFullYear() - startDate.getFullYear();
      const monthDiff = endDate.getMonth() - startDate.getMonth();
      const dayDiff = endDate.getDate() - startDate.getDate();
      let years = yearDiff;
      // å¦‚果结束日期的月日小于开始日期的月日,则减去1å¹´
      if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
        years = yearDiff - 1;
      }
      form.value.contractTerm = Math.max(0, years);
    } else {
      form.value.contractTerm = 0;
    }
  } else {
    form.value.contractTerm = 0;
  }
};
const submitForm = () => {
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      renewContract(props.id, form.value).then(res => {
        if (res.code === 200) {
          proxy.$modal.msgSuccess("续签合同成功");
          emit('completed');
          closeModal();
        }
      })
    }
  })
}
// å…³é—­å¼¹æ¡†
const closeModal = () => {
  // é‡ç½®è¡¨å•数据
  form.value = {
    contractTerm: 0,
    contractStartTime: "",
    contractEndTime: "",
  };
  isShow.value = false;
};
</script>
src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,69 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        title="详情"
        width="70%"
        @close="closeDia"
    >
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :tableLoading="tableLoading"
          height="600"
      ></PIMTable>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import {ref} from "vue";
import {staffOnJobInfo} from "@/api/personnelManagement/staffOnJob.js";
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const dialogFormVisible = ref(false);
const operationType = ref('')
const tableColumn = ref([
  {
    label: "合同开始日期",
    prop: "contractStartTime",
  },
  {
    label: "合同结束日期",
    prop: "contractEndTime",
  },
]);
const tableData = ref([]);
const tableLoading = ref(false);
// æ‰“开弹框
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  if (operationType.value === 'edit') {
    staffOnJobInfo({staffNo: row.staffNo}).then(res => {
      tableData.value = res.data
    })
  }
}
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  dialogFormVisible.value = false;
  emit('close')
};
defineExpose({
  openDialog,
});
</script>
<style scoped>
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,360 @@
<!--OA模块:员工档案-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">姓名:</span>
        <el-input
            v-model="searchForm.staffName"
            style="width: 240px"
            placeholder="请输入姓名搜索"
            @change="handleQuery"
            clearable
            :prefix-icon="Search"
        />
        <span class="search_title search_title2">部门:</span>
          <el-tree-select
            v-model="searchForm.sysDeptId"
            :data="deptOptions"
            check-strictly
            :render-after-expand="false"
            style="width: 240px"
            placeholder="请选择"
          />
          <span class="search_title search_title2">入职日期:</span>
          <el-date-picker
            v-model="searchForm.contractStartTime"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
          />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px"
        >搜索</el-button
        >
      </div>
      <div>
        <el-button type="primary" @click="openFormNewOrEditFormDia('add')">新增入职</el-button>
        <el-button type="info" @click="handleImport">导入</el-button>
        <el-button @click="handleOut">导出</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :page="page"
          :isSelection="true"
          @selection-change="handleSelectionChange"
          :tableLoading="tableLoading"
          @pagination="pagination"
          :total="page.total"
      ></PIMTable>
    </div>
    <show-form-dia ref="formDia" @close="handleQuery"></show-form-dia>
    <new-or-edit-form-dia ref="formDiaNewOrEditFormDia" @close="handleQuery"></new-or-edit-form-dia>
    <renew-contract
        v-if="isShowRenewContractModal"
        v-model:visible="isShowRenewContractModal"
        :id="id"
        @completed="handleQuery"
    />
    <!-- å¯¼å…¥å¯¹è¯æ¡† -->
    <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body>
      <el-upload
        ref="uploadRef"
        :limit="1"
        accept=".xlsx, .xls"
        :headers="upload.headers"
        :action="upload.url"
        :disabled="upload.isUploading"
        :on-progress="handleFileUploadProgress"
        :on-success="handleFileSuccess"
        :auto-upload="false"
        drag
      >
        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
        <template #tip>
          <div class="el-upload__tip text-center">
            <span>仅允许导入xls、xlsx格式文件。</span>
            <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline; margin-left: 5px;" @click="importTemplate">下载模板</el-link>
          </div>
        </template>
      </el-upload>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitFileForm">ç¡® å®š</el-button>
          <el-button @click="upload.open = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Search, UploadFilled } from "@element-plus/icons-vue";
import {onMounted, ref} from "vue";
import {ElMessageBox} from "element-plus";
import { deptTreeSelect } from "@/api/system/user.js";
import { staffOnJobListPage } from "@/api/personnelManagement/staffOnJob.js";
import { getToken } from "@/utils/auth";
import dayjs from "dayjs";
const NewOrEditFormDia = defineAsyncComponent(() => import("@/views/personnelManagement/employeeRecord/components/NewOrEditFormDia.vue"));
const ShowFormDia = defineAsyncComponent(() => import( "@/views/personnelManagement/employeeRecord/components/Show.vue"));
const RenewContract = defineAsyncComponent(() => import( "@/views/personnelManagement/employeeRecord/components/RenewContract.vue"));
const data = reactive({
  searchForm: {
    staffName: "",
    entryDate: undefined, // å½•入日期
    entryDateStart: undefined,
    entryDateEnd: undefined,
  },
  deptOptions: [],
});
const { searchForm, deptOptions } = toRefs(data);
const isShowRenewContractModal = ref(false);
const id = ref(0);
const tableColumn = ref([
  {
    label: "状态",
    prop: "staffState",
    dataType: "tag",
    formatData: (params) => {
      if (params == 0) {
        return "离职";
      } else if (params == 1) {
        return "在职";
      } else {
        return null;
      }
    },
    formatType: (params) => {
      if (params == 0) {
        return "danger";
      } else if (params == 1) {
        return "primary";
      } else {
        return null;
      }
    },
  },
  {
    label: "员工编号",
    prop: "staffNo",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
  {
    label: "别名",
    prop: "alias",
  },
  {
    label: "手机",
    prop: "phone",
    width: 150,
  },
  {
    label: "性别",
    prop: "sex",
  },
  {
    label: "出生日期",
    prop: "birthDate",
    width: 120,
  },
  {
    label: "入职日期",
    prop: "contractStartTime",
    width: 120,
  },
  {
    label: "年龄",
    prop: "age",
  },
  {
    label: "籍贯",
    prop: "nativePlace",
  },
  {
    label: "民族",
    prop: "nation",
    width: 100,
  },
  {
    label: "婚姻状况",
    prop: "maritalStatus",
    width: 100,
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    width: 180,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openFormNewOrEditFormDia("edit", row);
        },
      },
      {
        name: "续签合同",
        type: "text",
        showHide: row => row.staffState === 1,
        clickFun: (row) => {
          isShowRenewContractModal.value = true;
          id.value = row.id;
        },
      },
    ],
  },
]);
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
  total: 0
});
const formDia = ref()
const formDiaNewOrEditFormDia = ref()
const { proxy } = getCurrentInstance()
// å¯¼å…¥ç›¸å…³
const uploadRef = ref(null)
const upload = reactive({
  // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚
  open: false,
  // å¼¹å‡ºå±‚标题
  title: "",
  // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
  isUploading: false,
  // è®¾ç½®ä¸Šä¼ çš„请求头部
  headers: { Authorization: "Bearer " + getToken() },
  // ä¸Šä¼ çš„地址
  url: import.meta.env.VITE_APP_BASE_API + "/staff/staffOnJob/import"
})
const fetchDeptOptions = () => {
    deptTreeSelect().then(response => {
      deptOptions.value = filterDisabledDept(
        JSON.parse(JSON.stringify(response.data))
      );
    });
  };
const filterDisabledDept = deptList => {
    return deptList.filter(dept => {
      if (dept.disabled) {
        return false;
      }
      if (dept.children && dept.children.length) {
        dept.children = filterDisabledDept(dept.children);
      }
      return true;
    });
  };
/** æœç´¢æŒ‰é’®æ“ä½œ */
const handleQuery = () => {
  page.current = 1;
  getList();
};
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  fetchDeptOptions();
  tableLoading.value = true;
  const params = { ...searchForm.value, ...page };
  params.entryDate = undefined
  staffOnJobListPage({...params}).then(res => {
    tableLoading.value = false;
    tableData.value = res.data.records
    page.total = res.data.total;
  }).catch(err => {
    tableLoading.value = false;
  })
};
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// æ‰“开弹框
const openForm = (type, row) => {
  nextTick(() => {
    formDia.value?.openDialog(type, row)
  })
};
const openFormNewOrEditFormDia = (type, row) => {
  nextTick(() => {
    formDiaNewOrEditFormDia.value?.openDialog(type, row)
  })
};
// å¯¼å‡º
const handleOut = () => {
  ElMessageBox.confirm("选中的内容将被导出,是否确认导出?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
      .then(() => {
        proxy.download("/staff/staffOnJob/export", {staffState: 1}, "员工台账.xlsx");
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
};
// å¯¼å…¥æŒ‰é’®æ“ä½œ
const handleImport = () => {
  upload.title = "员工导入"
  upload.open = true
}
// ä¸‹è½½æ¨¡æ¿æ“ä½œ
const importTemplate = () => {
  proxy.download("/staff/staffOnJob/downloadTemplate", {}, `员工导入模板_${new Date().getTime()}.xlsx`)
}
// æ–‡ä»¶ä¸Šä¼ ä¸­å¤„理
const handleFileUploadProgress = (event, file, fileList) => {
  upload.isUploading = true
}
// æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç†
const handleFileSuccess = (response, file, fileList) => {
  upload.open = false
  upload.isUploading = false
  proxy.$refs["uploadRef"].handleRemove(file)
  proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true })
  getList()
}
// æäº¤ä¸Šä¼ æ–‡ä»¶
const submitFileForm = () => {
  proxy.$refs["uploadRef"].submit()
}
onMounted(() => {
  getList();
});
</script>
<style scoped>
.search_title2 {
  margin-left: 10px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        title="详情"
        width="70%"
        @close="closeDia"
    >
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :tableLoading="tableLoading"
          height="600"
      ></PIMTable>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
    <Files ref="filesDia"></Files>
  </div>
</template>
<script setup>
import {ref} from "vue";
import {findStaffContractListPage} from "@/api/personnelManagement/staffContract.js";
const Files = defineAsyncComponent(() => import( "@/views/personnelManagement/contractManagement/filesDia.vue"));
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const filesDia = ref()
const dialogFormVisible = ref(false);
const operationType = ref('')
const tableColumn = ref([
  {
    label: "合同年限",
    prop: "contractTerm",
  },
  {
    label: "合同开始日期",
    prop: "contractStartTime",
  },
  {
    label: "合同结束日期",
    prop: "contractEndTime",
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    width: 120,
    operation: [
      {
        name: "上传附件",
        type: "text",
        clickFun: (row) => {
          filesDia.value.openDialog( row,'合同')
        },
      }
    ],
  },
]);
const tableData = ref([]);
const tableLoading = ref(false);
// æ‰“开弹框
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  if (operationType.value === 'edit') {
    findStaffContractListPage({staffOnJobId: row.id}).then(res => {
      tableData.value = res.data.records
    })
  }
}
const openUploadFile = (row) => {
  filesDia.value.open = true
  filesDia.value.row = row
}
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  dialogFormVisible.value = false;
  emit('close')
};
defineExpose({
  openDialog,
});
</script>
<style scoped>
</style>
src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,197 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        title="上传附件"
        width="50%"
        @close="closeDia"
    >
      <div style="margin-bottom: 10px;text-align: right">
        <el-upload
            v-model:file-list="fileList"
            class="upload-demo"
            :action="uploadUrl"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            name="file"
            :show-file-list="false"
            :headers="headers"
            style="display: inline;margin-right: 10px"
        >
          <el-button type="primary">上传附件</el-button>
        </el-upload>
        <el-button type="danger" plain @click="handleDelete">删除</el-button>
      </div>
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :tableLoading="tableLoading"
          :isSelection="true"
          :page="page"
          @selection-change="handleSelectionChange"
          height="500"
          @pagination="paginationSearch"
          :total="page.total"
      >
      </PIMTable>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
    <filePreview ref="filePreviewRef" />
  </div>
</template>
<script setup>
import {ref} from "vue";
import {ElMessageBox} from "element-plus";
import {getToken} from "@/utils/auth.js";
import filePreview from '@/components/filePreview/index.vue'
import {
  fileAdd,
  fileDel,
  fileListPage
} from "@/api/financialManagement/revenueManagement.js";
import Pagination from "@/components/PIMTable/Pagination.vue";
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const dialogFormVisible = ref(false);
const currentId = ref('')
const selectedRows = ref([]);
const filePreviewRef = ref()
const tableColumn = ref([
  {
    label: "文件名称",
    prop: "name",
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    operation: [
      {
        name: "下载",
        type: "text",
        clickFun: (row) => {
          downLoadFile(row);
        },
      },
      {
        name: "预览",
        type: "text",
        clickFun: (row) => {
          lookFile(row);
        },
      }
    ],
  },
]);
const page = reactive({
    current: 1,
    size: 100,
});
const total = ref(0);
const tableData = ref([]);
const fileList = ref([]);
const tableLoading = ref(false);
const accountType = ref('')
const headers = ref({
  Authorization: "Bearer " + getToken(),
});
const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/file/upload"); // ä¸Šä¼ çš„图片服务器地址
// æ‰“开弹框
const openDialog = (row,type) => {
  accountType.value = type;
  dialogFormVisible.value = true;
  currentId.value = row.id;
  getList()
}
const paginationSearch = (obj) => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
};
const getList = () => {
  fileListPage({accountId: currentId.value,accountType:accountType.value, ...page}).then(res => {
    tableData.value = res.data.records;
    page.total = res.data.total;
  })
}
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  dialogFormVisible.value = false;
  emit('close')
};
// ä¸Šä¼ æˆåŠŸå¤„ç†
function handleUploadSuccess(res, file) {
  // å¦‚果上传成功
  if (res.code == 200) {
    const fileRow = {}
    fileRow.name = res.data.originalName
    fileRow.url = res.data.tempPath
    uploadFile(fileRow)
  } else {
    proxy.$modal.msgError("文件上传失败");
  }
}
function uploadFile(file) {
  file.accountId = currentId.value;
  file.accountType = accountType.value;
  fileAdd(file).then(res => {
    proxy.$modal.msgSuccess("文件上传成功");
    getList()
  })
}
// ä¸Šä¼ å¤±è´¥å¤„理
function handleUploadError() {
  proxy.$modal.msgError("文件上传失败");
}
// ä¸‹è½½é™„ä»¶
const downLoadFile = (row) => {
    proxy.$download.byUrl(row.url, row.originalFilename);
}
// åˆ é™¤
const handleDelete = () => {
  let ids = [];
  if (selectedRows.value.length > 0) {
    ids = selectedRows.value.map((item) => item.id);
  } else {
    proxy.$modal.msgWarning("请选择数据");
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    fileDel(ids).then((res) => {
      proxy.$modal.msgSuccess("删除成功");
      getList();
    });
  }).catch(() => {
    proxy.$modal.msg("已取消");
  });
};
// é¢„览附件
const lookFile = (row) => {
  filePreviewRef.value.open(row.url)
}
defineExpose({
  openDialog,
});
</script>
<style scoped>
</style>
src/views/officeProcessAutomation/HrManage/staff-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,296 @@
<!--OA模块:员工合同-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">姓名:</span>
        <el-input v-model="searchForm.staffName" style="width: 240px" placeholder="请输入姓名搜索" @change="handleQuery"
          clearable :prefix-icon="Search" />
        <span style="margin-left: 10px" class="search_title">合同结束日期:</span>
        <el-date-picker v-model="searchForm.entryDate" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange"
          placeholder="请选择" clearable @change="changeDaterange" />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
      <div>
        <el-button @click="handleOut">导出</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable rowKey="id" :column="tableColumn" :tableData="tableData" :page="page" :isSelection="true"
        @selection-change="handleSelectionChange" :tableLoading="tableLoading" @pagination="pagination"
        :total="page.total"></PIMTable>
    </div>
    <form-dia ref="formDia" @close="handleQuery"></form-dia>
    <!-- åˆåŒå¯¼å…¥å¯¹è¯æ¡† -->
    <el-dialog
      :title="upload.title"
      v-model="upload.open"
      width="400px"
      append-to-body
    >
      <el-upload
        ref="uploadRef"
        :limit="1"
        accept=".xlsx, .xls"
        :headers="upload.headers"
        :action="upload.url + '?updateSupport=' + upload.updateSupport"
        :disabled="upload.isUploading"
        :on-progress="handleFileUploadProgress"
        :on-success="handleFileSuccess"
        :auto-upload="false"
        drag
      >
        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
        <template #tip>
          <div class="el-upload__tip text-center">
            <span>仅允许导入xls、xlsx格式文件。</span>
          </div>
        </template>
      </el-upload>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitFileForm">ç¡® å®š</el-button>
          <el-button @click="upload.open = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <files-dia ref="filesDia"></files-dia>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { onMounted, ref } from "vue";
import FormDia from "@/views/personnelManagement/contractManagement/components/formDia.vue";
import { ElMessageBox } from "element-plus";
import { staffOnJobListPage } from "@/api/personnelManagement/staffOnJob.js";
import dayjs from "dayjs";
import { getToken } from "@/utils/auth.js";
import FilesDia from "./filesDia.vue";
const data = reactive({
  searchForm: {
    staffName: "",
    entryDate: null, // å½•入日期
    entryDateStart: undefined,
    entryDateEnd: undefined,
  },
});
const { searchForm } = toRefs(data);
const tableColumn = ref([
  {
    label: "状态",
    prop: "staffState",
    dataType: "tag",
    formatData: (params) => {
      if (params == 0) {
        return "离职";
      } else if (params == 1) {
        return "在职";
      } else {
        return null;
      }
    },
    formatType: (params) => {
      if (params == 0) {
        return "danger";
      } else if (params == 1) {
        return "primary";
      } else {
        return null;
      }
    },
  },
  {
    label: "员工编号",
    prop: "staffNo",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
  {
    label: "性别",
    prop: "sex",
  },
  {
    label: "户籍住址",
    prop: "nativePlace",
  },
  {
    label: "岗位",
    prop: "postName",
  },
  {
    label: "现住址",
    prop: "adress",
    width: 200
  },
  {
    label: "第一学历",
    prop: "firstStudy",
  },
  {
    label: "专业",
    prop: "profession",
    width: 100
  },
  {
    label: "年龄",
    prop: "age",
  },
  {
    label: "联系电话",
    prop: "phone",
    width: 150
  },
  {
    label: "紧急联系人",
    prop: "emergencyContact",
    width: 120
  },
  {
    label: "紧急联系人电话",
    prop: "emergencyContactPhone",
    width: 150
  },
  {
    label: "合同结束日期",
    prop: "contractExpireTime",
    width: 120
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    width: 120,
    operation: [
      {
        name: "详情",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        },
      }
    ],
  },
]);
const filesDia = ref()
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
  total: 0,
});
const formDia = ref()
const { proxy } = getCurrentInstance()
const changeDaterange = (value) => {
  searchForm.value.entryDateStart = undefined;
  searchForm.value.entryDateEnd = undefined;
  if (value) {
    searchForm.value.entryDateStart = dayjs(value[0]).format("YYYY-MM-DD");
    searchForm.value.entryDateEnd = dayjs(value[1]).format("YYYY-MM-DD");
  }
  getList();
};
// æ‰“开附件弹框
const openFilesFormDia = (row) => {
  nextTick(() => {
    filesDia.value?.openDialog( row,'合同')
  })
};
// æŸ¥è¯¢åˆ—表
/** æœç´¢æŒ‰é’®æ“ä½œ */
const handleQuery = () => {
  page.current = 1;
  getList();
};
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  const params = { ...searchForm.value, ...page };
  params.entryDate = undefined
  params.staffState = 1
  staffOnJobListPage(params).then(res => {
    tableLoading.value = false;
    tableData.value = res.data.records
    page.total = res.data.total;
  }).catch(err => {
    tableLoading.value = false;
  })
};
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// æ‰“开弹框
const openForm = (type, row) => {
  nextTick(() => {
    formDia.value?.openDialog(type, row)
  })
};
// å¯¼å‡º
const handleOut = () => {
  ElMessageBox.confirm("选中的内容将被导出,是否确认导出?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
    .then(() => {
      proxy.download("/staff/staffOnJob/export", {staffState: 1}, "合同管理.xlsx");
    })
    .catch(() => {
      proxy.$modal.msg("已取消");
    });
};
const upload = reactive({
  // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚(合同导入)
  open: false,
  // å¼¹å‡ºå±‚标题(合同导入)
  title: "",
  // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
  isUploading: false,
  // æ˜¯å¦æ›´æ–°å·²ç»å­˜åœ¨çš„用户数据
  updateSupport: 1,
  // è®¾ç½®ä¸Šä¼ çš„请求头部
  headers: { Authorization: "Bearer " + getToken() },
  // ä¸Šä¼ çš„地址
  url: import.meta.env.VITE_APP_BASE_API + "/staff/staffOnJob/import",
});
/** å¯¼å…¥æŒ‰é’®æ“ä½œ */
function handleImport() {
  upload.title = "合同导入";
  upload.open = true;
}
/** æäº¤ä¸Šä¼ æ–‡ä»¶ */
function submitFileForm() {
  proxy.$refs["uploadRef"].submit();
}
/**文件上传中处理 */
const handleFileUploadProgress = (event, file, fileList) => {
  upload.isUploading = true;
};
/** æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç† */
const handleFileSuccess = (response, file, fileList) => {
  upload.open = false;
  upload.isUploading = false;
  proxy.$refs["uploadRef"].handleRemove(file);
  getList();
};
onMounted(() => {
  getList();
});
</script>
<style scoped></style>
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,347 @@
<!--OA模块:调岗申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">审批单号:</span>
        <el-input
          v-model="searchForm.instanceNo"
          style="width: 220px"
          placeholder="请输入审批单号"
          clearable
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请人:</span>
        <el-select
          v-model="searchForm.applicantId"
          filterable
          remote
          clearable
          reserve-keyword
          placeholder="请选择或搜索申请人"
          style="width: 220px"
          :remote-method="remoteSearchApplicant"
          :loading="applicantSearchLoading"
        >
          <el-option
            v-for="u in applicantSearchOptions"
            :key="u.userId"
            :label="userSelectLabel(u)"
            :value="u.userId"
          />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openAddWithTemplate">新增调岗申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-form-item label="原岗位">
          <el-input :model-value="originalPostName" placeholder="选择申请人后自动带出" disabled />
        </el-form-item>
      </template>
    </ApprovalInstanceSubmitDialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.TRANSFER"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="调岗申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { findPostOptions } from "@/api/system/post.js";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
function isOriginalPostField(field) {
  const label = String(field?.label || "");
  return (
    label.includes("原岗位") ||
    field?.key === "originalPost" ||
    field?.key === "originalPostName" ||
    field?.key === "originalPostId"
  );
}
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isOriginalPostField(f));
}
function findApplicantTemplateField(fields = []) {
  return (
    fields.find((f) => String(f?.label || "").includes("申请人")) ||
    fields.find((f) => f?.type === "select" && f?.optionSource === SELECT_OPTION_SOURCE.USER) ||
    null
  );
}
const searchForm = reactive({
  instanceNo: "",
  applicantId: "",
});
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.TRANSFER,
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const allUsersCache = ref([]);
const postIdToName = ref({});
const targetPostOptions = ref([]);
const applicantSearchLoading = ref(false);
const applicantSearchOptions = ref([]);
const originalPostName = ref("");
const applicantTemplateField = computed(() =>
  findApplicantTemplateField(submitForm.formFieldDefs)
);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function firstPostId(user) {
  if (!user) return undefined;
  if (Array.isArray(user.postIds) && user.postIds.length) return user.postIds[0];
  if (user.postId != null && user.postId !== "") return user.postId;
  return undefined;
}
function resolveOriginalPost(user) {
  if (!user) return { originalPostName: "" };
  const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
  if (nameStr) return { originalPostName: nameStr };
  if (Array.isArray(user.posts) && user.posts.length) {
    return { originalPostName: (user.posts[0].postName ?? "").toString() || "未命名岗位" };
  }
  const pid = firstPostId(user);
  if (pid != null && pid !== "") {
    const n = postIdToName.value[String(pid)] || "";
    return { originalPostName: n || "当前岗位(未在岗位字典中)" };
  }
  return { originalPostName: "未分配岗位" };
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return [...list];
  return list.filter((u) => {
    const nick = (u.nickName || "").toLowerCase();
    const uname = (u.userName || "").toLowerCase();
    const phone = (u.phonenumber || u.phone || "").toString();
    return nick.includes(q) || uname.includes(q) || phone.includes(q);
  });
}
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
async function loadPostOptions() {
  try {
    const res = await findPostOptions();
    const rows = res.data ?? res.rows ?? [];
    targetPostOptions.value = Array.isArray(rows) ? rows : [];
  } catch {
    targetPostOptions.value = [];
  }
  const m = {};
  for (const p of targetPostOptions.value) {
    const id = p.postId ?? p.value ?? p.id;
    if (id != null && id !== "") m[String(id)] = p.postName ?? p.label ?? "";
  }
  postIdToName.value = m;
}
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) await loadUserPool();
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
}
function syncOriginalPostFromApplicant(uid) {
  const u = userById(uid);
  originalPostName.value = resolveOriginalPost(u).originalPostName;
}
watch(
  () => {
    const key = applicantTemplateField.value?.key;
    return key ? submitForm.formPayload[key] : undefined;
  },
  async (uid) => {
    if (!applicantTemplateField.value) return;
    if (!allUsersCache.value.length) await loadUserPool();
    syncOriginalPostFromApplicant(uid);
  }
);
watch(
  () => submitDialog.visible,
  async (v) => {
    if (!v) return;
    const key = applicantTemplateField.value?.key;
    if (key && submitForm.formPayload[key]) {
      syncOriginalPostFromApplicant(submitForm.formPayload[key]);
    }
  }
);
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions, {
  moduleKey: APPROVAL_MODULE_KEYS.TRANSFER,
});
function onSearch() {
  handleQuery(searchForm);
}
async function resetSearch() {
  searchForm.instanceNo = "";
  searchForm.applicantId = "";
  onSearch();
  await remoteSearchApplicant("");
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  await Promise.all([loadUserPool(), loadPostOptions()]);
  loadFlowUsers();
  await remoteSearchApplicant("");
  await initModuleList(searchForm);
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
</style>
src/views/officeProcessAutomation/HrManage/work-handover/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,249 @@
<!--OA模块:工作交接-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">审批单号:</span>
        <el-input
          v-model="searchForm.instanceNo"
          style="width: 220px"
          placeholder="请输入审批单号"
          clearable
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请人:</span>
        <el-select
          v-model="searchForm.applicantId"
          filterable
          remote
          clearable
          reserve-keyword
          placeholder="请选择或搜索申请人"
          style="width: 220px"
          :remote-method="remoteSearchApplicant"
          :loading="applicantSearchLoading"
        >
          <el-option
            v-for="u in applicantSearchOptions"
            :key="u.userId"
            :label="userSelectLabel(u)"
            :value="u.userId"
          />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openAddWithTemplate">新增工作交接</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      @submit="onSubmit"
    />
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="工作交接详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
const handoverStatusOptions = [
  { value: "in_progress", label: "进行中" },
  { value: "completed", label: "已完成" },
  { value: "returned", label: "已退回" },
];
const handoverTypeOptions = [
  { value: "resignation", label: "离职交接" },
  { value: "transfer", label: "调岗交接" },
];
const searchForm = reactive({
  instanceNo: "",
  applicantId: "",
});
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.WORK_HANDOVER,
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const allUsersCache = ref([]);
const applicantSearchOptions = ref([]);
const applicantSearchLoading = ref(false);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return list.slice(0, 50);
  return list
    .filter((u) => {
      const nick = (u.nickName || "").toLowerCase();
      const name = (u.userName || "").toLowerCase();
      const id = String(u.userId ?? u.id ?? "");
      return nick.includes(q) || name.includes(q) || id.includes(q);
    })
    .slice(0, 50);
}
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) await loadUserPool();
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
}
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions, {
  moduleKey: APPROVAL_MODULE_KEYS.WORK_HANDOVER,
});
function onSearch() {
  handleQuery(searchForm);
}
async function resetSearch() {
  searchForm.instanceNo = "";
  searchForm.applicantId = "";
  onSearch();
  await remoteSearchApplicant("");
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  await loadUserPool();
  loadFlowUsers();
  await remoteSearchApplicant("");
  await initModuleList(searchForm);
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
</style>
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  æ¨¡å—中文名:通知公告
  ç›®å½•标识:NoticeAnnouncement/notice-manage
  å¤ç”¨é¡µé¢ï¼š@/views/collaborativeApproval/noticeManagement/index.vue(协同审批-通知公告)
-->
<template>
  <NoticeManagement />
</template>
<script setup>
import NoticeManagement from "@/views/collaborativeApproval/noticeManagement/index.vue";
</script>
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,74 @@
<!-- è´¹ç”¨æŠ¥é”€ï¼šè¯¦æƒ…只读面板 -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="报销单号">{{ row.reimburseNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销状态">
      <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="费用类型">{{ expenseCategoryLabel(row.expenseCategory) }}</el-descriptions-item>
    <el-descriptions-item label="申请时间">{{ row.applyTime || row.createTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="员工编号">{{ row.employeeNo || row.applicantNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="员工姓名">{{ row.employeeName || row.applicantName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销原因" :span="2">{{ row.reimburseReason || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销金额">{{ row.applyAmount != null ? `${row.applyAmount} å…ƒ` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="收款人">{{ row.payee || "—" }}</el-descriptions-item>
    <el-descriptions-item label="收款账号">{{ row.payeeAccount || "—" }}</el-descriptions-item>
    <el-descriptions-item label="开户支行">{{ row.bankBranch || "—" }}</el-descriptions-item>
    <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
      <span class="reject-text">{{ row.rejectReason }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="创建时间" :span="2">{{ row.createTime || "—" }}</el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">报销明细</el-divider>
  <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small">
    <el-table-column type="index" label="序号" width="55" align="center" />
    <el-table-column prop="invoiceDate" label="发票日期" width="120" />
    <el-table-column label="费用科目" width="100">
      <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template>
    </el-table-column>
    <el-table-column prop="amount" label="金额" width="100" />
    <el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
  </el-table>
  <el-empty v-else description="暂无明细" :image-size="48" />
  <el-divider content-position="left">发票附件</el-divider>
  <template v-if="attachmentFiles.length">
    <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)">
      {{ f.name }}
    </el-tag>
  </template>
  <el-empty v-else description="暂无附件" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import { expenseCategoryLabel, expenseSubjectLabel, statusLabel, statusTagType } from "../costReimburseUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const attachmentFiles = computed(() => {
  const list =
    props.row?.attachmentList ||
    props.row?.storageBlobVOList ||
    props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
function openFile(f) {
  const url = f?.url || f?.downloadURL || f?.previewURL;
  if (url) window.open(url, "_blank");
}
</script>
<style scoped>
.reject-text {
  color: var(--el-color-danger);
}
.file-tag {
  margin: 0 8px 8px 0;
  cursor: pointer;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,313 @@
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 const APPROVAL_ROLE_LABELS = {
  direct_supervisor: "直属上级",
  dept_manager: "部门经理",
  cfo: "财务总监",
  compliance: "合规审核",
};
/** æŒ‰é‡‘额预设审批链 */
export const APPROVAL_AMOUNT_RULES = [
  {
    maxAmount: 500,
    description: "500元以内:直属上级审批",
    roles: ["direct_supervisor"],
  },
  {
    maxAmount: 5000,
    description: "500~5000元:直属上级 + éƒ¨é—¨ç»ç†",
    roles: ["direct_supervisor", "dept_manager"],
  },
  {
    maxAmount: Infinity,
    description: "超5000元:直属上级 + éƒ¨é—¨ç»ç† + è´¢åŠ¡æ€»ç›‘å¤æ ¸",
    roles: ["direct_supervisor", "dept_manager", "cfo"],
  },
];
/** éƒ¨åˆ†å“ç±»é¢å¤–审批节点 */
export const CATEGORY_EXTRA_APPROVAL = {
  business_entertainment: ["compliance"],
  office_procurement: [],
};
export function expenseCategoryLabel(v) {
  return EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function statusLabel(v) {
  if (v === "draft") return "草稿";
  if (v === "approved") return "已通过";
  if (v === "paid") return "已付款";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "draft") return "info";
  if (v === "approved" || v === "paid") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "warning";
}
export { formatApprovalFlowSummary } from "../shared/finReimbursementMappers.js";
export function resolveApprovalRoles(amount, expenseCategory) {
  const amt = Number(amount) || 0;
  let roles = [];
  for (const rule of APPROVAL_AMOUNT_RULES) {
    if (amt <= rule.maxAmount) {
      roles = [...rule.roles];
      break;
    }
  }
  if (!roles.length) roles = ["direct_supervisor"];
  const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || [];
  extra.forEach((r) => {
    if (!roles.includes(r)) roles.push(r);
  });
  return roles;
}
export function buildAutoApprovalFlow(amount, expenseCategory, previousNodes = []) {
  const roles = resolveApprovalRoles(amount, expenseCategory);
  const prevByRole = new Map();
  (previousNodes || []).forEach((n, idx) => {
    if (n?.roleKey) prevByRole.set(n.roleKey, n);
    else if (n?.approverId != null && n.approverId !== "") {
      prevByRole.set(`__idx_${idx}`, n);
    }
  });
  return roles.map((role, i) => {
    const prev = prevByRole.get(role) || prevByRole.get(`__idx_${i}`);
    const hasApprover = prev?.approverId != null && prev.approverId !== "";
    return {
      approverId: hasApprover ? prev.approverId : null,
      approverName: hasApprover
        ? prev.approverName || ""
        : APPROVAL_ROLE_LABELS[role] || role,
      roleKey: role,
      signMode: prev?.signMode || "countersign",
      sortOrder: i + 1,
      nodeOrder: i + 1,
      nodeStatus: i === 0 ? "process" : "wait",
      approveOpinion: "",
      approveTime: "",
    };
  });
}
export function getApprovalRuleHint(amount, expenseCategory) {
  const amt = Number(amount) || 0;
  const rule = APPROVAL_AMOUNT_RULES.find((r) => amt <= r.maxAmount) || APPROVAL_AMOUNT_RULES[APPROVAL_AMOUNT_RULES.length - 1];
  const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || [];
  const extraText = extra.length
    ? `;${expenseCategoryLabel(expenseCategory)}类另需:${extra.map((r) => APPROVAL_ROLE_LABELS[r] || r).join("、")}`
    : "";
  return `${rule.description}${extraText}`;
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: undefined,
    description: "",
  };
}
export function createEmptyForm() {
  return {
    id: undefined,
    reimburseNo: "",
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    expenseCategory: "",
    reimburseReason: "",
    applyAmount: undefined,
    payee: "",
    payeeAccount: "",
    bankBranch: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    approvalResult: "pending",
    rejectReason: "",
    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"),
  }));
}
export function initApprovalFlowNodes(nodes) {
  return (nodes || []).map((n, i) => ({
    ...n,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: n.approveOpinion || "",
    approveTime: n.approveTime || "",
  }));
}
export function advanceApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  nodes[idx] = {
    ...nodes[idx],
    nodeStatus: "finish",
    approveOpinion: opinion || "同意",
    approveTime: now,
  };
  const next = idx + 1;
  if (next >= nodes.length) {
    return { nodes, currentNodeIndex: idx, approvalResult: "approved", rejectReason: "" };
  }
  nodes[next] = { ...nodes[next], nodeStatus: "process" };
  return { nodes, currentNodeIndex: next, approvalResult: "pending", rejectReason: "" };
}
export function rejectApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  const reason = (opinion || "").trim() || "驳回";
  if (nodes[idx]) {
    nodes[idx] = {
      ...nodes[idx],
      nodeStatus: "error",
      approveOpinion: reason,
      approveTime: now,
    };
  }
  return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: reason };
}
export function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const expenseDetails = Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [];
  const applyAmount = raw.applyAmount ?? expenseDetails.reduce((s, d) => s + (Number(d.amount) || 0), 0);
  const expenseCategory = raw.expenseCategory || "other";
  const approvalFlowNodes =
    Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
      ? raw.approvalFlowNodes
      : buildAutoApprovalFlow(applyAmount, expenseCategory);
  return {
    id,
    reimburseNo: raw.reimburseNo || `CR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
    employeeName: raw.employeeName ?? raw.applicantName ?? "未知",
    applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
    applicantName: raw.employeeName ?? raw.applicantName ?? "未知",
    expenseCategory,
    reimburseReason: raw.reimburseReason ?? "",
    applyAmount,
    payee: raw.payee ?? "",
    payeeAccount: raw.payeeAccount ?? "",
    bankBranch: raw.bankBranch ?? "",
    expenseDetails,
    attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [],
    invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
    approvalFlowNodes,
    currentNodeIndex: raw.currentNodeIndex ?? 0,
    approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
    rejectReason: raw.rejectReason ?? "",
    approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
    applyTime: raw.applyTime || raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
    deptId: raw.deptId ?? "",
    deptName: raw.deptName ?? "",
  };
}
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,550 @@
<!--OA模块:费用报销(列表 /finReimbursement/listPage,reimbursementType=2)-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增费用报销</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1120px"
      append-to-body
      destroy-on-close
      class="cost-reimburse-form-dialog"
      @closed="onFormClosed"
    >
      <el-alert type="info" show-icon :closable="false" class="mb16">
        <template #title>全品类费用报销 Â· åˆ†ç±»æ¨¡æ¿ä¸€é”®å¡«æŠ¥</template>
        <template #default>
          æ”¯æŒå·®æ—…、办公采购、业务招待、交通费、通讯费等;按金额自动匹配审批链(500元内直属上级,超5000元财务总监复核)。
        </template>
      </el-alert>
      <div v-if="!formDialog.readonly" class="template-bar mb16">
        <span class="template-label">分类模板:</span>
        <el-button
          v-for="(tpl, key) in CATEGORY_TEMPLATES"
          :key="key"
          size="small"
          :type="form.expenseCategory === key ? 'primary' : 'default'"
          plain
          @click="applyTemplate(key)"
        >
          {{ tpl.label }}
        </el-button>
      </div>
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="120px"
        class="cost-reimburse-form"
        :disabled="formDialog.readonly"
      >
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">基本信息</span></template>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="员工编号">
                <el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="员工姓名" prop="applicantId">
                <el-select
                  v-model="form.applicantId"
                  filterable
                  remote
                  clearable
                  reserve-keyword
                  placeholder="请选择或搜索员工"
                  style="width: 100%"
                  :remote-method="remoteSearchApplicantForm"
                  :loading="applicantFormSearchLoading"
                  @change="onApplicantChange"
                >
                  <el-option
                    v-for="u in applicantFormOptions"
                    :key="u.userId"
                    :label="userSelectLabel(u)"
                    :value="u.userId"
                  />
                </el-select>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="费用类型" prop="expenseCategory">
                <el-select
                  v-model="form.expenseCategory"
                  placeholder="请选择费用类型"
                  style="width: 100%"
                  @change="onExpenseCategoryChange"
                >
                  <el-option
                    v-for="opt in EXPENSE_CATEGORY_OPTIONS"
                    :key="opt.value"
                    :label="opt.label"
                    :value="opt.value"
                  />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="报销状态">
                <el-tag
                  :type="form.approvalResult === 'approved' ? 'success' : form.approvalResult === 'rejected' ? 'danger' : 'warning'"
                  effect="plain"
                >
                  {{
                    form.approvalResult === "approved"
                      ? "已通过"
                      : form.approvalResult === "rejected"
                        ? "已驳回"
                        : "审核中"
                  }}
                </el-tag>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="24">
              <el-form-item label="报销原因" prop="reimburseReason">
                <el-input
                  v-model="form.reimburseReason"
                  type="textarea"
                  :rows="3"
                  placeholder="请填写报销原因"
                  maxlength="2000"
                  show-word-limit
                />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="报销金额" prop="applyAmount">
                <div class="amount-row">
                  <el-input-number
                    v-model="form.applyAmount"
                    :min="0"
                    :precision="2"
                    controls-position="right"
                    class="amount-input"
                    @change="autoAssignApprovalFlow"
                  />
                  <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
                    æŒ‰æ˜Žç»†æ±‡æ€» {{ detailTotalAmount }} å…ƒ
                  </el-button>
                </div>
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">报销明细</span>
              <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">
                æ–°å¢žæ˜Žç»†
              </el-button>
            </div>
          </template>
          <el-table :data="form.expenseDetails" border size="small" class="detail-table">
            <el-table-column type="index" label="序号" width="55" align="center" />
            <el-table-column label="发票日期" width="150">
              <template #default="{ row }">
                <el-date-picker
                  v-if="!formDialog.readonly"
                  v-model="row.invoiceDate"
                  type="date"
                  value-format="YYYY-MM-DD"
                  size="small"
                  style="width: 100%"
                />
                <span v-else>{{ row.invoiceDate || "—" }}</span>
              </template>
            </el-table-column>
            <el-table-column label="费用科目" width="130">
              <template #default="{ row }">
                <el-select v-if="!formDialog.readonly" v-model="row.expenseSubject" size="small" style="width: 100%">
                  <el-option
                    v-for="opt in EXPENSE_SUBJECT_OPTIONS"
                    :key="opt.value"
                    :label="opt.label"
                    :value="opt.value"
                  />
                </el-select>
                <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="金额" width="120">
              <template #default="{ row }">
                <el-input-number
                  v-if="!formDialog.readonly"
                  v-model="row.amount"
                  :min="0"
                  :precision="2"
                  size="small"
                  controls-position="right"
                  style="width: 100%"
                  @change="onDetailAmountChange"
                />
                <span v-else>{{ row.amount ?? "—" }}</span>
              </template>
            </el-table-column>
            <el-table-column label="描述" min-width="140">
              <template #default="{ row }">
                <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
                <span v-else>{{ row.description || "—" }}</span>
              </template>
            </el-table-column>
            <el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
              <template #default="{ $index }">
                <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">收款信息</span></template>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="收款人" prop="payee">
                <el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="收款账号" prop="payeeAccount">
                <el-input v-model="form.payeeAccount" placeholder="银行卡号" maxlength="30" />
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="开户支行" prop="bankBranch">
                <el-input v-model="form.bankBranch" placeholder="开户支行全称" maxlength="100" />
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">附件(发票)</span></template>
          <el-form-item label-width="0" class="attachment-form-item">
            <div class="upload-block">
              <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
            </div>
          </el-form-item>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">审批流程</span>
              <el-button v-if="!formDialog.readonly" type="primary" link size="small" @click="autoAssignApprovalFlow">
                æŒ‰è§„则重新分配
              </el-button>
            </div>
          </template>
          <el-alert type="success" :title="approvalRuleHint" show-icon :closable="false" class="mb12" />
          <el-form-item prop="approvalFlowNodes" label-width="0">
            <ApprovalFlowEditor
              v-if="!formDialog.readonly"
              v-model="form.approvalFlowNodes"
              :user-options="flowUserOptions"
              @update:model-value="onApprovalFlowChange"
            />
            <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
            <p v-if="!formDialog.readonly" class="flow-tip">系统已按金额与费用类型自动分配审批人,可手动调整。</p>
          </el-form-item>
        </el-card>
      </el-form>
      <template #footer>
        <el-button
          v-if="!formDialog.readonly"
          type="primary"
          :loading="submitSaving"
          @click="submitForm"
        >
          æ äº¤
        </el-button>
        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 é—­" : "取 æ¶ˆ" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="费用报销详情" width="900px" append-to-body destroy-on-close>
      <div v-loading="detailLoading">
      <DetailPanel :row="detailRow" />
      <el-divider content-position="left">审批流程</el-divider>
      <ApprovalFlowProgress
        :nodes="detailRow.approvalFlowProgressNodes ?? detailRow.approvalFlowNodes"
        :current-index="detailRow.currentNodeIndex ?? 0"
      />
      <el-divider content-position="left">审批记录</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      </div>
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="费用报销审批"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <DetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowProgressNodes ?? approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见" required>
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写具体原因(如:发票模糊需重传)"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="submitApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="submitApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "../travel-reimburse/components/ApprovalFlowProgress.vue";
import DetailPanel from "./components/DetailPanel.vue";
import { useCostReimburse } from "./useCostReimburse.js";
const cr = useCostReimburse();
const {
  Search,
  EXPENSE_CATEGORY_OPTIONS,
  CATEGORY_TEMPLATES,
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  importInputRef,
  formRef,
  form,
  formDialog,
  formRules,
  detailDialog,
  detailLoading,
  detailRow,
  approveDialog,
  approveOpinion,
  applicantFormSearchLoading,
  applicantFormOptions,
  flowUserOptions,
  detailTotalAmount,
  approvalRuleHint,
  handleQuery,
  resetSearch,
  pagination,
  remoteSearchApplicantForm,
  userSelectLabel,
  onApplicantChange,
  onExpenseCategoryChange,
  applyTemplate,
  onDetailAmountChange,
  onApprovalFlowChange,
  addExpenseDetail,
  removeExpenseDetail,
  syncApplyAmountFromDetails,
  autoAssignApprovalFlow,
  openFormDialog,
  onFormClosed,
  submitForm,
  submitSaving,
  approvalActionLabel,
  submitApprove,
  handleExport,
  handleImportClick,
  onImportFile,
} = cr;
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.mb12 {
  margin-bottom: 12px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.template-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}
.template-label {
  font-size: 14px;
  color: var(--el-text-color-secondary);
  flex-shrink: 0;
}
.form-section {
  margin-bottom: 16px;
  border: 1px solid var(--el-border-color-lighter);
}
.form-section :deep(.el-card__header) {
  padding: 12px 16px;
  background: var(--el-fill-color-lighter);
}
.form-section :deep(.el-card__body) {
  padding: 16px 16px 4px;
}
.card-header-title {
  font-size: 15px;
  font-weight: 600;
}
.card-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.amount-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
}
.amount-input {
  flex: 1;
  min-width: 160px;
}
.attachment-form-item {
  margin-bottom: 0;
}
.detail-table {
  margin-bottom: 0;
}
.upload-block {
  width: 100%;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.cost-reimburse-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.cost-reimburse-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.cost-reimburse-form :deep(.el-input-number) {
  width: 100%;
}
.cost-reimburse-form :deep(.el-row) {
  margin-bottom: 0;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,634 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import {
  deleteFinReimbursement,
  getFinReimbursementDetail,
  listFinReimbursementPage,
  persistFinReimbursement,
} from "@/api/officeProcessAutomation/finReimbursement.js";
import { ElMessageBox } from "element-plus";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
import {
  buildCostReimbursementSaveDto,
  buildFinReimbursementListParams,
  filterReimbursementRowsBySearch,
  hasActiveReimbursementSearch,
  canDeleteReimbursementRow,
  canEditReimbursementRow,
  enrichReimbursementListRowsWithApprovalFlow,
  filterRowsByReimbursementType,
  FIN_REIMBURSEMENT_TYPE,
  mapCostReimbursementRow,
  mapFinReimbursementDetailRow,
  resolveReimbursementDeleteId,
  unwrapFinReimbursementDetail,
  unwrapFinReimbursementPage,
  validateReimbursementApprovalNodes,
  validateReimbursementPersistDto,
} from "../shared/finReimbursementMappers.js";
import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
import {
  EXPENSE_CATEGORY_OPTIONS,
  CATEGORY_TEMPLATES,
  EXPENSE_SUBJECT_OPTIONS,
  expenseCategoryLabel,
  expenseSubjectLabel,
  statusLabel,
  statusTagType,
  formatApprovalFlowSummary,
  buildAutoApprovalFlow,
  getApprovalRuleHint,
  createEmptyExpenseDetail,
  createEmptyForm,
  applyCategoryTemplate,
  initApprovalFlowNodes,
  advanceApprovalFlow,
  rejectApprovalFlow,
  normalizeImportedRow,
} from "./costReimburseUtils.js";
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function demoFlowNodes(amount = 1200, category = "transport") {
  return buildAutoApprovalFlow(amount, category);
}
export function useCostReimburse() {
  const { proxy } = getCurrentInstance();
  const allRows = ref([]);
  const searchForm = reactive({
    applicantKeyword: "",
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const importInputRef = ref(null);
  const allUsersCache = ref([]);
  const applicantFormSearchLoading = ref(false);
  const applicantFormOptions = ref([]);
  const formRef = ref();
  const form = reactive(createEmptyForm());
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const detailDialog = reactive({ visible: false });
  const detailLoading = ref(false);
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const submitSaving = ref(false);
  const tableData = computed(() =>
    allRows.value.map((r) => ({
      ...r,
      approvalFlowSummary: formatApprovalFlowSummary(r),
    }))
  );
  async function fetchList() {
    tableLoading.value = true;
    try {
      const res = await listFinReimbursementPage(
        buildFinReimbursementListParams({
          page,
          searchForm,
          reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
        })
      );
      const { records, total } = unwrapFinReimbursementPage(res);
      const filtered = filterRowsByReimbursementType(
        records,
        FIN_REIMBURSEMENT_TYPE.COST
      );
      let mapped = filtered.map(mapCostReimbursementRow);
      mapped = await enrichReimbursementListRowsWithApprovalFlow(
        mapped,
        FIN_REIMBURSEMENT_TYPE.COST
      );
      if (hasActiveReimbursementSearch(searchForm)) {
        mapped = filterReimbursementRowsBySearch(mapped, searchForm);
      }
      allRows.value = mapped;
      const dropped = records.length - filtered.length;
      let nextTotal =
        dropped > 0 ? Math.max(0, Number(total) - dropped) : Number(total);
      if (hasActiveReimbursementSearch(searchForm)) {
        nextTotal = mapped.length;
      }
      page.total = nextTotal;
    } catch {
      allRows.value = [];
      page.total = 0;
      proxy?.$modal?.msgError?.("费用报销列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
  const detailTotalAmount = computed(() => {
    const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
    return Math.round(sum * 100) / 100;
  });
  const approvalRuleHint = computed(() =>
    getApprovalRuleHint(form.applyAmount ?? detailTotalAmount.value, form.expenseCategory)
  );
  const tableColumn = ref([
    { label: "报销单号", prop: "reimburseNo", width: 150 },
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人", prop: "applicantName", minWidth: 90 },
    { label: "报销金额(元)", prop: "applyAmount", width: 110 },
    { label: "报销原因", prop: "reimburseReason", minWidth: 160, showOverflowTooltip: true },
    { label: "申请时间", prop: "applyTime", width: 165 },
    { label: "创建时间", prop: "createTime", width: 165 },
    {
      label: "报销状态",
      prop: "approvalResult",
      width: 100,
      dataType: "tag",
      formatData: (v) => statusLabel(v),
      formatType: (v) => statusTagType(v),
    },
    {
      label: "审批流程",
      prop: "approvalFlowSummary",
      minWidth: 200,
      showOverflowTooltip: true,
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: [
        {
          name: "编辑",
          type: "text",
          disabled: (row) => !canEditReimbursementRow(row),
          clickFun: (row) => openFormDialog("edit", row),
        },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "删除",
          type: "danger",
          disabled: (row) => !canDeleteReimbursementRow(row),
          clickFun: (row) => confirmRemoveRow(row),
        },
      ],
    },
  ]);
  const formRules = {
    applicantId: [{ required: true, message: "请选择员工", trigger: "change" }],
    expenseCategory: [{ required: true, message: "请选择费用类型", trigger: "change" }],
    reimburseReason: [{ required: true, message: "请填写报销原因", trigger: "blur" }],
    applyAmount: [{ required: true, message: "请填写报销金额", trigger: "blur" }],
    payee: [{ required: true, message: "请填写收款人", trigger: "blur" }],
    payeeAccount: [{ required: true, message: "请填写收款账号", trigger: "blur" }],
    bankBranch: [{ required: true, message: "请填写开户支行", trigger: "blur" }],
    approvalFlowNodes: [
      {
        validator: (_r, _v, cb) => {
          const nodes = form.approvalFlowNodes || [];
          if (!nodes.length) {
            cb(new Error("请至少配置一个审批节点"));
            return;
          }
          if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
            cb(new Error("每个节点须选择审批人"));
            return;
          }
          cb();
        },
        trigger: "change",
      },
    ],
  };
  async function loadUserPool() {
    try {
      allUsersCache.value = unwrapArray(await userListNoPageByTenantId());
    } catch {
      allUsersCache.value = [];
    }
  }
  function userSelectLabel(u) {
    const nick = u.nickName || "";
    const name = u.userName || "";
    if (nick && name && nick !== name) return `${nick}(${name})`;
    return nick || name || `用户${u.userId ?? u.id ?? ""}`;
  }
  function userById(id) {
    return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
  }
  function employeeNoFromUser(u) {
    if (!u) return "";
    return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : "");
  }
  function filterUsersByQuery(query) {
    const list = allUsersCache.value.filter(isActiveUser);
    const q = (query || "").trim().toLowerCase();
    if (!q) return [...list];
    return list.filter((u) => {
      const nick = (u.nickName || "").toLowerCase();
      const uname = (u.userName || "").toLowerCase();
      return nick.includes(q) || uname.includes(q);
    });
  }
  async function remoteSearchApplicantForm(query) {
    applicantFormSearchLoading.value = true;
    try {
      if (!allUsersCache.value.length) await loadUserPool();
      applicantFormOptions.value = filterUsersByQuery(query);
    } finally {
      applicantFormSearchLoading.value = false;
    }
  }
  function onApplicantChange(uid) {
    const u = userById(uid);
    if (u) {
      form.employeeName = u.nickName || u.userName || "";
      form.employeeNo = employeeNoFromUser(u);
      form.payee = form.payee || form.employeeName;
      form.deptId = String(u.deptId ?? u.sysDeptId ?? "");
      form.deptName = u.dept?.deptName ?? u.deptName ?? "";
    } else {
      form.employeeName = "";
      form.employeeNo = "";
    }
  }
  function autoAssignApprovalFlow() {
    const amount = Number(form.applyAmount) || detailTotalAmount.value || 0;
    form.approvalFlowNodes = buildAutoApprovalFlow(
      amount,
      form.expenseCategory || "other",
      form.approvalFlowNodes
    );
    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
  }
  function onExpenseCategoryChange(val) {
    if (val && !(form.expenseDetails || []).length) {
      applyCategoryTemplate(form, val);
      syncApplyAmountFromDetails();
    }
    autoAssignApprovalFlow();
  }
  function applyTemplate(category) {
    applyCategoryTemplate(form, category);
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
    proxy?.$modal?.msgSuccess?.(`已应用「${CATEGORY_TEMPLATES[category]?.label || category}」填报模板`);
  }
  function onDetailAmountChange() {
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
  }
  function onApprovalFlowChange() {
    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
  }
  function addExpenseDetail() {
    form.expenseDetails.push(createEmptyExpenseDetail());
  }
  function removeExpenseDetail(index) {
    form.expenseDetails.splice(index, 1);
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
  }
  function syncApplyAmountFromDetails() {
    form.applyAmount = detailTotalAmount.value;
  }
  function handleQuery() {
    page.current = 1;
    return fetchList();
  }
  function resetSearch() {
    searchForm.applicantKeyword = "";
    handleQuery();
  }
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
    return fetchList();
  }
  async function loadCostDetailRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      throw new Error("missing id");
    }
    const res = await getFinReimbursementDetail(id);
    const raw = unwrapFinReimbursementDetail(res);
    return mapFinReimbursementDetailRow(raw, FIN_REIMBURSEMENT_TYPE.COST);
  }
  async function openDetail(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法查看详情:缺少报销单 ID");
      return;
    }
    detailDialog.visible = true;
    detailLoading.value = true;
    detailRow.value = { ...row };
    try {
      detailRow.value = await loadCostDetailRow(row);
    } catch {
      proxy?.$modal?.msgError?.("加载详情失败");
      detailDialog.visible = false;
    } finally {
      detailLoading.value = false;
    }
  }
  async function confirmRemoveRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法删除:缺少报销单 ID");
      return;
    }
    const title = row.reimburseNo || row.billNo || row.reimburseReason || "该报销单";
    try {
      await ElMessageBox.confirm(
        `确定要删除「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteFinReimbursement([id]);
      proxy?.$modal?.msgSuccess?.("删除成功");
      if (detailDialog.visible && resolveReimbursementDeleteId(detailRow.value) === id) {
        detailDialog.visible = false;
      }
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.("删除失败");
    }
  }
  function openApprove(row) {
    approveDialog.row = { ...row };
    approveDialog.visible = true;
  }
  function approvalActionLabel(v) {
    if (v === "approved") return "通过";
    if (v === "rejected") return "驳回";
    return "提交";
  }
  async function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.readonly = false;
    formDialog.title = mode === "add" ? "新增费用报销" : "编辑费用报销";
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      let editRow = row;
      try {
        editRow = await loadCostDetailRow(row);
      } catch {
        proxy?.$modal?.msgError?.("加载报销详情失败");
        return;
      }
      Object.assign(form, {
        ...JSON.parse(JSON.stringify(editRow)),
        reimbursementId: editRow.reimbursementId ?? editRow.id,
        attachmentList: JSON.parse(JSON.stringify(editRow.attachmentList || editRow.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(editRow.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(editRow.expenseDetails || [])),
      });
      const u = userById(editRow.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
    } else {
      form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
      remoteSearchApplicantForm("");
    }
    formDialog.visible = true;
    nextTick(() => {
      formRef.value?.clearValidate?.();
    });
  }
  function onFormClosed() {
    formRef.value?.resetFields?.();
  }
  async function submitForm() {
    try {
      await formRef.value?.validate?.();
    } catch {
      return;
    }
    if (!(form.expenseDetails || []).length) {
      proxy?.$modal?.msgWarning?.("请至少添加一条报销明细");
      return;
    }
    syncApplyAmountFromDetails();
    if (submitSaving.value) return;
    const isEdit = formDialog.mode === "edit";
    const dto = buildCostReimbursementSaveDto(form);
    const check = validateReimbursementPersistDto(dto, isEdit);
    if (!check.ok) {
      proxy?.$modal?.msgWarning?.(check.message);
      return;
    }
    const nodeCheck = validateReimbursementApprovalNodes(dto);
    if (!nodeCheck.ok) {
      proxy?.$modal?.msgWarning?.(nodeCheck.message);
      return;
    }
    submitSaving.value = true;
    try {
      await persistFinReimbursement(dto, isEdit);
      proxy?.$modal?.msgSuccess?.(isEdit ? "保存成功" : "提交成功");
      formDialog.visible = false;
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.(isEdit ? "保存失败" : "提交失败");
    } finally {
      submitSaving.value = false;
    }
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row) return;
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      proxy?.$modal?.msgWarning?.("驳回须填写审批意见(如:发票模糊需重传)");
      return;
    }
    const idx = allRows.value.findIndex((r) => r.id === row.id);
    if (idx === -1) return;
    const cur = allRows.value[idx];
    const operatorName = "当前审批人";
    const record = {
      operatorName,
      result,
      opinion: approveOpinion.value || (result === "approved" ? "同意" : "驳回"),
      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    };
    const records = [...(cur.approvalRecords || []), record];
    let flowUpdate;
    if (result === "approved") {
      flowUpdate = advanceApprovalFlow(cur, approveOpinion.value);
    } else {
      flowUpdate = rejectApprovalFlow(cur, approveOpinion.value);
    }
    allRows.value[idx] = {
      ...cur,
      approvalFlowNodes: flowUpdate.nodes,
      currentNodeIndex: flowUpdate.currentNodeIndex,
      approvalResult: flowUpdate.approvalResult,
      rejectReason: flowUpdate.rejectReason ?? cur.rejectReason,
      approvalRecords: records,
    };
    proxy?.$modal?.msgSuccess?.(result === "approved" ? "已通过" : "已驳回");
    approveDialog.visible = false;
    handleQuery();
  }
  function handleExport() {
    const data = allRows.value;
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `费用报销导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
    a.click();
    URL.revokeObjectURL(url);
    proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡`);
  }
  function handleImportClick() {
    importInputRef.value?.click?.();
  }
  function onImportFile(e) {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const parsed = JSON.parse(String(reader.result || ""));
        const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
        if (!Array.isArray(arr) || !arr.length) {
          proxy?.$modal?.msgWarning?.("导入格式须为费用报销 JSON æ•°ç»„");
          return;
        }
        arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i)));
        proxy?.$modal?.msgSuccess?.(`成功导入 ${arr.length} æ¡`);
        handleQuery();
      } catch {
        proxy?.$modal?.msgError?.("解析失败");
      }
    };
    reader.readAsText(file, "utf-8");
  }
  onMounted(async () => {
    loadUserPool();
    await fetchList();
    const editPayload = consumeReimburseEditFromApprove();
    if (editPayload?.reimbursementId != null) {
      await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
    }
  });
  return {
    Search,
    EXPENSE_CATEGORY_OPTIONS,
    CATEGORY_TEMPLATES,
    EXPENSE_SUBJECT_OPTIONS,
    expenseCategoryLabel,
    expenseSubjectLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    importInputRef,
    formRef,
    form,
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
    applicantFormSearchLoading,
    applicantFormOptions,
    flowUserOptions,
    detailTotalAmount,
    approvalRuleHint,
    handleQuery,
    resetSearch,
    pagination,
    remoteSearchApplicantForm,
    userSelectLabel,
    onApplicantChange,
    onExpenseCategoryChange,
    applyTemplate,
    onDetailAmountChange,
    onApprovalFlowChange,
    addExpenseDetail,
    removeExpenseDetail,
    syncApplyAmountFromDetails,
    autoAssignApprovalFlow,
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    approvalActionLabel,
    submitApprove,
    handleExport,
    handleImportClick,
    onImportFile,
  };
}
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
<!-- å·®æ—…/费用报销:审批列表内详情/审批弹窗内容(与报销页弹窗一致) -->
<template>
  <div v-loading="loading">
    <TravelDetailPanel v-if="isTravel" :row="reimburseRow" />
    <CostDetailPanel v-else :row="reimburseRow" />
    <el-divider content-position="left">流程进度</el-divider>
    <ApprovalFlowProgress
      :nodes="reimburseRow.approvalFlowProgressNodes ?? reimburseRow.approvalFlowNodes"
      :current-index="reimburseRow.currentNodeIndex ?? 0"
    />
    <template v-if="mode === 'detail'">
      <el-divider content-position="left">审批记录(全流程留痕)</el-divider>
      <el-timeline v-if="reimburseRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in reimburseRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ actionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
    </template>
    <el-form v-else label-width="100px" class="mt16">
      <el-form-item label="审批意见">
        <el-input
          :model-value="approveOpinion"
          type="textarea"
          :rows="3"
          maxlength="500"
          show-word-limit
          :placeholder="isTravel ? '通过可留空;驳回请填写原因' : '通过可留空;驳回请填写具体原因(如:发票模糊需重传)'"
          @update:model-value="$emit('update:approveOpinion', $event)"
        />
      </el-form-item>
    </el-form>
  </div>
</template>
<script setup>
import { computed } from "vue";
import { isTravelReimbursementType } from "../finReimbursementMappers.js";
import ApprovalFlowProgress from "../../travel-reimburse/components/ApprovalFlowProgress.vue";
import CostDetailPanel from "../../cost-reimburse/components/DetailPanel.vue";
import TravelDetailPanel from "../../travel-reimburse/components/DetailPanel.vue";
const props = defineProps({
  mode: { type: String, default: "detail" },
  moduleKey: { type: String, default: "" },
  reimburseRow: { type: Object, default: () => ({}) },
  loading: { type: Boolean, default: false },
  approveOpinion: { type: String, default: "" },
});
defineEmits(["update:approveOpinion"]);
const isTravel = computed(() =>
  isTravelReimbursementType(props.reimburseRow?.reimbursementType ?? props.moduleKey)
);
function actionLabel(v) {
  if (v === "approved") return "通过";
  if (v === "rejected") return "驳回";
  return "提交";
}
</script>
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,160 @@
import { formatDisplayTime } from "../../ApproveManage/approve-template/approveTemplateConstants.js";
import {
  mapRecordResultFromApi,
  mapRecordsFromApi,
  mapTasksToFlowNodes,
} from "../../ApproveManage/approve-list/approveListConstants.js";
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.storageBlobVOS ||
    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: mapRecordResultFromApi(
        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,
      raw: t,
    }))
    .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));
    });
}
/** tasks â†’ ApprovalFlowProgress èŠ‚ç‚¹ */
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.nodeOrder ?? node.levelNo ?? i + 1,
      sortOrder: node.nodeOrder ?? node.levelNo ?? i + 1,
      approverName: names || "—",
      approveOpinion: opinions,
      approveTime: approvers.find(a => a.approveTime)?.approveTime || "",
      nodeStatus,
      signMode: node.signMode,
    };
  });
}
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;
}
/** è¯¦æƒ… DTO è¡¥å…… tasks / é™„ä»¶ / å®¡æ‰¹è®°å½• */
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)
    : mapRecordsFromApi(source.records || source.approvalRecords);
  /** è¡¨å•编辑回显:保留 nodes æ˜ å°„(含 approverId),勿用 tasks è¦†ç›– */
  const approvalFlowNodes = Array.isArray(mapped.approvalFlowNodes)
    ? mapped.approvalFlowNodes
    : [];
  /** è¯¦æƒ…/进度条展示:有 tasks æ—¶ç”¨ä»»åŠ¡çŠ¶æ€èŠ‚ç‚¹ */
  const approvalFlowProgressNodes = tasks.length
    ? mapTasksToApprovalFlowNodes(tasks)
    : approvalFlowNodes;
  const currentNodeIndex = computeApprovalFlowCurrentIndex(
    approvalFlowProgressNodes.length ? approvalFlowProgressNodes : approvalFlowNodes
  );
  const rejectReason =
    approvalRecords.find(r => r.result === "rejected")?.opinion ||
    source.rejectReason ||
    "";
  return {
    ...mapped,
    tasks,
    storageBlobVOList: attachments,
    attachmentList: attachments,
    invoiceAttachments: attachments,
    approvalRecords,
    records: tasks.length ? tasks : source.records,
    approvalFlowNodes,
    approvalFlowProgressNodes,
    currentNodeIndex,
    rejectReason,
    flowNodes: tasks.length ? mapTasksToFlowNodes(tasks) : mapped.flowNodes,
  };
}
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,904 @@
import dayjs from "dayjs";
import { getFinReimbursementDetail } from "@/api/officeProcessAutomation/finReimbursement.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { mapSignModeToApi } from "../../ApproveManage/approve-template/approveTemplateConstants.js";
import { EXPENSE_CATEGORY_OPTIONS } from "../cost-reimburse/costReimburseUtils.js";
import { EXPENSE_SUBJECT_OPTIONS } from "../travel-reimburse/travelReimburseUtils.js";
import { mapTasksToFlowNodes } from "../../ApproveManage/approve-list/approveListConstants.js";
import { applyFinReimbursementDetailEnrichment } from "./finReimbursementDetailExtras.js";
/** æŠ¥é”€ç±»åž‹ï¼š1-差旅报销,2-费用报销 */
export const FIN_REIMBURSEMENT_TYPE = {
  TRAVEL: "1",
  COST: "2",
};
const REIMBURSEMENT_TYPE_LABEL = {
  [FIN_REIMBURSEMENT_TYPE.TRAVEL]: "差旅报销",
  [FIN_REIMBURSEMENT_TYPE.COST]: "费用报销",
};
/** å½’一化报销类型:1-差旅,2-费用 */
export function normalizeReimbursementType(val) {
  const s = String(val ?? "").trim();
  if (s === "1" || s === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (s === "2" || s === FIN_REIMBURSEMENT_TYPE.COST) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function reimbursementTypeLabel(type) {
  return REIMBURSEMENT_TYPE_LABEL[normalizeReimbursementType(type)] || "—";
}
export function getModuleKeyByReimbursementType(type) {
  const t = normalizeReimbursementType(type);
  if (t === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE;
  }
  if (t === FIN_REIMBURSEMENT_TYPE.COST) {
    return APPROVAL_MODULE_KEYS.COST_REIMBURSE;
  }
  return "";
}
/** ä¼˜å…ˆæŽ¥å£ reimbursementType,其次页面 moduleKey / å…¥å‚ */
export function resolveReimbursementType(raw, fallback) {
  const fromApi = normalizeReimbursementType(raw?.reimbursementType);
  if (fromApi) return fromApi;
  return (
    normalizeReimbursementType(fallback) ||
    getReimbursementTypeByModuleKey(fallback) ||
    ""
  );
}
export function isTravelReimbursementType(type) {
  return (
    resolveReimbursementType({ reimbursementType: type }, type) ===
    FIN_REIMBURSEMENT_TYPE.TRAVEL
  );
}
export function filterRowsByReimbursementType(rows, expectedType) {
  const expected = normalizeReimbursementType(expectedType);
  if (!expected) return rows || [];
  return (rows || []).filter((row) => {
    const t = resolveReimbursementType(row, expected);
    return t === expected;
  });
}
export function getReimbursementTypeByModuleKey(moduleKey) {
  if (moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function unwrapFinReimbursementPage(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") {
    return { records: [], total: 0 };
  }
  if (Array.isArray(data.records)) {
    return { records: data.records, total: Number(data.total ?? 0) };
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && Array.isArray(nested.records)) {
    return { records: nested.records, total: Number(nested.total ?? 0) };
  }
  return { records: [], total: 0 };
}
/** è¯¦æƒ…接口 data è§£åŒ… */
export function unwrapFinReimbursementDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.billNo != null || data.id != null || data.reimbursementType != null) {
    return data;
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && !Array.isArray(nested)) {
    return nested;
  }
  if (data.finReimbursementDto && typeof data.finReimbursementDto === "object") {
    return data.finReimbursementDto;
  }
  return data;
}
/** è¯¦æƒ…查询参数(query finReimbursementDto) */
export function buildFinReimbursementDetailParams(id) {
  const raw = id?.id != null ? id.id : id;
  const n = toNumber(raw);
  return { finReimbursementDto: { id: n != null ? n : raw } };
}
/** è¯¦æƒ… DTO â†’ é¡µé¢è¡Œï¼ˆæŒ‰ reimbursementType æ˜ å°„,含 tasks / storageBlobVOList) */
export function mapFinReimbursementDetailRow(raw, reimbursementTypeOrModuleKey) {
  const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey);
  let mapped = {};
  if (type === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    mapped = mapTravelReimbursementRow(raw);
  } else if (type === FIN_REIMBURSEMENT_TYPE.COST) {
    mapped = mapCostReimbursementRow(raw);
  } else {
    mapped = raw || {};
  }
  let formApprovalFlowNodes = mapNodesToFormFlow(resolveRowApiNodes(raw));
  if (!formApprovalFlowNodes.length && Array.isArray(raw?.tasks) && raw.tasks.length) {
    formApprovalFlowNodes = mapNodesToFormFlow(mapTasksToFlowNodes(raw.tasks));
  }
  const enriched = applyFinReimbursementDetailEnrichment(mapped, raw);
  return {
    ...enriched,
    approvalFlowNodes: formApprovalFlowNodes.length
      ? formApprovalFlowNodes
      : enriched.approvalFlowNodes,
    reimbursementType: type,
    reimbursementTypeLabel: reimbursementTypeLabel(type),
    moduleKey: getModuleKeyByReimbursementType(type),
  };
}
/** å•据状态 â†’ é¡µé¢ approvalResult(兼容 statusLabel) */
export function mapBillStatusToApprovalResult(billStatus) {
  const upper = String(billStatus ?? "").trim().toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "IN_APPROVAL") return "pending";
  if (upper === "APPROVED") return "approved";
  if (upper === "REJECTED") return "rejected";
  if (upper === "WITHDRAWN") return "cancelled";
  if (upper === "PAID") return "paid";
  return "pending";
}
function pickApplicantQuery(searchForm = {}) {
  const kw = (searchForm.applicantKeyword || "").trim();
  if (!kw) return {};
  // å ä½ã€Œå§“名或编号」:姓名走 applicantName;编号另传 applicantCode
  const out = { applicantName: kw };
  if (!/[\u4e00-\u9fa5]/.test(kw)) {
    out.applicantCode = kw;
  }
  return out;
}
/** æ˜¯å¦å­˜åœ¨åˆ—表筛选条件(仅申请人) */
export function hasActiveReimbursementSearch(searchForm = {}) {
  return Boolean((searchForm?.applicantKeyword || "").trim());
}
/** æœåŠ¡ç«¯æœªç”Ÿæ•ˆæ—¶ï¼ŒæŒ‰ç”³è¯·äººåšå‰ç«¯å…œåº•ç­›é€‰ */
export function filterReimbursementRowsBySearch(rows, searchForm = {}) {
  const list = Array.isArray(rows) ? rows : [];
  const kw = (searchForm?.applicantKeyword || "").trim().toLowerCase();
  if (!kw) return list;
  return list.filter((row) => {
    const parts = [
      row.applicantName,
      row.employeeName,
      row.applicantNo,
      row.applicantCode,
      row.employeeNo,
    ]
      .filter((v) => v != null && v !== "")
      .map((v) => String(v).toLowerCase());
    return parts.some((p) => p.includes(kw));
  });
}
/** æ‰å¹³åŒ–为 Spring GET å¯ç»‘定的 query(finReimbursementDto.xxx,勿用方括号) */
function appendDotNotationQuery(target, prefix, fields) {
  if (!fields || typeof fields !== "object") return;
  for (const [key, value] of Object.entries(fields)) {
    if (value == null || value === "") continue;
    target[`${prefix}.${key}`] = value;
  }
}
/** ç»„装 listPage æŸ¥è¯¢å‚数(扁平 page.* / finReimbursementDto.*,与 detail æŽ¥å£ä¸€è‡´ï¼‰ */
export function buildFinReimbursementListParams({
  page,
  searchForm,
  reimbursementType,
  extraDto = {},
}) {
  const dto = {
    reimbursementType,
    ...pickApplicantQuery(searchForm),
    ...(extraDto && typeof extraDto === "object" ? extraDto : {}),
  };
  const params = {
    current: page.current,
    size: page.size,
    "page.current": page.current,
    "page.size": page.size,
    ...dto,
  };
  appendDotNotationQuery(params, "finReimbursementDto", dto);
  return params;
}
function pickTravelField(obj, keys) {
  if (!obj || typeof obj !== "object") return "";
  for (const key of keys) {
    const v = obj[key];
    if (v != null && v !== "") return v;
  }
  return "";
}
/** å…¼å®¹ list/detail å¤šç§å·®æ—…子对象结构 */
export function pickTravelFromRow(row) {
  if (!row || typeof row !== "object") return {};
  const nested =
    (row.travel && typeof row.travel === "object" ? row.travel : null) ||
    row.finReimbursementTravel ||
    row.finReimbursementTravelDto ||
    row.travelDto ||
    row.travelVO ||
    {};
  const src =
    nested && typeof nested === "object" && Object.keys(nested).length
      ? nested
      : row;
  return {
    startTime: pickTravelField(src, [
      "startTime",
      "travelStartTime",
      "startDate",
      "travelStartDate",
      "departureTime",
    ]),
    endTime: pickTravelField(src, [
      "endTime",
      "travelEndTime",
      "endDate",
      "travelEndDate",
      "returnTime",
    ]),
    travelDays: src.travelDays,
    departureCity: pickTravelField(src, [
      "departureCity",
      "departurePlace",
      "departure",
    ]),
    destinationCity: pickTravelField(src, [
      "destinationCity",
      "destination",
      "destinationPlace",
    ]),
    hotelStandard: src.hotelStandard,
    lodgingDays: src.lodgingDays ?? src.hotelDays,
    mealAllowance: src.mealAllowance ?? src.livingSubsidy,
    transportAllowance: src.transportAllowance ?? src.transportSubsidy,
    lodgingLimit: src.lodgingLimit,
    withinStandard: src.withinStandard,
    standardTag: src.standardTag || "",
    id: src.id,
    reimbursementId: src.reimbursementId,
  };
}
/** åˆ—表/详情时间展示(ISO â†’ YYYY-MM-DD HH:mm:ss) */
export function formatReimbursementDateTime(val) {
  if (val == null || val === "") return "";
  const d = dayjs(val);
  if (!d.isValid()) return String(val);
  const raw = String(val);
  const hasTime = raw.includes("T") || /:\d{2}/.test(raw);
  return hasTime ? d.format("YYYY-MM-DD HH:mm:ss") : d.format("YYYY-MM-DD");
}
/** æŽ¥å£è¡Œ â†’ å·®æ—…报销列表行(兼容 useTravelReimburse å­—段) */
export function mapTravelReimbursementRow(row) {
  if (!row) return {};
  const travel = pickTravelFromRow(row);
  const details = Array.isArray(row.details) ? row.details : [];
  const base = {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    travelStartTime: formatReimbursementDateTime(travel.startTime),
    travelEndTime: formatReimbursementDateTime(travel.endTime),
    travelDays: travel.travelDays,
    departurePlace: travel.departureCity || "",
    destination: travel.destinationCity || "",
    hotelStandard: travel.hotelStandard,
    hotelDays: travel.lodgingDays,
    livingSubsidy: travel.mealAllowance,
    transportSubsidy: travel.transportAllowance,
    lodgingLimit: travel.lodgingLimit,
    needSpecialApproval: travel.withinStandard === "0" || travel.withinStandard === 0,
    standardTag: travel.standardTag || "",
    applyAmount: row.applyAmount,
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    payeeBank: row.payeeBank || "",
    billStatus: row.billStatus,
    approvalResult: mapBillStatusToApprovalResult(row.billStatus),
    createTime: formatReimbursementDateTime(row.createTime),
    expenseDetails: details.map((d) => ({
      ...d,
      expenseSubject: d.expenseCategory,
    })),
    travel:
      row.travel && typeof row.travel === "object" && Object.keys(row.travel).length
        ? row.travel
        : travel,
    details,
    nodes: resolveRowApiNodes(row),
    approvalFlowNodes: mapNodesToFormFlow(resolveRowApiNodes(row)),
    tasks: row.tasks || [],
    approvalFlowSummary: buildApprovalFlowSummaryForRow(row),
  };
  return base;
}
/** æŽ¥å£è¡Œ â†’ è´¹ç”¨æŠ¥é”€åˆ—表行(兼容 useCostReimburse å­—段) */
export function mapCostReimbursementRow(row) {
  if (!row) return {};
  const details = Array.isArray(row.details) ? row.details : [];
  const apiNodes = resolveRowApiNodes(row);
  const approvalFlowNodes = mapNodesToFormFlow(apiNodes);
  return {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    expenseCategory: row.expenseType || "",
    applyAmount: row.applyAmount,
    applyTime: formatReimbursementDateTime(row.createTime),
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    bankBranch: row.payeeBank || "",
    billStatus: row.billStatus,
    approvalResult: mapBillStatusToApprovalResult(row.billStatus),
    createTime: formatReimbursementDateTime(row.createTime),
    expenseDetails: details.map((d) => ({
      ...d,
      expenseSubject: d.expenseCategory,
    })),
    details,
    nodes: apiNodes,
    approvalFlowNodes,
    tasks: row.tasks || [],
    approvalFlowSummary: buildApprovalFlowSummaryForRow({
      ...row,
      nodes: apiNodes,
      approvalFlowNodes,
    }),
  };
}
function toNumber(val) {
  if (val == null || val === "") return undefined;
  const n = Number(val);
  return Number.isNaN(n) ? undefined : n;
}
function expenseSubjectToCategory(subject) {
  const hit = EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === subject);
  return hit?.label || subject || "";
}
function expenseCategoryToType(category) {
  const hit = EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === category);
  return hit?.label || category || "";
}
/** åˆ—表/详情行上的审批节点(listPage å¸¸ä¸è¿”回,需详情补全) */
export function resolveRowApiNodes(row) {
  if (!row || typeof row !== "object") return [];
  const list =
    row.nodes ||
    row.flowNodes ||
    row.approveNodes ||
    row.finReimbursementNodes ||
    row.nodeList ||
    row.reimbursementNodeList ||
    [];
  return Array.isArray(list) ? list : [];
}
function sortFlowNodesByLevel(nodes = []) {
  return [...(Array.isArray(nodes) ? nodes : [])].sort((a, b) => {
    const la = Number(a?.levelNo ?? a?.nodeOrder ?? a?.sortOrder ?? 0);
    const lb = Number(b?.levelNo ?? b?.nodeOrder ?? b?.sortOrder ?? 0);
    return la - lb;
  });
}
function formatApiNodeApproverLabel(node, index) {
  if (!node || typeof node !== "object") return "";
  const approvers = Array.isArray(node.approvers) ? node.approvers : [];
  const names = approvers
    .map((a) => (a?.approverName || "").trim())
    .filter(Boolean);
  if (names.length) return names.join("/");
  return (node.approverName || "").trim() || `节点${index + 1}`;
}
/** æŽ¥å£ nodes â†’ é¡µé¢å®¡æ‰¹æµï¼ˆå•审批人节点) */
export function mapNodesToFormFlow(nodes = []) {
  return sortFlowNodesByLevel(nodes).map((n, i) => {
    const approvers = Array.isArray(n.approvers) ? n.approvers : [];
    const first = approvers[0] || null;
    const names = approvers
      .map((a) => (a?.approverName || "").trim())
      .filter(Boolean);
    return {
      ...n,
      nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1,
      signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign",
      approverId:
        toNumber(first?.approverId ?? n.approverId) ??
        first?.approverId ??
        n.approverId ??
        null,
      approverName:
        names.join("、") || first?.approverName || n.approverName || "",
      nodeStatus: n.nodeStatus,
    };
  });
}
function formatTasksToFlowSummary(tasks = []) {
  const list = sortFlowNodesByLevel(
    (Array.isArray(tasks) ? tasks : []).map((t, i) => ({
      levelNo: t.levelNo ?? t.taskLevel ?? i + 1,
      approverName:
        (t.approverName || t.operatorName || t.createUserName || "").trim() ||
        "",
    }))
  );
  const parts = list.map((t) => t.approverName).filter(Boolean);
  return parts.length ? parts.join(" â†’ ") : "";
}
function buildApprovalFlowSummaryForRow(row) {
  const apiNodes = sortFlowNodesByLevel(resolveRowApiNodes(row));
  let flowNodes =
    row?.approvalFlowNodes?.length > 0
      ? sortFlowNodesByLevel(row.approvalFlowNodes)
      : mapNodesToFormFlow(apiNodes);
  if (!flowNodes.length && apiNodes.length) {
    const line = apiNodes
      .map((n, i) => formatApiNodeApproverLabel(n, i))
      .filter(Boolean)
      .join(" â†’ ");
    if (line) return line;
  }
  if (!flowNodes.length) {
    const fromTasks = formatTasksToFlowSummary(row?.tasks);
    if (fromTasks) return fromTasks;
    return "—";
  }
  return flowNodes
    .map((n, i) => {
      const name = (n.approverName || "").trim() || `节点${i + 1}`;
      if (n.nodeStatus === "finish") return `${name}✓`;
      if (n.nodeStatus === "error") return `${name}✗`;
      if (n.nodeStatus === "process") return `${name}…`;
      return name;
    })
    .join(" â†’ ");
}
/** åˆ—表「审批流程」列文案 */
export function formatApprovalFlowSummary(row) {
  return buildApprovalFlowSummaryForRow(row);
}
/** listPage å¸¸ä¸å¸¦å®Œæ•´ nodes,列表加载后统一拉详情补全多级审批流程 */
export async function enrichReimbursementListRowsWithApprovalFlow(
  rows,
  reimbursementType
) {
  const list = Array.isArray(rows) ? rows : [];
  if (!list.length) return list;
  const needIds = list
    .map((r) => resolveReimbursementDeleteId(r))
    .filter((id) => id != null);
  if (!needIds.length) return list;
  const detailById = new Map();
  await Promise.all(
    needIds.map(async (id) => {
      try {
        const res = await getFinReimbursementDetail(id);
        detailById.set(String(id), unwrapFinReimbursementDetail(res));
      } catch {
        /* å•行失败不影响列表 */
      }
    })
  );
  const mapRow =
    reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL
      ? mapTravelReimbursementRow
      : mapCostReimbursementRow;
  return list.map((row) => {
    const id = resolveReimbursementDeleteId(row);
    const detail = id != null ? detailById.get(String(id)) : null;
    if (!detail) return row;
    const merged = {
      ...row,
      ...detail,
      id: row.id ?? detail.id,
      reimbursementId: row.reimbursementId ?? row.id ?? detail.id,
      reimbursementType: detail.reimbursementType ?? row.reimbursementType,
    };
    return mapRow(merged);
  });
}
/** è¡¨å•上的审批流(兼容 approvalFlowNodes / nodes / flowNodes) */
export function resolveFormApprovalFlowNodes(form) {
  const list =
    form?.approvalFlowNodes ?? form?.nodes ?? form?.flowNodes ?? [];
  return Array.isArray(list) ? list : [];
}
/** é¡µé¢å®¡æ‰¹èŠ‚ç‚¹ â†’ æŽ¥å£ nodes */
export function mapApprovalFlowNodesToApi(nodes = [], templateId) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list
    .map((n, i) => {
      let approvers = [];
      if (Array.isArray(n.approvers) && n.approvers.length) {
        approvers = n.approvers
          .filter((a) => a?.approverId != null && a.approverId !== "")
          .map((a, idx) => {
            const item = {
              approverId: toNumber(a.approverId) ?? a.approverId,
              approverName: a.approverName || "",
              sortNo: a.sortNo ?? idx + 1,
            };
            if (a.id != null) item.id = a.id;
            if (a.nodeId != null) item.nodeId = a.nodeId;
            if (a.templateId != null) item.templateId = a.templateId;
            else if (templateId != null) item.templateId = templateId;
            if (a.roleKey) item.roleKey = a.roleKey;
            return item;
          });
      } else if (n.approverId != null && n.approverId !== "") {
        const item = {
          approverId: toNumber(n.approverId) ?? n.approverId,
          approverName: n.approverName || "",
          sortNo: 1,
        };
        if (n.roleKey) item.roleKey = n.roleKey;
        approvers = [item];
      }
      if (!approvers.length) return null;
      const node = {
        levelNo: n.levelNo ?? n.nodeOrder ?? n.sortOrder ?? i + 1,
        approveType: n.approveType || mapSignModeToApi(n.signMode),
        approvers,
      };
      if (n.id != null) node.id = n.id;
      if (n.templateId != null) node.templateId = n.templateId;
      else if (templateId != null) node.templateId = templateId;
      if (n.roleKey) node.roleKey = n.roleKey;
      return node;
    })
    .filter(Boolean);
}
/** ä¿å­˜å‰æ ¡éªŒ nodes å·²é…ç½® */
export function validateReimbursementApprovalNodes(dto) {
  if (Array.isArray(dto?.nodes) && dto.nodes.length > 0) {
    return { ok: true };
  }
  return { ok: false, message: "请配置审批流程并选择审批人" };
}
function mapDetailsToApi(details = []) {
  return (details || []).map((d, i) => {
    const item = {
      rowNo: d.rowNo ?? i + 1,
      invoiceDate: d.invoiceDate || undefined,
      expenseCategory: expenseSubjectToCategory(d.expenseSubject ?? d.expenseCategory),
      amount: toNumber(d.amount),
      description: d.description || "",
      invoiceNo: d.invoiceNo || undefined,
      invoiceType: d.invoiceType || undefined,
      invoiceAmount: toNumber(d.invoiceAmount),
      taxRate: toNumber(d.taxRate),
      taxAmount: toNumber(d.taxAmount),
      remark: d.remark || undefined,
    };
    if (d.id != null && !String(d.id).startsWith("ed_")) {
      item.id = toNumber(d.id) ?? d.id;
    }
    if (d.reimbursementId != null) item.reimbursementId = toNumber(d.reimbursementId);
    return item;
  });
}
function sumDetailAmount(details = []) {
  const sum = (details || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
  return Math.round(sum * 100) / 100;
}
/** è¡¨å•附件列表(兼容多种字段名) */
export function resolveFormAttachmentList(form) {
  const list =
    form?.attachmentList ??
    form?.storageBlobDTOs ??
    form?.storageBlobVOList ??
    form?.invoiceAttachments ??
    [];
  return Array.isArray(list) ? list : [];
}
/** é¡µé¢é™„ä»¶ â†’ ä¿å­˜ DTO(storageBlobVOList / storageBlobDTOs) */
export function mapFormAttachmentsToApi(list = [], reimbursementId) {
  const rid =
    reimbursementId != null
      ? toNumber(reimbursementId) ?? reimbursementId
      : undefined;
  return (list || [])
    .map((item, i) => {
      if (!item) return null;
      const url =
        item.url ||
        item.fileUrl ||
        item.downloadUrl ||
        item.downloadURL ||
        item.previewUrl ||
        item.previewURL ||
        item.link ||
        "";
      const name =
        item.fileName ||
        item.originalFilename ||
        item.originalFileName ||
        item.blobName ||
        item.name ||
        `附件${i + 1}`;
      const idRaw = item.id ?? item.blobId;
      const isTempId =
        idRaw != null &&
        /^(inv_|att_|ed_|local_)/.test(String(idRaw));
      if (!url && (idRaw == null || isTempId)) return null;
      const blob = {
        fileName: name,
        originalFilename: name,
        fileUrl: url || undefined,
        url: url || undefined,
      };
      if (idRaw != null && !isTempId) {
        const n = toNumber(idRaw);
        blob.id = n != null ? n : idRaw;
        blob.blobId = blob.id;
      }
      if (rid != null) blob.reimbursementId = rid;
      return blob;
    })
    .filter(Boolean);
}
function applyStorageBlobsToSaveDto(dto, form) {
  const blobs = mapFormAttachmentsToApi(
    resolveFormAttachmentList(form),
    dto?.id ?? form?.reimbursementId ?? form?.id
  );
  if (blobs.length) {
    dto.storageBlobVOList = blobs;
    dto.storageBlobDTOs = blobs;
  }
  return dto;
}
/** ä¿®æ”¹æ—¶è¡¥é½ä¸»è¡¨ä¸Žå­è¡¨å…³è” ID */
function applyReimbursementRelations(dto) {
  const rid = dto?.id;
  if (rid == null) return dto;
  if (dto.travel && typeof dto.travel === "object") {
    dto.travel.reimbursementId = rid;
  }
  if (Array.isArray(dto.details)) {
    dto.details.forEach((d) => {
      d.reimbursementId = rid;
    });
  }
  const blobLists = [dto.storageBlobVOList, dto.storageBlobDTOs].filter(Array.isArray);
  blobLists.forEach((list) => {
    list.forEach((b) => {
      b.reimbursementId = rid;
    });
  });
  return dto;
}
function resolveReimbursementId(form) {
  const rawId = form?.reimbursementId ?? form?.id;
  if (rawId == null || rawId === "" || String(rawId).startsWith("local_")) {
    return undefined;
  }
  return toNumber(rawId) ?? rawId;
}
/** å·®æ—…表单 â†’ FinReimbursementDto */
export function buildTravelReimbursementSaveDto(form, { computeTravelDays } = {}) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const travelDays =
    form.travelDays != null
      ? toNumber(form.travelDays)
      : computeTravelDays?.(form.travelStartTime, form.travelEndTime);
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
    expenseType: "差旅费",
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || undefined,
    payeeBank: form.payeeBank || undefined,
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    travel: {
      startTime: form.travelStartTime || undefined,
      endTime: form.travelEndTime || undefined,
      travelDays,
      departureCity: form.departurePlace || "",
      destinationCity: form.destination || "",
      hotelStandard: toNumber(form.hotelStandard),
      lodgingDays: toNumber(form.hotelDays),
      mealAllowance: toNumber(form.livingSubsidy),
      transportAllowance: toNumber(form.transportSubsidy),
      lodgingLimit: toNumber(form.lodgingLimit),
      standardTag: form.standardTag || (form.needSpecialApproval ? "超标特批" : "在标准范围内"),
      withinStandard: form.needSpecialApproval ? "0" : "1",
    },
    details,
    nodes: mapApprovalFlowNodesToApi(
      resolveFormApprovalFlowNodes(form),
      form.templateId
    ),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) {
    dto.billNo = form.billNo || form.reimburseNo;
  }
  if (form.approvalInstanceId != null) {
    dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  }
  if (form.approveProcessId != null) {
    dto.approveProcessId = toNumber(form.approveProcessId);
  }
  if (form.travel?.id != null) dto.travel.id = toNumber(form.travel.id);
  applyStorageBlobsToSaveDto(dto, form);
  return applyReimbursementRelations(dto);
}
/** è´¹ç”¨è¡¨å• â†’ FinReimbursementDto */
export function buildCostReimbursementSaveDto(form) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
    expenseType: expenseCategoryToType(form.expenseCategory),
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || "",
    payeeBank: form.bankBranch || form.payeeBank || "",
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    details,
    nodes: mapApprovalFlowNodesToApi(
      resolveFormApprovalFlowNodes(form),
      form.templateId
    ),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) {
    dto.billNo = form.billNo || form.reimburseNo;
  }
  if (form.approvalInstanceId != null) {
    dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  }
  if (form.approveProcessId != null) {
    dto.approveProcessId = toNumber(form.approveProcessId);
  }
  applyStorageBlobsToSaveDto(dto, form);
  return applyReimbursementRelations(dto);
}
/** åˆ—表行主键(删除/修改用 fin_reimbursement.id) */
export function resolveReimbursementDeleteId(row) {
  const raw = row?.reimbursementId ?? row?.id;
  if (raw == null || raw === "" || String(raw).startsWith("local_")) {
    return undefined;
  }
  const n = toNumber(raw);
  return n != null ? n : raw;
}
/** æ˜¯å¦å…è®¸åˆ é™¤ï¼ˆå®¡æ‰¹ä¸­ã€å·²é€šè¿‡ã€å·²ä»˜æ¬¾ä¸å¯åˆ ï¼‰ */
export function canDeleteReimbursementRow(row) {
  const key = mapBillStatusToApprovalResult(
    row?.billStatus ?? row?.approvalResult ?? row?.status
  );
  return key !== "pending" && key !== "approved" && key !== "paid";
}
/** æ˜¯å¦å…è®¸ç¼–辑(与删除规则一致) */
export function canEditReimbursementRow(row) {
  return canDeleteReimbursementRow(row);
}
/** ä¿®æ”¹åœºæ™¯å¿…须带主键 ID */
export function validateReimbursementPersistDto(dto, isEdit) {
  if (!isEdit) return { ok: true };
  if (dto?.id != null && dto.id !== "") return { ok: true };
  return { ok: false, message: "无法修改:缺少报销单 ID" };
}
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
import { getFinReimbursementDetail } from "@/api/officeProcessAutomation/finReimbursement.js";
import { matchBusinessTypeValue } from "../../ApproveManage/approve-list/approveListConstants.js";
import {
  APPROVAL_MODULE_KEYS,
  getApprovalModuleConfig,
} from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  getModuleKeyByReimbursementType,
  mapFinReimbursementDetailRow,
  resolveReimbursementType,
  unwrapFinReimbursementDetail,
} from "./finReimbursementMappers.js";
export const REIMBURSE_EDIT_FROM_APPROVE_KEY = "oa_reimburse_edit_from_approve";
const REIMBURSE_MODULE_KEYS = [
  APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE,
  APPROVAL_MODULE_KEYS.COST_REIMBURSE,
];
/** å®¡æ‰¹å®žä¾‹æ˜¯å¦å·®æ—…/费用报销 */
export function inferReimburseModuleKeyFromInstance(row) {
  if (!row) return "";
  for (const moduleKey of REIMBURSE_MODULE_KEYS) {
    const cfg = getApprovalModuleConfig(moduleKey);
    if (!cfg) continue;
    if (
      cfg.businessType != null &&
      cfg.businessType !== "" &&
      matchBusinessTypeValue(row.businessType, cfg.businessType)
    ) {
      return moduleKey;
    }
    if (matchBusinessTypeValue(row.businessType, cfg.approvalType)) {
      return moduleKey;
    }
    const text = `${row.templateName || ""}${row.title || ""}${row.businessName || ""}`;
    if ((cfg.typeLabels || []).some((l) => l && text.includes(l))) {
      return moduleKey;
    }
  }
  return "";
}
export function isReimburseApprovalInstance(row) {
  return Boolean(inferReimburseModuleKeyFromInstance(row));
}
/** å®¡æ‰¹å®žä¾‹å…³è”çš„ fin_reimbursement.id */
export function resolveFinReimbursementIdFromInstance(row) {
  const raw = row?.businessId ?? row?.formPayload?.reimbursementId;
  if (raw == null || raw === "") return undefined;
  const n = Number(raw);
  return Number.isNaN(n) ? raw : n;
}
/** æ‹‰å–报销详情并映射为差旅/费用页面行(以接口 reimbursementType ä¸ºå‡†ï¼‰ */
export async function loadReimburseDetailForInstance(instanceRow, moduleKey) {
  const mk = moduleKey || inferReimburseModuleKeyFromInstance(instanceRow);
  const id = resolveFinReimbursementIdFromInstance(instanceRow);
  if (id == null) {
    throw new Error("missing reimbursement id");
  }
  const res = await getFinReimbursementDetail(id);
  const raw = unwrapFinReimbursementDetail(res);
  const reimburseRow = mapFinReimbursementDetailRow(raw, mk);
  const reimbursementType = resolveReimbursementType(raw, mk);
  const resolvedMk =
    getModuleKeyByReimbursementType(reimbursementType) || mk;
  return {
    reimburseRow,
    instanceRow,
    moduleKey: resolvedMk,
    reimbursementType,
  };
}
export function stashReimburseEditFromApprove(moduleKey, reimbursementId) {
  sessionStorage.setItem(
    REIMBURSE_EDIT_FROM_APPROVE_KEY,
    JSON.stringify({ moduleKey, reimbursementId })
  );
}
export function consumeReimburseEditFromApprove() {
  const raw = sessionStorage.getItem(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  if (!raw) return null;
  sessionStorage.removeItem(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}
/** ä»Žå·²æ³¨å†Œè·¯ç”±è§£æžå·®æ—…/费用报销菜单 path(避免写死 path å¯¼è‡´ 404) */
export function resolveReimburseManageRoutePath(router, moduleKey) {
  if (!router?.getRoutes) return "";
  const needle =
    moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE
      ? "travel-reimburse"
      : moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
        ? "cost-reimburse"
        : "";
  if (!needle) return "";
  const labelHint =
    moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE ? "差旅" : "费用";
  const hit = router.getRoutes().find((r) => {
    const path = r.path || "";
    if (path.includes(needle)) return true;
    const title = r.meta?.title || "";
    return title.includes(labelHint) && title.includes("报销");
  });
  return hit?.path || "";
}
export async function navigateToReimburseManageForEdit(router, moduleKey, reimbursementId) {
  stashReimburseEditFromApprove(moduleKey, reimbursementId);
  const path = resolveReimburseManageRoutePath(router, moduleKey);
  if (!path) {
    throw new Error("route not found");
  }
  await router.push(path);
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
<!-- å·®æ—…报销:审批流程进度展示 -->
<template>
  <el-steps :active="activeStep" finish-status="success" align-center>
    <el-step
      v-for="(node, index) in sortedNodes"
      :key="index"
      :title="`节点 ${index + 1}`"
      :description="stepDescription(node)"
      :status="stepStatus(node, index)"
    />
  </el-steps>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
  nodes: { type: Array, default: () => [] },
  currentIndex: { type: Number, default: 0 },
});
const sortedNodes = computed(() => {
  const list = props.nodes || [];
  return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
});
const activeStep = computed(() => {
  const list = sortedNodes.value;
  if (!list.length) return 0;
  const finished = list.filter((n) => n.nodeStatus === "finish").length;
  const hasError = list.some((n) => n.nodeStatus === "error");
  if (hasError) return Math.max(0, props.currentIndex);
  return finished;
});
function stepDescription(node) {
  const name = (node.approverName || "").trim() || "未指定";
  const opinion = (node.approveOpinion || "").trim();
  if (opinion) return `${name}:${opinion}`;
  return name;
}
function stepStatus(node, index) {
  if (node.nodeStatus === "error") return "error";
  if (node.nodeStatus === "finish") return "success";
  if (node.nodeStatus === "process" || index === props.currentIndex) return "process";
  return "wait";
}
</script>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,82 @@
<!-- å·®æ—…报销:详情只读面板 -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="报销单号">{{ row.reimburseNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="状态">
      <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="员工编号">{{ row.employeeNo || row.applicantNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="员工姓名">{{ row.employeeName || row.applicantName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销原因" :span="2">{{ row.reimburseReason || "—" }}</el-descriptions-item>
    <el-descriptions-item label="出差开始">{{ row.travelStartTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="出差结束">{{ row.travelEndTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="出差地">{{ row.departurePlace || "—" }}</el-descriptions-item>
    <el-descriptions-item label="目的地">{{ row.destination || "—" }}</el-descriptions-item>
    <el-descriptions-item label="酒店标准">{{ row.hotelStandard != null ? `${row.hotelStandard} å…ƒ/晚` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="住宿天数">{{ row.hotelDays ?? "—" }}</el-descriptions-item>
    <el-descriptions-item label="生活补贴">{{ row.livingSubsidy != null ? `${row.livingSubsidy} å…ƒ` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="申请金额">{{ row.applyAmount != null ? `${row.applyAmount} å…ƒ` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="收款人">{{ row.payee || "—" }}</el-descriptions-item>
    <el-descriptions-item label="特批">
      <el-tag :type="row.needSpecialApproval ? 'danger' : 'info'" size="small">
        {{ row.needSpecialApproval ? "超支需特批" : "标准内" }}
      </el-tag>
    </el-descriptions-item>
    <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
      <span class="reject-text">{{ row.rejectReason }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="创建时间" :span="2">{{ row.createTime || "—" }}</el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">报销明细</el-divider>
  <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small">
    <el-table-column type="index" label="序号" width="55" align="center" />
    <el-table-column prop="invoiceDate" label="发票日期" width="120" />
    <el-table-column label="费用科目" width="100">
      <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template>
    </el-table-column>
    <el-table-column prop="amount" label="金额" width="100" />
    <el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
  </el-table>
  <el-empty v-else description="暂无明细" :image-size="48" />
  <el-divider content-position="left">发票附件</el-divider>
  <template v-if="attachmentFiles.length">
    <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)">
      {{ f.name }}
    </el-tag>
  </template>
  <el-empty v-else description="暂无附件" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import { expenseSubjectLabel, statusLabel, statusTagType } from "../travelReimburseUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const attachmentFiles = computed(() => {
  const list =
    props.row?.attachmentList ||
    props.row?.storageBlobVOList ||
    props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
function openFile(f) {
  const url = f?.url || f?.downloadURL || f?.previewURL;
  if (url) window.open(url, "_blank");
}
</script>
<style scoped>
.reject-text {
  color: var(--el-color-danger);
}
.file-tag {
  margin: 0 8px 8px 0;
  cursor: pointer;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,614 @@
<!--OA模块:差旅报销(列表 /finReimbursement/listPage,reimbursementType=1)-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增差旅报销</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1120px"
      append-to-body
      destroy-on-close
      class="travel-reimburse-form-dialog"
      @closed="onFormClosed"
    >
      <el-alert
        v-if="budgetHint.visible"
        :title="budgetHint.title"
        :type="budgetHint.type"
        :description="budgetHint.description"
        show-icon
        :closable="false"
        class="mb16"
      />
      <el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16">
        <template #title>差旅标准超支提醒(需特批)</template>
        <ul class="warn-list">
          <li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li>
        </ul>
      </el-alert>
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="120px"
        class="travel-reimburse-form"
        :disabled="formDialog.readonly"
      >
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">基本信息</span></template>
          <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="员工编号">
              <el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="员工姓名" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索员工"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="报销原因" prop="reimburseReason">
              <el-input
                v-model="form.reimburseReason"
                type="textarea"
                :rows="3"
                placeholder="请填写出差及报销原因"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="出差开始" prop="travelStartTime">
              <el-date-picker
                v-model="form.travelStartTime"
                type="datetime"
                placeholder="开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onTravelRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="出差结束" prop="travelEndTime">
              <el-date-picker
                v-model="form.travelEndTime"
                type="datetime"
                placeholder="结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onTravelRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="出差天数">
              <el-input :model-value="travelDaysDisplay" readonly>
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="出差地" prop="departurePlace">
              <el-input v-model="form.departurePlace" placeholder="出发城市" @blur="recalcTravelStandards" />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="目的地" prop="destination">
              <el-input v-model="form.destination" placeholder="目的城市" @blur="recalcTravelStandards" />
            </el-form-item>
          </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">差旅标准</span>
              <el-text type="info" size="small">{{ travelTierLabel }} Â· ç”Ÿæ´»è¡¥è´´å»ºè®® {{ suggestedLivingSubsidy }} å…ƒ</el-text>
            </div>
          </template>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="酒店标准">
              <el-input-number
                v-model="form.hotelStandard"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="住宿天数">
              <el-input-number
                v-model="form.hotelDays"
                :min="0"
                :max="365"
                :precision="0"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="生活补贴">
              <el-input-number
                v-model="form.livingSubsidy"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
        </el-row>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="交通补贴">
                <el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>元</template></el-input>
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="住宿限额">
                <el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>元</template></el-input>
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="特批标记">
                <el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain">
                  {{ form.needSpecialApproval ? "超支需特批" : "在标准范围内" }}
                </el-tag>
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">金额与收款</span></template>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="申请金额" prop="applyAmount">
                <div class="amount-row">
                  <el-input-number v-model="form.applyAmount" :min="0" :precision="2" controls-position="right" class="amount-input" />
                  <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
                    æŒ‰æ˜Žç»†æ±‡æ€» {{ detailTotalAmount }} å…ƒ
                  </el-button>
                </div>
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="收款人" prop="payee">
                <el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">报销明细</span>
              <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">新增明细</el-button>
            </div>
          </template>
        <el-table :data="form.expenseDetails" border size="small" class="detail-table">
          <el-table-column type="index" label="序号" width="55" align="center" />
          <el-table-column label="发票日期" width="150">
            <template #default="{ row }">
              <el-date-picker
                v-if="!formDialog.readonly"
                v-model="row.invoiceDate"
                type="date"
                value-format="YYYY-MM-DD"
                size="small"
                style="width: 100%"
              />
              <span v-else>{{ row.invoiceDate || "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column label="费用科目" width="130">
            <template #default="{ row }">
              <el-select
                v-if="!formDialog.readonly"
                v-model="row.expenseSubject"
                size="small"
                style="width: 100%"
                @change="recalcTravelStandards"
              >
                <el-option
                  v-for="opt in EXPENSE_SUBJECT_OPTIONS"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
              <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
            </template>
          </el-table-column>
          <el-table-column label="金额" width="120">
            <template #default="{ row }">
              <el-input-number
                v-if="!formDialog.readonly"
                v-model="row.amount"
                :min="0"
                :precision="2"
                size="small"
                controls-position="right"
                style="width: 100%"
                @change="onDetailAmountChange"
              />
              <span v-else>{{ row.amount ?? "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column label="描述" min-width="140">
            <template #default="{ row }">
              <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
              <span v-else>{{ row.description || "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
            <template #default="{ $index }">
              <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">附件(发票)</span></template>
          <el-form-item label-width="0" class="attachment-form-item">
            <div class="upload-block">
              <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
            </div>
          </el-form-item>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">审批流程</span></template>
          <el-form-item prop="approvalFlowNodes" label-width="0">
          <ApprovalFlowEditor
            v-if="!formDialog.readonly"
            v-model="form.approvalFlowNodes"
            :user-options="flowUserOptions"
            @update:model-value="onApprovalFlowChange"
          />
          <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
          <p v-if="!formDialog.readonly" class="flow-tip">至少保留一个节点;审核中、已通过的单据不可编辑。</p>
        </el-form-item>
        </el-card>
      </el-form>
      <template #footer>
        <el-button
          v-if="!formDialog.readonly"
          type="primary"
          :loading="submitSaving"
          @click="submitForm"
        >
          æ äº¤
        </el-button>
        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 é—­" : "取 æ¶ˆ" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="差旅报销详情" width="900px" append-to-body destroy-on-close>
      <div v-loading="detailLoading">
      <DetailPanel :row="detailRow" />
      <ApprovalFlowProgress
        class="mt16"
        :nodes="detailRow.approvalFlowProgressNodes ?? detailRow.approvalFlowNodes"
        :current-index="detailRow.currentNodeIndex ?? 0"
      />
      <el-divider content-position="left">审批记录(全流程留痕)</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      </div>
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="差旅报销审批"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <DetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowProgressNodes ?? approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见">
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写原因"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="submitApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="submitApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "./components/ApprovalFlowProgress.vue";
import DetailPanel from "./components/DetailPanel.vue";
import { useTravelReimburse } from "./useTravelReimburse.js";
const tr = useTravelReimburse();
const {
  Search,
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  importInputRef,
  formRef,
  form,
  formDialog,
  formRules,
  detailDialog,
  detailLoading,
  detailRow,
  approveDialog,
  approveOpinion,
  applicantFormSearchLoading,
  applicantFormOptions,
  flowUserOptions,
  travelDaysDisplay,
  travelTierLabel,
  suggestedLivingSubsidy,
  suggestedTransportSubsidy,
  suggestedHotelLimit,
  detailTotalAmount,
  overBudgetWarnings,
  budgetHint,
  handleQuery,
  resetSearch,
  pagination,
  remoteSearchApplicantForm,
  userSelectLabel,
  onApplicantChange,
  recalcTravelStandards,
  onTravelRangeChange,
  onDetailAmountChange,
  onApprovalFlowChange,
  addExpenseDetail,
  removeExpenseDetail,
  syncApplyAmountFromDetails,
  openFormDialog,
  onFormClosed,
  submitForm,
  submitSaving,
  openDetail,
  approvalActionLabel,
  submitApprove,
  handleExport,
  handleImportClick,
  onImportFile,
} = tr;
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.mb8 {
  margin-bottom: 8px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.form-section {
  margin-bottom: 16px;
  border: 1px solid var(--el-border-color-lighter);
}
.form-section :deep(.el-card__header) {
  padding: 12px 16px;
  background: var(--el-fill-color-lighter);
}
.form-section :deep(.el-card__body) {
  padding: 16px 16px 4px;
}
.card-header-title {
  font-size: 15px;
  font-weight: 600;
}
.card-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.amount-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
}
.amount-input {
  flex: 1;
  min-width: 160px;
}
.w-full {
  width: 100%;
}
.attachment-form-item {
  margin-bottom: 0;
}
.detail-table {
  margin-bottom: 0;
}
.section-title {
  font-size: 15px;
  font-weight: 600;
  margin: 8px 0 12px;
  color: var(--el-text-color-primary);
  border-left: 3px solid var(--el-color-primary);
  padding-left: 8px;
}
.field-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 4px;
}
.warn-list {
  margin: 0;
  padding-left: 18px;
}
.detail-toolbar {
  margin-bottom: 8px;
}
.upload-block {
  width: 100%;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.sync-btn {
  margin-top: 4px;
}
.travel-reimburse-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.travel-reimburse-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.travel-reimburse-form :deep(.el-input-number) {
  width: 100%;
}
.travel-reimburse-form :deep(.el-row) {
  margin-bottom: 0;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,189 @@
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 statusLabel(v) {
  if (v === "draft") return "草稿";
  if (v === "approved") return "通过";
  if (v === "paid") return "已付款";
  if (v === "rejected") return "驳回";
  if (v === "cancelled") return "已撤回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "draft") return "info";
  if (v === "approved" || v === "paid") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "warning";
}
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;
  const days = Math.ceil(t1.diff(t0, "day", true));
  return Math.max(1, days);
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: undefined,
    description: "",
  };
}
export function createEmptyForm() {
  return {
    id: undefined,
    reimburseNo: "",
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    reimburseReason: "",
    travelStartTime: "",
    travelEndTime: "",
    travelDays: undefined,
    departurePlace: "",
    destination: "",
    hotelStandard: undefined,
    hotelDays: undefined,
    livingSubsidy: undefined,
    applyAmount: undefined,
    payee: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    needSpecialApproval: false,
    deptId: "",
    deptName: "",
    travelTier: "tier3",
  };
}
export function initApprovalFlowNodes(nodes) {
  return (nodes || []).map((n, i) => ({
    ...n,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: n.approveOpinion || "",
    approveTime: n.approveTime || "",
  }));
}
export function advanceApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  nodes[idx] = {
    ...nodes[idx],
    nodeStatus: "finish",
    approveOpinion: opinion || "同意",
    approveTime: now,
  };
  const next = idx + 1;
  if (next >= nodes.length) {
    return { nodes, currentNodeIndex: idx, approvalResult: "approved" };
  }
  nodes[next] = { ...nodes[next], nodeStatus: "process" };
  return { nodes, currentNodeIndex: next, approvalResult: "pending" };
}
export function rejectApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  if (nodes[idx]) {
    nodes[idx] = {
      ...nodes[idx],
      nodeStatus: "error",
      approveOpinion: opinion || "驳回",
      approveTime: now,
    };
  }
  return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: opinion || "驳回" };
}
/** éƒ¨é—¨é¢„算(对接预算系统前返回空) */
export function mockDeptBudget(deptId) {
  if (!deptId) return null;
  return null;
}
export function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const travelDays =
    raw.travelDays != null
      ? Number(raw.travelDays)
      : computeTravelDays(raw.travelStartTime, raw.travelEndTime);
  return {
    id,
    reimburseNo: raw.reimburseNo || `TR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
    employeeName: raw.employeeName ?? raw.applicantName ?? "未知",
    applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
    applicantName: raw.employeeName ?? raw.applicantName ?? "未知",
    reimburseReason: raw.reimburseReason ?? "",
    travelStartTime: raw.travelStartTime ?? "",
    travelEndTime: raw.travelEndTime ?? "",
    travelDays: travelDays == null || Number.isNaN(travelDays) ? 1 : travelDays,
    departurePlace: raw.departurePlace ?? "",
    destination: raw.destination ?? "",
    hotelStandard: raw.hotelStandard,
    hotelDays: raw.hotelDays,
    livingSubsidy: raw.livingSubsidy,
    applyAmount: raw.applyAmount ?? 0,
    payee: raw.payee ?? "",
    expenseDetails: Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [],
    invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) ? raw.approvalFlowNodes : [],
    currentNodeIndex: raw.currentNodeIndex ?? 0,
    approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
    rejectReason: raw.rejectReason ?? "",
    approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
    needSpecialApproval: !!raw.needSpecialApproval,
    deptId: raw.deptId ?? "",
    deptName: raw.deptName ?? "",
    travelTier: raw.travelTier || detectTravelTier(raw.destination),
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
  };
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,696 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import {
  deleteFinReimbursement,
  getFinReimbursementDetail,
  listFinReimbursementPage,
  persistFinReimbursement,
} from "@/api/officeProcessAutomation/finReimbursement.js";
import { ElMessageBox } from "element-plus";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
import {
  buildFinReimbursementListParams,
  filterReimbursementRowsBySearch,
  hasActiveReimbursementSearch,
  buildTravelReimbursementSaveDto,
  canDeleteReimbursementRow,
  canEditReimbursementRow,
  enrichReimbursementListRowsWithApprovalFlow,
  filterRowsByReimbursementType,
  FIN_REIMBURSEMENT_TYPE,
  mapFinReimbursementDetailRow,
  mapTravelReimbursementRow,
  resolveReimbursementDeleteId,
  unwrapFinReimbursementDetail,
  unwrapFinReimbursementPage,
  validateReimbursementApprovalNodes,
  validateReimbursementPersistDto,
} from "../shared/finReimbursementMappers.js";
import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
import {
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  statusLabel,
  statusTagType,
  detectTravelTier,
  getTravelStandardByTier,
  computeTravelDays,
  createEmptyExpenseDetail,
  createEmptyForm,
  initApprovalFlowNodes,
  advanceApprovalFlow,
  rejectApprovalFlow,
  mockDeptBudget,
  normalizeImportedRow,
} from "./travelReimburseUtils.js";
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
export function useTravelReimburse() {
  const { proxy } = getCurrentInstance();
  const allRows = ref([]);
  const searchForm = reactive({ applicantKeyword: "" });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const importInputRef = ref(null);
  const allUsersCache = ref([]);
  const applicantFormSearchLoading = ref(false);
  const applicantFormOptions = ref([]);
  const formRef = ref();
  const form = reactive(createEmptyForm());
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const detailDialog = reactive({ visible: false });
  const detailLoading = ref(false);
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const submitSaving = ref(false);
  const tableData = computed(() => allRows.value);
  async function fetchList() {
    tableLoading.value = true;
    try {
      const res = await listFinReimbursementPage(
        buildFinReimbursementListParams({
          page,
          searchForm,
          reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
        })
      );
      const { records, total } = unwrapFinReimbursementPage(res);
      const filtered = filterRowsByReimbursementType(
        records,
        FIN_REIMBURSEMENT_TYPE.TRAVEL
      );
      let mapped = filtered.map(mapTravelReimbursementRow);
      mapped = await enrichReimbursementListRowsWithApprovalFlow(
        mapped,
        FIN_REIMBURSEMENT_TYPE.TRAVEL
      );
      if (hasActiveReimbursementSearch(searchForm)) {
        mapped = filterReimbursementRowsBySearch(mapped, searchForm);
      }
      allRows.value = mapped;
      const dropped = records.length - filtered.length;
      let nextTotal =
        dropped > 0 ? Math.max(0, Number(total) - dropped) : Number(total);
      if (hasActiveReimbursementSearch(searchForm)) {
        nextTotal = mapped.length;
      }
      page.total = nextTotal;
    } catch {
      allRows.value = [];
      page.total = 0;
      proxy?.$modal?.msgError?.("差旅报销列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
  const travelDaysDisplay = computed(() => {
    const d = computeTravelDays(form.travelStartTime, form.travelEndTime);
    return d == null ? "" : String(d);
  });
  const travelTierLabel = computed(() => {
    const std = getTravelStandardByTier(form.travelTier || detectTravelTier(form.destination));
    return `按${std.label}标准`;
  });
  const suggestedLivingSubsidy = computed(() => {
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || form.hotelDays || 0;
    const std = getTravelStandardByTier(form.travelTier);
    return Math.round(std.mealPerDay * days * 100) / 100;
  });
  const suggestedTransportSubsidy = computed(() => {
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || 0;
    const std = getTravelStandardByTier(form.travelTier);
    return Math.round(std.transportPerDay * days * 100) / 100;
  });
  const suggestedHotelLimit = computed(() => {
    const nights = form.hotelDays || 0;
    const perNight = form.hotelStandard ?? getTravelStandardByTier(form.travelTier).hotelPerNight;
    return Math.round(perNight * nights * 100) / 100;
  });
  const detailTotalAmount = computed(() => {
    const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
    return Math.round(sum * 100) / 100;
  });
  const overBudgetWarnings = computed(() => buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value));
  const budgetHint = computed(() => {
    if (!form.deptId) return { visible: false };
    const b = mockDeptBudget(form.deptId);
    const apply = Number(form.applyAmount) || detailTotalAmount.value || 0;
    const after = b.remainingAmount - apply;
    return {
      visible: true,
      type: after < 0 ? "error" : "info",
      title: `部门预算联动(${form.deptName || b.deptId})`,
      description: `年度预算 ${b.totalBudget} å…ƒï¼Œå·²ç”¨ ${b.usedAmount} å…ƒï¼Œå‰©ä½™ ${b.remainingAmount} å…ƒï¼›æœ¬å•申请后预计剩余 ${Math.round(after * 100) / 100} å…ƒã€‚`,
    };
  });
  const tableColumn = ref([
    { label: "报销单号", prop: "reimburseNo", width: 150 },
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人", prop: "applicantName", minWidth: 90 },
    { label: "出差开始", prop: "travelStartTime", width: 165 },
    { label: "出差结束", prop: "travelEndTime", width: 165 },
    { label: "创建时间", prop: "createTime", width: 165 },
    {
      label: "状态",
      prop: "approvalResult",
      width: 100,
      dataType: "tag",
      formatData: (v) => statusLabel(v),
      formatType: (v) => statusTagType(v),
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: [
        {
          name: "编辑",
          type: "text",
          disabled: (row) => !canEditReimbursementRow(row),
          clickFun: (row) => openFormDialog("edit", row),
        },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "删除",
          type: "danger",
          disabled: (row) => !canDeleteReimbursementRow(row),
          clickFun: (row) => confirmRemoveRow(row),
        },
      ],
    },
  ]);
  const formRules = {
    applicantId: [{ required: true, message: "请选择员工", trigger: "change" }],
    reimburseReason: [{ required: true, message: "请填写报销原因", trigger: "blur" }],
    travelStartTime: [{ required: true, message: "请选择出差开始时间", trigger: "change" }],
    travelEndTime: [
      { required: true, message: "请选择出差结束时间", trigger: "change" },
      {
        validator: (_r, val, cb) => {
          if (!form.travelStartTime || !val) { cb(); return; }
          if (computeTravelDays(form.travelStartTime, val) == null) cb(new Error("结束时间须晚于开始时间"));
          else cb();
        },
        trigger: "change",
      },
    ],
    departurePlace: [{ required: true, message: "请填写出差地", trigger: "blur" }],
    destination: [{ required: true, message: "请填写目的地", trigger: "blur" }],
    applyAmount: [{ required: true, message: "请填写申请金额", trigger: "blur" }],
    payee: [{ required: true, message: "请填写收款人", trigger: "blur" }],
    approvalFlowNodes: [
      {
        validator: (_r, _v, cb) => {
          const nodes = form.approvalFlowNodes || [];
          if (!nodes.length) { cb(new Error("请至少配置一个审批节点")); return; }
          if (nodes.some((n) => n.approverId == null || n.approverId === "")) { cb(new Error("每个节点须选择审批人")); return; }
          cb();
        },
        trigger: "change",
      },
    ],
  };
  function buildOverBudgetWarnings(f, detailTotal, hotelLimit, transportLimit, mealLimit) {
    const warnings = [];
    const bySubject = { transport: 0, hotel: 0, meal: 0, other: 0 };
    (f.expenseDetails || []).forEach((d) => {
      const key = d.expenseSubject || "other";
      bySubject[key] = (bySubject[key] || 0) + (Number(d.amount) || 0);
    });
    if (bySubject.transport > transportLimit && transportLimit > 0) {
      warnings.push(`交通费 ${bySubject.transport} å…ƒè¶…出标准 ${transportLimit} å…ƒ`);
    }
    if (bySubject.hotel > hotelLimit && hotelLimit > 0) {
      warnings.push(`住宿费 ${bySubject.hotel} å…ƒè¶…出限额 ${hotelLimit} å…ƒ`);
    }
    if (bySubject.meal > mealLimit && mealLimit > 0) {
      warnings.push(`餐饮费 ${bySubject.meal} å…ƒè¶…出生活补贴建议 ${mealLimit} å…ƒ`);
    }
    const std = getTravelStandardByTier(f.travelTier);
    if (f.hotelStandard > std.hotelPerNight) {
      warnings.push(`酒店标准 ${f.hotelStandard} å…ƒ/晚高于${std.label}标准 ${std.hotelPerNight} å…ƒ/晚`);
    }
    const apply = Number(f.applyAmount) || detailTotal;
    const standardTotal = transportLimit + hotelLimit + mealLimit;
    if (apply > standardTotal && standardTotal > 0) {
      warnings.push(`申请总额 ${apply} å…ƒé«˜äºŽå·®æ—…标准合计约 ${standardTotal} å…ƒ`);
    }
    return warnings;
  }
  async function loadUserPool() {
    try {
      allUsersCache.value = unwrapArray(await userListNoPageByTenantId());
    } catch {
      allUsersCache.value = [];
    }
  }
  function userSelectLabel(u) {
    const nick = u.nickName || "";
    const name = u.userName || "";
    if (nick && name && nick !== name) return `${nick}(${name})`;
    return nick || name || `用户${u.userId ?? u.id ?? ""}`;
  }
  function userById(id) {
    return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
  }
  function employeeNoFromUser(u) {
    if (!u) return "";
    return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : "");
  }
  function filterUsersByQuery(query) {
    const list = allUsersCache.value.filter(isActiveUser);
    const q = (query || "").trim().toLowerCase();
    if (!q) return [...list];
    return list.filter((u) => {
      const nick = (u.nickName || "").toLowerCase();
      const uname = (u.userName || "").toLowerCase();
      return nick.includes(q) || uname.includes(q);
    });
  }
  async function remoteSearchApplicantForm(query) {
    applicantFormSearchLoading.value = true;
    try {
      if (!allUsersCache.value.length) await loadUserPool();
      applicantFormOptions.value = filterUsersByQuery(query);
    } finally {
      applicantFormSearchLoading.value = false;
    }
  }
  function onApplicantChange(uid) {
    const u = userById(uid);
    if (u) {
      form.employeeName = u.nickName || u.userName || "";
      form.employeeNo = employeeNoFromUser(u);
      form.payee = form.payee || form.employeeName;
      form.deptId = String(u.deptId ?? u.sysDeptId ?? "");
      form.deptName = u.dept?.deptName ?? u.deptName ?? "";
    } else {
      form.employeeName = "";
      form.employeeNo = "";
    }
  }
  function recalcTravelStandards() {
    form.travelTier = detectTravelTier(form.destination);
    const std = getTravelStandardByTier(form.travelTier);
    if (form.hotelStandard == null || form.hotelStandard === 0) form.hotelStandard = std.hotelPerNight;
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
    if (days != null) {
      form.travelDays = days;
      if (form.hotelDays == null) form.hotelDays = Math.max(0, days - 1);
      if (form.livingSubsidy == null || form.livingSubsidy === 0) form.livingSubsidy = suggestedLivingSubsidy.value;
    }
    form.needSpecialApproval = buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value).length > 0;
  }
  function onTravelRangeChange() {
    recalcTravelStandards();
    nextTick(() => formRef.value?.validateField?.("travelEndTime"));
  }
  function onDetailAmountChange() {
    recalcTravelStandards();
  }
  function onApprovalFlowChange() {
    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
  }
  function addExpenseDetail() {
    form.expenseDetails.push(createEmptyExpenseDetail());
  }
  function removeExpenseDetail(index) {
    form.expenseDetails.splice(index, 1);
    recalcTravelStandards();
  }
  function mapAttachmentList(list) {
    return (list || []).map((f, i) => ({
      id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
      name: f.name || f.fileName || f.originalFilename || "未命名",
      url: f.url || f.downloadURL || f.previewURL || f.previewUrl || "",
    }));
  }
  function syncApplyAmountFromDetails() {
    form.applyAmount = detailTotalAmount.value;
    recalcTravelStandards();
  }
  function handleQuery() {
    page.current = 1;
    return fetchList();
  }
  function resetSearch() {
    searchForm.applicantKeyword = "";
    handleQuery();
  }
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
    return fetchList();
  }
  async function loadTravelDetailRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      throw new Error("missing id");
    }
    const res = await getFinReimbursementDetail(id);
    const raw = unwrapFinReimbursementDetail(res);
    return mapFinReimbursementDetailRow(raw, FIN_REIMBURSEMENT_TYPE.TRAVEL);
  }
  async function openDetail(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法查看详情:缺少报销单 ID");
      return;
    }
    detailDialog.visible = true;
    detailLoading.value = true;
    detailRow.value = { ...row };
    try {
      detailRow.value = await loadTravelDetailRow(row);
    } catch {
      proxy?.$modal?.msgError?.("加载详情失败");
      detailDialog.visible = false;
    } finally {
      detailLoading.value = false;
    }
  }
  async function confirmRemoveRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法删除:缺少报销单 ID");
      return;
    }
    const title = row.reimburseNo || row.billNo || row.reimburseReason || "该报销单";
    try {
      await ElMessageBox.confirm(
        `确定要删除「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteFinReimbursement([id]);
      proxy?.$modal?.msgSuccess?.("删除成功");
      if (detailDialog.visible && resolveReimbursementDeleteId(detailRow.value) === id) {
        detailDialog.visible = false;
      }
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.("删除失败");
    }
  }
  function openApprove(row) {
    approveDialog.row = { ...row };
    approveDialog.visible = true;
  }
  function approvalActionLabel(v) {
    if (v === "approved") return "通过";
    if (v === "rejected") return "驳回";
    return "提交";
  }
  async function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.readonly = false;
    formDialog.title = mode === "add" ? "新增差旅报销" : "编辑差旅报销";
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      let editRow = row;
      try {
        editRow = await loadTravelDetailRow(row);
      } catch {
        proxy?.$modal?.msgError?.("加载报销详情失败");
        return;
      }
      Object.assign(form, {
        ...JSON.parse(JSON.stringify(editRow)),
        reimbursementId: editRow.reimbursementId ?? editRow.id,
        attachmentList: JSON.parse(JSON.stringify(editRow.attachmentList || editRow.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(editRow.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(editRow.expenseDetails || [])),
      });
      const u = userById(editRow.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
    } else {
      form.approvalFlowNodes = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }];
      remoteSearchApplicantForm("");
    }
    formDialog.visible = true;
    nextTick(() => {
      formRef.value?.clearValidate?.();
      recalcTravelStandards();
    });
  }
  function onFormClosed() {
    formRef.value?.resetFields?.();
  }
  async function submitForm() {
    try {
      await formRef.value?.validate?.();
    } catch {
      return;
    }
    if (!(form.expenseDetails || []).length) {
      proxy?.$modal?.msgWarning?.("请至少添加一条报销明细");
      return;
    }
    recalcTravelStandards();
    if (form.needSpecialApproval) {
      try {
        await proxy.$modal.confirm("存在超支项,提交后将标记为需特批,是否继续?");
      } catch {
        return;
      }
    }
    if (submitSaving.value) return;
    const isEdit = formDialog.mode === "edit";
    const dto = buildTravelReimbursementSaveDto(form, { computeTravelDays });
    const check = validateReimbursementPersistDto(dto, isEdit);
    if (!check.ok) {
      proxy?.$modal?.msgWarning?.(check.message);
      return;
    }
    const nodeCheck = validateReimbursementApprovalNodes(dto);
    if (!nodeCheck.ok) {
      proxy?.$modal?.msgWarning?.(nodeCheck.message);
      return;
    }
    submitSaving.value = true;
    try {
      await persistFinReimbursement(dto, isEdit);
      proxy?.$modal?.msgSuccess?.(isEdit ? "保存成功" : "提交成功");
      formDialog.visible = false;
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.(isEdit ? "保存失败" : "提交失败");
    } finally {
      submitSaving.value = false;
    }
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row) return;
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      proxy?.$modal?.msgWarning?.("驳回须填写审批意见");
      return;
    }
    const idx = allRows.value.findIndex((r) => r.id === row.id);
    if (idx === -1) return;
    const cur = allRows.value[idx];
    const operatorName = "当前审批人";
    const record = {
      operatorName,
      result,
      opinion: approveOpinion.value || (result === "approved" ? "同意" : "驳回"),
      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    };
    const records = [...(cur.approvalRecords || []), record];
    let flowUpdate;
    if (result === "approved") {
      flowUpdate = advanceApprovalFlow(cur, approveOpinion.value);
    } else {
      flowUpdate = rejectApprovalFlow(cur, approveOpinion.value);
    }
    allRows.value[idx] = {
      ...cur,
      approvalFlowNodes: flowUpdate.nodes,
      currentNodeIndex: flowUpdate.currentNodeIndex,
      approvalResult: flowUpdate.approvalResult,
      rejectReason: flowUpdate.rejectReason ?? cur.rejectReason,
      approvalRecords: records,
    };
    proxy?.$modal?.msgSuccess?.(result === "approved" ? "已通过" : "已驳回");
    approveDialog.visible = false;
    handleQuery();
  }
  function handleExport() {
    const data = allRows.value;
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
    a.href = url;
    a.download = `差旅报销导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
    a.click();
    URL.revokeObjectURL(url);
    proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡`);
  }
  function handleImportClick() {
    importInputRef.value?.click?.();
  }
  function onImportFile(e) {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const parsed = JSON.parse(String(reader.result || ""));
        const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
        if (!Array.isArray(arr) || !arr.length) {
          proxy?.$modal?.msgWarning?.("导入格式须为差旅报销 JSON æ•°ç»„");
          return;
        }
        arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i)));
        proxy?.$modal?.msgSuccess?.(`成功导入 ${arr.length} æ¡`);
        handleQuery();
      } catch {
        proxy?.$modal?.msgError?.("解析失败");
      }
    };
    reader.readAsText(file, "utf-8");
  }
  onMounted(async () => {
    loadUserPool();
    await fetchList();
    const editPayload = consumeReimburseEditFromApprove();
    if (editPayload?.reimbursementId != null) {
      await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
    }
  });
  return {
    Search,
    EXPENSE_SUBJECT_OPTIONS,
    expenseSubjectLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    importInputRef,
    formRef,
    form,
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
    applicantFormSearchLoading,
    applicantFormOptions,
    flowUserOptions,
    travelDaysDisplay,
    travelTierLabel,
    suggestedLivingSubsidy,
    suggestedTransportSubsidy,
    suggestedHotelLimit,
    detailTotalAmount,
    overBudgetWarnings,
    budgetHint,
    handleQuery,
    resetSearch,
    pagination,
    remoteSearchApplicantForm,
    userSelectLabel,
    onApplicantChange,
    recalcTravelStandards,
    onTravelRangeChange,
    onDetailAmountChange,
    onApprovalFlowChange,
    addExpenseDetail,
    removeExpenseDetail,
    syncApplyAmountFromDetails,
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    confirmRemoveRow,
    openApprove,
    approvalActionLabel,
    submitApprove,
    handleExport,
    handleImportClick,
    onImportFile,
  };
}
src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,291 @@
<!--OA模块:部门管理-->
<template>
    <div class="app-container">
        <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch">
            <el-form-item label="部门名称" prop="deptName">
                <el-input
                    v-model="queryParams.deptName"
                    placeholder="请输入部门名称"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery"
                />
            </el-form-item>
            <el-form-item label="状态" prop="status">
                <el-select v-model="queryParams.status" placeholder="部门状态" clearable style="width: 200px">
                    <el-option
                        v-for="dict in sys_normal_disable"
                        :key="dict.value"
                        :label="dict.label"
                        :value="dict.value"
                    />
                </el-select>
            </el-form-item>
            <el-form-item>
                <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
                <el-button icon="Refresh" @click="resetQuery">重置</el-button>
            </el-form-item>
        </el-form>
        <el-row :gutter="10" class="mb8">
            <el-col :span="1.5">
                <el-button
                    type="primary"
                    plain
                    icon="Plus"
                    @click="handleAdd"
                    v-hasPermi="['system:dept:add']"
                >新增</el-button>
            </el-col>
            <el-col :span="1.5">
                <el-button
                    type="info"
                    plain
                    icon="Sort"
                    @click="toggleExpandAll"
                >展开/折叠</el-button>
            </el-col>
            <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
        </el-row>
        <el-table
            v-if="refreshTable"
            v-loading="loading"
            :data="deptList"
            row-key="deptId"
            :default-expand-all="isExpandAll"
            :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
        >
            <el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
            <el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
            <el-table-column prop="status" label="状态" width="100">
                <template #default="scope">
                    <dict-tag :options="sys_normal_disable" :value="scope.row.status" />
                </template>
            </el-table-column>
            <el-table-column label="创建时间" align="center" prop="createTime" width="200">
                <template #default="scope">
                    <span>{{ parseTime(scope.row.createTime) }}</span>
                </template>
            </el-table-column>
            <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
                <template #default="scope">
                    <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:dept:edit']">修改</el-button>
                    <el-button link type="primary" icon="Plus" @click="handleAdd(scope.row)" v-hasPermi="['system:dept:add']">新增</el-button>
                    <el-button v-if="scope.row.parentId != 0" link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:dept:remove']">删除</el-button>
                </template>
            </el-table-column>
        </el-table>
        <!-- æ·»åŠ æˆ–ä¿®æ”¹éƒ¨é—¨å¯¹è¯æ¡† -->
        <el-dialog :title="title" v-model="open" width="600px" append-to-body>
            <el-form ref="deptRef" :model="form" :rules="rules" label-width="80px">
                <el-row>
                    <el-col :span="24" v-if="form.parentId !== 0">
                        <el-form-item label="上级部门" prop="parentId">
                            <el-tree-select
                                v-model="form.parentId"
                                :data="deptOptions"
                                :props="{ value: 'deptId', label: 'deptName', children: 'children' }"
                                value-key="deptId"
                                placeholder="选择上级部门"
                                check-strictly
                            />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="部门名称" prop="deptName">
                            <el-input v-model="form.deptName" placeholder="请输入部门名称" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="显示排序" prop="orderNum">
                            <el-input-number v-model="form.orderNum" controls-position="right" :min="0"/>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="负责人" prop="leader">
                            <el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="联系电话" prop="phone">
                            <el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="邮箱" prop="email">
                            <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="部门状态">
                            <el-radio-group v-model="form.status">
                                <el-radio
                                    v-for="dict in sys_normal_disable"
                                    :key="dict.value"
                                    :value="dict.value"
                                >{{ dict.label }}</el-radio>
                            </el-radio-group>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="部门编号" prop="deptNick">
                            <el-input v-model="form.deptNick" placeholder="请输入部门编号" maxlength="50" />
                        </el-form-item>
                    </el-col>
                </el-row>
            </el-form>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
                    <el-button @click="cancel">取 æ¶ˆ</el-button>
                </div>
            </template>
        </el-dialog>
    </div>
</template>
<script setup name="Dept">
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/system/dept"
const { proxy } = getCurrentInstance()
const { sys_normal_disable } = proxy.useDict("sys_normal_disable")
const deptList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const title = ref("")
const deptOptions = ref([])
const isExpandAll = ref(true)
const refreshTable = ref(true)
const data = reactive({
    form: {},
    queryParams: {
        deptName: undefined,
        status: undefined
    },
    rules: {
        parentId: [{ required: true, message: "上级部门不能为空", trigger: "blur" }],
        deptName: [{ required: true, message: "部门名称不能为空", trigger: "blur" }],
        orderNum: [{ required: true, message: "显示排序不能为空", trigger: "blur" }],
        email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: ["blur", "change"] }],
        phone: [{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }],
        deptNick: [{ required: true, message: "部门编号不能为空", trigger: "blur" }],
    },
})
const { queryParams, form, rules } = toRefs(data)
/** æŸ¥è¯¢éƒ¨é—¨åˆ—表 */
function getList() {
    loading.value = true
    listDept(queryParams.value).then(response => {
        deptList.value = proxy.handleTree(response.data, "deptId")
        loading.value = false
    })
}
/** å–消按钮 */
function cancel() {
    open.value = false
    reset()
}
/** è¡¨å•重置 */
function reset() {
    form.value = {
        deptId: undefined,
        parentId: undefined,
        deptName: undefined,
        orderNum: 0,
        leader: undefined,
        phone: undefined,
        email: undefined,
        status: "0",
        deptNick: undefined,
    }
    proxy.resetForm("deptRef")
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
    getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
    proxy.resetForm("queryRef")
    handleQuery()
}
/** æ–°å¢žæŒ‰é’®æ“ä½œ */
function handleAdd(row) {
    reset()
    listDept().then(response => {
        deptOptions.value = proxy.handleTree(response.data, "deptId")
    })
    if (row != undefined) {
        form.value.parentId = row.deptId
    }
    open.value = true
    title.value = "添加部门"
}
/** å±•å¼€/折叠操作 */
function toggleExpandAll() {
    refreshTable.value = false
    isExpandAll.value = !isExpandAll.value
    nextTick(() => {
        refreshTable.value = true
    })
}
/** ä¿®æ”¹æŒ‰é’®æ“ä½œ */
function handleUpdate(row) {
    reset()
    listDeptExcludeChild(row.deptId).then(response => {
        deptOptions.value = proxy.handleTree(response.data, "deptId")
    })
    getDept(row.deptId).then(response => {
        form.value = response.data
        open.value = true
        title.value = "修改部门"
    })
}
/** æäº¤æŒ‰é’® */
function submitForm() {
    proxy.$refs["deptRef"].validate(valid => {
        if (valid) {
            if (form.value.deptId != undefined) {
                updateDept(form.value).then(response => {
                    proxy.$modal.msgSuccess("修改成功")
                    open.value = false
                    getList()
                })
            } else {
                addDept(form.value).then(response => {
                    proxy.$modal.msgSuccess("新增成功")
                    open.value = false
                    getList()
                })
            }
        }
    })
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
    proxy.$modal.confirm('是否确认删除名称为"' + row.deptName + '"的数据项?').then(function() {
        return delDept(row.deptId)
    }).then(() => {
        getList()
        proxy.$modal.msgSuccess("删除成功")
    }).catch(() => {})
}
getList()
</script>
src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,315 @@
<!--OA模块:日志管理-->
<template>
  <div class="app-container">
     <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
        <el-form-item label="操作地址" prop="operIp">
           <el-input
              v-model="queryParams.operIp"
              placeholder="请输入操作地址"
              clearable
              style="width: 240px;"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="系统模块" prop="title">
           <el-input
              v-model="queryParams.title"
              placeholder="请输入系统模块"
              clearable
              style="width: 240px;"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="操作人员" prop="operName">
           <el-input
              v-model="queryParams.operName"
              placeholder="请输入操作人员"
              clearable
              style="width: 240px;"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="类型" prop="businessType">
           <el-select
              v-model="queryParams.businessType"
              placeholder="操作类型"
              clearable
              style="width: 240px"
           >
              <el-option
                 v-for="dict in sys_oper_type"
                 :key="dict.value"
                 :label="dict.label"
                 :value="dict.value"
              />
           </el-select>
        </el-form-item>
        <el-form-item label="状态" prop="status">
           <el-select
              v-model="queryParams.status"
              placeholder="操作状态"
              clearable
              style="width: 240px"
           >
              <el-option
                 v-for="dict in sys_common_status"
                 :key="dict.value"
                 :label="dict.label"
                 :value="dict.value"
              />
           </el-select>
        </el-form-item>
        <el-form-item label="操作时间" style="width: 308px">
           <el-date-picker
              v-model="dateRange"
              value-format="YYYY-MM-DD HH:mm:ss"
              type="daterange"
              range-separator="-"
              start-placeholder="开始日期"
              end-placeholder="结束日期"
              :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
           ></el-date-picker>
        </el-form-item>
        <el-form-item>
           <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
           <el-button icon="Refresh" @click="resetQuery">重置</el-button>
        </el-form-item>
     </el-form>
     <el-row :gutter="10" class="mb8">
        <el-col :span="1.5">
           <el-button
              type="danger"
              plain
              icon="Delete"
              :disabled="multiple"
              @click="handleDelete"
              v-hasPermi="['monitor:operlog:remove']"
           >删除</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="danger"
              plain
              icon="Delete"
              @click="handleClean"
              v-hasPermi="['monitor:operlog:remove']"
           >清空</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="warning"
              plain
              icon="Download"
              @click="handleExport"
              v-hasPermi="['monitor:operlog:export']"
           >导出</el-button>
        </el-col>
        <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
     <el-table ref="operlogRef" v-loading="loading" :data="operlogList" @selection-change="handleSelectionChange" :default-sort="defaultSort" @sort-change="handleSortChange">
        <el-table-column type="selection" width="50" align="center" />
        <el-table-column label="日志编号" align="center" prop="operId" />
        <el-table-column label="系统模块" align="center" prop="title" :show-overflow-tooltip="true" />
        <el-table-column label="操作类型" align="center" prop="businessType">
           <template #default="scope">
              <dict-tag :options="sys_oper_type" :value="scope.row.businessType" />
           </template>
        </el-table-column>
        <el-table-column label="操作人员" align="center" width="110" prop="operName" :show-overflow-tooltip="true" sortable="custom" :sort-orders="['descending', 'ascending']" />
        <el-table-column label="操作地址" align="center" prop="operIp" width="130" :show-overflow-tooltip="true" />
        <el-table-column label="操作状态" align="center" prop="status">
           <template #default="scope">
              <dict-tag :options="sys_common_status" :value="scope.row.status" />
           </template>
        </el-table-column>
        <el-table-column label="操作日期" align="center" prop="operTime" width="180" sortable="custom" :sort-orders="['descending', 'ascending']">
           <template #default="scope">
              <span>{{ parseTime(scope.row.operTime) }}</span>
           </template>
        </el-table-column>
        <el-table-column label="消耗时间" align="center" prop="costTime" width="110" :show-overflow-tooltip="true" sortable="custom" :sort-orders="['descending', 'ascending']">
           <template #default="scope">
              <span>{{ scope.row.costTime }}毫秒</span>
           </template>
        </el-table-column>
        <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
           <template #default="scope">
              <el-button link type="primary" icon="View" @click="handleView(scope.row, scope.index)" v-hasPermi="['monitor:operlog:query']">详细</el-button>
           </template>
        </el-table-column>
     </el-table>
     <pagination
        v-show="total > 0"
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
     />
     <!-- æ“ä½œæ—¥å¿—详细 -->
     <el-dialog title="操作日志详细" v-model="open" width="800px" append-to-body>
        <el-form :model="form" label-width="100px">
           <el-row>
              <el-col :span="12">
                 <el-form-item label="操作模块:">{{ form.title }} / {{ typeFormat(form) }}</el-form-item>
                 <el-form-item
                   label="登录信息:"
                 >{{ form.operName }} / {{ form.operIp }} / {{ form.operLocation }}</el-form-item>
              </el-col>
              <el-col :span="12">
                 <el-form-item label="请求地址:">{{ form.operUrl }}</el-form-item>
                 <el-form-item label="请求方式:">{{ form.requestMethod }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="操作方法:">{{ form.method }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="请求参数:">{{ form.operParam }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="返回参数:">{{ form.jsonResult }}</el-form-item>
              </el-col>
              <el-col :span="8">
                 <el-form-item label="操作状态:">
                    <div v-if="form.status === 0">正常</div>
                    <div v-else-if="form.status === 1">失败</div>
                 </el-form-item>
              </el-col>
              <el-col :span="8">
                 <el-form-item label="消耗时间:">{{ form.costTime }}毫秒</el-form-item>
              </el-col>
              <el-col :span="8">
                 <el-form-item label="操作时间:">{{ parseTime(form.operTime) }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="异常信息:" v-if="form.status === 1">{{ form.errorMsg }}</el-form-item>
              </el-col>
           </el-row>
        </el-form>
        <template #footer>
           <div class="dialog-footer">
              <el-button @click="open = false">关 é—­</el-button>
           </div>
        </template>
     </el-dialog>
  </div>
</template>
<script setup name="Operlog">
import { list, delOperlog, cleanOperlog } from "@/api/monitor/operlog"
import {onMounted} from "vue";
const { proxy } = getCurrentInstance()
const { sys_oper_type, sys_common_status } = proxy.useDict("sys_oper_type","sys_common_status")
const operlogList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const dateRange = ref([])
const defaultSort = ref({ prop: "operTime", order: "descending" })
const data = reactive({
 form: {},
 queryParams: {
   pageNum: 1,
   pageSize: 10,
   operIp: undefined,
   title: undefined,
   operName: undefined,
   businessType: undefined,
   status: undefined
 }
})
const { queryParams, form } = toRefs(data)
/** æŸ¥è¯¢ç™»å½•日志 */
function getList() {
 loading.value = true
 list(proxy.addDateRange(queryParams.value, dateRange.value)).then(response => {
   operlogList.value = response.rows
   total.value = response.total
   loading.value = false
 })
}
/** æ“ä½œæ—¥å¿—类型字典翻译 */
function typeFormat(row, column) {
 return proxy.selectDictLabel(sys_oper_type.value, row.businessType)
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
 queryParams.value.pageNum = 1
 getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
 dateRange.value = []
 proxy.resetForm("queryRef")
 queryParams.value.pageNum = 1
 proxy.$refs["operlogRef"].sort(defaultSort.value.prop, defaultSort.value.order)
}
/** å¤šé€‰æ¡†é€‰ä¸­æ•°æ® */
function handleSelectionChange(selection) {
 ids.value = selection.map(item => item.operId)
 multiple.value = !selection.length
}
/** æŽ’序触发事件 */
function handleSortChange(column, prop, order) {
 queryParams.value.orderByColumn = column.prop
 queryParams.value.isAsc = column.order
 getList()
}
/** è¯¦ç»†æŒ‰é’®æ“ä½œ */
function handleView(row) {
 open.value = true
 form.value = row
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
 const operIds = row.operId || ids.value
 proxy.$modal.confirm('是否确认删除日志编号为"' + operIds + '"的数据项?').then(function () {
   return delOperlog(operIds)
 }).then(() => {
   getList()
   proxy.$modal.msgSuccess("删除成功")
 }).catch(() => {})
}
/** æ¸…空按钮操作 */
function handleClean() {
 proxy.$modal.confirm("是否确认清空所有操作日志数据项?").then(function () {
   return cleanOperlog()
 }).then(() => {
   getList()
   proxy.$modal.msgSuccess("清空成功")
 }).catch(() => {})
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
function handleExport() {
 proxy.download("monitor/operlog/export",{
   ...queryParams.value,
 }, `config_${new Date().getTime()}.xlsx`)
}
onMounted(() => {
 getList();
});
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<template>
   <div class="app-container">
      <h4 class="form-header h4">基本信息</h4>
      <el-form :model="form" label-width="80px">
         <el-row>
            <el-col :span="8" :offset="2">
               <el-form-item label="用户昵称" prop="nickName">
                  <el-input v-model="form.nickName" disabled />
               </el-form-item>
            </el-col>
            <el-col :span="8" :offset="2">
               <el-form-item label="登录账号" prop="userName">
                  <el-input v-model="form.userName" disabled />
               </el-form-item>
            </el-col>
         </el-row>
      </el-form>
      <h4 class="form-header h4">角色信息</h4>
      <el-table v-loading="loading" :row-key="getRowKey" @row-click="clickRow" ref="roleRef" @selection-change="handleSelectionChange" :data="roles.slice((pageNum - 1) * pageSize, pageNum * pageSize)">
         <el-table-column label="序号" width="55" type="index" align="center">
            <template #default="scope">
               <span>{{ (pageNum - 1) * pageSize + scope.$index + 1 }}</span>
            </template>
         </el-table-column>
         <el-table-column type="selection" :reserve-selection="true" :selectable="checkSelectable" width="55"></el-table-column>
         <el-table-column label="角色编号" align="center" prop="roleId" />
         <el-table-column label="角色名称" align="center" prop="roleName" />
         <el-table-column label="权限字符" align="center" prop="roleKey" />
         <el-table-column label="创建时间" align="center" prop="createTime" width="180">
            <template #default="scope">
               <span>{{ parseTime(scope.row.createTime) }}</span>
            </template>
         </el-table-column>
      </el-table>
      <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize" />
      <el-form label-width="100px">
         <div style="text-align: center;margin-left:-120px;margin-top:30px;">
            <el-button type="primary" @click="submitForm()">提交</el-button>
            <el-button @click="close()">返回</el-button>
         </div>
      </el-form>
   </div>
</template>
<script setup name="AuthRole">
import { getAuthRole, updateAuthRole } from "@/api/system/user"
const route = useRoute()
const { proxy } = getCurrentInstance()
const loading = ref(true)
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const roleIds = ref([])
const roles = ref([])
const form = ref({
  nickName: undefined,
  userName: undefined,
  userId: undefined
})
/** å•击选中行数据 */
function clickRow(row) {
  if (checkSelectable(row)) {
    proxy.$refs["roleRef"].toggleRowSelection(row)
  }
}
/** å¤šé€‰æ¡†é€‰ä¸­æ•°æ® */
function handleSelectionChange(selection) {
  roleIds.value = selection.map(item => item.roleId)
}
/** ä¿å­˜é€‰ä¸­çš„æ•°æ®ç¼–号 */
function getRowKey(row) {
  return row.roleId
}
// æ£€æŸ¥è§’色状态
function checkSelectable(row) {
  return row.status === "0" ? true : false
}
/** å…³é—­æŒ‰é’® */
function close() {
  const obj = { path: "/system/user" }
  proxy.$tab.closeOpenPage(obj)
}
/** æäº¤æŒ‰é’® */
function submitForm() {
  const userId = form.value.userId
  const rIds = roleIds.value.join(",")
  updateAuthRole({ userId: userId, roleIds: rIds }).then(response => {
    proxy.$modal.msgSuccess("授权成功")
    close()
  })
}
(() => {
  const userId = route.params && route.params.userId
  if (userId) {
    loading.value = true
    getAuthRole(userId).then(response => {
      form.value = response.user
      roles.value = response.roles
      total.value = roles.value.length
      nextTick(() => {
        roles.value.forEach(row => {
          if (row.flag) {
            proxy.$refs["roleRef"].toggleRowSelection(row)
          }
        })
      })
      loading.value = false
    })
  }
})()
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,550 @@
<!--OA模块:用户管理-->
<template>
    <div class="app-container">
        <el-row :gutter="20" style="height: calc(100vh - 8em)">
            <splitpanes :horizontal="appStore.device === 'mobile'" class="default-theme">
                <!--部门数据-->
                <pane size="16">
                    <el-col style="padding: 10px">
                        <div class="head-container">
                            <el-input v-model="deptNames" placeholder="请输入部门名称" clearable prefix-icon="Search" style="margin-bottom: 20px" />
                        </div>
                        <div class="head-container">
                            <el-tree :data="deptOptions" :props="{ label: 'label', children: 'children' }" :expand-on-click-node="false" :filter-node-method="filterNode" ref="deptTreeRef" node-key="id" highlight-current default-expand-all @node-click="handleNodeClick" />
                        </div>
                    </el-col>
                </pane>
                <!--用户数据-->
                <pane size="84">
                    <el-col style="padding: 10px; height: 100%; display: flex; flex-direction: column;">
                        <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
                            <el-form-item label="登录账号" prop="userName">
                                <el-input v-model="queryParams.userName" placeholder="请输入登录账号" clearable style="width: 240px" @keyup.enter="handleQuery" />
                            </el-form-item>
                            <el-form-item label="手机号码" prop="phonenumber">
                                <el-input v-model="queryParams.phonenumber" placeholder="请输入手机号码" clearable style="width: 240px" @keyup.enter="handleQuery" />
                            </el-form-item>
                            <el-form-item label="状态" prop="status">
                                <el-select v-model="queryParams.status" placeholder="用户状态" clearable style="width: 240px">
                                    <el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
                                </el-select>
                            </el-form-item>
                            <el-form-item label="创建时间" style="width: 308px">
                                <el-date-picker v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期"></el-date-picker>
                            </el-form-item>
                            <el-form-item>
                                <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
                                <el-button icon="Refresh" @click="resetQuery">重置</el-button>
                            </el-form-item>
                        </el-form>
                        <el-row :gutter="10" class="mb8">
                            <el-col :span="1.5">
                                <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['system:user:add']">新增</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['system:user:edit']">修改</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['system:user:remove']">删除</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="info" plain icon="Upload" @click="handleImport" v-hasPermi="['system:user:import']">导入</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['system:user:export']">导出</el-button>
                            </el-col>
                            <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
                        </el-row>
                        <div style="flex: 1; overflow: hidden;">
                            <el-table v-loading="loading" :data="userList" height="100%" @selection-change="handleSelectionChange">
                                <el-table-column type="selection" width="50" align="center" />
                                <el-table-column label="用户编号" align="center" key="userId" prop="userId" v-if="columns[0].visible" />
                                <el-table-column label="登录账号" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" />
                                <el-table-column label="用户昵称" align="center" key="nickName" prop="nickName" v-if="columns[2].visible" :show-overflow-tooltip="true" />
                                <el-table-column label="部门" align="center" key="deptNames" prop="deptNames" v-if="columns[3].visible" :show-overflow-tooltip="true" />
                                <el-table-column label="手机号码" align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" />
                                <el-table-column label="状态" align="center" key="status" v-if="columns[5].visible">
                                    <template #default="scope">
                                        <el-switch
                                            v-model="scope.row.status"
                                            active-value="0"
                                            inactive-value="1"
                                            @change="handleStatusChange(scope.row)"
                                        ></el-switch>
                                    </template>
                                </el-table-column>
                                <el-table-column label="创建时间" align="center" prop="createTime" v-if="columns[6].visible" width="160">
                                    <template #default="scope">
                                        <span>{{ parseTime(scope.row.createTime) }}</span>
                                    </template>
                                </el-table-column>
                                <el-table-column label="操作" align="center" width="150" class-name="small-padding fixed-width">
                                    <template #default="scope">
                                        <el-tooltip content="修改" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:user:edit']"></el-button>
                                        </el-tooltip>
                                        <el-tooltip content="删除" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:user:remove']"></el-button>
                                        </el-tooltip>
                                        <el-tooltip content="重置密码" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="Key" @click="handleResetPwd(scope.row)" v-hasPermi="['system:user:resetPwd']"></el-button>
                                        </el-tooltip>
                                        <el-tooltip content="分配角色" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="CircleCheck" @click="handleAuthRole(scope.row)" v-hasPermi="['system:user:edit']"></el-button>
                                        </el-tooltip>
                                    </template>
                                </el-table-column>
                            </el-table>
                        </div>
                        <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
                    </el-col>
                </pane>
            </splitpanes>
        </el-row>
        <!-- æ·»åŠ æˆ–ä¿®æ”¹ç”¨æˆ·é…ç½®å¯¹è¯æ¡† -->
        <el-dialog :title="title" v-model="open" width="600px" append-to-body>
            <el-form :model="form" :rules="rules" ref="userRef" label-width="80px">
                <el-row>
                    <el-col :span="12">
                        <el-form-item v-if="form.userId == undefined" label="登录账号" prop="userName">
                            <el-input v-model="form.userName" placeholder="请输入用户名称" maxlength="30" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item v-if="form.userId == undefined" label="用户密码" prop="password">
                            <el-input v-model="form.password" placeholder="请输入用户密码" type="password" maxlength="20" show-password />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="用户昵称" prop="nickName">
                            <el-input v-model="form.nickName" placeholder="请输入用户昵称" maxlength="30" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="归属部门" prop="deptId">
                            <el-tree-select v-model="form.deptId" :data="enabledDeptOptions" :props="{ value: 'id', label: 'label', children: 'children' }" value-key="id" placeholder="请选择归属部门" check-strictly />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="岗位" prop="postIds">
                            <el-select v-model="form.postIds" multiple placeholder="请选择">
                                <el-option v-for="item in postOptions" :key="item.postId" :label="item.postName" :value="item.postId" :disabled="item.status == 1"></el-option>
                            </el-select>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="角色" prop="roleIds">
                            <el-select v-model="form.roleIds" multiple placeholder="请选择">
                                <el-option v-for="item in roleOptions" :key="item.roleId" :label="item.roleName" :value="item.roleId" :disabled="item.status == 1"></el-option>
                            </el-select>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="手机号码" prop="phonenumber">
                            <el-input v-model="form.phonenumber" placeholder="请输入手机号码" maxlength="11" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="邮箱" prop="email">
                            <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="用户性别">
                            <el-select v-model="form.sex" placeholder="请选择">
                                <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label" :value="dict.value"></el-option>
                            </el-select>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="状态">
                            <el-radio-group v-model="form.status">
                                <el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
                            </el-radio-group>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="24">
                        <el-form-item label="备注">
                            <el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
                        </el-form-item>
                    </el-col>
                </el-row>
            </el-form>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
                    <el-button @click="cancel">取 æ¶ˆ</el-button>
                </div>
            </template>
        </el-dialog>
        <!-- ç”¨æˆ·å¯¼å…¥å¯¹è¯æ¡† -->
        <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body>
            <el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url + '?updateSupport=' + upload.updateSupport" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag>
                <el-icon class="el-icon--upload"><upload-filled /></el-icon>
                <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
                <template #tip>
                    <div class="el-upload__tip text-center">
                        <div class="el-upload__tip">
                            <el-checkbox v-model="upload.updateSupport" />是否更新已经存在的用户数据
                        </div>
                        <span>仅允许导入xls、xlsx格式文件。</span>
                        <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline" @click="importTemplate">下载模板</el-link>
                    </div>
                </template>
            </el-upload>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="primary" @click="submitFileForm">ç¡® å®š</el-button>
                    <el-button @click="upload.open = false">取 æ¶ˆ</el-button>
                </div>
            </template>
        </el-dialog>
    </div>
</template>
<script setup name="User">
import { getToken } from "@/utils/auth"
import useAppStore from '@/store/modules/app'
import { changeUserStatus, listUser, resetUserPwd, delUser, getUser, updateUser, addUser, deptTreeSelect } from "@/api/system/user"
import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
const router = useRouter()
const appStore = useAppStore()
const { proxy } = getCurrentInstance()
const { sys_normal_disable, sys_user_sex } = proxy.useDict("sys_normal_disable", "sys_user_sex")
const userList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const dateRange = ref([])
const deptNames = ref("")
const deptOptions = ref(undefined)
const enabledDeptOptions = ref(undefined)
const initPassword = ref(undefined)
const postOptions = ref([])
const roleOptions = ref([])
/*** ç”¨æˆ·å¯¼å…¥å‚æ•° */
const upload = reactive({
    // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚(用户导入)
    open: false,
    // å¼¹å‡ºå±‚标题(用户导入)
    title: "",
    // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
    isUploading: false,
    // æ˜¯å¦æ›´æ–°å·²ç»å­˜åœ¨çš„用户数据
    updateSupport: 0,
    // è®¾ç½®ä¸Šä¼ çš„请求头部
    headers: { Authorization: "Bearer " + getToken() },
    // ä¸Šä¼ çš„地址
    url: import.meta.env.VITE_APP_BASE_API + "/system/user/importData"
})
// åˆ—显隐信息
const columns = ref([
    { key: 0, label: `用户编号`, visible: true },
    { key: 1, label: `登录账号`, visible: true },
    { key: 2, label: `用户昵称`, visible: true },
    { key: 3, label: `部门`, visible: true },
    { key: 4, label: `手机号码`, visible: true },
    { key: 5, label: `状态`, visible: true },
    { key: 6, label: `创建时间`, visible: true }
])
const data = reactive({
    form: {},
    queryParams: {
        pageNum: 1,
        pageSize: 10,
        userName: undefined,
        phonenumber: undefined,
        status: undefined,
        deptId: undefined
    },
    rules: {
        userName: [{ required: true, message: "用户名称不能为空", trigger: "blur" }, { min: 2, max: 20, message: "用户名称长度必须介于 2 å’Œ 20 ä¹‹é—´", trigger: "blur" }],
        nickName: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
        password: [{ required: true, message: "用户密码不能为空", trigger: "blur" }, { min: 5, max: 20, message: "用户密码长度必须介于 5 å’Œ 20 ä¹‹é—´", trigger: "blur" }, { pattern: /^[^<>"'|\\]+$/, message: "不能包含非法字符:< > \" ' \\\ |", trigger: "blur" }],
        email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: ["blur", "change"] }],
        phonenumber: [{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }],
        deptId: [{ required: true, message: "归属部门不能为空", trigger: "change" }],
        postIds: [{ required: true, message: "岗位不能为空", trigger: "change" }],
        roleIds: [{ required: true, message: "角色不能为空", trigger: "change" }]
    }
})
const { queryParams, form, rules } = toRefs(data)
/** é€šè¿‡æ¡ä»¶è¿‡æ»¤èŠ‚ç‚¹  */
const filterNode = (value, data) => {
    if (!value) return true
    return data.label.indexOf(value) !== -1
}
/** æ ¹æ®åç§°ç­›é€‰éƒ¨é—¨æ ‘ */
watch(deptNames, val => {
    proxy.$refs["deptTreeRef"].filter(val)
})
/** æŸ¥è¯¢ç”¨æˆ·åˆ—表 */
function getList() {
    loading.value = true
    listUser(proxy.addDateRange(queryParams.value, dateRange.value)).then(res => {
        loading.value = false
        userList.value = res.rows
        total.value = res.total
    })
}
/** æŸ¥è¯¢éƒ¨é—¨ä¸‹æ‹‰æ ‘结构 */
function getDeptTree() {
    deptTreeSelect().then(response => {
        deptOptions.value = response.data
        enabledDeptOptions.value = filterDisabledDept(JSON.parse(JSON.stringify(response.data)))
    })
}
/** è¿‡æ»¤ç¦ç”¨çš„部门 */
function filterDisabledDept(deptList) {
    return deptList.filter(dept => {
        if (dept.disabled) {
            return false
        }
        if (dept.children && dept.children.length) {
            dept.children = filterDisabledDept(dept.children)
        }
        return true
    })
}
/** èŠ‚ç‚¹å•å‡»äº‹ä»¶ */
function handleNodeClick(data) {
    queryParams.value.deptId = data.id
    handleQuery()
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
    queryParams.value.pageNum = 1
    getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
    dateRange.value = []
    proxy.resetForm("queryRef")
    queryParams.value.deptId = undefined
    proxy.$refs.deptTreeRef.setCurrentKey(null)
    handleQuery()
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
    const userIds = row.userId || ids.value
    proxy.$modal.confirm('是否确认删除用户编号为"' + userIds + '"的数据项?').then(function () {
        return delUser(userIds)
    }).then(() => {
        getList()
        proxy.$modal.msgSuccess("删除成功")
    }).catch(() => {})
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
function handleExport() {
    proxy.download("system/user/export", {
        ...queryParams.value,
    },`user_${new Date().getTime()}.xlsx`)
}
/** ç”¨æˆ·çŠ¶æ€ä¿®æ”¹  */
function handleStatusChange(row) {
    let text = row.status === "0" ? "启用" : "停用"
    proxy.$modal.confirm('确认要"' + text + '""' + row.userName + '"用户吗?').then(function () {
        return changeUserStatus(row.userId, row.status)
    }).then(() => {
        proxy.$modal.msgSuccess(text + "成功")
    }).catch(function () {
        row.status = row.status === "0" ? "1" : "0"
    })
}
/** æ›´å¤šæ“ä½œ */
function handleCommand(command, row) {
    switch (command) {
        case "handleResetPwd":
            handleResetPwd(row)
            break
        case "handleAuthRole":
            handleAuthRole(row)
            break
        default:
            break
    }
}
/** è·³è½¬è§’色分配 */
function handleAuthRole(row) {
    const userId = row.userId
    router.push("/system/user-auth/role/" + userId)
}
/** é‡ç½®å¯†ç æŒ‰é’®æ“ä½œ */
function handleResetPwd(row) {
    proxy.$prompt('请输入"' + row.userName + '"的新密码', "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        closeOnClickModal: false,
        inputPattern: /^.{5,20}$/,
        inputErrorMessage: "用户密码长度必须介于 5 å’Œ 20 ä¹‹é—´",
        inputValidator: (value) => {
            if (/<|>|"|'|\||\\/.test(value)) {
                return "不能包含非法字符:< > \" ' \\\ |"
            }
        },
    }).then(({ value }) => {
        resetUserPwd(row.userId, value).then(response => {
            proxy.$modal.msgSuccess("修改成功,新密码是:" + value)
        })
    }).catch(() => {})
}
/** é€‰æ‹©æ¡æ•°  */
function handleSelectionChange(selection) {
    ids.value = selection.map(item => item.userId)
    single.value = selection.length != 1
    multiple.value = !selection.length
}
/** å¯¼å…¥æŒ‰é’®æ“ä½œ */
function handleImport() {
    upload.title = "用户导入"
    upload.open = true
}
/** ä¸‹è½½æ¨¡æ¿æ“ä½œ */
function importTemplate() {
    proxy.download("system/user/importTemplate", {
    }, `user_template_${new Date().getTime()}.xlsx`)
}
/**文件上传中处理 */
const handleFileUploadProgress = (event, file, fileList) => {
    upload.isUploading = true
}
/** æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç† */
const handleFileSuccess = (response, file, fileList) => {
    upload.open = false
    upload.isUploading = false
    proxy.$refs["uploadRef"].handleRemove(file)
    proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true })
    getList()
}
/** æäº¤ä¸Šä¼ æ–‡ä»¶ */
function submitFileForm() {
    proxy.$refs["uploadRef"].submit()
}
/** é‡ç½®æ“ä½œè¡¨å• */
function reset() {
    form.value = {
        userId: undefined,
        deptId: undefined,
        userName: undefined,
        nickName: undefined,
        password: undefined,
        phonenumber: undefined,
        email: undefined,
        sex: undefined,
        status: "0",
        remark: undefined,
        postIds: [],
        roleIds: []
    }
    proxy.resetForm("userRef")
}
/** å–消按钮 */
function cancel() {
    open.value = false
    reset()
}
/** æ–°å¢žæŒ‰é’®æ“ä½œ */
function handleAdd() {
    reset()
    getUser().then(response => {
        postOptions.value = response.posts
        roleOptions.value = response.roles
        open.value = true
        title.value = "添加用户"
        form.value.password = initPassword.value
    })
}
/** ä¿®æ”¹æŒ‰é’®æ“ä½œ */
function handleUpdate(row) {
    reset()
    const userId = row.userId || ids.value
    getUser(userId).then(response => {
        form.value = response.data
        postOptions.value = response.posts
        roleOptions.value = response.roles
        form.value.postIds = response.postIds
        form.value.roleIds = response.roleIds
        open.value = true
        title.value = "修改用户"
        form.password = ""
    })
}
/** æäº¤æŒ‰é’® */
function submitForm() {
    proxy.$refs["userRef"].validate(valid => {
        if (valid) {
            // å½’属部门虽然是单选,但后端需要传数组字段 deptIds
            const payload = {
                ...form.value,
                deptIds: form.value.deptId ? [form.value.deptId] : []
            }
            if (form.value.userId != undefined) {
                updateUser(payload).then(response => {
                    proxy.$modal.msgSuccess("修改成功")
                    open.value = false
                    getList()
                })
            } else {
                addUser(payload).then(response => {
                    proxy.$modal.msgSuccess("新增成功")
                    open.value = false
                    getList()
                })
            }
        }
    })
}
getDeptTree()
getList()
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
<template>
   <div class="app-container">
      <el-row :gutter="20">
         <el-col :span="6" :xs="24">
            <el-card class="box-card">
               <template v-slot:header>
                 <div class="clearfix">
                   <span>个人信息</span>
                 </div>
               </template>
               <div>
                  <div class="text-center">
                     <userAvatar />
                  </div>
                  <ul class="list-group list-group-striped">
                     <li class="list-group-item">
                        <svg-icon icon-class="user" />用户名称
                        <div class="pull-right">{{ state.user.userName }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="phone" />手机号码
                        <div class="pull-right">{{ state.user.phonenumber }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="email" />用户邮箱
                        <div class="pull-right">{{ state.user.email }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="tree" />所属部门
                        <div class="pull-right" v-if="state.user.dept">{{ state.user.dept.deptName }} / {{ state.postGroup }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="peoples" />所属角色
                        <div class="pull-right">{{ state.roleGroup }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="date" />创建日期
                        <div class="pull-right">{{ state.user.createTime }}</div>
                     </li>
                  </ul>
               </div>
            </el-card>
         </el-col>
         <el-col :span="18" :xs="24">
            <el-card>
               <template v-slot:header>
                 <div class="clearfix">
                   <span>基本资料</span>
                 </div>
               </template>
               <el-tabs v-model="activeTab">
                  <el-tab-pane label="基本资料" name="userinfo">
                     <userInfo :user="state.user" />
                  </el-tab-pane>
                  <el-tab-pane label="修改密码" name="resetPwd">
                     <resetPwd />
                  </el-tab-pane>
               </el-tabs>
            </el-card>
         </el-col>
      </el-row>
   </div>
</template>
<script setup name="Profile">
import userAvatar from "./userAvatar"
import userInfo from "./userInfo"
import resetPwd from "./resetPwd"
import { getUserProfile } from "@/api/system/user"
const activeTab = ref("userinfo")
const state = reactive({
  user: {},
  roleGroup: {},
  postGroup: {}
})
function getUser() {
  getUserProfile().then(response => {
    state.user = response.data
    state.roleGroup = response.roleGroup
    state.postGroup = response.postGroup
  })
}
getUser()
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
<template>
   <el-form ref="pwdRef" :model="user" :rules="rules" label-width="80px">
      <el-form-item label="旧密码" prop="oldPassword">
         <el-input v-model="user.oldPassword" placeholder="请输入旧密码" type="password" show-password />
      </el-form-item>
      <el-form-item label="新密码" prop="newPassword">
         <el-input v-model="user.newPassword" placeholder="请输入新密码" type="password" show-password />
      </el-form-item>
      <el-form-item label="确认密码" prop="confirmPassword">
         <el-input v-model="user.confirmPassword" placeholder="请确认新密码" type="password" show-password/>
      </el-form-item>
      <el-form-item>
      <el-button type="primary" @click="submit">保存</el-button>
      <el-button type="danger" @click="close">关闭</el-button>
      </el-form-item>
   </el-form>
</template>
<script setup>
import { updateUserPwd } from "@/api/system/user"
const { proxy } = getCurrentInstance()
const user = reactive({
  oldPassword: undefined,
  newPassword: undefined,
  confirmPassword: undefined
})
const equalToPassword = (rule, value, callback) => {
  if (user.newPassword !== value) {
    callback(new Error("两次输入的密码不一致"))
  } else {
    callback()
  }
}
const rules = ref({
  oldPassword: [{ required: true, message: "旧密码不能为空", trigger: "blur" }],
  newPassword: [{ required: true, message: "新密码不能为空", trigger: "blur" }, { min: 6, max: 20, message: "长度在 6 åˆ° 20 ä¸ªå­—符", trigger: "blur" }, { pattern: /^[^<>"'|\\]+$/, message: "不能包含非法字符:< > \" ' \\\ |", trigger: "blur" }],
  confirmPassword: [{ required: true, message: "确认密码不能为空", trigger: "blur" }, { required: true, validator: equalToPassword, trigger: "blur" }]
})
/** æäº¤æŒ‰é’® */
function submit() {
  proxy.$refs.pwdRef.validate(valid => {
    if (valid) {
      updateUserPwd(user.oldPassword, user.newPassword).then(response => {
        proxy.$modal.msgSuccess("修改成功")
      })
    }
  })
}
/** å…³é—­æŒ‰é’® */
function close() {
  proxy.$tab.closePage()
}
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,168 @@
<template>
  <div class="user-info-head" @click="editCropper()">
    <img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
    <el-dialog :title="title" v-model="open" width="800px" append-to-body @opened="modalOpened" @close="closeDialog">
      <el-row>
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <vue-cropper ref="cropper" :img="options.img" :info="true" :autoCrop="options.autoCrop"
            :autoCropWidth="options.autoCropWidth" :autoCropHeight="options.autoCropHeight" :fixedBox="options.fixedBox"
            :outputType="options.outputType" @realTime="realTime" v-if="visible" />
        </el-col>
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <div class="avatar-upload-preview">
            <img :src="options.previews.url" :style="options.previews.img" />
          </div>
        </el-col>
      </el-row>
      <br />
      <el-row>
        <el-col :lg="2" :md="2">
          <el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
            <el-button>
              é€‰æ‹©
              <el-icon class="el-icon--right">
                <Upload />
              </el-icon>
            </el-button>
          </el-upload>
        </el-col>
        <el-col :lg="{ span: 1, offset: 2 }" :md="2">
          <el-button icon="Plus" @click="changeScale(1)"></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
          <el-button icon="Minus" @click="changeScale(-1)"></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
          <el-button icon="RefreshLeft" @click="rotateLeft()"></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
          <el-button icon="RefreshRight" @click="rotateRight()"></el-button>
        </el-col>
        <el-col :lg="{ span: 2, offset: 6 }" :md="2">
          <el-button type="primary" @click="uploadImg()">提 äº¤</el-button>
        </el-col>
      </el-row>
    </el-dialog>
  </div>
</template>
<script setup>
import "vue-cropper/dist/index.css"
import { VueCropper } from "vue-cropper"
import { uploadAvatar } from "@/api/system/user"
import useUserStore from "@/store/modules/user"
const userStore = useUserStore()
const { proxy } = getCurrentInstance()
const open = ref(false)
const visible = ref(false)
const title = ref("修改头像")
//图片裁剪数据
const options = reactive({
  img: userStore.avatar,     // è£å‰ªå›¾ç‰‡çš„地址
  autoCrop: true,            // æ˜¯å¦é»˜è®¤ç”Ÿæˆæˆªå›¾æ¡†
  autoCropWidth: 200,        // é»˜è®¤ç”Ÿæˆæˆªå›¾æ¡†å®½åº¦
  autoCropHeight: 200,       // é»˜è®¤ç”Ÿæˆæˆªå›¾æ¡†é«˜åº¦
  fixedBox: true,            // å›ºå®šæˆªå›¾æ¡†å¤§å° ä¸å…è®¸æ”¹å˜
  outputType: "png",         // é»˜è®¤ç”Ÿæˆæˆªå›¾ä¸ºPNG格式
  filename: 'avatar',        // æ–‡ä»¶åç§°
  previews: {}               //预览数据
})
/** ç¼–辑头像 */
function editCropper() {
  open.value = true
}
/** æ‰“开弹出层结束时的回调 */
function modalOpened() {
  visible.value = true
}
/** è¦†ç›–默认上传行为 */
function requestUpload() { }
/** å‘左旋转 */
function rotateLeft() {
  proxy.$refs.cropper.rotateLeft()
}
/** å‘右旋转 */
function rotateRight() {
  proxy.$refs.cropper.rotateRight()
}
/** å›¾ç‰‡ç¼©æ”¾ */
function changeScale(num) {
  num = num || 1
  proxy.$refs.cropper.changeScale(num)
}
/** ä¸Šä¼ é¢„处理 */
function beforeUpload(file) {
  if (file.type.indexOf("image/") == -1) {
    proxy.$modal.msgError("文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。")
  } else {
    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = () => {
      options.img = reader.result
      options.filename = file.name
    }
  }
}
/** ä¸Šä¼ å›¾ç‰‡ */
function uploadImg() {
  proxy.$refs.cropper.getCropBlob(data => {
    let formData = new FormData()
    formData.append("avatarfile", data, options.filename)
    uploadAvatar(formData).then(response => {
      open.value = false
      options.img = import.meta.env.VITE_APP_BASE_API + '/profile/' + response.imgUrl
      userStore.avatar = options.img
      proxy.$modal.msgSuccess("修改成功")
      visible.value = false
    })
  })
}
/** å®žæ—¶é¢„览 */
function realTime(data) {
  options.previews = data
}
/** å…³é—­çª—口 */
function closeDialog() {
  options.img = userStore.avatar
  options.visible = false
}
</script>
<style lang='scss' scoped>
.user-info-head {
  position: relative;
  display: inline-block;
  height: 120px;
}
.user-info-head:hover:after {
  content: "+";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  color: #eee;
  background: rgba(0, 0, 0, 0.5);
  font-size: 24px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  cursor: pointer;
  line-height: 110px;
  border-radius: 50%;
}
</style>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
<template>
   <el-form ref="userRef" :model="form" :rules="rules" label-width="80px">
      <el-form-item label="用户昵称" prop="nickName">
         <el-input v-model="form.nickName" maxlength="30" />
      </el-form-item>
      <el-form-item label="手机号码" prop="phonenumber">
         <el-input v-model="form.phonenumber" maxlength="11" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
         <el-input v-model="form.email" maxlength="50" />
      </el-form-item>
      <el-form-item label="性别">
         <el-radio-group v-model="form.sex">
            <el-radio value="0">男</el-radio>
            <el-radio value="1">女</el-radio>
         </el-radio-group>
      </el-form-item>
      <el-form-item>
      <el-button type="primary" @click="submit">保存</el-button>
      <el-button type="danger" @click="close">关闭</el-button>
      </el-form-item>
   </el-form>
</template>
<script setup>
import { updateUserProfile } from "@/api/system/user"
const props = defineProps({
  user: {
    type: Object
  }
})
const { proxy } = getCurrentInstance()
const form = ref({})
const rules = ref({
  nickName: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
  email: [{ required: true, message: "邮箱地址不能为空", trigger: "blur" }, { type: "email", message: "请输入正确的邮箱地址", trigger: ["blur", "change"] }],
  phonenumber: [{ required: true, message: "手机号码不能为空", trigger: "blur" }, { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }],
})
/** æäº¤æŒ‰é’® */
function submit() {
  proxy.$refs.userRef.validate(valid => {
    if (valid) {
      updateUserProfile(form.value).then(response => {
        proxy.$modal.msgSuccess("修改成功")
        props.user.phonenumber = form.value.phonenumber
        props.user.email = form.value.email
      })
    }
  })
}
/** å…³é—­æŒ‰é’® */
function close() {
  proxy.$tab.closePage()
}
// å›žæ˜¾å½“前登录用户信息
watch(() => props.user, user => {
  if (user) {
    form.value = { nickName: user.nickName, phonenumber: user.phonenumber, email: user.email, sex: user.sex }
  }
},{ immediate: true })
</script>
src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
<!--OA模块:缓存监控-->
<template>
  <div class="app-container">
    <el-row :gutter="10">
      <el-col :span="24" class="card-box">
        <el-card>
          <template #header><Monitor style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">基本信息</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%">
              <tbody>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">Redis版本</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_version }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">运行模式</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_mode == "standalone" ? "单机" : "集群" }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">端口</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.tcp_port }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">客户端数</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.connected_clients }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">运行时间(天)</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.uptime_in_days }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">使用内存</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.used_memory_human }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">使用CPU</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ parseFloat(cache.info.used_cpu_user_children).toFixed(2) }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">内存配置</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.maxmemory_human }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">AOF是否开启</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.aof_enabled == "0" ? "否" : "是" }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">RDB是否成功</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.rdb_last_bgsave_status }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">Key数量</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.dbSize">{{ cache.dbSize }} </div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">网络入口/出口</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.instantaneous_input_kbps }}kps/{{cache.info.instantaneous_output_kbps}}kps</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
      <el-col :span="12" class="card-box">
        <el-card>
          <template #header><PieChart style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">命令统计</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <div ref="commandstats" style="height: 420px" />
          </div>
        </el-card>
      </el-col>
      <el-col :span="12" class="card-box">
        <el-card>
          <template #header><Odometer style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">内存信息</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <div ref="usedmemory" style="height: 420px" />
          </div>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>
<script setup name="Cache">
import { getCache } from '@/api/monitor/cache'
import * as echarts from 'echarts'
const cache = ref([])
const commandstats = ref(null)
const usedmemory = ref(null)
const { proxy } = getCurrentInstance()
function getList() {
  proxy.$modal.loading("正在加载缓存监控数据,请稍候!")
  getCache().then(response => {
    proxy.$modal.closeLoading()
    cache.value = response.data
    const commandstatsIntance = echarts.init(commandstats.value, "macarons")
    commandstatsIntance.setOption({
      tooltip: {
        trigger: "item",
        formatter: "{a} <br/>{b} : {c} ({d}%)"
      },
      series: [
        {
          name: "命令",
          type: "pie",
          roseType: "radius",
          radius: [15, 95],
          center: ["50%", "38%"],
          data: response.data.commandStats,
          animationEasing: "cubicInOut",
          animationDuration: 1000
        }
      ]
    })
    const usedmemoryInstance = echarts.init(usedmemory.value, "macarons")
    usedmemoryInstance.setOption({
      tooltip: {
        formatter: "{b} <br/>{a} : " + cache.value.info.used_memory_human
      },
      series: [
        {
          name: "峰值",
          type: "gauge",
          min: 0,
          max: 1000,
          detail: {
            formatter: cache.value.info.used_memory_human
          },
          data: [
            {
              value: parseFloat(cache.value.info.used_memory_human),
              name: "内存消耗"
            }
          ]
        }
      ]
    })
    window.addEventListener("resize", () => {
      commandstatsIntance.resize()
      usedmemoryInstance.resize()
    })
  })
}
getList()
</script>
在上述文件截断后对比
src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue src/views/personnelManagement/contractManagement/index.vue