From 61b9452f138841d453bf4b2503d78c2aaf2e4394 Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期六, 23 五月 2026 17:13:33 +0800
Subject: [PATCH] Merge branch 'dev-new_pro_OA' into dev_NEW_pro
---
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue | 857 ++
src/api/basicData/enum.js | 8
src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue | 176
src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue | 347 +
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js | 148
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js | 904 ++
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js | 124
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue | 85
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue | 12
src/views/personnelManagement/contractManagement/index.vue | 2
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue | 260
src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue | 134
src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue | 12
src/views/officeProcessAutomation/HrManage/staff-contract/index.vue | 296
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js | 634 +
src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue | 69
src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue | 315
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue | 566 +
src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue | 96
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue | 74
src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue | 304
src/views/officeProcessAutomation/HrManage/staff-archive/index.vue | 360 +
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js | 91
src/views/officeProcessAutomation/HrManage/resign-apply/index.vue | 220
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js | 207
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue | 59
src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue | 191
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNewsList.js | 55
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js | 160
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js | 301
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue | 360 +
src/api/officeProcessAutomation/approvalTemplate.js | 58
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js | 628 +
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue | 325
src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js | 45
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js | 136
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue | 49
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js | 671 +
src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue | 147
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue | 168
src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue | 123
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue | 123
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue | 112
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue | 67
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue | 122
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue | 399 +
src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue | 291
src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue | 141
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js | 189
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js | 11
src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js | 140
src/views/officeProcessAutomation/HrManage/work-handover/index.vue | 249
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue | 174
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js | 154
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue | 347 +
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsMappers.js | 221
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue | 550 +
src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue | 115
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue | 82
src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue | 263
src/api/officeProcessAutomation/finReimbursement.js | 71
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue | 613 +
src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue | 181
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue | 152
src/api/officeProcessAutomation/enterpriseNews.js | 38
src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue | 197
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue | 614 +
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue | 87
src/views/financialManagement/receivable/invoiceApply.vue | 1
src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue | 12
src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js | 35
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js | 334
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js | 408 +
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js | 355 +
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js | 696 ++
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js | 313
src/views/officeProcessAutomation/HrManage/post-manage/index.vue | 292
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue | 169
src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue | 550 +
src/api/officeProcessAutomation/approvalInstance.js | 47
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js | 259
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue | 176
src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue | 14
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue | 85
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue | 70
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue | 819 ++
86 files changed, 20,114 insertions(+), 1 deletions(-)
diff --git a/src/api/basicData/enum.js b/src/api/basicData/enum.js
index 350df17..9e8b503 100644
--- a/src/api/basicData/enum.js
+++ b/src/api/basicData/enum.js
@@ -1,5 +1,13 @@
import request from "@/utils/request.js";
+/** 瀹℃壒妯℃澘绫诲瀷绛夐�氱敤鏋氫妇锛圱ypeEnums锛� */
+export function getTypeEnums() {
+ return request({
+ url: '/basic/enum/TypeEnums',
+ method: 'get'
+ })
+}
+
export function findAllStockRecordTypeOptions() {
return request({
url: '/basic/enum/stockRecordType',
diff --git a/src/api/officeProcessAutomation/approvalInstance.js b/src/api/officeProcessAutomation/approvalInstance.js
new file mode 100644
index 0000000..054861c
--- /dev/null
+++ b/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,
+ });
+}
+
+/** 鍒犻櫎瀹℃壒瀹炰緥锛坆ody 涓� 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,
+ });
+}
diff --git a/src/api/officeProcessAutomation/approvalTemplate.js b/src/api/officeProcessAutomation/approvalTemplate.js
new file mode 100644
index 0000000..3ade018
--- /dev/null
+++ b/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",
+ });
+}
+
+/** 鏂板瀹℃壒妯℃澘锛坆ody 涓� ApprovalTemplateDto锛� */
+export function addApprovalTemplate(approvalTemplateDto) {
+ return request({
+ url: "/approvalTemplate/add",
+ method: "post",
+ data: approvalTemplateDto,
+ });
+}
+
+/** 淇敼瀹℃壒妯℃澘锛坆ody 涓� ApprovalTemplateDto锛� */
+export function updateApprovalTemplate(approvalTemplateDto) {
+ return request({
+ url: "/approvalTemplate/update",
+ method: "put",
+ data: approvalTemplateDto,
+ });
+}
+
+/** 鍒犻櫎瀹℃壒妯℃澘锛坆ody 涓烘ā鏉� 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,
+ });
+}
diff --git a/src/api/officeProcessAutomation/enterpriseNews.js b/src/api/officeProcessAutomation/enterpriseNews.js
new file mode 100644
index 0000000..52f345d
--- /dev/null
+++ b/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,
+ });
+}
+
+/** 鍒犻櫎浼佷笟鏂伴椈锛坆ody 涓� 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,
+ });
+}
diff --git a/src/api/officeProcessAutomation/finReimbursement.js b/src/api/officeProcessAutomation/finReimbursement.js
new file mode 100644
index 0000000..84c3560
--- /dev/null
+++ b/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锛歋pring 缁戝畾 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锛坆ody 涓� 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);
+}
diff --git a/src/views/financialManagement/receivable/invoiceApply.vue b/src/views/financialManagement/receivable/invoiceApply.vue
index 14fdd67..31b6345 100644
--- a/src/views/financialManagement/receivable/invoiceApply.vue
+++ b/src/views/financialManagement/receivable/invoiceApply.vue
@@ -766,6 +766,7 @@
dialogTitle.value = "缂栬緫寮�绁ㄧ敵璇�";
fillFormFromRow(row);
dialogVisible.value = true;
+ loadOutboundBatches(form.customerId, true);
};
const view = (row) => {
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
new file mode 100644
index 0000000..938a787
--- /dev/null
+++ b/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锛坧ending | 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 瑙f瀽濉姤瀛楁瀹氫箟涓� 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 };
+}
+
+/** 瑙f瀽瀹炰緥 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)) : [];
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
new file mode 100644
index 0000000..f54c167
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
new file mode 100644
index 0000000..7933db5
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue
new file mode 100644
index 0000000..e5f2eef
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/InstanceFlowDisplay.vue
@@ -0,0 +1,147 @@
+<!-- 瀹℃壒瀹炰緥锛歵asks 瀹℃壒娴佺▼灞曠ず锛堟í鍚戞楠ゆ潯锛� -->
+<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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
new file mode 100644
index 0000000..bbfa56a
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
new file mode 100644
index 0000000..67b9213
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js
new file mode 100644
index 0000000..0251647
--- /dev/null
+++ b/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 瑙f瀽瀛楁瀹氫箟涓庡~鎶ュ�硷紝骞堕摵骞冲埌琛屼笂渚涗富琛� 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 鐢熸垚涓昏〃鍔ㄦ�佸垪锛坙abel 鍙栬嚜妯℃澘瀛楁 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(),
+ },
+ ];
+ });
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceListSearch.js
new file mode 100644
index 0000000..3042d0c
--- /dev/null
+++ b/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锛坅pprovalInstanceDto.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)
+ );
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
new file mode 100644
index 0000000..a85cf9c
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -0,0 +1,154 @@
+import { matchBusinessTypeValue } from "../approve-list/approveListConstants.js";
+
+/**
+ * 鍚勪笟鍔℃ā鍧椾笌瀹℃壒妯℃澘绫诲瀷鐨勬槧灏勶紙閰嶇疆鍖栧叆鍙o級
+ * 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 瑙f瀽鏈ā鍧� 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];
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js
new file mode 100644
index 0000000..d68016f
--- /dev/null
+++ b/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锛坧rop 涓� 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;
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue
new file mode 100644
index 0000000..2488216
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue
new file mode 100644
index 0000000..53de9e1
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue
new file mode 100644
index 0000000..409dd41
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
new file mode 100644
index 0000000..d6e7073
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue b/src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue
new file mode 100644
index 0000000..8adfebc
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js
new file mode 100644
index 0000000..b474bb2
--- /dev/null
+++ b/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 瑙f瀽灞曠ず瀛楁锛�
+ * @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 瑙f瀽 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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js
new file mode 100644
index 0000000..d49ec53
--- /dev/null
+++ b/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=浠呮湰绫诲瀷妯℃澘锛泆niversal=闇�鍏堥�夌被鍨�
+ */
+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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js b/src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js
new file mode 100644
index 0000000..2788ac7
--- /dev/null
+++ b/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 };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
new file mode 100644
index 0000000..727f896
--- /dev/null
+++ b/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";
+}
+
+/** 灏嗘帴鍙h繑鍥炵殑妯℃澘杞负銆岀郴缁熷父鐢ㄥ鎵广�嶅崱鐗囨暟鎹� */
+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 瑙e寘 */
+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锛圓pprovalTemplateDto锛� */
+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(" 鈫� ");
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
new file mode 100644
index 0000000..6880f3f
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
new file mode 100644
index 0000000..78304ea
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
new file mode 100644
index 0000000..c1f66bd
--- /dev/null
+++ b/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: "" }],
+ };
+}
+
+/** 瑙f瀽鍗曢」榛樿鍊硷紙渚涙彁浜ら〉 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("銆�");
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
new file mode 100644
index 0000000..d094c13
--- /dev/null
+++ b/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锛圝SON锛夈��</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">鍙笂浼犳ā鏉胯鏄庢枃妗c�佸埗搴︽枃浠剁瓑锛堥�夊~锛夈��</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>
+
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js
new file mode 100644
index 0000000..99706b4
--- /dev/null
+++ b/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];
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
new file mode 100644
index 0000000..61aa6c0
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js
new file mode 100644
index 0000000..8397288
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue b/src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
new file mode 100644
index 0000000..4d800df
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue b/src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue
new file mode 100644
index 0000000..9e3ada5
--- /dev/null
+++ b/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銆乶ickName銆乽serName */
+ 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>
diff --git a/src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue b/src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
new file mode 100644
index 0000000..9b3d91e
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue b/src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue
new file mode 100644
index 0000000..d6d9ef4
--- /dev/null
+++ b/src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue
@@ -0,0 +1,12 @@
+<!--
+ 妯″潡涓枃鍚嶏細閲囪喘鍚堝悓
+ 鐩綍鏍囪瘑锛欳ontractManage/purchase-contract锛坧urchase-contract 鈫� 涓枃锛氶噰璐悎鍚岋級
+ 澶嶇敤椤甸潰锛欯/views/procurementManagement/procurementLedger/index.vue锛堥噰璐彴璐︼紱鏂囦欢鍚� index.vue 鈫� 鍏ュ彛椤碉級
+-->
+<template>
+ <ProcurementLedger />
+</template>
+
+<script setup>
+import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
+</script>
diff --git a/src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue b/src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue
new file mode 100644
index 0000000..6be106a
--- /dev/null
+++ b/src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue
@@ -0,0 +1,12 @@
+<!--
+ 妯″潡涓枃鍚嶏細閿�鍞悎鍚�
+ 鐩綍鏍囪瘑锛欳ontractManage/sale-contract锛坰ale-contract 鈫� 涓枃锛氶攢鍞悎鍚岋級
+ 澶嶇敤椤甸潰锛欯/views/procurementManagement/procurementLedger/index.vue锛堥噰璐彴璐︼紱鏂囦欢鍚� index.vue 鈫� 鍏ュ彛椤碉級
+-->
+<template>
+ <ProcurementLedger />
+</template>
+
+<script setup>
+import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
+</script>
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue
new file mode 100644
index 0000000..1124472
--- /dev/null
+++ b/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">姝f枃鍐呭</el-divider>
+ <div v-if="row.contentHtml" class="news-html-body" v-html="row.contentHtml" />
+ <el-empty v-else description="鏆傛棤姝f枃" :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>
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js
new file mode 100644
index 0000000..b870ab7
--- /dev/null
+++ b/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";
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsMappers.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsMappers.js
new file mode 100644
index 0000000..27cb9bc
--- /dev/null
+++ b/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(),
+ },
+ ];
+}
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
new file mode 100644
index 0000000..9bc29c3
--- /dev/null
+++ b/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: "鏀跨瓥瑙h", 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 };
+}
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
new file mode 100644
index 0000000..f263a41
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
@@ -0,0 +1,566 @@
+<!--OA妯″潡锛欵nterpriseNews 浼佷笟鏂伴椈锛坙istPage|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="姝f枃" 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>
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNewsList.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNewsList.js
new file mode 100644
index 0000000..66aef1e
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/HrManage/post-manage/index.vue b/src/views/officeProcessAutomation/HrManage/post-manage/index.vue
new file mode 100644
index 0000000..a57137c
--- /dev/null
+++ b/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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue b/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
new file mode 100644
index 0000000..291d57d
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
@@ -0,0 +1,174 @@
+<!--OA妯″潡锛氳浆姝g敵璇�-->
+<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>
diff --git a/src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue b/src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue
new file mode 100644
index 0000000..86c59ce
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/HrManage/resign-apply/index.vue b/src/views/officeProcessAutomation/HrManage/resign-apply/index.vue
new file mode 100644
index 0000000..2b20970
--- /dev/null
+++ b/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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue
new file mode 100644
index 0000000..0aa4f06
--- /dev/null
+++ b/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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue
new file mode 100644
index 0000000..c1470e7
--- /dev/null
+++ b/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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue
new file mode 100644
index 0000000..bd63608
--- /dev/null
+++ b/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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue
new file mode 100644
index 0000000..be33436
--- /dev/null
+++ b/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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue
new file mode 100644
index 0000000..2ad06fb
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue
new file mode 100644
index 0000000..9c2acfc
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue
new file mode 100644
index 0000000..5d0b261
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/officeProcessAutomation/HrManage/staff-archive/index.vue b/src/views/officeProcessAutomation/HrManage/staff-archive/index.vue
new file mode 100644
index 0000000..66cec7a
--- /dev/null
+++ b/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>浠呭厑璁稿鍏ls銆亁lsx鏍煎紡鏂囦欢銆�</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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue b/src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue
new file mode 100644
index 0000000..54b2ef9
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue b/src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue
new file mode 100644
index 0000000..02f9cef
--- /dev/null
+++ b/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>
\ No newline at end of file
diff --git a/src/views/officeProcessAutomation/HrManage/staff-contract/index.vue b/src/views/officeProcessAutomation/HrManage/staff-contract/index.vue
new file mode 100644
index 0000000..8186bdd
--- /dev/null
+++ b/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>浠呭厑璁稿鍏ls銆亁lsx鏍煎紡鏂囦欢銆�</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>
+
diff --git a/src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue b/src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue
new file mode 100644
index 0000000..f731d57
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/HrManage/work-handover/index.vue b/src/views/officeProcessAutomation/HrManage/work-handover/index.vue
new file mode 100644
index 0000000..a9b96ae
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
new file mode 100644
index 0000000..3f65cb7
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
@@ -0,0 +1,12 @@
+<!--
+ 妯″潡涓枃鍚嶏細閫氱煡鍏憡
+ 鐩綍鏍囪瘑锛歂oticeAnnouncement/notice-manage
+ 澶嶇敤椤甸潰锛欯/views/collaborativeApproval/noticeManagement/index.vue锛堝崗鍚屽鎵�-閫氱煡鍏憡锛�
+-->
+<template>
+ <NoticeManagement />
+</template>
+
+<script setup>
+import NoticeManagement from "@/views/collaborativeApproval/noticeManagement/index.vue";
+</script>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
new file mode 100644
index 0000000..4db16a7
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
new file mode 100644
index 0000000..1736b3e
--- /dev/null
+++ b/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 ?? "",
+ };
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
new file mode 100644
index 0000000..c9da4fc
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -0,0 +1,550 @@
+<!--OA妯″潡锛氳垂鐢ㄦ姤閿�锛堝垪琛� /finReimbursement/listPage锛宺eimbursementType=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>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
new file mode 100644
index 0000000..3b90a3b
--- /dev/null
+++ b/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?.("瑙f瀽澶辫触");
+ }
+ };
+ 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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue b/src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue
new file mode 100644
index 0000000..724376d
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js b/src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js
new file mode 100644
index 0000000..c72633b
--- /dev/null
+++ b/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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js b/src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js
new file mode 100644
index 0000000..2525f70
--- /dev/null
+++ b/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 瑙e寘 */
+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;
+}
+
+/** 璇︽儏鏌ヨ鍙傛暟锛坬uery 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锛坒inReimbursementDto.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,
+ };
+}
+
+/** 鍒楄〃/璇︽儏鏃堕棿灞曠ず锛圛SO 鈫� 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锛坰torageBlobVOList / 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" };
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js b/src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js
new file mode 100644
index 0000000..664d646
--- /dev/null
+++ b/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;
+ }
+}
+
+/** 浠庡凡娉ㄥ唽璺敱瑙f瀽宸梾/璐圭敤鎶ラ攢鑿滃崟 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);
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue
new file mode 100644
index 0000000..03a5fa3
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
new file mode 100644
index 0000000..2c1d8a4
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
new file mode 100644
index 0000000..17737e3
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -0,0 +1,614 @@
+<!--OA妯″潡锛氬樊鏃呮姤閿�锛堝垪琛� /finReimbursement/listPage锛宺eimbursementType=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>
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
new file mode 100644
index 0000000..6c94c61
--- /dev/null
+++ b/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"),
+ };
+}
diff --git a/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
new file mode 100644
index 0000000..c92d88c
--- /dev/null
+++ b/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?.("瑙f瀽澶辫触");
+ }
+ };
+ 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,
+ };
+}
diff --git a/src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue b/src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue
new file mode 100644
index 0000000..9dd4e90
--- /dev/null
+++ b/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="璇疯緭鍏ヨ礋璐d汉" 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>
+
diff --git a/src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue b/src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue
new file mode 100644
index 0000000..2701c1a
--- /dev/null
+++ b/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">姝e父</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>
+
diff --git a/src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue b/src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue
new file mode 100644
index 0000000..a7546aa
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue b/src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue
new file mode 100644
index 0000000..97a06b1
--- /dev/null
+++ b/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>浠呭厑璁稿鍏ls銆亁lsx鏍煎紡鏂囦欢銆�</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>
+
diff --git a/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue b/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue
new file mode 100644
index 0000000..719a028
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue b/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue
new file mode 100644
index 0000000..73c6b18
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue b/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue
new file mode 100644
index 0000000..2594543
--- /dev/null
+++ b/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", // 榛樿鐢熸垚鎴浘涓篜NG鏍煎紡
+ 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锛孭NG鍚庣紑鐨勬枃浠躲��")
+ } 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>
\ No newline at end of file
diff --git a/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue b/src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue
new file mode 100644
index 0000000..5099ffa
--- /dev/null
+++ b/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>
diff --git a/src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue b/src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue
new file mode 100644
index 0000000..d5a90b0
--- /dev/null
+++ b/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("姝e湪鍔犺浇缂撳瓨鐩戞帶鏁版嵁锛岃绋嶅�欙紒")
+ 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>
+
diff --git a/src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue b/src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue
new file mode 100644
index 0000000..fe13414
--- /dev/null
+++ b/src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue
@@ -0,0 +1,14 @@
+<!--OA妯″潡锛氭暟鎹洃鎺�-->
+<template>
+ <div>
+ <i-frame v-model:src="url"></i-frame>
+ </div>
+</template>
+
+<script setup>
+import iFrame from '@/components/iFrame'
+
+import { ref } from 'vue'
+
+const url = ref(import.meta.env.VITE_APP_BASE_API + '/druid/login.html')
+</script>
diff --git a/src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue b/src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue
new file mode 100644
index 0000000..053d55e
--- /dev/null
+++ b/src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue
@@ -0,0 +1,191 @@
+<!--OA妯″潡锛氭湇鍔″櫒鐩戞帶-->
+<template>
+ <div class="app-container">
+ <el-row :gutter="10">
+ <el-col :span="12" class="card-box">
+ <el-card>
+ <template #header><Cpu style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">CPU</span></template>
+ <div class="el-table el-table--enable-row-hover el-table--medium">
+ <table cellspacing="0" style="width: 100%;">
+ <thead>
+ <tr>
+ <th class="el-table__cell is-leaf"><div class="cell">灞炴��</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">鍊�</div></th>
+ </tr>
+ </thead>
+ <tbody>
+ <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="server.cpu">{{ server.cpu.cpuNum }}</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="server.cpu">{{ server.cpu.used }}%</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="server.cpu">{{ server.cpu.sys }}%</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="server.cpu">{{ server.cpu.free }}%</div></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </el-card>
+ </el-col>
+
+ <el-col :span="12" class="card-box">
+ <el-card>
+ <template #header><Tickets 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%;">
+ <thead>
+ <tr>
+ <th class="el-table__cell is-leaf"><div class="cell">灞炴��</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">鍐呭瓨</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">JVM</div></th>
+ </tr>
+ </thead>
+ <tbody>
+ <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="server.mem">{{ server.mem.total }}G</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.total }}M</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="server.mem">{{ server.mem.used}}G</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.used}}M</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="server.mem">{{ server.mem.free }}G</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.free }}M</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="server.mem" :class="{'text-danger': server.mem.usage > 80}">{{ server.mem.usage }}%</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm" :class="{'text-danger': server.jvm.usage > 80}">{{ server.jvm.usage }}%</div></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </el-card>
+ </el-col>
+
+ <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">鏈嶅姟鍣ㄥ悕绉�</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.computerName }}</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="server.sys">{{ server.sys.osName }}</div></td>
+ </tr>
+ <tr>
+ <td class="el-table__cell is-leaf"><div class="cell">鏈嶅姟鍣↖P</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.computerIp }}</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="server.sys">{{ server.sys.osArch }}</div></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </el-card>
+ </el-col>
+
+ <el-col :span="24" class="card-box">
+ <el-card>
+ <template #header><CoffeeCup style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">Java铏氭嫙鏈轰俊鎭�</span></template>
+ <div class="el-table el-table--enable-row-hover el-table--medium">
+ <table cellspacing="0" style="width: 100%;table-layout:fixed;">
+ <tbody>
+ <tr>
+ <td class="el-table__cell is-leaf"><div class="cell">Java鍚嶇О</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.name }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell">Java鐗堟湰</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.version }}</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="server.jvm">{{ server.jvm.startTime }}</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="server.jvm">{{ server.jvm.runTime }}</div></td>
+ </tr>
+ <tr>
+ <td colspan="1" class="el-table__cell is-leaf"><div class="cell">瀹夎璺緞</div></td>
+ <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.home }}</div></td>
+ </tr>
+ <tr>
+ <td colspan="1" class="el-table__cell is-leaf"><div class="cell">椤圭洰璺緞</div></td>
+ <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.userDir }}</div></td>
+ </tr>
+ <tr>
+ <td colspan="1" class="el-table__cell is-leaf"><div class="cell">杩愯鍙傛暟</div></td>
+ <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.inputArgs }}</div></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </el-card>
+ </el-col>
+
+ <el-col :span="24" class="card-box">
+ <el-card>
+ <template #header><MessageBox 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%;">
+ <thead>
+ <tr>
+ <th class="el-table__cell el-table__cell is-leaf"><div class="cell">鐩樼璺緞</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">鏂囦欢绯荤粺</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">鐩樼绫诲瀷</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">鎬诲ぇ灏�</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">鍙敤澶у皬</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">宸茬敤澶у皬</div></th>
+ <th class="el-table__cell is-leaf"><div class="cell">宸茬敤鐧惧垎姣�</div></th>
+ </tr>
+ </thead>
+ <tbody v-if="server.sysFiles">
+ <tr v-for="(sysFile, index) in server.sysFiles" :key="index">
+ <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.dirName }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.sysTypeName }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.typeName }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.total }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.free }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.used }}</div></td>
+ <td class="el-table__cell is-leaf"><div class="cell" :class="{'text-danger': sysFile.usage > 80}">{{ sysFile.usage }}%</div></td>
+ </tr>
+ </tbody>
+ </table>
+ </div>
+ </el-card>
+ </el-col>
+ </el-row>
+ </div>
+</template>
+
+<script setup>
+import { getServer } from '@/api/monitor/server'
+import {onMounted} from "vue";
+
+const server = ref([])
+const { proxy } = getCurrentInstance()
+
+function getList() {
+ proxy.$modal.loading("姝e湪鍔犺浇鏈嶅姟鐩戞帶鏁版嵁锛岃绋嶅�欙紒")
+ getServer().then(response => {
+ server.value = response.data
+ proxy.$modal.closeLoading()
+ })
+}
+
+onMounted(() => {
+ getList();
+});
+</script>
diff --git a/src/views/personnelManagement/contractManagement/index.vue b/src/views/personnelManagement/contractManagement/index.vue
index 074b9ac..ae0087e 100644
--- a/src/views/personnelManagement/contractManagement/index.vue
+++ b/src/views/personnelManagement/contractManagement/index.vue
@@ -136,7 +136,7 @@
},
{
label: "宀椾綅",
- prop: "postJob",
+ prop: "postName",
},
{
label: "鐜颁綇鍧�",
--
Gitblit v1.9.3