| | |
| | | <!--OA模块:加班申请(字段为前端占位,后期与后端接口对齐)--> |
| | | <!--OA模块:加班申请--> |
| | | <template> |
| | | <div class="app-container"> |
| | | <div class="search_form mb20"> |
| | |
| | | <el-dialog |
| | | v-model="formDialog.visible" |
| | | :title="formDialog.title" |
| | | width="960px" |
| | | width="1040px" |
| | | append-to-body |
| | | destroy-on-close |
| | | class="overtime-apply-form-dialog" |
| | |
| | | </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> |
| | |
| | | <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> |
| | | </div> |
| | | <el-icon v-if="index < detailApprovalFlowNodes.length - 1" class="flow-arrow"> |
| | | <ArrowRight /> |
| | | </el-icon> |
| | | <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> |
| | | </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> |
| | |
| | | </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"; |
| | | |
| | |
| | | { label: "法定节假日加班", value: "holiday" }, |
| | | ]; |
| | | |
| | | /** 预设审批流节点(与流程引擎配置对齐占位) */ |
| | | const PRESET_APPROVAL_FLOW_NODES = [ |
| | | { roleCode: "direct_leader", roleName: "直属上级", sortOrder: 1 }, |
| | | { roleCode: "dept_leader", roleName: "部门负责人", sortOrder: 2 }, |
| | | ]; |
| | | |
| | | 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 demoApprovalFlowNodes() { |
| | | return [ |
| | | { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" }, |
| | | { approverId: null, approverName: "", sortOrder: 2, nodeOrder: 2, roleName: "", roleCode: "" }, |
| | | ]; |
| | | } |
| | | |
| | | 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) { |
| | |
| | | overtimeEndTime: "", |
| | | overtimeReason: "", |
| | | attachmentList: [], |
| | | approvalFlowNodes: [ |
| | | { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" }, |
| | | ], |
| | | }); |
| | | |
| | | const { proxy } = getCurrentInstance(); |
| | |
| | | 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", |
| | |
| | | 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", |
| | |
| | | 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); |
| | |
| | | nextTick(() => { |
| | | formRef.value?.validateField?.("overtimeEndTime"); |
| | | }); |
| | | } |
| | | |
| | | function onApprovalFlowChange() { |
| | | nextTick(() => formRef.value?.validateField?.("approvalFlowNodes")); |
| | | } |
| | | |
| | | const formRules = { |
| | |
| | | }, |
| | | ], |
| | | 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 }); |
| | | |
| | |
| | | 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", |
| | |
| | | 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) { |
| | |
| | | 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") { |
| | |
| | | .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> |