From 9bfda877a67bd2bdfe0c12bfca8ccf88f8db3f4b Mon Sep 17 00:00:00 2001
From: yyb <995253665@qq.com>
Date: 星期一, 18 五月 2026 10:35:28 +0800
Subject: [PATCH] 合并OA流程页面文件夹 dev-new_pro_OA -> dev_NEW_pro
---
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-list/components/ApproveDetailPanel.vue | 94
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue | 253
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue | 909 +++
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 | 314 +
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js | 662 ++
src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue | 73
src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue | 315 +
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue | 461 +
src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue | 96
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue | 71
src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue | 304 +
src/views/officeProcessAutomation/HrManage/staff-archive/index.vue | 407 +
src/views/officeProcessAutomation/HrManage/resign-apply/index.vue | 245
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js | 375 +
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue | 59
src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue | 191
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue | 360 +
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js | 343 +
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue | 934 +++
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue | 77
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue | 49
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js | 316 +
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue | 168
src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue | 123
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue | 67
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue | 356 +
src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue | 291
src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue | 141
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js | 332 +
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js | 193
src/views/officeProcessAutomation/HrManage/work-handover/index.vue | 810 ++
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue | 676 ++
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue | 792 ++
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue | 556 +
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js | 440 +
src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue | 115
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue | 81
src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue | 263
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue | 436 +
src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue | 181
src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue | 197
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue | 623 ++
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue | 87
src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue | 12
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js | 260
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js | 160
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js | 689 ++
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js | 309 +
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/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue | 14
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js | 194
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue | 365 +
58 files changed, 17,519 insertions(+), 0 deletions(-)
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..447627a
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -0,0 +1,316 @@
+import dayjs from "dayjs";
+
+/** 瀹℃壒绫诲瀷锛堜笌鍚庣瀛楁 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" },
+];
+
+/** 瀹℃壒鐘舵�� approvalStatus */
+export const APPROVAL_STATUS_OPTIONS = [
+ { value: "pending", label: "瀹℃牳涓�" },
+ { value: "approved", label: "宸查�氳繃" },
+ { value: "rejected", label: "宸查┏鍥�" },
+ { value: "cancelled", label: "宸叉挙閿�" },
+];
+
+/** 瀹℃壒鏂瑰紡 approvalMode */
+export const APPROVAL_MODE_OPTIONS = [
+ { value: "parallel", label: "涓庣" },
+ { value: "or_sign", label: "鎴栫" },
+];
+
+/**
+ * 鎻愪氦瀹℃壒妯℃澘锛堟寜绫诲瀷涓�閿~鎶ワ紝瀛楁鍚庢湡涓庡悗绔ā鏉垮悓姝ワ級
+ */
+export const SUBMIT_TEMPLATES = {
+ cost_reimburse: {
+ approvalType: "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 },
+ ],
+ approvalMode: "parallel",
+ },
+ travel_reimburse: {
+ approvalType: "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 },
+ ],
+ approvalMode: "parallel",
+ },
+ overtime: {
+ approvalType: "overtime",
+ label: "鍔犵彮鐢宠",
+ fields: [
+ { key: "summary", label: "鍔犵彮浜嬬敱", type: "textarea", required: true, rows: 3 },
+ { key: "overtimeDate", label: "鍔犵彮鏃ユ湡", type: "date", required: true },
+ { key: "hours", label: "鍔犵彮鏃堕暱(灏忔椂)", type: "number", required: true, min: 0.5, precision: 1 },
+ ],
+ approvalMode: "parallel",
+ },
+ leave: {
+ approvalType: "leave",
+ label: "璇峰亣鐢宠",
+ 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 },
+ ],
+ approvalMode: "parallel",
+ },
+ work_handover: {
+ approvalType: "work_handover",
+ label: "宸ヤ綔浜ゆ帴",
+ fields: [
+ { key: "summary", label: "浜ゆ帴璇存槑", type: "textarea", required: true, rows: 3 },
+ { key: "handoverTo", label: "浜ゆ帴瀵硅薄", type: "text", required: true },
+ ],
+ approvalMode: "parallel",
+ },
+ regular: {
+ approvalType: "regular",
+ label: "杞鐢宠",
+ fields: [
+ { key: "summary", label: "杞璇存槑", type: "textarea", required: true, rows: 3 },
+ { key: "regularDate", label: "鎷熻浆姝f棩鏈�", type: "date", required: true },
+ ],
+ approvalMode: "parallel",
+ },
+ resign: {
+ approvalType: "resign",
+ label: "绂昏亴鐢宠",
+ fields: [
+ { key: "summary", label: "绂昏亴鍘熷洜", type: "textarea", required: true, rows: 3 },
+ { key: "lastWorkDay", label: "鏈�鍚庡伐浣滄棩", type: "date", required: true },
+ ],
+ approvalMode: "or_sign",
+ },
+ transfer: {
+ approvalType: "transfer",
+ label: "璋冨矖鐢宠",
+ fields: [
+ { key: "summary", label: "璋冨矖璇存槑", type: "textarea", required: true, rows: 2 },
+ { key: "targetDept", label: "鐩爣閮ㄩ棬", type: "text", required: true },
+ { key: "targetPost", label: "鐩爣宀椾綅", type: "text", required: true },
+ ],
+ approvalMode: "parallel",
+ },
+};
+
+export const STORAGE_KEY = "oa_unified_approve_list_v1";
+
+export function approvalTypeLabel(v) {
+ return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+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) {
+ return APPROVAL_STATUS_OPTIONS.find((x) => x.value === v)?.label || "鈥�";
+}
+
+export function approvalStatusTagType(v) {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ if (v === "cancelled") return "info";
+ return "primary";
+}
+
+export function approvalModeLabel(v) {
+ if (v === "countersign") return "鎴栫";
+ return APPROVAL_MODE_OPTIONS.find((x) => x.value === v)?.label || "涓庣";
+}
+
+export function unreadLabel(v) {
+ return v ? "鏄�" : "鍚�";
+}
+
+export function buildDefaultFlowNodes() {
+ return [
+ {
+ approverId: "mock_supervisor",
+ approverName: "鐩村睘涓婄骇",
+ sortOrder: 1,
+ nodeOrder: 1,
+ nodeStatus: "process",
+ approveOpinion: "",
+ approveTime: "",
+ },
+ {
+ approverId: "mock_manager",
+ approverName: "閮ㄩ棬缁忕悊",
+ sortOrder: 2,
+ nodeOrder: 2,
+ nodeStatus: "wait",
+ approveOpinion: "",
+ approveTime: "",
+ },
+ ];
+}
+
+function demoRow(partial) {
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ return {
+ id: partial.id,
+ bizId: partial.bizId || partial.id,
+ applicantNo: partial.applicantNo,
+ applicantName: partial.applicantName,
+ approvalType: partial.approvalType,
+ approvalMode: partial.approvalMode || "parallel",
+ unread: partial.unread ?? false,
+ approvalStatus: partial.approvalStatus || "pending",
+ createTime: partial.createTime || now,
+ summary: partial.summary || "",
+ formPayload: partial.formPayload || {},
+ approvalFlowNodes: partial.approvalFlowNodes || buildDefaultFlowNodes(),
+ currentNodeIndex: partial.currentNodeIndex ?? 0,
+ approvalRecords: partial.approvalRecords || [],
+ rejectReason: partial.rejectReason || "",
+ sourceRoute: partial.sourceRoute || "",
+ };
+}
+
+/** 鍒濆婕旂ず鏁版嵁锛堝叡 22 鏉★紝涓庡師鍨嬫暟閲忎竴鑷达級 */
+export function createInitialMockRows() {
+ const types = [
+ "cost_reimburse",
+ "travel_reimburse",
+ "overtime",
+ "leave",
+ "work_handover",
+ "regular",
+ "resign",
+ "transfer",
+ "cost_reimburse",
+ "leave",
+ "overtime",
+ "travel_reimburse",
+ "work_handover",
+ "regular",
+ "cost_reimburse",
+ "leave",
+ "transfer",
+ "resign",
+ "overtime",
+ "travel_reimburse",
+ "cost_reimburse",
+ "leave",
+ ];
+ const applicants = [
+ { no: "007", name: "鑻规灉" },
+ { no: "Guest001", name: "澶栭儴鐢ㄦ埛" },
+ { no: "0056", name: "鐜嬩簲" },
+ { no: "0042", name: "鏉庡洓" },
+ { no: "0088", name: "鐚尗" },
+ { no: "0012", name: "寮犱笁" },
+ { no: "0033", name: "璧靛叚" },
+ ];
+ const summaries = [
+ "鍔炲叕鐢ㄥ搧閲囪喘鎶ラ攢",
+ "涓婃捣鍑哄樊宸梾璐�",
+ "鍛ㄦ湯椤圭洰鍔犵彮",
+ "骞村亣 3 澶�",
+ "绂昏亴宸ヤ綔浜ゆ帴",
+ "璇曠敤鏈熻浆姝g敵璇�",
+ "涓汉鍘熷洜绂昏亴",
+ "璋冭嚦閿�鍞儴",
+ "瀹㈡埛鎺ュ緟椁愯垂",
+ "鐥呭亣 1 澶�",
+ "鑺傚亣鏃ュ�肩彮鍔犵彮",
+ "鍖椾含鍩硅宸梾",
+ "椤圭洰鏂囨。浜ゆ帴",
+ "鐮斿彂宀楄浆姝�",
+ "閫氳璐规姤閿�",
+ "浜嬪亣鍗婂ぉ",
+ "璋冨矖鑷冲競鍦洪儴",
+ "鍗忓晢绂昏亴",
+ "宸ヤ綔鏃ュ欢鏃跺姞鐝�",
+ "鎴愰兘灞曚細宸梾",
+ "浜ら�氳垂鎶ラ攢",
+ "璋冧紤 1 澶�",
+ ];
+ const statuses = ["pending", "pending", "pending", "approved", "pending", "pending", "rejected", "pending"];
+ return types.map((approvalType, i) => {
+ const ap = applicants[i % applicants.length];
+ const daysAgo = i % 14;
+ return demoRow({
+ id: `mock_${i + 1}`,
+ bizId: `BIZ${String(2025031400 + i)}`,
+ applicantNo: ap.no,
+ applicantName: ap.name,
+ approvalType,
+ approvalMode: i % 5 === 0 ? "or_sign" : "parallel",
+ unread: i % 3 === 0,
+ approvalStatus: statuses[i % statuses.length],
+ createTime: dayjs().subtract(daysAgo, "day").hour(9 + (i % 8)).minute((i * 7) % 60).second(0).format("YYYY-MM-DD HH:mm:ss"),
+ summary: summaries[i],
+ formPayload: { summary: summaries[i] },
+ });
+ });
+}
+
+export function loadStoredRows() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveStoredRows(rows) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+ } catch {
+ /* ignore quota */
+ }
+}
+
+export function createEmptySubmitForm(templateKey) {
+ const tpl = SUBMIT_TEMPLATES[templateKey];
+ const payload = { summary: "" };
+ (tpl?.fields || []).forEach((f) => {
+ if (f.type === "number") payload[f.key] = undefined;
+ else if (f.type === "datetimerange") payload[f.key] = [];
+ else payload[f.key] = "";
+ });
+ return {
+ templateKey: templateKey || "",
+ approvalMode: tpl?.approvalMode || "parallel",
+ formPayload: payload,
+ approvalFlowNodes: buildDefaultFlowNodes(),
+ };
+}
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..19328af
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
@@ -0,0 +1,94 @@
+<!-- 缁熶竴瀹℃壒锛氫笟鍔℃憳瑕� -->
+<template>
+ <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="瀹℃壒鏂瑰紡">
+ <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</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="鐢宠鎽樿" :span="2">{{ 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">{{ row.createTime || "鈥�" }}</el-descriptions-item>
+ </el-descriptions>
+
+ <template v-if="extraFields.length">
+ <el-divider content-position="left">濉姤鍐呭</el-divider>
+ <el-descriptions :column="2" border size="small">
+ <el-descriptions-item v-for="item in extraFields" :key="item.key" :label="item.label">
+ {{ item.display }}
+ </el-descriptions-item>
+ </el-descriptions>
+ </template>
+</template>
+
+<script setup>
+import { computed } from "vue";
+import {
+ approvalTypeLabel,
+ approvalTypeStyle,
+ approvalModeLabel,
+ approvalStatusLabel,
+ approvalStatusTagType,
+ SUBMIT_TEMPLATES,
+} from "../approveListConstants.js";
+
+const props = defineProps({
+ row: { type: Object, default: () => ({}) },
+});
+
+const extraFields = computed(() => {
+ const payload = props.row?.formPayload || {};
+ const tpl = Object.values(SUBMIT_TEMPLATES).find((t) => t.approvalType === props.row?.approvalType);
+ if (!tpl?.fields?.length) {
+ return Object.keys(payload)
+ .filter((k) => k !== "summary" && payload[k] != null && payload[k] !== "")
+ .map((k) => ({ key: k, label: k, display: formatValue(payload[k]) }));
+ }
+ return tpl.fields
+ .map((f) => {
+ const val = payload[f.key];
+ if (val == null || val === "" || (Array.isArray(val) && !val.length)) return null;
+ let display = formatValue(val);
+ if (f.type === "select" && f.options) {
+ display = f.options.find((o) => o.value === val)?.label || display;
+ }
+ return { key: f.key, label: f.label, display };
+ })
+ .filter(Boolean);
+});
+
+function formatValue(val) {
+ if (Array.isArray(val)) return val.join(" 鑷� ");
+ return String(val);
+}
+</script>
+
+<style scoped>
+.approve-type-cell {
+ display: inline-block;
+ padding: 2px 10px;
+ border-radius: 4px;
+ font-size: 13px;
+ line-height: 1.5;
+}
+.approval-method-text {
+ color: var(--el-color-danger);
+ font-weight: 500;
+}
+.reject-text {
+ color: var(--el-color-danger);
+}
+</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..774b322
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -0,0 +1,436 @@
+<!--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.approvalType"
+ placeholder="璇烽�夋嫨瀹℃壒绫诲瀷"
+ clearable
+ filterable
+ style="width: 200px"
+ >
+ <el-option
+ v-for="opt in APPROVAL_TYPE_OPTIONS"
+ :key="opt.value"
+ :label="opt.label"
+ :value="opt.value"
+ />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">鐢宠浜哄悕绉帮細</span>
+ <el-input
+ v-model="searchForm.applicantKeyword"
+ style="width: 200px"
+ placeholder="璇疯緭鍏ョ敵璇蜂汉鍚嶇О"
+ clearable
+ :prefix-icon="Search"
+ @keyup.enter="handleQuery"
+ />
+ <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>
+ <template #approvalMethod="{ row }">
+ <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
+ </template>
+ </PIMTable>
+ </div>
+
+ <!-- 鎻愪氦瀹℃壒锛堟寜妯℃澘锛� -->
+ <el-dialog
+ v-model="submitDialog.visible"
+ :title="submitDialog.step === 1 ? '閫夋嫨瀹℃壒妯℃澘' : `鎻愪氦${activeTemplate?.label || '瀹℃壒'}`"
+ width="720px"
+ append-to-body
+ destroy-on-close
+ class="approve-submit-dialog"
+ @closed="submitDialog.step = 1"
+ >
+ <template v-if="submitDialog.step === 1">
+ <p class="template-hint">璇烽�夋嫨瑕佹彁浜ょ殑瀹℃壒绫诲瀷锛岀郴缁熷皢鎸夊搴旀ā鏉垮紩瀵煎~鎶ワ紙瀛楁鍚庢湡涓庡悗绔悓姝ワ級銆�</p>
+ <div class="template-grid">
+ <div
+ v-for="(tpl, key) in SUBMIT_TEMPLATES"
+ :key="key"
+ class="template-card"
+ @click="onTemplatePick(key)"
+ >
+ <span class="template-card-type" :style="approvalTypeStyle(tpl.approvalType)">
+ {{ tpl.label }}
+ </span>
+ <span class="template-card-desc">{{ tpl.summaryPlaceholder || "鐐瑰嚮濉啓骞舵彁浜�" }}</span>
+ </div>
+ </div>
+ </template>
+
+ <template v-else>
+ <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
+ <el-form-item label="瀹℃壒绫诲瀷">
+ <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
+ {{ activeTemplate.label }}
+ </span>
+ <el-button type="primary" link class="ml12" @click="backToTemplatePick">鏇存崲妯℃澘</el-button>
+ </el-form-item>
+ <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
+ <el-radio-group v-model="submitForm.approvalMode">
+ <el-radio value="parallel">涓庣</el-radio>
+ <el-radio value="or_sign">鎴栫</el-radio>
+ </el-radio-group>
+ </el-form-item>
+ <template v-for="field in activeTemplate.fields" :key="field.key">
+ <el-form-item :label="field.label" :prop="`formPayload.${field.key}`">
+ <el-input
+ v-if="field.type === 'text'"
+ v-model="submitForm.formPayload[field.key]"
+ :placeholder="`璇疯緭鍏�${field.label}`"
+ maxlength="200"
+ />
+ <el-input
+ v-else-if="field.type === 'textarea'"
+ v-model="submitForm.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="submitForm.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="submitForm.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="submitForm.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="submitForm.formPayload[field.key]"
+ :placeholder="`璇烽�夋嫨${field.label}`"
+ style="width: 100%"
+ clearable
+ >
+ <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
+ </el-select>
+ </el-form-item>
+ </template>
+ <el-form-item label="瀹℃壒娴佺▼">
+ <ApprovalFlowEditor v-model="submitForm.approvalFlowNodes" :user-options="flowUserOptions" />
+ <p class="flow-tip">鑷冲皯淇濈暀涓�涓鎵硅妭鐐癸紱鎻愪氦鍚庤繘鍏ャ�屽鏍镐腑銆嶇姸鎬併��</p>
+ </el-form-item>
+ </el-form>
+ </template>
+
+ <template #footer>
+ <el-button v-if="submitDialog.step === 2" type="primary" @click="onSubmitNew">鎻� 浜�</el-button>
+ <el-button @click="submitDialog.visible = false">{{ submitDialog.step === 1 ? "鍙� 娑�" : "鍏� 闂�" }}</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog
+ v-model="detailDialog.visible"
+ title="瀹℃壒璇︽儏"
+ width="920px"
+ append-to-body
+ destroy-on-close
+ >
+ <ApproveDetailPanel :row="detailRow" />
+ <el-divider content-position="left">瀹℃壒娴佺▼</el-divider>
+ <ApprovalFlowProgress
+ :nodes="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" />
+ <template #footer>
+ <el-button
+ v-if="detailRow.approvalStatus === 'pending'"
+ type="primary"
+ @click="openApproveFromDetail"
+ >
+ 鍘诲鎵�
+ </el-button>
+ <el-button @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 瀹℃壒鎿嶄綔 -->
+ <el-dialog
+ v-model="approveDialog.visible"
+ title="瀹℃壒澶勭悊"
+ width="960px"
+ append-to-body
+ destroy-on-close
+ @closed="approveOpinion = ''"
+ >
+ <ApproveDetailPanel :row="approveDialog.row" />
+ <el-divider content-position="left">娴佺▼杩涘害</el-divider>
+ <ApprovalFlowProgress
+ :nodes="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="onApprove('approved')">閫� 杩�</el-button>
+ <el-button type="danger" @click="onApprove('rejected')">椹� 鍥�</el-button>
+ <el-button @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 { onMounted, ref } from "vue";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
+import ApprovalFlowProgress from "@/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue";
+import { approvalTypeStyle } from "./approveListConstants.js";
+import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
+import { useApproveList } from "./useApproveList.js";
+
+const al = useApproveList();
+const {
+ Search,
+ APPROVAL_TYPE_OPTIONS,
+ SUBMIT_TEMPLATES,
+ approvalTypeLabel,
+ approvalModeLabel,
+ approvalActionLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ detailDialog,
+ detailRow,
+ approveDialog,
+ approveOpinion,
+ submitDialog,
+ submitForm,
+ submitFormRef,
+ activeTemplate,
+ submitFormRules,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openSubmitDialog,
+ onTemplatePick,
+ backToTemplatePick,
+ submitNewApproval,
+ submitApprove,
+ openDetail,
+ openApprove,
+} = al;
+
+const flowUserOptions = ref([]);
+
+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 onSubmitNew() {
+ const ok = await submitNewApproval();
+ if (ok) ElMessage.success("瀹℃壒宸叉彁浜�");
+}
+
+function onApprove(result) {
+ const ret = submitApprove(result);
+ if (ret?.needOpinion) {
+ ElMessage.warning("椹冲洖鏃惰濉啓瀹℃壒鎰忚");
+ return;
+ }
+ if (ret?.ok) {
+ ElMessage.success(result === "approved" ? "宸查�氳繃" : "宸查┏鍥�");
+ }
+}
+
+function openApproveFromDetail() {
+ const row = detailRow.value;
+ detailDialog.visible = false;
+ openApprove(row);
+}
+
+onMounted(() => {
+ loadUsers();
+ 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;
+}
+.approval-method-text {
+ color: var(--el-color-danger);
+ font-weight: 500;
+}
+.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;
+}
+.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;
+}
+.flow-tip {
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+ margin-top: 8px;
+}
+.approve-submit-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+</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..48103aa
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -0,0 +1,343 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import useUserStore from "@/store/modules/user";
+import { computed, reactive, ref, watch } from "vue";
+import {
+ APPROVAL_TYPE_OPTIONS,
+ SUBMIT_TEMPLATES,
+ approvalModeLabel,
+ approvalStatusLabel,
+ approvalStatusTagType,
+ approvalTypeLabel,
+ createEmptySubmitForm,
+ createInitialMockRows,
+ loadStoredRows,
+ saveStoredRows,
+ buildDefaultFlowNodes,
+} from "./approveListConstants.js";
+
+function advanceFlow(row, result, opinion) {
+ const nodes = row.approvalFlowNodes || [];
+ const idx = row.currentNodeIndex ?? 0;
+ const node = nodes[idx];
+ if (!node) return;
+ node.nodeStatus = result === "approved" ? "finish" : "error";
+ node.approveOpinion = opinion || (result === "approved" ? "鍚屾剰" : "椹冲洖");
+ node.approveTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ row.approvalRecords = row.approvalRecords || [];
+ row.approvalRecords.push({
+ operatorName: node.approverName || "瀹℃壒浜�",
+ result,
+ opinion: node.approveOpinion,
+ time: node.approveTime,
+ });
+ if (result === "rejected") {
+ row.approvalStatus = "rejected";
+ row.rejectReason = opinion || node.approveOpinion;
+ return;
+ }
+ const next = idx + 1;
+ if (next < nodes.length) {
+ row.currentNodeIndex = next;
+ nodes[next].nodeStatus = "process";
+ row.approvalStatus = "pending";
+ } else {
+ row.approvalStatus = "approved";
+ row.rejectReason = "";
+ }
+}
+
+export function useApproveList() {
+ const userStore = useUserStore();
+ const stored = loadStoredRows();
+ const allRows = ref(stored?.length ? stored : createInitialMockRows());
+
+ const searchForm = reactive({
+ approvalType: "",
+ applicantKeyword: "",
+ 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 submitDialog = reactive({ visible: false, step: 1 });
+ const submitForm = reactive(createEmptySubmitForm(""));
+ const submitFormRef = ref();
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ if (searchForm.approvalType) {
+ list = list.filter((r) => r.approvalType === searchForm.approvalType);
+ }
+ const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.applicantName || "").toLowerCase();
+ const no = (r.applicantNo || "").toLowerCase();
+ return name.includes(kw) || no.includes(kw);
+ });
+ }
+ const range = searchForm.createTimeRange;
+ if (range?.length === 2) {
+ const [from, to] = range;
+ list = list.filter((r) => {
+ const t = (r.createTime || "").slice(0, 10);
+ return t && t >= from && t <= to;
+ });
+ }
+ return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ const activeTemplate = computed(() => SUBMIT_TEMPLATES[submitForm.templateKey] || null);
+
+ const submitFormRules = computed(() => {
+ const rules = {
+ templateKey: [{ required: true, message: "璇烽�夋嫨瀹℃壒绫诲瀷", trigger: "change" }],
+ };
+ (activeTemplate.value?.fields || []).forEach((f) => {
+ if (!f.required) return;
+ if (f.type === "number") {
+ rules[`formPayload.${f.key}`] = [{ required: true, message: `璇峰~鍐�${f.label}`, trigger: "blur" }];
+ } else if (f.type === "datetimerange") {
+ rules[`formPayload.${f.key}`] = [{ required: true, message: `璇烽�夋嫨${f.label}`, trigger: "change" }];
+ } else {
+ rules[`formPayload.${f.key}`] = [{ required: true, message: `璇峰~鍐�${f.label}`, trigger: "blur" }];
+ }
+ });
+ return rules;
+ });
+
+ const tableColumn = ref([
+ { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 110 },
+ { label: "鐢宠浜哄悕绉�", prop: "applicantName", minWidth: 100 },
+ {
+ label: "瀹℃壒绫诲瀷",
+ prop: "approvalType",
+ minWidth: 140,
+ dataType: "slot",
+ slot: "approveType",
+ },
+ {
+ label: "瀹℃壒鏂瑰紡",
+ prop: "approvalMode",
+ width: 90,
+ dataType: "slot",
+ slot: "approvalMethod",
+ },
+ {
+ 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 },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 160,
+ operation: [
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "瀹℃壒",
+ type: "text",
+ disabled: (row) => row.approvalStatus !== "pending",
+ clickFun: (row) => openApprove(row),
+ },
+ ],
+ },
+ ]);
+
+ function persist() {
+ saveStoredRows(allRows.value);
+ }
+
+ function handleQuery() {
+ tableLoading.value = true;
+ page.current = 1;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 200);
+ }
+
+ function resetSearch() {
+ searchForm.approvalType = "";
+ searchForm.applicantKeyword = "";
+ searchForm.createTimeRange = [];
+ handleQuery();
+ }
+
+ function pagination({ page: p, limit }) {
+ page.current = p;
+ page.size = limit;
+ }
+
+ function markRead(row) {
+ if (!row.unread) return;
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (hit) {
+ hit.unread = false;
+ persist();
+ }
+ }
+
+ function openDetail(row) {
+ markRead(row);
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function openApprove(row) {
+ markRead(row);
+ approveDialog.row = { ...row };
+ approveOpinion.value = "";
+ approveDialog.visible = true;
+ }
+
+ function openSubmitDialog() {
+ Object.assign(submitForm, createEmptySubmitForm(""));
+ submitDialog.step = 1;
+ submitDialog.visible = true;
+ }
+
+ function onTemplatePick(key) {
+ Object.assign(submitForm, createEmptySubmitForm(key));
+ submitDialog.step = 2;
+ }
+
+ function backToTemplatePick() {
+ submitDialog.step = 1;
+ }
+
+ async function submitNewApproval() {
+ if (!submitFormRef.value) return false;
+ try {
+ await submitFormRef.value.validate();
+ } catch {
+ return false;
+ }
+ const tpl = activeTemplate.value;
+ if (!tpl) return false;
+ const id = `user_${Date.now()}`;
+ const summary =
+ submitForm.formPayload.summary ||
+ submitForm.formPayload.handoverTo ||
+ `${tpl.label}鐢宠`;
+ const row = {
+ id,
+ bizId: `BIZ${dayjs().format("YYYYMMDDHHmmss")}`,
+ applicantNo: userStore.name || String(userStore.id || "褰撳墠鐢ㄦ埛"),
+ applicantName: userStore.nickName || userStore.name || "褰撳墠鐢ㄦ埛",
+ approvalType: tpl.approvalType,
+ approvalMode: submitForm.approvalMode,
+ unread: false,
+ approvalStatus: "pending",
+ createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ summary,
+ formPayload: { ...submitForm.formPayload },
+ approvalFlowNodes: (submitForm.approvalFlowNodes?.length
+ ? submitForm.approvalFlowNodes
+ : buildDefaultFlowNodes()
+ ).map((n, i) => ({ ...n, nodeStatus: i === 0 ? "process" : n.nodeStatus || "wait" })),
+ currentNodeIndex: 0,
+ approvalRecords: [],
+ rejectReason: "",
+ };
+ allRows.value.unshift(row);
+ persist();
+ submitDialog.visible = false;
+ page.current = 1;
+ return true;
+ }
+
+ function submitApprove(result) {
+ const row = approveDialog.row;
+ if (!row) return;
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit || hit.approvalStatus !== "pending") return;
+ if (result === "rejected" && !(approveOpinion.value || "").trim()) {
+ return { needOpinion: true };
+ }
+ advanceFlow(hit, result, (approveOpinion.value || "").trim());
+ hit.unread = false;
+ persist();
+ approveDialog.visible = false;
+ if (detailDialog.visible && detailRow.value?.id === hit.id) {
+ detailRow.value = { ...hit };
+ }
+ return { ok: true };
+ }
+
+ function approvalActionLabel(result) {
+ if (result === "approved") return "閫氳繃";
+ if (result === "rejected") return "椹冲洖";
+ return "寰呭鐞�";
+ }
+
+ return {
+ Search,
+ APPROVAL_TYPE_OPTIONS,
+ SUBMIT_TEMPLATES,
+ approvalTypeLabel,
+ approvalModeLabel,
+ approvalStatusLabel,
+ approvalStatusTagType,
+ approvalActionLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ detailDialog,
+ detailRow,
+ approveDialog,
+ approveOpinion,
+ submitDialog,
+ submitForm,
+ submitFormRef,
+ activeTemplate,
+ submitFormRules,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openSubmitDialog,
+ onTemplatePick,
+ backToTemplatePick,
+ submitNewApproval,
+ submitApprove,
+ openDetail,
+ openApprove,
+ };
+}
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..81884a1
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -0,0 +1,160 @@
+import dayjs from "dayjs";
+import { APPROVAL_TYPE_OPTIONS, SUBMIT_TEMPLATES } from "../approve-list/approveListConstants.js";
+
+/** 鑺傜偣鍐呭鎵规柟寮忥細浼氱 / 鎴栫 */
+export const NODE_SIGN_MODE_OPTIONS = [
+ { value: "countersign", label: "浼氱", desc: "鏈妭鐐规墍鏈夊鎵逛汉鍧囬渶閫氳繃" },
+ { value: "or_sign", label: "鎴栫", desc: "鏈妭鐐逛换涓�瀹℃壒浜洪�氳繃鍗冲彲" },
+];
+
+export const STORAGE_KEY = "oa_approve_template_custom_v1";
+
+/** 绯荤粺鍐呯疆甯哥敤瀹℃壒锛堝彧璇诲睍绀猴紝鏉ユ簮浜庡鎵瑰垪琛ㄦ彁浜ゆā鏉匡級 */
+export function getBuiltinTemplates() {
+ return Object.entries(SUBMIT_TEMPLATES).map(([key, tpl]) => ({
+ key,
+ approvalType: tpl.approvalType,
+ label: tpl.label,
+ summary: tpl.summaryPlaceholder || "绯荤粺棰勭疆濉姤瀛楁",
+ fieldCount: (tpl.fields || []).length,
+ defaultMode: tpl.approvalMode,
+ }));
+}
+
+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: "",
+ enabled: true,
+ flowNodes: [createEmptyNode(1)],
+ };
+}
+
+export function normalizeFlowNodes(nodes) {
+ const list = Array.isArray(nodes) ? nodes : [];
+ return list.map((n, i) => ({
+ nodeOrder: i + 1,
+ signMode: n.signMode === "or_sign" ? "or_sign" : "countersign",
+ approvers: (n.approvers || [])
+ .filter((a) => a?.approverId != null && a.approverId !== "")
+ .map((a) => ({
+ approverId: a.approverId,
+ approverName: a.approverName || "",
+ })),
+ }));
+}
+
+export function validateTemplateForm(form) {
+ const name = (form.templateName || "").trim();
+ if (!name) 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} 涓妭鐐归�夋嫨鑷冲皯涓�鍚嶅鎵逛汉` };
+ }
+ }
+ 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(" 鈫� ");
+}
+
+export function createInitialMockTemplates() {
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ return [
+ {
+ id: "tpl_demo_1",
+ templateName: "椤圭洰绔嬮」瀹℃壒",
+ description: "璺ㄩ儴闂ㄩ」鐩珛椤癸紝闇�鎶�鏈�佽储鍔′緷娆′細绛�",
+ enabled: true,
+ createTime: dayjs().subtract(5, "day").format("YYYY-MM-DD HH:mm:ss"),
+ updateTime: now,
+ flowNodes: [
+ {
+ nodeOrder: 1,
+ signMode: "countersign",
+ approvers: [
+ { approverId: "mock_tech_lead", approverName: "鎶�鏈礋璐d汉" },
+ { approverId: "mock_pm", approverName: "椤圭洰缁忕悊" },
+ ],
+ },
+ {
+ nodeOrder: 2,
+ signMode: "or_sign",
+ approvers: [
+ { approverId: "mock_finance", approverName: "璐㈠姟涓荤" },
+ { approverId: "mock_cfo", approverName: "璐㈠姟鎬荤洃" },
+ ],
+ },
+ ],
+ },
+ {
+ id: "tpl_demo_2",
+ templateName: "鍚堝悓鐢ㄥ嵃鐢宠",
+ description: "娉曞姟涓庤鏀挎垨绛惧悗锛屾�荤粡鐞嗙粓瀹�",
+ enabled: true,
+ createTime: dayjs().subtract(12, "day").format("YYYY-MM-DD HH:mm:ss"),
+ updateTime: dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss"),
+ flowNodes: [
+ {
+ nodeOrder: 1,
+ signMode: "or_sign",
+ approvers: [
+ { approverId: "mock_legal", approverName: "娉曞姟涓撳憳" },
+ { approverId: "mock_admin", approverName: "琛屾斂涓荤" },
+ ],
+ },
+ {
+ nodeOrder: 2,
+ signMode: "countersign",
+ approvers: [{ approverId: "mock_ceo", approverName: "鎬荤粡鐞�" }],
+ },
+ ],
+ },
+ ];
+}
+
+export function loadStoredTemplates() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const parsed = JSON.parse(raw);
+ return Array.isArray(parsed) ? parsed : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveStoredTemplates(rows) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+ } catch {
+ /* ignore */
+ }
+}
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..45b32c0
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
@@ -0,0 +1,356 @@
+<!-- 瀹℃壒妯℃澘锛氬彲閰嶇疆鑺傜偣鏁帮紝姣忚妭鐐瑰浜� + 浼氱/鎴栫 -->
+<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-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>
+ </div>
+ <p class="tfe-mode-tip">{{ signModeTip(item.signMode) }}</p>
+ <div 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">
+ <el-tag
+ v-for="a in item.approvers"
+ :key="String(a.approverId)"
+ size="small"
+ type="info"
+ effect="plain"
+ >
+ {{ a.approverName || "鈥�" }}
+ </el-tag>
+ </div>
+ <div 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>
+ </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 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 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: () => [] },
+});
+
+const emit = defineEmits(["update:modelValue"]);
+
+const innerList = ref([]);
+
+function signModeTip(mode) {
+ return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.desc || "";
+}
+
+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(),
+ nodeOrder: n.nodeOrder,
+ signMode: n.signMode,
+ approverIds: n.approvers.map((a) => a.approverId),
+ approvers: [...n.approvers],
+ }));
+}
+
+function publicShape(rows) {
+ return normalizeFlowNodes(
+ (rows || []).map((r) => ({
+ 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 : [];
+ row.approverIds = idList;
+ row.approvers = idList.map((id) => {
+ const u = findUser(id);
+ return {
+ approverId: id,
+ approverName: u ? u.nickName || u.userName || "" : "",
+ };
+ });
+ emitOut();
+}
+
+function addNode() {
+ innerList.value.push({
+ _uid: newUid(),
+ nodeOrder: innerList.value.length + 1,
+ signMode: "countersign",
+ approverIds: [],
+ approvers: [],
+ });
+ 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>
+.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-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/index.vue b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
new file mode 100644
index 0000000..a79d546
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -0,0 +1,365 @@
+<!--OA妯″潡锛氬鎵规ā鏉匡紙绯荤粺甯哥敤 + 鑷畾涔夊鑺傜偣娴佺▼锛�-->
+<template>
+ <div class="app-container approve-template-page">
+ <el-tabs v-model="activeTab" class="template-tabs">
+ <el-tab-pane label="绯荤粺甯哥敤瀹℃壒" name="builtin">
+ <el-alert type="info" show-icon :closable="false" class="mb16">
+ <template #title>绯荤粺棰勭疆妯℃澘</template>
+ <template #default>
+ 浠ヤ笅涓� OA 妯″潡鍐呯疆鐨勫父鐢ㄥ鎵圭被鍨嬶紝濉姤瀛楁涓庨粯璁ゅ鎵规柟寮忕敱绯荤粺缁存姢锛涙彁浜ゅ鎵规椂鍙洿鎺ラ�夌敤銆�
+ </template>
+ </el-alert>
+ <div class="builtin-grid">
+ <div v-for="item in builtinTemplates" :key="item.key" class="builtin-card">
+ <span class="builtin-label">{{ item.label }}</span>
+ <p class="builtin-summary">{{ item.summary }}</p>
+ <div class="builtin-meta">
+ <el-tag size="small" effect="plain">{{ item.fieldCount }} 涓~鎶ラ」</el-tag>
+ <el-tag size="small" type="warning" effect="plain">
+ 榛樿{{ item.defaultMode === "or_sign" ? "鎴栫" : "涓庣" }}
+ </el-tag>
+ <el-tag size="small" type="info" effect="plain">鍙</el-tag>
+ </div>
+ </div>
+ </div>
+ </el-tab-pane>
+
+ <el-tab-pane label="鑷畾涔夊鎵规ā鏉�" name="custom">
+ <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-tab-pane>
+ </el-tabs>
+
+ <!-- 鏂板缓 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="960px"
+ append-to-body
+ destroy-on-close
+ class="template-form-dialog"
+ @closed="formRef?.resetFields?.()"
+ >
+ <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="妯℃澘鍚嶇О" prop="templateName">
+ <el-input v-model="form.templateName" placeholder="濡傦細椤圭洰绔嬮」瀹℃壒" maxlength="50" show-word-limit />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <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="瀹℃壒娴佺▼" required>
+ <TemplateFlowEditor v-model="form.flowNodes" :user-options="flowUserOptions" />
+ <p class="flow-tip">
+ 鎸夐『搴忔祦杞細鍙负姣忎釜鑺傜偣娣诲姞澶氬悕瀹℃壒浜猴紱浼氱闇�鍏ㄩ儴閫氳繃锛屾垨绛句换涓�浜洪�氳繃鍗冲彲杩涘叆涓嬩竴鑺傜偣銆�
+ </p>
+ </el-form-item>
+ </el-form>
+ <template #footer>
+ <el-button type="primary" @click="onSubmitForm">淇� 瀛�</el-button>
+ <el-button @click="formDialog.visible = false">鍙� 娑�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="妯℃澘璇︽儏" width="880px" append-to-body destroy-on-close>
+ <el-descriptions :column="2" border>
+ <el-descriptions-item label="妯℃澘鍚嶇О">{{ detailRow.templateName }}</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="鍒涘缓鏃堕棿">{{ detailRow.createTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鏇存柊鏃堕棿">{{ detailRow.updateTime || "鈥�" }}</el-descriptions-item>
+ </el-descriptions>
+ <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" />
+ <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, Document, Plus, RefreshRight } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+import { onMounted, ref } from "vue";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import TemplateFlowEditor from "./components/TemplateFlowEditor.vue";
+import { useApproveTemplate } from "./useApproveTemplate.js";
+
+const at = useApproveTemplate();
+const {
+ Search,
+ activeTab,
+ builtinTemplates,
+ nodeSignModeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ submitForm,
+} = at;
+
+const flowUserOptions = ref([]);
+
+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 editFromDetail() {
+ const row = detailRow.value;
+ detailDialog.visible = false;
+ openFormDialog("edit", row);
+}
+
+onMounted(() => {
+ loadUsers();
+ handleQuery();
+});
+</script>
+
+<style scoped>
+.mb20 {
+ margin-bottom: 20px;
+}
+.mb16 {
+ margin-bottom: 16px;
+}
+.ml10 {
+ margin-left: 10px;
+}
+.ml12 {
+ margin-left: 12px;
+}
+.page-header .header-title {
+ display: flex;
+ align-items: center;
+ gap: 8px;
+ font-size: 18px;
+ font-weight: 600;
+ color: var(--el-text-color-primary);
+ margin-bottom: 8px;
+}
+.title-icon {
+ font-size: 22px;
+ color: var(--el-color-primary);
+}
+.header-desc {
+ margin: 0;
+ font-size: 13px;
+ color: var(--el-text-color-secondary);
+ line-height: 1.6;
+ max-width: 920px;
+}
+.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;
+}
+.builtin-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
+ gap: 12px;
+}
+.builtin-card {
+ padding: 14px 16px;
+ border: 1px solid var(--el-border-color-lighter);
+ border-radius: var(--radius-md, 8px);
+ background: var(--el-fill-color-blank);
+}
+.builtin-label {
+ font-size: 15px;
+ font-weight: 600;
+ color: var(--el-text-color-primary);
+}
+.builtin-summary {
+ margin: 8px 0 10px;
+ font-size: 12px;
+ color: var(--el-text-color-secondary);
+ line-height: 1.5;
+ min-height: 36px;
+}
+.builtin-meta {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 6px;
+}
+.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);
+}
+.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/useApproveTemplate.js b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
new file mode 100644
index 0000000..c56d055
--- /dev/null
+++ b/src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -0,0 +1,260 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { ElMessageBox } from "element-plus";
+import { computed, reactive, ref, watch } from "vue";
+import {
+ createEmptyTemplateForm,
+ createInitialMockTemplates,
+ flowNodesSummary,
+ getBuiltinTemplates,
+ loadStoredTemplates,
+ nodeSignModeLabel,
+ saveStoredTemplates,
+ validateTemplateForm,
+} from "./approveTemplateConstants.js";
+
+export function useApproveTemplate() {
+ const stored = loadStoredTemplates();
+ const allTemplates = ref(stored?.length ? stored : createInitialMockTemplates());
+
+ const activeTab = ref("custom");
+ const builtinTemplates = getBuiltinTemplates();
+
+ const searchForm = reactive({
+ keyword: "",
+ enabledOnly: false,
+ });
+
+ const tableLoading = ref(false);
+ const page = reactive({ current: 1, size: 10, total: 0 });
+
+ const formDialog = reactive({ visible: false, title: "", mode: "add" });
+ const form = reactive(createEmptyTemplateForm());
+ const formRef = ref();
+
+ const detailDialog = reactive({ visible: false });
+ const detailRow = ref({});
+
+ const filteredList = computed(() => {
+ let list = [...allTemplates.value];
+ const kw = (searchForm.keyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.templateName || "").toLowerCase();
+ const desc = (r.description || "").toLowerCase();
+ return name.includes(kw) || desc.includes(kw);
+ });
+ }
+ if (searchForm.enabledOnly) {
+ list = list.filter((r) => r.enabled !== false);
+ }
+ return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ const formRules = {
+ templateName: [{ required: true, message: "璇疯緭鍏ユā鏉垮悕绉�", trigger: "blur" }],
+ };
+
+ const tableColumn = ref([
+ { label: "妯℃澘鍚嶇О", prop: "templateName", minWidth: 140 },
+ { 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: "updateTime", width: 170 },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 200,
+ operation: [
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ { name: "缂栬緫", type: "text", clickFun: (row) => openFormDialog("edit", row) },
+ { name: "鍒犻櫎", type: "text", clickFun: (row) => removeTemplate(row) },
+ ],
+ },
+ ]);
+
+ function persist() {
+ saveStoredTemplates(allTemplates.value);
+ }
+
+ function handleQuery() {
+ tableLoading.value = true;
+ page.current = 1;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+ }
+
+ function resetSearch() {
+ searchForm.keyword = "";
+ searchForm.enabledOnly = false;
+ handleQuery();
+ }
+
+ function pagination({ page: p, limit }) {
+ page.current = p;
+ page.size = limit;
+ }
+
+ function resetForm(row) {
+ const base = createEmptyTemplateForm();
+ if (!row) {
+ Object.assign(form, base);
+ return;
+ }
+ Object.assign(form, {
+ ...base,
+ id: row.id,
+ templateName: row.templateName || "",
+ description: row.description || "",
+ enabled: row.enabled !== false,
+ flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
+ });
+ }
+
+ function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.title = mode === "add" ? "鏂板缓鑷畾涔夊鎵规ā鏉�" : "缂栬緫鑷畾涔夊鎵规ā鏉�";
+ resetForm(mode === "edit" ? row : null);
+ formDialog.visible = true;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function isNameDuplicate(name, excludeId) {
+ const n = (name || "").trim();
+ return allTemplates.value.some((t) => t.templateName?.trim() === n && t.id !== excludeId);
+ }
+
+ 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 (isNameDuplicate(validated.name, form.id)) {
+ return { message: "妯℃澘鍚嶇О宸插瓨鍦紝璇锋洿鎹㈠悕绉�" };
+ }
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ if (formDialog.mode === "add") {
+ allTemplates.value.unshift({
+ id: `tpl_${Date.now()}`,
+ templateName: validated.name,
+ description: (form.description || "").trim(),
+ enabled: form.enabled !== false,
+ createTime: now,
+ updateTime: now,
+ flowNodes: validated.nodes,
+ });
+ } else {
+ const hit = allTemplates.value.find((t) => t.id === form.id);
+ if (!hit) return { message: "妯℃澘涓嶅瓨鍦ㄦ垨宸插垹闄�" };
+ hit.templateName = validated.name;
+ hit.description = (form.description || "").trim();
+ hit.enabled = form.enabled !== false;
+ hit.flowNodes = validated.nodes;
+ hit.updateTime = now;
+ }
+ persist();
+ formDialog.visible = false;
+ page.current = 1;
+ return { ok: true };
+ }
+
+ async function removeTemplate(row) {
+ try {
+ await ElMessageBox.confirm(`纭畾鍒犻櫎妯℃澘銆�${row.templateName}銆嶅悧锛焋, "鎻愮ず", {
+ type: "warning",
+ confirmButtonText: "鍒犻櫎",
+ cancelButtonText: "鍙栨秷",
+ });
+ } catch {
+ return;
+ }
+ const idx = allTemplates.value.findIndex((t) => t.id === row.id);
+ if (idx >= 0) {
+ allTemplates.value.splice(idx, 1);
+ persist();
+ }
+ }
+
+ function toggleEnabled(row) {
+ const hit = allTemplates.value.find((t) => t.id === row.id);
+ if (!hit) return;
+ hit.enabled = !hit.enabled;
+ hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ persist();
+ }
+
+ return {
+ Search,
+ activeTab,
+ builtinTemplates,
+ nodeSignModeLabel,
+ flowNodesSummary,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ submitForm,
+ toggleEnabled,
+ };
+}
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..7c3849b
--- /dev/null
+++ b/src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
@@ -0,0 +1,934 @@
+<!--OA妯″潡锛氳鍋囩敵璇�-->
+<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"
+ />
+ <span class="search_title" style="margin-left: 12px">璇峰亣绫诲瀷锛�</span>
+ <el-select v-model="searchForm.leaveType" placeholder="鍏ㄩ儴" clearable style="width: 180px">
+ <el-option v-for="opt in LEAVE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <el-button type="primary" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+ <el-button @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div>
+ <el-button type="primary" @click="openFormDialog('add')">鏂板璇峰亣鐢宠</el-button>
+ </div>
+ </div>
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ @pagination="pagination"
+ :total="page.total"
+ />
+ </div>
+
+ <!-- 鏂板 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="960px"
+ append-to-body
+ destroy-on-close
+ class="leave-apply-form-dialog"
+ @closed="onFormClosed"
+ >
+ <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="leave-apply-form">
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <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="24">
+ <el-col :span="12">
+ <el-form-item label="璇峰亣绫诲瀷" prop="leaveType">
+ <el-select v-model="form.leaveType" placeholder="璇烽�夋嫨璇峰亣绫诲瀷" clearable filterable style="width: 100%">
+ <el-option v-for="opt in LEAVE_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="鍋囨湡浣欓" 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-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="璇峰亣寮�濮嬫椂闂�" prop="leaveStartTime">
+ <el-date-picker
+ v-model="form.leaveStartTime"
+ type="datetime"
+ placeholder="璇烽�夋嫨寮�濮嬫椂闂�"
+ format="YYYY-MM-DD HH:mm:ss"
+ value-format="YYYY-MM-DD HH:mm:ss"
+ style="width: 100%"
+ @change="onLeaveRangeChange"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="璇峰亣缁撴潫鏃堕棿" prop="leaveEndTime">
+ <el-date-picker
+ v-model="form.leaveEndTime"
+ type="datetime"
+ placeholder="璇烽�夋嫨缁撴潫鏃堕棿"
+ format="YYYY-MM-DD HH:mm:ss"
+ value-format="YYYY-MM-DD HH:mm:ss"
+ style="width: 100%"
+ @change="onLeaveRangeChange"
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="璇峰亣鏃堕暱">
+ <el-input :model-value="leaveDurationDisplay" readonly placeholder="鏍规嵁璧锋鏃堕棿鑷姩璁$畻">
+ <template #append>澶�</template>
+ </el-input>
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
+ <el-radio-group v-model="form.approvalMode">
+ <el-radio value="parallel">涓庣</el-radio>
+ <el-radio value="or_sign">鎴栫</el-radio>
+ </el-radio-group>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="瀹℃壒浜�" prop="approverIds">
+ <el-tree-select
+ v-model="form.approverIds"
+ :data="approverTreeData"
+ multiple
+ collapse-tags
+ collapse-tags-tooltip
+ :max-collapse-tags="2"
+ :render-after-expand="false"
+ placeholder="璇烽�夋嫨瀹℃壒浜猴紙鍙閫夛級"
+ style="width: 100%"
+ :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
+ check-strictly
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="璇峰亣浜嬬敱" prop="leaveReason">
+ <el-input
+ v-model="form.leaveReason"
+ type="textarea"
+ :rows="4"
+ placeholder="璇峰~鍐欒鍋囦簨鐢�"
+ maxlength="2000"
+ show-word-limit
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="闄勪欢">
+ <div class="upload-block">
+ <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="鐐瑰嚮閫夋嫨鏂囦欢" />
+ </div>
+ </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="formDialog.visible = false">鍙� 娑�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="璇峰亣鐢宠璇︽儏" width="720px" append-to-body>
+ <el-descriptions :column="1" border>
+ <el-descriptions-item label="鐢宠浜虹紪鍙�">{{ detailRow.applicantNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鐢宠浜�">{{ detailRow.applicantName }}</el-descriptions-item>
+ <el-descriptions-item label="璇峰亣绫诲瀷">{{ leaveTypeLabel(detailRow.leaveType) }}</el-descriptions-item>
+ <el-descriptions-item label="鍋囨湡浣欓">{{ formatBalance(detailRow.leaveBalanceDays) }}</el-descriptions-item>
+ <el-descriptions-item label="璇峰亣寮�濮嬫椂闂�">{{ detailRow.leaveStartTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="璇峰亣缁撴潫鏃堕棿">{{ detailRow.leaveEndTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="璇峰亣鏃堕暱">{{ formatDuration(detailRow.leaveDurationDays) }}</el-descriptions-item>
+ <el-descriptions-item label="璇峰亣浜嬬敱">{{ detailRow.leaveReason }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒缁撴灉">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒鏂瑰紡">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒浜�">{{ detailRow.approverNames || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍒涘缓鏃堕棿">{{ detailRow.createTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="闄勪欢">
+ <template v-if="detailRow.attachmentList?.length">
+ <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info">
+ {{ f.name }}
+ </el-tag>
+ </template>
+ <span v-else>鏃�</span>
+ </el-descriptions-item>
+ </el-descriptions>
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 闄勪欢鍒楄〃 -->
+ <el-dialog v-model="filesDialog.visible" title="闄勪欢" width="520px" append-to-body>
+ <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
+ <el-table-column type="index" label="搴忓彿" width="60" align="center" />
+ <el-table-column prop="name" label="鏂囦欢鍚�" min-width="200" show-overflow-tooltip />
+ <el-table-column label="鎿嶄綔" width="100" align="center">
+ <template #default="{ row }">
+ <el-button link type="primary" @click="mockDownload(row)">涓嬭浇</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ <el-empty v-else description="鏆傛棤闄勪欢" />
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="filesDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+
+/** 璇峰亣绫诲瀷锛坴alue 涓庡悗绔榻愬崰浣嶏級 */
+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 leaveTypeLabel(v) {
+ const hit = LEAVE_TYPE_OPTIONS.find((x) => x.value === v);
+ return hit?.label || "鈥�";
+}
+
+/** 涓庡悗绔害瀹氬瓧娈碉紙鍗犱綅锛� */
+const createEmptyForm = () => ({
+ id: undefined,
+ applicantId: "",
+ applicantNo: "",
+ applicantName: "",
+ leaveType: "",
+ leaveBalanceDays: undefined,
+ leaveStartTime: "",
+ leaveEndTime: "",
+ leaveReason: "",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+ attachmentList: [],
+});
+
+const { proxy } = getCurrentInstance();
+
+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 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;
+ });
+}
+
+function getUserDeptId(u) {
+ return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
+}
+
+function getDeptNodeKey(node) {
+ const k = node?.id ?? node?.value ?? node?.deptId;
+ if (k == null || k === "") return null;
+ return k;
+}
+
+function isActiveUser(u) {
+ if (u.delFlag === "2" || u.delFlag === 2) return false;
+ if (u.status == null) return true;
+ return String(u.status) === "0";
+}
+
+function userToTreeLeaf(u) {
+ return {
+ id: String(u.userId ?? u.id),
+ label: u.nickName || u.userName || `鐢ㄦ埛${u.userId ?? u.id}`,
+ };
+}
+
+function buildUsersByDeptId(users) {
+ const map = new Map();
+ const unassigned = [];
+ for (const u of users) {
+ if (!isActiveUser(u)) continue;
+ const did = getUserDeptId(u);
+ if (did == null || did === "" || did === 0 || did === "0") {
+ unassigned.push(u);
+ continue;
+ }
+ const k = String(did);
+ if (!map.has(k)) map.set(k, []);
+ map.get(k).push(u);
+ }
+ return { map, unassigned };
+}
+
+function collectUserLabels(nodes, map) {
+ (nodes || []).forEach((n) => {
+ if (n.children?.length) {
+ collectUserLabels(n.children, map);
+ } else if (n.id != null && !String(n.id).startsWith("dept_")) {
+ map[String(n.id)] = n.label;
+ }
+ });
+}
+
+function mergeDeptTreeWithUsers(nodes, usersByDept) {
+ if (!Array.isArray(nodes)) return [];
+ const out = [];
+ for (const node of nodes) {
+ const deptIdRaw = getDeptNodeKey(node);
+ if (deptIdRaw == null) continue;
+ const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
+ const usersHere = usersByDept.get(String(deptIdRaw)) || [];
+ const userChildren = usersHere.map(userToTreeLeaf);
+ const children = [...sub, ...userChildren];
+ if (!children.length) continue;
+ out.push({
+ id: `dept_${deptIdRaw}`,
+ label: node.label ?? node.deptName ?? "閮ㄩ棬",
+ disabled: true,
+ children,
+ });
+ }
+ return out;
+}
+
+function buildFlatApproverTree(users) {
+ const list = users.filter(isActiveUser).map(userToTreeLeaf);
+ if (!list.length) return [];
+ return [
+ {
+ id: "dept_all_users",
+ label: "绯荤粺鐢ㄦ埛",
+ disabled: true,
+ children: list,
+ },
+ ];
+}
+
+const approverTreeData = ref([]);
+const approverLabelMap = ref({});
+
+async function loadApproverTree() {
+ try {
+ const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]);
+ let rawTree = unwrapArray(deptRes);
+ rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
+ let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
+ if (!deptTree.length && rawTree.length) {
+ deptTree = JSON.parse(JSON.stringify(rawTree));
+ }
+ const users = unwrapArray(userRes);
+ const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
+ let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
+ if (unassigned.length) {
+ merged.push({
+ id: "dept_unassigned",
+ label: "鏈垎閰嶉儴闂�",
+ disabled: true,
+ children: unassigned.map(userToTreeLeaf),
+ });
+ }
+ if (!merged.length && users.length) {
+ merged = buildFlatApproverTree(users);
+ }
+ approverTreeData.value = merged;
+ const map = {};
+ collectUserLabels(merged, map);
+ approverLabelMap.value = map;
+ } catch {
+ approverTreeData.value = [];
+ approverLabelMap.value = {};
+ proxy?.$modal?.msgWarning?.("瀹℃壒浜烘暟鎹姞杞藉け璐ワ紝璇锋鏌ョ綉缁滄垨绋嶅悗閲嶈瘯");
+ }
+}
+
+function resolveApproverNames(ids) {
+ if (!ids?.length) return "";
+ const map = approverLabelMap.value;
+ return ids.map((id) => map[String(id)] || id).join("銆�");
+}
+
+function approvalModeLabel(mode) {
+ if (mode === "or_sign") return "鎴栫";
+ return "涓庣";
+}
+
+function approvalResultLabel(v) {
+ if (v === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ if (v === "cancelled") return "宸叉挙閿�";
+ return "寰呭鎵�";
+}
+
+/** 鎸夎捣姝㈡椂闂磋绠楄鍋囧ぉ鏁帮紙鍚椂鍒嗙锛岀粨鏋滀繚鐣欎袱浣嶅皬鏁帮級 */
+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 formatDuration(v) {
+ if (v == null || v === "") return "鈥�";
+ return `${v} 澶ー;
+}
+
+function formatBalance(v) {
+ if (v == null || v === "") return "鈥�";
+ return `${v} 澶ー;
+}
+
+/** 绯荤粺鐢ㄦ埛缂撳瓨 */
+const allUsersCache = ref([]);
+
+async function loadUserPool() {
+ try {
+ const res = await userListNoPageByTenantId();
+ allUsersCache.value = unwrapArray(res);
+ } 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) {
+ if (id == null || id === "") return undefined;
+ return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
+}
+
+function applicantNoFromUser(u) {
+ if (!u) return "";
+ return (
+ u.userName ??
+ u.userCode ??
+ u.jobNumber ??
+ u.workNo ??
+ (u.userId != null ? String(u.userId) : "")
+ );
+}
+
+/** 鏈湴妯℃嫙锛氭牴鎹敤鎴风敓鎴愮ǔ瀹氥�屽亣鏈熶綑棰濄�嶅崰浣� */
+function mockLeaveBalance(u) {
+ if (!u) return undefined;
+ const idStr = String(u.userId ?? u.id ?? "0");
+ let s = 0;
+ for (let i = 0; i < idStr.length; i++) s += idStr.charCodeAt(i);
+ return Math.round(((s % 130) / 10 + 5) * 100) / 100;
+}
+
+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);
+ });
+}
+
+const applicantFormSearchLoading = ref(false);
+const applicantFormOptions = ref([]);
+
+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.applicantName = u.nickName || u.userName || "";
+ form.applicantNo = applicantNoFromUser(u);
+ form.leaveBalanceDays = mockLeaveBalance(u);
+ } else {
+ form.applicantName = "";
+ form.applicantNo = "";
+ form.leaveBalanceDays = undefined;
+ }
+}
+
+/** 鏈湴妯℃嫙鍒楄〃鏁版嵁 */
+const allRows = ref([
+ {
+ id: "1",
+ applicantId: "mock_1",
+ applicantNo: "zhangsan",
+ applicantName: "寮犱笁",
+ leaveType: "annual",
+ leaveBalanceDays: 12,
+ leaveStartTime: "2026-05-10 09:00:00",
+ leaveEndTime: "2026-05-12 18:00:00",
+ leaveDurationDays: 2.38,
+ leaveReason: "骞翠紤鍋囪繑涔℃帰浜层��",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+ approvalResult: "pending",
+ attachmentList: [{ name: "杞︾エ璁㈠崟.pdf" }],
+ createTime: "2026-05-09 10:20:00",
+ },
+ {
+ id: "2",
+ applicantId: "mock_2",
+ applicantNo: "lisi",
+ applicantName: "鏉庡洓",
+ leaveType: "sick",
+ leaveBalanceDays: 0,
+ leaveStartTime: "2026-05-14 08:30:00",
+ leaveEndTime: "2026-05-14 12:00:00",
+ leaveDurationDays: 0.15,
+ leaveReason: "涓婂崍闂ㄨ瘖澶嶆煡銆�",
+ approvalMode: "or_sign",
+ approverIds: [],
+ approverNames: "",
+ approvalResult: "approved",
+ attachmentList: [],
+ createTime: "2026-05-13 16:00:00",
+ },
+]);
+
+const searchForm = reactive({
+ applicantKeyword: "",
+ leaveType: "",
+});
+
+const tableLoading = ref(false);
+const page = reactive({
+ current: 1,
+ size: 10,
+ total: 0,
+});
+
+const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.applicantName || "").toLowerCase();
+ const no = (r.applicantNo || "").toLowerCase();
+ return name.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.leaveType) {
+ list = list.filter((r) => r.leaveType === searchForm.leaveType);
+ }
+ return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+});
+
+watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) {
+ page.current = maxPage;
+ }
+ },
+ { immediate: true }
+);
+
+const tableData = computed(() => {
+ const list = filteredList.value;
+ const start = (page.current - 1) * page.size;
+ return list.slice(start, start + page.size);
+});
+
+const tableColumn = ref([
+ { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 120 },
+ { label: "鐢宠浜�", prop: "applicantName", minWidth: 100 },
+ {
+ label: "璇峰亣绫诲瀷",
+ prop: "leaveType",
+ width: 100,
+ formatData: (v) => leaveTypeLabel(v),
+ },
+ {
+ label: "璇峰亣鏃堕暱",
+ prop: "leaveDurationDays",
+ width: 120,
+ formatData: (v) => (v == null || v === "" ? "鈥�" : `${v} 澶ー),
+ },
+ { label: "璇峰亣浜嬬敱", prop: "leaveReason", minWidth: 180 },
+ { label: "鍒涘缓鏃堕棿", prop: "createTime", width: 170 },
+ {
+ label: "瀹℃壒缁撴灉",
+ prop: "approvalResult",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => approvalResultLabel(v),
+ formatType: (v) => {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ if (v === "cancelled") return "info";
+ return "warning";
+ },
+ },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 220,
+ operation: [
+ {
+ name: "缂栬緫",
+ type: "text",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "鏌ョ湅璇︽儏",
+ type: "text",
+ clickFun: (row) => openDetail(row),
+ },
+ {
+ name: "闄勪欢",
+ type: "text",
+ clickFun: (row) => openFiles(row),
+ },
+ ],
+ },
+]);
+
+const formDialog = reactive({
+ visible: false,
+ title: "",
+ mode: "add",
+});
+const formRef = ref();
+const form = reactive(createEmptyForm());
+
+const leaveDurationDisplay = computed(() => {
+ const d = computeLeaveDays(form.leaveStartTime, form.leaveEndTime);
+ return d == null ? "" : String(d);
+});
+
+function onLeaveRangeChange() {
+ nextTick(() => {
+ formRef.value?.validateField?.("leaveEndTime");
+ });
+}
+
+const formRules = {
+ applicantId: [{ required: true, message: "璇烽�夋嫨鐢宠浜�", trigger: "change" }],
+ leaveType: [{ required: true, message: "璇烽�夋嫨璇峰亣绫诲瀷", trigger: "change" }],
+ leaveBalanceDays: [
+ {
+ required: true,
+ message: "璇峰~鍐欏亣鏈熶綑棰�",
+ trigger: "blur",
+ },
+ ],
+ leaveStartTime: [{ required: true, message: "璇烽�夋嫨璇峰亣寮�濮嬫椂闂�", trigger: "change" }],
+ leaveEndTime: [
+ { required: true, message: "璇烽�夋嫨璇峰亣缁撴潫鏃堕棿", trigger: "change" },
+ {
+ validator: (_rule, val, callback) => {
+ if (!form.leaveStartTime || !val) {
+ callback();
+ return;
+ }
+ const d = computeLeaveDays(form.leaveStartTime, val);
+ if (d == null) {
+ callback(new Error("缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�"));
+ } else {
+ callback();
+ }
+ },
+ trigger: "change",
+ },
+ ],
+ leaveReason: [{ required: true, message: "璇峰~鍐欒鍋囦簨鐢�", trigger: "blur" }],
+ approvalMode: [{ required: true, message: "璇烽�夋嫨瀹℃壒鏂瑰紡", trigger: "change" }],
+ approverIds: [
+ {
+ type: "array",
+ required: true,
+ message: "璇烽�夋嫨瀹℃壒浜�",
+ trigger: "change",
+ },
+ ],
+};
+
+const detailDialog = reactive({ visible: false });
+const detailRow = ref({});
+
+const filesDialog = reactive({ visible: false, row: null });
+
+function handleQuery() {
+ page.current = 1;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+}
+
+function resetSearch() {
+ searchForm.applicantKeyword = "";
+ searchForm.leaveType = "";
+ handleQuery();
+}
+
+function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+}
+
+function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+}
+
+function openFiles(row) {
+ filesDialog.row = row;
+ filesDialog.visible = true;
+}
+
+function mockDownload(row) {
+ const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
+ if (url) {
+ window.open(url, "_blank");
+ return;
+ }
+ proxy?.$modal?.msgSuccess?.(`宸叉ā鎷熶笅杞斤細${row.name}`);
+}
+
+async function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.title = mode === "add" ? "鏂板璇峰亣鐢宠" : "缂栬緫璇峰亣鐢宠";
+ await loadApproverTree();
+ if (!allUsersCache.value.length) {
+ await loadUserPool();
+ }
+ Object.assign(form, createEmptyForm());
+ if (mode === "edit" && row) {
+ Object.assign(form, {
+ id: row.id,
+ applicantId: row.applicantId,
+ applicantNo: row.applicantNo,
+ applicantName: row.applicantName,
+ leaveType: row.leaveType,
+ leaveBalanceDays: row.leaveBalanceDays,
+ leaveStartTime: row.leaveStartTime,
+ leaveEndTime: row.leaveEndTime,
+ leaveReason: row.leaveReason,
+ approvalMode: row.approvalMode === "countersign" ? "or_sign" : row.approvalMode || "parallel",
+ approverIds: (row.approverIds || []).map((id) => String(id)),
+ approverNames: row.approverNames,
+ attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
+ });
+ const u = userById(row.applicantId);
+ if (u) {
+ applicantFormOptions.value = [u];
+ } else if (row.applicantId) {
+ applicantFormOptions.value = [
+ {
+ userId: row.applicantId,
+ nickName: row.applicantName,
+ userName: row.applicantNo,
+ },
+ ];
+ }
+ } else {
+ remoteSearchApplicantForm("");
+ }
+ formDialog.visible = true;
+ nextTick(() => formRef.value?.clearValidate?.());
+}
+
+function onFormClosed() {
+ formRef.value?.resetFields?.();
+}
+
+async function submitForm() {
+ try {
+ await formRef.value?.validate?.();
+ } catch {
+ return;
+ }
+ const days = computeLeaveDays(form.leaveStartTime, form.leaveEndTime);
+ if (days == null) {
+ proxy?.$modal?.msgWarning?.("璇锋鏌ヨ鍋囪捣姝㈡椂闂达紝缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�");
+ return;
+ }
+ form.approverNames = resolveApproverNames(form.approverIds);
+ const payload = {
+ applicantId: form.applicantId,
+ applicantNo: form.applicantNo,
+ applicantName: form.applicantName,
+ leaveType: form.leaveType,
+ leaveBalanceDays: form.leaveBalanceDays,
+ leaveStartTime: form.leaveStartTime,
+ leaveEndTime: form.leaveEndTime,
+ leaveDurationDays: days,
+ leaveReason: form.leaveReason,
+ approvalMode: form.approvalMode,
+ approverIds: [...form.approverIds],
+ approverNames: form.approverNames,
+ attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+ };
+ if (formDialog.mode === "add") {
+ const id = `local_${Date.now()}`;
+ allRows.value.unshift({
+ id,
+ ...payload,
+ approvalResult: "pending",
+ createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ });
+ proxy?.$modal?.msgSuccess?.("鏂板鎴愬姛锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx !== -1) {
+ const prev = allRows.value[idx];
+ allRows.value[idx] = {
+ ...prev,
+ id: form.id,
+ ...payload,
+ approvalResult: prev.approvalResult ?? "pending",
+ createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+}
+
+onMounted(() => {
+ loadApproverTree();
+});
+</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);
+}
+.upload-block {
+ width: 100%;
+}
+.mr6 {
+ margin-right: 6px;
+}
+.mb6 {
+ margin-bottom: 6px;
+}
+.leave-apply-form :deep(.el-row) {
+ margin-bottom: 0;
+}
+.leave-apply-form :deep(.el-form-item) {
+ margin-bottom: 18px;
+}
+.leave-apply-form-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+</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..0bdd83f
--- /dev/null
+++ b/src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
@@ -0,0 +1,909 @@
+<!--OA妯″潡锛氬姞鐝敵璇�-->
+<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"
+ />
+ <span class="search_title" style="margin-left: 12px">鍔犵彮绫诲瀷锛�</span>
+ <el-select v-model="searchForm.overtimeType" placeholder="鍏ㄩ儴" clearable style="width: 180px">
+ <el-option v-for="opt in OVERTIME_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ <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"
+ @pagination="pagination"
+ :total="page.total"
+ />
+ </div>
+
+ <!-- 鏂板 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="1040px"
+ append-to-body
+ destroy-on-close
+ class="overtime-apply-form-dialog"
+ @closed="onFormClosed"
+ >
+ <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="overtime-apply-form">
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <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="24">
+ <el-col :span="12">
+ <el-form-item label="鍔犵彮绫诲瀷" prop="overtimeType">
+ <el-select v-model="form.overtimeType" placeholder="璇烽�夋嫨鍔犵彮绫诲瀷" clearable filterable style="width: 100%">
+ <el-option v-for="opt in OVERTIME_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="鍔犵彮鏃ユ湡" prop="overtimeDate">
+ <el-date-picker
+ v-model="form.overtimeDate"
+ type="date"
+ placeholder="璇烽�夋嫨鍔犵彮鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="鍔犵彮寮�濮嬫棩鏈�" prop="overtimeStartTime">
+ <el-date-picker
+ v-model="form.overtimeStartTime"
+ type="datetime"
+ placeholder="璇烽�夋嫨寮�濮嬫椂闂�"
+ format="YYYY-MM-DD HH:mm:ss"
+ value-format="YYYY-MM-DD HH:mm:ss"
+ style="width: 100%"
+ @change="onOvertimeRangeChange"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="鍔犵彮缁撴潫鏃ユ湡" prop="overtimeEndTime">
+ <el-date-picker
+ v-model="form.overtimeEndTime"
+ type="datetime"
+ placeholder="璇烽�夋嫨缁撴潫鏃堕棿"
+ format="YYYY-MM-DD HH:mm:ss"
+ value-format="YYYY-MM-DD HH:mm:ss"
+ style="width: 100%"
+ @change="onOvertimeRangeChange"
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="鍔犵彮鏃堕暱">
+ <el-input :model-value="overtimeHoursDisplay" readonly placeholder="鏍规嵁璧锋鏃堕棿鑷姩璁$畻">
+ <template #append>灏忔椂</template>
+ </el-input>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="瀹℃壒娴佺▼" prop="approvalFlowNodes">
+ <ApprovalFlowEditor
+ v-model="form.approvalFlowNodes"
+ :user-options="flowUserOptions"
+ @update:model-value="onApprovalFlowChange"
+ />
+ <p class="flow-tip">鑷冲皯淇濈暀涓�涓妭鐐癸紱姣忎釜鑺傜偣閫夋嫨涓�鍚嶅鎵逛汉锛涘彲鏂板銆佸垹闄ゆ垨璋冩暣椤哄簭銆�</p>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="鍔犵彮浜嬬敱" prop="overtimeReason">
+ <el-input
+ v-model="form.overtimeReason"
+ type="textarea"
+ :rows="4"
+ placeholder="璇峰~鍐欏姞鐝簨鐢�"
+ maxlength="2000"
+ show-word-limit
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="闄勪欢">
+ <div class="upload-block">
+ <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="鐐瑰嚮閫夋嫨鏂囦欢" />
+ </div>
+ </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="formDialog.visible = false">鍙� 娑�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="鍔犵彮鐢宠璇︽儏" width="720px" append-to-body>
+ <el-descriptions :column="1" border>
+ <el-descriptions-item label="鐢宠浜虹紪鍙�">{{ detailRow.applicantNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鐢宠浜�">{{ detailRow.applicantName }}</el-descriptions-item>
+ <el-descriptions-item label="鍔犵彮绫诲瀷">{{ overtimeTypeLabel(detailRow.overtimeType) }}</el-descriptions-item>
+ <el-descriptions-item label="鍔犵彮鏃ユ湡">{{ detailRow.overtimeDate || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍔犵彮寮�濮嬫棩鏈�">{{ detailRow.overtimeStartTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍔犵彮缁撴潫鏃ユ湡">{{ detailRow.overtimeEndTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍔犵彮鏃堕暱">{{ formatHours(detailRow.overtimeHours) }}</el-descriptions-item>
+ <el-descriptions-item label="鍔犵彮浜嬬敱">{{ detailRow.overtimeReason }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒娴佺▼">
+ <template v-if="sortedApprovalNodes(detailRow).length">
+ <div class="detail-flow-chain">
+ <template v-for="(n, i) in sortedApprovalNodes(detailRow)" :key="i">
+ <span class="detail-flow-step">{{ i + 1 }}. {{ approvalNodeLabel(n) }}</span>
+ <span v-if="i < sortedApprovalNodes(detailRow).length - 1" class="detail-flow-sep">鈫�</span>
+ </template>
+ </div>
+ </template>
+ <span v-else>鈥�</span>
+ </el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒缁撴灉">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
+ <el-descriptions-item label="鍒涘缓鏃堕棿">{{ detailRow.createTime || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="闄勪欢">
+ <template v-if="detailRow.attachmentList?.length">
+ <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info">
+ {{ f.name }}
+ </el-tag>
+ </template>
+ <span v-else>鏃�</span>
+ </el-descriptions-item>
+ </el-descriptions>
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 闄勪欢鍒楄〃 -->
+ <el-dialog v-model="filesDialog.visible" title="闄勪欢" width="520px" append-to-body>
+ <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
+ <el-table-column type="index" label="搴忓彿" width="60" align="center" />
+ <el-table-column prop="name" label="鏂囦欢鍚�" min-width="200" show-overflow-tooltip />
+ <el-table-column label="鎿嶄綔" width="100" align="center">
+ <template #default="{ row }">
+ <el-button link type="primary" @click="mockDownload(row)">涓嬭浇</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ <el-empty v-else description="鏆傛棤闄勪欢" />
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="filesDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import ApprovalFlowEditor from "./components/ApprovalFlowEditor.vue";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from "vue";
+
+/** 鍔犵彮绫诲瀷锛坴alue 涓庡悗绔榻愬崰浣嶏級 */
+const OVERTIME_TYPE_OPTIONS = [
+ { label: "宸ヤ綔鏃ュ姞鐝�", value: "weekday" },
+ { label: "浼戞伅鏃ュ姞鐝�", value: "weekend" },
+ { label: "娉曞畾鑺傚亣鏃ュ姞鐝�", value: "holiday" },
+];
+
+/** 鏈湴婕旂ず锛氫袱鏉$┖鑺傜偣锛屾彁浜ゅ墠椤讳负姣忚妭鐐归�夋嫨瀹℃壒浜� */
+function demoApprovalFlowNodes() {
+ return [
+ { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" },
+ { approverId: null, approverName: "", sortOrder: 2, nodeOrder: 2, roleName: "", roleCode: "" },
+ ];
+}
+
+function sortedApprovalNodes(row) {
+ const list = row?.approvalFlowNodes;
+ if (!Array.isArray(list) || !list.length) return [];
+ return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
+}
+
+function approvalNodeLabel(n) {
+ const name = (n.approverName || "").trim();
+ if (name) return name;
+ return "鏈�夋嫨瀹℃壒浜�";
+}
+
+function overtimeTypeLabel(v) {
+ const hit = OVERTIME_TYPE_OPTIONS.find((x) => x.value === v);
+ return hit?.label || "鈥�";
+}
+
+const createEmptyForm = () => ({
+ id: undefined,
+ applicantId: "",
+ applicantNo: "",
+ applicantName: "",
+ overtimeType: "",
+ overtimeDate: "",
+ overtimeStartTime: "",
+ overtimeEndTime: "",
+ overtimeReason: "",
+ attachmentList: [],
+ approvalFlowNodes: [
+ { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" },
+ ],
+});
+
+const { proxy } = getCurrentInstance();
+
+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 approvalResultLabel(v) {
+ if (v === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ if (v === "cancelled") return "宸叉挙閿�";
+ return "寰呭鎵�";
+}
+
+/** 鎸夎捣姝㈡椂闂磋绠楀姞鐝椂闀匡紙灏忔椂锛屼繚鐣欎袱浣嶅皬鏁帮級 */
+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;
+ const hours = t1.diff(t0, "millisecond") / (60 * 60 * 1000);
+ return Math.round(hours * 100) / 100;
+}
+
+function formatHours(v) {
+ if (v == null || v === "") return "鈥�";
+ return `${v} 灏忔椂`;
+}
+
+const allUsersCache = ref([]);
+
+async function loadUserPool() {
+ try {
+ const res = await userListNoPageByTenantId();
+ allUsersCache.value = unwrapArray(res);
+ } 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) {
+ if (id == null || id === "") return undefined;
+ return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
+}
+
+function applicantNoFromUser(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((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);
+ });
+}
+
+const applicantFormSearchLoading = ref(false);
+const applicantFormOptions = ref([]);
+
+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.applicantName = u.nickName || u.userName || "";
+ form.applicantNo = applicantNoFromUser(u);
+ } else {
+ form.applicantName = "";
+ form.applicantNo = "";
+ }
+}
+
+const allRows = ref([
+ {
+ id: "1",
+ applicantId: "mock_1",
+ applicantNo: "zhangsan",
+ applicantName: "寮犱笁",
+ overtimeType: "weekday",
+ overtimeDate: "2026-05-10",
+ overtimeStartTime: "2026-05-10 18:00:00",
+ overtimeEndTime: "2026-05-10 21:30:00",
+ overtimeHours: 3.5,
+ overtimeReason: "椤圭洰涓婄嚎淇濋殰銆�",
+ approvalFlowNodes: demoApprovalFlowNodes(),
+ approvalResult: "pending",
+ attachmentList: [{ name: "浠诲姟鍗�.pdf" }],
+ createTime: "2026-05-09 10:20:00",
+ },
+ {
+ id: "2",
+ applicantId: "mock_2",
+ applicantNo: "lisi",
+ applicantName: "鏉庡洓",
+ overtimeType: "weekend",
+ overtimeDate: "2026-05-11",
+ overtimeStartTime: "2026-05-11 09:00:00",
+ overtimeEndTime: "2026-05-11 12:15:00",
+ overtimeHours: 3.25,
+ overtimeReason: "瀹㈡埛鐜板満鏀寔銆�",
+ approvalFlowNodes: demoApprovalFlowNodes(),
+ approvalResult: "approved",
+ attachmentList: [],
+ createTime: "2026-05-10 16:00:00",
+ },
+]);
+
+const searchForm = reactive({
+ applicantKeyword: "",
+ overtimeType: "",
+});
+
+const tableLoading = ref(false);
+const page = reactive({
+ current: 1,
+ size: 10,
+ total: 0,
+});
+
+const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.applicantName || "").toLowerCase();
+ const no = (r.applicantNo || "").toLowerCase();
+ return name.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.overtimeType) {
+ list = list.filter((r) => r.overtimeType === searchForm.overtimeType);
+ }
+ return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+});
+
+watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) {
+ page.current = maxPage;
+ }
+ },
+ { immediate: true }
+);
+
+const tableData = computed(() => {
+ const list = filteredList.value;
+ const start = (page.current - 1) * page.size;
+ return list.slice(start, start + page.size);
+});
+
+const tableColumn = ref([
+ { label: "鐢宠浜虹紪鍙�", prop: "applicantNo", width: 120 },
+ { label: "鐢宠浜�", prop: "applicantName", minWidth: 100 },
+ { label: "鍔犵彮鏃ユ湡", prop: "overtimeDate", width: 120 },
+ { label: "鍔犵彮寮�濮嬫棩鏈�", prop: "overtimeStartTime", width: 170 },
+ { label: "鍔犵彮缁撴潫鏃ユ湡", prop: "overtimeEndTime", width: 170 },
+ {
+ label: "鍔犵彮鏃堕暱",
+ prop: "overtimeHours",
+ width: 120,
+ formatData: (v) => (v == null || v === "" ? "鈥�" : `${v} 灏忔椂`),
+ },
+ {
+ label: "瀹℃壒缁撴灉",
+ prop: "approvalResult",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => approvalResultLabel(v),
+ formatType: (v) => {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ if (v === "cancelled") return "info";
+ return "warning";
+ },
+ },
+ { label: "鍒涘缓鏃堕棿", prop: "createTime", width: 170 },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 220,
+ operation: [
+ {
+ name: "缂栬緫",
+ type: "text",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "鏌ョ湅璇︽儏",
+ type: "text",
+ clickFun: (row) => openDetail(row),
+ },
+ {
+ name: "闄勪欢",
+ type: "text",
+ clickFun: (row) => openFiles(row),
+ },
+ ],
+ },
+]);
+
+const formDialog = reactive({
+ visible: false,
+ title: "",
+ mode: "add",
+});
+const formRef = ref();
+const form = reactive(createEmptyForm());
+
+const flowUserOptions = computed(() => allUsersCache.value.filter((u) => isActiveUser(u)));
+
+const overtimeHoursDisplay = computed(() => {
+ const h = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
+ return h == null ? "" : String(h);
+});
+
+function onOvertimeRangeChange() {
+ nextTick(() => {
+ formRef.value?.validateField?.("overtimeEndTime");
+ });
+}
+
+function onApprovalFlowChange() {
+ nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
+}
+
+const formRules = {
+ applicantId: [{ required: true, message: "璇烽�夋嫨鐢宠浜�", trigger: "change" }],
+ overtimeType: [{ required: true, message: "璇烽�夋嫨鍔犵彮绫诲瀷", trigger: "change" }],
+ overtimeDate: [{ required: true, message: "璇烽�夋嫨鍔犵彮鏃ユ湡", trigger: "change" }],
+ overtimeStartTime: [{ required: true, message: "璇烽�夋嫨鍔犵彮寮�濮嬫椂闂�", trigger: "change" }],
+ overtimeEndTime: [
+ { required: true, message: "璇烽�夋嫨鍔犵彮缁撴潫鏃堕棿", trigger: "change" },
+ {
+ validator: (_rule, val, callback) => {
+ if (!form.overtimeStartTime || !val) {
+ callback();
+ return;
+ }
+ const h = computeOvertimeHours(form.overtimeStartTime, val);
+ if (h == null) {
+ callback(new Error("缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�"));
+ } else {
+ callback();
+ }
+ },
+ trigger: "change",
+ },
+ ],
+ overtimeReason: [{ required: true, message: "璇峰~鍐欏姞鐝簨鐢�", trigger: "blur" }],
+ approvalFlowNodes: [
+ {
+ validator: (_rule, _val, callback) => {
+ const nodes = form.approvalFlowNodes || [];
+ if (!nodes.length) {
+ callback(new Error("璇疯嚦灏戜繚鐣欎竴涓鎵硅妭鐐�"));
+ return;
+ }
+ if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
+ callback(new Error("姣忎釜瀹℃壒鑺傜偣蹇呴』閫夋嫨涓�鍚嶅鎵逛汉"));
+ return;
+ }
+ const ids = nodes.map((n) => String(n.approverId));
+ if (new Set(ids).size !== ids.length) {
+ callback(new Error("鍚屼竴瀹℃壒浜轰笉鑳介噸澶嶅嚭鐜板湪澶氫釜鑺傜偣"));
+ return;
+ }
+ callback();
+ },
+ trigger: "change",
+ },
+ ],
+};
+
+const detailDialog = reactive({ visible: false });
+const detailRow = ref({});
+
+const filesDialog = reactive({ visible: false, row: null });
+
+const importInputRef = ref(null);
+
+function handleQuery() {
+ page.current = 1;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+}
+
+function resetSearch() {
+ searchForm.applicantKeyword = "";
+ searchForm.overtimeType = "";
+ handleQuery();
+}
+
+function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+}
+
+function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+}
+
+function openFiles(row) {
+ filesDialog.row = row;
+ filesDialog.visible = true;
+}
+
+function mockDownload(row) {
+ const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
+ if (url) {
+ window.open(url, "_blank");
+ return;
+ }
+ proxy?.$modal?.msgSuccess?.(`宸叉ā鎷熶笅杞斤細${row.name}`);
+}
+
+function handleExport() {
+ const data = filteredList.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} 鏉★紙褰撳墠绛涢�夌粨鏋滐紝JSON锛塦);
+}
+
+function handleImportClick() {
+ importInputRef.value?.click?.();
+}
+
+function normalizeImportedRow(raw, idx) {
+ const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
+ const hours =
+ raw.overtimeHours != null && raw.overtimeHours !== ""
+ ? Number(raw.overtimeHours)
+ : computeOvertimeHours(raw.overtimeStartTime, raw.overtimeEndTime);
+ return {
+ id,
+ applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
+ applicantNo: raw.applicantNo ?? "",
+ applicantName: raw.applicantName ?? "鏈煡",
+ overtimeType: raw.overtimeType || "weekday",
+ overtimeDate: raw.overtimeDate ?? "",
+ overtimeStartTime: raw.overtimeStartTime ?? "",
+ overtimeEndTime: raw.overtimeEndTime ?? "",
+ overtimeHours: hours == null || Number.isNaN(hours) ? 0 : Math.round(hours * 100) / 100,
+ overtimeReason: raw.overtimeReason ?? "",
+ approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
+ ? raw.approvalFlowNodes.map((n) => ({ ...n }))
+ : [],
+ approvalResult: raw.approvalResult && ["pending", "approved", "rejected", "cancelled"].includes(raw.approvalResult)
+ ? raw.approvalResult
+ : "pending",
+ attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [],
+ createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ };
+}
+
+function onImportFile(e) {
+ const input = e.target;
+ const file = input.files?.[0];
+ input.value = "";
+ if (!file) return;
+ const reader = new FileReader();
+ reader.onload = () => {
+ try {
+ const text = String(reader.result || "");
+ const parsed = JSON.parse(text);
+ const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
+ if (!Array.isArray(arr) || !arr.length) {
+ proxy?.$modal?.msgWarning?.("瀵煎叆鏂囦欢鏍煎紡涓嶆纭紝闇�涓哄姞鐝敵璇峰璞℃暟缁� JSON");
+ return;
+ }
+ let n = 0;
+ for (let i = 0; i < arr.length; i++) {
+ allRows.value.unshift(normalizeImportedRow(arr[i], i));
+ n++;
+ }
+ proxy?.$modal?.msgSuccess?.(`鎴愬姛瀵煎叆 ${n} 鏉★紙鏈湴鍚堝苟锛塦);
+ handleQuery();
+ } catch {
+ proxy?.$modal?.msgError?.("瑙f瀽澶辫触锛岃浣跨敤瀵煎嚭鏂囦欢鎴栫害瀹� JSON 缁撴瀯");
+ }
+ };
+ reader.readAsText(file, "utf-8");
+}
+
+async function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.title = mode === "add" ? "鏂板鍔犵彮鐢宠" : "缂栬緫鍔犵彮鐢宠";
+ if (!allUsersCache.value.length) {
+ await loadUserPool();
+ }
+ Object.assign(form, createEmptyForm());
+ if (mode === "edit" && row) {
+ Object.assign(form, {
+ id: row.id,
+ applicantId: row.applicantId,
+ applicantNo: row.applicantNo,
+ applicantName: row.applicantName,
+ overtimeType: row.overtimeType,
+ overtimeDate: row.overtimeDate,
+ overtimeStartTime: row.overtimeStartTime,
+ overtimeEndTime: row.overtimeEndTime,
+ overtimeReason: row.overtimeReason,
+ attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
+ approvalFlowNodes: row.approvalFlowNodes?.length
+ ? JSON.parse(JSON.stringify(row.approvalFlowNodes))
+ : [],
+ });
+ const u = userById(row.applicantId);
+ if (u) {
+ applicantFormOptions.value = [u];
+ } else if (row.applicantId) {
+ applicantFormOptions.value = [
+ {
+ userId: row.applicantId,
+ nickName: row.applicantName,
+ userName: row.applicantNo,
+ },
+ ];
+ }
+ } else {
+ remoteSearchApplicantForm("");
+ }
+ formDialog.visible = true;
+ nextTick(() => formRef.value?.clearValidate?.());
+}
+
+function onFormClosed() {
+ formRef.value?.resetFields?.();
+}
+
+async function submitForm() {
+ try {
+ await formRef.value?.validate?.();
+ } catch {
+ return;
+ }
+ const hours = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
+ if (hours == null) {
+ proxy?.$modal?.msgWarning?.("璇锋鏌ュ姞鐝捣姝㈡椂闂达紝缁撴潫鏃堕棿椤绘櫄浜庡紑濮嬫椂闂�");
+ return;
+ }
+ const payload = {
+ applicantId: form.applicantId,
+ applicantNo: form.applicantNo,
+ applicantName: form.applicantName,
+ overtimeType: form.overtimeType,
+ overtimeDate: form.overtimeDate,
+ overtimeStartTime: form.overtimeStartTime,
+ overtimeEndTime: form.overtimeEndTime,
+ overtimeHours: hours,
+ overtimeReason: form.overtimeReason,
+ approvalFlowNodes: (form.approvalFlowNodes || []).map((n, i) => ({
+ approverId: n.approverId,
+ approverName:
+ n.approverName || userById(n.approverId)?.nickName || userById(n.approverId)?.userName || "",
+ sortOrder: i + 1,
+ nodeOrder: i + 1,
+ roleName: n.roleName || "",
+ roleCode: n.roleCode || "",
+ })),
+ attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+ };
+ if (formDialog.mode === "add") {
+ const id = `local_${Date.now()}`;
+ allRows.value.unshift({
+ id,
+ ...payload,
+ approvalResult: "pending",
+ createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ });
+ proxy?.$modal?.msgSuccess?.("鏂板鎴愬姛锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx !== -1) {
+ const prev = allRows.value[idx];
+ allRows.value[idx] = {
+ ...prev,
+ id: form.id,
+ ...payload,
+ approvalResult: prev.approvalResult ?? "pending",
+ createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+}
+
+</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;
+ align-items: center;
+ 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;
+}
+.upload-block {
+ width: 100%;
+}
+.mr6 {
+ margin-right: 6px;
+}
+.mb6 {
+ margin-bottom: 6px;
+}
+.overtime-apply-form :deep(.el-row) {
+ margin-bottom: 0;
+}
+.overtime-apply-form :deep(.el-form-item) {
+ margin-bottom: 18px;
+}
+.overtime-apply-form-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+.flow-tip {
+ margin: 10px 0 0;
+ font-size: 12px;
+ line-height: 1.5;
+ color: var(--el-text-color-secondary);
+}
+.detail-flow-chain {
+ display: flex;
+ flex-wrap: wrap;
+ align-items: center;
+ gap: 6px 8px;
+ line-height: 1.6;
+}
+.detail-flow-step {
+ font-size: 14px;
+ color: var(--el-text-color-primary);
+}
+.detail-flow-sep {
+ color: var(--el-text-color-secondary);
+ font-size: 13px;
+}
+</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..bb25ba0
--- /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.publishStatus)" size="small">
+ {{ publishStatusLabel(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'">
+ <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/enterpriseNewsUtils.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
new file mode 100644
index 0000000..edc7f7e
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
@@ -0,0 +1,375 @@
+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_review", label: "寰呭鏍�", tag: "warning" },
+ { value: "published", label: "宸插彂甯�", tag: "success" },
+ { value: "archived", label: "宸插綊妗�", tag: "" },
+];
+
+/** 鎺掔増妯℃澘 */
+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: "鍐呭缂栬緫" },
+];
+
+export const STORAGE_KEY = "oa_enterprise_news_v1";
+
+/** 婕旂ず鐢ㄧ洰鏍囧彈浼楋紙鍚庢湡瀵规帴缁勭粐鏋舵瀯锛� */
+export const MOCK_AUDIENCE = [
+ { userId: "u1", employeeNo: "zhangsan", name: "寮犱笁", deptName: "鐮斿彂閮�", isManagement: false },
+ { userId: "u2", employeeNo: "lisi", name: "鏉庡洓", deptName: "鐮斿彂閮�", isManagement: false },
+ { userId: "u3", employeeNo: "wangwu", name: "鐜嬩簲", deptName: "琛屾斂閮�", isManagement: false },
+ { userId: "u4", employeeNo: "zhaoliu", name: "璧靛叚", deptName: "閿�鍞儴", isManagement: false },
+ { userId: "u5", employeeNo: "sunqi", name: "瀛欎竷", deptName: "璐㈠姟閮�", isManagement: false },
+ { userId: "u6", employeeNo: "zhouba", name: "鍛ㄥ叓", deptName: "鎬荤粡鍔�", isManagement: true },
+ { userId: "u7", employeeNo: "wujiu", name: "鍚翠節", deptName: "鎬荤粡鍔�", isManagement: true },
+ { userId: "u8", employeeNo: "zhengshi", name: "閮戝崄", deptName: "浜哄姏璧勬簮閮�", isManagement: false },
+];
+
+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) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function publishStatusTag(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.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,
+ };
+}
+
+function buildReadRecords(readUserIds = []) {
+ const set = new Set(readUserIds);
+ return MOCK_AUDIENCE.map((u) => ({
+ userId: u.userId,
+ employeeNo: u.employeeNo,
+ name: u.name,
+ deptName: u.deptName,
+ readAt: set.has(u.userId) ? dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss") : "",
+ lastRemindAt: "",
+ }));
+}
+
+function createVersionSnapshot(row, changeNote = "鍙戝竷") {
+ return {
+ versionNo: row.versionNo || 1,
+ title: row.title,
+ summary: row.summary,
+ contentHtml: row.contentHtml,
+ newsType: row.newsType,
+ publishTime: row.publishTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ archivedAt: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ changeNote,
+ publisherName: row.publisherName || "绯荤粺",
+ };
+}
+
+export function createInitialMockNews() {
+ const policyContent =
+ "<p><strong>2026 骞磋�冨嫟绠$悊鍒跺害锛堣瘯琛岋級</strong></p><p>涓�銆佷笂鐝椂闂� 9:00锛屽脊鎬ф墦鍗$獥鍙� 8:30鈥�9:30銆�</p><p>浜屻�佽鍋囬』鎻愬墠鍦� OA 鎻愪氦瀹℃壒銆�</p><p>涓夈�佹湰鍒跺害鑷� 2026-06-01 璧锋墽琛屻��</p>";
+ const cultureContent =
+ "<p>2026 浼佷笟骞翠細鍦嗘弧钀藉箷锛佹劅璋㈡瘡涓�浣嶅悓浜嬬殑鍙備笌锛屼互涓嬩负绮惧僵鐬棿鍥鹃泦銆�</p>";
+ const strategyContent =
+ "<p><strong>2026 涓嬪崐骞存垬鐣ユ柟鍚戯紙鍐呴儴锛�</strong></p><p>鑱氱劍鏍稿績浜у搧绾垮崌绾т笌娴峰甯傚満鎷撳睍锛屽叿浣撴寚鏍囪闄勪欢銆�</p>";
+
+ const policyRow = {
+ id: "news_1",
+ newsNo: "EN202605150001",
+ title: "鍏充簬鍙戝竷鏂拌�冨嫟鍒跺害鐨勯�氱煡",
+ summary: "璇峰叏浣撳憳宸ヨ鐪熼槄璇诲苟纭鐭ユ倝锛岃嚜 2026-06-01 璧锋墽琛屻��",
+ newsType: "policy",
+ layoutTemplate: "policy",
+ contentHtml: policyContent,
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [{ name: "鑰冨嫟鍒跺害2026.pdf", url: "/mock/attendance-policy.pdf" }],
+ editorRole: "hr",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "published",
+ publisherName: "浜哄姏璧勬簮閮�",
+ publishTime: "2026-05-15 10:00:00",
+ readRecords: buildReadRecords(["u6", "u7", "u8"]),
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [
+ {
+ versionNo: 1,
+ title: "鍏充簬鍙戝竷鏂拌�冨嫟鍒跺害鐨勯�氱煡锛堝緛姹傛剰瑙佺锛�",
+ summary: "寰佹眰鎰忚绋�",
+ contentHtml: "<p>寰佹眰鎰忚绋匡細涓婄彮鏃堕棿 9:00鈥︹��</p>",
+ newsType: "policy",
+ publishTime: "2026-05-10 09:00:00",
+ archivedAt: "2026-05-15 09:55:00",
+ changeNote: "瀹氱鍙戝竷",
+ publisherName: "浜哄姏璧勬簮閮�",
+ },
+ ],
+ versionNo: 2,
+ requireReadConfirm: true,
+ createTime: "2026-05-10 09:00:00",
+ updateTime: "2026-05-15 10:00:00",
+ };
+
+ const cultureRow = {
+ id: "news_2",
+ newsNo: "EN202605200002",
+ title: "2026 浼佷笟骞翠細绮惧僵鐬棿",
+ summary: "骞翠細鍥鹃泦涓婄嚎锛屾杩庣偣璧炵暀瑷�锛屽叡寤轰紒涓氭枃鍖栥��",
+ newsType: "culture",
+ layoutTemplate: "gallery",
+ contentHtml: cultureContent,
+ coverImage: "/mock/annual-cover.jpg",
+ mediaList: [
+ { type: "image", name: "寮�鍦�.jpg", url: "/mock/annual-1.jpg" },
+ { type: "image", name: "棰佸.jpg", url: "/mock/annual-2.jpg" },
+ { type: "video", name: "骞翠細鑺辩诞.mp4", url: "/mock/annual.mp4" },
+ ],
+ attachmentList: [],
+ editorRole: "dept_manager",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "published",
+ publisherName: "琛屾斂閮�",
+ publishTime: "2026-05-20 14:30:00",
+ readRecords: buildReadRecords(["u1", "u2", "u3", "u4", "u5", "u6", "u7"]),
+ remindLogs: [],
+ likes: [
+ { userId: "u1", name: "寮犱笁", time: "2026-05-20 15:01:00" },
+ { userId: "u2", name: "鏉庡洓", time: "2026-05-20 15:05:00" },
+ { userId: "u4", name: "璧靛叚", time: "2026-05-20 16:20:00" },
+ ],
+ comments: [
+ { id: "c1", userId: "u1", name: "寮犱笁", content: "鑺傜洰澶簿褰╀簡锛�", time: "2026-05-20 15:10:00" },
+ { id: "c2", userId: "u3", name: "鐜嬩簲", content: "鏈熷緟鏄庡勾鍐嶈仛锛�", time: "2026-05-20 17:00:00" },
+ ],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ createTime: "2026-05-20 14:00:00",
+ updateTime: "2026-05-20 14:30:00",
+ };
+
+ const strategyRow = {
+ id: "news_3",
+ newsNo: "EN202605220003",
+ title: "2026 涓嬪崐骞存垬鐣ヨ鍒掕鐐�",
+ summary: "浠呴檺绠$悊灞傞槄璇伙紝璇峰嬁瀵瑰浼犳挱銆�",
+ newsType: "announcement",
+ layoutTemplate: "briefing",
+ contentHtml: strategyContent,
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [{ name: "鎴樼暐鎸囨爣.pdf", url: "/mock/strategy.pdf" }],
+ editorRole: "admin",
+ reviewerRole: "admin",
+ readScope: "management",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "published",
+ publisherName: "鎬荤粡鍔�",
+ publishTime: "2026-05-22 09:00:00",
+ readRecords: buildReadRecords(["u6", "u7"]),
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ createTime: "2026-05-22 08:30:00",
+ updateTime: "2026-05-22 09:00:00",
+ };
+
+ const industryDraft = {
+ id: "news_4",
+ newsNo: "EN202605250004",
+ title: "鍒堕�犱笟鏁板瓧鍖栬浆鍨嬭秼鍔跨畝鎶�",
+ summary: "琛屼笟鍔ㄦ�佽崏绋匡紝寰呯鐞嗗憳瀹℃牳鍚庡彂甯冦��",
+ newsType: "industry",
+ layoutTemplate: "standard",
+ contentHtml: "<p>鏈湡绠�鎶ユ⒊鐞嗗伐涓氫簰鑱旂綉涓� AI 璐ㄦ搴旂敤妗堜緥鈥︹��</p>",
+ coverImage: "",
+ mediaList: [],
+ attachmentList: [],
+ editorRole: "editor",
+ reviewerRole: "admin",
+ readScope: "all",
+ targetDeptIds: [],
+ targetUserIds: [],
+ publishStatus: "pending_review",
+ publisherName: "甯傚満閮�",
+ publishTime: "",
+ readRecords: [],
+ remindLogs: [],
+ likes: [],
+ comments: [],
+ versions: [],
+ versionNo: 1,
+ requireReadConfirm: false,
+ createTime: "2026-05-25 11:00:00",
+ updateTime: "2026-05-25 11:00:00",
+ };
+
+ return [policyRow, cultureRow, strategyRow, industryDraft];
+}
+
+export function loadStoredNews() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const data = JSON.parse(raw);
+ return Array.isArray(data) ? data : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveStoredNews(rows) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+ } catch {
+ /* ignore */
+ }
+}
+
+/** 鎸夐槄璇昏寖鍥磋В鏋愮洰鏍囧彈浼� */
+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 nextNewsNo() {
+ return `EN${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
+}
+
+export function pushVersionBeforeUpdate(row, changeNote) {
+ const versions = row.versions || [];
+ versions.unshift(createVersionSnapshot(row, changeNote));
+ row.versions = versions;
+ row.versionNo = (row.versionNo || 1) + 1;
+}
+
+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..a8b743a
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
@@ -0,0 +1,461 @@
+<!--OA妯″潡锛欵nterpriseNews 浼佷笟鏂伴椈-->
+<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="handleQuery"
+ />
+ <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.publishStatus" placeholder="鍏ㄩ儴" clearable style="width: 120px">
+ <el-option v-for="opt in PUBLISH_STATUS_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.publishTimeRange"
+ 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="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"
+ >
+ <template #newsType="{ row }">
+ <span class="news-type-tag" :style="{ color: newsTypeColor(row.newsType) }">
+ {{ newsTypeLabel(row.newsType) }}
+ </span>
+ </template>
+ </PIMTable>
+ </div>
+
+ <!-- 鏂板缓 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="960px"
+ append-to-body
+ destroy-on-close
+ class="news-form-dialog"
+ @closed="formRef?.resetFields?.()"
+ >
+ <el-form
+ ref="formRef"
+ :model="form"
+ :rules="formRules"
+ label-width="110px"
+ :disabled="formDialog.readonly"
+ >
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鏂伴椈鍒嗙被" prop="newsType">
+ <el-select v-model="form.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="form.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="form.title" placeholder="鏂伴椈鏍囬" maxlength="100" show-word-limit />
+ </el-form-item>
+ <el-form-item label="鎽樿">
+ <el-input v-model="form.summary" type="textarea" :rows="2" maxlength="300" show-word-limit />
+ </el-form-item>
+ <el-form-item label="姝f枃" prop="contentHtml">
+ <Editor v-model="form.contentHtml" :min-height="280" />
+ </el-form-item>
+ <el-form-item label="闄勪欢">
+ <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="涓婁紶 PDF / 鏂囨。" />
+ </el-form-item>
+ <el-form-item v-if="form.layoutTemplate === 'gallery'" label="鍥鹃泦/瑙嗛">
+ <el-input
+ v-model="galleryInput"
+ placeholder="杈撳叆璧勬簮鍚嶇О鍚庡洖杞︽坊鍔狅紙婕旂ず锛�"
+ @keyup.enter="addGalleryItem"
+ />
+ <el-tag
+ v-for="(m, i) in form.mediaList"
+ :key="i"
+ closable
+ class="media-tag"
+ @close="form.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="form.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="form.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="form.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="form.readScope === 'department'" label="鍙閮ㄩ棬">
+ <el-select v-model="form.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="form.requireReadConfirm" active-text="闇�闃呰纭锛堜究浜庣粺璁℃湭璇伙級" />
+ </el-form-item>
+ <el-form-item label="鍙戝竷浜�">
+ <el-input v-model="form.publisherName" placeholder="濡傦細浜哄姏璧勬簮閮�" maxlength="50" />
+ </el-form-item>
+ </el-form>
+ <template v-if="!formDialog.readonly" #footer>
+ <el-button @click="formDialog.visible = false">鍙� 娑�</el-button>
+ <el-button @click="onSave('save')">瀛樿崏绋�</el-button>
+ <el-button type="warning" @click="onSave('submit_review')">鎻愪氦瀹℃牳</el-button>
+ <el-button type="primary" @click="onSave('publish')">鐩存帴鍙戝竷</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="鏂伴椈璇︽儏" width="880px" append-to-body destroy-on-close>
+ <NewsDetailPanel
+ :row="detailRow"
+ @like="onDetailLike"
+ @comment="onDetailComment"
+ />
+ <template #footer>
+ <el-button
+ v-if="detailRow.publishStatus === 'published' && getUnreadEmployees(detailRow).length"
+ type="warning"
+ @click="openUnreadFromDetail"
+ >
+ 鏈鎻愰啋
+ </el-button>
+ <el-button @click="openVersionFromDetail">鐗堟湰鐣欒瘉</el-button>
+ <el-button @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 鏈鎻愰啋 -->
+ <el-dialog
+ v-model="unreadDialog.visible"
+ :title="`鏈槄璇诲憳宸� 路 ${unreadDialog.row?.title || ''}`"
+ width="720px"
+ append-to-body
+ destroy-on-close
+ >
+ <el-alert type="warning" show-icon :closable="false" class="mb12">
+ 鏀跨瓥浼犺揪鍦烘櫙锛氬彂甯冩柊鑰冨嫟鍒跺害绛夊繀璇讳俊鎭悗锛屽彲鍕鹃�夋湭璇诲憳宸ョ敱 HR 瀹氬悜鎻愰啋锛堟紨绀烘暟鎹紝鍚庢湡瀵规帴娑堟伅涓績锛夈��
+ </el-alert>
+ <div class="unread-toolbar mb12">
+ <el-button size="small" @click="selectAllUnread">鍏ㄩ�夋湭璇�</el-button>
+ <span class="unread-stat">鍏� {{ unreadList.length }} 浜烘湭璇�</span>
+ </div>
+ <el-table
+ :data="unreadList"
+ border
+ size="small"
+ max-height="360"
+ @selection-change="onUnreadSelectionChange"
+ >
+ <el-table-column type="selection" width="48" />
+ <el-table-column prop="employeeNo" label="宸ュ彿" width="100" />
+ <el-table-column prop="name" label="濮撳悕" width="90" />
+ <el-table-column prop="deptName" label="閮ㄩ棬" min-width="120" />
+ </el-table>
+ <el-divider v-if="unreadDialog.row?.remindLogs?.length" content-position="left">鎻愰啋璁板綍</el-divider>
+ <el-timeline v-if="unreadDialog.row?.remindLogs?.length">
+ <el-timeline-item
+ v-for="(log, i) in unreadDialog.row.remindLogs"
+ :key="i"
+ :timestamp="log.time"
+ >
+ {{ log.operator }} 宸插悜 {{ log.count }} 浜哄彂閫侀槄璇绘彁閱�
+ </el-timeline-item>
+ </el-timeline>
+ <template #footer>
+ <el-button type="primary" @click="onSendRemind">鍙戦�佸畾鍚戞彁閱�</el-button>
+ <el-button @click="unreadDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 鐗堟湰鐣欒瘉 -->
+ <el-dialog
+ v-model="versionDialog.visible"
+ :title="`鍘嗗彶鐗堟湰鐣欒瘉 路 ${versionDialog.row?.title || ''}`"
+ width="800px"
+ append-to-body
+ destroy-on-close
+ >
+ <el-alert type="info" show-icon :closable="false" class="mb12">
+ 浜夎鍙戠敓鏃跺彲鏌ラ槄鍘嗗彶鐗堟湰锛岃瘉鏄庡綋鏃跺彂甯冨唴瀹逛笌鍙戝竷鏃堕棿锛堝悎瑙勭暀璇侊級銆�
+ </el-alert>
+ <el-descriptions :column="2" border class="mb16">
+ <el-descriptions-item label="褰撳墠鐗堟湰">v{{ versionDialog.row?.versionNo || 1 }}</el-descriptions-item>
+ <el-descriptions-item label="鏈�杩戝彂甯�">{{ versionDialog.row?.publishTime || "鈥�" }}</el-descriptions-item>
+ </el-descriptions>
+ <el-table :data="versionList" border size="small" empty-text="鏆傛棤鍘嗗彶鐗堟湰">
+ <el-table-column prop="versionNo" label="鐗堟湰" width="70" align="center" />
+ <el-table-column prop="title" label="鏍囬" min-width="160" show-overflow-tooltip />
+ <el-table-column prop="changeNote" label="鍙樻洿璇存槑" width="120" />
+ <el-table-column prop="publishTime" label="鍙戝竷鏃堕棿" width="170" />
+ <el-table-column prop="archivedAt" label="褰掓。鏃堕棿" width="170" />
+ <el-table-column label="鎿嶄綔" width="90" align="center">
+ <template #default="{ row: ver }">
+ <el-button type="primary" link @click="previewVersion(ver)">鏌ョ湅</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ <template #footer>
+ <el-button @click="versionDialog.visible = false">鍏� 闂�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 鐗堟湰棰勮 -->
+ <el-dialog v-model="versionPreview.visible" title="鍘嗗彶鐗堟湰鍐呭" width="640px" append-to-body>
+ <p class="version-meta">
+ v{{ versionPreview.data?.versionNo }} 路 {{ versionPreview.data?.changeNote }} 路
+ {{ versionPreview.data?.publishTime }}
+ </p>
+ <div class="version-html" v-html="versionPreview.data?.contentHtml || ''" />
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { Plus, RefreshRight } from "@element-plus/icons-vue";
+import { ElMessage } from "element-plus";
+import { computed, onMounted, reactive, ref } from "vue";
+import Editor from "@/components/Editor/index.vue";
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import { newsTypeColor } from "./enterpriseNewsUtils.js";
+import NewsDetailPanel from "./components/NewsDetailPanel.vue";
+import { useEnterpriseNews } from "./useEnterpriseNews.js";
+
+const {
+ Search,
+ NEWS_TYPE_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ LAYOUT_TEMPLATE_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ PUBLISH_ROLE_OPTIONS,
+ DEPT_OPTIONS,
+ newsTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ unreadDialog,
+ unreadList,
+ versionDialog,
+ getUnreadEmployees,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ openUnreadRemind,
+ openVersionHistory,
+ saveForm,
+ sendUnreadRemind,
+ toggleLike,
+ addComment,
+} = useEnterpriseNews();
+
+const galleryInput = ref("");
+const unreadSelected = ref([]);
+const versionPreview = reactive({ visible: false, data: null });
+
+const versionList = computed(() => {
+ const row = versionDialog.row;
+ if (!row) return [];
+ const history = [...(row.versions || [])];
+ return history.sort((a, b) => (b.versionNo || 0) - (a.versionNo || 0));
+});
+
+function addGalleryItem() {
+ const name = (galleryInput.value || "").trim();
+ if (!name) return;
+ form.mediaList = form.mediaList || [];
+ form.mediaList.push({ type: "image", name, url: `/mock/${name}` });
+ galleryInput.value = "";
+}
+
+function onSave(action) {
+ const ret = saveForm(action);
+ if (ret?.message) {
+ ElMessage.warning(ret.message);
+ return;
+ }
+ if (ret?.ok) {
+ ElMessage.success(action === "publish" ? "宸插彂甯�" : action === "submit_review" ? "宸叉彁浜ゅ鏍�" : "宸蹭繚瀛�");
+ }
+}
+
+function onDetailLike() {
+ toggleLike(detailRow.value);
+}
+
+function onDetailComment(text) {
+ const ret = addComment(detailRow.value, text);
+ if (ret?.message) ElMessage.warning(ret.message);
+ else if (ret?.ok) ElMessage.success("璇勮宸插彂甯�");
+}
+
+function openUnreadFromDetail() {
+ const row = detailRow.value;
+ detailDialog.visible = false;
+ openUnreadRemind(row);
+}
+
+function openVersionFromDetail() {
+ const row = detailRow.value;
+ detailDialog.visible = false;
+ openVersionHistory(row);
+}
+
+function onUnreadSelectionChange(rows) {
+ unreadSelected.value = rows.map((r) => r.userId);
+}
+
+function selectAllUnread() {
+ unreadSelected.value = unreadList.value.map((u) => u.userId);
+}
+
+function onSendRemind() {
+ const ids = unreadSelected.value;
+ const ret = sendUnreadRemind(ids);
+ if (ret?.message) {
+ ElMessage.warning(ret.message);
+ return;
+ }
+ if (ret?.ok) ElMessage.success(`宸插悜 ${ret.count} 鍚嶅憳宸ュ彂閫侀槄璇绘彁閱抈);
+}
+
+function previewVersion(ver) {
+ versionPreview.data = ver;
+ versionPreview.visible = true;
+}
+
+onMounted(() => {
+ handleQuery();
+});
+</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;
+}
+.news-type-tag {
+ font-weight: 600;
+ font-size: 13px;
+}
+.media-tag {
+ margin: 6px 8px 0 0;
+}
+.unread-toolbar {
+ display: flex;
+ align-items: center;
+ gap: 12px;
+}
+.unread-stat {
+ color: var(--el-text-color-secondary);
+ font-size: 13px;
+}
+.version-meta {
+ color: var(--el-text-color-secondary);
+ font-size: 13px;
+ margin-bottom: 12px;
+}
+.version-html {
+ padding: 12px;
+ background: var(--el-fill-color-light);
+ border-radius: 6px;
+ max-height: 400px;
+ overflow-y: auto;
+}
+.mb16 {
+ margin-bottom: 16px;
+}
+.mb12 {
+ margin-bottom: 12px;
+}
+.ml10 {
+ margin-left: 10px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js
new file mode 100644
index 0000000..d272b83
--- /dev/null
+++ b/src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js
@@ -0,0 +1,440 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { ElMessageBox } from "element-plus";
+import { computed, reactive, ref, watch } from "vue";
+import {
+ NEWS_TYPE_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ LAYOUT_TEMPLATE_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ PUBLISH_ROLE_OPTIONS,
+ DEPT_OPTIONS,
+ createEmptyForm,
+ createInitialMockNews,
+ loadStoredNews,
+ saveStoredNews,
+ getUnreadEmployees,
+ readRate,
+ nextNewsNo,
+ pushVersionBeforeUpdate,
+ validateNewsForm,
+ newsTypeLabel,
+ publishStatusLabel,
+} from "./enterpriseNewsUtils.js";
+
+export function useEnterpriseNews() {
+ const stored = loadStoredNews();
+ const allRows = ref(stored?.length ? stored : createInitialMockNews());
+
+ const searchForm = reactive({
+ keyword: "",
+ newsType: "",
+ publishStatus: "",
+ publishTimeRange: [],
+ });
+
+ const tableLoading = ref(false);
+ const page = reactive({ current: 1, size: 10, total: 0 });
+
+ const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
+ const form = reactive(createEmptyForm());
+ const formRef = ref();
+
+ const detailDialog = reactive({ visible: false });
+ const detailRow = ref({});
+
+ const unreadDialog = reactive({ visible: false, row: null });
+ const unreadSelection = ref([]);
+
+ const versionDialog = reactive({ visible: false, row: null });
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.keyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const title = (r.title || "").toLowerCase();
+ const summary = (r.summary || "").toLowerCase();
+ const no = (r.newsNo || "").toLowerCase();
+ return title.includes(kw) || summary.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.newsType) {
+ list = list.filter((r) => r.newsType === searchForm.newsType);
+ }
+ if (searchForm.publishStatus) {
+ list = list.filter((r) => r.publishStatus === searchForm.publishStatus);
+ }
+ const range = searchForm.publishTimeRange;
+ if (range?.length === 2 && range[0] && range[1]) {
+ const start = dayjs(range[0]).startOf("day");
+ const end = dayjs(range[1]).endOf("day");
+ list = list.filter((r) => {
+ if (!r.publishTime) return false;
+ const t = dayjs(r.publishTime);
+ return t.isAfter(start) && t.isBefore(end);
+ });
+ }
+ return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ const unreadList = computed(() => {
+ if (!unreadDialog.row) return [];
+ return getUnreadEmployees(unreadDialog.row);
+ });
+
+ const formRules = {
+ title: [{ required: true, message: "璇疯緭鍏ユ柊闂绘爣棰�", trigger: "blur" }],
+ newsType: [{ required: true, message: "璇烽�夋嫨鏂伴椈鍒嗙被", trigger: "change" }],
+ readScope: [{ required: true, message: "璇烽�夋嫨闃呰鑼冨洿", trigger: "change" }],
+ };
+
+ const tableColumn = ref([
+ { label: "缂栧彿", prop: "newsNo", width: 150 },
+ { label: "鏍囬", prop: "title", minWidth: 180, showOverflowTooltip: true },
+ {
+ label: "鍒嗙被",
+ prop: "newsType",
+ width: 100,
+ dataType: "slot",
+ slot: "newsType",
+ },
+ {
+ label: "鐘舵��",
+ prop: "publishStatus",
+ width: 90,
+ dataType: "tag",
+ formatData: (v) => publishStatusLabel(v),
+ formatType: (v) => {
+ const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
+ return hit?.tag || "info";
+ },
+ },
+ {
+ label: "闃呰鐜�",
+ prop: "readRecords",
+ width: 90,
+ align: "center",
+ formatData: (_, row) => `${readRate(row)}%`,
+ },
+ {
+ label: "鏈",
+ prop: "id",
+ width: 70,
+ align: "center",
+ formatData: (_, row) => {
+ if (row.publishStatus !== "published") return "鈥�";
+ return getUnreadEmployees(row).length;
+ },
+ },
+ { label: "鍙戝竷浜�", prop: "publisherName", width: 110 },
+ { label: "鍙戝竷鏃堕棿", prop: "publishTime", width: 170 },
+ { label: "鏇存柊鏃堕棿", prop: "updateTime", width: 170 },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 280,
+ operation: [
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "缂栬緫",
+ type: "text",
+ disabled: (row) => row.publishStatus === "archived",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "瀹℃牳",
+ type: "text",
+ disabled: (row) => row.publishStatus !== "pending_review",
+ clickFun: (row) => openReview(row),
+ },
+ {
+ name: "鏈鎻愰啋",
+ type: "text",
+ disabled: (row) =>
+ row.publishStatus !== "published" || getUnreadEmployees(row).length === 0,
+ clickFun: (row) => openUnreadRemind(row),
+ },
+ { name: "鐗堟湰鐣欒瘉", type: "text", clickFun: (row) => openVersionHistory(row) },
+ ],
+ },
+ ]);
+
+ function persist() {
+ saveStoredNews(allRows.value);
+ }
+
+ function handleQuery() {
+ tableLoading.value = true;
+ page.current = 1;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 200);
+ }
+
+ function resetSearch() {
+ searchForm.keyword = "";
+ searchForm.newsType = "";
+ searchForm.publishStatus = "";
+ searchForm.publishTimeRange = [];
+ handleQuery();
+ }
+
+ function pagination({ page: p, limit }) {
+ page.current = p;
+ page.size = limit;
+ }
+
+ function resetForm(target = createEmptyForm()) {
+ Object.assign(form, createEmptyForm(), target);
+ }
+
+ function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.readonly = mode === "view";
+ formDialog.title =
+ mode === "add" ? "鏂板缓浼佷笟鏂伴椈" : mode === "edit" ? "缂栬緫浼佷笟鏂伴椈" : "鏌ョ湅浼佷笟鏂伴椈";
+ if (mode === "add") {
+ resetForm({ publisherName: "褰撳墠鐢ㄦ埛" });
+ } else {
+ resetForm({
+ ...JSON.parse(JSON.stringify(row)),
+ targetDeptIds: [...(row.targetDeptIds || [])],
+ targetUserIds: [...(row.targetUserIds || [])],
+ mediaList: [...(row.mediaList || [])],
+ attachmentList: [...(row.attachmentList || [])],
+ });
+ }
+ formDialog.visible = true;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function openUnreadRemind(row) {
+ unreadDialog.row = row;
+ unreadSelection.value = [];
+ unreadDialog.visible = true;
+ }
+
+ function openVersionHistory(row) {
+ versionDialog.row = row;
+ versionDialog.visible = true;
+ }
+
+ async function openReview(row) {
+ try {
+ await ElMessageBox.confirm(
+ `纭瀹℃牳閫氳繃骞跺彂甯冦��${row.title}銆嶏紵澶栭儴/琛屼笟绫绘柊闂婚渶绠$悊鍛樺鏍搞�俙,
+ "瀹℃牳鍙戝竷",
+ { type: "warning", confirmButtonText: "閫氳繃骞跺彂甯�", cancelButtonText: "鍙栨秷" }
+ );
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ hit.publishStatus = "published";
+ hit.publishTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ hit.updateTime = hit.publishTime;
+ if (!hit.readRecords?.length) {
+ hit.readRecords = [];
+ }
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ function saveForm(submitAction = "save") {
+ const v = validateNewsForm(form);
+ if (!v.ok) return { ok: false, message: v.message };
+
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ const payload = {
+ ...JSON.parse(JSON.stringify(form)),
+ title: v.title,
+ updateTime: now,
+ };
+
+ if (formDialog.mode === "add") {
+ payload.id = `news_${Date.now()}`;
+ payload.newsNo = nextNewsNo();
+ payload.createTime = now;
+ if (submitAction === "submit_review") {
+ payload.publishStatus = "pending_review";
+ } else if (submitAction === "publish") {
+ payload.publishStatus = "published";
+ payload.publishTime = now;
+ } else {
+ payload.publishStatus = "draft";
+ }
+ allRows.value.unshift(payload);
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx < 0) return { ok: false, message: "璁板綍涓嶅瓨鍦�" };
+ const prev = allRows.value[idx];
+ if (prev.publishStatus === "published" && submitAction !== "draft") {
+ pushVersionBeforeUpdate(prev, submitAction === "publish" ? "淇鍙戝竷" : "鍐呭鏇存柊");
+ }
+ if (submitAction === "submit_review") {
+ payload.publishStatus = "pending_review";
+ } else if (submitAction === "publish") {
+ payload.publishStatus = "published";
+ payload.publishTime = payload.publishTime || now;
+ }
+ payload.versions = prev.versions || [];
+ payload.versionNo = prev.versionNo || 1;
+ if (prev.publishStatus === "published" && submitAction === "publish") {
+ payload.versionNo = (prev.versionNo || 1) + 1;
+ }
+ allRows.value[idx] = { ...prev, ...payload };
+ }
+ persist();
+ formDialog.visible = false;
+ return { ok: true };
+ }
+
+ function archiveNews(row) {
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (hit) {
+ hit.publishStatus = "archived";
+ hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ persist();
+ }
+ }
+
+ function sendUnreadRemind(selectedIds) {
+ const row = unreadDialog.row;
+ if (!row || !selectedIds?.length) return { ok: false, message: "璇烽�夋嫨瑕佹彁閱掔殑鍛樺伐" };
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return { ok: false };
+
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ hit.remindLogs = hit.remindLogs || [];
+ hit.remindLogs.push({
+ time: now,
+ count: selectedIds.length,
+ operator: "HR",
+ userIds: [...selectedIds],
+ });
+
+ const records = hit.readRecords || [];
+ selectedIds.forEach((uid) => {
+ let rec = records.find((r) => r.userId === uid);
+ if (!rec) {
+ const emp = getUnreadEmployees(hit).find((e) => e.userId === uid);
+ rec = {
+ userId: uid,
+ employeeNo: emp?.employeeNo || "",
+ name: emp?.name || "",
+ deptName: emp?.deptName || "",
+ readAt: "",
+ lastRemindAt: now,
+ };
+ records.push(rec);
+ } else {
+ rec.lastRemindAt = now;
+ }
+ });
+ hit.readRecords = records;
+ hit.updateTime = now;
+ persist();
+ unreadDialog.visible = false;
+ return { ok: true, count: selectedIds.length };
+ }
+
+ function toggleLike(row, userId = "u1", userName = "寮犱笁") {
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ hit.likes = hit.likes || [];
+ const idx = hit.likes.findIndex((l) => l.userId === userId);
+ if (idx >= 0) {
+ hit.likes.splice(idx, 1);
+ } else {
+ hit.likes.push({ userId, name: userName, time: dayjs().format("YYYY-MM-DD HH:mm:ss") });
+ }
+ persist();
+ if (detailRow.value?.id === row.id) {
+ detailRow.value = { ...hit };
+ }
+ }
+
+ function addComment(row, content, userId = "u1", userName = "寮犱笁") {
+ const text = (content || "").trim();
+ if (!text) return { ok: false, message: "璇疯緭鍏ヨ瘎璁哄唴瀹�" };
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return { ok: false };
+ hit.comments = hit.comments || [];
+ hit.comments.push({
+ id: `c_${Date.now()}`,
+ userId,
+ name: userName,
+ content: text,
+ time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ });
+ persist();
+ if (detailRow.value?.id === row.id) {
+ detailRow.value = { ...hit };
+ }
+ return { ok: true };
+ }
+
+ return {
+ Search,
+ NEWS_TYPE_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ LAYOUT_TEMPLATE_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ PUBLISH_ROLE_OPTIONS,
+ DEPT_OPTIONS,
+ newsTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ unreadDialog,
+ unreadList,
+ unreadSelection,
+ versionDialog,
+ getUnreadEmployees,
+ readRate,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ openUnreadRemind,
+ openVersionHistory,
+ openReview,
+ saveForm,
+ archiveNews,
+ sendUnreadRemind,
+ toggleLike,
+ addComment,
+ };
+}
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..b95b6e7
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
@@ -0,0 +1,676 @@
+<!--OA妯″潡锛氳浆姝g敵璇�-->
+<template>
+ <div class="app-container">
+ <div class="search_form mb20">
+ <div>
+ <span class="search_title">鐢宠浜猴細</span>
+ <el-input
+ v-model="searchForm.applicantName"
+ style="width: 220px"
+ placeholder="璇疯緭鍏ョ敵璇蜂汉"
+ clearable
+ :prefix-icon="Search"
+ @keyup.enter="handleQuery"
+ />
+ <span class="search_title" style="margin-left: 12px">鐢宠鏃ユ湡锛�</span>
+ <el-date-picker
+ v-model="searchForm.applyDateRange"
+ 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" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+ <el-button @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div>
+ <el-button type="primary" @click="openFormDialog('add')">鏂板杞鐢宠</el-button>
+ </div>
+ </div>
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ @pagination="pagination"
+ :total="page.total"
+ />
+ </div>
+
+ <!-- 鏂板 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="960px"
+ append-to-body
+ destroy-on-close
+ class="regular-apply-form-dialog"
+ @closed="onFormClosed"
+ >
+ <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="regular-apply-form">
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="鐢宠浜�" prop="applicantName">
+ <el-input v-model="form.applicantName" placeholder="璇疯緭鍏ョ敵璇蜂汉" maxlength="50" show-word-limit />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="鐢宠鏃ユ湡" prop="applyDate">
+ <el-date-picker
+ v-model="form.applyDate"
+ type="date"
+ placeholder="璇烽�夋嫨鐢宠鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="杞鏃ユ湡" prop="regularizationDate">
+ <el-date-picker
+ v-model="form.regularizationDate"
+ type="date"
+ placeholder="璇烽�夋嫨杞鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
+ <el-radio-group v-model="form.approvalMode">
+ <el-radio value="parallel">涓庣</el-radio>
+ <el-radio value="countersign">浼氱</el-radio>
+ </el-radio-group>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="瀹℃壒浜�" prop="approverIds">
+ <el-tree-select
+ v-model="form.approverIds"
+ :data="approverTreeData"
+ multiple
+ collapse-tags
+ collapse-tags-tooltip
+ :max-collapse-tags="2"
+ :render-after-expand="false"
+ placeholder="璇烽�夋嫨瀹℃壒浜猴紙鍙閫夛級"
+ style="width: 100%"
+ :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
+ check-strictly
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="璇曠敤鏈熷伐浣滄�荤粨" prop="probationSummary">
+ <el-input
+ v-model="form.probationSummary"
+ type="textarea"
+ :rows="4"
+ placeholder="璇峰~鍐欒瘯鐢ㄦ湡宸ヤ綔鎬荤粨"
+ maxlength="2000"
+ show-word-limit
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="闄勪欢">
+ <div class="upload-block">
+ <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="鐐瑰嚮閫夋嫨鏂囦欢" />
+ </div>
+ </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="formDialog.visible = false">鍙� 娑�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏锛堝彧璇伙級 -->
+ <el-dialog v-model="detailDialog.visible" title="杞鐢宠璇︽儏" width="640px" append-to-body>
+ <el-descriptions :column="1" border>
+ <el-descriptions-item label="鐢宠浜�">{{ detailRow.applicantName }}</el-descriptions-item>
+ <el-descriptions-item label="鐢宠鏃ユ湡">{{ detailRow.applyDate }}</el-descriptions-item>
+ <el-descriptions-item label="杞鏃ユ湡">{{ detailRow.regularizationDate }}</el-descriptions-item>
+ <el-descriptions-item label="璇曠敤鏈熷伐浣滄�荤粨">{{ detailRow.probationSummary }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒缁撴灉">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒鏂瑰紡">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒浜�">{{ detailRow.approverNames || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="闄勪欢">
+ <template v-if="detailRow.attachmentList?.length">
+ <el-tag
+ v-for="(f, i) in detailRow.attachmentList"
+ :key="i"
+ class="mr6 mb6"
+ type="info"
+ >
+ {{ f.name }}
+ </el-tag>
+ </template>
+ <span v-else>鏃�</span>
+ </el-descriptions-item>
+ </el-descriptions>
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 闄勪欢鍒楄〃 -->
+ <el-dialog v-model="filesDialog.visible" title="闄勪欢" width="520px" append-to-body>
+ <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
+ <el-table-column type="index" label="搴忓彿" width="60" align="center" />
+ <el-table-column prop="name" label="鏂囦欢鍚�" min-width="200" show-overflow-tooltip />
+ <el-table-column label="鎿嶄綔" width="100" align="center">
+ <template #default="{ row }">
+ <el-button link type="primary" @click="mockDownload(row)">涓嬭浇</el-button>
+ </template>
+ </el-table-column>
+ </el-table>
+ <el-empty v-else description="鏆傛棤闄勪欢" />
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="filesDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { Search } from "@element-plus/icons-vue";
+import FileUpload from "@/components/AttachmentUpload/file/index.vue";
+import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+
+/** 涓庡悗绔害瀹氬瓧娈碉紙鍗犱綅锛� */
+const createEmptyForm = () => ({
+ id: undefined,
+ applicantName: "",
+ applyDate: "",
+ regularizationDate: "",
+ probationSummary: "",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+ attachmentList: [],
+});
+
+const { proxy } = getCurrentInstance();
+
+/** 瀹℃壒浜烘爲锛氶儴闂ㄦ爲 + 绯荤粺鐢ㄦ埛锛堜笌 staff-archive / user-manage 鍚屾簮鎺ュ彛锛� */
+const approverTreeData = ref([]);
+const approverLabelMap = ref({});
+
+/** 鎺ュ彛杩斿洖缁熶竴鎷嗘垚鏁扮粍锛堝吋瀹� axios 鎷︽埅鍣ㄥ凡瑙e寘涓� { data } 鎴栫洿鎺ユ暟缁勭瓑鎯呭喌锛� */
+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 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;
+ });
+}
+
+function getUserDeptId(u) {
+ return (
+ u.deptId ??
+ u.sysDeptId ??
+ u.dept?.deptId ??
+ u.dept?.id ??
+ u.dept_id
+ );
+}
+
+/** 閮ㄩ棬鏍戣妭鐐逛富閿紙鑻ヤ緷涓�鑸负 id锛岄儴鍒嗗満鏅负 value锛� */
+function getDeptNodeKey(node) {
+ const k = node?.id ?? node?.value ?? node?.deptId;
+ if (k == null || k === "") return null;
+ return k;
+}
+
+function isActiveUser(u) {
+ if (u.delFlag === "2" || u.delFlag === 2) return false;
+ if (u.status == null) return true;
+ return String(u.status) === "0";
+}
+
+function userToTreeLeaf(u) {
+ return {
+ id: String(u.userId ?? u.id),
+ label: u.nickName || u.userName || `鐢ㄦ埛${u.userId ?? u.id}`,
+ };
+}
+
+/** 鎸夐儴闂� id 鍒嗙粍锛涙棤閮ㄩ棬鎴� id 涓� 0 鐨勭敤鎴疯繘鍏ユ湭鍒嗛厤鍒楄〃 */
+function buildUsersByDeptId(users) {
+ const map = new Map();
+ const unassigned = [];
+ for (const u of users) {
+ if (!isActiveUser(u)) continue;
+ const did = getUserDeptId(u);
+ if (did == null || did === "" || did === 0 || did === "0") {
+ unassigned.push(u);
+ continue;
+ }
+ const k = String(did);
+ if (!map.has(k)) map.set(k, []);
+ map.get(k).push(u);
+ }
+ return { map, unassigned };
+}
+
+function collectUserLabels(nodes, map) {
+ (nodes || []).forEach((n) => {
+ if (n.children?.length) {
+ collectUserLabels(n.children, map);
+ } else if (n.id != null && !String(n.id).startsWith("dept_")) {
+ map[String(n.id)] = n.label;
+ }
+ });
+}
+
+/** 閮ㄩ棬鑺傜偣 id 鍔犲墠缂�锛岄伩鍏嶄笌 userId 鏁板�煎啿绐侊紱鍙�夎妭鐐逛负鐪熷疄 userId 瀛楃涓� */
+function mergeDeptTreeWithUsers(nodes, usersByDept) {
+ if (!Array.isArray(nodes)) return [];
+ const out = [];
+ for (const node of nodes) {
+ const deptIdRaw = getDeptNodeKey(node);
+ if (deptIdRaw == null) continue;
+ const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
+ const usersHere = usersByDept.get(String(deptIdRaw)) || [];
+ const userChildren = usersHere.map(userToTreeLeaf);
+ const children = [...sub, ...userChildren];
+ if (!children.length) continue;
+ out.push({
+ id: `dept_${deptIdRaw}`,
+ label: node.label ?? node.deptName ?? "閮ㄩ棬",
+ disabled: true,
+ children,
+ });
+ }
+ return out;
+}
+
+function buildFlatApproverTree(users) {
+ const list = users.filter(isActiveUser).map(userToTreeLeaf);
+ if (!list.length) return [];
+ return [
+ {
+ id: "dept_all_users",
+ label: "绯荤粺鐢ㄦ埛",
+ disabled: true,
+ children: list,
+ },
+ ];
+}
+
+async function loadApproverTree() {
+ try {
+ const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]);
+ let rawTree = unwrapArray(deptRes);
+ rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
+ let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
+ if (!deptTree.length && rawTree.length) {
+ deptTree = JSON.parse(JSON.stringify(rawTree));
+ }
+ const users = unwrapArray(userRes);
+ const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
+ let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
+ if (unassigned.length) {
+ merged.push({
+ id: "dept_unassigned",
+ label: "鏈垎閰嶉儴闂�",
+ disabled: true,
+ children: unassigned.map(userToTreeLeaf),
+ });
+ }
+ if (!merged.length && users.length) {
+ merged = buildFlatApproverTree(users);
+ }
+ approverTreeData.value = merged;
+ const map = {};
+ collectUserLabels(merged, map);
+ approverLabelMap.value = map;
+ } catch {
+ approverTreeData.value = [];
+ approverLabelMap.value = {};
+ proxy?.$modal?.msgWarning?.("瀹℃壒浜烘暟鎹姞杞藉け璐ワ紝璇锋鏌ョ綉缁滄垨绋嶅悗閲嶈瘯");
+ }
+}
+
+function resolveApproverNames(ids) {
+ if (!ids?.length) return "";
+ const map = approverLabelMap.value;
+ return ids.map((id) => map[String(id)] || id).join("銆�");
+}
+
+function approvalModeLabel(mode) {
+ if (mode === "countersign") return "浼氱";
+ return "涓庣";
+}
+
+function approvalResultLabel(v) {
+ if (v === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ if (v === "cancelled") return "宸叉挙閿�";
+ return "寰呭鎵�";
+}
+
+/** 鏈湴妯℃嫙鏁版嵁婧� */
+const allRows = ref([
+ {
+ id: "1",
+ applicantName: "鍛ㄦ槑",
+ applyDate: "2026-05-01",
+ regularizationDate: "2026-06-01",
+ probationSummary: "璇曠敤鏈熷唴瀹屾垚妯″潡寮�鍙戜笌鑱旇皟锛岀啛鎮変笟鍔℃祦绋嬨��",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+ approvalResult: "pending",
+ attachmentList: [{ name: "宸ヤ綔鎬荤粨.pdf" }, { name: "鑰冩牳琛�.xlsx" }],
+ },
+ {
+ id: "2",
+ applicantName: "鍚磋姵",
+ applyDate: "2026-05-08",
+ regularizationDate: "2026-06-10",
+ probationSummary: "瀹屾垚鍏ヨ亴鍩硅涓庡矖浣嶅疄璺碉紝杈惧埌宀椾綅瑕佹眰銆�",
+ approvalMode: "countersign",
+ approverIds: [],
+ approverNames: "",
+ approvalResult: "approved",
+ attachmentList: [],
+ },
+]);
+
+const searchForm = reactive({
+ applicantName: "",
+ applyDateRange: null,
+});
+
+const tableLoading = ref(false);
+const page = reactive({
+ current: 1,
+ size: 10,
+ total: 0,
+});
+
+const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const name = (searchForm.applicantName || "").trim();
+ if (name) {
+ list = list.filter((r) => r.applicantName.includes(name));
+ }
+ const range = searchForm.applyDateRange;
+ if (range && range.length === 2) {
+ const [start, end] = range;
+ list = list.filter((r) => r.applyDate >= start && r.applyDate <= end);
+ }
+ return list.sort((a, b) => (a.applyDate < b.applyDate ? 1 : -1));
+});
+
+watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) {
+ page.current = maxPage;
+ }
+ },
+ { immediate: true }
+);
+
+const tableData = computed(() => {
+ const list = filteredList.value;
+ const start = (page.current - 1) * page.size;
+ return list.slice(start, start + page.size);
+});
+
+const tableColumn = ref([
+ { label: "鐢宠浜�", prop: "applicantName", minWidth: 100 },
+ { label: "鐢宠鏃ユ湡", prop: "applyDate", width: 120 },
+ { label: "杞鏃ユ湡", prop: "regularizationDate", width: 120 },
+ { label: "璇曠敤鏈熷伐浣滄�荤粨", prop: "probationSummary", minWidth: 200 },
+ {
+ label: "瀹℃壒缁撴灉",
+ prop: "approvalResult",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => approvalResultLabel(v),
+ formatType: (v) => {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ if (v === "cancelled") return "info";
+ return "warning";
+ },
+ },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 200,
+ operation: [
+ {
+ name: "缂栬緫",
+ type: "text",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "鏌ョ湅璇︽儏",
+ type: "text",
+ clickFun: (row) => openDetail(row),
+ },
+ {
+ name: "闄勪欢",
+ type: "text",
+ clickFun: (row) => openFiles(row),
+ },
+ ],
+ },
+]);
+
+const formDialog = reactive({
+ visible: false,
+ title: "",
+ mode: "add",
+});
+const formRef = ref();
+const form = reactive(createEmptyForm());
+
+const formRules = {
+ applicantName: [{ required: true, message: "璇疯緭鍏ョ敵璇蜂汉", trigger: "blur" }],
+ applyDate: [{ required: true, message: "璇烽�夋嫨鐢宠鏃ユ湡", trigger: "change" }],
+ regularizationDate: [{ required: true, message: "璇烽�夋嫨杞鏃ユ湡", trigger: "change" }],
+ probationSummary: [{ required: true, message: "璇峰~鍐欒瘯鐢ㄦ湡宸ヤ綔鎬荤粨", trigger: "blur" }],
+ approvalMode: [{ required: true, message: "璇烽�夋嫨瀹℃壒鏂瑰紡", trigger: "change" }],
+ approverIds: [
+ {
+ type: "array",
+ required: true,
+ message: "璇烽�夋嫨瀹℃壒浜�",
+ trigger: "change",
+ },
+ ],
+};
+
+const detailDialog = reactive({ visible: false });
+const detailRow = ref({});
+
+const filesDialog = reactive({ visible: false, row: null });
+
+function handleQuery() {
+ page.current = 1;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+}
+
+function resetSearch() {
+ searchForm.applicantName = "";
+ searchForm.applyDateRange = null;
+ handleQuery();
+}
+
+function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+}
+
+function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+}
+
+function openFiles(row) {
+ filesDialog.row = row;
+ filesDialog.visible = true;
+}
+
+function mockDownload(row) {
+ const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
+ if (url) {
+ window.open(url, "_blank");
+ return;
+ }
+ proxy?.$modal?.msgSuccess?.(`宸叉ā鎷熶笅杞斤細${row.name}`);
+}
+
+function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.title = mode === "add" ? "鏂板杞鐢宠" : "缂栬緫杞鐢宠";
+ loadApproverTree();
+ Object.assign(form, createEmptyForm());
+ if (mode === "edit" && row) {
+ Object.assign(form, {
+ id: row.id,
+ applicantName: row.applicantName,
+ applyDate: row.applyDate,
+ regularizationDate: row.regularizationDate,
+ probationSummary: row.probationSummary,
+ approvalMode: row.approvalMode,
+ approverIds: (row.approverIds || []).map((id) => String(id)),
+ approverNames: row.approverNames,
+ attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
+ });
+ }
+ formDialog.visible = true;
+ nextTick(() => formRef.value?.clearValidate?.());
+}
+
+function onFormClosed() {
+ formRef.value?.resetFields?.();
+}
+
+async function submitForm() {
+ try {
+ await formRef.value?.validate?.();
+ } catch {
+ return;
+ }
+ form.approverNames = resolveApproverNames(form.approverIds);
+ const payload = {
+ applicantName: form.applicantName,
+ applyDate: form.applyDate,
+ regularizationDate: form.regularizationDate,
+ probationSummary: form.probationSummary,
+ approvalMode: form.approvalMode,
+ approverIds: [...form.approverIds],
+ approverNames: form.approverNames,
+ attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+ };
+ if (formDialog.mode === "add") {
+ const id = `local_${Date.now()}`;
+ allRows.value.unshift({ id, ...payload, approvalResult: "pending" });
+ proxy?.$modal?.msgSuccess?.("鏂板鎴愬姛锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx !== -1) {
+ const prev = allRows.value[idx];
+ allRows.value[idx] = {
+ ...prev,
+ id: form.id,
+ ...payload,
+ approvalResult: prev.approvalResult ?? "pending",
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+}
+
+onMounted(() => {
+ loadApproverTree();
+});
+</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);
+}
+.upload-block {
+ width: 100%;
+}
+.mr6 {
+ margin-right: 6px;
+}
+.mb6 {
+ margin-bottom: 6px;
+}
+.regular-apply-form :deep(.el-row) {
+ margin-bottom: 0;
+}
+.regular-apply-form :deep(.el-form-item) {
+ margin-bottom: 18px;
+}
+.regular-apply-form-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+</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..5ef0cf9
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/resign-apply/index.vue
@@ -0,0 +1,245 @@
+<!--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>
+ <!-- <el-button type="danger" plain @click="handleDelete">鍒犻櫎</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, batchDeleteStaffLeaves} 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 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(() => {
+ batchDeleteStaffLeaves(ids).then((res) => {
+ proxy.$modal.msgSuccess("鍒犻櫎鎴愬姛");
+ getList();
+ });
+ })
+ .catch(() => {
+ proxy.$modal.msg("宸插彇娑�");
+ });
+};
+// 瀵煎嚭
+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..9220d45
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue
@@ -0,0 +1,73 @@
+<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: "contractTerm",
+ // },
+ {
+ 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..c0d8b2b
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/staff-archive/index.vue
@@ -0,0 +1,407 @@
+<!--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="璇烽�夋嫨"
+ />
+ <!-- <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 type="primary" @click="openFormNewOrEditFormDia('add')">鏂板鍏ヨ亴</el-button>
+ <el-button type="info" @click="handleImport">瀵煎叆</el-button>
+ <el-button @click="handleOut">瀵煎嚭</el-button>
+ <!-- <el-button type="danger" plain @click="handleDelete">鍒犻櫎</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 {batchDeleteStaffOnJobs, 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;
+ },
+ },
+ // {
+ // 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 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 => {
+ console.log(response.data)
+ 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 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 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 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(() => {
+ batchDeleteStaffOnJobs(ids).then((res) => {
+ proxy.$modal.msgSuccess("鍒犻櫎鎴愬姛");
+ getList();
+ });
+ })
+ .catch(() => {
+ proxy.$modal.msg("宸插彇娑�");
+ });
+};
+
+// 瀵煎嚭
+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..1125445
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/staff-contract/index.vue
@@ -0,0 +1,314 @@
+<!--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>
+ <!-- <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>
+ <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: "postJob",
+ },
+ {
+ 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: "contractTerm",
+ // },
+ // {
+ // label: "鍚堝悓寮�濮嬫棩鏈�",
+ // prop: "contractStartTime",
+ // width: 120
+ // },
+ {
+ 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) => {
+ console.log(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() {
+ console.log(upload.url + '?updateSupport=' + upload.updateSupport)
+ 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..6b72316
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue
@@ -0,0 +1,792 @@
+<!--OA妯″潡锛氳皟宀楃敵璇�-->
+<template>
+ <div class="app-container">
+ <div class="search_form mb20">
+ <div>
+ <span class="search_title">鐢宠浜猴細</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>
+ <span class="search_title" style="margin-left: 12px">杞矖鏃堕棿锛�</span>
+ <el-date-picker
+ v-model="searchForm.transferDateRange"
+ 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" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+ <el-button @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div>
+ <el-button type="primary" @click="openFormDialog('add')">鏂板璋冨矖鐢宠</el-button>
+ </div>
+ </div>
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ @pagination="pagination"
+ :total="page.total"
+ />
+ </div>
+
+ <!-- 鏂板 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="720px"
+ append-to-body
+ destroy-on-close
+ class="transfer-apply-form-dialog"
+ @closed="onFormClosed"
+ >
+ <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="transfer-apply-form">
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <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="24">
+ <el-col :span="12">
+ <el-form-item label="杞矖鏃ユ湡" prop="transferDate">
+ <el-date-picker
+ v-model="form.transferDate"
+ type="date"
+ placeholder="璇烽�夋嫨杞矖鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="鍘熷矖浣�" prop="originalPostName">
+ <el-input v-model="form.originalPostName" placeholder="閫夋嫨鐢宠浜哄悗鑷姩甯﹀嚭" disabled />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="杞叆宀椾綅" prop="targetPostId">
+ <el-select v-model="form.targetPostId" placeholder="璇烽�夋嫨杞叆宀椾綅" clearable filterable style="width: 100%">
+ <el-option
+ v-for="p in targetPostOptions"
+ :key="p.postId"
+ :label="p.postName"
+ :value="p.postId"
+ :disabled="p.status === '1' || p.status === 1"
+ />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
+ <el-radio-group v-model="form.approvalMode">
+ <el-radio value="parallel">涓庣</el-radio>
+ <el-radio value="countersign">浼氱</el-radio>
+ </el-radio-group>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="瀹℃壒浜�" prop="approverIds">
+ <el-tree-select
+ v-model="form.approverIds"
+ :data="approverTreeData"
+ multiple
+ collapse-tags
+ collapse-tags-tooltip
+ :max-collapse-tags="2"
+ :render-after-expand="false"
+ placeholder="璇烽�夋嫨瀹℃壒浜猴紙鍙閫夛級"
+ style="width: 100%"
+ :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
+ check-strictly
+ />
+ </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="formDialog.visible = false">鍙� 娑�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="璋冨矖鐢宠璇︽儏" width="560px" append-to-body>
+ <el-descriptions :column="1" border>
+ <el-descriptions-item label="鐢宠浜�">{{ detailRow.applicantName }}</el-descriptions-item>
+ <el-descriptions-item label="杞矖鏃ユ湡">{{ detailRow.transferDate }}</el-descriptions-item>
+ <el-descriptions-item label="鍘熷矖浣�">{{ detailRow.originalPostName }}</el-descriptions-item>
+ <el-descriptions-item label="杞叆宀椾綅">{{ detailRow.targetPostName }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒缁撴灉">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒鏂瑰紡">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒浜�">{{ detailRow.approverNames || "鈥�" }}</el-descriptions-item>
+ </el-descriptions>
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { findPostOptions } from "@/api/system/post.js";
+import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+
+const { proxy } = getCurrentInstance();
+
+/** 涓庡悗绔害瀹氬瓧娈碉紙鏈湴鍗犱綅锛屽悗鏈熸帴鍙e榻愶級 */
+const createEmptyForm = () => ({
+ id: undefined,
+ applicantId: "",
+ applicantName: "",
+ transferDate: "",
+ originalPostId: "",
+ originalPostName: "",
+ targetPostId: "",
+ targetPostName: "",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+});
+
+/** 绯荤粺鐢ㄦ埛缂撳瓨锛�/system/user/userListNoPageByTenantId锛屼笌杞鐢宠绛変竴鑷达級 */
+const allUsersCache = ref([]);
+/** 宀椾綅瀛楀吀 postId -> postName锛�/system/post/optionselect锛屼笌鍛樺伐妗f鍏ヨ亴琛ㄥ崟涓�鑷达級 */
+const postIdToName = ref({});
+const targetPostOptions = ref([]);
+
+function rebuildPostIdMap() {
+ 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;
+}
+
+function targetPostNameById(postId) {
+ if (postId == null || postId === "") return "";
+ const k = String(postId);
+ return (
+ postIdToName.value[k] ||
+ targetPostOptions.value.find((x) => String(x.postId ?? x.id ?? x.value) === k)?.postName ||
+ ""
+ );
+}
+
+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;
+}
+
+/** 浠庣敤鎴峰璞¤В鏋愩�屽師宀椾綅銆嶏紙鍏煎 postName / postIds / posts 绛夊父瑙佽繑鍥烇級 */
+function resolveOriginalPost(user) {
+ if (!user) return { originalPostId: "", originalPostName: "" };
+ const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
+ if (nameStr) {
+ const pid = firstPostId(user);
+ return { originalPostId: pid != null && pid !== "" ? String(pid) : "", originalPostName: nameStr };
+ }
+ if (Array.isArray(user.posts) && user.posts.length) {
+ const p0 = user.posts[0];
+ return {
+ originalPostId: p0.postId != null ? String(p0.postId) : "",
+ originalPostName: (p0.postName ?? "").toString() || "鏈懡鍚嶅矖浣�",
+ };
+ }
+ const pid = firstPostId(user);
+ if (pid != null && pid !== "") {
+ const n = postIdToName.value[String(pid)] || "";
+ return {
+ originalPostId: String(pid),
+ originalPostName: n || "褰撳墠宀椾綅锛堟湭鍦ㄥ矖浣嶅瓧鍏镐腑锛�",
+ };
+ }
+ return { originalPostId: "", 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 = [];
+ }
+ rebuildPostIdMap();
+}
+
+/** 鏌ヨ鍖猴細涓嬫媺杩滅▼妯$硦锛堟暟鎹潵鑷� userListNoPageByTenantId锛屽墠绔繃婊わ級 */
+const applicantSearchLoading = ref(false);
+const applicantSearchOptions = ref([]);
+
+async function remoteSearchApplicant(query) {
+ applicantSearchLoading.value = true;
+ try {
+ if (!allUsersCache.value.length) {
+ await loadUserPool();
+ }
+ applicantSearchOptions.value = filterUsersByQuery(query);
+ } finally {
+ applicantSearchLoading.value = false;
+ }
+}
+
+/** 琛ㄥ崟鍐呯敵璇蜂汉涓嬫媺 */
+const applicantFormSearchLoading = ref(false);
+const applicantFormOptions = ref([]);
+
+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.applicantName = u.nickName || u.userName || "";
+ const { originalPostId, originalPostName } = resolveOriginalPost(u);
+ form.originalPostId = originalPostId;
+ form.originalPostName = originalPostName;
+ } else {
+ form.applicantName = "";
+ form.originalPostId = "";
+ form.originalPostName = "";
+ }
+}
+
+/** 瀹℃壒浜烘爲 */
+const approverTreeData = ref([]);
+const approverLabelMap = ref({});
+
+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 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;
+ });
+}
+
+function getUserDeptId(u) {
+ return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
+}
+
+function getDeptNodeKey(node) {
+ const k = node?.id ?? node?.value ?? node?.deptId;
+ if (k == null || k === "") return null;
+ return k;
+}
+
+function isActiveUser(u) {
+ if (u.delFlag === "2" || u.delFlag === 2) return false;
+ if (u.status == null) return true;
+ return String(u.status) === "0";
+}
+
+function userToTreeLeaf(u) {
+ return {
+ id: String(u.userId ?? u.id),
+ label: u.nickName || u.userName || `鐢ㄦ埛${u.userId ?? u.id}`,
+ };
+}
+
+function buildUsersByDeptId(users) {
+ const map = new Map();
+ const unassigned = [];
+ for (const u of users) {
+ if (!isActiveUser(u)) continue;
+ const did = getUserDeptId(u);
+ if (did == null || did === "" || did === 0 || did === "0") {
+ unassigned.push(u);
+ continue;
+ }
+ const k = String(did);
+ if (!map.has(k)) map.set(k, []);
+ map.get(k).push(u);
+ }
+ return { map, unassigned };
+}
+
+function collectUserLabels(nodes, map) {
+ (nodes || []).forEach((n) => {
+ if (n.children?.length) {
+ collectUserLabels(n.children, map);
+ } else if (n.id != null && !String(n.id).startsWith("dept_")) {
+ map[String(n.id)] = n.label;
+ }
+ });
+}
+
+function mergeDeptTreeWithUsers(nodes, usersByDept) {
+ if (!Array.isArray(nodes)) return [];
+ const out = [];
+ for (const node of nodes) {
+ const deptIdRaw = getDeptNodeKey(node);
+ if (deptIdRaw == null) continue;
+ const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
+ const usersHere = usersByDept.get(String(deptIdRaw)) || [];
+ const userChildren = usersHere.map(userToTreeLeaf);
+ const children = [...sub, ...userChildren];
+ if (!children.length) continue;
+ out.push({
+ id: `dept_${deptIdRaw}`,
+ label: node.label ?? node.deptName ?? "閮ㄩ棬",
+ disabled: true,
+ children,
+ });
+ }
+ return out;
+}
+
+function buildFlatApproverTree(users) {
+ const list = users.filter(isActiveUser).map(userToTreeLeaf);
+ if (!list.length) return [];
+ return [
+ {
+ id: "dept_all_users",
+ label: "绯荤粺鐢ㄦ埛",
+ disabled: true,
+ children: list,
+ },
+ ];
+}
+
+async function loadApproverTree() {
+ try {
+ const needFetchUsers = !allUsersCache.value.length;
+ const [deptRes, userRes] = await Promise.all([
+ deptTreeSelect(),
+ needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null),
+ ]);
+ let rawTree = unwrapArray(deptRes);
+ rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
+ let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
+ if (!deptTree.length && rawTree.length) {
+ deptTree = JSON.parse(JSON.stringify(rawTree));
+ }
+ let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value];
+ if (needFetchUsers && users.length) {
+ allUsersCache.value = users;
+ }
+ const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
+ let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
+ if (unassigned.length) {
+ merged.push({
+ id: "dept_unassigned",
+ label: "鏈垎閰嶉儴闂�",
+ disabled: true,
+ children: unassigned.map(userToTreeLeaf),
+ });
+ }
+ if (!merged.length && users.length) {
+ merged = buildFlatApproverTree(users);
+ }
+ approverTreeData.value = merged;
+ const map = {};
+ collectUserLabels(merged, map);
+ approverLabelMap.value = map;
+ } catch {
+ approverTreeData.value = [];
+ approverLabelMap.value = {};
+ proxy?.$modal?.msgWarning?.("瀹℃壒浜烘暟鎹姞杞藉け璐ワ紝璇锋鏌ョ綉缁滄垨绋嶅悗閲嶈瘯");
+ }
+}
+
+function resolveApproverNames(ids) {
+ if (!ids?.length) return "";
+ const map = approverLabelMap.value;
+ return ids.map((id) => map[String(id)] || id).join("銆�");
+}
+
+function approvalModeLabel(mode) {
+ if (mode === "countersign") return "浼氱";
+ return "涓庣";
+}
+
+function approvalResultLabel(v) {
+ if (v === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ if (v === "cancelled") return "宸叉挙閿�";
+ return "寰呭鎵�";
+}
+
+/** 鏈湴妯℃嫙鍒楄〃鏁版嵁 */
+const allRows = ref([
+ {
+ id: "1",
+ applicantId: "1001",
+ applicantName: "鍛ㄦ槑",
+ transferDate: "2026-05-20",
+ originalPostId: "post_dev",
+ originalPostName: "杞欢寮�鍙戝伐绋嬪笀",
+ targetPostId: "post_senior_dev",
+ targetPostName: "楂樼骇杞欢寮�鍙戝伐绋嬪笀",
+ approvalResult: "pending",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+ },
+ {
+ id: "2",
+ applicantId: "1002",
+ applicantName: "鍚磋姵",
+ transferDate: "2026-05-10",
+ originalPostId: "post_pm",
+ originalPostName: "浜у搧缁忕悊",
+ targetPostId: "post_senior_pm",
+ targetPostName: "楂樼骇浜у搧缁忕悊",
+ approvalResult: "approved",
+ approvalMode: "countersign",
+ approverIds: [],
+ approverNames: "寮犱笁銆佹潕鍥�",
+ },
+]);
+
+const searchForm = reactive({
+ applicantId: "",
+ transferDateRange: null,
+});
+
+const tableLoading = ref(false);
+const page = reactive({
+ current: 1,
+ size: 10,
+ total: 0,
+});
+
+const filteredList = computed(() => {
+ let list = [...allRows.value];
+ if (searchForm.applicantId) {
+ list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId));
+ }
+ const range = searchForm.transferDateRange;
+ if (range && range.length === 2) {
+ const [start, end] = range;
+ list = list.filter((r) => r.transferDate >= start && r.transferDate <= end);
+ }
+ return list.sort((a, b) => (a.transferDate < b.transferDate ? 1 : -1));
+});
+
+watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) {
+ page.current = maxPage;
+ }
+ },
+ { immediate: true }
+);
+
+const tableData = computed(() => {
+ const list = filteredList.value;
+ const start = (page.current - 1) * page.size;
+ return list.slice(start, start + page.size);
+});
+
+const tableColumn = ref([
+ { label: "鐢宠浜�", prop: "applicantName", minWidth: 100 },
+ { label: "杞矖鏃ユ湡", prop: "transferDate", width: 120 },
+ { label: "鍘熷矖浣�", prop: "originalPostName", minWidth: 140 },
+ { label: "杞叆宀椾綅", prop: "targetPostName", minWidth: 160 },
+ {
+ label: "瀹℃壒缁撴灉",
+ prop: "approvalResult",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => approvalResultLabel(v),
+ formatType: (v) => {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ if (v === "cancelled") return "info";
+ return "warning";
+ },
+ },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 180,
+ operation: [
+ { name: "缂栬緫", type: "text", clickFun: (row) => openFormDialog("edit", row) },
+ { name: "鏌ョ湅璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ ],
+ },
+]);
+
+const formDialog = reactive({
+ visible: false,
+ title: "",
+ mode: "add",
+});
+const formRef = ref();
+const form = reactive(createEmptyForm());
+
+const formRules = {
+ applicantId: [{ required: true, message: "璇烽�夋嫨鐢宠浜�", trigger: "change" }],
+ transferDate: [{ required: true, message: "璇烽�夋嫨杞矖鏃ユ湡", trigger: "change" }],
+ originalPostName: [{ required: true, message: "鍘熷矖浣嶄笉鑳戒负绌�", trigger: "change" }],
+ targetPostId: [{ required: true, message: "璇烽�夋嫨杞叆宀椾綅", trigger: "change" }],
+ approvalMode: [{ required: true, message: "璇烽�夋嫨瀹℃壒鏂瑰紡", trigger: "change" }],
+ approverIds: [{ type: "array", required: true, message: "璇烽�夋嫨瀹℃壒浜�", trigger: "change" }],
+};
+
+const detailDialog = reactive({ visible: false });
+const detailRow = ref({});
+
+function handleQuery() {
+ page.current = 1;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+}
+
+async function resetSearch() {
+ searchForm.applicantId = "";
+ searchForm.transferDateRange = null;
+ handleQuery();
+ await remoteSearchApplicant("");
+}
+
+function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+}
+
+function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+}
+
+function ensureApplicantInFormOptions(row) {
+ if (!row?.applicantId) return;
+ const id = String(row.applicantId);
+ if (!applicantFormOptions.value.some((u) => String(u.userId ?? u.id) === id)) {
+ applicantFormOptions.value = [
+ {
+ userId: row.applicantId,
+ nickName: row.applicantName,
+ userName: row.applicantUserName,
+ },
+ ...applicantFormOptions.value,
+ ];
+ }
+}
+
+async function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.title = mode === "add" ? "鏂板璋冨矖鐢宠" : "缂栬緫璋冨矖鐢宠";
+ loadApproverTree();
+ Object.assign(form, createEmptyForm());
+ await remoteSearchApplicantForm("");
+ if (mode === "edit" && row) {
+ ensureApplicantInFormOptions(row);
+ Object.assign(form, {
+ id: row.id,
+ applicantId: row.applicantId,
+ applicantName: row.applicantName,
+ transferDate: row.transferDate,
+ originalPostId: row.originalPostId,
+ originalPostName: row.originalPostName,
+ targetPostId: row.targetPostId,
+ targetPostName: row.targetPostName,
+ approvalMode: row.approvalMode,
+ approverIds: (row.approverIds || []).map((id) => String(id)),
+ approverNames: row.approverNames,
+ });
+ }
+ formDialog.visible = true;
+ nextTick(() => formRef.value?.clearValidate?.());
+}
+
+function onFormClosed() {
+ formRef.value?.resetFields?.();
+}
+
+async function submitForm() {
+ try {
+ await formRef.value?.validate?.();
+ } catch {
+ return;
+ }
+ form.approverNames = resolveApproverNames(form.approverIds);
+ form.targetPostName = targetPostNameById(form.targetPostId);
+ const payload = {
+ applicantId: form.applicantId,
+ applicantName: form.applicantName,
+ transferDate: form.transferDate,
+ originalPostId: form.originalPostId,
+ originalPostName: form.originalPostName,
+ targetPostId: form.targetPostId,
+ targetPostName: form.targetPostName,
+ approvalMode: form.approvalMode,
+ approverIds: [...form.approverIds],
+ approverNames: form.approverNames,
+ };
+ if (formDialog.mode === "add") {
+ const id = `local_${Date.now()}`;
+ allRows.value.unshift({
+ id,
+ ...payload,
+ approvalResult: "pending",
+ });
+ proxy?.$modal?.msgSuccess?.("鏂板鎴愬姛锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ const prev = idx !== -1 ? allRows.value[idx] : {};
+ if (idx !== -1) {
+ allRows.value[idx] = {
+ ...prev,
+ id: form.id,
+ ...payload,
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+}
+
+onMounted(async () => {
+ await Promise.all([loadUserPool(), loadPostOptions()]);
+ rebuildPostIdMap();
+ loadApproverTree();
+ await remoteSearchApplicant("");
+});
+</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);
+}
+.transfer-apply-form :deep(.el-row) {
+ margin-bottom: 0;
+}
+.transfer-apply-form :deep(.el-form-item) {
+ margin-bottom: 18px;
+}
+.transfer-apply-form-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+</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..2e05b85
--- /dev/null
+++ b/src/views/officeProcessAutomation/HrManage/work-handover/index.vue
@@ -0,0 +1,810 @@
+<!--OA妯″潡锛氬伐浣滀氦鎺�-->
+<template>
+ <div class="app-container">
+ <div class="search_form mb20">
+ <div>
+ <span class="search_title">鐢宠浜猴細</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>
+ <span class="search_title" style="margin-left: 12px">浜ゆ帴鐘舵�侊細</span>
+ <el-select v-model="searchForm.handoverStatus" placeholder="鍏ㄩ儴" clearable style="width: 140px">
+ <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" />
+ </el-select>
+ <span class="search_title" style="margin-left: 12px">浜ゆ帴绫诲瀷锛�</span>
+ <el-select v-model="searchForm.handoverType" placeholder="鍏ㄩ儴" clearable style="width: 140px">
+ <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
+ </el-select>
+ <el-button type="primary" style="margin-left: 10px" @click="handleQuery">鎼滅储</el-button>
+ <el-button @click="resetSearch">閲嶇疆</el-button>
+ </div>
+ <div>
+ <el-button type="primary" @click="openFormDialog('add')">鏂板宸ヤ綔浜ゆ帴</el-button>
+ </div>
+ </div>
+ <div class="table_list">
+ <PIMTable
+ rowKey="id"
+ :column="tableColumn"
+ :tableData="tableData"
+ :page="page"
+ :isSelection="false"
+ :tableLoading="tableLoading"
+ @pagination="pagination"
+ :total="page.total"
+ />
+ </div>
+
+ <!-- 鏂板 / 缂栬緫 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="720px"
+ append-to-body
+ destroy-on-close
+ class="work-handover-form-dialog"
+ @closed="onFormClosed"
+ >
+ <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="work-handover-form">
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <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="24">
+ <el-col :span="12">
+ <el-form-item label="绂昏亴鏃ユ湡" prop="leaveDate">
+ <el-date-picker
+ v-model="form.leaveDate"
+ type="date"
+ placeholder="璇烽�夋嫨绂昏亴鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="浜ゆ帴鐘舵��" prop="handoverStatus">
+ <el-select v-model="form.handoverStatus" placeholder="璇烽�夋嫨浜ゆ帴鐘舵��" style="width: 100%">
+ <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="浜ゆ帴绫诲瀷" prop="handoverType">
+ <el-select v-model="form.handoverType" placeholder="璇烽�夋嫨浜ゆ帴绫诲瀷" style="width: 100%">
+ <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="浜ゆ帴浜�" prop="handoverPersonId">
+ <el-select
+ v-model="form.handoverPersonId"
+ filterable
+ remote
+ clearable
+ reserve-keyword
+ placeholder="璇烽�夋嫨鎴栨悳绱氦鎺ヤ汉"
+ style="width: 100%"
+ :remote-method="remoteSearchHandoverPerson"
+ :loading="handoverPersonSearchLoading"
+ @change="onHandoverPersonChange"
+ >
+ <el-option
+ v-for="u in handoverPersonOptions"
+ :key="u.userId"
+ :label="userSelectLabel(u)"
+ :value="u.userId"
+ />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="12">
+ <el-form-item label="瀹℃壒鏂瑰紡" prop="approvalMode">
+ <el-radio-group v-model="form.approvalMode">
+ <el-radio value="parallel">涓庣</el-radio>
+ <el-radio value="countersign">浼氱</el-radio>
+ </el-radio-group>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="24">
+ <el-col :span="24">
+ <el-form-item label="瀹℃壒浜�" prop="approverIds">
+ <el-tree-select
+ v-model="form.approverIds"
+ :data="approverTreeData"
+ multiple
+ collapse-tags
+ collapse-tags-tooltip
+ :max-collapse-tags="2"
+ :render-after-expand="false"
+ placeholder="璇烽�夋嫨瀹℃壒浜猴紙鍙閫夛級"
+ style="width: 100%"
+ :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
+ check-strictly
+ />
+ </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="formDialog.visible = false">鍙� 娑�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="宸ヤ綔浜ゆ帴璇︽儏" width="560px" append-to-body>
+ <el-descriptions :column="1" border>
+ <el-descriptions-item label="鐢宠浜�">{{ detailRow.applicantName }}</el-descriptions-item>
+ <el-descriptions-item label="绂昏亴鏃ユ湡">{{ detailRow.leaveDate || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="浜ゆ帴鐘舵��">{{ handoverStatusLabel(detailRow.handoverStatus) }}</el-descriptions-item>
+ <el-descriptions-item label="浜ゆ帴绫诲瀷">{{ handoverTypeLabel(detailRow.handoverType) }}</el-descriptions-item>
+ <el-descriptions-item label="浜ゆ帴浜�">{{ detailRow.handoverPersonName || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒缁撴灉">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒鏂瑰紡">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
+ <el-descriptions-item label="瀹℃壒浜�">{{ detailRow.approverNames || "鈥�" }}</el-descriptions-item>
+ </el-descriptions>
+ <template #footer>
+ <div class="dialog-footer">
+ <el-button type="primary" @click="detailDialog.visible = false">鍏� 闂�</el-button>
+ </div>
+ </template>
+ </el-dialog>
+ </div>
+</template>
+
+<script setup>
+import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+
+const { proxy } = getCurrentInstance();
+
+const handoverStatusOptions = [
+ { value: "in_progress", label: "杩涜涓�" },
+ { value: "completed", label: "宸插畬鎴�" },
+ { value: "returned", label: "宸查��鍥�" },
+];
+
+const handoverTypeOptions = [
+ { value: "resignation", label: "绂昏亴浜ゆ帴" },
+ { value: "transfer", label: "璋冨矖浜ゆ帴" },
+];
+
+function handoverStatusLabel(v) {
+ return handoverStatusOptions.find((o) => o.value === v)?.label || "鈥�";
+}
+
+function handoverTypeLabel(v) {
+ return handoverTypeOptions.find((o) => o.value === v)?.label || "鈥�";
+}
+
+/** 涓庡悗绔害瀹氬瓧娈碉紙鏈湴鍗犱綅锛屽悗鏈熸帴鍙e榻愶級 */
+const createEmptyForm = () => ({
+ id: undefined,
+ applicantId: "",
+ applicantName: "",
+ leaveDate: "",
+ handoverStatus: "in_progress",
+ handoverType: "resignation",
+ handoverPersonId: "",
+ handoverPersonName: "",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+});
+
+const allUsersCache = ref([]);
+
+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) {
+ 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 = [];
+ }
+}
+
+const applicantSearchLoading = ref(false);
+const applicantSearchOptions = ref([]);
+
+async function remoteSearchApplicant(query) {
+ applicantSearchLoading.value = true;
+ try {
+ if (!allUsersCache.value.length) {
+ await loadUserPool();
+ }
+ applicantSearchOptions.value = filterUsersByQuery(query);
+ } finally {
+ applicantSearchLoading.value = false;
+ }
+}
+
+const applicantFormSearchLoading = ref(false);
+const applicantFormOptions = ref([]);
+
+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);
+ form.applicantName = u ? u.nickName || u.userName || "" : "";
+}
+
+const handoverPersonSearchLoading = ref(false);
+const handoverPersonOptions = ref([]);
+
+async function remoteSearchHandoverPerson(query) {
+ handoverPersonSearchLoading.value = true;
+ try {
+ if (!allUsersCache.value.length) {
+ await loadUserPool();
+ }
+ handoverPersonOptions.value = filterUsersByQuery(query);
+ } finally {
+ handoverPersonSearchLoading.value = false;
+ }
+}
+
+function onHandoverPersonChange(uid) {
+ const u = userById(uid);
+ form.handoverPersonName = u ? u.nickName || u.userName || "" : "";
+}
+
+const approverTreeData = ref([]);
+const approverLabelMap = ref({});
+
+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 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;
+ });
+}
+
+function getUserDeptId(u) {
+ return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
+}
+
+function getDeptNodeKey(node) {
+ const k = node?.id ?? node?.value ?? node?.deptId;
+ if (k == null || k === "") return null;
+ return k;
+}
+
+function isActiveUser(u) {
+ if (u.delFlag === "2" || u.delFlag === 2) return false;
+ if (u.status == null) return true;
+ return String(u.status) === "0";
+}
+
+function userToTreeLeaf(u) {
+ return {
+ id: String(u.userId ?? u.id),
+ label: u.nickName || u.userName || `鐢ㄦ埛${u.userId ?? u.id}`,
+ };
+}
+
+function buildUsersByDeptId(users) {
+ const map = new Map();
+ const unassigned = [];
+ for (const u of users) {
+ if (!isActiveUser(u)) continue;
+ const did = getUserDeptId(u);
+ if (did == null || did === "" || did === 0 || did === "0") {
+ unassigned.push(u);
+ continue;
+ }
+ const k = String(did);
+ if (!map.has(k)) map.set(k, []);
+ map.get(k).push(u);
+ }
+ return { map, unassigned };
+}
+
+function collectUserLabels(nodes, map) {
+ (nodes || []).forEach((n) => {
+ if (n.children?.length) {
+ collectUserLabels(n.children, map);
+ } else if (n.id != null && !String(n.id).startsWith("dept_")) {
+ map[String(n.id)] = n.label;
+ }
+ });
+}
+
+function mergeDeptTreeWithUsers(nodes, usersByDept) {
+ if (!Array.isArray(nodes)) return [];
+ const out = [];
+ for (const node of nodes) {
+ const deptIdRaw = getDeptNodeKey(node);
+ if (deptIdRaw == null) continue;
+ const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
+ const usersHere = usersByDept.get(String(deptIdRaw)) || [];
+ const userChildren = usersHere.map(userToTreeLeaf);
+ const children = [...sub, ...userChildren];
+ if (!children.length) continue;
+ out.push({
+ id: `dept_${deptIdRaw}`,
+ label: node.label ?? node.deptName ?? "閮ㄩ棬",
+ disabled: true,
+ children,
+ });
+ }
+ return out;
+}
+
+function buildFlatApproverTree(users) {
+ const list = users.filter(isActiveUser).map(userToTreeLeaf);
+ if (!list.length) return [];
+ return [
+ {
+ id: "dept_all_users",
+ label: "绯荤粺鐢ㄦ埛",
+ disabled: true,
+ children: list,
+ },
+ ];
+}
+
+async function loadApproverTree() {
+ try {
+ const needFetchUsers = !allUsersCache.value.length;
+ const [deptRes, userRes] = await Promise.all([
+ deptTreeSelect(),
+ needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null),
+ ]);
+ let rawTree = unwrapArray(deptRes);
+ rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
+ let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
+ if (!deptTree.length && rawTree.length) {
+ deptTree = JSON.parse(JSON.stringify(rawTree));
+ }
+ let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value];
+ if (needFetchUsers && users.length) {
+ allUsersCache.value = users;
+ }
+ const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
+ let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
+ if (unassigned.length) {
+ merged.push({
+ id: "dept_unassigned",
+ label: "鏈垎閰嶉儴闂�",
+ disabled: true,
+ children: unassigned.map(userToTreeLeaf),
+ });
+ }
+ if (!merged.length && users.length) {
+ merged = buildFlatApproverTree(users);
+ }
+ approverTreeData.value = merged;
+ const map = {};
+ collectUserLabels(merged, map);
+ approverLabelMap.value = map;
+ } catch {
+ approverTreeData.value = [];
+ approverLabelMap.value = {};
+ proxy?.$modal?.msgWarning?.("瀹℃壒浜烘暟鎹姞杞藉け璐ワ紝璇锋鏌ョ綉缁滄垨绋嶅悗閲嶈瘯");
+ }
+}
+
+function resolveApproverNames(ids) {
+ if (!ids?.length) return "";
+ const map = approverLabelMap.value;
+ return ids.map((id) => map[String(id)] || id).join("銆�");
+}
+
+function approvalModeLabel(mode) {
+ if (mode === "countersign") return "浼氱";
+ return "涓庣";
+}
+
+function approvalResultLabel(v) {
+ if (v === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ if (v === "cancelled") return "宸叉挙閿�";
+ return "寰呭鎵�";
+}
+
+function handoverStatusTagType(v) {
+ if (v === "completed") return "success";
+ if (v === "returned") return "danger";
+ return "warning";
+}
+
+function handoverTypeTagType(v) {
+ return v === "transfer" ? "info" : "";
+}
+
+/** 鏈湴妯℃嫙鍒楄〃鏁版嵁 */
+const allRows = ref([
+ {
+ id: "1",
+ applicantId: "1001",
+ applicantName: "鍛ㄦ槑",
+ leaveDate: "2026-05-28",
+ handoverStatus: "in_progress",
+ handoverType: "resignation",
+ handoverPersonId: "1003",
+ handoverPersonName: "鐜嬪己",
+ approvalResult: "pending",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "",
+ },
+ {
+ id: "2",
+ applicantId: "1002",
+ applicantName: "鍚磋姵",
+ leaveDate: "2026-05-15",
+ handoverStatus: "completed",
+ handoverType: "transfer",
+ handoverPersonId: "1004",
+ handoverPersonName: "璧垫晱",
+ approvalResult: "approved",
+ approvalMode: "countersign",
+ approverIds: [],
+ approverNames: "寮犱笁銆佹潕鍥�",
+ },
+ {
+ id: "3",
+ applicantId: "1005",
+ applicantName: "闄堟旦",
+ leaveDate: "2026-04-20",
+ handoverStatus: "returned",
+ handoverType: "resignation",
+ handoverPersonId: "1006",
+ handoverPersonName: "鍒樻磱",
+ approvalResult: "rejected",
+ approvalMode: "parallel",
+ approverIds: [],
+ approverNames: "鏉庡洓",
+ },
+]);
+
+const searchForm = reactive({
+ applicantId: "",
+ handoverStatus: "",
+ handoverType: "",
+});
+
+const tableLoading = ref(false);
+const page = reactive({
+ current: 1,
+ size: 10,
+ total: 0,
+});
+
+const filteredList = computed(() => {
+ let list = [...allRows.value];
+ if (searchForm.applicantId) {
+ list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId));
+ }
+ if (searchForm.handoverStatus) {
+ list = list.filter((r) => r.handoverStatus === searchForm.handoverStatus);
+ }
+ if (searchForm.handoverType) {
+ list = list.filter((r) => r.handoverType === searchForm.handoverType);
+ }
+ return list.sort((a, b) => (a.leaveDate < b.leaveDate ? 1 : -1));
+});
+
+watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) {
+ page.current = maxPage;
+ }
+ },
+ { immediate: true }
+);
+
+const tableData = computed(() => {
+ const list = filteredList.value;
+ const start = (page.current - 1) * page.size;
+ return list.slice(start, start + page.size);
+});
+
+const tableColumn = ref([
+ { label: "鐢宠浜�", prop: "applicantName", minWidth: 100 },
+ { label: "绂昏亴鏃ユ湡", prop: "leaveDate", width: 120 },
+ {
+ label: "浜ゆ帴鐘舵��",
+ prop: "handoverStatus",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => handoverStatusLabel(v),
+ formatType: (v) => handoverStatusTagType(v),
+ },
+ {
+ label: "浜ゆ帴绫诲瀷",
+ prop: "handoverType",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => handoverTypeLabel(v),
+ formatType: (v) => handoverTypeTagType(v),
+ },
+ { label: "浜ゆ帴浜�", prop: "handoverPersonName", minWidth: 100 },
+ {
+ label: "瀹℃壒缁撴灉",
+ prop: "approvalResult",
+ width: 110,
+ dataType: "tag",
+ formatData: (v) => approvalResultLabel(v),
+ formatType: (v) => {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ if (v === "cancelled") return "info";
+ return "warning";
+ },
+ },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 200,
+ operation: [
+ { name: "缂栬緫", type: "text", clickFun: (row) => openFormDialog("edit", row) },
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ ],
+ },
+]);
+
+const formDialog = reactive({
+ visible: false,
+ title: "",
+ mode: "add",
+});
+const formRef = ref();
+const form = reactive(createEmptyForm());
+
+const formRules = {
+ applicantId: [{ required: true, message: "璇烽�夋嫨鐢宠浜�", trigger: "change" }],
+ leaveDate: [{ required: true, message: "璇烽�夋嫨绂昏亴鏃ユ湡", trigger: "change" }],
+ handoverStatus: [{ required: true, message: "璇烽�夋嫨浜ゆ帴鐘舵��", trigger: "change" }],
+ handoverType: [{ required: true, message: "璇烽�夋嫨浜ゆ帴绫诲瀷", trigger: "change" }],
+ handoverPersonId: [{ required: true, message: "璇烽�夋嫨浜ゆ帴浜�", trigger: "change" }],
+ approvalMode: [{ required: true, message: "璇烽�夋嫨瀹℃壒鏂瑰紡", trigger: "change" }],
+ approverIds: [{ type: "array", required: true, message: "璇烽�夋嫨瀹℃壒浜�", trigger: "change" }],
+};
+
+const detailDialog = reactive({ visible: false });
+const detailRow = ref({});
+
+function handleQuery() {
+ page.current = 1;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+}
+
+async function resetSearch() {
+ searchForm.applicantId = "";
+ searchForm.handoverStatus = "";
+ searchForm.handoverType = "";
+ handleQuery();
+ await remoteSearchApplicant("");
+}
+
+function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+}
+
+function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+}
+
+function ensureUserInOptions(optionsRef, row, idKey, nameKey) {
+ const id = row?.[idKey];
+ if (id == null || id === "") return;
+ const sid = String(id);
+ if (!optionsRef.value.some((u) => String(u.userId ?? u.id) === sid)) {
+ optionsRef.value = [
+ {
+ userId: id,
+ nickName: row[nameKey],
+ userName: row.applicantUserName,
+ },
+ ...optionsRef.value,
+ ];
+ }
+}
+
+async function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.title = mode === "add" ? "鏂板宸ヤ綔浜ゆ帴" : "缂栬緫宸ヤ綔浜ゆ帴";
+ loadApproverTree();
+ Object.assign(form, createEmptyForm());
+ await Promise.all([remoteSearchApplicantForm(""), remoteSearchHandoverPerson("")]);
+ if (mode === "edit" && row) {
+ ensureUserInOptions(applicantFormOptions, row, "applicantId", "applicantName");
+ ensureUserInOptions(handoverPersonOptions, row, "handoverPersonId", "handoverPersonName");
+ Object.assign(form, {
+ id: row.id,
+ applicantId: row.applicantId,
+ applicantName: row.applicantName,
+ leaveDate: row.leaveDate,
+ handoverStatus: row.handoverStatus,
+ handoverType: row.handoverType,
+ handoverPersonId: row.handoverPersonId,
+ handoverPersonName: row.handoverPersonName,
+ approvalMode: row.approvalMode,
+ approverIds: (row.approverIds || []).map((id) => String(id)),
+ approverNames: row.approverNames,
+ });
+ }
+ formDialog.visible = true;
+ nextTick(() => formRef.value?.clearValidate?.());
+}
+
+function onFormClosed() {
+ formRef.value?.resetFields?.();
+}
+
+async function submitForm() {
+ try {
+ await formRef.value?.validate?.();
+ } catch {
+ return;
+ }
+ form.approverNames = resolveApproverNames(form.approverIds);
+ const payload = {
+ applicantId: form.applicantId,
+ applicantName: form.applicantName,
+ leaveDate: form.leaveDate,
+ handoverStatus: form.handoverStatus,
+ handoverType: form.handoverType,
+ handoverPersonId: form.handoverPersonId,
+ handoverPersonName: form.handoverPersonName,
+ approvalMode: form.approvalMode,
+ approverIds: [...form.approverIds],
+ approverNames: form.approverNames,
+ };
+ if (formDialog.mode === "add") {
+ const id = `local_${Date.now()}`;
+ allRows.value.unshift({
+ id,
+ ...payload,
+ approvalResult: "pending",
+ });
+ proxy?.$modal?.msgSuccess?.("鏂板鎴愬姛锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ const prev = idx !== -1 ? allRows.value[idx] : {};
+ if (idx !== -1) {
+ allRows.value[idx] = {
+ ...prev,
+ id: form.id,
+ ...payload,
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+}
+
+onMounted(async () => {
+ await loadUserPool();
+ loadApproverTree();
+ await remoteSearchApplicant("");
+});
+</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);
+}
+.work-handover-form :deep(.el-row) {
+ margin-bottom: 0;
+}
+.work-handover-form :deep(.el-form-item) {
+ margin-bottom: 18px;
+}
+.work-handover-form-dialog :deep(.el-dialog__body) {
+ padding-top: 12px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue
new file mode 100644
index 0000000..9a490fc
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue
@@ -0,0 +1,77 @@
+<!-- NoticeAnnouncement锛氬叕鍛婅鎯呭彧璇婚潰鏉� -->
+<template>
+ <el-descriptions :column="2" border>
+ <el-descriptions-item label="鍏憡缂栧彿">{{ row.noticeNo || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鐘舵��">
+ <el-tag :type="statusTag" size="small">{{ statusText }}</el-tag>
+ </el-descriptions-item>
+ <el-descriptions-item label="鍏憡绫诲瀷">
+ <span class="type-badge" :style="{ color: noticeTypeColor(row.noticeType) }">
+ {{ noticeTypeLabel(row.noticeType) }}
+ </span>
+ </el-descriptions-item>
+ <el-descriptions-item label="浼樺厛绾�">
+ <el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
+ </el-descriptions-item>
+ <el-descriptions-item label="鏍囬" :span="2">{{ row.title || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="鍙戝竷鏃ユ湡">{{ row.publishDate || "鈥�" }}</el-descriptions-item>
+ <el-descriptions-item label="杩囨湡鏃ユ湡">{{ row.expireDate || "闀挎湡鏈夋晥" }}</el-descriptions-item>
+ <el-descriptions-item label="闃呰鑼冨洿">{{ readScopeLabel(row.readScope) }}</el-descriptions-item>
+ <el-descriptions-item label="闇�闃呰纭">{{ row.requireReadConfirm ? "鏄�" : "鍚�" }}</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="闃呰閲�">{{ row.readCount ?? 0 }}</el-descriptions-item>
+ </el-descriptions>
+
+ <el-divider content-position="left">鍏憡鍐呭</el-divider>
+ <div v-if="row.priority === 'urgent'" class="urgent-banner">
+ <el-alert title="绱ф�ラ�氱煡" type="error" :closable="false" show-icon />
+ </div>
+ <div v-if="row.contentHtml" class="notice-html-body" v-html="row.contentHtml" />
+ <el-empty v-else description="鏆傛棤鍐呭" :image-size="48" />
+</template>
+
+<script setup>
+import { computed } from "vue";
+import {
+ noticeTypeLabel,
+ noticeTypeColor,
+ priorityLabel,
+ priorityTag,
+ publishStatusLabel,
+ publishStatusTag,
+ readScopeLabel,
+ isExpired,
+} from "../noticeAnnouncementUtils.js";
+
+const props = defineProps({
+ row: { type: Object, default: () => ({}) },
+});
+
+const statusText = computed(() => {
+ if (isExpired(props.row) && props.row.publishStatus === "published") return "宸茶繃鏈�";
+ return publishStatusLabel(props.row.publishStatus);
+});
+
+const statusTag = computed(() => {
+ if (isExpired(props.row) && props.row.publishStatus === "published") return "";
+ return publishStatusTag(props.row.publishStatus);
+});
+</script>
+
+<style scoped>
+.type-badge {
+ font-weight: 600;
+}
+.urgent-banner {
+ margin-bottom: 12px;
+}
+.notice-html-body {
+ padding: 12px;
+ background: var(--el-fill-color-light);
+ border-radius: 6px;
+ max-height: 400px;
+ overflow-y: auto;
+ line-height: 1.7;
+}
+</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..4599ced
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
@@ -0,0 +1,253 @@
+<!--OA妯″潡锛歂oticeAnnouncement 閫氱煡鍏憡-->
+<template>
+ <div class="app-container notice-announcement-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="handleQuery"
+ />
+ <span class="search_title" style="margin-left: 12px">绫诲瀷锛�</span>
+ <el-select v-model="searchForm.noticeType" placeholder="鍏ㄩ儴" clearable style="width: 130px">
+ <el-option v-for="opt in NOTICE_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.priority" placeholder="鍏ㄩ儴" clearable style="width: 110px">
+ <el-option v-for="opt in PRIORITY_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.publishStatus" placeholder="鍏ㄩ儴" clearable style="width: 110px">
+ <el-option v-for="opt in PUBLISH_STATUS_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.publishDateRange"
+ 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="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"
+ >
+ <template #noticeType="{ row }">
+ <span class="notice-type-tag" :style="{ color: noticeTypeColor(row.noticeType) }">
+ {{ noticeTypeLabel(row.noticeType) }}
+ </span>
+ </template>
+ </PIMTable>
+ </div>
+
+ <!-- 娣诲姞 / 淇敼 -->
+ <el-dialog
+ v-model="formDialog.visible"
+ :title="formDialog.title"
+ width="800px"
+ append-to-body
+ destroy-on-close
+ class="notice-form-dialog"
+ @closed="formRef?.resetFields?.()"
+ >
+ <el-form
+ ref="formRef"
+ :model="form"
+ :rules="formRules"
+ label-width="100px"
+ :disabled="formDialog.readonly"
+ >
+ <el-form-item label="鏍囬" prop="title">
+ <el-input v-model="form.title" placeholder="璇疯緭鍏ュ叕鍛婃爣棰�" maxlength="100" show-word-limit />
+ </el-form-item>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鍏憡绫诲瀷" prop="noticeType">
+ <el-select v-model="form.noticeType" placeholder="璇烽�夋嫨" style="width: 100%" @change="onNoticeTypeChange">
+ <el-option v-for="opt in NOTICE_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="form.priority" placeholder="璇烽�夋嫨" style="width: 100%">
+ <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
+ </el-select>
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-row :gutter="20">
+ <el-col :span="12">
+ <el-form-item label="鍙戝竷鏃ユ湡" prop="publishDate">
+ <el-date-picker
+ v-model="form.publishDate"
+ type="date"
+ placeholder="鍙戝竷鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ />
+ </el-form-item>
+ </el-col>
+ <el-col :span="12">
+ <el-form-item label="杩囨湡鏃ユ湡">
+ <el-date-picker
+ v-model="form.expireDate"
+ type="date"
+ placeholder="鍙�夛紝鐣欑┖涓洪暱鏈熸湁鏁�"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 100%"
+ clearable
+ />
+ </el-form-item>
+ </el-col>
+ </el-row>
+ <el-form-item label="闃呰鑼冨洿">
+ <el-radio-group v-model="form.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="form.readScope === 'department'" label="鍙閮ㄩ棬">
+ <el-select v-model="form.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 v-if="form.noticeType === 'emergency'" label="蹇呰纭">
+ <el-switch v-model="form.requireReadConfirm" active-text="绱ф�ラ�氱煡闇�鍛樺伐纭宸茶" />
+ </el-form-item>
+ <el-form-item label="鍐呭" prop="contentHtml">
+ <Editor v-model="form.contentHtml" :min-height="280" placeholder="璇疯緭鍏ュ唴瀹�" />
+ </el-form-item>
+ <el-form-item label="鍙戝竷浜�">
+ <el-input v-model="form.publisherName" placeholder="濡傦細琛屾斂閮�" maxlength="50" />
+ </el-form-item>
+ </el-form>
+ <template v-if="!formDialog.readonly" #footer>
+ <el-button @click="formDialog.visible = false">鍙� 娑�</el-button>
+ <el-button @click="onSave(false)">瀛樿崏绋�</el-button>
+ <el-button type="primary" @click="onSave(true)">纭� 瀹�</el-button>
+ </template>
+ </el-dialog>
+
+ <!-- 璇︽儏 -->
+ <el-dialog v-model="detailDialog.visible" title="鍏憡璇︽儏" width="800px" append-to-body destroy-on-close>
+ <NoticeDetailPanel :row="detailRow" />
+ <template #footer>
+ <el-button @click="detailDialog.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 { onMounted } from "vue";
+import Editor from "@/components/Editor/index.vue";
+import { noticeTypeColor } from "./noticeAnnouncementUtils.js";
+import NoticeDetailPanel from "./components/NoticeDetailPanel.vue";
+import { useNoticeAnnouncement } from "./useNoticeAnnouncement.js";
+
+const {
+ Search,
+ NOTICE_TYPE_OPTIONS,
+ PRIORITY_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ DEPT_OPTIONS,
+ noticeTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ saveForm,
+} = useNoticeAnnouncement();
+
+function onNoticeTypeChange(type) {
+ if (type === "emergency") {
+ form.priority = "urgent";
+ form.requireReadConfirm = true;
+ }
+}
+
+function onSave(publish) {
+ const ret = saveForm(publish);
+ if (ret?.message) {
+ ElMessage.warning(ret.message);
+ return;
+ }
+ if (ret?.ok) {
+ ElMessage.success(publish ? "鍏憡宸插彂甯�" : "宸蹭繚瀛樿崏绋�");
+ }
+}
+
+onMounted(() => {
+ handleQuery();
+});
+</script>
+
+<style scoped>
+.notice-announcement-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;
+}
+.notice-type-tag {
+ font-weight: 600;
+ font-size: 13px;
+}
+.ml10 {
+ margin-left: 10px;
+}
+.mb20 {
+ margin-bottom: 20px;
+}
+</style>
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js
new file mode 100644
index 0000000..f6b789d
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js
@@ -0,0 +1,194 @@
+import dayjs from "dayjs";
+
+/** 鍏憡绫诲瀷 */
+export const NOTICE_TYPE_OPTIONS = [
+ { value: "emergency", label: "绱ф�ラ�氱煡", color: "#f56c6c" },
+ { value: "employee", label: "鍛樺伐鍏憡", color: "#409eff" },
+ { value: "company", label: "浼佷笟鍏憡", color: "#e6a23c" },
+];
+
+/** 浼樺厛绾� */
+export const PRIORITY_OPTIONS = [
+ { value: "urgent", label: "绱ф��", tag: "danger" },
+ { value: "high", label: "閲嶈", tag: "warning" },
+ { value: "normal", label: "鏅��", tag: "info" },
+];
+
+/** 鍙戝竷鐘舵�� */
+export const PUBLISH_STATUS_OPTIONS = [
+ { value: "draft", label: "鑽夌", tag: "info" },
+ { value: "published", label: "宸插彂甯�", tag: "success" },
+ { value: "withdrawn", label: "宸叉挙鍥�", tag: "warning" },
+ { value: "expired", label: "宸茶繃鏈�", tag: "" },
+];
+
+/** 闃呰鑼冨洿 */
+export const READ_SCOPE_OPTIONS = [
+ { value: "all", label: "鍏ㄥ憳鍙" },
+ { value: "department", label: "鎸囧畾閮ㄩ棬" },
+ { value: "management", label: "绠$悊灞�" },
+];
+
+export const DEPT_OPTIONS = [
+ { value: "101", label: "鐮斿彂閮�" },
+ { value: "102", label: "閿�鍞儴" },
+ { value: "103", label: "琛屾斂閮�" },
+ { value: "104", label: "璐㈠姟閮�" },
+ { value: "105", label: "鎬荤粡鍔�" },
+ { value: "106", label: "浜哄姏璧勬簮閮�" },
+];
+
+export const STORAGE_KEY = "oa_notice_announcement_v1";
+
+export function noticeTypeLabel(v) {
+ return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function noticeTypeColor(v) {
+ return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399";
+}
+
+export function priorityLabel(v) {
+ return PRIORITY_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function priorityTag(v) {
+ return PRIORITY_OPTIONS.find((x) => x.value === v)?.tag || "info";
+}
+
+export function publishStatusLabel(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function publishStatusTag(v) {
+ return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info";
+}
+
+export function readScopeLabel(v) {
+ return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "鈥�";
+}
+
+export function createEmptyForm() {
+ return {
+ id: "",
+ noticeNo: "",
+ title: "",
+ noticeType: "employee",
+ priority: "normal",
+ contentHtml: "",
+ publishDate: dayjs().format("YYYY-MM-DD"),
+ expireDate: "",
+ readScope: "all",
+ targetDeptIds: [],
+ requireReadConfirm: false,
+ publishStatus: "draft",
+ publisherName: "",
+ publishTime: "",
+ readCount: 0,
+ createTime: "",
+ updateTime: "",
+ };
+}
+
+export function createInitialMockNotices() {
+ return [
+ {
+ id: "notice_1",
+ noticeNo: "NA202605100001",
+ title: "鍏充簬鍙伴澶╂皵灞呭鍔炲叕鐨勭揣鎬ラ�氱煡",
+ noticeType: "emergency",
+ priority: "urgent",
+ contentHtml:
+ "<p><strong>绱ф�ラ�氱煡</strong></p><p>鍙楀彴椋庡奖鍝嶏紝鏄庢棩锛�5鏈�17鏃ワ級鍏ㄤ綋鍛樺伐灞呭鍔炲叕锛岃鍚勯儴闂ㄨ礋璐d汉鍋氬ソ宸ヤ綔瀹夋帓涓庡憳宸ヨ仈缁溿��</p>",
+ publishDate: "2026-05-16",
+ expireDate: "2026-05-20",
+ readScope: "all",
+ targetDeptIds: [],
+ requireReadConfirm: true,
+ publishStatus: "published",
+ publisherName: "琛屾斂閮�",
+ publishTime: "2026-05-16 08:30:00",
+ readCount: 128,
+ createTime: "2026-05-16 08:00:00",
+ updateTime: "2026-05-16 08:30:00",
+ },
+ {
+ id: "notice_2",
+ noticeNo: "NA202605120002",
+ title: "2026骞寸鍗堣妭鏀惧亣瀹夋帓鍏憡",
+ noticeType: "employee",
+ priority: "high",
+ contentHtml:
+ "<p>鏍规嵁鍥藉娉曞畾鑺傚亣鏃ュ畨鎺掞紝绔崍鑺傛斁鍋囨椂闂翠负 6鏈�8鏃ヨ嚦6鏈�10鏃ワ紝鍏�3澶┿��6鏈�7鏃ワ紙鍛ㄥ叚锛夋甯镐笂鐝��</p>",
+ publishDate: "2026-05-12",
+ expireDate: "2026-06-15",
+ readScope: "all",
+ targetDeptIds: [],
+ requireReadConfirm: false,
+ publishStatus: "published",
+ publisherName: "浜哄姏璧勬簮閮�",
+ publishTime: "2026-05-12 10:00:00",
+ readCount: 256,
+ createTime: "2026-05-12 09:30:00",
+ updateTime: "2026-05-12 10:00:00",
+ },
+ {
+ id: "notice_3",
+ noticeNo: "NA202605140003",
+ title: "鍔炲叕鍖哄煙娑堥槻婕旂粌閫氱煡",
+ noticeType: "company",
+ priority: "normal",
+ contentHtml: "<p>瀹氫簬 5鏈�25鏃� 14:00 鍦ㄦ�婚儴澶фゼ杩涜娑堥槻婕旂粌锛岃鍚勯儴闂ㄦ彁鍓嶅畨鎺掍汉鍛樺弬鍔犮��</p>",
+ publishDate: "2026-05-14",
+ expireDate: "2026-05-26",
+ readScope: "department",
+ targetDeptIds: ["101", "102", "103"],
+ requireReadConfirm: false,
+ publishStatus: "draft",
+ publisherName: "琛屾斂閮�",
+ publishTime: "",
+ readCount: 0,
+ createTime: "2026-05-14 15:00:00",
+ updateTime: "2026-05-14 15:00:00",
+ },
+ ];
+}
+
+export function loadStoredNotices() {
+ try {
+ const raw = localStorage.getItem(STORAGE_KEY);
+ if (!raw) return null;
+ const data = JSON.parse(raw);
+ return Array.isArray(data) ? data : null;
+ } catch {
+ return null;
+ }
+}
+
+export function saveStoredNotices(rows) {
+ try {
+ localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
+ } catch {
+ /* ignore */
+ }
+}
+
+export function nextNoticeNo() {
+ return `NA${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
+}
+
+export function validateNoticeForm(form) {
+ const title = (form.title || "").trim();
+ if (!title) return { ok: false, message: "璇疯緭鍏ュ叕鍛婃爣棰�" };
+ if (!form.publishDate) return { ok: false, message: "璇烽�夋嫨鍙戝竷鏃ユ湡" };
+ if (!form.noticeType) return { ok: false, message: "璇烽�夋嫨鍏憡绫诲瀷" };
+ if (form.readScope === "department" && !(form.targetDeptIds || []).length) {
+ return { ok: false, message: "璇烽�夋嫨鍙閮ㄩ棬" };
+ }
+ return { ok: true, title };
+}
+
+export function isExpired(row) {
+ if (!row.expireDate) return false;
+ return dayjs(row.expireDate).endOf("day").isBefore(dayjs());
+}
diff --git a/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
new file mode 100644
index 0000000..c9a9d9f
--- /dev/null
+++ b/src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
@@ -0,0 +1,332 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { ElMessageBox } from "element-plus";
+import { computed, reactive, ref, watch } from "vue";
+import {
+ NOTICE_TYPE_OPTIONS,
+ PRIORITY_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ DEPT_OPTIONS,
+ createEmptyForm,
+ createInitialMockNotices,
+ loadStoredNotices,
+ saveStoredNotices,
+ nextNoticeNo,
+ validateNoticeForm,
+ noticeTypeLabel,
+ priorityLabel,
+ publishStatusLabel,
+ isExpired,
+} from "./noticeAnnouncementUtils.js";
+
+export function useNoticeAnnouncement() {
+ const stored = loadStoredNotices();
+ const allRows = ref(stored?.length ? stored : createInitialMockNotices());
+
+ const searchForm = reactive({
+ keyword: "",
+ noticeType: "",
+ priority: "",
+ publishStatus: "",
+ publishDateRange: [],
+ });
+
+ const tableLoading = ref(false);
+ const page = reactive({ current: 1, size: 10, total: 0 });
+
+ const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
+ const form = reactive(createEmptyForm());
+ const formRef = ref();
+
+ const detailDialog = reactive({ visible: false });
+ const detailRow = ref({});
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.keyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => (r.title || "").toLowerCase().includes(kw) || (r.noticeNo || "").toLowerCase().includes(kw));
+ }
+ if (searchForm.noticeType) list = list.filter((r) => r.noticeType === searchForm.noticeType);
+ if (searchForm.priority) list = list.filter((r) => r.priority === searchForm.priority);
+ if (searchForm.publishStatus) list = list.filter((r) => r.publishStatus === searchForm.publishStatus);
+ const range = searchForm.publishDateRange;
+ if (range?.length === 2 && range[0] && range[1]) {
+ const start = dayjs(range[0]).startOf("day");
+ const end = dayjs(range[1]).endOf("day");
+ list = list.filter((r) => {
+ if (!r.publishDate) return false;
+ const t = dayjs(r.publishDate);
+ return !t.isBefore(start) && !t.isAfter(end);
+ });
+ }
+ return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ const formRules = {
+ title: [{ required: true, message: "璇疯緭鍏ュ叕鍛婃爣棰�", trigger: "blur" }],
+ publishDate: [{ required: true, message: "璇烽�夋嫨鍙戝竷鏃ユ湡", trigger: "change" }],
+ noticeType: [{ required: true, message: "璇烽�夋嫨鍏憡绫诲瀷", trigger: "change" }],
+ };
+
+ const tableColumn = ref([
+ { label: "缂栧彿", prop: "noticeNo", width: 150 },
+ { label: "鏍囬", prop: "title", minWidth: 200, showOverflowTooltip: true },
+ {
+ label: "绫诲瀷",
+ prop: "noticeType",
+ width: 100,
+ dataType: "slot",
+ slot: "noticeType",
+ },
+ {
+ label: "浼樺厛绾�",
+ prop: "priority",
+ width: 90,
+ dataType: "tag",
+ formatData: (v) => priorityLabel(v),
+ formatType: (v) => {
+ const hit = PRIORITY_OPTIONS.find((x) => x.value === v);
+ return hit?.tag || "info";
+ },
+ },
+ {
+ label: "鐘舵��",
+ prop: "publishStatus",
+ width: 90,
+ dataType: "tag",
+ formatData: (v, row) => (isExpired(row) && v === "published" ? "宸茶繃鏈�" : publishStatusLabel(v)),
+ formatType: (v, row) => {
+ if (isExpired(row) && v === "published") return "";
+ const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
+ return hit?.tag || "info";
+ },
+ },
+ { label: "鍙戝竷鏃ユ湡", prop: "publishDate", width: 120 },
+ { label: "鍙戝竷浜�", prop: "publisherName", width: 110 },
+ { label: "闃呰閲�", prop: "readCount", width: 80, align: "center" },
+ {
+ dataType: "action",
+ label: "鎿嶄綔",
+ align: "center",
+ fixed: "right",
+ width: 220,
+ operation: [
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "淇敼",
+ type: "text",
+ disabled: (row) => row.publishStatus === "withdrawn",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ {
+ name: "鍙戝竷",
+ type: "text",
+ disabled: (row) => row.publishStatus === "published",
+ clickFun: (row) => publishNotice(row),
+ },
+ {
+ name: "鎾ゅ洖",
+ type: "text",
+ disabled: (row) => row.publishStatus !== "published",
+ clickFun: (row) => withdrawNotice(row),
+ },
+ { name: "鍒犻櫎", type: "text", clickFun: (row) => deleteNotice(row) },
+ ],
+ },
+ ]);
+
+ function persist() {
+ saveStoredNotices(allRows.value);
+ }
+
+ function handleQuery() {
+ tableLoading.value = true;
+ page.current = 1;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 200);
+ }
+
+ function resetSearch() {
+ searchForm.keyword = "";
+ searchForm.noticeType = "";
+ searchForm.priority = "";
+ searchForm.publishStatus = "";
+ searchForm.publishDateRange = [];
+ handleQuery();
+ }
+
+ function pagination({ page: p, limit }) {
+ page.current = p;
+ page.size = limit;
+ }
+
+ function resetForm(target = createEmptyForm()) {
+ Object.assign(form, createEmptyForm(), target);
+ }
+
+ function openFormDialog(mode, row) {
+ formDialog.mode = mode;
+ formDialog.readonly = mode === "view";
+ formDialog.title =
+ mode === "add" ? "娣诲姞鍏憡" : mode === "edit" ? "淇敼鍏憡" : "鏌ョ湅鍏憡";
+ if (mode === "add") {
+ resetForm({ publisherName: "褰撳墠鐢ㄦ埛", priority: "normal" });
+ } else {
+ resetForm({
+ ...JSON.parse(JSON.stringify(row)),
+ targetDeptIds: [...(row.targetDeptIds || [])],
+ });
+ }
+ formDialog.visible = true;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ function saveForm(publish = false) {
+ const v = validateNoticeForm(form);
+ if (!v.ok) return { ok: false, message: v.message };
+
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ const payload = {
+ ...JSON.parse(JSON.stringify(form)),
+ title: v.title,
+ updateTime: now,
+ };
+
+ if (form.noticeType === "emergency" && payload.priority === "normal") {
+ payload.priority = "urgent";
+ }
+
+ if (formDialog.mode === "add") {
+ payload.id = `notice_${Date.now()}`;
+ payload.noticeNo = nextNoticeNo();
+ payload.createTime = now;
+ payload.readCount = 0;
+ if (publish) {
+ payload.publishStatus = "published";
+ payload.publishTime = now;
+ } else {
+ payload.publishStatus = "draft";
+ }
+ allRows.value.unshift(payload);
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx < 0) return { ok: false, message: "璁板綍涓嶅瓨鍦�" };
+ const prev = allRows.value[idx];
+ if (publish) {
+ payload.publishStatus = "published";
+ payload.publishTime = payload.publishTime || now;
+ }
+ allRows.value[idx] = { ...prev, ...payload };
+ }
+ persist();
+ formDialog.visible = false;
+ return { ok: true };
+ }
+
+ async function publishNotice(row) {
+ try {
+ await ElMessageBox.confirm(`纭鍙戝竷銆�${row.title}銆嶏紵`, "鍙戝竷鍏憡", {
+ type: "warning",
+ confirmButtonText: "鍙戝竷",
+ cancelButtonText: "鍙栨秷",
+ });
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ hit.publishStatus = "published";
+ hit.publishTime = now;
+ hit.updateTime = now;
+ if (hit.noticeType === "emergency") hit.priority = "urgent";
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async function withdrawNotice(row) {
+ try {
+ await ElMessageBox.confirm(`纭鎾ゅ洖銆�${row.title}銆嶏紵鎾ゅ洖鍚庡憳宸ョ灏嗕笉鍐嶅睍绀恒�俙, "鎾ゅ洖鍏憡", {
+ type: "warning",
+ confirmButtonText: "鎾ゅ洖",
+ cancelButtonText: "鍙栨秷",
+ });
+ const hit = allRows.value.find((r) => r.id === row.id);
+ if (!hit) return;
+ hit.publishStatus = "withdrawn";
+ hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ async function deleteNotice(row) {
+ try {
+ await ElMessageBox.confirm(`纭鍒犻櫎銆�${row.title}銆嶏紵姝ゆ搷浣滀笉鍙仮澶嶃�俙, "鍒犻櫎鍏憡", {
+ type: "warning",
+ confirmButtonText: "鍒犻櫎",
+ cancelButtonText: "鍙栨秷",
+ });
+ allRows.value = allRows.value.filter((r) => r.id !== row.id);
+ persist();
+ return true;
+ } catch {
+ return false;
+ }
+ }
+
+ return {
+ Search,
+ NOTICE_TYPE_OPTIONS,
+ PRIORITY_OPTIONS,
+ PUBLISH_STATUS_OPTIONS,
+ READ_SCOPE_OPTIONS,
+ DEPT_OPTIONS,
+ noticeTypeLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ formDialog,
+ form,
+ formRef,
+ formRules,
+ detailDialog,
+ detailRow,
+ isExpired,
+ handleQuery,
+ resetSearch,
+ pagination,
+ openFormDialog,
+ openDetail,
+ saveForm,
+ publishNotice,
+ withdrawNotice,
+ deleteNotice,
+ };
+}
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..bfe1b68
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
@@ -0,0 +1,71 @@
+<!-- 璐圭敤鎶ラ攢锛氳鎯呭彧璇婚潰鏉� -->
+<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?.length ? props.row.attachmentList : 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..8cb8fa0
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
@@ -0,0 +1,309 @@
+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 MOCK_APPROVERS_BY_ROLE = {
+ direct_supervisor: { approverId: "mock_supervisor", approverName: "鐩村睘涓婄骇" },
+ dept_manager: { approverId: "mock_manager", approverName: "閮ㄩ棬缁忕悊" },
+ cfo: { approverId: "mock_cfo", approverName: "璐㈠姟鎬荤洃" },
+ compliance: { approverId: "mock_compliance", approverName: "鍚堣瀹℃牳" },
+};
+
+/** 鎸夐噾棰濋璁惧鎵归摼 */
+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 === "approved") return "宸查�氳繃";
+ if (v === "rejected") return "宸查┏鍥�";
+ return "瀹℃牳涓�";
+}
+
+export function statusTagType(v) {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ return "warning";
+}
+
+export function formatApprovalFlowSummary(row) {
+ const nodes = row?.approvalFlowNodes || [];
+ if (!nodes.length) return "鈥�";
+ return nodes
+ .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 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) {
+ const roles = resolveApprovalRoles(amount, expenseCategory);
+ return roles.map((role, i) => {
+ const mock = MOCK_APPROVERS_BY_ROLE[role] || { approverId: `mock_${role}`, approverName: role };
+ return {
+ approverId: mock.approverId,
+ approverName: mock.approverName,
+ roleKey: role,
+ 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) => MOCK_APPROVERS_BY_ROLE[r]?.approverName || 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..b384569
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -0,0 +1,556 @@
+<!--OA妯″潡锛氳垂鐢ㄦ姤閿�-->
+<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"
+ />
+ <span class="search_title" style="margin-left: 12px">鐢宠鏃堕棿锛�</span>
+ <el-date-picker
+ v-model="searchForm.applyTimeFrom"
+ type="date"
+ placeholder="寮�濮嬫棩鏈�"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 150px"
+ clearable
+ />
+ <span class="search_title" style="margin-left: 8px">鑷�</span>
+ <el-date-picker
+ v-model="searchForm.applyTimeTo"
+ type="date"
+ placeholder="缁撴潫鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 150px; margin-left: 8px"
+ clearable
+ />
+ <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" @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>
+ <DetailPanel :row="detailRow" />
+ <el-divider content-position="left">瀹℃壒娴佺▼</el-divider>
+ <ApprovalFlowProgress :nodes="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" />
+ <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?.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,
+ 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,
+ 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..79ffe6b
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
@@ -0,0 +1,662 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+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([
+ {
+ id: "1",
+ reimburseNo: "CR202605100001",
+ applicantId: "mock_1",
+ employeeNo: "zhangsan",
+ employeeName: "寮犱笁",
+ applicantNo: "zhangsan",
+ applicantName: "寮犱笁",
+ expenseCategory: "office_procurement",
+ reimburseReason: "閲囪喘鎵撳嵃鏈虹榧撱�丄4绾哥瓑鍔炲叕鑰楁潗銆�",
+ applyAmount: 680,
+ payee: "寮犱笁",
+ payeeAccount: "6222 **** **** 1234",
+ bankBranch: "涓浗宸ュ晢閾惰鏉窞瑗挎箹鏀",
+ expenseDetails: [
+ { id: "d1", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 380, description: "A4澶嶅嵃绾�" },
+ { id: "d2", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 300, description: "纭掗紦" },
+ ],
+ attachmentList: [{ name: "閲囪喘鍙戠エ.pdf", url: "/mock/invoice1.pdf" }],
+ approvalFlowNodes: demoFlowNodes(680, "office_procurement"),
+ currentNodeIndex: 0,
+ approvalResult: "pending",
+ rejectReason: "",
+ approvalRecords: [],
+ applyTime: "2026-05-10 09:15:00",
+ createTime: "2026-05-10 09:15:00",
+ deptId: "101",
+ deptName: "琛屾斂閮�",
+ },
+ {
+ id: "2",
+ reimburseNo: "CR202605080002",
+ applicantId: "mock_2",
+ employeeNo: "lisi",
+ employeeName: "鏉庡洓",
+ applicantNo: "lisi",
+ applicantName: "鏉庡洓",
+ expenseCategory: "business_entertainment",
+ reimburseReason: "鎺ュ緟閲嶇偣瀹㈡埛鍟嗗姟瀹磋銆�",
+ applyAmount: 3200,
+ payee: "鏉庡洓",
+ payeeAccount: "6217 **** **** 5678",
+ bankBranch: "鎷涘晢閾惰姝︽眽鍏夎胺鏀",
+ expenseDetails: [
+ { id: "d3", invoiceDate: "2026-05-06", expenseSubject: "entertainment", amount: 3200, description: "瀹㈡埛瀹磋" },
+ ],
+ attachmentList: [],
+ approvalFlowNodes: demoFlowNodes(3200, "business_entertainment").map((n, i) => ({
+ ...n,
+ nodeStatus: i === 0 ? "error" : "wait",
+ approveOpinion: i === 0 ? "鍙戠エ妯$硦闇�閲嶄紶" : "",
+ approveTime: i === 0 ? "2026-05-09 14:20:00" : "",
+ })),
+ currentNodeIndex: 0,
+ approvalResult: "rejected",
+ rejectReason: "鍙戠エ妯$硦闇�閲嶄紶",
+ approvalRecords: [
+ { operatorName: "鐩村睘涓婄骇", result: "rejected", opinion: "鍙戠エ妯$硦闇�閲嶄紶", time: "2026-05-09 14:20:00" },
+ ],
+ applyTime: "2026-05-07 16:30:00",
+ createTime: "2026-05-07 16:30:00",
+ deptId: "102",
+ deptName: "閿�鍞儴",
+ },
+ {
+ id: "3",
+ reimburseNo: "CR202605050003",
+ applicantId: "mock_3",
+ employeeNo: "wangwu",
+ employeeName: "鐜嬩簲",
+ applicantNo: "wangwu",
+ applicantName: "鐜嬩簲",
+ expenseCategory: "communication",
+ reimburseReason: "5鏈堝洜鍏瘽璐规姤閿�銆�",
+ applyAmount: 198,
+ payee: "鐜嬩簲",
+ payeeAccount: "6228 **** **** 9012",
+ bankBranch: "涓浗寤鸿閾惰鎴愰兘楂樻柊鏀",
+ expenseDetails: [
+ { id: "d4", invoiceDate: "2026-05-05", expenseSubject: "phone", amount: 198, description: "璇濊垂璐﹀崟" },
+ ],
+ attachmentList: [{ name: "璇濊垂璐﹀崟.jpg", url: "/mock/phone.jpg" }],
+ approvalFlowNodes: demoFlowNodes(198, "communication").map((n) => ({
+ ...n,
+ nodeStatus: "finish",
+ approveOpinion: "鍚屾剰",
+ approveTime: "2026-05-06 10:00:00",
+ })),
+ currentNodeIndex: 0,
+ approvalResult: "approved",
+ rejectReason: "",
+ approvalRecords: [{ operatorName: "鐩村睘涓婄骇", result: "approved", opinion: "鍚屾剰", time: "2026-05-06 10:00:00" }],
+ applyTime: "2026-05-05 11:00:00",
+ createTime: "2026-05-05 11:00:00",
+ deptId: "103",
+ deptName: "鎶�鏈儴",
+ },
+ ]);
+
+ const searchForm = reactive({
+ applicantKeyword: "",
+ applyTimeFrom: "",
+ applyTimeTo: "",
+ });
+ 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 detailRow = ref({});
+ const approveDialog = reactive({ visible: false, row: null });
+ const approveOpinion = ref("");
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.applicantName || r.employeeName || "").toLowerCase();
+ const no = (r.applicantNo || r.employeeNo || "").toLowerCase();
+ return name.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.applyTimeFrom) {
+ list = list.filter((r) => {
+ const t = (r.applyTime || r.createTime || "").slice(0, 10);
+ return !t || t >= searchForm.applyTimeFrom;
+ });
+ }
+ if (searchForm.applyTimeTo) {
+ list = list.filter((r) => {
+ const t = (r.applyTime || r.createTime || "").slice(0, 10);
+ return !t || t <= searchForm.applyTimeTo;
+ });
+ }
+ return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size).map((r) => ({
+ ...r,
+ approvalFlowSummary: formatApprovalFlowSummary(r),
+ }));
+ });
+
+ 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) => row.approvalResult === "pending" || row.approvalResult === "approved",
+ clickFun: (row) => openFormDialog("edit", row),
+ },
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ {
+ name: "瀹℃壒",
+ type: "text",
+ disabled: (row) => row.approvalResult !== "pending",
+ clickFun: (row) => openApprove(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");
+ 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;
+ tableLoading.value = true;
+ setTimeout(() => {
+ tableLoading.value = false;
+ }, 150);
+ }
+
+ function resetSearch() {
+ searchForm.applicantKeyword = "";
+ searchForm.applyTimeFrom = "";
+ searchForm.applyTimeTo = "";
+ handleQuery();
+ }
+
+ function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ 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) {
+ Object.assign(form, {
+ ...JSON.parse(JSON.stringify(row)),
+ attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])),
+ approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])),
+ expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
+ });
+ const u = userById(row.applicantId);
+ applicantFormOptions.value = u
+ ? [u]
+ : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.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();
+ autoAssignApprovalFlow();
+
+ const payload = {
+ reimburseNo: form.reimburseNo || `CR${dayjs().format("YYYYMMDDHHmmss")}`,
+ applicantId: form.applicantId,
+ employeeNo: form.employeeNo,
+ employeeName: form.employeeName,
+ applicantNo: form.employeeNo,
+ applicantName: form.employeeName,
+ expenseCategory: form.expenseCategory,
+ reimburseReason: form.reimburseReason,
+ applyAmount: form.applyAmount,
+ payee: form.payee,
+ payeeAccount: form.payeeAccount,
+ bankBranch: form.bankBranch,
+ expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
+ attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+ invoiceAttachments: (form.attachmentList || []).map((f, i) => ({
+ id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
+ name: f.name || f.fileName || "鏈懡鍚�",
+ url: f.url || f.downloadURL || "",
+ })),
+ approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+ currentNodeIndex: 0,
+ deptId: form.deptId,
+ deptName: form.deptName,
+ };
+
+ if (formDialog.mode === "add") {
+ const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
+ allRows.value.unshift({
+ id: `local_${Date.now()}`,
+ ...payload,
+ approvalResult: "pending",
+ rejectReason: "",
+ approvalRecords: [],
+ applyTime: now,
+ createTime: now,
+ });
+ proxy?.$modal?.msgSuccess?.("鎻愪氦鎴愬姛锛屽凡杩涘叆瀹℃壒锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx !== -1) {
+ const prev = allRows.value[idx];
+ allRows.value[idx] = {
+ ...prev,
+ ...payload,
+ id: form.id,
+ approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult,
+ approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+ currentNodeIndex: 0,
+ rejectReason: prev.approvalResult === "rejected" ? "" : prev.rejectReason,
+ applyTime: prev.applyTime,
+ createTime: prev.createTime,
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+ }
+
+ 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 = filteredList.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(() => loadUserPool());
+
+ return {
+ Search,
+ EXPENSE_CATEGORY_OPTIONS,
+ CATEGORY_TEMPLATES,
+ EXPENSE_SUBJECT_OPTIONS,
+ expenseCategoryLabel,
+ expenseSubjectLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ importInputRef,
+ formRef,
+ form,
+ formDialog,
+ formRules,
+ detailDialog,
+ 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,
+ openDetail,
+ approvalActionLabel,
+ submitApprove,
+ handleExport,
+ handleImportClick,
+ onImportFile,
+ };
+}
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..d09e580
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
@@ -0,0 +1,81 @@
+<!-- 宸梾鎶ラ攢锛氳鎯呭彧璇婚潰鏉� -->
+<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?.length
+ ? props.row.attachmentList
+ : 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..2e81e18
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -0,0 +1,623 @@
+<!--OA妯″潡锛氬樊鏃呮姤閿�-->
+<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"
+ />
+ <span class="search_title" style="margin-left: 12px">鍑哄樊寮�濮嬶細</span>
+ <el-date-picker
+ v-model="searchForm.travelStartFrom"
+ type="date"
+ placeholder="寮�濮嬫棩鏈�"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 150px"
+ clearable
+ />
+ <span class="search_title" style="margin-left: 8px">缁撴潫锛�</span>
+ <el-date-picker
+ v-model="searchForm.travelEndTo"
+ type="date"
+ placeholder="缁撴潫鏃ユ湡"
+ format="YYYY-MM-DD"
+ value-format="YYYY-MM-DD"
+ style="width: 150px"
+ clearable
+ />
+ <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" @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>
+ <DetailPanel :row="detailRow" />
+ <ApprovalFlowProgress
+ class="mt16"
+ :nodes="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" />
+ <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?.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,
+ 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,
+ 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..d898614
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
@@ -0,0 +1,193 @@
+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 === "approved") return "閫氳繃";
+ if (v === "rejected") return "椹冲洖";
+ return "瀹℃牳涓�";
+}
+
+export function statusTagType(v) {
+ if (v === "approved") return "success";
+ if (v === "rejected") return "danger";
+ 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) {
+ const id = String(deptId || "default");
+ let s = 0;
+ for (let i = 0; i < id.length; i++) s += id.charCodeAt(i);
+ const total = 500000 + (s % 200) * 1000;
+ const used = (s % 80) * 3500;
+ return {
+ deptId: id,
+ totalBudget: total,
+ usedAmount: used,
+ remainingAmount: Math.max(0, total - used),
+ };
+}
+
+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..9125d64
--- /dev/null
+++ b/src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
@@ -0,0 +1,689 @@
+import { Search } from "@element-plus/icons-vue";
+import dayjs from "dayjs";
+import { userListNoPageByTenantId } from "@/api/system/user.js";
+import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
+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";
+}
+
+function demoFlowNodes(names = ["閮ㄩ棬涓荤", "璐㈠姟瀹℃牳"]) {
+ return names.map((name, i) => ({
+ approverId: `mock_${i + 1}`,
+ approverName: name,
+ sortOrder: i + 1,
+ nodeOrder: i + 1,
+ nodeStatus: i === 0 ? "process" : "wait",
+ approveOpinion: "",
+ approveTime: "",
+ }));
+}
+
+export function useTravelReimburse() {
+ const { proxy } = getCurrentInstance();
+
+ const allRows = ref([
+ {
+ id: "1",
+ reimburseNo: "TR202605090001",
+ applicantId: "mock_1",
+ employeeNo: "zhangsan",
+ employeeName: "寮犱笁",
+ applicantNo: "zhangsan",
+ applicantName: "寮犱笁",
+ reimburseReason: "璧翠笂娴峰弬鍔犺涓氬睍浼氬強瀹㈡埛鎷滆銆�",
+ travelStartTime: "2026-05-10 08:00:00",
+ travelEndTime: "2026-05-13 18:00:00",
+ travelDays: 4,
+ departurePlace: "鏉窞",
+ destination: "涓婃捣",
+ hotelStandard: 600,
+ hotelDays: 3,
+ livingSubsidy: 400,
+ applyAmount: 4580,
+ payee: "寮犱笁",
+ expenseDetails: [
+ { id: "d1", invoiceDate: "2026-05-10", expenseSubject: "transport", amount: 553, description: "楂橀搧寰�杩�" },
+ { id: "d2", invoiceDate: "2026-05-11", expenseSubject: "hotel", amount: 1680, description: "閰掑簵浣忓" },
+ ],
+ attachmentList: [{ name: "楂橀搧绁�.pdf", url: "/mock/invoice1.pdf" }],
+ invoiceAttachments: [{ name: "楂橀搧绁�.pdf", url: "/mock/invoice1.pdf" }],
+ approvalFlowNodes: demoFlowNodes(),
+ currentNodeIndex: 0,
+ approvalResult: "pending",
+ rejectReason: "",
+ approvalRecords: [],
+ needSpecialApproval: false,
+ deptId: "101",
+ deptName: "閿�鍞儴",
+ travelTier: "tier1",
+ createTime: "2026-05-09 10:20:00",
+ },
+ {
+ id: "2",
+ reimburseNo: "TR202605080002",
+ applicantId: "mock_2",
+ employeeNo: "lisi",
+ employeeName: "鏉庡洓",
+ applicantNo: "lisi",
+ applicantName: "鏉庡洓",
+ reimburseReason: "鎴愰兘鍒嗗叕鍙告妧鏈敮鎸併��",
+ travelStartTime: "2026-05-05 09:00:00",
+ travelEndTime: "2026-05-07 17:00:00",
+ travelDays: 3,
+ departurePlace: "姝︽眽",
+ destination: "鎴愰兘",
+ hotelStandard: 450,
+ hotelDays: 2,
+ livingSubsidy: 240,
+ applyAmount: 2100,
+ payee: "鏉庡洓",
+ expenseDetails: [{ id: "d3", invoiceDate: "2026-05-06", expenseSubject: "meal", amount: 180, description: "宸ヤ綔椁�" }],
+ attachmentList: [],
+ invoiceAttachments: [],
+ approvalFlowNodes: demoFlowNodes().map((n, i) => ({ ...n, nodeStatus: "finish", approveOpinion: "鍚屾剰", approveTime: "2026-05-08 11:00:00" })),
+ currentNodeIndex: 1,
+ approvalResult: "approved",
+ rejectReason: "",
+ approvalRecords: [{ operatorName: "閮ㄩ棬涓荤", result: "approved", opinion: "鍚屾剰", time: "2026-05-08 10:00:00" }],
+ needSpecialApproval: false,
+ deptId: "102",
+ deptName: "鎶�鏈儴",
+ travelTier: "tier2",
+ createTime: "2026-05-07 16:00:00",
+ },
+ ]);
+
+ const searchForm = reactive({ applicantKeyword: "", travelStartFrom: "", travelEndTo: "" });
+ 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 detailRow = ref({});
+ const approveDialog = reactive({ visible: false, row: null });
+ const approveOpinion = ref("");
+
+ const filteredList = computed(() => {
+ let list = [...allRows.value];
+ const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
+ if (kw) {
+ list = list.filter((r) => {
+ const name = (r.applicantName || r.employeeName || "").toLowerCase();
+ const no = (r.applicantNo || r.employeeNo || "").toLowerCase();
+ return name.includes(kw) || no.includes(kw);
+ });
+ }
+ if (searchForm.travelStartFrom) {
+ list = list.filter((r) => !r.travelStartTime || r.travelStartTime.slice(0, 10) >= searchForm.travelStartFrom);
+ }
+ if (searchForm.travelEndTo) {
+ list = list.filter((r) => !r.travelEndTime || r.travelEndTime.slice(0, 10) <= searchForm.travelEndTo);
+ }
+ return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
+ });
+
+ watch(
+ filteredList,
+ (list) => {
+ page.total = list.length;
+ const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
+ if (page.current > maxPage) page.current = maxPage;
+ },
+ { immediate: true }
+ );
+
+ const tableData = computed(() => {
+ const start = (page.current - 1) * page.size;
+ return filteredList.value.slice(start, start + page.size);
+ });
+
+ 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: 200,
+ operation: [
+ { name: "缂栬緫", type: "text", disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", clickFun: (row) => openFormDialog("edit", row) },
+ { name: "璇︽儏", type: "text", clickFun: (row) => openDetail(row) },
+ { name: "瀹℃壒", type: "text", disabled: (row) => row.approvalResult !== "pending", clickFun: (row) => openApprove(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;
+ tableLoading.value = true;
+ setTimeout(() => { tableLoading.value = false; }, 150);
+ }
+
+ function resetSearch() {
+ searchForm.applicantKeyword = "";
+ searchForm.travelStartFrom = "";
+ searchForm.travelEndTo = "";
+ handleQuery();
+ }
+
+ function pagination(obj) {
+ page.current = obj.page;
+ page.size = obj.limit;
+ }
+
+ function openDetail(row) {
+ detailRow.value = { ...row };
+ detailDialog.visible = true;
+ }
+
+ 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) {
+ Object.assign(form, {
+ ...JSON.parse(JSON.stringify(row)),
+ attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])),
+ approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])),
+ expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
+ });
+ const u = userById(row.applicantId);
+ applicantFormOptions.value = u ? [u] : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.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;
+ }
+ }
+ const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
+ const payload = {
+ reimburseNo: form.reimburseNo || `TR${dayjs().format("YYYYMMDDHHmmss")}`,
+ applicantId: form.applicantId,
+ employeeNo: form.employeeNo,
+ employeeName: form.employeeName,
+ applicantNo: form.employeeNo,
+ applicantName: form.employeeName,
+ reimburseReason: form.reimburseReason,
+ travelStartTime: form.travelStartTime,
+ travelEndTime: form.travelEndTime,
+ travelDays: days,
+ departurePlace: form.departurePlace,
+ destination: form.destination,
+ hotelStandard: form.hotelStandard,
+ hotelDays: form.hotelDays,
+ livingSubsidy: form.livingSubsidy,
+ applyAmount: form.applyAmount,
+ payee: form.payee,
+ expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
+ attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
+ invoiceAttachments: mapAttachmentList(form.attachmentList),
+ approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+ currentNodeIndex: 0,
+ needSpecialApproval: form.needSpecialApproval,
+ deptId: form.deptId,
+ deptName: form.deptName,
+ travelTier: form.travelTier,
+ };
+ if (formDialog.mode === "add") {
+ allRows.value.unshift({
+ id: `local_${Date.now()}`,
+ ...payload,
+ approvalResult: "pending",
+ rejectReason: "",
+ approvalRecords: [],
+ createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
+ });
+ proxy?.$modal?.msgSuccess?.("鎻愪氦鎴愬姛锛屽凡杩涘叆瀹℃壒锛堟湰鍦版ā鎷燂級");
+ } else {
+ const idx = allRows.value.findIndex((r) => r.id === form.id);
+ if (idx !== -1) {
+ const prev = allRows.value[idx];
+ allRows.value[idx] = {
+ ...prev,
+ ...payload,
+ id: form.id,
+ approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult,
+ approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
+ currentNodeIndex: 0,
+ createTime: prev.createTime,
+ };
+ }
+ proxy?.$modal?.msgSuccess?.("淇濆瓨鎴愬姛锛堟湰鍦版ā鎷燂級");
+ }
+ formDialog.visible = false;
+ handleQuery();
+ }
+
+ 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 = filteredList.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(() => loadUserPool());
+
+ return {
+ Search,
+ EXPENSE_SUBJECT_OPTIONS,
+ expenseSubjectLabel,
+ searchForm,
+ tableLoading,
+ page,
+ tableData,
+ tableColumn,
+ importInputRef,
+ formRef,
+ form,
+ formDialog,
+ formRules,
+ detailDialog,
+ 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,
+ openDetail,
+ 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>
--
Gitblit v1.9.3