yyb
2026-05-19 2256ff02d95fd71e1522bc9422356774b701c153
OA页面/审批模板
已添加27个文件
已修改3个文件
3418 ■■■■■ 文件已修改
src/api/oa/approvalTemplate.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaPaths.js 41 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaWorkbench.js 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages.json 133 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/indexItem.vue 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/detail.vue 418 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/edit.vue 1567 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/index.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | 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/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/oa/approvalTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import request from "@/utils/request";
/** å®¡æ‰¹æ¨¡æ¿åˆ†é¡µæŸ¥è¯¢ */
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,41 @@
/**
 * 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`,
  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,139 @@
        "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-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/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,18 @@
<!--
  OA / å®¡æ‰¹ç®¡ç† / å®¡æ‰¹åˆ—表
  è·¯ç”±ï¼š/pages/oa/ApproveManage/approve-list/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 = "ApproveManage/approve-list";
  const { config } = useOaPage(pageKey);
</script>
src/pages/oa/ApproveManage/approve-template/detail.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,418 @@
<!--
  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">{{ templateTypeText(detail.templateType) }}</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";
  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 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 templateTypeText = type => {
    const val = Number(type);
    if (val === 0) return "系统内置";
    if (val === 1) return "自定义";
    return "-";
  };
  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 => {
    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,1567 @@
<!--
  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="templateType"
                        required
                        class="form-item-type">
            <up-radio-group v-model="form.templateType"
                            class="type-radio-group"
                            placement="row"
                            @change="onTemplateTypeChange">
              <up-radio v-for="opt in TEMPLATE_TYPE_OPTIONS"
                        :key="opt.value"
                        :name="opt.value"
                        :label="opt.name" />
            </up-radio-group>
          </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>
  </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";
  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: "",
    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" }],
    templateType: [
      {
        validator: (_rule, value, callback) => {
          if (value === "" || value === null || value === undefined) {
            callback(new Error("请选择模板类型"));
            return;
          }
          callback();
        },
        trigger: "change",
      },
    ],
  };
  const TEMPLATE_TYPE_OPTIONS = [
    { name: "系统内置", value: 0 },
    { name: "自定义", value: 1 },
  ];
  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 || "";
    form.templateType =
      row.templateType === 0 || row.templateType === 1
        ? row.templateType
        : Number(row.templateType) || 1;
    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 onTemplateTypeChange = () => {
    formRef.value?.validateField?.("templateType");
  };
  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() || "",
      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);
    }
  });
  onMounted(() => {
    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-type .u-form-item__body) {
    align-items: center !important;
  }
  .type-radio-group {
    display: flex;
    justify-content: flex-end;
    flex-wrap: nowrap;
  }
  :deep(.type-radio-group .u-radio) {
    margin-left: 20px;
  }
  :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,304 @@
<!--
  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">{{ templateTypeText(item.templateType) }}</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";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  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 templateTypeText = type => {
    const val = Number(type);
    if (val === 0) return "系统内置";
    if (val === 1) return "自定义";
    return "-";
  };
  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(() => {
    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/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;
  }