yyb
11 小时以前 04b1a9cfde4049be9a38b9832d5289d4a192c883
加班申请模块和审批流程公共组件
已添加1个文件
已修改2个文件
561 ■■■■ 文件已修改
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue 199 ●●●● 补丁 | 查看 | 原始文档 | 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>