| src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 |
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
@@ -1,4 +1,4 @@ <!--OA模åï¼è¯·åç³è¯·ï¼å段为å端å ä½ï¼åæä¸å端æ¥å£å¯¹é½ï¼--> <!--OA模åï¼è¯·åç³è¯·--> <template> <div class="app-container"> <div class="search_form mb20"> src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue
¶Ô±ÈÐÂÎļþ @@ -0,0 +1,360 @@ <!-- å çç³è¯·æ¨¡åå ï¼å¯å¢å 审æ¹èç¹ï¼æ¯èç¹å¿ é 1 人 --> <template> <div class="afe"> <div v-if="innerList.length" class="afe-flow"> <div v-for="(item, index) in innerList" :key="item._uid" class="afe-flow-item"> <div class="afe-card" :class="{ 'afe-card--empty': !item.approverId }"> <div class="afe-badge">{{ index + 1 }}</div> <div class="afe-avatar-wrap"> <div class="afe-avatar" :class="{ 'afe-avatar--on': item.approverId }" :style="item.approverId ? { backgroundColor: avatarColor(item.approverName) } : {}" > <span v-if="item.approverId">{{ (item.approverName || '?').charAt(0) }}</span> <el-icon v-else :size="22"><User /></el-icon> </div> <div class="afe-level">{{ levelText(index) }}</div> </div> <div class="afe-select"> <el-select v-model="item.approverId" placeholder="è¯·éæ©å®¡æ¹äºº" filterable clearable style="width: 100%" @change="(v) => onPick(v, item)" > <el-option v-for="u in userOptions" :key="String(u.userId ?? u.id)" :label="optionLabel(u)" :value="u.userId ?? u.id" /> </el-select> </div> <div class="afe-actions"> <el-button type="primary" circle size="small" :disabled="index === 0" title="åç§»" @click="moveLeft(index)"> <el-icon><ArrowLeft /></el-icon> </el-button> <el-button type="primary" circle size="small" :disabled="index === innerList.length - 1" title="åç§»" @click="moveRight(index)" > <el-icon><ArrowRight /></el-icon> </el-button> <el-button type="danger" circle size="small" title="å é¤èç¹" @click="remove(index)"> <el-icon><Delete /></el-icon> </el-button> </div> </div> <div v-if="index < innerList.length - 1" class="afe-conn"> <div class="afe-conn-line"></div> <el-icon class="afe-conn-icon"><ArrowRight /></el-icon> </div> </div> <div class="afe-add-wrap"> <div class="afe-conn" v-if="innerList.length"> <div class="afe-conn-line"></div> <el-icon class="afe-conn-icon"><ArrowRight /></el-icon> </div> <div class="afe-add-card" @click="addNode"> <div class="afe-add-icon"><el-icon :size="26"><Plus /></el-icon></div> <span>æ°å¢èç¹</span> </div> </div> </div> <div v-else class="afe-empty"> <el-icon :size="44" color="#c0c4cc"><User /></el-icon> <p>ææ å®¡æ¹èç¹</p> <el-button type="primary" @click="addNode">æ·»å 第ä¸ä¸ªèç¹</el-button> </div> </div> </template> <script setup> import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue"; import { ref, watch } from "vue"; const props = defineProps({ modelValue: { type: Array, default: () => [] }, /** ä¸ç¶é¡µ userList ç»æä¸è´ï¼userId / idãnickNameãuserName */ userOptions: { type: Array, default: () => [] }, }); const emit = defineEmits(["update:modelValue"]); const innerList = ref([]); const palette = ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#9B59B6", "#1ABC9C"]; function avatarColor(name) { if (!name) return "#c0c4cc"; let h = 0; for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h); return palette[Math.abs(h) % palette.length]; } function levelText(i) { const t = ["第ä¸çº§", "第äºçº§", "第ä¸çº§", "第å级", "第äºçº§", "第å 级", "第ä¸çº§", "ç¬¬å «çº§"]; return t[i] || `第${i + 1}级`; } function optionLabel(u) { const nick = u.nickName || ""; const un = u.userName || ""; if (nick && un && nick !== un) return `${nick}ï¼${un}ï¼`; return nick || un || `ç¨æ·${u.userId ?? u.id ?? ""}`; } function newUid() { return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`; } function mapIn(rows) { if (!Array.isArray(rows)) return []; return rows.map((r, i) => ({ _uid: newUid(), approverId: r.approverId ?? r.approver_id ?? null, approverName: r.approverName ?? r.approver_name ?? "", sortOrder: r.sortOrder ?? r.nodeOrder ?? i + 1, nodeOrder: r.nodeOrder ?? r.sortOrder ?? i + 1, roleName: r.roleName ?? "", roleCode: r.roleCode ?? "", })); } function publicShape(rows) { const arr = Array.isArray(rows) ? rows : []; return arr.map((r, i) => ({ approverId: r.approverId ?? null, approverName: r.approverName ?? "", roleName: r.roleName ?? "", roleCode: r.roleCode ?? "", sortOrder: i + 1, })); } function emitOut() { const out = innerList.value.map((r, i) => ({ approverId: r.approverId ?? null, approverName: r.approverName ?? "", sortOrder: i + 1, nodeOrder: i + 1, roleName: r.roleName ?? "", roleCode: r.roleCode ?? "", })); emit("update:modelValue", out); } watch( () => props.modelValue, (v) => { const next = publicShape(v || []); if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return; innerList.value = mapIn(v || []); }, { deep: true, immediate: true } ); function findUser(id) { if (id == null || id === "") return null; return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null; } function onPick(userId, row) { if (!userId) { row.approverName = ""; emitOut(); return; } const u = findUser(userId); row.approverName = u ? u.nickName || u.userName || "" : ""; emitOut(); } function addNode() { innerList.value.push({ _uid: newUid(), approverId: null, approverName: "", roleName: "", roleCode: "", }); emitOut(); } function remove(index) { innerList.value.splice(index, 1); emitOut(); } function moveLeft(index) { if (index < 1) return; const t = innerList.value[index]; innerList.value[index] = innerList.value[index - 1]; innerList.value[index - 1] = t; emitOut(); } function moveRight(index) { if (index >= innerList.value.length - 1) return; const t = innerList.value[index]; innerList.value[index] = innerList.value[index + 1]; innerList.value[index + 1] = t; emitOut(); } </script> <style scoped> .afe { width: 100%; } .afe-flow { display: flex; align-items: flex-start; flex-wrap: nowrap; overflow-x: auto; padding: 6px 0 10px; gap: 0; } .afe-flow-item { display: flex; align-items: center; } .afe-card { width: 200px; flex-shrink: 0; border: 2px solid var(--el-border-color); border-radius: 12px; padding: 14px 12px 12px; position: relative; background: var(--el-bg-color); } .afe-card--empty { border-style: dashed; background: var(--el-fill-color-lighter); } .afe-badge { position: absolute; top: -8px; left: 12px; width: 22px; height: 22px; border-radius: 50%; background: var(--el-color-primary); color: #fff; font-size: 12px; font-weight: 700; display: flex; align-items: center; justify-content: center; } .afe-avatar-wrap { display: flex; flex-direction: column; align-items: center; margin: 6px 0 10px; } .afe-avatar { width: 48px; height: 48px; border-radius: 50%; background: var(--el-fill-color); display: flex; align-items: center; justify-content: center; color: var(--el-text-color-placeholder); margin-bottom: 6px; font-size: 18px; font-weight: 600; } .afe-avatar--on { color: #fff; } .afe-level { font-size: 12px; color: var(--el-text-color-secondary); } .afe-select { margin-bottom: 10px; } .afe-actions { display: flex; justify-content: center; gap: 8px; padding-top: 10px; border-top: 1px solid var(--el-border-color-lighter); } .afe-conn { display: flex; align-items: center; width: 40px; flex-shrink: 0; align-self: center; } .afe-conn-line { flex: 1; height: 2px; background: var(--el-border-color); } .afe-conn-icon { font-size: 14px; color: var(--el-text-color-placeholder); margin-left: -2px; } .afe-add-wrap { display: flex; align-items: center; } .afe-add-card { width: 120px; min-height: 168px; flex-shrink: 0; border: 2px dashed var(--el-border-color); border-radius: 12px; display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px; cursor: pointer; color: var(--el-text-color-regular); font-size: 13px; background: var(--el-fill-color-lighter); transition: border-color 0.2s, background 0.2s; } .afe-add-card:hover { border-color: var(--el-color-primary); background: var(--el-color-primary-light-9); color: var(--el-color-primary); } .afe-add-icon { width: 44px; height: 44px; border-radius: 50%; background: var(--el-color-primary); color: #fff; display: flex; align-items: center; justify-content: center; } .afe-empty { text-align: center; padding: 28px 16px; border: 1px dashed var(--el-border-color); border-radius: 12px; background: var(--el-fill-color-lighter); } .afe-empty p { margin: 10px 0 14px; color: var(--el-text-color-secondary); font-size: 14px; } </style> src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
@@ -1,4 +1,4 @@ <!--OA模åï¼å çç³è¯·ï¼å段为å端å ä½ï¼åæä¸å端æ¥å£å¯¹é½ï¼--> <!--OA模åï¼å çç³è¯·--> <template> <div class="app-container"> <div class="search_form mb20"> @@ -44,7 +44,7 @@ <el-dialog v-model="formDialog.visible" :title="formDialog.title" width="960px" width="1040px" append-to-body destroy-on-close class="overtime-apply-form-dialog" @@ -136,23 +136,13 @@ </el-row> <el-row :gutter="24"> <el-col :span="24"> <el-form-item label="é¢è®¾å®¡æ¹æµ"> <div class="approval-flow-preview"> <div v-for="(node, index) in PRESET_APPROVAL_FLOW_NODES" :key="node.roleCode" class="flow-node-wrap" > <div class="flow-node"> <span class="flow-node-order">{{ index + 1 }}</span> <span class="flow-node-name">{{ node.roleName }}</span> </div> <el-icon v-if="index < PRESET_APPROVAL_FLOW_NODES.length - 1" class="flow-arrow"> <ArrowRight /> </el-icon> </div> </div> <p class="flow-tip">æé¡ºåºé级审æ¹ï¼åèç¹å®¡æ¹äººç±ç³»ç»æ ¹æ®ç»ç»æ¶æèªå¨å¹é </p> <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> @@ -199,22 +189,16 @@ <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="é¢è®¾å®¡æ¹æµ"> <div class="approval-flow-preview approval-flow-detail"> <div v-for="(node, index) in detailApprovalFlowNodes" :key="node.roleCode" class="flow-node-wrap" > <div class="flow-node flow-node--compact"> <span class="flow-node-order">{{ index + 1 }}</span> <span class="flow-node-name">{{ node.roleName }}</span> <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> <el-icon v-if="index < detailApprovalFlowNodes.length - 1" class="flow-arrow"> <ArrowRight /> </el-icon> </div> </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> @@ -256,9 +240,10 @@ </template> <script setup> import { ArrowRight, Search } from "@element-plus/icons-vue"; 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"; @@ -269,22 +254,24 @@ { label: "æ³å®è忥å ç", value: "holiday" }, ]; /** é¢è®¾å®¡æ¹æµèç¹ï¼ä¸æµç¨å¼æé 置对é½å ä½ï¼ */ const PRESET_APPROVAL_FLOW_NODES = [ { roleCode: "direct_leader", roleName: "ç´å±ä¸çº§", sortOrder: 1 }, { roleCode: "dept_leader", roleName: "é¨é¨è´è´£äºº", sortOrder: 2 }, /** æ¬å°æ¼ç¤ºï¼ä¸¤æ¡ç©ºèç¹ï¼æäº¤å须为æ¯èç¹éæ©å®¡æ¹äºº */ function demoApprovalFlowNodes() { return [ { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" }, { approverId: null, approverName: "", sortOrder: 2, nodeOrder: 2, roleName: "", roleCode: "" }, ]; function resolveApprovalFlowNodes(row) { const nodes = row?.approvalFlowNodes; if (Array.isArray(nodes) && nodes.length) { return [...nodes].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0)); } return PRESET_APPROVAL_FLOW_NODES; } function cloneApprovalFlowNodes() { return PRESET_APPROVAL_FLOW_NODES.map((n) => ({ ...n })); 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) { @@ -303,6 +290,9 @@ overtimeEndTime: "", overtimeReason: "", attachmentList: [], approvalFlowNodes: [ { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" }, ], }); const { proxy } = getCurrentInstance(); @@ -426,7 +416,7 @@ overtimeEndTime: "2026-05-10 21:30:00", overtimeHours: 3.5, overtimeReason: "项ç®ä¸çº¿ä¿éã", approvalFlowNodes: cloneApprovalFlowNodes(), approvalFlowNodes: demoApprovalFlowNodes(), approvalResult: "pending", attachmentList: [{ name: "ä»»å¡å.pdf" }], createTime: "2026-05-09 10:20:00", @@ -442,7 +432,7 @@ overtimeEndTime: "2026-05-11 12:15:00", overtimeHours: 3.25, overtimeReason: "客æ·ç°åºæ¯æã", approvalFlowNodes: cloneApprovalFlowNodes(), approvalFlowNodes: demoApprovalFlowNodes(), approvalResult: "approved", attachmentList: [], createTime: "2026-05-10 16:00:00", @@ -555,6 +545,8 @@ 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); @@ -564,6 +556,10 @@ nextTick(() => { formRef.value?.validateField?.("overtimeEndTime"); }); } function onApprovalFlowChange() { nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); } const formRules = { @@ -590,11 +586,32 @@ }, ], 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 detailApprovalFlowNodes = computed(() => resolveApprovalFlowNodes(detailRow.value)); const filesDialog = reactive({ visible: false, row: null }); @@ -673,7 +690,7 @@ overtimeReason: raw.overtimeReason ?? "", approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length ? raw.approvalFlowNodes.map((n) => ({ ...n })) : cloneApprovalFlowNodes(), : [], approvalResult: raw.approvalResult && ["pending", "approved", "rejected", "cancelled"].includes(raw.approvalResult) ? raw.approvalResult : "pending", @@ -730,6 +747,9 @@ 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) { @@ -775,7 +795,15 @@ overtimeEndTime: form.overtimeEndTime, overtimeHours: hours, overtimeReason: form.overtimeReason, approvalFlowNodes: cloneApprovalFlowNodes(), 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") { @@ -857,60 +885,25 @@ .overtime-apply-form-dialog :deep(.el-dialog__body) { padding-top: 12px; } .approval-flow-preview { display: flex; flex-wrap: wrap; align-items: center; gap: 8px; width: 100%; } .approval-flow-detail { padding: 4px 0; } .flow-node-wrap { display: flex; align-items: center; gap: 8px; } .flow-node { display: flex; align-items: center; gap: 10px; min-width: 140px; padding: 10px 16px; background: var(--el-fill-color-light); border: 1px solid var(--el-border-color-lighter); border-radius: 8px; } .flow-node--compact { min-width: 120px; padding: 8px 12px; } .flow-node-order { display: inline-flex; align-items: center; justify-content: center; width: 22px; height: 22px; font-size: 12px; font-weight: 600; color: #fff; background: var(--el-color-primary); border-radius: 50%; flex-shrink: 0; } .flow-node-name { font-size: 14px; color: var(--el-text-color-primary); } .flow-arrow { font-size: 18px; color: var(--el-text-color-secondary); } .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>