7da4d12ea36c879f0d0b98a8fcb9a74923310f11..9d89dedf542a7b9f8e2549c44723771133f79ef2
2026-05-19 yyb
模板类型变更
9d89de 对比 | 目录
2026-05-19 yyb
OA页面/审批模板
2256ff 对比 | 目录
已添加32个文件
已修改3个文件
4780 ■■■■■ 文件已修改
src/api/basic/enum.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalInstance.js 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalTemplate.js 56 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaWorkbench.js 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 147 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/indexItem.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/apply.vue 548 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 299 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/template-select.vue 308 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/detail.vue 425 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/edit.vue 1617 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/index.vue 314 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/leave-apply/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/AttendManage/overtime-apply/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ContractManage/purchase-contract/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ContractManage/sale-contract/index.vue 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/EnterpriseNews/news-manage/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/post-manage/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/regular-apply/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/resign-apply/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/staff-archive/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/staff-contract/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/transfer-apply/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/HrManage/work-handover/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/NoticeAnnouncement/notice-manage/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/cost-reimburse/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ReimburseManage/travel-reimburse/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_components/OaListPage.vue 182 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalTemplateType.js 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/oaPageRegistry.js 256 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/oaStorage.js 26 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/oaUi.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/useOaPage.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/works.vue 42 ●●●●● 补丁 | 查看 | 原始文档 | 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
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,56 @@
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({
    url: "/approvalTemplate/listPage",
    method: "get",
    params,
  });
}
/** å®¡æ‰¹æ¨¡æ¿è¯¦æƒ… */
export function getApprovalTemplateDetail(id) {
  return request({
    url: `/approvalTemplate/detail/${id}`,
    method: "get",
  });
}
/** æ–°å¢žå®¡æ‰¹æ¨¡æ¿ */
export function addApprovalTemplate(data) {
  return request({
    url: "/approvalTemplate/add",
    method: "post",
    data,
  });
}
/** ä¿®æ”¹å®¡æ‰¹æ¨¡æ¿ */
export function updateApprovalTemplate(data) {
  return request({
    url: "/approvalTemplate/update",
    method: "put",
    data,
  });
}
/** åˆ é™¤å®¡æ‰¹æ¨¡æ¿ï¼ˆä¼  ID æ•°ç»„) */
export function deleteApprovalTemplate(ids) {
  return request({
    url: "/approvalTemplate/delete",
    method: "post",
    data: ids,
  });
}
src/config/oaPaths.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
/**
 * OA æ¨¡å—路径常量(pages.json path ä¸å«å‰ç¼€ /)
 * å¯¼èˆªä½¿ç”¨ï¼šuni.navigateTo({ url: OA_NAV.xxx })
 */
const P = "pages/oa";
export const OA_NAV = {
  /** äººäº‹ç®¡ç† */
  staffArchive: `/${P}/HrManage/staff-archive/index`,
  staffContract: `/${P}/HrManage/staff-contract/index`,
  regularApply: `/${P}/HrManage/regular-apply/index`,
  transferApply: `/${P}/HrManage/transfer-apply/index`,
  resignApply: `/${P}/HrManage/resign-apply/index`,
  workHandover: `/${P}/HrManage/work-handover/index`,
  postManage: `/${P}/HrManage/post-manage/index`,
  /** å‡å‹¤ç®¡ç† */
  leaveApply: `/${P}/AttendManage/leave-apply/index`,
  overtimeApply: `/${P}/AttendManage/overtime-apply/index`,
  /** æŠ¥é”€ç®¡ç† */
  travelReimburse: `/${P}/ReimburseManage/travel-reimburse/index`,
  costReimburse: `/${P}/ReimburseManage/cost-reimburse/index`,
  /** åˆåŒç®¡ç† */
  purchaseContract: `/${P}/ContractManage/purchase-contract/index`,
  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`,
  /** ä¼ä¸šæ–°é—» / å…¬å‘Šé€šçŸ¥ */
  enterpriseNews: `/${P}/EnterpriseNews/news-manage/index`,
  noticeAnnouncement: `/${P}/NoticeAnnouncement/notice-manage/index`,
};
/** pages.json æ³¨å†Œç”¨ path(无 / å‰ç¼€ï¼‰ */
export const OA_PAGE_PATHS = Object.fromEntries(
  Object.entries(OA_NAV).map(([key, url]) => [
    key,
    url.replace(/^\//, ""),
  ])
);
src/config/oaWorkbench.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
import { OA_NAV } from "./oaPaths.js";
/**
 * OA æ¨¡å—分组(工作台展示 / æ–‡æ¡£å¯¹ç…§ï¼‰
 */
export const OA_MODULES = [
  {
    key: "HrManage",
    name: "人事管理",
    children: [
      { label: "员工档案", icon: "/static/images/icon/renyuanxinzi.svg", path: OA_NAV.staffArchive },
      { 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/gongchuguanli.svg", path: OA_NAV.workHandover },
      { label: "岗位管理", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
    ],
  },
  {
    key: "AttendManage",
    name: "假勤管理",
    children: [
      { label: "请假申请", icon: "/static/images/icon/qingjiaguanli.svg", path: OA_NAV.leaveApply },
      { label: "加班申请", icon: "/static/images/icon/dakaqiandao.svg", path: OA_NAV.overtimeApply },
    ],
  },
  {
    key: "ReimburseManage",
    name: "报销管理",
    children: [
      { label: "差旅报销", icon: "/static/images/icon/chuchaiguanli.svg", path: OA_NAV.travelReimburse },
      { label: "费用报销", icon: "/static/images/icon/baoxiaoguanli.svg", path: OA_NAV.costReimburse },
    ],
  },
  {
    key: "ContractManage",
    name: "合同管理",
    children: [
      { label: "采购合同", icon: "/static/images/icon/caigoutaizhang.svg", path: OA_NAV.purchaseContract },
      { label: "销售合同", icon: "/static/images/icon/xiaoshoutaizhang.svg", path: OA_NAV.saleContract },
    ],
  },
  {
    key: "ApproveManage",
    name: "审批管理",
    children: [
      { label: "审批列表", icon: "/static/images/icon/xietongshenpi.svg", path: OA_NAV.approveList },
      { label: "审批模板", icon: "/static/images/icon/guizhangzhidu.svg", path: OA_NAV.approveTemplate },
    ],
  },
  {
    key: "EnterpriseNews",
    name: "企业新闻",
    children: [
      { label: "企业新闻", icon: "/static/images/icon/zhishiku.svg", path: OA_NAV.enterpriseNews },
    ],
  },
  {
    key: "NoticeAnnouncement",
    name: "公告通知",
    children: [
      { label: "公告通知", icon: "/static/images/icon/tongzhigonggao.svg", path: OA_NAV.noticeAnnouncement },
    ],
  },
];
/** å·¥ä½œå°æ‰å¹³èœå•(纯前端配置) */
export const OA_WORKBENCH_ITEMS = OA_MODULES.flatMap(module =>
  module.children.map(item => ({
    ...item,
    module: module.name,
    moduleKey: module.key,
  }))
);
src/pages.json
@@ -1304,6 +1304,153 @@
        "navigationBarTitleText": "归还登记",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/staff-archive/index",
      "style": {
        "navigationBarTitleText": "员工档案",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/staff-contract/index",
      "style": {
        "navigationBarTitleText": "员工合同",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/regular-apply/index",
      "style": {
        "navigationBarTitleText": "转正申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/transfer-apply/index",
      "style": {
        "navigationBarTitleText": "调岗申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/resign-apply/index",
      "style": {
        "navigationBarTitleText": "离职申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/work-handover/index",
      "style": {
        "navigationBarTitleText": "工作交接",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/HrManage/post-manage/index",
      "style": {
        "navigationBarTitleText": "岗位管理",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/AttendManage/leave-apply/index",
      "style": {
        "navigationBarTitleText": "请假申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/AttendManage/overtime-apply/index",
      "style": {
        "navigationBarTitleText": "加班申请",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/travel-reimburse/index",
      "style": {
        "navigationBarTitleText": "差旅报销",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ReimburseManage/cost-reimburse/index",
      "style": {
        "navigationBarTitleText": "费用报销",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ContractManage/purchase-contract/index",
      "style": {
        "navigationBarTitleText": "采购合同",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ContractManage/sale-contract/index",
      "style": {
        "navigationBarTitleText": "销售合同",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-list/index",
      "style": {
        "navigationBarTitleText": "审批列表",
        "navigationStyle": "custom"
      }
    },
    {
      "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": "审批模板",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/edit",
      "style": {
        "navigationBarTitleText": "新建审批模板",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/ApproveManage/approve-template/detail",
      "style": {
        "navigationBarTitleText": "模板详情",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/EnterpriseNews/news-manage/index",
      "style": {
        "navigationBarTitleText": "企业新闻",
        "navigationStyle": "custom"
      }
    },
    {
      "path": "pages/oa/NoticeAnnouncement/notice-manage/index",
      "style": {
        "navigationBarTitleText": "公告通知",
        "navigationStyle": "custom"
      }
    }
  ],
  "subPackages": [
src/pages/indexItem.vue
@@ -27,6 +27,7 @@
<script setup>
  import { onMounted, reactive, ref } from "vue";
  import { OA_WORKBENCH_ITEMS } from "@/config/oaWorkbench.js";
  import useUserStore from "@/store/modules/user";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
@@ -110,10 +111,15 @@
      { icon: "/static/images/icon/baojiaguanli.svg", label: "报价审批" },
      { icon: "/static/images/icon/fahuoguanli.svg", label: "发货审批" },
    ],
    "OA办公": OA_WORKBENCH_ITEMS.map(item => ({ ...item })),
  };
  // å¤„理常用功能点击
  const handleCommonItemClick = item => {
    if (item.path) {
      uni.navigateTo({ url: item.path });
      return;
    }
    const url = routeMapping[item.label];
    if (url) {
      uni.navigateTo({ url });
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
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,299 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹åˆ—表
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/index
-->
<template>
  <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>
  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 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
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,425 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹æ¨¡æ¿è¯¦æƒ…
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-template/detail
-->
<template>
  <view class="template-detail-page">
    <PageHeader title="模板详情"
                @back="goBack" />
    <scroll-view class="detail-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="info-list">
            <view class="info-item">
              <text class="info-label">模板名称</text>
              <text class="info-value">{{ detail.templateName || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">审批类型</text>
              <text class="info-value">{{ businessTypeText(detail.businessType) }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">启用状态</text>
              <text class="info-value"
                    :class="enabledClass(detail.enabled)">
                {{ enabledText(detail.enabled) }}
              </text>
            </view>
            <view class="info-item">
              <text class="info-label">模板说明</text>
              <text class="info-value">{{ detail.description || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">创建人</text>
              <text class="info-value">{{ detail.createdUserName || "-" }}</text>
            </view>
            <view class="info-item">
              <text class="info-label">创建时间</text>
              <text class="info-value">{{ detail.createTime || "-" }}</text>
            </view>
          </view>
        </view>
        <view class="section">
          <view class="section-title">填报配置</view>
          <view class="info-list">
            <view class="info-item">
              <text class="info-label">填报提示</text>
              <text class="info-value">{{ formConfigData.prompt || "-" }}</text>
            </view>
          </view>
          <view v-if="formConfigData.fields.length"
                class="field-block">
            <view v-for="(field, index) in formConfigData.fields"
                  :key="field.key || index"
                  class="field-card">
              <view class="field-card-head">
                <text class="field-card-name">{{ field.label }}</text>
                <text class="field-tag">{{ fieldTypeLabel(field.type) }}</text>
                <text v-if="field.required"
                      class="field-tag field-tag--req">必填</text>
              </view>
              <text v-if="field.defaultValue"
                    class="field-card-default">
                é»˜è®¤å€¼ï¼š{{ field.defaultValue }}
              </text>
            </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="编辑"
                   @cancel="goBack"
                   @confirm="goEdit" />
  </view>
</template>
<script setup>
  import { computed, 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 {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const FIELD_TYPE_MAP = {
    text: "单行文本",
    textarea: "多行文本",
    number: "数字",
    date: "日期",
  };
  const templateId = ref("");
  const detail = ref(null);
  const loading = ref(false);
  const typeLabelMap = ref({});
  const formConfigData = computed(() => {
    const raw = detail.value?.formConfig;
    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 levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const enabledText = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "启用";
    if (val === "0") return "停用";
    return "-";
  };
  const enabledClass = enabled => {
    const val = String(enabled ?? "");
    if (val === "1") return "status-on";
    if (val === "0") return "status-off";
    return "";
  };
  const fieldTypeLabel = type => FIELD_TYPE_MAP[type] || type || "-";
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
  const goBack = () => {
    uni.navigateBack();
  };
  const goEdit = () => {
    if (!templateId.value || !detail.value) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, detail.value);
    uni.navigateTo({
      url: `/pages/oa/ApproveManage/approve-template/edit?id=${templateId.value}`,
    });
  };
  const loadDetail = () => {
    if (!templateId.value) return;
    loading.value = true;
    detail.value = null;
    getApprovalTemplateDetail(templateId.value)
      .then(res => {
        detail.value = res?.data || null;
        if (!detail.value) {
          uni.showToast({ title: "未获取到详情", icon: "none" });
        }
      })
      .catch(() => {
        uni.showToast({ title: "获取详情失败", icon: "none" });
      })
      .finally(() => {
        loading.value = false;
      });
  };
  onLoad(options => {
    fetchApprovalTemplateTypes()
      .then(opts => {
        typeLabelMap.value = buildTypeLabelMap(opts);
      })
      .catch(() => {});
    if (options?.id) {
      templateId.value = options.id;
      loadDetail();
    }
  });
</script>
<style scoped lang="scss">
  .template-detail-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: #f0f3f8;
  }
  .detail-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;
  }
  .info-list {
    padding: 4px 0;
  }
  .info-item {
    display: flex;
    align-items: flex-start;
    padding: 11px 16px;
    border-bottom: 1px solid #f5f7fa;
    gap: 12px;
    &:last-child {
      border-bottom: none;
    }
  }
  .info-label {
    flex-shrink: 0;
    width: 88px;
    font-size: 14px;
    color: #606266;
  }
  .info-value {
    flex: 1;
    font-size: 14px;
    color: #303133;
    text-align: right;
    word-break: break-all;
  }
  .status-on {
    color: #18a058;
  }
  .status-off {
    color: #909399;
  }
  .field-block {
    padding: 0 12px 12px;
  }
  .field-card {
    padding: 10px 12px;
    margin-bottom: 8px;
    background: #f8fafc;
    border-radius: 8px;
    border: 1px solid #eef2f6;
    &:last-child {
      margin-bottom: 0;
    }
  }
  .field-card-head {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
  }
  .field-card-name {
    font-size: 14px;
    font-weight: 500;
    color: #303133;
  }
  .field-tag {
    font-size: 11px;
    padding: 2px 8px;
    border-radius: 4px;
    color: #2979ff;
    background: #ecf5ff;
    &--req {
      color: #f56c6c;
      background: #fef0f0;
    }
  }
  .field-card-default {
    display: block;
    margin-top: 6px;
    font-size: 12px;
    color: #909399;
  }
  .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-template/edit.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,1617 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / æ–°å»ºå®¡æ‰¹æ¨¡æ¿
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-template/edit
-->
<template>
  <view class="template-edit-page">
    <PageHeader :title="pageTitle"
                @back="goBack" />
    <scroll-view class="form-scroll"
                 scroll-y
                 :show-scrollbar="false">
      <up-form ref="formRef"
               :model="form"
               :rules="rules"
               label-width="88"
               input-align="right"
               error-message-align="right">
        <u-cell-group title="基本信息"
                      class="form-section">
          <up-form-item label="模板名称"
                        prop="templateName"
                        required
                        class="form-item-name">
            <up-input v-model="form.templateName"
                      class="name-input-inline"
                      placeholder="请输入模板名称"
                      maxlength="50"
                      clearable />
          </up-form-item>
          <up-form-item label="审批类型"
                        prop="businessType"
                        required
                        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">
            <view class="switch-wrap">
              <up-switch v-model="enabledBool" />
            </view>
          </up-form-item>
          <up-form-item label="模板说明"
                        class="form-item-desc"
                        label-position="top">
            <view class="desc-input-shell">
              <up-textarea v-model="form.description"
                           placeholder="选填"
                           maxlength="200"
                           border="none"
                           height="72" />
            </view>
          </up-form-item>
        </u-cell-group>
        <view class="section-card">
          <view class="section-head section-head--between">
            <text class="section-title">填报配置</text>
            <view class="head-actions">
              <text class="head-link"
                    @click="showPresetSheet = true">预设</text>
              <text class="head-link head-link--primary"
                    @click="openFieldEditor()">添加</text>
            </view>
          </view>
          <view class="section-body">
            <view class="prompt-row">
              <text class="prompt-label">填报提示</text>
              <up-input v-model="formConfig.prompt"
                        class="prompt-input"
                        placeholder="选填"
                        maxlength="200"
                        clearable />
            </view>
            <view v-if="formConfig.fields.length"
                  class="field-list">
              <view v-for="(field, index) in formConfig.fields"
                    :key="field.key"
                    class="field-item">
                <view class="field-main">
                  <view class="field-title-row">
                    <text class="field-name">{{ field.label }}</text>
                    <text class="type-tag"
                          :class="fieldTypeTagClass(field.type)">
                      {{ fieldTypeLabel(field.type) }}
                    </text>
                    <text v-if="field.required"
                          class="req-tag">必填</text>
                  </view>
                  <text v-if="field.defaultValue"
                        class="field-default">默认:{{ field.defaultValue }}</text>
                </view>
                <view class="field-actions">
                  <view class="icon-btn icon-btn--edit"
                        @click="openFieldEditor(field, index)">
                    <up-icon name="edit-pen"
                             size="16"
                             color="#2979ff" />
                  </view>
                  <view class="icon-btn icon-btn--del"
                        @click="removeField(index)">
                    <up-icon name="trash"
                             size="16"
                             color="#f56c6c" />
                  </view>
                </view>
              </view>
            </view>
            <view v-else
                  class="empty-mini">
              <text>暂无填报项</text>
            </view>
          </view>
        </view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">审批流程</text>
          </view>
          <view class="flow-wrap">
            <view v-for="(node, nodeIndex) in flowNodes"
                  :key="node._key"
                  class="flow-node-block">
              <view class="flow-node-card">
                <view class="node-header">
                  <view class="node-level-badge">{{ nodeIndex + 1 }}</view>
                  <text class="node-level-text">第{{ levelLabel(nodeIndex + 1) }}级</text>
                  <view v-if="flowNodes.length > 1"
                        class="node-delete"
                        @click="removeNode(nodeIndex)">
                    <up-icon name="trash"
                             size="16"
                             color="#f56c6c" />
                  </view>
                </view>
                <view class="approve-type-row">
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'AND' }"
                        @click="node.approveType = 'AND'">
                    ä¼šç­¾
                  </view>
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'OR' }"
                        @click="node.approveType = 'OR'">
                    æˆ–ç­¾
                  </view>
                </view>
                <view class="approver-list">
                  <view v-for="(approver, approverIndex) in node.approvers"
                        :key="`${node._key}-${approver.approverId}-${approverIndex}`"
                        class="approver-chip">
                    <view class="approver-avatar">{{ (approver.approverName || "?").charAt(0) }}</view>
                    <text class="approver-name">{{ approver.approverName }}</text>
                    <view class="approver-remove"
                          hover-class="approver-remove--active"
                          @tap.stop="removeApprover(nodeIndex, approverIndex)"
                          @click.stop="removeApprover(nodeIndex, approverIndex)">
                      <text class="remove-icon">×</text>
                    </view>
                  </view>
                  <view class="add-approver"
                        @click="openUserPicker(nodeIndex)">
                    <up-icon name="plus"
                             size="14"
                             color="#2979ff" />
                    <text>添加</text>
                  </view>
                </view>
              </view>
              <view v-if="nodeIndex < flowNodes.length - 1"
                    class="flow-connector">
                <view class="flow-connector-line" />
              </view>
            </view>
            <view class="add-node-bar"
                  @click="addNode">
              <up-icon name="plus-circle"
                       size="20"
                       color="#2979ff" />
              <text>添加级次</text>
            </view>
          </view>
        </view>
      </up-form>
    </scroll-view>
    <FooterButtons :loading="submitting"
                   confirm-text="保存"
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-action-sheet :show="showPresetSheet"
                     title="从预设导入"
                     :actions="presetActions"
                     @select="onSelectPreset"
                     @close="showPresetSheet = false" />
    <up-popup :show="showFieldEditor"
              mode="bottom"
              round="16"
              @close="closeFieldEditor">
      <view class="field-editor">
        <view class="sheet-handle" />
        <text class="editor-title">{{ editingFieldIndex >= 0 ? "编辑填报项" : "添加填报项" }}</text>
        <view class="editor-form">
          <view class="editor-row">
            <text class="editor-label required">字段名称</text>
            <up-input v-model="fieldDraft.label"
                      placeholder="请输入"
                      clearable />
          </view>
          <view class="editor-row editor-row--block">
            <text class="editor-label required">字段类型</text>
            <view class="type-chip-grid">
              <view v-for="opt in FIELD_TYPE_OPTIONS"
                    :key="opt.value"
                    class="type-chip"
                    :class="{ active: fieldDraft.type === opt.value }"
                    @click="selectFieldType(opt.value)">
                {{ opt.name }}
              </view>
            </view>
          </view>
          <view class="editor-row editor-row--block">
            <text class="editor-label">默认值</text>
            <up-textarea v-if="fieldDraft.type === 'textarea'"
                         v-model="fieldDraft.defaultValue"
                         placeholder="选填"
                         maxlength="500"
                         height="72" />
            <view v-else-if="fieldDraft.type === 'date'"
                  class="default-date-row"
                  @click="showDefaultDatePicker = true">
              <up-input :model-value="fieldDraft.defaultValue"
                        placeholder="选择日期"
                        readonly />
              <up-icon name="calendar"
                       size="18"
                       color="#909399" />
            </view>
            <up-input v-else
                      v-model="fieldDraft.defaultValue"
                      :type="fieldDraft.type === 'number' ? 'digit' : 'text'"
                      placeholder="选填"
                      clearable />
          </view>
          <view class="editor-row editor-row--switch">
            <text class="editor-label">是否必填</text>
            <up-switch v-model="fieldDraft.required" />
          </view>
        </view>
        <view class="editor-footer">
          <view class="editor-btn editor-btn--cancel"
                @click="closeFieldEditor">取消</view>
          <view class="editor-btn editor-btn--confirm"
                @click="confirmFieldEditor">确定</view>
        </view>
      </view>
    </up-popup>
    <up-popup :show="showDefaultDatePicker"
              mode="bottom"
              @close="showDefaultDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="defaultDateTs"
                          mode="date"
                          @confirm="onDefaultDateConfirm"
                          @cancel="showDefaultDatePicker = false" />
    </up-popup>
    <up-popup :show="showUserPicker"
              mode="bottom"
              round="16"
              @close="closeUserPicker">
      <view class="user-picker">
        <view class="sheet-handle" />
        <view class="picker-head">
          <text class="picker-cancel"
                @click="closeUserPicker">取消</text>
          <text class="picker-title">选择审批人</text>
          <text class="picker-confirm"
                @click="confirmUserPicker">
            ç¡®å®š{{ pickerSelectedIds.length ? `(${pickerSelectedIds.length})` : "" }}
          </text>
        </view>
        <scroll-view class="user-scroll"
                     scroll-y>
          <view v-for="user in availableUsers"
                :key="user.userId"
                class="user-item"
                :class="{ selected: isUserSelected(user.userId) }"
                @click="toggleUser(user)">
            <view class="user-avatar">{{ (user.nickName || "?").charAt(0) }}</view>
            <text class="user-name">{{ user.nickName }}</text>
            <view class="user-check"
                  :class="{ checked: isUserSelected(user.userId) }">
              <up-icon v-if="isUserSelected(user.userId)"
                       name="checkmark"
                       size="14"
                       color="#fff" />
            </view>
          </view>
        </scroll-view>
      </view>
    </up-popup>
    <up-action-sheet :show="showBusinessTypeSheet"
                     title="选择审批类型"
                     :actions="businessTypeActions"
                     @select="onSelectBusinessType"
                     @close="showBusinessTypeSheet = false" />
  </view>
</template>
<script setup>
  import { computed, onMounted, reactive, ref } from "vue";
  import { onLoad } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import {
    addApprovalTemplate,
    updateApprovalTemplate,
  } 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";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const FORM_PRESETS = [
    {
      name: "通用报销",
      prompt: "请填写报销事由、金额等",
      fields: [
        { key: "reason", label: "报销事由", type: "textarea", required: true },
        { key: "amount", label: "报销金额(元)", type: "number", required: true },
        { key: "applyDate", label: "申请日期", type: "date", required: true },
      ],
    },
    {
      name: "请假申请",
      prompt: "请填写请假类型、起止时间等",
      fields: [
        { key: "leaveType", label: "请假类型", type: "text", required: true },
        { key: "startTime", label: "开始时间", type: "date", required: true },
        { key: "endTime", label: "结束时间", type: "date", required: true },
        { key: "reason", label: "请假事由", type: "textarea", required: true },
      ],
    },
    {
      name: "采购申请",
      prompt: "请填写采购事由、预估金额等",
      fields: [
        { key: "title", label: "采购事由", type: "textarea", required: true },
        { key: "amount", label: "预估金额(元)", type: "number", required: true },
      ],
    },
  ];
  const FIELD_TYPE_OPTIONS = [
    { name: "单行文本", value: "text" },
    { name: "多行文本", value: "textarea" },
    { name: "数字", value: "number" },
    { name: "日期", value: "date" },
  ];
  const formRef = ref();
  const submitting = ref(false);
  const userList = ref([]);
  const templateId = ref(null);
  const showPresetSheet = ref(false);
  const showFieldEditor = ref(false);
  const showUserPicker = ref(false);
  const showDefaultDatePicker = ref(false);
  const defaultDateTs = ref(Date.now());
  const editingFieldIndex = ref(-1);
  const editingNodeIndex = ref(-1);
  const pickerSelectedIds = ref([]);
  const form = reactive({
    templateName: "",
    businessType: null,
    templateType: 1,
    enabled: "1",
    description: "",
  });
  const formConfig = reactive({
    prompt: "",
    fields: [],
  });
  const fieldDraft = reactive({
    label: "",
    type: "text",
    defaultValue: "",
    required: true,
  });
  let nodeKeySeed = 1;
  const createNode = () => ({
    _key: `node_${nodeKeySeed++}`,
    approveType: "AND",
    approvers: [],
  });
  const flowNodes = ref([createNode()]);
  const rules = {
    templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
    businessType: [
      {
        validator: (_rule, value, callback) => {
          if (value === "" || value === null || value === undefined) {
            callback(new Error("请选择审批类型"));
            return;
          }
          callback();
        },
        trigger: "change",
      },
    ],
  };
  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,
    value: item.name,
  }));
  const enabledBool = computed({
    get: () => form.enabled === "1",
    set: val => {
      form.enabled = val ? "1" : "0";
    },
  });
  const isEditMode = computed(() => templateId.value != null && templateId.value !== "");
  const pageTitle = computed(() =>
    isEditMode.value ? "编辑审批模板" : "新建审批模板"
  );
  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.map(f => ({ ...f })) : [],
      };
    } catch {
      return { prompt: "", fields: [] };
    }
  };
  const mapNodesFromRow = nodes => {
    if (!Array.isArray(nodes) || !nodes.length) {
      return [createNode()];
    }
    return nodes.map(node => ({
      _key: `node_${nodeKeySeed++}`,
      id: node.id,
      templateId: node.templateId,
      approveType: node.approveType || "AND",
      approvers: (node.approvers || []).map((approver, idx) => ({
        id: approver.id,
        nodeId: approver.nodeId,
        templateId: approver.templateId,
        approverId: approver.approverId,
        approverName: approver.approverName,
        sortNo: approver.sortNo ?? idx + 1,
      })),
    }));
  };
  const fillFormFromRow = row => {
    if (!row) return;
    templateId.value = row.id;
    form.templateName = row.templateName || "";
    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 || "";
    const config = parseFormConfig(row.formConfig);
    formConfig.prompt = config.prompt;
    formConfig.fields = config.fields;
    flowNodes.value = mapNodesFromRow(row.nodes);
  };
  const availableUsers = computed(() => {
    const node = flowNodes.value[editingNodeIndex.value];
    if (!node) return userList.value;
    const selectedIds = new Set(node.approvers.map(a => a.approverId));
    return userList.value.filter(user => !selectedIds.has(user.userId));
  });
  const levelLabel = n => LEVEL_TEXT[n] || String(n);
  const fieldTypeLabel = type =>
    FIELD_TYPE_OPTIONS.find(item => item.value === type)?.name || type;
  const fieldTypeTagClass = type => {
    const map = {
      text: "type-tag--text",
      textarea: "type-tag--area",
      number: "type-tag--num",
      date: "type-tag--date",
    };
    return map[type] || "type-tag--text";
  };
  const goBack = () => {
    uni.navigateBack();
  };
  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 => {
    const preset = FORM_PRESETS.find(item => item.name === action.value);
    if (!preset) return;
    formConfig.prompt = preset.prompt;
    formConfig.fields = preset.fields.map(field => ({ ...field }));
    showPresetSheet.value = false;
    uni.showToast({ title: "已导入预设", icon: "success" });
  };
  const selectFieldType = type => {
    if (fieldDraft.type === type) return;
    fieldDraft.type = type;
    fieldDraft.defaultValue = "";
  };
  const onDefaultDateConfirm = e => {
    fieldDraft.defaultValue = formatDateToYMD(e.value);
    showDefaultDatePicker.value = false;
  };
  const openFieldEditor = (field, index = -1) => {
    editingFieldIndex.value = index;
    if (field) {
      fieldDraft.label = field.label;
      fieldDraft.type = field.type || "text";
      fieldDraft.defaultValue = field.defaultValue ?? "";
      fieldDraft.required = !!field.required;
    } else {
      fieldDraft.label = "";
      fieldDraft.type = "text";
      fieldDraft.defaultValue = "";
      fieldDraft.required = true;
    }
    if (fieldDraft.type === "date" && fieldDraft.defaultValue) {
      const parsed = Date.parse(fieldDraft.defaultValue);
      defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed;
    } else {
      defaultDateTs.value = Date.now();
    }
    showFieldEditor.value = true;
  };
  const closeFieldEditor = () => {
    showFieldEditor.value = false;
    editingFieldIndex.value = -1;
  };
  const buildFieldKey = label => {
    const base = (label || "field")
      .trim()
      .replace(/\s+/g, "_")
      .replace(/[^\w\u4e00-\u9fa5]/g, "");
    let key = base || "field";
    let index = 1;
    while (formConfig.fields.some((item, idx) => item.key === key && idx !== editingFieldIndex.value)) {
      key = `${base}_${index++}`;
    }
    return key;
  };
  const confirmFieldEditor = () => {
    if (!fieldDraft.label?.trim()) {
      uni.showToast({ title: "请输入字段名称", icon: "none" });
      return;
    }
    const defaultValue = String(fieldDraft.defaultValue ?? "").trim();
    const existingKey =
      editingFieldIndex.value >= 0
        ? formConfig.fields[editingFieldIndex.value]?.key
        : null;
    const payload = {
      key: existingKey || buildFieldKey(fieldDraft.label),
      label: fieldDraft.label.trim(),
      type: fieldDraft.type,
      required: !!fieldDraft.required,
      defaultValue,
    };
    if (editingFieldIndex.value >= 0) {
      formConfig.fields.splice(editingFieldIndex.value, 1, payload);
    } else {
      formConfig.fields.push(payload);
    }
    closeFieldEditor();
  };
  const removeField = index => {
    formConfig.fields.splice(index, 1);
  };
  const addNode = () => {
    flowNodes.value.push(createNode());
  };
  const removeNode = index => {
    if (flowNodes.value.length <= 1) {
      uni.showToast({ title: "至少保留一个审批节点", icon: "none" });
      return;
    }
    flowNodes.value.splice(index, 1);
  };
  const openUserPicker = nodeIndex => {
    editingNodeIndex.value = nodeIndex;
    pickerSelectedIds.value = [];
    showUserPicker.value = true;
  };
  const closeUserPicker = () => {
    showUserPicker.value = false;
    editingNodeIndex.value = -1;
    pickerSelectedIds.value = [];
  };
  const isUserSelected = userId => pickerSelectedIds.value.includes(userId);
  const toggleUser = user => {
    const ids = pickerSelectedIds.value;
    const index = ids.indexOf(user.userId);
    if (index >= 0) {
      ids.splice(index, 1);
    } else {
      ids.push(user.userId);
    }
  };
  const confirmUserPicker = () => {
    const node = flowNodes.value[editingNodeIndex.value];
    if (!node) {
      closeUserPicker();
      return;
    }
    const selectedUsers = userList.value.filter(user =>
      pickerSelectedIds.value.includes(user.userId)
    );
    if (!selectedUsers.length) {
      uni.showToast({ title: "请选择审批人", icon: "none" });
      return;
    }
    const startSort = node.approvers.length;
    selectedUsers.forEach((user, idx) => {
      node.approvers.push({
        approverId: user.userId,
        approverName: user.nickName,
        sortNo: startSort + idx + 1,
      });
    });
    closeUserPicker();
  };
  const removeApprover = (nodeIndex, approverIndex) => {
    const node = flowNodes.value[nodeIndex];
    if (!node?.approvers?.length) return;
    const next = node.approvers
      .filter((_, idx) => idx !== approverIndex)
      .map((item, idx) => ({
        ...item,
        sortNo: idx + 1,
      }));
    node.approvers = next;
  };
  const validateFlow = () => {
    if (!flowNodes.value.length) {
      uni.showToast({ title: "请配置审批流程", icon: "none" });
      return false;
    }
    const emptyNode = flowNodes.value.find(node => !node.approvers.length);
    if (emptyNode) {
      uni.showToast({ title: "请为每个审批节点添加审批人", icon: "none" });
      return false;
    }
    return true;
  };
  const buildSubmitPayload = () => {
    const tid = templateId.value;
    const payload = {
      templateName: form.templateName.trim(),
      enabled: form.enabled,
      description: form.description?.trim() || "",
      businessType: form.businessType,
      templateType: form.templateType,
      formConfig: JSON.stringify({
        prompt: formConfig.prompt?.trim() || "",
        fields: formConfig.fields,
      }),
      nodes: flowNodes.value.map((node, index) => {
        const nodePayload = {
          levelNo: index + 1,
          approveType: node.approveType,
          approvers: node.approvers.map((approver, approverIndex) => {
            const approverPayload = {
              approverId: approver.approverId,
              approverName: approver.approverName,
              sortNo: approverIndex + 1,
            };
            if (isEditMode.value) {
              if (approver.id != null) approverPayload.id = approver.id;
              if (approver.nodeId != null) approverPayload.nodeId = approver.nodeId;
              else if (node.id != null) approverPayload.nodeId = node.id;
              if (approver.templateId != null) approverPayload.templateId = approver.templateId;
              else if (tid != null) approverPayload.templateId = tid;
            }
            return approverPayload;
          }),
        };
        if (isEditMode.value) {
          if (node.id != null) nodePayload.id = node.id;
          if (node.templateId != null) nodePayload.templateId = node.templateId;
          else if (tid != null) nodePayload.templateId = tid;
        }
        return nodePayload;
      }),
    };
    if (isEditMode.value) {
      payload.id = tid;
    }
    return payload;
  };
  const handleSubmit = async () => {
    const valid = await formRef.value.validate().catch(() => false);
    if (!valid || !validateFlow()) return;
    submitting.value = true;
    const submitApi = isEditMode.value ? updateApprovalTemplate : addApprovalTemplate;
    submitApi(buildSubmitPayload())
      .then(() => {
        uni.showToast({
          title: isEditMode.value ? "修改成功" : "保存成功",
          icon: "success",
        });
        uni.removeStorageSync(EDIT_STORAGE_KEY);
        setTimeout(() => {
          uni.navigateBack();
        }, 300);
      })
      .catch(() => {
        uni.showToast({
          title: isEditMode.value ? "修改失败" : "保存失败",
          icon: "none",
        });
      })
      .finally(() => {
        submitting.value = false;
      });
  };
  onLoad(options => {
    if (options?.id) {
      const row = uni.getStorageSync(EDIT_STORAGE_KEY);
      if (row && String(row.id) === String(options.id)) {
        fillFormFromRow(row);
      } else {
        templateId.value = options.id;
        uni.showToast({ title: "未获取到模板数据", icon: "none" });
      }
      uni.removeStorageSync(EDIT_STORAGE_KEY);
    }
  });
  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 || [];
      })
      .catch(() => {
        userList.value = [];
      });
  });
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  $primary: #2979ff;
  $primary-light: #ecf5ff;
  $text: #1f2d3d;
  $text-secondary: #606266;
  $text-muted: #909399;
  $border: #ebeef5;
  $bg-page: #f0f3f8;
  $radius-lg: 12px;
  $radius-md: 10px;
  $shadow-card: 0 2px 12px rgba(31, 45, 61, 0.05);
  .template-edit-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: $bg-page;
  }
  .form-scroll {
    flex: 1;
    height: 0;
    padding: 10px 12px calc(96px + env(safe-area-inset-bottom));
  }
  .section-card {
    margin-bottom: 10px;
    background: #fff;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
    &--between {
      display: flex;
      align-items: center;
      justify-content: space-between;
    }
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .head-actions {
    display: flex;
    align-items: center;
    gap: 16px;
  }
  .head-link {
    font-size: 14px;
    color: $text-secondary;
    &--primary {
      color: $primary;
      font-weight: 500;
    }
  }
  .section-body {
    padding: 2px 16px 14px;
  }
  .form-section {
    margin-bottom: 10px;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: $shadow-card;
  }
  :deep(.form-section .u-cell-group__title) {
    padding: 12px 16px 8px !important;
    font-size: 15px !important;
    font-weight: 600 !important;
    color: $text !important;
    background: #fff !important;
  }
  :deep(.form-section .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.form-section .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-name .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-name .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.name-input-inline),
  :deep(.name-input-inline .u-input__content) {
    width: 100% !important;
    flex: 1 !important;
  }
  :deep(.name-input-inline input),
  :deep(.name-input-inline .u-input__content__field-wrapper__field) {
    width: 100% !important;
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-select .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-select .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-select .u-input__content__field-wrapper__field) {
    text-align: right !important;
  }
  :deep(.form-item-switch .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-switch .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    display: flex !important;
    justify-content: flex-end !important;
  }
  .switch-wrap {
    display: flex;
    justify-content: flex-end;
    align-items: center;
    width: 100%;
  }
  :deep(.form-item-desc .u-form-item__body) {
    flex-direction: column !important;
    align-items: stretch !important;
    padding: 10px 0 12px !important;
  }
  :deep(.form-item-desc .u-form-item__content) {
    justify-content: stretch !important;
    width: 100% !important;
  }
  .desc-input-shell {
    width: 100%;
    box-sizing: border-box;
    padding: 8px 12px;
    background: #fff;
    border: 1px solid #dcdfe6;
    border-radius: 6px;
  }
  :deep(.desc-input-shell .u-textarea),
  :deep(.desc-input-shell textarea) {
    width: 100% !important;
    font-size: 15px !important;
    text-align: left !important;
  }
  .form-row-item {
    margin: 0 !important;
    padding: 0 !important;
  }
  :deep(.form-row-item .u-form-item__body) {
    padding: 0;
  }
  :deep(.form-row-item .u-form-item__body__right__message) {
    margin-top: 4px;
    padding-left: 0;
  }
  .form-row {
    padding: 10px 0;
    border-bottom: 1px solid #f5f7fa;
    &:last-child {
      border-bottom: none;
    }
    &--column {
      flex-direction: column;
      align-items: stretch;
      gap: 8px;
    }
    &--compact {
      padding-top: 8px;
    }
  }
  .form-row-label {
    display: block;
    font-size: 14px;
    color: $text-secondary;
    margin-bottom: 8px;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 3px;
    }
  }
  .form-row--column .form-row-label {
    margin-bottom: 0;
  }
  .prompt-row {
    display: flex;
    align-items: center;
    padding: 12px 0;
    margin-bottom: 4px;
    border-bottom: 1px solid #f5f7fa;
    gap: 8px;
  }
  .prompt-label {
    flex-shrink: 0;
    width: 88px;
    font-size: 14px;
    color: $text-secondary;
  }
  .prompt-input {
    flex: 1;
    min-width: 0;
  }
  :deep(.prompt-input),
  :deep(.prompt-input .u-input__content) {
    width: 100% !important;
  }
  :deep(.prompt-input input),
  :deep(.prompt-input .u-input__content__field-wrapper__field) {
    width: 100% !important;
    text-align: right !important;
    font-size: 15px !important;
  }
  .input-box,
  .textarea-box {
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
    overflow: hidden;
  }
  .textarea-box {
    padding: 4px 0;
  }
  .field-list {
    display: flex;
    flex-direction: column;
    gap: 8px;
    margin-top: 8px;
  }
  .field-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    background: #f8fafc;
    border-radius: $radius-md;
    border: 1px solid #eef2f6;
  }
  .field-main {
    flex: 1;
    min-width: 0;
  }
  .field-title-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
  }
  .field-name {
    font-size: 15px;
    font-weight: 600;
    color: $text;
  }
  .type-tag {
    font-size: 11px;
    padding: 2px 8px;
    border-radius: 4px;
    &--text {
      color: #2979ff;
      background: #ecf5ff;
    }
    &--area {
      color: #7c5cfc;
      background: #f3efff;
    }
    &--num {
      color: #e6a23c;
      background: #fdf6ec;
    }
    &--date {
      color: #18a058;
      background: #e8faf0;
    }
  }
  .req-tag {
    font-size: 11px;
    padding: 2px 6px;
    color: #f56c6c;
    background: #fef0f0;
    border-radius: 4px;
  }
  .field-default {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .field-actions {
    display: flex;
    gap: 6px;
    flex-shrink: 0;
  }
  .icon-btn {
    width: 32px;
    height: 32px;
    border-radius: 8px;
    display: flex;
    align-items: center;
    justify-content: center;
    &--edit {
      background: #ecf5ff;
    }
    &--del {
      background: #fef0f0;
    }
  }
  .empty-mini {
    padding: 20px 0;
    text-align: center;
    font-size: 13px;
    color: $text-muted;
  }
  .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: $radius-md;
    padding: 12px;
  }
  .node-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    background: $primary;
    color: #fff;
    font-size: 14px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .node-level-text {
    flex: 1;
    font-size: 15px;
    font-weight: 600;
    color: $text;
  }
  .node-delete {
    padding: 6px;
    flex-shrink: 0;
  }
  .approve-type-row {
    display: flex;
    background: #f0f3f8;
    border-radius: 8px;
    padding: 3px;
    margin-bottom: 10px;
  }
  .type-btn {
    flex: 1;
    text-align: center;
    padding: 8px 0;
    font-size: 14px;
    color: $text-secondary;
    border-radius: 6px;
    &.active {
      background: #fff;
      color: $primary;
      font-weight: 500;
    }
  }
  .approver-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
  }
  .approver-chip {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px 6px 6px;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 24px;
    box-shadow: 0 2px 6px rgba(41, 121, 255, 0.06);
  }
  .approver-avatar {
    width: 26px;
    height: 26px;
    border-radius: 50%;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 12px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .approver-name {
    font-size: 13px;
    color: $text;
    max-width: 80px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .approver-remove {
    flex-shrink: 0;
    width: 22px;
    height: 22px;
    margin-left: 2px;
    border-radius: 50%;
    background: #f2f3f5;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .approver-remove--active {
    background: #fde2e2;
  }
  .remove-icon {
    font-size: 16px;
    line-height: 1;
    color: #909399;
    font-weight: 300;
  }
  .add-approver {
    display: flex;
    align-items: center;
    gap: 4px;
    padding: 8px 14px;
    border: 1.5px dashed #a8cfff;
    border-radius: 24px;
    background: $primary-light;
    color: $primary;
    font-size: 13px;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 4px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #d0dff0;
  }
  .add-node-bar {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin-top: 8px;
    padding: 11px;
    border: 1px dashed #c6daf5;
    border-radius: $radius-md;
    color: $primary;
    font-size: 14px;
  }
  .sheet-handle {
    width: 40px;
    height: 4px;
    margin: 10px auto 6px;
    background: #e4e7ed;
    border-radius: 2px;
  }
  .field-editor,
  .user-picker {
    padding: 0 18px calc(18px + env(safe-area-inset-bottom));
    background: #fff;
    max-height: 85vh;
  }
  .editor-title {
    display: block;
    font-size: 16px;
    font-weight: 600;
    color: $text;
    text-align: center;
    margin-bottom: 14px;
  }
  .editor-form {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .editor-row {
    display: flex;
    flex-direction: column;
    gap: 10px;
    &--switch {
      flex-direction: row;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
    }
  }
  .editor-label {
    font-size: 14px;
    font-weight: 500;
    color: $text-secondary;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 4px;
    }
  }
  .editor-row .input-box,
  .editor-row .textarea-box {
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
  }
  .type-chip-grid {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 10px;
  }
  .type-chip {
    text-align: center;
    padding: 10px 6px;
    font-size: 13px;
    color: $text-secondary;
    background: #f7f9fc;
    border: 1px solid #eef1f6;
    border-radius: 8px;
    &.active {
      color: $primary;
      background: $primary-light;
      border-color: $primary;
      font-weight: 500;
    }
  }
  .default-date-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 0 12px;
    min-height: 44px;
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
  }
  .editor-footer {
    display: flex;
    gap: 10px;
    margin-top: 16px;
    padding-top: 14px;
    border-top: 1px solid #f5f7fa;
  }
  .editor-btn {
    flex: 1;
    text-align: center;
    padding: 11px 0;
    border-radius: 8px;
    font-size: 15px;
    &--cancel {
      color: $text-secondary;
      background: #f5f7fa;
    }
    &--confirm {
      color: #fff;
      background: $primary;
    }
  }
  .picker-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding-bottom: 14px;
    border-bottom: 1px solid #f5f7fa;
    margin-bottom: 8px;
  }
  .picker-title {
    font-size: 16px;
    font-weight: 600;
    color: $text;
  }
  .picker-cancel {
    font-size: 15px;
    color: $text-muted;
    min-width: 48px;
  }
  .picker-confirm {
    font-size: 15px;
    color: $primary;
    font-weight: 600;
    min-width: 48px;
    text-align: right;
  }
  .user-scroll {
    max-height: 52vh;
  }
  .user-item {
    display: flex;
    align-items: center;
    gap: 12px;
    padding: 14px 4px;
    border-bottom: 1px solid #f5f7fa;
    border-radius: 10px;
    margin-bottom: 4px;
    transition: background 0.2s;
    &.selected {
      background: #f5f9ff;
    }
  }
  .user-avatar {
    width: 40px;
    height: 40px;
    border-radius: 12px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 16px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .user-name {
    flex: 1;
    font-size: 15px;
    color: $text;
  }
  .user-check {
    width: 22px;
    height: 22px;
    border-radius: 50%;
    border: 2px solid #dcdfe6;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
    &.checked {
      background: $primary;
      border-color: $primary;
    }
  }
</style>
src/pages/oa/ApproveManage/approve-template/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,314 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹æ¨¡æ¿
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-template/index
-->
<template>
  <view class="approve-template-page sales-account">
    <PageHeader title="审批模板"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="queryParams.templateName"
                    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.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 class="detail-row">
              <text class="detail-label">创建人</text>
              <text class="detail-value">{{ item.createdUserName || "-" }}</text>
            </view>
            <view class="detail-row">
              <text class="detail-label">创建时间</text>
              <text class="detail-value">{{ item.createTime || "-" }}</text>
            </view>
          </view>
          <view class="action-buttons">
            <up-button class="action-btn"
                       size="small"
                       @click.stop="goDetail(item)">
              è¯¦æƒ…
            </up-button>
            <up-button class="action-btn"
                       size="small"
                       type="primary"
                       @click.stop="goEdit(item)">
              ç¼–辑
            </up-button>
            <up-button class="action-btn"
                       size="small"
                       type="error"
                       plain
                       @click.stop="handleDelete(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>
  import { reactive, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import {
    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: "",
  });
  const list = ref([]);
  const pageStatus = ref("loadmore");
  const page = reactive({
    current: 1,
    size: 10,
    total: 0,
  });
  const buildListParams = () => ({
    page: {
      current: page.current,
      size: page.size,
    },
    approvalTemplateDto: {
      templateName: queryParams.templateName?.trim() || undefined,
    },
  });
  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 businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
      .then(opts => {
        typeLabelMap.value = buildTypeLabelMap(opts);
      })
      .catch(() => {});
  const nodeCount = item => {
    const count = item?.nodes?.length;
    return count != null ? `${count} ä¸ª` : "-";
  };
  const getList = () => {
    if (pageStatus.value === "loading" || pageStatus.value === "nomore") return;
    pageStatus.value = "loading";
    listApprovalTemplatePage(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.removeStorageSync(EDIT_STORAGE_KEY);
    uni.navigateTo({
      url: "/pages/oa/ApproveManage/approve-template/edit",
    });
  };
  const goDetail = item => {
    if (!item?.id) return;
    uni.navigateTo({
      url: `/pages/oa/ApproveManage/approve-template/detail?id=${item.id}`,
    });
  };
  const goEdit = item => {
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    uni.navigateTo({
      url: `/pages/oa/ApproveManage/approve-template/edit?id=${item.id}`,
    });
  };
  const handleDelete = item => {
    if (!item?.id) return;
    const name = item.templateName || "该模板";
    uni.showModal({
      title: "删除确认",
      content: `确定删除「${name}」吗?删除后无法恢复。`,
      confirmText: "删除",
      confirmColor: "#f56c6c",
      success: res => {
        if (!res.confirm) return;
        uni.showLoading({ title: "删除中...", mask: true });
        deleteApprovalTemplate([item.id])
          .then(() => {
            uni.showToast({ title: "删除成功", icon: "success" });
            handleSearch();
          })
          .catch(() => {
            uni.showToast({ title: "删除失败", icon: "none" });
          })
          .finally(() => {
            uni.hideLoading();
          });
      },
    });
  };
  onShow(() => {
    loadTemplateTypes();
    handleSearch();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .approve-template-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/AttendManage/leave-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / å‡å‹¤ç®¡ç† / è¯·å‡ç”³è¯·
  è·¯ç”±ï¼š/pages/oa/AttendManage/leave-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</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);
</script>
src/pages/oa/AttendManage/overtime-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / å‡å‹¤ç®¡ç† / åŠ ç­ç”³è¯·
  è·¯ç”±ï¼š/pages/oa/AttendManage/overtime-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</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);
</script>
src/pages/oa/ContractManage/purchase-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
<!--
  OA / åˆåŒç®¡ç† / é‡‡è´­åˆåŒ
  è·¯ç”±ï¼š/pages/oa/ContractManage/purchase-contract/index
  è¯´æ˜Žï¼šè·³è½¬è‡³é‡‡è´­å°è´¦ /pages/procurementManagement/procurementLedger/index
-->
<template>
  <view class="redirect-page" />
</template>
<script setup>
  /** OA - åˆåŒç®¡ç† - é‡‡è´­åˆåŒï¼ˆè·³è½¬é‡‡è´­å°è´¦ï¼‰ */
  import { onLoad } from "@dcloudio/uni-app";
  onLoad(() => {
    uni.redirectTo({
      url: "/pages/procurementManagement/procurementLedger/index",
    });
  });
</script>
<style scoped>
  .redirect-page {
    min-height: 100vh;
    background: #f8f9fa;
  }
</style>
src/pages/oa/ContractManage/sale-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
<!--
  OA / åˆåŒç®¡ç† / é”€å”®åˆåŒ
  è·¯ç”±ï¼š/pages/oa/ContractManage/sale-contract/index
  è¯´æ˜Žï¼šè·³è½¬è‡³é”€å”®å°è´¦ /pages/sales/salesAccount/index
-->
<template>
  <view class="redirect-page" />
</template>
<script setup>
  /** OA - åˆåŒç®¡ç† - é”€å”®åˆåŒï¼ˆè·³è½¬é”€å”®å°è´¦ï¼‰ */
  import { onLoad } from "@dcloudio/uni-app";
  onLoad(() => {
    uni.redirectTo({
      url: "/pages/sales/salesAccount/index",
    });
  });
</script>
<style scoped>
  .redirect-page {
    min-height: 100vh;
    background: #f8f9fa;
  }
</style>
src/pages/oa/EnterpriseNews/news-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / ä¼ä¸šæ–°é—»
  è·¯ç”±ï¼š/pages/oa/EnterpriseNews/news-manage/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - ä¼ä¸šæ–°é—» */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "EnterpriseNews/news-manage";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/post-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å²—位管理
  è·¯ç”±ï¼š/pages/oa/HrManage/post-manage/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å²—位管理 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/post-manage";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/regular-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / è½¬æ­£ç”³è¯·
  è·¯ç”±ï¼š/pages/oa/HrManage/regular-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</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);
</script>
src/pages/oa/HrManage/resign-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / ç¦»èŒç”³è¯·
  è·¯ç”±ï¼š/pages/oa/HrManage/resign-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - ç¦»èŒç”³è¯· */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/resign-apply";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/staff-archive/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å‘˜å·¥æ¡£æ¡ˆ
  è·¯ç”±ï¼š/pages/oa/HrManage/staff-archive/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å‘˜å·¥æ¡£æ¡ˆ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/staff-archive";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/staff-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å‘˜å·¥åˆåŒ
  è·¯ç”±ï¼š/pages/oa/HrManage/staff-contract/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - äººäº‹ç®¡ç† - å‘˜å·¥åˆåŒ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "HrManage/staff-contract";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/HrManage/transfer-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / è°ƒå²—申请
  è·¯ç”±ï¼š/pages/oa/HrManage/transfer-apply/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</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);
</script>
src/pages/oa/HrManage/work-handover/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / äººäº‹ç®¡ç† / å·¥ä½œäº¤æŽ¥
  è·¯ç”±ï¼š/pages/oa/HrManage/work-handover/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</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);
</script>
src/pages/oa/NoticeAnnouncement/notice-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / å…¬å‘Šé€šçŸ¥
  è·¯ç”±ï¼š/pages/oa/NoticeAnnouncement/notice-manage/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - å…¬å‘Šé€šçŸ¥ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "NoticeAnnouncement/notice-manage";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/ReimburseManage/cost-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / æŠ¥é”€ç®¡ç† / è´¹ç”¨æŠ¥é”€
  è·¯ç”±ï¼š/pages/oa/ReimburseManage/cost-reimburse/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - æŠ¥é”€ç®¡ç† - è´¹ç”¨æŠ¥é”€ */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "ReimburseManage/cost-reimburse";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/ReimburseManage/travel-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / æŠ¥é”€ç®¡ç† / å·®æ—…报销
  è·¯ç”±ï¼š/pages/oa/ReimburseManage/travel-reimburse/index
-->
<template>
  <OaListPage v-if="config"
              :page-key="pageKey"
              :page-config="config" />
</template>
<script setup>
  /** OA - æŠ¥é”€ç®¡ç† - å·®æ—…报销 */
  import OaListPage from "../../_components/OaListPage.vue";
  import { useOaPage } from "../../_utils/useOaPage.js";
  const pageKey = "ReimburseManage/travel-reimburse";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/_components/OaListPage.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,182 @@
<template>
  <view class="oa-page sales-account">
    <PageHeader :title="pageConfig.title"
                @back="goBack" />
    <view class="search-section">
      <view class="search-bar">
        <view class="search-input">
          <up-input v-model="keyword"
                    class="search-text"
                    :placeholder="`搜索${pageConfig.title}`"
                    clearable
                    @change="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">
      <view v-if="displayList.length"
            class="ledger-list">
        <view v-for="item in displayList"
              :key="item.id"
              class="ledger-item"
              @click="openDetail(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.summary || item.applicantName || pageConfig.title }}</text>
            </view>
            <u-tag :type="getStatusMeta(item.status).type"
                   :text="getStatusMeta(item.status).text" />
          </view>
          <up-divider />
          <view class="item-details">
            <view v-for="field in pageConfig.fields"
                  :key="field.prop"
                  class="detail-row">
              <text class="detail-label">{{ field.label }}</text>
              <text class="detail-value">{{ item[field.prop] || "-" }}</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">{{ item.createTime || "-" }}</text>
            </view>
          </view>
        </view>
      </view>
      <view v-else
            class="empty-wrap">
        <up-empty mode="list"
                  :text="`暂无${pageConfig.title}数据`" />
      </view>
    </scroll-view>
    <view class="footer-add">
      <up-button type="primary"
                 text="新增"
                 @click="handleAdd" />
    </view>
  </view>
</template>
<script setup>
  import { computed, ref } from "vue";
  import { onShow } from "@dcloudio/uni-app";
  import PageHeader from "@/components/PageHeader.vue";
  import { ensureList, saveList } from "../_utils/oaStorage.js";
  import { getStatusMeta } from "../_utils/oaPageRegistry.js";
  import { showToast } from "../_utils/oaUi.js";
  const props = defineProps({
    pageKey: {
      type: String,
      required: true,
    },
    pageConfig: {
      type: Object,
      required: true,
    },
  });
  const keyword = ref("");
  const list = ref([]);
  const displayList = computed(() => {
    const kw = keyword.value.trim();
    if (!kw) return list.value;
    return list.value.filter(item => {
      const text = [
        item.summary,
        item.applicantName,
        item.deptName,
        ...props.pageConfig.fields.map(f => item[f.prop]),
      ]
        .filter(Boolean)
        .join(" ");
      return text.includes(kw);
    });
  });
  const loadData = () => {
    list.value = ensureList(
      props.pageConfig.storageKey,
      props.pageConfig.mockRows || []
    );
  };
  const handleSearch = () => {
    /* å…³é”®å­—ç”± computed è¿‡æ»¤ */
  };
  const goBack = () => {
    uni.navigateBack();
  };
  const openDetail = item => {
    showToast(`查看:${item.summary || props.pageConfig.title}`);
  };
  const handleAdd = () => {
    const row = {
      ...(props.pageConfig.mockRows?.[0] || {}),
      id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
      applicantName: "当前用户",
      status: "pending",
      createTime: new Date().toISOString().slice(0, 19).replace("T", " "),
      summary: `新建${props.pageConfig.title}`,
    };
    list.value = [row, ...list.value];
    saveList(props.pageConfig.storageKey, list.value);
    showToast("已新增(本地示例)", "success");
  };
  onShow(() => {
    loadData();
  });
</script>
<style scoped lang="scss">
  @import "@/styles/sales-common.scss";
  .oa-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
  }
  .list-scroll {
    flex: 1;
    height: 0;
    padding-bottom: 80px;
  }
  .empty-wrap {
    padding: 48px 20px;
  }
  .footer-add {
    position: fixed;
    left: 0;
    right: 0;
    bottom: 0;
    padding: 12px 20px calc(12px + env(safe-area-inset-bottom));
    background: #fff;
    box-shadow: 0 -2px 12px rgba(0, 0, 0, 0.06);
  }
</style>
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;
src/pages/oa/_utils/oaPageRegistry.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,256 @@
import { OA_NAV } from "@/config/oaPaths.js";
const STATUS_MAP = {
  pending: { text: "审核中", type: "warning" },
  approved: { text: "已通过", type: "success" },
  rejected: { text: "已驳回", type: "error" },
  draft: { text: "草稿", type: "info" },
  published: { text: "已发布", type: "success" },
};
function baseRow(extra = {}) {
  return {
    id: `${Date.now()}_${Math.random().toString(36).slice(2, 8)}`,
    applicantName: "张三",
    deptName: "研发部",
    status: "pending",
    createTime: "2026-05-18 09:00:00",
    summary: "示例数据,可对接后端接口",
    ...extra,
  };
}
/** å„子页面配置:title、storageKey、列表展示字段、初始 mock */
export const OA_PAGE_REGISTRY = {
  "HrManage/staff-archive": {
    title: "员工档案",
    module: "人事管理",
    storageKey: "oa_hr_staff_archive_v1",
    path: OA_NAV.staffArchive,
    fields: [
      { label: "员工编号", prop: "staffNo" },
      { label: "岗位", prop: "postJob" },
      { label: "联系电话", prop: "phone" },
    ],
    mockRows: [
      baseRow({
        staffNo: "E2026001",
        postJob: "工程师",
        phone: "13800000001",
        summary: "李明 Â· åœ¨èŒ",
      }),
    ],
  },
  "HrManage/staff-contract": {
    title: "员工合同",
    module: "人事管理",
    storageKey: "oa_hr_staff_contract_v1",
    path: OA_NAV.staffContract,
    fields: [
      { label: "合同编号", prop: "contractNo" },
      { label: "合同类型", prop: "contractType" },
      { label: "到期日", prop: "endDate" },
    ],
    mockRows: [
      baseRow({
        contractNo: "HT-2026-001",
        contractType: "劳动合同",
        endDate: "2027-12-31",
      }),
    ],
  },
  "HrManage/regular-apply": {
    title: "转正申请",
    module: "人事管理",
    storageKey: "oa_hr_regular_apply_v1",
    path: OA_NAV.regularApply,
    fields: [
      { label: "入职日期", prop: "entryDate" },
      { label: "转正日期", prop: "regularDate" },
    ],
    mockRows: [baseRow({ entryDate: "2025-11-01", regularDate: "2026-05-20" })],
  },
  "HrManage/transfer-apply": {
    title: "调岗申请",
    module: "人事管理",
    storageKey: "oa_hr_transfer_apply_v1",
    path: OA_NAV.transferApply,
    fields: [
      { label: "原岗位", prop: "fromPost" },
      { label: "目标岗位", prop: "toPost" },
    ],
    mockRows: [
      baseRow({ fromPost: "开发工程师", toPost: "高级开发工程师" }),
    ],
  },
  "HrManage/resign-apply": {
    title: "离职申请",
    module: "人事管理",
    storageKey: "oa_hr_resign_apply_v1",
    path: OA_NAV.resignApply,
    fields: [
      { label: "预计离职日", prop: "leaveDate" },
      { label: "离职原因", prop: "reason" },
    ],
    mockRows: [baseRow({ leaveDate: "2026-06-30", reason: "个人发展" })],
  },
  "HrManage/work-handover": {
    title: "工作交接",
    module: "人事管理",
    storageKey: "oa_hr_work_handover_v1",
    path: OA_NAV.workHandover,
    fields: [
      { label: "交接人", prop: "handoverTo" },
      { label: "交接事项", prop: "handoverItems" },
    ],
    mockRows: [
      baseRow({ handoverTo: "王五", handoverItems: "项目文档、客户资料" }),
    ],
  },
  "HrManage/post-manage": {
    title: "岗位管理",
    module: "人事管理",
    storageKey: "oa_hr_post_manage_v1",
    path: OA_NAV.postManage,
    fields: [
      { label: "岗位编码", prop: "postCode" },
      { label: "所属部门", prop: "deptName" },
    ],
    mockRows: [baseRow({ postCode: "DEV-01", summary: "开发工程师" })],
  },
  "AttendManage/leave-apply": {
    title: "请假申请",
    module: "假勤管理",
    storageKey: "oa_attend_leave_apply_v1",
    path: OA_NAV.leaveApply,
    fields: [
      { label: "请假类型", prop: "leaveType" },
      { label: "开始时间", prop: "startTime" },
      { label: "结束时间", prop: "endTime" },
    ],
    mockRows: [
      baseRow({
        leaveType: "年假",
        startTime: "2026-05-20 09:00",
        endTime: "2026-05-21 18:00",
      }),
    ],
  },
  "AttendManage/overtime-apply": {
    title: "加班申请",
    module: "假勤管理",
    storageKey: "oa_attend_overtime_apply_v1",
    path: OA_NAV.overtimeApply,
    fields: [
      { label: "加班日期", prop: "overtimeDate" },
      { label: "时长(小时)", prop: "hours" },
    ],
    mockRows: [
      baseRow({ overtimeDate: "2026-05-18", hours: "3", summary: "版本上线" }),
    ],
  },
  "ReimburseManage/travel-reimburse": {
    title: "差旅报销",
    module: "报销管理",
    storageKey: "oa_reimburse_travel_v1",
    path: OA_NAV.travelReimburse,
    fields: [
      { label: "出差地点", prop: "destination" },
      { label: "金额(元)", prop: "amount" },
    ],
    mockRows: [
      baseRow({ destination: "上海", amount: "2680.50", summary: "客户拜访差旅" }),
    ],
  },
  "ReimburseManage/cost-reimburse": {
    title: "费用报销",
    module: "报销管理",
    storageKey: "oa_reimburse_cost_v1",
    path: OA_NAV.costReimburse,
    fields: [
      { label: "费用科目", prop: "category" },
      { label: "金额(元)", prop: "amount" },
    ],
    mockRows: [
      baseRow({ category: "办公用品", amount: "356.00", summary: "采购文具" }),
    ],
  },
  "ApproveManage/approve-list": {
    title: "审批列表",
    module: "审批管理",
    storageKey: "oa_unified_approve_list_v1",
    path: OA_NAV.approveList,
    fields: [
      { label: "审批类型", prop: "approvalTypeLabel" },
      { label: "当前节点", prop: "currentNode" },
    ],
    mockRows: [
      baseRow({
        approvalTypeLabel: "请假申请",
        currentNode: "部门负责人",
      }),
    ],
  },
  "ApproveManage/approve-template": {
    title: "审批模板",
    module: "审批管理",
    storageKey: "oa_approve_template_custom_v1",
    path: OA_NAV.approveTemplate,
    fields: [
      { label: "模板名称", prop: "templateName" },
      { label: "节点数", prop: "nodeCount" },
    ],
    mockRows: [
      baseRow({
        templateName: "通用审批流",
        nodeCount: "3",
        status: "approved",
        summary: "系统内置模板",
      }),
    ],
  },
  "EnterpriseNews/news-manage": {
    title: "企业新闻",
    module: "企业新闻",
    storageKey: "oa_enterprise_news_v1",
    path: OA_NAV.enterpriseNews,
    fields: [
      { label: "栏目", prop: "category" },
      { label: "阅读量", prop: "readCount" },
    ],
    mockRows: [
      baseRow({
        category: "公司动态",
        readCount: "128",
        status: "published",
        summary: "2026年第一季度经营通报",
      }),
    ],
  },
  "NoticeAnnouncement/notice-manage": {
    title: "公告通知",
    module: "公告通知",
    storageKey: "oa_notice_announcement_v1",
    path: OA_NAV.noticeAnnouncement,
    fields: [
      { label: "公告类型", prop: "noticeType" },
      { label: "优先级", prop: "priority" },
    ],
    mockRows: [
      baseRow({
        noticeType: "企业公告",
        priority: "普通",
        status: "published",
        summary: "五一劳动节放假安排",
      }),
    ],
  },
};
export function getOaPageConfig(pageKey) {
  return OA_PAGE_REGISTRY[pageKey] || null;
}
export function getStatusMeta(status) {
  return STATUS_MAP[status] || { text: status || "—", type: "info" };
}
src/pages/oa/_utils/oaStorage.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,26 @@
export function loadList(storageKey, defaultRows = []) {
  try {
    const raw = uni.getStorageSync(storageKey);
    if (!raw) {
      return defaultRows.map(row => ({ ...row }));
    }
    const parsed = typeof raw === "string" ? JSON.parse(raw) : raw;
    return Array.isArray(parsed)
      ? parsed.map(row => ({ ...row }))
      : defaultRows.map(row => ({ ...row }));
  } catch {
    return defaultRows.map(row => ({ ...row }));
  }
}
export function saveList(storageKey, rows) {
  uni.setStorageSync(storageKey, JSON.stringify(rows));
}
export function ensureList(storageKey, defaultRows) {
  const list = loadList(storageKey, defaultRows);
  if (!uni.getStorageSync(storageKey)) {
    saveList(storageKey, list);
  }
  return list;
}
src/pages/oa/_utils/oaUi.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,13 @@
export function showToast(title, icon = "none") {
  uni.showToast({ title, icon });
}
export function confirmModal(content, title = "提示") {
  return new Promise(resolve => {
    uni.showModal({
      title,
      content,
      success: res => resolve(Boolean(res.confirm)),
    });
  });
}
src/pages/oa/_utils/useOaPage.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,6 @@
import { getOaPageConfig } from "./oaPageRegistry.js";
export function useOaPage(pageKey) {
  const config = getOaPageConfig(pageKey);
  return { pageKey, config };
}
src/pages/works.vue
@@ -1,5 +1,28 @@
<template>
  <view class="content">
    <!-- OA办公模块 -->
    <view class="common-module oa-module"
          v-if="hasOaItems">
      <view class="module-header">
        <view class="module-title-container">
          <text class="module-title">OA办公</text>
        </view>
      </view>
      <view class="module-content">
        <up-grid :border="false"
                 col="4">
          <up-grid-item v-for="(item, index) in oaItems"
                        :key="index"
                        @click="handleCommonItemClick(item)">
            <view class="icon-container">
              <image :src="item.icon"
                     class="item-icon"></image>
            </view>
            <text class="item-label">{{item.label}}</text>
          </up-grid-item>
        </up-grid>
      </view>
    </view>
    <!-- ååŒåŠžå…¬æ¨¡å— -->
    <view class="common-module collaboration-module"
          v-if="hasCollaborationItems">
@@ -308,6 +331,7 @@
  import { userLoginFacotryList } from "@/api/login";
  import { getProductWorkOrderById } from "@/api/productionManagement/productionReporting";
  import DownloadProgressMask from "@/components/DownloadProgressMask.vue";
  import { OA_WORKBENCH_ITEMS } from "@/config/oaWorkbench.js";
  import modal from "@/plugins/modal";
  import useUserStore from "@/store/modules/user";
@@ -535,6 +559,11 @@
      label: "安全培训考核",
    },
  ]);
  // OA办公功能数据(纯前端配置,不参与后端权限过滤)
  const oaItems = reactive(
    OA_WORKBENCH_ITEMS.map(item => ({ ...item }))
  );
  // ååŒåŠžå…¬åŠŸèƒ½æ•°æ®
  const collaborationItems = reactive([
    {
@@ -630,6 +659,10 @@
  // å¤„理常用功能点击
  const handleCommonItemClick = item => {
    if (item.path) {
      uni.navigateTo({ url: item.path });
      return;
    }
    // æ ¹æ®ä¸åŒçš„功能项进行跳转
    switch (item.label) {
      case "客户档案":
@@ -1268,6 +1301,7 @@
  const hasAfterSalesServiceItems = computed(
    () => afterSalesServiceItems.length > 0
  );
  const hasOaItems = computed(() => oaItems.length > 0);
  const hasCollaborationItems = computed(() => collaborationItems.length > 0);
  const hasSafetyItems = computed(() => safetyItems.length > 0);
  const hasQualityItems = computed(() => qualityItems.length > 0);
@@ -1646,6 +1680,10 @@
    --module-color: #4caf50;
  }
  .oa-module {
    --module-color: #673ab7;
  }
  .production-module {
    --module-color: #ff9800;
  }
@@ -1888,6 +1926,10 @@
    --module-color: #4caf50;
  }
  .oa-module {
    --module-color: #673ab7;
  }
  .production-module {
    --module-color: #ff9800;
  }