yyb
2026-05-19 9d89dedf542a7b9f8e2549c44723771133f79ef2
模板类型变更
已添加5个文件
已修改7个文件
1472 ■■■■■ 文件已修改
src/api/basic/enum.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalInstance.js 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalTemplate.js 11 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/apply.vue 548 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 297 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/template-select.vue 308 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/detail.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/edit.vue 112 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalTemplateType.js 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/basic/enum.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,9 @@
import request from "@/utils/request";
/** å®¡æ‰¹æ¨¡æ¿ç±»åž‹æžšä¸¾ GET /basic/enum/TypeEnums */
export function getTypeEnums() {
  return request({
    url: "/basic/enum/TypeEnums",
    method: "get",
  });
}
src/api/oa/approvalInstance.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,28 @@
import request from "@/utils/request";
/** å®¡æ‰¹å®žä¾‹åˆ†é¡µæŸ¥è¯¢ GET /approvalInstance/listPage */
export function listApprovalInstancePage(params) {
  return request({
    url: "/approvalInstance/listPage",
    method: "get",
    params,
  });
}
/** æ–°å»ºå®¡æ‰¹å®žä¾‹ POST /approvalInstance/save */
export function saveApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/save",
    method: "post",
    data: { approvalInstanceDto },
  });
}
/** å®¡æ ¸ä¸­ä¿®æ”¹å®¡æ‰¹å®žä¾‹ PUT /approvalInstance/update */
export function updateApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/update",
    method: "put",
    data: { approvalInstanceDto },
  });
}
src/api/oa/approvalTemplate.js
@@ -1,5 +1,16 @@
import request from "@/utils/request";
/**
 * æŒ‰ templateType æŸ¥è¯¢å·²å¯ç”¨æ¨¡æ¿åˆ—表(非 businessType)
 * GET /approvalTemplate/list/{templateType}  ä¾‹ï¼šlist/1 = è‡ªå®šä¹‰å·²å¯ç”¨
 */
export function listApprovalTemplateByType(templateType) {
  return request({
    url: `/approvalTemplate/list/${templateType}`,
    method: "get",
  });
}
/** å®¡æ‰¹æ¨¡æ¿åˆ†é¡µæŸ¥è¯¢ */
export function listApprovalTemplatePage(params) {
  return request({
src/config/oaPaths.js
@@ -24,6 +24,8 @@
  saleContract: `/${P}/ContractManage/sale-contract/index`,
  /** å®¡æ‰¹ç®¡ç† */
  approveList: `/${P}/ApproveManage/approve-list/index`,
  approveListTemplateSelect: `/${P}/ApproveManage/approve-list/template-select`,
  approveListApply: `/${P}/ApproveManage/approve-list/apply`,
  approveTemplate: `/${P}/ApproveManage/approve-template/index`,
  approveTemplateEdit: `/${P}/ApproveManage/approve-template/edit`,
  approveTemplateDetail: `/${P}/ApproveManage/approve-template/detail`,
src/pages.json
@@ -1404,6 +1404,20 @@
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/template-select",
      "style": {
        "navigationBarTitleText": "选择审批模板",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/apply",
      "style": {
        "navigationBarTitleText": "发起审批",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/index",
      "style": {
        "navigationBarTitleText": "审批模板",
src/pages/oa/ApproveManage/approve-list/apply.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,548 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å‘起审批
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/apply
-->
<template>
  <view class="approve-apply-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view class="form-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            class="loading-wrap">
        <up-loading-icon mode="circle" />
        <text class="loading-text">加载中...</text>
      </view>
      <template v-else-if="detail">
        <view class="section">
          <view class="section-title">基本信息</view>
          <view class="form-body">
            <view class="form-row">
              <text class="form-label required">审批标题</text>
              <up-input v-model="form.title"
                        placeholder="请输入审批标题"
                        maxlength="100"
                        clearable />
            </view>
            <view class="form-row">
              <text class="form-label">审批模板</text>
              <text class="form-readonly">{{ templateName }}</text>
            </view>
            <view class="form-row">
              <text class="form-label">申请人</text>
              <text class="form-readonly">{{ displayApplicantName }}</text>
            </view>
          </view>
        </view>
        <view class="section">
          <view class="section-title">填报内容</view>
          <view v-if="formConfigData.prompt"
                class="form-prompt">
            {{ formConfigData.prompt }}
          </view>
          <view v-if="formConfigData.fields.length"
                class="form-body">
            <view v-for="field in formConfigData.fields"
                  :key="field.key"
                  class="form-row form-row--field">
              <text class="form-label"
                    :class="{ required: field.required }">{{ field.label }}</text>
              <up-textarea v-if="field.type === 'textarea'"
                           v-model="formValues[field.key]"
                           :placeholder="`请输入${field.label}`"
                           maxlength="500"
                           border="surround"
                           height="80" />
              <view v-else-if="field.type === 'date'"
                    class="date-trigger"
                    @click="openDatePicker(field.key)">
                <up-input :model-value="formValues[field.key]"
                          :placeholder="`请选择${field.label}`"
                          readonly />
              </view>
              <up-input v-else
                        v-model="formValues[field.key]"
                        :type="field.type === 'number' ? 'digit' : 'text'"
                        :placeholder="`请输入${field.label}`"
                        clearable />
            </view>
          </view>
          <view v-else
                class="empty-hint">该模板暂无填报项</view>
        </view>
        <view class="section">
          <view class="section-title">审批流程</view>
          <view v-if="detail.nodes?.length"
                class="flow-list">
            <view v-for="(node, index) in detail.nodes"
                  :key="node.id || index"
                  class="flow-card">
              <view class="flow-card-head">
                <text class="flow-level">第{{ levelLabel(node.levelNo || index + 1) }}级</text>
                <text class="flow-type">{{ approveTypeText(node.approveType) }}</text>
              </view>
              <view class="approver-tags">
                <text v-for="(approver, aIdx) in node.approvers || []"
                      :key="approver.id || aIdx"
                      class="approver-tag">
                  {{ approver.approverName || "-" }}
                </text>
                <text v-if="!(node.approvers || []).length"
                      class="empty-hint inline">暂无审批人</text>
              </view>
            </view>
          </view>
          <view v-else
                class="empty-hint">暂无审批节点</view>
        </view>
      </template>
      <view v-else
            class="empty-wrap">
        <up-empty mode="data"
                  text="未获取到模板详情" />
      </view>
    </scroll-view>
    <FooterButtons v-if="!loading && detail"
                   cancel-text="取消"
                   :confirm-text="confirmText"
                   :loading="submitting"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-popup :show="showDatePicker"
              mode="bottom"
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="date"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
    </up-popup>
  </view>
</template>
<script setup>
  import { computed, reactive, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js";
  import {
    saveApprovalInstance,
    updateApprovalInstance,
  } from "@/api/oa/approvalInstance.js";
  import useUserStore from "@/store/modules/user";
  import { formatDateToYMD, parseTime } from "@/utils/ruoyi";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const userStore = useUserStore();
  const templateId = ref("");
  const instanceId = ref("");
  const instanceRow = ref(null);
  const detail = ref(null);
  const loading = ref(false);
  const submitting = ref(false);
  const formValues = reactive({});
  const form = reactive({ title: "" });
  const showDatePicker = ref(false);
  const datePickerTs = ref(Date.now());
  const activeDateFieldKey = ref("");
  const isEditMode = computed(() => !!instanceId.value);
  const pageTitle = computed(() => (isEditMode.value ? "编辑审批" : "发起审批"));
  const confirmText = computed(() => (isEditMode.value ? "保存" : "提交审批"));
  const applicantName = computed(
    () => userStore.nickName || userStore.name || "-"
  );
  const displayApplicantName = computed(
    () => instanceRow.value?.applicantName || applicantName.value
  );
  const templateName = computed(
    () => detail.value?.templateName || instanceRow.value?.templateName || "-"
  );
  const parseFormConfig = raw => {
    if (!raw) return { prompt: "", fields: [] };
    try {
      const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
      return {
        prompt: obj?.prompt || "",
        fields: Array.isArray(obj?.fields) ? obj.fields : [],
      };
    } catch {
      return { prompt: "", fields: [] };
    }
  };
  const formConfigData = computed(() => {
    const raw = isEditMode.value
      ? instanceRow.value?.formConfig
      : detail.value?.formConfig;
    return parseFormConfig(raw);
  });
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
  const initFormValues = fields => {
    Object.keys(formValues).forEach(key => {
      delete formValues[key];
    });
    fields.forEach(field => {
      if (!field?.key) return;
      formValues[field.key] = field.value ?? field.defaultValue ?? "";
    });
  };
  const openDatePicker = fieldKey => {
    activeDateFieldKey.value = fieldKey;
    const current = formValues[fieldKey];
    datePickerTs.value = current ? new Date(current).getTime() : Date.now();
    showDatePicker.value = true;
  };
  const onDateConfirm = e => {
    const ts = e?.value ?? datePickerTs.value;
    if (activeDateFieldKey.value) {
      formValues[activeDateFieldKey.value] = formatDateToYMD(ts);
    }
    showDatePicker.value = false;
  };
  const validateForm = () => {
    if (!form.title?.trim()) {
      uni.showToast({ title: "请输入审批标题", icon: "none" });
      return false;
    }
    for (const field of formConfigData.value.fields) {
      if (!field.required) continue;
      const val = formValues[field.key];
      if (val === undefined || val === null || String(val).trim() === "") {
        uni.showToast({ title: `请填写${field.label}`, icon: "none" });
        return false;
      }
    }
    if (!detail.value?.nodes?.length) {
      uni.showToast({ title: "模板未配置审批流程", icon: "none" });
      return false;
    }
    return true;
  };
  const buildFormConfigPayload = () =>
    JSON.stringify({
      prompt: formConfigData.value.prompt,
      fields: formConfigData.value.fields.map(field => ({
        ...field,
        value: formValues[field.key] ?? "",
      })),
    });
  const buildSavePayload = () => ({
    templateId: detail.value.id,
    templateName: detail.value.templateName,
    businessType: detail.value.businessType,
    title: form.title.trim(),
    status: "PENDING",
    currentLevel: 1,
    applicantId: userStore.id,
    applicantName: applicantName.value,
    applyTime: parseTime(new Date()),
    deptId: userStore.currentDeptId || undefined,
    formConfig: buildFormConfigPayload(),
  });
  const buildUpdatePayload = () => {
    const row = instanceRow.value || {};
    return {
      id: instanceId.value,
      instanceNo: row.instanceNo,
      templateId: row.templateId ?? detail.value?.id,
      templateName: row.templateName ?? detail.value?.templateName,
      businessId: row.businessId,
      businessType: row.businessType,
      title: form.title.trim(),
      status: row.status || "PENDING",
      currentLevel: row.currentLevel,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      applyTime: row.applyTime,
      deptId: row.deptId,
      formConfig: buildFormConfigPayload(),
    };
  };
  const handleSubmit = () => {
    if (!validateForm() || submitting.value) return;
    submitting.value = true;
    const submitApi = isEditMode.value
      ? updateApprovalInstance
      : saveApprovalInstance;
    const payload = isEditMode.value ? buildUpdatePayload() : buildSavePayload();
    submitApi(payload)
      .then(() => {
        uni.showToast({
          title: isEditMode.value ? "保存成功" : "提交成功",
          icon: "success",
        });
        if (isEditMode.value) {
          uni.removeStorageSync(EDIT_STORAGE_KEY);
        }
        setTimeout(() => {
          uni.navigateBack({ delta: isEditMode.value ? 1 : 2 });
        }, 300);
      })
      .catch(() => {
        uni.showToast({
          title: isEditMode.value ? "保存失败" : "提交失败",
          icon: "none",
        });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  const loadTemplateDetail = () => {
    if (!templateId.value) return Promise.resolve();
    return getApprovalTemplateDetail(templateId.value)
      .then(res => {
        detail.value = res?.data || null;
        if (!detail.value) {
          uni.showToast({ title: "未获取到模板详情", icon: "none" });
        }
        return detail.value;
      })
      .catch(() => {
        uni.showToast({ title: "获取模板详情失败", icon: "none" });
        return null;
      });
  };
  const loadForCreate = async () => {
    loading.value = true;
    detail.value = null;
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(formConfigData.value.fields);
      if (!form.title && detail.value.templateName) {
        form.title = `${detail.value.templateName}申请`;
      }
    } finally {
      loading.value = false;
    }
  };
  const loadForEdit = async () => {
    const row = uni.getStorageSync(EDIT_STORAGE_KEY);
    if (!row || String(row.id) !== String(instanceId.value)) {
      uni.showToast({ title: "未获取到审批数据", icon: "none" });
      return;
    }
    uni.removeStorageSync(EDIT_STORAGE_KEY);
    instanceRow.value = row;
    templateId.value = row.templateId;
    form.title = row.title || "";
    loading.value = true;
    detail.value = null;
    try {
      await loadTemplateDetail();
      initFormValues(formConfigData.value.fields);
    } finally {
      loading.value = false;
    }
  };
  const goBack = () => {
    uni.navigateBack();
  };
  onLoad(options => {
    if (options?.id) {
      instanceId.value = options.id;
      loadForEdit();
      return;
    }
    if (options?.templateId) {
      templateId.value = options.templateId;
      loadForCreate();
      return;
    }
    uni.showToast({ title: "缺少页面参数", icon: "none" });
  });
</script>
<style scoped lang="scss">
  .approve-apply-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: #f0f3f8;
  }
  .form-scroll {
    flex: 1;
    height: 0;
    padding: 10px 12px calc(96px + env(safe-area-inset-bottom));
  }
  .loading-wrap {
    padding: 48px 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
  }
  .loading-text {
    font-size: 14px;
    color: #909399;
  }
  .section {
    background: #fff;
    border-radius: 12px;
    margin-bottom: 10px;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
  }
  .section-title {
    padding: 12px 16px;
    font-size: 15px;
    font-weight: 600;
    color: #1f2d3d;
    border-bottom: 1px solid #f2f4f7;
    border-left: 3px solid #2979ff;
    padding-left: 13px;
  }
  .form-body {
    padding: 8px 16px 16px;
  }
  .form-row {
    padding: 10px 0;
    border-bottom: 1px solid #f5f7fa;
    &:last-child {
      border-bottom: none;
    }
    &--field {
      flex-direction: column;
      align-items: stretch;
    }
  }
  .form-label {
    display: block;
    margin-bottom: 8px;
    font-size: 14px;
    color: #606266;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 4px;
    }
  }
  .form-readonly {
    font-size: 14px;
    color: #303133;
  }
  .form-prompt {
    margin: 12px 16px 0;
    padding: 10px 12px;
    font-size: 13px;
    color: #606266;
    background: #f8fafc;
    border-radius: 8px;
    line-height: 1.5;
  }
  .date-trigger {
    width: 100%;
  }
  .flow-list {
    padding: 12px;
  }
  .flow-card {
    padding: 12px;
    margin-bottom: 8px;
    background: #f8fafc;
    border-radius: 8px;
    border: 1px solid #eef2f6;
    &:last-child {
      margin-bottom: 0;
    }
  }
  .flow-card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }
  .flow-level {
    font-size: 14px;
    font-weight: 600;
    color: #303133;
  }
  .flow-type {
    font-size: 13px;
    color: #2979ff;
  }
  .approver-tags {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }
  .approver-tag {
    padding: 4px 10px;
    font-size: 13px;
    color: #303133;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 16px;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: #909399;
    &.inline {
      padding: 0;
    }
  }
  .empty-wrap {
    padding: 48px 20px;
  }
</style>
src/pages/oa/ApproveManage/approve-list/index.vue
@@ -3,16 +3,297 @@
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
  <view class="approve-list-page sales-account">
    <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>
    </view>
    <scroll-view class="list-scroll"
                 scroll-y
                 :show-scrollbar="false"
                 @scrolltolower="loadMore">
      <view v-if="list.length"
            class="ledger-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" />
              </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">{{ item.applyTime || "-" }}</text>
            </view>
            <view v-if="item.finishTime"
                  class="detail-row">
              <text class="detail-label">完成时间</text>
              <text class="detail-value">{{ item.finishTime }}</text>
            </view>
          </view>
          <view v-if="canEdit(item) || item.isApprove"
                class="action-buttons">
            <up-button v-if="canEdit(item)"
                       class="action-btn"
                       size="small"
                       @click.stop="goEdit(item)">
              ç¼–辑
            </up-button>
            <up-button v-if="item.isApprove"
                       class="action-btn"
                       size="small"
                       type="primary"
                       @click.stop="handleApprove(item)">
              å®¡æ‰¹
            </up-button>
          </view>
        </view>
        <up-loadmore :status="pageStatus" />
      </view>
      <view v-else
            class="empty-wrap">
        <up-empty mode="list"
                  text="暂无审批数据" />
      </view>
    </scroll-view>
    <view class="fab-button"
          @click="goAdd">
      <up-icon name="plus"
               size="28"
               color="#ffffff" />
    </view>
  </view>
</template>
<script setup>
  /** OA - å®¡æ‰¹ç®¡ç† - å®¡æ‰¹åˆ—表 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  import { 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";
  const pageKey = "ApproveManage/approve-list";
  const { config } = useOaPage(pageKey);
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  const userStore = useUserStore();
  const queryParams = reactive({
    keyword: "",
  });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  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 currentApproverName = item => {
    const tasks = item?.tasks;
    if (!Array.isArray(tasks) || !tasks.length) return "-";
    const pending = tasks.find(t => t.taskStatus === "PENDING");
    if (pending?.approverName) return pending.approverName;
    const names = [...new Set(tasks.map(t => t.approverName).filter(Boolean))];
    return names.length ? names.join("、") : "-";
  };
  const buildListParams = () => {
    const keyword = queryParams.keyword?.trim();
    const dto = {};
    if (keyword) {
      if (/[\u4e00-\u9fa5]/.test(keyword)) {
        dto.title = keyword;
      } else {
        dto.instanceNo = keyword;
      }
    }
    return {
      page: {
        current: page.current,
        size: page.size,
      },
      approvalInstanceDto: 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";
        } else {
          pageStatus.value = "loadmore";
          page.current += 1;
        }
      })
      .catch(() => {
        if (page.current === 1) {
          list.value = [];
        }
        pageStatus.value = "loadmore";
        uni.showToast({ title: "查询失败", icon: "none" });
      });
  };
  const handleSearch = () => {
    page.current = 1;
    pageStatus.value = "loadmore";
    list.value = [];
    getList();
  };
  const loadMore = () => {
    if (pageStatus.value === "loadmore") {
      getList();
    }
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const goAdd = () => {
    uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  };
  const canEdit = item =>
    item?.status === "PENDING" &&
    String(item.applicantId) === String(userStore.id);
  const goEdit = item => {
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?id=${item.id}`,
    });
  };
  const handleApprove = item => {
    if (!item?.id) return;
    uni.showToast({ title: "审批详情页待对接", icon: "none" });
  };
  onShow(() => {
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .approve-list-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  .list-scroll {
    flex: 1;
    height: 0;
    padding-bottom: calc(80px + env(safe-area-inset-bottom));
  }
  .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;
  }
</style>
src/pages/oa/ApproveManage/approve-list/template-select.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,308 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / é€‰æ‹©å®¡æ‰¹æ¨¡æ¿
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/template-select
  Tab:TypeEnums â†’ businessType;列表:GET /approvalTemplate/list/1(自定义已启用)后按 businessType ç­›é€‰
-->
<template>
  <view class="template-select-page sales-account">
    <PageHeader title="选择审批模板"
                @back="goBack" />
    <view v-if="typeOptions.length"
          class="step-section">
      <view class="tabs-wrap">
        <up-tabs :list="tabList"
                 :current="activeTab"
                 line-color="#2979ff"
                 @click="onTabClick" />
      </view>
    </view>
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="keyword"
                    class="search-text"
                    placeholder="请输入模板名称"
                    clearable />
        </view>
        <view class="filter-button">
          <up-icon name="search"
                   size="24"
                   color="#999" />
        </view>
      </view>
    </view>
    <scroll-view class="list-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <view v-if="loading"
            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">
        <view v-for="item in displayList"
              :key="item.id"
              class="ledger-item ledger-item--clickable"
              @click="selectTemplate(item)">
          <view class="item-header">
            <view class="item-left">
              <view class="document-icon">
                <up-icon name="file-text"
                         size="16"
                         color="#ffffff" />
              </view>
              <text class="item-id">{{ item.templateName || "-" }}</text>
            </view>
            <u-tag :type="enabledTagType(item.enabled)"
                   :text="enabledText(item.enabled)" />
          </view>
          <up-divider />
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">审批类型</text>
              <text class="detail-value">{{ businessTypeText(item.businessType) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">审批节点</text>
              <text class="detail-value">{{ nodeCount(item) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">模板说明</text>
              <text class="detail-value">{{ item.description || "-" }}</text>
            </view>
          </view>
        </view>
      </view>
      <view v-else
            class="empty-wrap">
        <up-empty mode="list"
                  :text="emptyText" />
      </view>
    </scroll-view>
  </view>
</template>
<script setup>
  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,
    filterTemplatesByBusinessType,
    getBusinessTypeLabel,
    getDefaultTypeTabIndex,
  } from "../../_utils/approvalTemplateType.js";
  const typeOptions = ref([]);
  const typeLabelMap = ref({});
  /** å…¨éƒ¨è‡ªå®šä¹‰å·²å¯ç”¨æ¨¡æ¿ï¼ˆlist/1 ä¸€æ¬¡æ‹‰å–) */
  const allTemplates = ref([]);
  const activeTab = ref(0);
  const keyword = ref("");
  const loading = ref(false);
  const tabList = computed(() =>
    typeOptions.value.map(opt => ({ name: opt.name }))
  );
  const currentTypeOption = computed(() => typeOptions.value[activeTab.value]);
  const currentSource = computed(() => {
    const businessType = currentTypeOption.value?.value;
    return filterTemplatesByBusinessType(allTemplates.value, businessType);
  });
  const displayList = computed(() => {
    const kw = keyword.value?.trim().toLowerCase();
    if (!kw) return currentSource.value;
    return currentSource.value.filter(item =>
      (item.templateName || "").toLowerCase().includes(kw)
    );
  });
  const emptyText = computed(() => {
    const typeName = currentTypeOption.value?.name || "该审批类型";
    return `暂无${typeName}下的模板`;
  });
  const businessTypeText = type =>
    getBusinessTypeLabel(type, typeLabelMap.value);
  const enabledText = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "启用";
    if (val === "0") return "停用";
    return "-";
  };
  const enabledTagType = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "success";
    if (val === "0") return "info";
    return "info";
  };
  const nodeCount = item => {
    const count = item?.nodes?.length;
    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([
        fetchApprovalTemplateTypes(),
        loadCustomTemplates(),
      ]);
      typeOptions.value = opts;
      typeLabelMap.value = buildTypeLabelMap(opts);
      activeTab.value = getDefaultTypeTabIndex(opts);
    } catch {
      typeOptions.value = [];
      typeLabelMap.value = {};
      uni.showToast({ title: "获取审批类型失败", icon: "none" });
    } finally {
      loading.value = false;
    }
  };
  const onTabClick = item => {
    activeTab.value = item?.index ?? 0;
    keyword.value = "";
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const selectTemplate = item => {
    if (!item?.id) return;
    if (String(item.enabled) === "0") {
      uni.showToast({ title: "该模板已停用", icon: "none" });
      return;
    }
    uni.navigateTo({
      url: `${OA_NAV.approveListApply}?templateId=${item.id}`,
    });
  };
  onLoad(() => {
    initPage();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .template-select-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  .step-section {
    background: #fff;
    border-bottom: 1px solid #f0f0f0;
  }
  .step-label {
    display: block;
    padding: 10px 16px 0;
    font-size: 13px;
    font-weight: 600;
    color: #303133;
  }
  .step-hint {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    padding: 10px 16px 4px;
    gap: 8px;
  }
  .step-desc {
    flex-shrink: 0;
    font-size: 12px;
    color: #909399;
  }
  .tabs-wrap {
    padding: 0 12px 4px;
  }
  .list-scroll {
    flex: 1;
    height: 0;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .loading-wrap {
    padding: 48px 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 12px;
  }
  .loading-text {
    font-size: 14px;
    color: #909399;
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .ledger-item--clickable:active {
    opacity: 0.92;
  }
  .card-footer {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-top: 10px;
    padding-top: 10px;
    border-top: 1px dashed #e8ecf0;
  }
  .card-footer-tip {
    font-size: 13px;
    color: #2979ff;
  }
</style>
src/pages/oa/ApproveManage/approve-template/detail.vue
@@ -24,8 +24,8 @@
              <text class="info-value">{{ detail.templateName || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">模板类型</text>
              <text class="info-value">{{ templateTypeText(detail.templateType) }}</text>
              <text class="info-label">审批类型</text>
              <text class="info-value">{{ businessTypeText(detail.businessType) }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">启用状态</text>
@@ -125,6 +125,11 @@
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
@@ -139,6 +144,7 @@
  const templateId = ref("");
  const detail = ref(null);
  const loading = ref(false);
  const typeLabelMap = ref({});
  const formConfigData = computed(() => {
    const raw = detail.value?.formConfig;
@@ -156,12 +162,8 @@
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const templateTypeText = type => {
    const val = Number(type);
    if (val === 0) return "系统内置";
    if (val === 1) return "自定义";
    return "-";
  };
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const enabledText = enabled => {
    const val = String(enabled ?? "");
@@ -213,6 +215,11 @@
  };
  onLoad(options => {
    fetchApprovalTemplateTypes()
      .then(opts => {
        typeLabelMap.value = buildTypeLabelMap(opts);
      })
      .catch(() => {});
    if (options?.id) {
      templateId.value = options.id;
      loadDetail();
src/pages/oa/ApproveManage/approve-template/edit.vue
@@ -28,19 +28,18 @@
                      maxlength="50"
                      clearable />
          </up-form-item>
          <up-form-item label="模板类型"
                        prop="templateType"
          <up-form-item label="审批类型"
                        prop="businessType"
                        required
                        class="form-item-type">
            <up-radio-group v-model="form.templateType"
                            class="type-radio-group"
                            placement="row"
                            @change="onTemplateTypeChange">
              <up-radio v-for="opt in TEMPLATE_TYPE_OPTIONS"
                        :key="opt.value"
                        :name="opt.value"
                        :label="opt.name" />
            </up-radio-group>
                        class="form-item-select"
                        @click="openBusinessTypeSheet">
            <up-input :model-value="businessTypeText"
                      placeholder="请选择审批类型"
                      readonly />
            <template #right>
              <up-icon name="arrow-right"
                       @click.stop="openBusinessTypeSheet" />
            </template>
          </up-form-item>
          <up-form-item label="启用状态"
                        class="form-item-switch">
@@ -311,6 +310,12 @@
        </scroll-view>
      </view>
    </up-popup>
    <up-action-sheet :show="showBusinessTypeSheet"
                     title="选择审批类型"
                     :actions="businessTypeActions"
                     @select="onSelectBusinessType"
                     @close="showBusinessTypeSheet = false" />
  </view>
</template>
@@ -325,6 +330,7 @@
  } from "@/api/oa/approvalTemplate.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { fetchApprovalTemplateTypes } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
@@ -384,6 +390,7 @@
  const form = reactive({
    templateName: "",
    businessType: null,
    templateType: 1,
    enabled: "1",
    description: "",
@@ -413,11 +420,11 @@
  const rules = {
    templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
    templateType: [
    businessType: [
      {
        validator: (_rule, value, callback) => {
          if (value === "" || value === null || value === undefined) {
            callback(new Error("请选择模板类型"));
            callback(new Error("请选择审批类型"));
            return;
          }
          callback();
@@ -427,10 +434,22 @@
    ],
  };
  const TEMPLATE_TYPE_OPTIONS = [
    { name: "系统内置", value: 0 },
    { name: "自定义", value: 1 },
  ];
  const businessTypeOptions = ref([]);
  const showBusinessTypeSheet = ref(false);
  const businessTypeActions = computed(() =>
    businessTypeOptions.value.map(opt => ({
      name: opt.name,
      value: opt.value,
    }))
  );
  const businessTypeText = computed(() => {
    const matched = businessTypeOptions.value.find(
      opt => String(opt.value) === String(form.businessType)
    );
    return matched?.name || "";
  });
  const presetActions = FORM_PRESETS.map(item => ({
    name: item.name,
@@ -487,10 +506,12 @@
    if (!row) return;
    templateId.value = row.id;
    form.templateName = row.templateName || "";
    form.templateType =
      row.templateType === 0 || row.templateType === 1
        ? row.templateType
        : Number(row.templateType) || 1;
    const parsedBusiness = Number(row.businessType);
    form.businessType = Number.isNaN(parsedBusiness)
      ? row.businessType
      : parsedBusiness;
    const parsedTemplateType = Number(row.templateType);
    form.templateType = Number.isNaN(parsedTemplateType) ? 1 : parsedTemplateType;
    form.enabled = String(row.enabled ?? "1");
    form.description = row.description || "";
@@ -526,8 +547,18 @@
    uni.navigateBack();
  };
  const onTemplateTypeChange = () => {
    formRef.value?.validateField?.("templateType");
  const openBusinessTypeSheet = () => {
    if (!businessTypeOptions.value.length) {
      uni.showToast({ title: "审批类型加载中", icon: "none" });
      return;
    }
    showBusinessTypeSheet.value = true;
  };
  const onSelectBusinessType = action => {
    form.businessType = action.value;
    showBusinessTypeSheet.value = false;
    formRef.value?.validateField?.("businessType");
  };
  const onSelectPreset = action => {
@@ -710,6 +741,7 @@
      templateName: form.templateName.trim(),
      enabled: form.enabled,
      description: form.description?.trim() || "",
      businessType: form.businessType,
      templateType: form.templateType,
      formConfig: JSON.stringify({
        prompt: formConfig.prompt?.trim() || "",
@@ -792,7 +824,25 @@
    }
  });
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
      .then(opts => {
        businessTypeOptions.value = opts;
        if (!templateId.value && opts.length) {
          const matched = opts.some(
            opt => String(opt.value) === String(form.businessType)
          );
          if (!matched) {
            form.businessType = opts[0].value;
          }
        }
      })
      .catch(() => {
        uni.showToast({ title: "获取审批类型失败", icon: "none" });
      });
  onMounted(() => {
    loadTemplateTypes();
    userListNoPageByTenantId()
      .then(res => {
        userList.value = res?.data || [];
@@ -926,18 +976,18 @@
    font-size: 15px !important;
  }
  :deep(.form-item-type .u-form-item__body) {
  :deep(.form-item-select .u-form-item__body) {
    align-items: center !important;
  }
  .type-radio-group {
    display: flex;
    justify-content: flex-end;
    flex-wrap: nowrap;
  :deep(.form-item-select .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.type-radio-group .u-radio) {
    margin-left: 20px;
  :deep(.form-item-select .u-input__content__field-wrapper__field) {
    text-align: right !important;
  }
  :deep(.form-item-switch .u-form-item__body) {
src/pages/oa/ApproveManage/approve-template/index.vue
@@ -48,8 +48,8 @@
          <up-divider />
          <view class="item-details">
            <view class="detail-row">
              <text class="detail-label">模板类型</text>
              <text class="detail-value">{{ templateTypeText(item.templateType) }}</text>
              <text class="detail-label">审批类型</text>
              <text class="detail-value">{{ businessTypeText(item.businessType) }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">审批节点</text>
@@ -115,8 +115,14 @@
    deleteApprovalTemplate,
    listApprovalTemplatePage,
  } from "@/api/oa/approvalTemplate.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const typeLabelMap = ref({});
  const queryParams = reactive({
    templateName: "",
@@ -155,12 +161,15 @@
    return "info";
  };
  const templateTypeText = type => {
    const val = Number(type);
    if (val === 0) return "系统内置";
    if (val === 1) return "自定义";
    return "-";
  };
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
      .then(opts => {
        typeLabelMap.value = buildTypeLabelMap(opts);
      })
      .catch(() => {});
  const nodeCount = item => {
    const count = item?.nodes?.length;
@@ -266,6 +275,7 @@
  };
  onShow(() => {
    loadTemplateTypes();
    handleSearch();
  });
</script>
src/pages/oa/_utils/approvalTemplateType.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
import { getTypeEnums } from "@/api/basic/enum.js";
/**
 * GET /approvalTemplate/list/{type} è·¯å¾„参数为 templateType
 * 1 = è‡ªå®šä¹‰ä¸”已启用(与 businessType æ— å…³ï¼‰
 */
export const CUSTOM_TEMPLATE_LIST_TYPE = 1;
/** ä¸šåŠ¡ç±»åž‹æžšä¸¾å…œåº•ï¼ˆapproveType:1公出 2请假 â€¦ï¼‰ */
export const FALLBACK_BUSINESS_TYPE_OPTIONS = [
  { name: "公出管理", value: 1 },
  { name: "请假管理", value: 2 },
];
/** å°† /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 {
        name: String(name),
        value: Number.isNaN(num) ? value : num,
      };
    });
  }
  return [];
}
/** æ‹‰å–业务类型枚举(TypeEnums â†’ businessType) */
export async function fetchApprovalTemplateTypes() {
  const res = await getTypeEnums();
  const options = normalizeEnumOptions(res?.data);
  return options.length ? options : [...FALLBACK_BUSINESS_TYPE_OPTIONS];
}
/** æŒ‰ businessType ç­›é€‰æ¨¡æ¿ */
export function filterTemplatesByBusinessType(templates, businessType) {
  if (businessType == null || businessType === "") return [];
  return (templates || []).filter(
    item => String(item.businessType) === String(businessType)
  );
}
/** é»˜è®¤ Tab ä¸‹æ ‡ï¼ˆå¯æŒ‰ä¸šåŠ¡ç±»åž‹ value æŒ‡å®šï¼Œé»˜è®¤ç¬¬ä¸€é¡¹ï¼‰ */
export function getDefaultTypeTabIndex(options, defaultBusinessType) {
  if (!options?.length) return 0;
  if (defaultBusinessType == null) return 0;
  const idx = options.findIndex(
    opt => String(opt.value) === String(defaultBusinessType)
  );
  return idx >= 0 ? idx : 0;
}
export function buildTypeLabelMap(options) {
  const map = {};
  (options || []).forEach(opt => {
    map[String(opt.value)] = opt.name;
  });
  return map;
}
/** æ ¹æ® businessType æ˜¾ç¤ºä¸šåŠ¡ç±»åž‹åç§° */
export function getTemplateTypeLabel(type, labelMap) {
  if (type == null || type === "") return "-";
  return labelMap?.[String(type)] ?? String(type);
}
export const getBusinessTypeLabel = getTemplateTypeLabel;