转正申请/调岗申请/工作交接/请假申请/加班申请页面画页面,接口联调和web端保持一致
日报
已添加11个文件
已修改13个文件
3748 ■■■■■ 文件已修改
src/api/oa/approvalInstance.js 21 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/system/post.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaWorkbench.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue 415 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/apply.vue 250 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/approve.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/detail.vue 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 295 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/template-select.vue 146 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/leave-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/overtime-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/regular-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/transfer-apply/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/work-handover/index.vue 12 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/ApprovalInstanceListPage.vue 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/ApprovalModuleSearchPopup.vue 268 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_styles/oa-approval-list.scss 409 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleApplyExtras.js 293 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleListSearch.js 319 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalModuleRegistry.js 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalTemplateType.js 153 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approveListUtils.js 327 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalInstance.js
@@ -29,3 +29,24 @@
    data: { approvalInstanceDto },
  });
}
/** å®¡æ‰¹ï¼ˆé€šè¿‡/驳回)POST /approvalInstance/approve */
export function approveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/approve",
    method: "post",
    data: { approvalInstanceDto },
  });
}
/** åˆ é™¤å®¡æ‰¹å®žä¾‹ DELETE /approvalInstance/delete */
export function deleteApprovalInstance(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    id => id != null && id !== ""
  );
  return request({
    url: "/approvalInstance/delete",
    method: "delete",
    data: idList,
  });
}
src/api/system/post.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import request from "@/utils/request";
/** å²—位下拉 GET /system/post/optionselect */
export function findPostOptions(query) {
  return request({
    url: "/system/post/optionselect",
    method: "get",
    params: query,
  });
}
src/config/oaPaths.js
@@ -26,6 +26,8 @@
  approveList: `/${P}/ApproveManage/approve-list/index`,
  approveListTemplateSelect: `/${P}/ApproveManage/approve-list/template-select`,
  approveListApply: `/${P}/ApproveManage/approve-list/apply`,
  approveListDetail: `/${P}/ApproveManage/approve-list/detail`,
  approveListApprove: `/${P}/ApproveManage/approve-list/approve`,
  approveTemplate: `/${P}/ApproveManage/approve-template/index`,
  approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`,
  approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`,
src/config/oaWorkbench.js
@@ -12,7 +12,7 @@
      // { label: "员工合同", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.staffContract },
      { label: "转正申请", icon: "/static/images/icon/hetongguanli.svg", path: OA_NAV.regularApply },
      { label: "调岗申请", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.transferApply },
      { label: "离职申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
      // { label: "离职申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.resignApply },
      { label: "工作交接", icon: "/static/images/icon/gongchuguanli.svg", path: OA_NAV.workHandover },
      // { label: "岗位管理", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
    ],
src/pages.json
@@ -1418,6 +1418,20 @@
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/detail",
      "style": {
        "navigationBarTitleText": "审批详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/approve",
      "style": {
        "navigationBarTitleText": "审批处理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/index",
      "style": {
        "navigationBarTitleText": "审批模板",
src/pages/oa/ApproveManage/approve-list/_components/ApproveInstanceDetailBody.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,415 @@
<!--
  å®¡æ‰¹å®žä¾‹è¯¦æƒ…展示:基本信息 + å¡«æŠ¥ + æµç¨‹ + å®¡æ‰¹è®°å½•
-->
<template>
  <view class="detail-body">
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">基本信息</text>
      </view>
      <view class="info-rows">
        <view class="info-row">
          <text class="info-label">业务单号</text>
          <text class="info-value">{{ row.instanceNo || row.id || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">审批状态</text>
          <u-tag :type="statusTagType(row.status)"
                 :text="statusLabel(row.status)"
                 size="mini" />
        </view>
        <view class="info-row">
          <text class="info-label">模板名称</text>
          <text class="info-value">{{ row.templateName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">业务名称</text>
          <text class="info-value">{{ row.businessName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请人</text>
          <text class="info-value">{{ row.applicantName || "—" }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请标题</text>
          <text class="info-value">{{ row.title || "—" }}</text>
        </view>
        <view v-if="rejectReason"
              class="info-row">
          <text class="info-label">驳回原因</text>
          <text class="info-value reject-text">{{ rejectReason }}</text>
        </view>
        <view class="info-row">
          <text class="info-label">申请时间</text>
          <text class="info-value">{{ formatDateTime(row.applyTime || row.createTime) }}</text>
        </view>
        <view v-if="row.finishTime"
              class="info-row">
          <text class="info-label">完成时间</text>
          <text class="info-value">{{ formatDateTime(row.finishTime) }}</text>
        </view>
      </view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">填报内容</text>
      </view>
      <view v-if="displayFields.length"
            class="info-rows">
        <view v-for="field in displayFields"
              :key="field.key"
              class="info-row">
          <text class="info-label">{{ field.label }}</text>
          <text class="info-value">{{ displayFieldValue(field) }}</text>
        </view>
        <view v-for="(extra, idx) in moduleExtraRows"
              :key="`extra-${idx}`"
              class="info-row">
          <text class="info-label">{{ extra.label }}</text>
          <text class="info-value">{{ extra.value }}</text>
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无填报内容</view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">审批流程({{ flowNodes.length }} é¡¹ï¼‰</text>
      </view>
      <view v-if="flowNodes.length"
            class="flow-wrap">
        <view v-for="(node, nodeIndex) in flowNodes"
              :key="nodeIndex"
              class="flow-node-block">
          <view class="flow-node-card">
            <view class="node-header">
              <view class="node-level-badge">{{ node.levelNo }}</view>
              <text class="node-level-text">第{{ levelLabel(node.levelNo) }}级</text>
              <u-tag size="mini"
                     :type="node.approveType === 'OR' ? 'warning' : 'primary'"
                     :text="node.approveType === 'OR' ? '或签' : '会签'"
                     plain />
            </view>
            <view class="approver-list">
              <view v-for="(a, aIdx) in node.approvers"
                    :key="aIdx"
                    class="approver-row">
                <text class="approver-name">{{ a.approverName }}</text>
                <u-tag v-if="a.taskStatus"
                       size="mini"
                       :type="taskStatusTagType(a.taskStatus)"
                       :text="taskStatusText(a.taskStatus)"
                       plain />
              </view>
            </view>
          </view>
          <view v-if="nodeIndex < flowNodes.length - 1"
                class="flow-connector-line" />
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无流程节点</view>
    </view>
    <view class="section-card">
      <view class="section-head">
        <text class="section-title">审批记录</text>
      </view>
      <view v-if="approvalRecords.length"
            class="record-list">
        <view v-for="(rec, index) in approvalRecords"
              :key="rec.id ?? index"
              class="record-item">
          <view class="record-head">
            <text class="record-operator">{{ rec.operatorName }}</text>
            <u-tag size="mini"
                   :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'error' : 'info'"
                   :text="recordActionLabel(rec.result)"
                   plain />
          </view>
          <text class="record-time">{{ rec.time }}</text>
          <text class="record-opinion">{{ rec.opinion || "无意见" }}</text>
        </view>
      </view>
      <view v-else
            class="empty-hint">暂无审批记录</view>
    </view>
  </view>
</template>
<script setup>
  import { computed } from "vue";
  import { APPROVAL_MODULE_KEYS } from "../../../_utils/approvalModuleRegistry.js";
  import {
    computeLeaveDurationDisplay,
    computeOvertimeHoursDisplay,
  } from "../../../_utils/approvalModuleApplyExtras.js";
  import {
    businessStatusTagType,
    businessStatusText,
    displayFieldValue,
    formatDateTime,
    getRejectReasonFromRecords,
    instanceStatusTagType,
    instanceStatusText,
    mapApprovalRecords,
    mapTasksToFlowNodes,
    recordActionLabel,
    resolveInstanceDisplayFields,
    taskStatusTagType,
    taskStatusText,
  } from "../../../_utils/approveListUtils.js";
  import { parseApprovalFormConfig } from "../../../_utils/approvalFormField.js";
  const props = defineProps({
    row: { type: Object, default: () => ({}) },
    moduleKey: { type: String, default: "" },
  });
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const isBusinessModule = computed(() =>
    [
      APPROVAL_MODULE_KEYS.LEAVE,
      APPROVAL_MODULE_KEYS.OVERTIME,
      APPROVAL_MODULE_KEYS.TRANSFER,
      APPROVAL_MODULE_KEYS.REGULAR,
      APPROVAL_MODULE_KEYS.WORK_HANDOVER,
    ].includes(props.moduleKey)
  );
  const statusLabel = status =>
    isBusinessModule.value ? businessStatusText(status) : instanceStatusText(status);
  const statusTagType = status =>
    isBusinessModule.value ? businessStatusTagType(status) : instanceStatusTagType(status);
  const displayFields = computed(() =>
    resolveInstanceDisplayFields(props.row?.formConfig)
  );
  const moduleExtraRows = computed(() => {
    const rows = [];
    const cfg = parseApprovalFormConfig(props.row?.formConfig);
    const payload = {};
    (cfg.fields || []).forEach(f => {
      if (f?.key) payload[f.key] = f.value ?? "";
    });
    if (props.moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
      const balance = payload.leaveBalanceDays;
      if (balance != null && balance !== "") {
        rows.push({ label: "假期余额", value: `${balance} å¤©` });
      }
      const days = computeLeaveDurationDisplay(cfg.fields, payload);
      if (days) rows.push({ label: "请假时长", value: `${days} å¤©` });
    }
    if (props.moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
      const hours = computeOvertimeHoursDisplay(cfg.fields, payload);
      if (hours) rows.push({ label: "加班时长", value: `${hours} å°æ—¶` });
    }
    if (props.moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
      const post = payload.originalPostName || payload.originalPost;
      if (post) rows.push({ label: "原岗位", value: post });
    }
    return rows;
  });
  const flowNodes = computed(() => mapTasksToFlowNodes(props.row?.tasks));
  const approvalRecords = computed(() =>
    mapApprovalRecords(props.row?.records)
  );
  const rejectReason = computed(() =>
    getRejectReasonFromRecords(props.row?.records)
  );
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
</script>
<style scoped lang="scss">
  $primary: #2979ff;
  $text: #1f2d3d;
  $text-muted: #909399;
  .detail-body {
    display: flex;
    flex-direction: column;
    gap: 10px;
  }
  .section-card {
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .info-rows {
    padding: 4px 16px 12px;
  }
  .info-row {
    display: flex;
    align-items: flex-start;
    justify-content: space-between;
    gap: 12px;
    padding: 10px 0;
    border-bottom: 1px solid #f5f6f8;
    &:last-child {
      border-bottom: none;
    }
  }
  .info-label {
    flex-shrink: 0;
    font-size: 14px;
    color: $text-muted;
    min-width: 72px;
  }
  .info-value {
    flex: 1;
    font-size: 14px;
    color: $text;
    text-align: right;
    word-break: break-all;
  }
  .reject-text {
    color: #f56c6c;
  }
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-node-block {
    display: flex;
    flex-direction: column;
    align-items: stretch;
  }
  .flow-node-card {
    background: #fafbfd;
    border: 1px solid #e8eef5;
    border-radius: 10px;
    padding: 12px;
  }
  .node-header {
    display: flex;
    align-items: center;
    gap: 8px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    background: $primary;
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .node-level-text {
    flex: 1;
    font-size: 14px;
    font-weight: 600;
    color: $text;
  }
  .approver-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .approver-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .approver-name {
    font-size: 13px;
    color: #606266;
  }
  .flow-connector-line {
    width: 2px;
    height: 12px;
    background: #d0dff0;
    margin: 4px auto;
  }
  .record-list {
    padding: 8px 16px 14px;
  }
  .record-item {
    padding: 10px 0;
    border-bottom: 1px solid #f0f2f5;
    &:last-child {
      border-bottom: none;
    }
  }
  .record-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: 8px;
  }
  .record-operator {
    font-size: 14px;
    font-weight: 600;
    color: $text;
  }
  .record-time {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .record-opinion {
    display: block;
    margin-top: 6px;
    font-size: 13px;
    color: #606266;
    line-height: 1.5;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: $text-muted;
  }
</style>
src/pages/oa/ApproveManage/approve-list/apply.vue
@@ -56,7 +56,7 @@
                   label-width="88"
                   input-align="right"
                   class="dynamic-form">
            <up-form-item v-for="field in formConfigData.fields"
            <up-form-item v-for="field in displayTemplateFields"
                          :key="field.key"
                          :label="field.label"
                          :required="!!field.required"
@@ -120,6 +120,66 @@
          </up-form>
          <view v-else
                class="empty-hint">该模板暂无填报项</view>
          <!-- è¯·å‡ï¼šå‡æœŸä½™é¢ + æ—¶é•¿è‡ªåŠ¨è®¡ç®— -->
          <view v-if="isLeaveModule"
                class="module-extra-block">
            <up-form :model="extraForm"
                     label-width="88"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="假期余额"
                            required
                            class="form-item-inline">
                <up-input v-model="extraForm.leaveBalanceDays"
                          type="digit"
                          placeholder="请输入天数"
                          clearable />
              </up-form-item>
              <up-form-item label="请假时长"
                            class="form-item-inline">
                <view class="readonly-with-unit">
                  <up-input :model-value="leaveDurationText"
                            readonly
                            placeholder="根据请假时间自动计算" />
                  <text class="unit-text">天</text>
                </view>
              </up-form-item>
            </up-form>
          </view>
          <!-- åŠ ç­ï¼šæ—¶é•¿è‡ªåŠ¨è®¡ç®— -->
          <view v-if="isOvertimeModule"
                class="module-extra-block">
            <up-form label-width="88"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="加班时长"
                            class="form-item-inline">
                <view class="readonly-with-unit">
                  <up-input :model-value="overtimeHoursText"
                            readonly
                            placeholder="根据加班时间自动计算" />
                  <text class="unit-text">小时</text>
                </view>
              </up-form-item>
            </up-form>
          </view>
          <!-- è°ƒå²—:原岗位自动带出 -->
          <view v-if="isTransferModule"
                class="module-extra-block">
            <up-form label-width="88"
                     input-align="right"
                     class="dynamic-form">
              <up-form-item label="原岗位"
                            class="form-item-readonly">
                <up-input :model-value="extraForm.originalPostName"
                          readonly
                          placeholder="选择申请人后自动带出" />
              </up-form-item>
            </up-form>
          </view>
        </view>
        <view class="section-card">
@@ -200,7 +260,7 @@
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { computed, reactive, ref, watch } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
@@ -212,7 +272,25 @@
  import useUserStore from "@/store/modules/user";
  import { parseTime } from "@/utils/ruoyi";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { findPostOptions } from "@/api/system/post.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
  import {
    computeLeaveDurationDisplay,
    computeOvertimeHoursDisplay,
    displayTemplateFieldsByModule,
    findApplicantTemplateField,
    findLeaveTimeTemplateField,
    findOvertimeTimeTemplateField,
    inferModuleKeyFromRow,
    loadModuleExtrasFromRow,
    resolveOriginalPostName,
    syncModuleExtrasToFormValues,
    unwrapUserArray,
    userById,
    validateModuleExtras,
    buildPostIdToNameMap,
  } from "../../_utils/approvalModuleApplyExtras.js";
  import {
    formatDatetimerangeDisplay,
    formatFieldDateValue,
@@ -233,10 +311,12 @@
    parseFieldDateToTs,
  } from "../../_utils/approvalFormField.js";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  import { EDIT_STORAGE_KEY } from "../../_utils/approveListUtils.js";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const userStore = useUserStore();
  const moduleKey = ref("");
  const templateId = ref("");
  const instanceId = ref("");
  const instanceRow = ref(null);
@@ -245,6 +325,22 @@
  const submitting = ref(false);
  const formValues = reactive({});
  const form = reactive({ title: "" });
  const extraForm = reactive({
    leaveBalanceDays: undefined,
    originalPostName: "",
  });
  const postIdToName = ref({});
  const transferUserPool = ref([]);
  const isLeaveModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.LEAVE
  );
  const isOvertimeModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.OVERTIME
  );
  const isTransferModule = computed(
    () => moduleKey.value === APPROVAL_MODULE_KEYS.TRANSFER
  );
  const showDatePicker = ref(false);
  const datePickerTs = ref(Date.now());
@@ -288,6 +384,31 @@
      );
    }
    return parseApprovalFormConfig(detail.value?.formConfig);
  });
  const displayTemplateFields = computed(() =>
    displayTemplateFieldsByModule(moduleKey.value, formConfigData.value.fields)
  );
  const leaveDurationText = computed(() => {
    if (!isLeaveModule.value) return "";
    const fields = formConfigData.value.fields;
    const timeField = findLeaveTimeTemplateField(fields);
    if (timeField?.key) void formValues[timeField.key];
    return computeLeaveDurationDisplay(fields, formValues);
  });
  const overtimeHoursText = computed(() => {
    if (!isOvertimeModule.value) return "";
    const fields = formConfigData.value.fields;
    const timeField = findOvertimeTimeTemplateField(fields);
    if (timeField?.key) void formValues[timeField.key];
    return computeOvertimeHoursDisplay(fields, formValues);
  });
  const applicantPickerValue = computed(() => {
    const f = findApplicantTemplateField(formConfigData.value.fields);
    return f?.key ? formValues[f.key] : undefined;
  });
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
@@ -414,7 +535,7 @@
      uni.showToast({ title: "请输入审批标题", icon: "none" });
      return false;
    }
    for (const field of formConfigData.value.fields) {
    for (const field of displayTemplateFields.value) {
      if (!field.required) continue;
      const val = formValues[field.key];
      if (val === undefined || val === null || String(val).trim() === "") {
@@ -453,17 +574,35 @@
      uni.showToast({ title: "模板未配置审批流程", icon: "none" });
      return false;
    }
    const moduleMsg = validateModuleExtras(
      moduleKey.value,
      formConfigData.value.fields,
      formValues,
      extraForm
    );
    if (moduleMsg) {
      uni.showToast({ title: moduleMsg, icon: "none" });
      return false;
    }
    return true;
  };
  const buildFormConfigPayload = () =>
    JSON.stringify({
  const buildFormConfigPayload = () => {
    syncModuleExtrasToFormValues(
      moduleKey.value,
      formValues,
      extraForm,
      formConfigData.value.fields
    );
    const allFields = formConfigData.value.fields || [];
    return JSON.stringify({
      prompt: formConfigData.value.prompt,
      fields: formConfigData.value.fields.map(field => ({
      fields: allFields.map(field => ({
        ...field,
        value: formValues[field.key] ?? "",
      })),
    });
  };
  const buildSavePayload = () => ({
    templateId: detail.value.id,
@@ -562,7 +701,8 @@
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(formConfigData.value.fields);
      initFormValues(displayTemplateFields.value);
      resetModuleExtras();
      if (!form.title && detail.value.templateName) {
        form.title = `${detail.value.templateName}申请`;
      }
@@ -579,6 +719,9 @@
      return;
    }
    instanceRow.value = row;
    if (!moduleKey.value) {
      moduleKey.value = inferModuleKeyFromRow(row);
    }
    templateId.value = row.templateId;
    form.title = row.title || "";
@@ -587,11 +730,65 @@
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(formConfigData.value.fields);
      initFormValues(displayTemplateFields.value);
      applyModuleExtrasFromRow();
      if (isTransferModule.value) {
        await ensureTransferLookupData();
        syncOriginalPostFromApplicant(applicantPickerValue.value);
      }
    } finally {
      loading.value = false;
    }
  };
  function resetModuleExtras() {
    extraForm.leaveBalanceDays = undefined;
    extraForm.originalPostName = "";
  }
  function applyModuleExtrasFromRow() {
    const loaded = loadModuleExtrasFromRow(
      moduleKey.value,
      instanceRow.value,
      formValues
    );
    if (loaded.leaveBalanceDays != null) {
      extraForm.leaveBalanceDays = loaded.leaveBalanceDays;
    }
    if (loaded.originalPostName) {
      extraForm.originalPostName = loaded.originalPostName;
    }
  }
  async function ensureTransferLookupData() {
    if (!transferUserPool.value.length) {
      try {
        const res = await userListNoPageByTenantId();
        transferUserPool.value = unwrapUserArray(res);
      } catch {
        transferUserPool.value = [];
      }
    }
    if (!Object.keys(postIdToName.value).length) {
      try {
        const res = await findPostOptions();
        const rows = res?.data ?? res?.rows ?? [];
        postIdToName.value = buildPostIdToNameMap(Array.isArray(rows) ? rows : []);
      } catch {
        postIdToName.value = {};
      }
    }
  }
  function syncOriginalPostFromApplicant(uid) {
    if (!isTransferModule.value) return;
    if (!uid) {
      extraForm.originalPostName = "";
      return;
    }
    const user = userById(transferUserPool.value, uid);
    extraForm.originalPostName = resolveOriginalPostName(user, postIdToName.value);
  }
  const goBack = () => {
    uni.navigateBack();
@@ -614,8 +811,18 @@
      });
  };
  watch(applicantPickerValue, async uid => {
    if (!isTransferModule.value) return;
    await ensureTransferLookupData();
    syncOriginalPostFromApplicant(uid);
  });
  onLoad(options => {
    moduleKey.value = options?.moduleKey || "";
    loadPickerSourceData();
    if (isTransferModule.value) {
      ensureTransferLookupData();
    }
    if (options?.id) {
      instanceId.value = options.id;
      loadForEdit();
@@ -1015,4 +1222,29 @@
  .empty-wrap {
    padding: 48px 20px;
  }
  .module-extra-block {
    margin-top: 8px;
    padding-top: 8px;
    border-top: 1px dashed #e8ecf0;
  }
  .readonly-with-unit {
    display: flex;
    align-items: center;
    gap: 8px;
    width: 100%;
    justify-content: flex-end;
  }
  .readonly-with-unit :deep(.u-input) {
    flex: 1;
    min-width: 0;
  }
  .unit-text {
    flex-shrink: 0;
    font-size: 14px;
    color: $text-muted;
  }
</style>
src/pages/oa/ApproveManage/approve-list/approve.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹å¤„理
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/approve
-->
<template>
  <view class="oa-detail-page">
    <PageHeader title="审批处理"
                @back="goBack" />
    <scroll-view v-if="row"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ApproveInstanceDetailBody :row="row"
                                 :module-key="detailModuleKey" />
      <view class="section-card opinion-card">
        <view class="section-head">
          <text class="section-title">审批意见</text>
        </view>
        <view class="opinion-wrap">
          <up-textarea v-model="approveOpinion"
                       placeholder="通过可留空;驳回请填写具体原因"
                       maxlength="500"
                       count
                       height="100"
                       border="surround" />
        </view>
      </view>
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                text="未获取到审批数据" />
    </view>
    <view v-if="row"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            :class="{ 'is-disabled': submitting }"
            @click="goBack">取消</text>
      <text class="oa-footer-btn btn-danger"
            :class="{ 'is-disabled': submitting }"
            @click="submitApprove('rejected')">驳回</text>
      <text class="oa-footer-btn btn-success"
            :class="{ 'is-disabled': submitting }"
            @click="submitApprove('approved')">通过</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
  import { approveApprovalInstance } from "@/api/oa/approvalInstance.js";
  import {
    buildApproveInstanceDto,
    canApproveInstance,
    loadInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
  const instanceId = ref("");
  const row = ref(null);
  const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value));
  const approveOpinion = ref("");
  const submitting = ref(false);
  const goBack = () => {
    uni.navigateBack();
  };
  const submitApprove = uiResult => {
    if (!row.value?.id || submitting.value) return;
    if (uiResult === "rejected" && !(approveOpinion.value || "").trim()) {
      uni.showToast({ title: "驳回时请填写审批意见", icon: "none" });
      return;
    }
    submitting.value = true;
    const dto = buildApproveInstanceDto(
      row.value.id,
      uiResult,
      approveOpinion.value
    );
    approveApprovalInstance(dto)
      .then(() => {
        uni.showToast({
          title: uiResult === "approved" ? "已通过" : "已驳回",
          icon: "success",
        });
        setTimeout(() => {
          const pages = getCurrentPages();
          const prevRoute = pages[pages.length - 2]?.route || "";
          const delta = prevRoute.includes("approve-list/detail") ? 2 : 1;
          uni.navigateBack({ delta });
        }, 300);
      })
      .catch(() => {
        uni.showToast({ title: "审批操作失败", icon: "none" });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  onLoad(options => {
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    instanceId.value = options.id;
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入审批", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    if (!canApproveInstance(cached)) {
      uni.showToast({ title: "当前审批不可处理", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    row.value = cached;
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
  $primary: #2979ff;
  .opinion-card {
    margin-top: 10px;
    background: #fff;
    border-radius: 12px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: #1f2d3d;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .opinion-wrap {
    padding: 12px 16px 16px;
  }
</style>
src/pages/oa/ApproveManage/approve-list/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹è¯¦æƒ…
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/detail
-->
<template>
  <view class="oa-detail-page">
    <PageHeader title="审批详情"
                @back="goBack" />
    <scroll-view v-if="row"
                 class="oa-detail-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <ApproveInstanceDetailBody :row="row"
                                 :module-key="detailModuleKey" />
    </scroll-view>
    <view v-else
          class="oa-empty">
      <up-empty mode="data"
                text="未获取到审批数据" />
    </view>
    <view v-if="row"
          class="oa-page-footer">
      <text class="oa-footer-btn btn-default"
            @click="goBack">返回</text>
      <text v-if="showEdit"
            class="oa-footer-btn btn-warn"
            @click="goEdit">修改</text>
      <text v-if="showApprove && !fromBusiness"
            class="oa-footer-btn btn-primary"
            @click="goApprove">去审批</text>
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApproveInstanceDetailBody from "./_components/ApproveInstanceDetailBody.vue";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import {
    canApproveInstance,
    canEditBusinessInstanceRow,
    canModifyInstance,
    EDIT_STORAGE_KEY,
    loadInstanceRow,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  import { inferModuleKeyFromRow } from "../../_utils/approvalModuleApplyExtras.js";
  const userStore = useUserStore();
  const instanceId = ref("");
  const fromBusiness = ref(false);
  const row = ref(null);
  const detailModuleKey = computed(() => inferModuleKeyFromRow(row.value));
  const showEdit = computed(() => {
    if (fromBusiness.value) {
      return canEditBusinessInstanceRow(row.value);
    }
    return canModifyInstance(row.value, userStore);
  });
  const showApprove = computed(() => canApproveInstance(row.value));
  const goBack = () => {
    uni.navigateBack();
  };
  const goEdit = () => {
    if (!showEdit.value || !row.value?.id) return;
    if (fromBusiness.value && !canEditBusinessInstanceRow(row.value)) {
      uni.showToast({ title: "进行中或已完成的审批不可修改", icon: "none" });
      return;
    }
    uni.setStorageSync(EDIT_STORAGE_KEY, row.value);
    const mk = detailModuleKey.value;
    const q = mk ? `&moduleKey=${mk}` : "";
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${row.value.id}${q}`,
    });
  };
  const goApprove = () => {
    if (!row.value?.id) return;
    stashInstanceRow(row.value);
    uni.navigateTo({
      url: `${OA_NAV.approveListApprove}?id=${row.value.id}`,
    });
  };
  onLoad(options => {
    fromBusiness.value = options?.from === "business";
    if (!options?.id) {
      uni.showToast({ title: "缺少审批 ID", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    instanceId.value = options.id;
    const cached = loadInstanceRow(options.id);
    if (!cached) {
      uni.showToast({ title: "请从列表进入详情", icon: "none" });
      setTimeout(goBack, 500);
      return;
    }
    row.value = cached;
  });
</script>
<style scoped lang="scss">
  @import "../../_styles/oa-approval-list.scss";
</style>
src/pages/oa/ApproveManage/approve-list/index.vue
@@ -1,109 +1,94 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹åˆ—表
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/index
-->
<template>
  <view class="approve-list-page sales-account">
  <view class="oa-approval-page">
    <PageHeader title="审批列表"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="queryParams.keyword"
                    class="search-text"
                    placeholder="审批标题 / å®¡æ‰¹ç¼–号"
                    clearable
                    @confirm="handleSearch" />
        </view>
        <view class="filter-button"
              @click="handleSearch">
          <up-icon name="search"
                   size="24"
                   color="#999" />
        </view>
    <view class="oa-toolbar">
      <view class="oa-filter-chip active-search">
        <up-icon name="search"
                 size="18"
                 color="#666" />
        <up-input v-model="queryParams.keyword"
                  class="chip-input"
                  placeholder="审批标题 / å®¡æ‰¹ç¼–号"
                  clearable
                  border="none"
                  @confirm="handleSearch" />
      </view>
      <view class="oa-icon-btn"
            @click="handleSearch">
        <up-icon name="search"
                 size="20"
                 color="#2979ff" />
      </view>
    </view>
    <scroll-view class="list-scroll"
    <scroll-view class="oa-list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 :style="{ height: listScrollHeight + 'px' }"
                 @scrolltolower="loadMore">
      <view v-if="list.length"
            class="ledger-list">
            class="oa-card-list">
        <view v-for="item in list"
              :key="item.id"
              class="ledger-item">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text"
                         size="16"
                         color="#ffffff" />
              class="oa-card"
              @click="openDetail(item)">
          <view class="oa-card-head">
            <view class="oa-card-title-wrap">
              <text class="oa-card-title">{{ item.title || item.instanceNo || "-" }}</text>
              <text v-if="item.templateName"
                    class="oa-card-sub">{{ item.templateName }}</text>
            </view>
            <text :class="['oa-status', businessStatusClass(item.status)]">
              {{ statusText(item.status) }}
            </text>
          </view>
          <view class="oa-card-body">
            <view class="oa-info-grid">
              <view class="oa-info-row">
                <text class="oa-info-label">审批编号</text>
                <text class="oa-info-value">{{ item.instanceNo || "-" }}</text>
              </view>
              <text class="item-id">{{ item.title || item.instanceNo || "-" }}</text>
            </view>
            <u-tag :type="statusTagType(item.status)"
                   :text="statusText(item.status)" />
          </view>
          <up-divider />
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">审批编号</text>
              <text class="detail-value">{{ item.instanceNo || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">模板名称</text>
              <text class="detail-value">{{ item.templateName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">业务名称</text>
              <text class="detail-value">{{ item.businessName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">申请人</text>
              <text class="detail-value">{{ item.applicantName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">当前级别</text>
              <text class="detail-value">{{ formatLevel(item.currentLevel) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">当前审批人</text>
              <text class="detail-value">{{ currentApproverName(item) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">申请时间</text>
              <text class="detail-value">{{ formatDateTime(item.applyTime) }}</text>
            </view>
            <view v-if="item.finishTime"
                  class="detail-row">
              <text class="detail-label">完成时间</text>
              <text class="detail-value">{{ formatDateTime(item.finishTime) }}</text>
              <view v-if="item.businessName"
                    class="oa-info-row">
                <text class="oa-info-label">业务名称</text>
                <text class="oa-info-value">{{ item.businessName }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请人</text>
                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">当前审批人</text>
                <text class="oa-info-value">{{ currentApproverName(item) }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请时间</text>
                <text class="oa-info-value">{{ formatDateTime(item.applyTime) }}</text>
              </view>
            </view>
          </view>
          <view v-if="canModify(item) || item.isApprove"
                class="action-buttons">
            <up-button v-if="canModify(item)"
                       class="action-btn"
                       size="small"
                       type="warning"
                       plain
                       @click.stop="goModify(item)">
              ç¼–辑
            </up-button>
            <up-button v-if="item.isApprove"
                       class="action-btn"
                       size="small"
                       type="primary"
                       @click.stop="handleApprove(item)">
              å®¡æ‰¹
            </up-button>
                class="oa-card-foot"
                @click.stop>
            <text v-if="canModify(item)"
                  class="oa-foot-btn btn-edit"
                  @click="goModify(item)">编辑</text>
            <text v-if="item.isApprove"
                  class="oa-foot-btn btn-approve"
                  @click="handleApprove(item)">审批</text>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else
            class="empty-wrap">
            class="oa-empty">
        <up-empty mode="list"
                  text="暂无审批数据" />
      </view>
@@ -119,71 +104,48 @@
</template>
<script setup>
  import { reactive, ref } from "vue";
  import { onMounted, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { listApprovalInstancePage } from "@/api/oa/approvalInstance.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import { parseTime } from "@/utils/ruoyi";
  import {
    businessStatusClass,
    businessStatusText,
    canModifyInstance,
    EDIT_STORAGE_KEY,
    stashInstanceRow,
  } from "../../_utils/approveListUtils.js";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  const userStore = useUserStore();
  const queryParams = reactive({
    keyword: "",
  });
  const queryParams = reactive({ keyword: "" });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({ current: 1, size: 10, total: 0 });
  const listScrollHeight = ref(400);
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  function calcListScrollHeight() {
    const sys = uni.getSystemInfoSync();
    const statusBar = sys.statusBarHeight || 0;
    const navBar = 44;
    const toolbar = 56;
    const fabGap = 16;
    listScrollHeight.value = Math.max(
      200,
      sys.windowHeight - statusBar - navBar - toolbar - fabGap
    );
  }
  const STATUS_TEXT = {
    PENDING: "进行中",
    APPROVED: "已通过",
    REJECTED: "已驳回",
  };
  const STATUS_TAG = {
    PENDING: "warning",
    APPROVED: "success",
    REJECTED: "error",
  };
  const statusText = status => STATUS_TEXT[status] || status || "-";
  const statusTagType = status => STATUS_TAG[status] || "info";
  const formatLevel = level => {
    if (level == null || level === "") return "-";
    return `第 ${level} çº§`;
  };
  const statusText = status => businessStatusText(status);
  const formatDateTime = val => {
    if (!val) return "-";
    return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
  };
  /** æ˜¯å¦æœ¬äººå‘起的审批(兼容列表未返回 applicantId) */
  const isOwnApplication = item => {
    const uid = userStore.id;
    if (item?.applicantId != null && uid != null && uid !== "") {
      return String(item.applicantId) === String(uid);
    }
    const loginName = userStore.nickName || userStore.name;
    if (loginName && item?.applicantName) {
      return String(item.applicantName).trim() === String(loginName).trim();
    }
    return false;
  };
  /** ä»…「进行中」且本人发起时可编辑(已通过/已驳回不显示编辑) */
  const canModify = item => item?.status === "PENDING" && isOwnApplication(item);
  const canModify = item => canModifyInstance(item, userStore);
  const currentApproverName = item => {
    const tasks = item?.tasks;
@@ -204,31 +166,22 @@
        dto.instanceNo = keyword;
      }
    }
    return {
      page: {
        current: page.current,
        size: page.size,
      },
      approvalInstanceDto: dto,
    };
    return { current: page.current, size: page.size, ...dto };
  };
  const getList = () => {
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    listApprovalInstancePage(buildListParams())
      .then(res => {
        const pageData = res?.data || {};
        const records = pageData.records || [];
        const total = pageData.total ?? 0;
        if (page.current === 1) {
          list.value = records;
        } else {
          list.value = [...list.value, ...records];
        }
        page.total = total;
        if (list.value.length >= total || records.length < page.size) {
          pageStatus.value = "nomore";
@@ -238,9 +191,7 @@
        }
      })
      .catch(() => {
        if (page.current === 1) {
          list.value = [];
        }
        if (page.current === 1) list.value = [];
        pageStatus.value = "loadmore";
        uni.showToast({ title: "查询失败", icon: "none" });
      });
@@ -254,17 +205,16 @@
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") {
      getList();
    }
    if (pageStatus.value === "loadmore") getList();
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const goBack = () => uni.navigateBack();
  const goAdd = () => uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  const goAdd = () => {
    uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  const openDetail = item => {
    if (!item?.id) return;
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListDetail}?id=${item.id}` });
  };
  const goModify = item => {
@@ -274,50 +224,43 @@
    }
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${item.id}`,
    });
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApply}?id=${item.id}` });
  };
  const handleApprove = item => {
    if (!item?.id) return;
    uni.showToast({ title: "审批详情页待对接", icon: "none" });
    if (!item.isApprove) {
      uni.showToast({ title: "当前审批无需您处理", icon: "none" });
      return;
    }
    stashInstanceRow(item);
    uni.navigateTo({ url: `${OA_NAV.approveListApprove}?id=${item.id}` });
  };
  onMounted(() => calcListScrollHeight());
  onShow(() => {
    calcListScrollHeight();
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  @import "../../_styles/oa-approval-list.scss";
  .approve-list-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  .active-search {
    padding-right: 4px;
  }
  .list-scroll {
  .chip-input {
    flex: 1;
    height: 0;
    padding-bottom: calc(80px + env(safe-area-inset-bottom));
    font-size: 14px;
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .action-buttons {
    display: flex;
    justify-content: flex-end;
    gap: 10px;
    margin-top: 12px;
    padding-top: 12px;
    border-top: 1px solid #f0f0f0;
  }
  .action-btn {
    min-width: 72px;
  :deep(.chip-input .u-input__content) {
    background: transparent !important;
    padding: 0 !important;
  }
</style>
src/pages/oa/ApproveManage/approve-list/template-select.vue
@@ -5,10 +5,10 @@
-->
<template>
  <view class="template-select-page sales-account">
    <PageHeader title="选择审批模板"
    <PageHeader :title="pageHeaderTitle"
                @back="goBack" />
    <view v-if="typeOptions.length"
    <view v-if="typeOptions.length && !moduleKey"
          class="step-section">
      <view class="tabs-wrap">
        <up-tabs :list="tabList"
@@ -16,6 +16,11 @@
                 line-color="#2979ff"
                 @click="onTabClick" />
      </view>
    </view>
    <view v-if="useAllTemplatesFallback && allTemplates.length"
          class="fallback-hint">
      <text>当前类型下无匹配模板,已显示全部 {{ allTemplates.length }} ä¸ªå¯ç”¨æ¨¡æ¿</text>
    </view>
    <view class="search-section">
@@ -41,11 +46,6 @@
            class="loading-wrap">
        <up-loading-icon mode="circle" />
        <text class="loading-text">加载中...</text>
      </view>
      <view v-else-if="!typeOptions.length"
            class="empty-wrap">
        <up-empty mode="list"
                  text="未获取到审批类型" />
      </view>
      <view v-else-if="displayList.length"
            class="ledger-list">
@@ -95,17 +95,25 @@
  import { computed, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import {
    buildTypeLabelMap,
    CUSTOM_TEMPLATE_LIST_TYPE,
    fetchApprovalTemplateTypes,
    buildTypeOptionsFromTemplates,
    FALLBACK_BUSINESS_TYPE_OPTIONS,
    fetchEnabledApprovalTemplates,
    filterTemplatesByBusinessType,
    filterTemplatesByBusinessTypes,
    getBusinessTypeLabel,
    getDefaultTypeTabIndex,
    pickTabIndexWithTemplates,
  } from "../../_utils/approvalTemplateType.js";
  import {
    getApprovalModuleConfig,
    getModuleMatchingBusinessTypes,
    resolveModuleBusinessType,
  } from "../../_utils/approvalModuleRegistry.js";
  const moduleKey = ref("");
  const typeOptions = ref([]);
  const typeLabelMap = ref({});
  /** å…¨éƒ¨è‡ªå®šä¹‰å·²å¯ç”¨æ¨¡æ¿ï¼ˆlist/1 ä¸€æ¬¡æ‹‰å–) */
@@ -118,9 +126,45 @@
    typeOptions.value.map(opt => ({ name: opt.name }))
  );
  const moduleConfig = computed(() =>
    moduleKey.value ? getApprovalModuleConfig(moduleKey.value) : null
  );
  const pageHeaderTitle = computed(() => {
    if (moduleConfig.value?.label) {
      return `选择${moduleConfig.value.label}模板`;
    }
    return "选择审批模板";
  });
  const moduleBusinessTypes = computed(() => {
    if (!moduleKey.value) return [];
    return getModuleMatchingBusinessTypes(moduleKey.value, typeOptions.value);
  });
  const currentTypeOption = computed(() => typeOptions.value[activeTab.value]);
  /** æ—  moduleKey ä¸”当前 Tab ç­›ä¸åˆ°æ—¶ï¼Œå±•示全部模板避免「有数据却空白」 */
  const useAllTemplatesFallback = computed(() => {
    if (moduleKey.value) return false;
    if (!allTemplates.value.length) return false;
    const businessType = currentTypeOption.value?.value;
    if (businessType == null || businessType === "") return true;
    return filterTemplatesByBusinessType(allTemplates.value, businessType).length === 0;
  });
  const currentSource = computed(() => {
    if (moduleKey.value && moduleBusinessTypes.value.length) {
      const filtered = filterTemplatesByBusinessTypes(
        allTemplates.value,
        moduleBusinessTypes.value
      );
      if (filtered.length) return filtered;
      return allTemplates.value;
    }
    if (useAllTemplatesFallback.value) {
      return allTemplates.value;
    }
    const businessType = currentTypeOption.value?.value;
    return filterTemplatesByBusinessType(allTemplates.value, businessType);
  });
@@ -134,8 +178,17 @@
  });
  const emptyText = computed(() => {
    if (allTemplates.value.length === 0) {
      return "暂无已启用的审批模板,请先在「审批模板」中创建并启用";
    }
    if (moduleConfig.value?.label) {
      return `暂无${moduleConfig.value.label}可用模板`;
    }
    if (useAllTemplatesFallback.value) {
      return "当前类型下无匹配模板";
    }
    const typeName = currentTypeOption.value?.name || "该审批类型";
    return `暂无${typeName}下的模板`;
    return `暂无${typeName}下的模板(可切换上方类型)`;
  });
  const businessTypeText = type =>
@@ -160,41 +213,46 @@
    return count != null ? `${count} ä¸ª` : "-";
  };
  const normalizeList = data => {
    const list = Array.isArray(data)
      ? data
      : Array.isArray(data?.records)
        ? data.records
        : [];
    return list.filter(item => String(item?.enabled ?? "1") === "1");
  };
  const loadCustomTemplates = () =>
    listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE)
      .then(res => {
        allTemplates.value = normalizeList(res?.data);
      })
      .catch(() => {
        allTemplates.value = [];
        uni.showToast({ title: "加载模板列表失败", icon: "none" });
      });
  const initPage = async () => {
    loading.value = true;
    keyword.value = "";
    allTemplates.value = [];
    try {
      const [opts] = await Promise.all([
      const [opts, templates] = await Promise.all([
        fetchApprovalTemplateTypes(),
        loadCustomTemplates(),
        fetchEnabledApprovalTemplates(),
      ]);
      typeOptions.value = opts;
      typeLabelMap.value = buildTypeLabelMap(opts);
      activeTab.value = getDefaultTypeTabIndex(opts);
      let resolvedOpts = opts?.length ? opts : buildTypeOptionsFromTemplates(templates);
      if (!resolvedOpts.length) {
        resolvedOpts = [...FALLBACK_BUSINESS_TYPE_OPTIONS];
      }
      typeOptions.value = resolvedOpts;
      typeLabelMap.value = buildTypeLabelMap(resolvedOpts);
      allTemplates.value = templates;
      if (!templates.length) {
        uni.showToast({
          title: "未获取到已启用模板",
          icon: "none",
          duration: 2500,
        });
      }
      if (moduleKey.value) {
        const resolved = resolveModuleBusinessType(moduleKey.value, opts);
        const idx = opts.findIndex(
          opt => String(opt.value) === String(resolved)
        );
        activeTab.value =
          idx >= 0 ? idx : pickTabIndexWithTemplates(resolvedOpts, templates);
      } else {
        activeTab.value = pickTabIndexWithTemplates(resolvedOpts, templates);
      }
    } catch {
      typeOptions.value = [];
      typeLabelMap.value = {};
      uni.showToast({ title: "获取审批类型失败", icon: "none" });
      allTemplates.value = [];
      uni.showToast({ title: "加载模板失败", icon: "none" });
    } finally {
      loading.value = false;
    }
@@ -215,12 +273,14 @@
      uni.showToast({ title: "该模板已停用", icon: "none" });
      return;
    }
    const base = `${OA_NAV.approveListApply}?templateId=${item.id}`;
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?templateId=${item.id}`,
      url: moduleKey.value ? `${base}&moduleKey=${moduleKey.value}` : base,
    });
  };
  onLoad(() => {
  onLoad(options => {
    moduleKey.value = options?.moduleKey || "";
    initPage();
  });
</script>
@@ -234,6 +294,16 @@
    min-height: 100vh;
  }
  .fallback-hint {
    margin: 8px 12px 0;
    padding: 8px 12px;
    font-size: 12px;
    color: #e6a23c;
    background: #fdf6ec;
    border-radius: 6px;
    line-height: 1.5;
  }
  .step-section {
    background: #fff;
    border-bottom: 1px solid #f0f0f0;
src/pages/oa/AttendManage/leave-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/AttendManage/leave-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.LEAVE" />
</template>
<script setup>
  /** OA - å‡å‹¤ç®¡ç† - è¯·å‡ç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "AttendManage/leave-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/AttendManage/overtime-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/AttendManage/overtime-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.OVERTIME" />
</template>
<script setup>
  /** OA - å‡å‹¤ç®¡ç† - åŠ ç­ç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "AttendManage/overtime-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/regular-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/HrManage/regular-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.REGULAR" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - è½¬æ­£ç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/regular-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/transfer-apply/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/HrManage/transfer-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.TRANSFER" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - è°ƒå²—申请 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/transfer-apply";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/HrManage/work-handover/index.vue
@@ -3,16 +3,10 @@
  è·¯ç”±ï¼š/pages/oa/HrManage/work-handover/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <ApprovalInstanceListPage :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å·¥ä½œäº¤æŽ¥ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/work-handover";
  const { config } = useOaPage(pageKey);
  import ApprovalInstanceListPage from "../../_components/ApprovalInstanceListPage.vue";
  import { APPROVAL_MODULE_KEYS } from "../../_utils/approvalModuleRegistry.js";
</script>
src/pages/oa/_components/ApprovalInstanceListPage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,347 @@
<!--
  ä¸šåŠ¡å®¡æ‰¹ç”³è¯·åˆ—è¡¨ï¼ˆè½¬æ­£/调岗/交接/请假/加班)
-->
<template>
  <view class="oa-approval-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <view class="oa-toolbar">
      <view class="oa-filter-chip"
            :class="{ active: hasActiveFilter }"
            @click="showFilter = true">
        <up-icon name="list"
                 size="18"
                 :color="hasActiveFilter ? '#2979ff' : '#666'" />
        <text class="chip-label">筛选</text>
        <text v-if="filterSummary"
              class="chip-value">{{ filterSummary }}</text>
        <text v-else
              class="chip-placeholder">全部条件</text>
      </view>
      <view class="oa-icon-btn"
            @click="handleSearch">
        <up-icon name="search"
                 size="20"
                 color="#666" />
      </view>
    </view>
    <ApprovalModuleSearchPopup v-model:show="showFilter"
                               :module-key="moduleKey"
                               v-model="searchForm"
                               @search="handleSearch"
                               @reset="handleReset" />
    <scroll-view class="oa-list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 :style="{ height: listScrollHeight + 'px' }"
                 @scrolltolower="loadMore">
      <view v-if="displayList.length"
            class="oa-card-list">
        <view v-for="item in displayList"
              :key="item.id"
              class="oa-card"
              @click="openDetail(item)">
          <view class="oa-card-head">
            <view class="oa-card-title-wrap">
              <text class="oa-card-title">{{ cardTitle(item) }}</text>
              <text v-if="item.instanceNo"
                    class="oa-card-sub">{{ item.instanceNo }}</text>
            </view>
            <text :class="['oa-status', businessStatusClass(item.status)]">
              {{ businessStatusText(item.status) }}
            </text>
          </view>
          <view class="oa-card-body">
            <view class="oa-info-grid">
              <view v-for="(row, idx) in visibleDisplayRows(item)"
                    :key="'f-' + idx"
                    class="oa-info-row">
                <text class="oa-info-label">{{ row.label }}</text>
                <text class="oa-info-value">{{ row.value || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请人</text>
                <text class="oa-info-value">{{ item.applicantName || "-" }}</text>
              </view>
              <view class="oa-info-row">
                <text class="oa-info-label">申请时间</text>
                <text class="oa-info-value">{{ item.createTime || "-" }}</text>
              </view>
            </view>
          </view>
          <view v-if="canEditBusinessInstanceRow(item)"
                class="oa-card-foot"
                @click.stop>
            <text class="oa-foot-btn btn-edit"
                  @click="goEdit(item)">修改</text>
            <text class="oa-foot-btn btn-delete"
                  @click="confirmDelete(item)">删除</text>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else-if="!tableLoading"
            class="oa-empty">
        <up-empty mode="list"
                  :text="`暂无${pageTitle}数据`" />
      </view>
      <view v-if="tableLoading && !list.length"
            class="oa-loading">
        <up-loading-icon mode="circle" />
      </view>
    </scroll-view>
    <view class="fab-button"
          @click="handleAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff" />
    </view>
  </view>
</template>
<script setup>
  import { computed, onMounted, reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import ApprovalModuleSearchPopup from "./ApprovalModuleSearchPopup.vue";
  import {
    deleteApprovalInstance,
    listApprovalInstancePage,
  } from "@/api/oa/approvalInstance.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import { fetchApprovalTemplateTypes } from "../_utils/approvalTemplateType.js";
  import {
    getApprovalModuleConfig,
    resolveModuleBusinessType,
  } from "../_utils/approvalModuleRegistry.js";
  import {
    buildModuleListDto,
    createModuleSearchForm,
    filterRowsByModuleSearch,
    filterRowsByModuleBusinessType,
    formatDateRangeLabel,
    getModuleSearchMeta,
  } from "../_utils/approvalModuleListSearch.js";
  import {
    buildInstanceListParams,
    businessStatusClass,
    businessStatusText,
    canEditBusinessInstanceRow,
    EDIT_STORAGE_KEY,
    mapInstanceListRow,
    stashInstanceRow,
    unwrapInstancePage,
  } from "../_utils/approveListUtils.js";
  const props = defineProps({
    moduleKey: { type: String, required: true },
  });
  const moduleConfig = computed(() => getApprovalModuleConfig(props.moduleKey));
  const pageTitle = computed(() => moduleConfig.value?.label || "申请");
  const showFilter = ref(false);
  const searchForm = reactive(createModuleSearchForm(props.moduleKey));
  const list = ref([]);
  const tableLoading = ref(false);
  const pageStatus = ref("loadmore");
  const businessType = ref("");
  const typeOptions = ref([]);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const listScrollHeight = ref(400);
  function calcListScrollHeight() {
    const sys = uni.getSystemInfoSync();
    const statusBar = sys.statusBarHeight || 0;
    const navBar = 44;
    const toolbar = 56;
    const fabGap = 16;
    listScrollHeight.value = Math.max(
      200,
      sys.windowHeight - statusBar - navBar - toolbar - fabGap
    );
  }
  const displayList = computed(() => {
    const byType = filterRowsByModuleBusinessType(
      props.moduleKey,
      list.value,
      typeOptions.value
    );
    return filterRowsByModuleSearch(props.moduleKey, byType, searchForm);
  });
  const hasActiveFilter = computed(() => Boolean(filterSummary.value));
  const filterSummary = computed(() => {
    const parts = [];
    const meta = getModuleSearchMeta(props.moduleKey);
    for (const field of meta.fields || []) {
      const val = searchForm[field.key];
      if (field.type === "input" && (val || "").trim()) {
        parts.push(`${field.label}:${String(val).trim()}`);
      } else if (field.type === "daterange" && Array.isArray(val) && val[0]) {
        parts.push(`${field.label}:${formatDateRangeLabel(val)}`);
      } else if (field.type === "select" && val) {
        const opt = (field.options || []).find(o => o.value === val);
        parts.push(`${field.label}:${opt?.label || val}`);
      } else if (field.type === "user" && val) {
        parts.push(`${field.label}:已选`);
      }
    }
    return parts.join(";");
  });
  function cardTitle(item) {
    return item.summary || item.title || pageTitle.value;
  }
  function visibleDisplayRows(item) {
    const rows = item.displayRows || [];
    return rows.slice(0, 2);
  }
  const buildListRequestParams = () => {
    const extraDto = buildModuleListDto(props.moduleKey, searchForm);
    return buildInstanceListParams({
      page,
      businessType: businessType.value,
      extraDto,
      searchForm,
    });
  };
  const fetchList = async (reset = false) => {
    if (reset) {
      page.current = 1;
      pageStatus.value = "loadmore";
      list.value = [];
    }
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    if (!businessType.value && businessType.value !== 0) return;
    pageStatus.value = "loading";
    tableLoading.value = true;
    try {
      const res = await listApprovalInstancePage(buildListRequestParams());
      const { records, total } = unwrapInstancePage(res);
      const listFields = moduleConfig.value?.listFields || [];
      const mapped = records.map(row => mapInstanceListRow(row, listFields));
      if (page.current === 1) {
        list.value = mapped;
      } else {
        list.value = [...list.value, ...mapped];
      }
      page.total = total;
      if (list.value.length >= total || records.length < page.size) {
        pageStatus.value = "nomore";
      } else {
        pageStatus.value = "loadmore";
        page.current += 1;
      }
    } catch {
      if (page.current === 1) list.value = [];
      pageStatus.value = "loadmore";
      uni.showToast({ title: `${pageTitle.value}加载失败`, icon: "none" });
    } finally {
      tableLoading.value = false;
    }
  };
  const initBusinessType = async () => {
    try {
      typeOptions.value = await fetchApprovalTemplateTypes();
      const resolved = resolveModuleBusinessType(props.moduleKey, typeOptions.value);
      businessType.value =
        resolved != null && resolved !== ""
          ? resolved
          : moduleConfig.value?.approvalType ?? "";
    } catch {
      businessType.value = moduleConfig.value?.approvalType ?? "";
    }
  };
  const handleSearch = () => fetchList(true);
  const handleReset = () => {
    Object.assign(searchForm, createModuleSearchForm(props.moduleKey));
    fetchList(true);
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") fetchList(false);
  };
  const goBack = () => uni.navigateBack();
  const openDetail = item => {
    if (!item?.id) return;
    stashInstanceRow(item);
    uni.navigateTo({
      url: `${OA_NAV.approveListDetail}?id=${item.id}&from=business`,
    });
  };
  const goEdit = item => {
    if (!canEditBusinessInstanceRow(item)) {
      uni.showToast({ title: "进行中或已完成的审批不可修改", icon: "none" });
      return;
    }
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    stashInstanceRow(item);
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${item.id}&moduleKey=${props.moduleKey}`,
    });
  };
  const confirmDelete = item => {
    if (!item?.id) return;
    const title = item.title || item.templateName || item.instanceNo || "该审批";
    uni.showModal({
      title: "删除确认",
      content: `确定要删除「${title}」吗?删除后不可恢复。`,
      confirmText: "确定删除",
      confirmColor: "#f56c6c",
      success: async res => {
        if (!res.confirm) return;
        try {
          await deleteApprovalInstance([item.id]);
          uni.showToast({ title: "删除成功", icon: "success" });
          fetchList(true);
        } catch {
          uni.showToast({ title: "删除失败", icon: "none" });
        }
      },
    });
  };
  const handleAdd = () => {
    uni.navigateTo({
      url: `${OA_NAV.approveListTemplateSelect}?moduleKey=${props.moduleKey}`,
    });
  };
  onMounted(() => {
    calcListScrollHeight();
  });
  onShow(async () => {
    calcListScrollHeight();
    await initBusinessType();
    fetchList(true);
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  @import "../_styles/oa-approval-list.scss";
</style>
src/pages/oa/_components/ApprovalModuleSearchPopup.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,268 @@
<!--
  ä¸šåŠ¡å®¡æ‰¹åˆ—è¡¨ç­›é€‰å¼¹çª—
-->
<template>
  <up-popup :show="modelShow"
            mode="bottom"
            round
            @close="closePopup">
    <view class="oa-filter-popup">
      <view class="oa-filter-head">筛选条件</view>
      <scroll-view scroll-y
                   class="oa-filter-scroll">
        <view v-for="field in fields"
              :key="field.key"
              class="oa-filter-field">
          <text class="oa-field-label">{{ field.label }}</text>
          <up-input v-if="field.type === 'input'"
                    v-model="localForm[field.key]"
                    :placeholder="field.placeholder || `请输入${field.label}`"
                    clearable
                    border="surround" />
          <view v-else-if="field.type === 'daterange'"
                class="oa-picker-row"
                @click="openDateRange(field.key)">
            <text :class="{ placeholder: !dateLabel(field.key) }">
              {{ dateLabel(field.key) || `请选择${field.label}` }}
            </text>
            <up-icon name="calendar"
                     size="18"
                     color="#999" />
          </view>
          <view v-else-if="field.type === 'user'"
                class="oa-picker-row"
                @click="openUserPicker">
            <text :class="{ placeholder: !userLabel }">
              {{ userLabel || field.placeholder || '请选择申请人' }}
            </text>
            <up-icon name="arrow-down"
                     size="14"
                     color="#999" />
          </view>
          <view v-else-if="field.type === 'select'"
                class="oa-picker-row"
                @click="openSelect(field)">
            <text :class="{ placeholder: !selectLabel(field) }">
              {{ selectLabel(field) || `请选择${field.label}` }}
            </text>
            <up-icon name="arrow-down"
                     size="14"
                     color="#999" />
          </view>
        </view>
      </scroll-view>
      <view class="oa-filter-actions">
        <up-button text="重置"
                   shape="circle"
                   @click="handleReset" />
        <up-button type="primary"
                   text="确定"
                   shape="circle"
                   @click="handleConfirm" />
      </view>
    </view>
    <up-calendar :show="calendarShow"
                 mode="range"
                 :maxDate="maxDate"
                 minDate="2020-01-01"
                 :monthNum="24"
                 @confirm="onDateConfirm"
                 @close="calendarShow = false" />
    <up-picker :show="pickerShow"
               :columns="pickerColumns"
               keyName="label"
               @confirm="onPickerConfirm"
               @cancel="pickerShow = false"
               @close="pickerShow = false" />
    <up-popup :show="userPickerShow"
              mode="bottom"
              round
              @close="userPickerShow = false">
      <view class="oa-user-popup">
        <view class="oa-user-popup-title">选择申请人</view>
        <up-input v-model="userKeyword"
                  placeholder="搜索姓名或编号"
                  clearable
                  border="surround" />
        <scroll-view scroll-y
                     class="oa-user-list">
          <view v-for="u in filteredUsers"
                :key="u.userId ?? u.id"
                class="oa-user-item"
                @click="pickUser(u)">
            <text>{{ userSelectLabel(u) }}</text>
          </view>
          <view v-if="!filteredUsers.length"
                class="oa-user-empty">暂无匹配用户</view>
        </scroll-view>
      </view>
    </up-popup>
  </up-popup>
</template>
<script setup>
  import { computed, reactive, ref, watch } from "vue";
  import dayjs from "dayjs";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import {
    formatDateRangeLabel,
    getModuleSearchMeta,
    resetModuleSearchForm,
    userSelectLabel,
  } from "../_utils/approvalModuleListSearch.js";
  const props = defineProps({
    show: { type: Boolean, default: false },
    moduleKey: { type: String, required: true },
    modelValue: { type: Object, default: () => ({}) },
  });
  const emit = defineEmits(["update:show", "update:modelValue", "search", "reset"]);
  const modelShow = computed({
    get: () => props.show,
    set: v => emit("update:show", v),
  });
  function closePopup() {
    emit("update:show", false);
  }
  const localForm = reactive({});
  const calendarShow = ref(false);
  const activeDateKey = ref("applyDateRange");
  const maxDate = dayjs().format("YYYY-MM-DD");
  const pickerShow = ref(false);
  const pickerColumns = ref([[]]);
  const activeSelectField = ref(null);
  const userPickerShow = ref(false);
  const userKeyword = ref("");
  const allUsers = ref([]);
  const fields = computed(() => getModuleSearchMeta(props.moduleKey).fields || []);
  const userLabel = computed(() => {
    const id = localForm.applicantId;
    if (!id) return "";
    const u = allUsers.value.find(x => String(x.userId ?? x.id) === String(id));
    return u ? userSelectLabel(u) : String(id);
  });
  const filteredUsers = computed(() => {
    const q = userKeyword.value.trim().toLowerCase();
    const list = allUsers.value.filter(u => {
      if (u.delFlag === "2" || u.delFlag === 2) return false;
      if (u.status != null && String(u.status) !== "0") return false;
      return true;
    });
    if (!q) return list.slice(0, 50);
    return list
      .filter(u => {
        const nick = (u.nickName || "").toLowerCase();
        const name = (u.userName || "").toLowerCase();
        const id = String(u.userId ?? u.id ?? "");
        return nick.includes(q) || name.includes(q) || id.includes(q);
      })
      .slice(0, 50);
  });
  function unwrapUsers(res) {
    if (Array.isArray(res)) return res;
    if (Array.isArray(res?.data)) return res.data;
    if (Array.isArray(res?.rows)) return res.rows;
    return [];
  }
  async function loadUsers() {
    if (allUsers.value.length) return;
    try {
      const res = await userListNoPageByTenantId();
      allUsers.value = unwrapUsers(res);
    } catch {
      allUsers.value = [];
    }
  }
  watch(
    () => props.show,
    v => {
      if (!v) return;
      Object.assign(localForm, props.modelValue || {});
    }
  );
  function dateLabel(key) {
    return formatDateRangeLabel(localForm[key]);
  }
  function openDateRange(key) {
    activeDateKey.value = key;
    calendarShow.value = true;
  }
  function onDateConfirm(e) {
    if (!e?.length) {
      calendarShow.value = false;
      return;
    }
    localForm[activeDateKey.value] = [e[0], e[e.length - 1]];
    calendarShow.value = false;
  }
  function selectLabel(field) {
    const val = localForm[field.key];
    if (!val) return "";
    const opt = (field.options || []).find(o => o.value === val);
    return opt?.label || String(val);
  }
  function openSelect(field) {
    activeSelectField.value = field;
    pickerColumns.value = [field.options || []];
    pickerShow.value = true;
  }
  function onPickerConfirm(e) {
    const item = e?.value?.[0];
    if (activeSelectField.value?.key && item) {
      localForm[activeSelectField.value.key] = item.value;
    }
    pickerShow.value = false;
  }
  async function openUserPicker() {
    await loadUsers();
    userKeyword.value = "";
    userPickerShow.value = true;
  }
  function pickUser(u) {
    localForm.applicantId = u.userId ?? u.id ?? "";
    userPickerShow.value = false;
  }
  function handleReset() {
    resetModuleSearchForm(props.moduleKey, localForm);
    emit("update:modelValue", { ...localForm });
    emit("reset", { ...localForm });
    emit("update:show", false);
  }
  function handleConfirm() {
    emit("update:modelValue", { ...localForm });
    emit("search", { ...localForm });
    emit("update:show", false);
  }
</script>
<style scoped lang="scss">
  @import "../_styles/oa-approval-list.scss";
</style>
src/pages/oa/_styles/oa-approval-list.scss
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,409 @@
// OA å®¡æ‰¹åˆ—表(业务申请 + å®¡æ‰¹åˆ—表)统一样式
.oa-approval-page {
  min-height: 100vh;
  background: #f2f4f7;
  display: flex;
  flex-direction: column;
  box-sizing: border-box;
}
.oa-list-scroll {
  width: 100%;
  box-sizing: border-box;
}
.oa-toolbar {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 10px 16px;
  background: #fff;
  border-bottom: 1px solid #eef0f3;
}
.oa-filter-chip {
  flex: 1;
  min-height: 40px;
  padding: 0 14px;
  background: #f5f7fa;
  border-radius: 20px;
  display: flex;
  align-items: center;
  gap: 6px;
  border: 1px solid transparent;
  &.active {
    background: #ecf3ff;
    border-color: #b3d4ff;
  }
  .chip-label {
    font-size: 14px;
    color: #333;
    font-weight: 500;
    flex-shrink: 0;
  }
  .chip-value {
    flex: 1;
    font-size: 13px;
    color: #666;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .chip-placeholder {
    flex: 1;
    font-size: 13px;
    color: #aaa;
  }
}
.oa-icon-btn {
  width: 40px;
  height: 40px;
  border-radius: 20px;
  background: #f5f7fa;
  display: flex;
  align-items: center;
  justify-content: center;
  flex-shrink: 0;
  &:active {
    background: #e8ebf0;
  }
}
.oa-card-list {
  padding: 12px 16px 4px;
}
.oa-card {
  background: #fff;
  border-radius: 12px;
  margin-bottom: 12px;
  overflow: hidden;
  box-shadow: 0 1px 4px rgba(15, 23, 42, 0.06);
  &:active {
    opacity: 0.92;
  }
}
.oa-card-head {
  padding: 14px 14px 10px;
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: 10px;
}
.oa-card-title-wrap {
  flex: 1;
  min-width: 0;
}
.oa-card-title {
  display: block;
  font-size: 15px;
  font-weight: 600;
  color: #1a1a1a;
  line-height: 1.4;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
.oa-card-sub {
  display: block;
  margin-top: 4px;
  font-size: 12px;
  color: #8c8c8c;
}
.oa-status {
  flex-shrink: 0;
  font-size: 11px;
  line-height: 1;
  padding: 5px 8px;
  border-radius: 4px;
  font-weight: 500;
  &.status-pending {
    color: #d46b08;
    background: #fff7e6;
  }
  &.status-approved {
    color: #389e0d;
    background: #f6ffed;
  }
  &.status-rejected {
    color: #cf1322;
    background: #fff1f0;
  }
  &.status-draft {
    color: #595959;
    background: #f5f5f5;
  }
  &.status-cancelled {
    color: #8c8c8c;
    background: #fafafa;
  }
}
.oa-card-body {
  padding: 0 14px 12px;
}
.oa-info-grid {
  display: flex;
  flex-direction: column;
  gap: 8px;
}
.oa-info-row {
  display: flex;
  align-items: baseline;
  font-size: 13px;
  line-height: 1.45;
}
.oa-info-label {
  width: 72px;
  flex-shrink: 0;
  color: #8c8c8c;
}
.oa-info-value {
  flex: 1;
  color: #333;
  word-break: break-all;
}
.oa-card-foot {
  display: flex;
  align-items: center;
  justify-content: flex-end;
  gap: 10px;
  padding: 10px 14px;
  background: #fafbfc;
  border-top: 1px solid #f0f2f5;
}
.oa-foot-btn {
  min-width: 64px;
  height: 32px;
  line-height: 32px;
  padding: 0 14px;
  font-size: 13px;
  border-radius: 16px;
  text-align: center;
  &.btn-edit {
    color: #2979ff;
    background: #ecf3ff;
  }
  &.btn-delete {
    color: #ff4d4f;
    background: #fff1f0;
  }
  &.btn-approve {
    color: #fff;
    background: #2979ff;
  }
}
.oa-empty,
.oa-loading {
  padding: 48px 20px;
}
.oa-loading {
  display: flex;
  justify-content: center;
}
// ç­›é€‰å¼¹çª—
.oa-filter-popup {
  padding: 16px 16px calc(16px + env(safe-area-inset-bottom));
  max-height: 72vh;
  display: flex;
  flex-direction: column;
  background: #fff;
}
.oa-filter-head {
  text-align: center;
  font-size: 16px;
  font-weight: 600;
  color: #1a1a1a;
  padding-bottom: 12px;
  border-bottom: 1px solid #f0f2f5;
  margin-bottom: 12px;
}
.oa-filter-scroll {
  max-height: 50vh;
}
.oa-filter-field {
  margin-bottom: 16px;
}
.oa-field-label {
  display: block;
  font-size: 13px;
  color: #666;
  margin-bottom: 8px;
}
.oa-picker-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  min-height: 44px;
  padding: 0 14px;
  background: #f5f7fa;
  border-radius: 8px;
  font-size: 14px;
  color: #333;
  .placeholder {
    color: #bbb;
  }
}
.oa-filter-actions {
  display: flex;
  gap: 12px;
  margin-top: 16px;
  padding-top: 12px;
}
.oa-filter-actions :deep(.u-button) {
  flex: 1;
  border-radius: 22px !important;
}
.oa-user-popup {
  padding: 16px;
  max-height: 60vh;
  background: #fff;
}
.oa-user-popup-title {
  text-align: center;
  font-weight: 600;
  font-size: 16px;
  margin-bottom: 12px;
}
.oa-user-list {
  max-height: 40vh;
  margin-top: 10px;
}
.oa-user-item {
  padding: 14px 4px;
  font-size: 14px;
  color: #333;
  border-bottom: 1px solid #f0f2f5;
}
.oa-user-empty {
  text-align: center;
  color: #999;
  padding: 24px;
  font-size: 13px;
}
// è¯¦æƒ… / å®¡æ‰¹å¤„理等页底部操作栏
.oa-page-footer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: 100;
  display: flex;
  flex-direction: row;
  flex-wrap: nowrap;
  align-items: center;
  gap: 10px;
  padding: 10px 16px calc(10px + env(safe-area-inset-bottom));
  background: #fff;
  border-top: 1px solid #eef0f3;
  box-shadow: 0 -4px 16px rgba(15, 23, 42, 0.08);
}
.oa-footer-btn {
  flex: 1;
  min-width: 0;
  height: 44px;
  line-height: 44px;
  text-align: center;
  font-size: 15px;
  font-weight: 500;
  border-radius: 22px;
  border: none;
  &:active {
    opacity: 0.85;
  }
  &.btn-default {
    color: #666;
    background: #f2f4f7;
  }
  &.btn-primary {
    color: #fff;
    background: linear-gradient(140deg, #00baff 0%, #006cfb 100%);
    box-shadow: 0 4px 10px rgba(0, 108, 251, 0.25);
  }
  &.btn-warn {
    color: #fff;
    background: linear-gradient(140deg, #ffb347 0%, #ff9800 100%);
    box-shadow: 0 4px 10px rgba(255, 152, 0, 0.25);
  }
  &.btn-success {
    color: #fff;
    background: linear-gradient(140deg, #52c41a 0%, #389e0d 100%);
    box-shadow: 0 4px 10px rgba(56, 158, 13, 0.25);
  }
  &.btn-danger {
    color: #fff;
    background: linear-gradient(140deg, #ff7875 0%, #f5222d 100%);
    box-shadow: 0 4px 10px rgba(245, 34, 45, 0.2);
  }
  &.is-disabled {
    opacity: 0.5;
    pointer-events: none;
  }
}
.oa-detail-page {
  min-height: 100vh;
  background: #f2f4f7;
  display: flex;
  flex-direction: column;
}
.oa-detail-scroll {
  flex: 1;
  height: 0;
  padding: 10px 12px;
  padding-bottom: calc(76px + env(safe-area-inset-bottom));
  box-sizing: border-box;
}
src/pages/oa/_utils/approvalModuleApplyExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,293 @@
import dayjs from "dayjs";
import {
  APPROVAL_MODULE_KEYS,
  APPROVAL_MODULE_REGISTRY,
  getModuleMatchingBusinessTypes,
} from "./approvalModuleRegistry.js";
import { matchBusinessTypeValue } from "./approvalTemplateType.js";
import { parseDatetimerangeValue } from "./approvalFormField.js";
/** äººå‘˜ä¸‹æ‹‰å­—段识别(与 Web SELECT_OPTION_SOURCE.USER ç­‰ä»·ï¼‰ */
export function isUserSelectField(field) {
  const src = String(field?.optionSource ?? "").toLowerCase();
  return (
    src === "user" ||
    src === "personnel" ||
    src === "userlist" ||
    (field?.type === "select" && String(field?.label || "").includes("申请人"))
  );
}
export function findApplicantTemplateField(fields = []) {
  return (
    fields.find(f => String(f?.label || "").includes("申请人")) ||
    fields.find(f => isUserSelectField(f)) ||
    null
  );
}
/* ---------- è¯·å‡ ---------- */
export function isLeaveBalanceField(field) {
  const label = String(field?.label || "");
  return label.includes("假期余额") || field?.key === "leaveBalanceDays";
}
export function isLeaveDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("请假时长") || field?.key === "leaveDurationDays";
}
export function displayLeaveTemplateFields(fields = []) {
  return (fields || []).filter(
    f => !isLeaveBalanceField(f) && !isLeaveDurationField(f)
  );
}
export function findLeaveTimeTemplateField(fields = []) {
  return (
    fields.find(
      f => f?.type === "datetimerange" && String(f?.label || "").includes("请假时间")
    ) ||
    fields.find(f => f?.type === "datetimerange" && f?.key === "dateRange") ||
    fields.find(f => f?.type === "datetimerange") ||
    null
  );
}
export function resolveTimeRangeFromPayload(payload, timeField) {
  if (!timeField?.key) return { start: "", end: "" };
  const val = payload?.[timeField.key];
  if (Array.isArray(val) && val.length >= 2) {
    return { start: val[0] || "", end: val[1] || "" };
  }
  return parseDatetimerangeValue(val);
}
export 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;
}
export function computeLeaveDurationDisplay(fields, formPayload) {
  const timeField = findLeaveTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField);
  const d = computeLeaveDays(start, end);
  return d == null ? "" : String(d);
}
export function validateLeaveBeforeSubmit(fields, formPayload) {
  const timeField = findLeaveTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, timeField);
  if (computeLeaveDays(start, end) == null) {
    return "请检查模板中的请假时间,结束时间须晚于开始时间";
  }
  return "";
}
/* ---------- åŠ ç­ ---------- */
export function isOvertimeDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("加班时长") || field?.key === "overtimeHours";
}
export function displayOvertimeTemplateFields(fields = []) {
  return (fields || []).filter(f => !isOvertimeDurationField(f));
}
export function findOvertimeTimeTemplateField(fields = []) {
  return (
    fields.find(
      f => f?.type === "datetimerange" && String(f?.label || "").includes("加班时间")
    ) ||
    fields.find(f => f?.type === "datetimerange") ||
    null
  );
}
export function computeOvertimeHours(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  return Math.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100;
}
export function computeOvertimeHoursDisplay(fields, formPayload) {
  const field = findOvertimeTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, field);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
}
export function validateOvertimeBeforeSubmit(fields, formPayload) {
  const field = findOvertimeTimeTemplateField(fields);
  const { start, end } = resolveTimeRangeFromPayload(formPayload, field);
  if (computeOvertimeHours(start, end) == null) {
    return "请检查模板中的加班时间,结束时间须晚于开始时间";
  }
  return "";
}
/* ---------- è°ƒå²— ---------- */
export function isOriginalPostField(field) {
  const label = String(field?.label || "");
  return (
    label.includes("原岗位") ||
    field?.key === "originalPost" ||
    field?.key === "originalPostName" ||
    field?.key === "originalPostId"
  );
}
export function displayTransferTemplateFields(fields = []) {
  return (fields || []).filter(f => !isOriginalPostField(f));
}
export function unwrapUserArray(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 [];
}
export function isActiveUser(u) {
  if (u?.delFlag === "2" || u?.delFlag === 2) return false;
  if (u?.status == null) return true;
  return String(u.status) === "0";
}
export function 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;
}
export function buildPostIdToNameMap(postRows = []) {
  const m = {};
  for (const p of postRows) {
    const id = p.postId ?? p.value ?? p.id;
    if (id != null && id !== "") {
      m[String(id)] = p.postName ?? p.label ?? p.name ?? "";
    }
  }
  return m;
}
export function resolveOriginalPostName(user, postIdToName = {}) {
  if (!user) return "";
  const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
  if (nameStr) return nameStr;
  if (Array.isArray(user.posts) && user.posts.length) {
    return (user.posts[0].postName ?? "").toString() || "未命名岗位";
  }
  const pid = firstPostId(user);
  if (pid != null && pid !== "") {
    const n = postIdToName[String(pid)] || "";
    return n || "当前岗位(未在岗位字典中)";
  }
  return "未分配岗位";
}
export function userById(users, id) {
  if (id == null || id === "") return undefined;
  return (users || []).find(u => String(u.userId ?? u.id) === String(id));
}
/** æŒ‰ moduleKey è¿‡æ»¤æ¨¡æ¿å¡«æŠ¥é¡¹ */
export function displayTemplateFieldsByModule(moduleKey, fields = []) {
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    return displayLeaveTemplateFields(fields);
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
    return displayOvertimeTemplateFields(fields);
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
    return displayTransferTemplateFields(fields);
  }
  return fields || [];
}
/** ä¿å­˜å‰å°†ä¸šåŠ¡æ‰©å±•å­—æ®µå†™å…¥ formValues */
export function syncModuleExtrasToFormValues(moduleKey, formValues, extras, fields) {
  if (!moduleKey || !formValues) return;
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    if (extras.leaveBalanceDays != null && extras.leaveBalanceDays !== "") {
      formValues.leaveBalanceDays = extras.leaveBalanceDays;
    }
    const days = computeLeaveDurationDisplay(fields, formValues);
    if (days) formValues.leaveDurationDays = days;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
    const hours = computeOvertimeHoursDisplay(fields, formValues);
    if (hours) formValues.overtimeHours = hours;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER && extras.originalPostName) {
    formValues.originalPostName = extras.originalPostName;
    formValues.originalPost = extras.originalPostName;
  }
}
/** ä¸šåŠ¡æ‰©å±•æ ¡éªŒ */
export function validateModuleExtras(moduleKey, fields, formPayload, extras) {
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    if (
      extras.leaveBalanceDays == null ||
      extras.leaveBalanceDays === "" ||
      Number.isNaN(Number(extras.leaveBalanceDays))
    ) {
      return "请填写假期余额";
    }
    const msg = validateLeaveBeforeSubmit(fields, formPayload);
    if (msg) return msg;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.OVERTIME) {
    const msg = validateOvertimeBeforeSubmit(fields, formPayload);
    if (msg) return msg;
  }
  return "";
}
/** ä»Žå®žä¾‹ businessType æŽ¨æ–­ moduleKey(编辑入口未带 moduleKey æ—¶ï¼‰ */
export function inferModuleKeyFromRow(row, typeOptions = []) {
  const bt = row?.businessType;
  if (bt == null || bt === "") return "";
  for (const key of Object.values(APPROVAL_MODULE_KEYS)) {
    const types = getModuleMatchingBusinessTypes(key, typeOptions);
    if (types.some(t => matchBusinessTypeValue(t, bt))) return key;
    const cfg = APPROVAL_MODULE_REGISTRY[key];
    if (cfg && matchBusinessTypeValue(cfg.approvalType, bt)) return key;
  }
  return "";
}
/** ç¼–辑回显:从实例行恢复扩展字段 */
export function loadModuleExtrasFromRow(moduleKey, row, formPayload) {
  const extras = {
    leaveBalanceDays: undefined,
    originalPostName: "",
  };
  if (!moduleKey || !row) return extras;
  const payload = formPayload || {};
  if (moduleKey === APPROVAL_MODULE_KEYS.LEAVE) {
    const v = payload.leaveBalanceDays ?? row.leaveBalanceDays;
    extras.leaveBalanceDays =
      v != null && v !== "" ? Number(v) : undefined;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.TRANSFER) {
    extras.originalPostName =
      payload.originalPostName ||
      payload.originalPost ||
      row.originalPostName ||
      "";
  }
  return extras;
}
src/pages/oa/_utils/approvalModuleListSearch.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,319 @@
import {
  APPROVAL_MODULE_KEYS,
  APPROVAL_MODULE_REGISTRY,
  getApprovalModuleConfig,
  getModuleMatchingBusinessTypes,
} from "./approvalModuleRegistry.js";
import { parseApprovalFormConfig } from "./approvalFormField.js";
import { matchBusinessTypeValue } from "./approvalTemplateType.js";
/** ä¸Ž Web leave-apply LEAVE_TYPE_OPTIONS ä¸€è‡´ */
export 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" },
];
/** ä¸Ž Web overtime-apply OVERTIME_TYPE_OPTIONS ä¸€è‡´ */
export const OVERTIME_TYPE_OPTIONS = [
  { label: "工作日加班", value: "weekday" },
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
export const HANDOVER_STATUS_OPTIONS = [
  { label: "进行中", value: "in_progress" },
  { label: "已完成", value: "completed" },
  { label: "已退回", value: "returned" },
];
export const HANDOVER_TYPE_OPTIONS = [
  { label: "离职交接", value: "resignation" },
  { label: "调岗交接", value: "transfer" },
];
function buildFormPayloadFromFields(fields = []) {
  const payload = {};
  for (const f of fields) {
    if (!f?.key) continue;
    const val = f.value ?? f.defaultValue;
    if (val !== undefined && val !== null && val !== "") {
      payload[f.key] = val;
    }
  }
  return payload;
}
/** è§£æžå®žä¾‹ formConfig / formPayload(与 Web resolveInstanceFormFields å¯¹é½ï¼‰ */
export function resolveInstanceFormPayload(row) {
  const cfg = parseApprovalFormConfig(row?.formConfig);
  const fields = (row?.formFieldDefs?.length ? row.formFieldDefs : cfg.fields) || [];
  const formPayload = {
    ...(fields.length ? buildFormPayloadFromFields(fields) : {}),
    ...cfg.formPayload,
    ...(row?.formPayload || {}),
  };
  return { fields, formPayload };
}
export function getRowPayloadValue(row, keys) {
  const keyList = Array.isArray(keys) ? keys : [keys];
  const { formPayload } = resolveInstanceFormPayload(row);
  for (const k of keyList) {
    if (row?.[k] != null && row[k] !== "") return row[k];
    if (formPayload[k] != null && formPayload[k] !== "") return formPayload[k];
  }
  return "";
}
function pickDateRange(searchForm) {
  const range =
    searchForm?.createTimeRange ??
    searchForm?.applyDateRange ??
    searchForm?.transferDateRange;
  if (!Array.isArray(range) || !range[0]) return {};
  const out = { createTimeStart: range[0] };
  if (range[1]) out.createTimeEnd = range[1];
  return out;
}
/** å„模块默认查询表单(与 Web searchForm å­—段一致) */
export function createModuleSearchForm(moduleKey) {
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.REGULAR:
      return { applicantName: "", applyDateRange: null };
    case APPROVAL_MODULE_KEYS.TRANSFER:
      return { applicantId: "", transferDateRange: null };
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      return { applicantId: "", handoverStatus: "", handoverType: "" };
    case APPROVAL_MODULE_KEYS.LEAVE:
      return { applicantKeyword: "", leaveType: "" };
    case APPROVAL_MODULE_KEYS.OVERTIME:
      return { applicantKeyword: "", overtimeType: "" };
    default:
      return {};
  }
}
/** æœåŠ¡ç«¯ listPage DTO ç‰‡æ®µï¼ˆä¸Ž Web buildExtraListParams + buildApprovalInstanceListParams ä¸€è‡´ï¼‰ */
export function buildModuleListDto(moduleKey, searchForm = {}) {
  const sf = searchForm || {};
  const dto = { ...pickDateRange(sf) };
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.REGULAR: {
      const name = (sf.applicantName || "").trim();
      if (name) dto.applicantName = name;
      break;
    }
    case APPROVAL_MODULE_KEYS.TRANSFER:
      break;
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      break;
    case APPROVAL_MODULE_KEYS.LEAVE:
    case APPROVAL_MODULE_KEYS.OVERTIME:
      break;
    default:
      break;
  }
  return dto;
}
function matchApplicantKeyword(row, keyword) {
  const kw = (keyword || "").trim().toLowerCase();
  if (!kw) return true;
  const parts = [
    row?.applicantName,
    row?.applicantNo,
    row?.applicantId,
    getRowPayloadValue(row, ["applicant", "applicantName", "applicantId"]),
  ]
    .filter(v => v != null && v !== "")
    .map(v => String(v).toLowerCase());
  return parts.some(p => p.includes(kw));
}
function matchSelectValue(row, keys, expected) {
  if (!expected) return true;
  const raw = getRowPayloadValue(row, keys);
  return String(raw) === String(expected);
}
function matchApplicantId(row, applicantId) {
  if (!applicantId) return true;
  const id = String(applicantId);
  if (row?.applicantId != null && String(row.applicantId) === id) return true;
  const payloadApplicant = getRowPayloadValue(row, [
    "applicant",
    "applicantId",
    "applicantUserId",
  ]);
  return String(payloadApplicant) === id;
}
/** æŒ‰æ¨¡å— businessType / æ ‡é¢˜å½’属过滤(服务端未生效时的兜底) */
export function filterRowsByModuleBusinessType(moduleKey, rows, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return rows;
  const types = getModuleMatchingBusinessTypes(moduleKey, typeOptions);
  const myLabels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  return (rows || []).filter(row => {
    if (types.length && row?.businessType != null && row.businessType !== "") {
      if (types.some(t => matchBusinessTypeValue(row.businessType, t))) {
        return true;
      }
    }
    const title = String(row?.title || row?.templateName || "").trim();
    if (title) {
      if (myLabels.some(l => title === l || title.includes(l))) return true;
      for (const [key, other] of Object.entries(APPROVAL_MODULE_REGISTRY)) {
        if (key === moduleKey) continue;
        const otherLabels = [other.label, ...(other.typeLabels || [])].filter(Boolean);
        if (otherLabels.some(l => title === l || (l.length > 2 && title.includes(l)))) {
          return false;
        }
      }
    }
    return types.length === 0;
  });
}
/** å‰ç«¯ç­›é€‰ï¼ˆWeb æœªä¸‹å‘接口的字段与 Web è¡Œä¸ºä¸€è‡´ï¼‰ */
export function filterRowsByModuleSearch(moduleKey, rows, searchForm = {}) {
  const sf = searchForm || {};
  const list = Array.isArray(rows) ? rows : [];
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.TRANSFER:
      return list.filter(
        row =>
          matchApplicantId(row, sf.applicantId) &&
          matchApplicantKeyword(row, sf.applicantKeyword)
      );
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      return list.filter(
        row =>
          matchApplicantId(row, sf.applicantId) &&
          matchSelectValue(row, ["handoverStatus", "交接状态"], sf.handoverStatus) &&
          matchSelectValue(row, ["handoverType", "交接类型"], sf.handoverType)
      );
    case APPROVAL_MODULE_KEYS.LEAVE:
      return list.filter(
        row =>
          matchApplicantKeyword(row, sf.applicantKeyword) &&
          matchSelectValue(row, ["leaveType", "请假类型"], sf.leaveType)
      );
    case APPROVAL_MODULE_KEYS.OVERTIME:
      return list.filter(
        row =>
          matchApplicantKeyword(row, sf.applicantKeyword) &&
          matchSelectValue(row, ["overtimeType", "加班类型"], sf.overtimeType)
      );
    default:
      return list;
  }
}
/** æ¨¡å—筛选 UI é…ç½® */
export function getModuleSearchMeta(moduleKey) {
  switch (moduleKey) {
    case APPROVAL_MODULE_KEYS.REGULAR:
      return {
        fields: [
          { key: "applicantName", type: "input", label: "申请人", placeholder: "请输入申请人" },
          { key: "applyDateRange", type: "daterange", label: "申请日期" },
        ],
      };
    case APPROVAL_MODULE_KEYS.TRANSFER:
      return {
        fields: [
          { key: "applicantId", type: "user", label: "申请人", placeholder: "请选择申请人" },
          { key: "transferDateRange", type: "daterange", label: "转岗时间" },
        ],
      };
    case APPROVAL_MODULE_KEYS.WORK_HANDOVER:
      return {
        fields: [
          { key: "applicantId", type: "user", label: "申请人", placeholder: "请选择申请人" },
          {
            key: "handoverStatus",
            type: "select",
            label: "交接状态",
            options: HANDOVER_STATUS_OPTIONS,
          },
          {
            key: "handoverType",
            type: "select",
            label: "交接类型",
            options: HANDOVER_TYPE_OPTIONS,
          },
        ],
      };
    case APPROVAL_MODULE_KEYS.LEAVE:
      return {
        fields: [
          {
            key: "applicantKeyword",
            type: "input",
            label: "申请人",
            placeholder: "姓名或编号",
          },
          {
            key: "leaveType",
            type: "select",
            label: "请假类型",
            options: LEAVE_TYPE_OPTIONS,
          },
        ],
      };
    case APPROVAL_MODULE_KEYS.OVERTIME:
      return {
        fields: [
          {
            key: "applicantKeyword",
            type: "input",
            label: "申请人",
            placeholder: "姓名或编号",
          },
          {
            key: "overtimeType",
            type: "select",
            label: "加班类型",
            options: OVERTIME_TYPE_OPTIONS,
          },
        ],
      };
    default:
      return { fields: [] };
  }
}
export function resetModuleSearchForm(moduleKey, target) {
  const defaults = createModuleSearchForm(moduleKey);
  Object.keys(target).forEach(k => {
    if (!(k in defaults)) delete target[k];
  });
  Object.assign(target, defaults);
}
export function formatDateRangeLabel(range) {
  if (!Array.isArray(range) || !range[0]) return "";
  if (range[1]) return `${range[0]} è‡³ ${range[1]}`;
  return range[0];
}
export 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 ?? ""}`;
}
src/pages/oa/_utils/approvalModuleRegistry.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,137 @@
/** ä¸Ž Web approvalModuleRegistry ä¸€è‡´ */
export const APPROVAL_MODULE_KEYS = {
  REGULAR: "regular",
  TRANSFER: "transfer",
  WORK_HANDOVER: "work_handover",
  LEAVE: "leave",
  OVERTIME: "overtime",
};
export const APPROVAL_MODULE_REGISTRY = {
  [APPROVAL_MODULE_KEYS.REGULAR]: {
    label: "转正申请",
    approvalType: "regular",
    typeLabels: ["转正", "转正申请"],
    listFields: [
      { label: "入职日期", prop: "entryDate" },
      { label: "转正日期", prop: "regularDate" },
    ],
  },
  [APPROVAL_MODULE_KEYS.TRANSFER]: {
    label: "调岗申请",
    approvalType: "transfer",
    typeLabels: ["调岗", "调动", "调岗申请", "调动申请"],
    listFields: [
      { label: "原岗位", prop: "fromPost" },
      { label: "目标岗位", prop: "toPost" },
    ],
  },
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
    label: "工作交接",
    approvalType: "work_handover",
    typeLabels: ["工作交接", "交接", "工作交接审批"],
    listFields: [
      { label: "交接人", prop: "handoverTo" },
      { label: "交接事项", prop: "handoverItems" },
    ],
  },
  [APPROVAL_MODULE_KEYS.LEAVE]: {
    label: "请假申请",
    approvalType: "leave",
    typeLabels: ["请假", "请假申请", "请假审批"],
    listFields: [
      { label: "请假类型", prop: "leaveType" },
      { label: "开始时间", prop: "startTime" },
      { label: "结束时间", prop: "endTime" },
    ],
  },
  [APPROVAL_MODULE_KEYS.OVERTIME]: {
    label: "加班申请",
    approvalType: "overtime",
    typeLabels: ["加班", "加班申请", "加班审批"],
    listFields: [
      { label: "加班日期", prop: "overtimeDate" },
      { label: "时长(小时)", prop: "hours" },
    ],
  },
};
export function getApprovalModuleConfig(moduleKey) {
  if (!moduleKey) return null;
  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
}
export function getModuleListBusinessType(moduleKey) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return "";
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  return cfg.approvalType || "";
}
function matchBiz(a, b) {
  if (a == null || a === "" || b == null || b === "") return false;
  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
}
export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return null;
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  const hitByLabel = (typeOptions || []).find(opt => {
    const optLabel = String(opt?.name || opt?.label || "").trim();
    if (!optLabel) return false;
    return labels.some(
      l => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
  });
  if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value;
  if (cfg.approvalType) {
    const hitByValue = (typeOptions || []).find(
      opt =>
        matchBiz(opt?.value, cfg.approvalType) || matchBiz(opt?.code, cfg.approvalType)
    );
    if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value;
  }
  return cfg.approvalType || null;
}
export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return [];
  const values = new Set();
  const primary = resolveModuleBusinessType(moduleKey, typeOptions);
  if (primary != null && primary !== "") values.add(primary);
  if (cfg.approvalType) values.add(cfg.approvalType);
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  for (const opt of typeOptions || []) {
    const optLabel = String(opt?.name || opt?.label || "").trim();
    if (!optLabel) continue;
    const matched = labels.some(
      l => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
    if (matched && opt.value != null && opt.value !== "") {
      values.add(opt.value);
    }
  }
  return [...values];
}
/** åˆ—表页 moduleKey ä¸Žè·¯ç”± pageKey å¯¹ç…§ */
export const PAGE_KEY_TO_MODULE = {
  "HrManage/regular-apply": APPROVAL_MODULE_KEYS.REGULAR,
  "HrManage/transfer-apply": APPROVAL_MODULE_KEYS.TRANSFER,
  "HrManage/work-handover": APPROVAL_MODULE_KEYS.WORK_HANDOVER,
  "AttendManage/leave-apply": APPROVAL_MODULE_KEYS.LEAVE,
  "AttendManage/overtime-apply": APPROVAL_MODULE_KEYS.OVERTIME,
};
export function getModuleKeyFromPageKey(pageKey) {
  return PAGE_KEY_TO_MODULE[pageKey] || "";
}
src/pages/oa/_utils/approvalTemplateType.js
@@ -1,4 +1,5 @@
import { getTypeEnums } from "@/api/basic/enum.js";
import { listApprovalTemplateByType } from "@/api/oa/approvalTemplate.js";
/**
 * GET /approvalTemplate/list/{type} è·¯å¾„参数为 templateType
@@ -19,58 +20,120 @@
  { name: "请假管理", value: 2 },
];
/** è§£æž TypeEnums æŽ¥å£è¿”回(兼容 { TypeEnums: [] } åµŒå¥—) */
export function unwrapEnumList(data) {
  if (Array.isArray(data)) return data;
  if (!data || typeof data !== "object") return [];
  if (Array.isArray(data.TypeEnums)) return data.TypeEnums;
  if (Array.isArray(data.typeEnums)) return data.typeEnums;
  const nested = Object.values(data).find(v => Array.isArray(v));
  return nested || [];
}
/** è§£æžæ¨¡æ¿åˆ—表接口返回 */
export function unwrapTemplateList(payload) {
  const data = payload?.data ?? payload;
  if (Array.isArray(data)) return data;
  if (Array.isArray(data?.records)) return data.records;
  if (Array.isArray(data?.list)) return data.list;
  if (Array.isArray(data?.rows)) return data.rows;
  return [];
}
/** enabled:1 / true ä¸ºå¯ç”¨ï¼ˆä¸Ž Web mapEnabledFromApi ä¸€è‡´ï¼‰ */
export function mapEnabledFromApi(enabled) {
  if (enabled === undefined || enabled === null) return true;
  if (enabled === true || enabled === 1) return true;
  const s = String(enabled).toLowerCase();
  return s === "1" || s === "true" || s === "yes";
}
export function filterEnabledTemplates(list) {
  return (list || []).filter(row => mapEnabledFromApi(row?.enabled));
}
/** å°† /basic/enum/TypeEnums å“åº”规范为 { name, value }[] */
export function normalizeEnumOptions(data) {
  if (!data) return [];
  if (Array.isArray(data)) {
    return data
      .map(item => {
        const name =
          item?.name ??
          item?.label ??
          item?.text ??
          item?.dictLabel ??
          item?.description;
        const rawValue =
          item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id;
        if (name == null || rawValue === undefined || rawValue === null) {
          return null;
        }
        const num = Number(rawValue);
        return {
          name: String(name),
          value: Number.isNaN(num) ? rawValue : num,
        };
      })
      .filter(Boolean);
  }
  if (typeof data === "object") {
    return Object.entries(data).map(([value, name]) => {
      const num = Number(value);
  return unwrapEnumList(data)
    .map(item => {
      const name =
        item?.name ??
        item?.label ??
        item?.text ??
        item?.dictLabel ??
        item?.description;
      const rawValue =
        item?.value ?? item?.code ?? item?.dictValue ?? item?.key ?? item?.id;
      if (name == null || rawValue === undefined || rawValue === null) {
        return null;
      }
      const num = Number(rawValue);
      return {
        name: String(name),
        value: Number.isNaN(num) ? value : num,
        value: Number.isNaN(num) ? rawValue : num,
      };
    });
  }
  return [];
    })
    .filter(Boolean);
}
/** æ‹‰å–业务类型枚举(TypeEnums â†’ businessType) */
export async function fetchApprovalTemplateTypes() {
  const res = await getTypeEnums();
  const options = normalizeEnumOptions(res?.data);
  return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
  try {
    const res = await getTypeEnums();
    const options = normalizeEnumOptions(res?.data ?? res);
    return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
  } catch {
    return [...FALLBACK_BUSINESS_TYPE_OPTIONS];
  }
}
/** é€‰å–第一个「有模板」的 Tab ä¸‹æ ‡ */
export function pickTabIndexWithTemplates(typeOptions, templates) {
  if (!typeOptions?.length) return 0;
  for (let i = 0; i < typeOptions.length; i++) {
    const bt = typeOptions[i]?.value;
    if (filterTemplatesByBusinessType(templates, bt).length > 0) return i;
  }
  return 0;
}
/** æ‹‰å–已启用模板(自定义 + ç³»ç»Ÿå†…置,与 Web å¯¼å…¥é€»è¾‘一致) */
export async function fetchEnabledApprovalTemplates() {
  const [customRes, builtinRes] = await Promise.all([
    listApprovalTemplateByType(CUSTOM_TEMPLATE_LIST_TYPE),
    listApprovalTemplateByType(SYSTEM_TEMPLATE_TYPE),
  ]);
  const merged = [
    ...unwrapTemplateList(customRes),
    ...unwrapTemplateList(builtinRes),
  ];
  const byId = new Map();
  merged.forEach(item => {
    if (item?.id != null) byId.set(String(item.id), item);
  });
  return filterEnabledTemplates([...byId.values()]);
}
/** businessType å®½æ¾åŒ¹é…ï¼ˆä¸Ž Web matchBusinessTypeValue ä¸€è‡´ï¼‰ */
export function matchBusinessTypeValue(a, b) {
  if (a == null || a === "" || b == null || b === "") return false;
  return a === b || a === Number(b) || Number(a) === b || String(a) === String(b);
}
/** æŒ‰ businessType ç­›é€‰æ¨¡æ¿ */
export function filterTemplatesByBusinessType(templates, businessType) {
  if (businessType == null || businessType === "") return [];
  return (templates || []).filter(
    item => String(item.businessType) === String(businessType)
  return (templates || []).filter(item =>
    matchBusinessTypeValue(item.businessType, businessType)
  );
}
/** æŒ‰å¤šä¸ª businessType ç­›é€‰ï¼ˆä¸šåŠ¡æ¨¡å—ï¼‰ */
export function filterTemplatesByBusinessTypes(templates, businessTypes = []) {
  const types = (businessTypes || []).filter(t => t != null && t !== "");
  if (!types.length) return [];
  return (templates || []).filter(item =>
    types.some(t => matchBusinessTypeValue(item.businessType, t))
  );
}
@@ -84,6 +147,20 @@
  return idx >= 0 ? idx : 0;
}
/** TypeEnums ä¸ºç©ºæ—¶ï¼Œä»Žæ¨¡æ¿åˆ—表反推 Tab */
export function buildTypeOptionsFromTemplates(templates) {
  const map = new Map();
  (templates || []).forEach(item => {
    const v = item?.businessType;
    if (v == null || v === "") return;
    const key = String(v);
    if (!map.has(key)) {
      map.set(key, { name: `审批类型 ${key}`, value: v });
    }
  });
  return [...map.values()];
}
export function buildTypeLabelMap(options) {
  const map = {};
  (options || []).forEach(opt => {
src/pages/oa/_utils/approveListUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,327 @@
import { parseTime } from "@/utils/ruoyi";
import {
  formatFieldDisplayValue,
  getFieldOptionLabel,
  isSelectField,
  mergeFormConfigForEdit,
} from "./approvalFormField.js";
export const DETAIL_STORAGE_KEY = "oa_approve_instance_detail_row";
export const INSTANCE_STATUS_TEXT = {
  PENDING: "进行中",
  APPROVED: "已通过",
  REJECTED: "已驳回",
  DRAFT: "草稿",
};
export const INSTANCE_STATUS_TAG = {
  PENDING: "warning",
  APPROVED: "success",
  REJECTED: "error",
  DRAFT: "info",
};
export const TASK_STATUS_TEXT = {
  PENDING: "待处理",
  APPROVED: "已通过",
  REJECTED: "已驳回",
};
export const TASK_STATUS_TAG = {
  PENDING: "warning",
  APPROVED: "success",
  REJECTED: "error",
};
export function instanceStatusText(status) {
  return INSTANCE_STATUS_TEXT[status] || status || "-";
}
export function instanceStatusTagType(status) {
  return INSTANCE_STATUS_TAG[status] || "info";
}
export function taskStatusText(status) {
  const key = String(status || "").toUpperCase();
  return TASK_STATUS_TEXT[key] || status || "待处理";
}
export function taskStatusTagType(status) {
  const key = String(status || "").toUpperCase();
  return TASK_STATUS_TAG[key] || "info";
}
export function formatDateTime(val) {
  if (!val) return "-";
  return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
}
/** è§£æžå®žä¾‹ formConfig ä¸ºåªè¯»å±•示字段 */
export function resolveInstanceDisplayFields(formConfig) {
  const merged = mergeFormConfigForEdit("", formConfig);
  return (merged.fields || []).filter(f => f?.key);
}
export function displayFieldValue(field) {
  const val = field.value ?? field.defaultValue;
  if (val === undefined || val === null || val === "") return "-";
  if (isSelectField(field)) {
    return getFieldOptionLabel(field, val) || String(val);
  }
  const shown = formatFieldDisplayValue(field, val);
  return shown || String(val);
}
/** å®¡æ‰¹è®°å½• result:approved | rejected | pending */
export function mapRecordResult(action) {
  const s = String(action || "").toUpperCase();
  if (s === "APPROVED" || s === "APPROVE" || s === "PASS") return "approved";
  if (s === "REJECTED" || s === "REJECT" || s === "REFUSE") return "rejected";
  return "pending";
}
export function recordActionLabel(result) {
  if (result === "approved") return "通过";
  if (result === "rejected") return "驳回";
  return "待处理";
}
export function mapApprovalRecords(records) {
  const list = Array.isArray(records) ? records : [];
  return list.map((r, index) => ({
    id: r.id ?? index,
    operatorName: r.approverName || r.operatorName || r.createUserName || "—",
    result: mapRecordResult(r.approveAction ?? r.action ?? r.status),
    opinion: r.approveComment || r.comment || r.opinion || "",
    time: formatDateTime(r.approveTime || r.createTime || r.time),
  }));
}
export function getRejectReasonFromRecords(records) {
  const mapped = mapApprovalRecords(records);
  const hit = mapped.find(r => r.result === "rejected");
  return hit?.opinion || "";
}
/** åˆ—表 tasks â†’ æµç¨‹èŠ‚ç‚¹ï¼ˆä¸Ž apply é¡µèŠ‚ç‚¹ç»“æž„æŽ¥è¿‘ï¼‰ */
export function mapTasksToFlowNodes(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  if (!list.length) return [];
  const byLevel = new Map();
  list.forEach(t => {
    const level = Number(t.levelNo ?? t.taskLevel ?? t.nodeOrder ?? 1);
    if (!byLevel.has(level)) {
      byLevel.set(level, {
        levelNo: level,
        approveType: t.approveType || "AND",
        approvers: [],
      });
    }
    const node = byLevel.get(level);
    node.approvers.push({
      approverName: t.approverName || "—",
      taskStatus: t.taskStatus ?? t.status,
      approveComment: t.approveComment,
      approveTime: t.approveTime,
    });
    if (t.approveType) node.approveType = t.approveType;
  });
  return [...byLevel.entries()]
    .sort(([a], [b]) => a - b)
    .map(([, node]) => node);
}
/** ç»„装审批提交 DTO(与 Web buildApproveInstanceDto ä¸€è‡´ï¼‰ */
export function buildApproveInstanceDto(id, uiResult, comment) {
  const opinion = (comment || "").trim();
  return {
    id,
    approveAction: uiResult === "rejected" ? "REJECTED" : "APPROVED",
    approveComment: opinion || (uiResult === "approved" ? "同意" : ""),
  };
}
/** æ˜¯å¦æœ¬äººå‘起的审批 */
export function isOwnApplication(item, userStore) {
  const uid = userStore?.id;
  if (item?.applicantId != null && uid != null && uid !== "") {
    return String(item.applicantId) === String(uid);
  }
  const loginName = userStore?.nickName || userStore?.name;
  if (loginName && item?.applicantName) {
    return String(item.applicantName).trim() === String(loginName).trim();
  }
  return false;
}
/** ä»…进行中且本人发起时可编辑 */
export function canModifyInstance(item, userStore) {
  return item?.status === "PENDING" && isOwnApplication(item, userStore);
}
/** å¾…当前用户审批 */
export function canApproveInstance(item) {
  return Boolean(item?.isApprove) && item?.status === "PENDING";
}
export function stashInstanceRow(item) {
  if (item) {
    uni.setStorageSync(DETAIL_STORAGE_KEY, item);
  }
}
export function loadInstanceRow(id) {
  const row = uni.getStorageSync(DETAIL_STORAGE_KEY);
  if (!row || String(row.id) !== String(id)) return null;
  return row;
}
export const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
/** ä¸šåŠ¡ç”³è¯·é¡µçŠ¶æ€ï¼šè¿›è¡Œä¸­/已完成不可修改(与 Web canEditBusinessInstanceRow ä¸€è‡´ï¼‰ */
export function normalizeApprovalStatusKey(v) {
  if (v == null || v === "") return "pending";
  const upper = String(v).trim().toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "APPROVED" || upper === "PASS") return "approved";
  if (upper === "REJECTED" || upper === "REJECT" || upper === "REFUSE") {
    return "rejected";
  }
  if (upper === "CANCELLED" || upper === "CANCEL") return "cancelled";
  if (upper === "PENDING" || upper === "IN_PROGRESS") return "pending";
  const lower = String(v).trim().toLowerCase();
  if (["draft", "pending", "approved", "rejected", "cancelled"].includes(lower)) {
    return lower;
  }
  return "pending";
}
export function canEditBusinessInstanceRow(row) {
  const key = normalizeApprovalStatusKey(row?.status ?? row?.approvalStatus);
  return key !== "pending" && key !== "approved";
}
export function businessStatusText(status) {
  const key = normalizeApprovalStatusKey(status);
  if (key === "draft") return "草稿";
  if (key === "pending") return "进行中";
  if (key === "approved") return "已完成";
  if (key === "rejected") return "已驳回";
  if (key === "cancelled") return "已撤销";
  return instanceStatusText(status);
}
export function businessStatusTagType(status) {
  const key = normalizeApprovalStatusKey(status);
  if (key === "approved") return "success";
  if (key === "rejected") return "error";
  if (key === "draft" || key === "cancelled") return "info";
  return "warning";
}
/** OA åˆ—表自定义状态角标 class */
export function businessStatusClass(status) {
  return `status-${normalizeApprovalStatusKey(status)}`;
}
/**
 * ä¸Ž Web buildApprovalInstanceListParams ä¸€è‡´ï¼šæ‰å¹³ query(current/size/businessType/...)
 * å®¡æ‰¹åˆ—表不传 businessType å³æŸ¥å…¨éƒ¨
 */
export function buildInstanceListParams({
  page,
  businessType,
  extraDto = {},
  searchForm,
}) {
  const extra = { ...(extraDto && typeof extraDto === "object" ? extraDto : {}) };
  if (extra.createTime != null && extra.createTimeStart == null) {
    extra.createTimeStart = extra.createTime;
  }
  delete extra.createTime;
  const params = {
    current: page.current,
    size: page.size,
    ...extra,
  };
  const bizType = businessType ?? searchForm?.businessType;
  if (bizType != null && bizType !== "") {
    params.businessType = bizType;
  }
  if (searchForm?.status) {
    params.status = searchForm.status;
  }
  const range =
    searchForm?.createTimeRange ??
    searchForm?.applyDateRange ??
    searchForm?.transferDateRange;
  if (Array.isArray(range) && range[0] && params.createTimeStart == null) {
    params.createTimeStart = range[0];
  }
  if (Array.isArray(range) && range[1] && params.createTimeEnd == null) {
    params.createTimeEnd = range[1];
  }
  return params;
}
export function unwrapInstancePage(res) {
  const data = res?.data ?? res;
  return {
    records: Array.isArray(data?.records) ? data.records : [],
    total: Number(data?.total ?? 0),
  };
}
/** ä»Ž formConfig æå–列表展示字段(label + value) */
export function buildFormDisplayRows(formConfig, listFields = []) {
  const fields = resolveInstanceDisplayFields(formConfig);
  const rows = [];
  const propKeys = (listFields || []).map(f => f.prop).filter(Boolean);
  if (propKeys.length) {
    propKeys.forEach(prop => {
      const hit = fields.find(f => f.key === prop);
      if (hit) {
        rows.push({ label: hit.label, value: displayFieldValue(hit) });
      }
    });
  } else {
    fields.slice(0, 3).forEach(f => {
      rows.push({ label: f.label, value: displayFieldValue(f) });
    });
  }
  return rows;
}
/** åˆ—表行增强(保留原始字段供详情/编辑) */
export function mapInstanceListRow(row, listFields = []) {
  if (!row) return {};
  const displayRows = buildFormDisplayRows(row.formConfig, listFields);
  const extra = {};
  const formFields = resolveInstanceDisplayFields(row.formConfig);
  (listFields || []).forEach(def => {
    if (!def?.prop) return;
    const hit = formFields.find(f => f.key === def.prop);
    extra[def.prop] = hit ? displayFieldValue(hit) : "-";
  });
  const formPayload = {};
  formFields.forEach(f => {
    if (f?.key) formPayload[f.key] = f.value ?? f.defaultValue ?? "";
  });
  return {
    ...row,
    approvalStatus: normalizeApprovalStatusKey(row.status),
    summary: row.title || row.templateName || "",
    createTime: formatDateTime(row.applyTime || row.createTime),
    displayRows,
    formPayload,
    ...extra,
  };
}