yyb
10 天以前 b014cdaf7fcf42cd2b310968f9d47d4420444a6a
审批模板增加配置模板导入,按钮权限控制,新建页面ui优化
已添加1个文件
已修改8个文件
2734 ■■■■ 文件已修改
src/api/oa/approvalInstance.js 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/config/oaWorkbench.js 50 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/apply.vue 802 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-list/index.vue 44 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/detail.vue 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/edit.vue 1443 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/ApproveManage/approve-template/index.vue 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalFormField.js 363 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/pages/oa/_utils/approvalTemplateType.js 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/oa/approvalInstance.js
@@ -18,7 +18,10 @@
  });
}
/** å®¡æ ¸ä¸­ä¿®æ”¹å®¡æ‰¹å®žä¾‹ PUT /approvalInstance/update */
/**
 * ä¿®æ”¹å®¡æ‰¹å®žä¾‹ PUT /approvalInstance/update
 * @param {Object} approvalInstanceDto å®¡æ‰¹å®žä¾‹ï¼ˆéœ€å« id,其余字段按业务保留/更新)
 */
export function updateApprovalInstance(approvalInstanceDto) {
  return request({
    url: "/approvalInstance/update",
src/config/oaWorkbench.js
@@ -8,13 +8,13 @@
    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/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 },
      // { label: "岗位管理", icon: "/static/images/icon/gongxuguanli.svg", path: OA_NAV.postManage },
    ],
  },
  {
@@ -33,14 +33,14 @@
      { 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: "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: "审批管理",
@@ -49,20 +49,20 @@
      { 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 },
    ],
  },
  // {
  //   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 },
  //   ],
  // },
];
/** å·¥ä½œå°æ‰å¹³èœå•(纯前端配置) */
src/pages/oa/ApproveManage/approve-list/apply.vue
@@ -16,83 +16,150 @@
        <text class="loading-text">加载中...</text>
      </view>
      <template v-else-if="detail">
        <view class="section">
          <view class="section-title">基本信息</view>
          <view class="form-body">
            <view class="form-row">
              <text class="form-label required">审批标题</text>
        <up-form :model="form"
                 label-width="88"
                 input-align="right">
          <u-cell-group title="基本信息"
                        class="form-section">
            <up-form-item label="审批标题"
                          required
                          class="form-item-name">
              <up-input v-model="form.title"
                        class="name-input-inline"
                        placeholder="请输入审批标题"
                        maxlength="100"
                        clearable />
            </view>
            <view class="form-row">
              <text class="form-label">审批模板</text>
              <text class="form-readonly">{{ templateName }}</text>
            </view>
            <view class="form-row">
              <text class="form-label">申请人</text>
              <text class="form-readonly">{{ displayApplicantName }}</text>
            </view>
          </view>
        </view>
            </up-form-item>
            <up-form-item label="审批模板"
                          class="form-item-readonly">
              <up-input :model-value="templateName"
                        readonly />
            </up-form-item>
            <up-form-item label="申请人"
                          class="form-item-readonly">
              <up-input :model-value="displayApplicantName"
                        readonly />
            </up-form-item>
          </u-cell-group>
        </up-form>
        <view class="section">
          <view class="section-title">填报内容</view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">填报内容</text>
          </view>
          <view v-if="formConfigData.prompt"
                class="form-prompt">
            {{ formConfigData.prompt }}
          </view>
          <view v-if="formConfigData.fields.length"
                class="form-body">
            <view v-for="field in formConfigData.fields"
                  :key="field.key"
                  class="form-row form-row--field">
              <text class="form-label"
                    :class="{ required: field.required }">{{ field.label }}</text>
              <up-textarea v-if="field.type === 'textarea'"
          <up-form v-if="formConfigData.fields.length"
                   :model="formValues"
                   label-width="88"
                   input-align="right"
                   class="dynamic-form">
            <up-form-item v-for="field in formConfigData.fields"
                          :key="field.key"
                          :label="field.label"
                          :required="!!field.required"
                          :class="formItemClass(field)">
              <up-textarea v-if="isTextareaField(field)"
                           v-model="formValues[field.key]"
                           :placeholder="`请输入${field.label}`"
                           maxlength="500"
                           border="surround"
                           height="80" />
              <view v-else-if="field.type === 'date'"
                    class="date-trigger"
                    @click="openDatePicker(field.key)">
                <up-input :model-value="formValues[field.key]"
              <view v-else-if="isDatetimerangeField(field)"
                    class="daterange-fill">
                <view class="range-fill-row"
                      @click="openRangePicker(field, 'start')">
                  <text class="range-fill-label">开始</text>
                  <up-input :model-value="getRangePartDisplay(field, 'start')"
                            placeholder="开始时间"
                            readonly />
                  <up-icon name="calendar"
                           size="16"
                           color="#909399" />
                </view>
                <text class="range-fill-sep">至</text>
                <view class="range-fill-row"
                      @click="openRangePicker(field, 'end')">
                  <text class="range-fill-label">结束</text>
                  <up-input :model-value="getRangePartDisplay(field, 'end')"
                            placeholder="结束时间"
                            readonly />
                  <up-icon name="calendar"
                           size="16"
                           color="#909399" />
                </view>
              </view>
              <view v-else-if="isDateLikeField(field)"
                    class="field-trigger"
                    @click="openDatePicker(field)">
                <up-input :model-value="formatFieldDisplayValue(field, formValues[field.key])"
                          :placeholder="`请选择${field.label}`"
                          readonly />
                <up-icon :name="getDatePickerMode(field) === 'time' ? 'clock' : 'calendar'"
                         size="18"
                         color="#909399" />
              </view>
              <view v-else-if="isSelectField(field)"
                    class="field-trigger"
                    @click="openSelectPicker(field)">
                <up-input :model-value="getSelectDisplayText(field)"
                          :placeholder="`请选择${field.label}`"
                          readonly />
                <up-icon name="arrow-right"
                         size="16"
                         color="#c0c4cc" />
              </view>
              <up-input v-else
                        v-model="formValues[field.key]"
                        :type="field.type === 'number' ? 'digit' : 'text'"
                        :type="isNumberField(field) ? 'digit' : 'text'"
                        :placeholder="`请输入${field.label}`"
                        clearable />
            </view>
          </view>
            </up-form-item>
          </up-form>
          <view v-else
                class="empty-hint">该模板暂无填报项</view>
        </view>
        <view class="section">
          <view class="section-title">审批流程</view>
        <view class="section-card">
          <view class="section-head">
            <text class="section-title">审批流程</text>
          </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>
                class="flow-wrap">
            <view v-for="(node, nodeIndex) in detail.nodes"
                  :key="node.id || nodeIndex"
                  class="flow-node-block">
              <view class="flow-node-card">
                <view class="node-header">
                  <view class="node-level-badge">{{ node.levelNo || nodeIndex + 1 }}</view>
                  <text class="node-level-text">第{{ levelLabel(node.levelNo || nodeIndex + 1) }}级</text>
                </view>
                <view class="approve-type-row approve-type-row--readonly">
                  <view class="type-btn"
                        :class="{ active: node.approveType !== 'OR' }">
                    ä¼šç­¾
                  </view>
                  <view class="type-btn"
                        :class="{ active: node.approveType === 'OR' }">
                    æˆ–ç­¾
                  </view>
                </view>
                <view class="approver-list">
                  <view v-for="(approver, aIdx) in node.approvers || []"
                        :key="approver.id || aIdx"
                        class="approver-chip">
                    <view class="approver-avatar">{{ (approver.approverName || "?").charAt(0) }}</view>
                    <text class="approver-name">{{ approver.approverName || "-" }}</text>
                  </view>
                  <text v-if="!(node.approvers || []).length"
                        class="empty-hint inline">暂无审批人</text>
                </view>
              </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 v-if="nodeIndex < detail.nodes.length - 1"
                    class="flow-connector">
                <view class="flow-connector-line" />
              </view>
            </view>
          </view>
@@ -119,10 +186,16 @@
              @close="showDatePicker = false">
      <up-datetime-picker :show="true"
                          v-model="datePickerTs"
                          mode="date"
                          :mode="datePickerMode"
                          @confirm="onDateConfirm"
                          @cancel="showDatePicker = false" />
                          @cancel="onDatePickerCancel" />
    </up-popup>
    <up-action-sheet :show="showSelectSheet"
                     :title="selectSheetTitle"
                     :actions="selectSheetActions"
                     @select="onSelectOption"
                     @close="showSelectSheet = false" />
  </view>
</template>
@@ -137,7 +210,28 @@
    updateApprovalInstance,
  } from "@/api/oa/approvalInstance.js";
  import useUserStore from "@/store/modules/user";
  import { formatDateToYMD, parseTime } from "@/utils/ruoyi";
  import { parseTime } from "@/utils/ruoyi";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import {
    formatDatetimerangeDisplay,
    formatFieldDateValue,
    formatFieldDisplayValue,
    getDatePickerMode,
    getFieldInitialValue,
    getFieldOptionLabel,
    isDatetimerangeField,
    isDateLikeField,
    isNumberField,
    isSelectField,
    isTextareaField,
    joinDatetimerangeValue,
    mergeFormConfigForEdit,
    parseDatetimerangeValue,
    resolveFieldOptions,
    parseApprovalFormConfig,
    parseFieldDateToTs,
  } from "../../_utils/approvalFormField.js";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
@@ -154,12 +248,25 @@
  const showDatePicker = ref(false);
  const datePickerTs = ref(Date.now());
  const activeDateFieldKey = ref("");
  const activeDateField = ref(null);
  const activeRangePart = ref("start");
  const datePickerMode = computed(() => {
    const field = activeDateField.value;
    if (!field) return "date";
    if (isDatetimerangeField(field)) return "datetime";
    return getDatePickerMode(field);
  });
  const showSelectSheet = ref(false);
  const activeSelectField = ref(null);
  const pickerUserList = ref([]);
  const pickerDeptList = ref([]);
  const isEditMode = computed(() => !!instanceId.value);
  const pageTitle = computed(() => (isEditMode.value ? "编辑审批" : "发起审批"));
  const confirmText = computed(() => (isEditMode.value ? "保存" : "提交审批"));
  const pageTitle = computed(() => (isEditMode.value ? "修改审批" : "发起审批"));
  const confirmText = computed(() => (isEditMode.value ? "保存修改" : "提交审批"));
  const applicantName = computed(
    () => userStore.nickName || userStore.name || "-"
@@ -173,28 +280,72 @@
    () => detail.value?.templateName || instanceRow.value?.templateName || "-"
  );
  const parseFormConfig = raw => {
    if (!raw) return { prompt: "", fields: [] };
    try {
      const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
      return {
        prompt: obj?.prompt || "",
        fields: Array.isArray(obj?.fields) ? obj.fields : [],
      };
    } catch {
      return { prompt: "", fields: [] };
    }
  };
  const formConfigData = computed(() => {
    const raw = isEditMode.value
      ? instanceRow.value?.formConfig
      : detail.value?.formConfig;
    return parseFormConfig(raw);
    if (isEditMode.value) {
      return mergeFormConfigForEdit(
        detail.value?.formConfig,
        instanceRow.value?.formConfig
      );
    }
    return parseApprovalFormConfig(detail.value?.formConfig);
  });
  const levelLabel = n => LEVEL_TEXT[Number(n)] || String(n);
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
  const selectSheetTitle = computed(
    () => (activeSelectField.value?.label ? `选择${activeSelectField.value.label}` : "请选择")
  );
  const selectSheetActions = computed(() => {
    const field = activeSelectField.value;
    if (!field) return [];
    return resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    }).map(opt => ({
      name: opt.label,
      value: opt.value,
    }));
  });
  const formItemClass = field => {
    if (isTextareaField(field)) return "form-item-textarea";
    if (isDatetimerangeField(field)) return "form-item-daterange";
    if (isSelectField(field) || isDateLikeField(field)) return "form-item-select";
    return "form-item-inline";
  };
  const getRangePartDisplay = (field, part) => {
    const parts = parseDatetimerangeValue(formValues[field.key]);
    const val = part === "start" ? parts.start : parts.end;
    return val ? formatFieldDisplayValue({ type: "datetime" }, val) : "";
  };
  const openRangePicker = (field, part) => {
    activeDateField.value = field;
    activeRangePart.value = part;
    const parts = parseDatetimerangeValue(formValues[field.key]);
    const val = part === "start" ? parts.start : parts.end;
    datePickerTs.value = parseFieldDateToTs(val) ?? Date.now();
    showDatePicker.value = true;
  };
  const getSelectDisplayText = field => {
    const stored = formValues[field.key];
    const options = resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    });
    const matched = options.find(
      opt =>
        String(opt.value) === String(stored) || String(opt.label) === String(stored)
    );
    return (
      matched?.label ||
      getFieldOptionLabel(field, stored) ||
      (stored !== undefined && stored !== null ? String(stored) : "")
    );
  };
  const initFormValues = fields => {
    Object.keys(formValues).forEach(key => {
@@ -202,23 +353,60 @@
    });
    fields.forEach(field => {
      if (!field?.key) return;
      formValues[field.key] = field.value ?? field.defaultValue ?? "";
      formValues[field.key] = getFieldInitialValue(field);
    });
  };
  const openDatePicker = fieldKey => {
    activeDateFieldKey.value = fieldKey;
    const current = formValues[fieldKey];
    datePickerTs.value = current ? new Date(current).getTime() : Date.now();
  const openSelectPicker = field => {
    const options = resolveFieldOptions(field, {
      users: pickerUserList.value,
      depts: pickerDeptList.value,
    });
    if (!options.length) {
      uni.showToast({ title: "该字段未配置下拉选项", icon: "none" });
      return;
    }
    activeSelectField.value = field;
    showSelectSheet.value = true;
  };
  const onSelectOption = action => {
    const key = activeSelectField.value?.key;
    if (key) {
      formValues[key] = action.value;
    }
    showSelectSheet.value = false;
    activeSelectField.value = null;
  };
  const openDatePicker = field => {
    activeDateField.value = field;
    const current = formValues[field.key];
    datePickerTs.value = parseFieldDateToTs(current) ?? Date.now();
    showDatePicker.value = true;
  };
  const onDatePickerCancel = () => {
    showDatePicker.value = false;
    activeDateField.value = null;
  };
  const onDateConfirm = e => {
    const ts = e?.value ?? datePickerTs.value;
    if (activeDateFieldKey.value) {
      formValues[activeDateFieldKey.value] = formatDateToYMD(ts);
    const field = activeDateField.value;
    if (field?.key) {
      if (isDatetimerangeField(field)) {
        const parts = parseDatetimerangeValue(formValues[field.key]);
        const formatted = formatFieldDateValue({ type: "datetime" }, ts);
        formValues[field.key] = joinDatetimerangeValue(
          activeRangePart.value === "start" ? formatted : parts.start,
          activeRangePart.value === "end" ? formatted : parts.end
        );
      } else {
        formValues[field.key] = formatFieldDateValue(field, ts);
      }
    }
    showDatePicker.value = false;
    onDatePickerCancel();
  };
  const validateForm = () => {
@@ -230,8 +418,35 @@
      if (!field.required) continue;
      const val = formValues[field.key];
      if (val === undefined || val === null || String(val).trim() === "") {
        uni.showToast({ title: `请填写${field.label}`, icon: "none" });
        const action =
          isSelectField(field) || isDateLikeField(field) || isDatetimerangeField(field)
            ? "请选择"
            : "请填写";
        uni.showToast({ title: `${action}${field.label}`, icon: "none" });
        return false;
      }
      if (isDatetimerangeField(field)) {
        const { start, end } = parseDatetimerangeValue(val);
        if (!start || !end) {
          uni.showToast({ title: `请完整选择${field.label}`, icon: "none" });
          return false;
        }
      }
      if (isSelectField(field)) {
        const options = resolveFieldOptions(field, {
          users: pickerUserList.value,
          depts: pickerDeptList.value,
        });
        if (
          options.length &&
          !options.some(
            opt =>
              String(opt.value) === String(val) || String(opt.label) === String(val)
          )
        ) {
          uni.showToast({ title: `${field.label}选项无效`, icon: "none" });
          return false;
        }
      }
    }
    if (!detail.value?.nodes?.length) {
@@ -272,15 +487,23 @@
      templateId: row.templateId ?? detail.value?.id,
      templateName: row.templateName ?? detail.value?.templateName,
      businessId: row.businessId,
      businessType: row.businessType,
      businessType: row.businessType ?? detail.value?.businessType,
      title: form.title.trim(),
      status: row.status || "PENDING",
      currentLevel: row.currentLevel,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      applyTime: row.applyTime,
      finishTime: row.finishTime,
      createUser: row.createUser,
      createTime: row.createTime,
      updateUser: row.updateUser,
      updateTime: row.updateTime,
      deptId: row.deptId,
      deleted: row.deleted,
      formConfig: buildFormConfigPayload(),
      approveAction: row.approveAction,
      approveComment: row.approveComment,
    };
  };
@@ -296,7 +519,7 @@
    submitApi(payload)
      .then(() => {
        uni.showToast({
          title: isEditMode.value ? "保存成功" : "提交成功",
          title: isEditMode.value ? "修改成功" : "提交成功",
          icon: "success",
        });
        if (isEditMode.value) {
@@ -308,7 +531,7 @@
      })
      .catch(() => {
        uni.showToast({
          title: isEditMode.value ? "保存失败" : "提交失败",
          title: isEditMode.value ? "修改失败" : "提交失败",
          icon: "none",
        });
      })
@@ -352,9 +575,9 @@
    const row = uni.getStorageSync(EDIT_STORAGE_KEY);
    if (!row || String(row.id) !== String(instanceId.value)) {
      uni.showToast({ title: "未获取到审批数据", icon: "none" });
      setTimeout(() => uni.navigateBack(), 500);
      return;
    }
    uni.removeStorageSync(EDIT_STORAGE_KEY);
    instanceRow.value = row;
    templateId.value = row.templateId;
    form.title = row.title || "";
@@ -363,6 +586,7 @@
    detail.value = null;
    try {
      await loadTemplateDetail();
      if (!detail.value) return;
      initFormValues(formConfigData.value.fields);
    } finally {
      loading.value = false;
@@ -373,7 +597,25 @@
    uni.navigateBack();
  };
  const loadPickerSourceData = () => {
    userListNoPageByTenantId()
      .then(res => {
        pickerUserList.value = res?.data || [];
      })
      .catch(() => {
        pickerUserList.value = [];
      });
    getDept()
      .then(res => {
        pickerDeptList.value = res?.data || [];
      })
      .catch(() => {
        pickerDeptList.value = [];
      });
  };
  onLoad(options => {
    loadPickerSourceData();
    if (options?.id) {
      instanceId.value = options.id;
      loadForEdit();
@@ -389,11 +631,22 @@
</script>
<style scoped lang="scss">
  @import "@/static/scss/form-common.scss";
  $primary: #2979ff;
  $text: #1f2d3d;
  $text-secondary: #606266;
  $text-muted: #909399;
  $bg-page: #f0f3f8;
  $radius-lg: 12px;
  $radius-md: 10px;
  $shadow-card: 0 2px 12px rgba(31, 45, 61, 0.05);
  .approve-apply-page {
    display: flex;
    flex-direction: column;
    min-height: 100vh;
    background: #f0f3f8;
    background: $bg-page;
  }
  .form-scroll {
@@ -412,130 +665,347 @@
  .loading-text {
    font-size: 14px;
    color: $text-muted;
  }
  .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-readonly .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-readonly .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-readonly .u-input__content__field-wrapper__field) {
    text-align: right !important;
    color: #303133 !important;
  }
  .dynamic-form {
    padding: 0 0 4px;
  }
  :deep(.dynamic-form .u-form-item) {
    padding: 0 16px !important;
  }
  :deep(.dynamic-form .u-form-item__body) {
    padding: 10px 0 !important;
    min-height: auto !important;
  }
  :deep(.form-item-inline .u-form-item__body) {
    flex-direction: row !important;
    align-items: center !important;
  }
  :deep(.form-item-inline .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-inline input),
  :deep(.form-item-inline .u-input__content__field-wrapper__field) {
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-select .u-form-item__body) {
    align-items: center !important;
  }
  :deep(.form-item-select .u-form-item__content) {
    flex: 1 !important;
    min-width: 0 !important;
    justify-content: flex-end !important;
  }
  :deep(.form-item-textarea .u-form-item__body) {
    flex-direction: column !important;
    align-items: stretch !important;
    padding: 10px 0 12px !important;
  }
  :deep(.form-item-textarea .u-form-item__content) {
    width: 100% !important;
    justify-content: stretch !important;
  }
  .field-trigger {
    display: flex;
    align-items: center;
    justify-content: flex-end;
    gap: 6px;
    width: 100%;
    min-width: 0;
  }
  :deep(.field-trigger .u-input) {
    flex: 1 !important;
    min-width: 0 !important;
  }
  :deep(.field-trigger .u-input__content__field-wrapper__field) {
    text-align: right !important;
    font-size: 15px !important;
  }
  :deep(.form-item-daterange .u-form-item__body) {
    flex-direction: column !important;
    align-items: stretch !important;
  }
  :deep(.form-item-daterange .u-form-item__content) {
    width: 100% !important;
    justify-content: stretch !important;
  }
  .daterange-fill {
    width: 100%;
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .range-fill-row {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    background: #f7f9fc;
    border: 1px solid #eef1f6;
    border-radius: 8px;
  }
  .range-fill-label {
    flex-shrink: 0;
    width: 36px;
    font-size: 13px;
    color: #909399;
  }
  .section {
    background: #fff;
    border-radius: 12px;
  .range-fill-sep {
    font-size: 12px;
    color: #c0c4cc;
    text-align: center;
  }
  :deep(.range-fill-row .u-input) {
    flex: 1 !important;
    min-width: 0 !important;
  }
  .section-card {
    margin-bottom: 10px;
    background: #fff;
    border-radius: $radius-lg;
    overflow: hidden;
    box-shadow: 0 2px 12px rgba(31, 45, 61, 0.05);
    box-shadow: $shadow-card;
  }
  .section-head {
    padding: 12px 16px;
    border-bottom: 1px solid #f2f4f7;
  }
  .section-title {
    padding: 12px 16px;
    font-size: 15px;
    font-weight: 600;
    color: #1f2d3d;
    border-bottom: 1px solid #f2f4f7;
    border-left: 3px solid #2979ff;
    padding-left: 13px;
  }
  .form-body {
    padding: 8px 16px 16px;
  }
  .form-row {
    padding: 10px 0;
    border-bottom: 1px solid #f5f7fa;
    &:last-child {
      border-bottom: none;
    }
    &--field {
      flex-direction: column;
      align-items: stretch;
    }
  }
  .form-label {
    display: block;
    margin-bottom: 8px;
    font-size: 14px;
    color: #606266;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 4px;
    }
  }
  .form-readonly {
    font-size: 14px;
    color: #303133;
    color: $text;
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .form-prompt {
    margin: 12px 16px 0;
    padding: 10px 12px;
    font-size: 13px;
    color: #606266;
    color: $text-secondary;
    background: #f8fafc;
    border-radius: 8px;
    line-height: 1.5;
  }
  .date-trigger {
    width: 100%;
  .flow-wrap {
    padding: 10px 16px 14px;
  }
  .flow-list {
  .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;
  }
  .flow-card {
    padding: 12px;
    margin-bottom: 8px;
    background: #f8fafc;
  .node-header {
    display: flex;
    align-items: center;
    gap: 10px;
    margin-bottom: 10px;
  }
  .node-level-badge {
    width: 26px;
    height: 26px;
    border-radius: 8px;
    border: 1px solid #eef2f6;
    background: $primary;
    color: #fff;
    font-size: 14px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
    &:last-child {
      margin-bottom: 0;
  .node-level-text {
    flex: 1;
    font-size: 15px;
    font-weight: 600;
    color: $text;
  }
  .approve-type-row {
    display: flex;
    background: #f0f3f8;
    border-radius: 8px;
    padding: 3px;
    margin-bottom: 10px;
    &--readonly {
      pointer-events: none;
    }
  }
  .flow-card-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    margin-bottom: 8px;
  }
  .flow-level {
  .type-btn {
    flex: 1;
    text-align: center;
    padding: 8px 0;
    font-size: 14px;
    font-weight: 600;
    color: #303133;
    color: $text-secondary;
    border-radius: 6px;
    &.active {
      background: #fff;
      color: $primary;
      font-weight: 500;
    }
  }
  .flow-type {
    font-size: 13px;
    color: #2979ff;
  }
  .approver-tags {
  .approver-list {
    display: flex;
    flex-wrap: wrap;
    gap: 8px;
    align-items: center;
  }
  .approver-tag {
    padding: 4px 10px;
    font-size: 13px;
    color: #303133;
  .approver-chip {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 6px 12px 6px 6px;
    background: #fff;
    border: 1px solid #dce8f8;
    border-radius: 16px;
    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: 120px;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
  }
  .flow-connector {
    display: flex;
    justify-content: center;
    padding: 4px 0;
  }
  .flow-connector-line {
    width: 2px;
    height: 14px;
    background: #d0dff0;
  }
  .empty-hint {
    padding: 12px 16px 16px;
    font-size: 13px;
    color: #909399;
    color: $text-muted;
    &.inline {
      padding: 0;
src/pages/oa/ApproveManage/approve-list/index.vue
@@ -73,20 +73,22 @@
            </view>
            <view class="detail-row">
              <text class="detail-label">申请时间</text>
              <text class="detail-value">{{ item.applyTime || "-" }}</text>
              <text class="detail-value">{{ formatDateTime(item.applyTime) }}</text>
            </view>
            <view v-if="item.finishTime"
                  class="detail-row">
              <text class="detail-label">完成时间</text>
              <text class="detail-value">{{ item.finishTime }}</text>
              <text class="detail-value">{{ formatDateTime(item.finishTime) }}</text>
            </view>
          </view>
          <view v-if="canEdit(item) || item.isApprove"
          <view v-if="canModify(item) || item.isApprove"
                class="action-buttons">
            <up-button v-if="canEdit(item)"
            <up-button v-if="canModify(item)"
                       class="action-btn"
                       size="small"
                       @click.stop="goEdit(item)">
                       type="warning"
                       plain
                       @click.stop="goModify(item)">
              ç¼–辑
            </up-button>
            <up-button v-if="item.isApprove"
@@ -123,6 +125,7 @@
  import { listApprovalInstancePage } from "@/api/oa/approvalInstance.js";
  import { OA_NAV } from "@/config/oaPaths.js";
  import useUserStore from "@/store/modules/user";
  import { parseTime } from "@/utils/ruoyi";
  const EDIT_STORAGE_KEY = "oa_approve_instance_edit_row";
  const userStore = useUserStore();
@@ -160,6 +163,27 @@
    if (level == null || level === "") return "-";
    return `第 ${level} çº§`;
  };
  const formatDateTime = val => {
    if (!val) return "-";
    return parseTime(val, "{y}-{m}-{d} {h}:{i}:{s}") || String(val);
  };
  /** æ˜¯å¦æœ¬äººå‘起的审批(兼容列表未返回 applicantId) */
  const isOwnApplication = item => {
    const uid = userStore.id;
    if (item?.applicantId != null && uid != null && uid !== "") {
      return String(item.applicantId) === String(uid);
    }
    const loginName = userStore.nickName || userStore.name;
    if (loginName && item?.applicantName) {
      return String(item.applicantName).trim() === String(loginName).trim();
    }
    return false;
  };
  /** ä»…「进行中」且本人发起时可编辑(已通过/已驳回不显示编辑) */
  const canModify = item => item?.status === "PENDING" && isOwnApplication(item);
  const currentApproverName = item => {
    const tasks = item?.tasks;
@@ -243,11 +267,11 @@
    uni.navigateTo({ url: OA_NAV.approveListTemplateSelect });
  };
  const canEdit = item =>
    item?.status === "PENDING" &&
    String(item.applicantId) === String(userStore.id);
  const goEdit = item => {
  const goModify = item => {
    if (!canModify(item)) {
      uni.showToast({ title: "仅进行中的本人申请可编辑", icon: "none" });
      return;
    }
    if (!item?.id) return;
    uni.setStorageSync(EDIT_STORAGE_KEY, item);
    uni.navigateTo({
src/pages/oa/ApproveManage/approve-template/detail.vue
@@ -125,6 +125,7 @@
  import PageHeader from "@/components/PageHeader.vue";
  import FooterButtons from "@/components/FooterButtons.vue";
  import { getApprovalTemplateDetail } from "@/api/oa/approvalTemplate.js";
  import { getFieldEditorTypeLabel } from "../../_utils/approvalFormField.js";
  import {
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
@@ -133,13 +134,6 @@
  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);
@@ -179,7 +173,7 @@
    return "";
  };
  const fieldTypeLabel = type => FIELD_TYPE_MAP[type] || type || "-";
  const fieldTypeLabel = type => getFieldEditorTypeLabel(type);
  const approveTypeText = type => (type === "OR" ? "或签" : "会签");
src/pages/oa/ApproveManage/approve-template/edit.vue
@@ -26,17 +26,21 @@
                      class="name-input-inline"
                      placeholder="请输入模板名称"
                      maxlength="50"
                      clearable />
                      :disabled="isSystemTemplate"
                      :clearable="!isSystemTemplate" />
          </up-form-item>
          <up-form-item label="审批类型"
                        prop="businessType"
                        required
                        class="form-item-select"
                        :class="{ 'form-item-select--disabled': isSystemTemplate }"
                        @click="openBusinessTypeSheet">
            <up-input :model-value="businessTypeText"
                      placeholder="请选择审批类型"
                      readonly />
            <template #right>
                      readonly
                      :disabled="isSystemTemplate" />
            <template v-if="!isSystemTemplate"
                      #right>
              <up-icon name="arrow-right"
                       @click.stop="openBusinessTypeSheet" />
            </template>
@@ -62,12 +66,16 @@
        <view class="section-card">
          <view class="section-head section-head--between">
            <text class="section-title">填报配置</text>
            <view class="section-head-left">
              <text class="section-title">填报项配置</text>
              <text class="section-count">共 {{ formConfig.fields.length }} é¡¹</text>
            </view>
            <view class="head-actions">
              <text class="head-link"
                    @click="showPresetSheet = true">预设</text>
              <text class="head-link head-link--import"
                    :class="{ 'head-link--disabled': !canImportTemplate }"
                    @click="openTemplateImport">从已有模板导入</text>
              <text class="head-link head-link--primary"
                    @click="openFieldEditor()">添加</text>
                    @click="openFieldEditor()">+ æ·»åŠ å¡«æŠ¥é¡¹</text>
            </view>
          </view>
          <view class="section-body">
@@ -83,34 +91,46 @@
                  class="field-list">
              <view v-for="(field, index) in formConfig.fields"
                    :key="field.key"
                    class="field-item">
                    class="field-item"
                    :class="{ 'field-item--locked': isFieldLocked(field) }"
                    @click="onFieldItemClick(field, index)">
                <view class="field-order">{{ index + 1 }}</view>
                <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 class="field-tags">
                      <text class="type-tag"
                            :class="fieldTypeTagClass(field.type)">
                        {{ fieldTypeLabel(field.type) }}
                      </text>
                      <text v-if="field.required"
                            class="req-tag">必填</text>
                    </view>
                  </view>
                  <text class="field-key">{{ field.key }}</text>
                  <text v-if="field.defaultValue"
                        class="field-default">默认:{{ field.defaultValue }}</text>
                        class="field-default">
                    é»˜è®¤ï¼š{{ formatFieldDefaultPreview(field) }}
                  </text>
                </view>
                <view class="field-actions">
                <view v-if="!isFieldLocked(field)"
                      class="field-actions"
                      @click.stop>
                  <view class="icon-btn icon-btn--edit"
                        @click="openFieldEditor(field, index)">
                        @click.stop="openFieldEditor(field, index)">
                    <up-icon name="edit-pen"
                             size="16"
                             color="#2979ff" />
                  </view>
                  <view class="icon-btn icon-btn--del"
                        @click="removeField(index)">
                        @click.stop="removeField(index)">
                    <up-icon name="trash"
                             size="16"
                             color="#f56c6c" />
                  </view>
                </view>
                <view v-else
                      class="field-lock-tag">内置</view>
              </view>
            </view>
            <view v-else
@@ -196,11 +216,11 @@
                   @cancel="goBack"
                   @confirm="handleSubmit" />
    <up-action-sheet :show="showPresetSheet"
                     title="从预设导入"
                     :actions="presetActions"
                     @select="onSelectPreset"
                     @close="showPresetSheet = false" />
    <up-action-sheet :show="showTemplateImportSheet"
                     title="从已有模板导入"
                     :actions="templateImportActions"
                     @select="onSelectImportTemplate"
                     @close="showTemplateImportSheet = false" />
    <up-popup :show="showFieldEditor"
              mode="bottom"
@@ -208,71 +228,255 @@
              @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 class="editor-header">
          <text class="editor-title">{{ editingFieldIndex >= 0 ? "编辑填报项" : "添加填报项" }}</text>
          <text class="editor-subtitle">配置字段属性、校验与默认值</text>
        </view>
        <scroll-view class="editor-scroll"
                     scroll-y
                     :show-scrollbar="false">
          <view class="editor-form">
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">基础信息</text>
              </view>
              <view class="editor-cell">
                <text class="editor-label required">显示名称</text>
                <view class="editor-input-box">
                  <up-input v-model="fieldDraft.label"
                            placeholder="如:报销说明"
                            border="none"
                            clearable />
                </view>
              </view>
              <view class="editor-cell">
                <text class="editor-label required">字段标识</text>
                <view class="editor-input-box">
                  <up-input v-model="fieldDraft.key"
                            placeholder="如:summary"
                            border="none"
                            clearable />
                </view>
              </view>
              <view class="editor-cell editor-cell--tap"
                    @click="openFieldTypePicker">
                <text class="editor-label required">控件类型</text>
                <view class="picker-value-row">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.type }">
                    {{ fieldDraftTypeText || "请选择" }}
                  </text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
              </view>
            </view>
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">校验与格式</text>
              </view>
              <view class="editor-cell editor-cell--switch">
                <view class="switch-label-wrap">
                  <text class="editor-label">是否必填</text>
                  <text class="switch-hint">提交审批时须填写该项</text>
                </view>
                <up-switch v-model="fieldDraft.required"
                           active-color="#2979ff" />
              </view>
            </view>
            <view v-if="isSelectDraft"
                  class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">下拉选项</text>
              </view>
              <view class="editor-cell editor-cell--tap"
                    @click="openOptionSourcePicker">
                <text class="editor-label">选项来源</text>
                <view class="picker-value-row">
                  <text class="picker-value">{{ fieldDraftOptionSourceText }}</text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
              </view>
              <view v-if="fieldDraft.optionSource === 'manual'"
                    class="manual-options">
                <text class="manual-options-title">手动选项</text>
                <view class="manual-options-table">
                  <view class="option-table-head">
                    <text class="option-col option-col--idx" />
                    <text class="option-col option-col--label">显示文本</text>
                    <text class="option-col option-col--value">选项值</text>
                    <text class="option-col option-col--action" />
                  </view>
                  <view v-for="(opt, optIndex) in fieldDraft.options"
                        :key="optIndex"
                        class="option-card">
                    <text class="option-idx">{{ optIndex + 1 }}</text>
                    <view class="option-input-wrap">
                      <up-input v-model="opt.label"
                                placeholder="如:工作日加班"
                                border="none"
                                clearable />
                    </view>
                    <view class="option-input-wrap option-input-wrap--value">
                      <up-input v-model="opt.value"
                                placeholder="如:0"
                                border="none"
                                clearable />
                    </view>
                    <view class="option-del"
                          hover-class="option-del--active"
                          @click.stop="removeDraftOption(optIndex)">
                      <up-icon name="trash"
                               size="16"
                               color="#f56c6c" />
                    </view>
                  </view>
                </view>
                <view class="add-option-btn"
                      hover-class="add-option-btn--active"
                      @click="addDraftOption">
                  <up-icon name="plus-circle"
                           size="16"
                           color="#2979ff" />
                  <text>添加选项</text>
                </view>
              </view>
              <view v-else
                    class="option-source-tip">
                <up-icon name="info-circle"
                         size="14"
                         color="#909399" />
                <text>发起审批时将自动加载{{ fieldDraftOptionSourceText }}</text>
              </view>
            </view>
            <view class="editor-section-card">
              <view class="editor-section-head">
                <text class="editor-section-title">默认值</text>
              </view>
              <text class="default-hint">
                é€‰æ‹©è¯¥æ¨¡æ¿æäº¤å®¡æ‰¹æ—¶è‡ªåŠ¨é¢„å¡«ï¼Œç”¨æˆ·ä»å¯ä¿®æ”¹
              </text>
              <view class="editor-cell editor-cell--value">
                <up-textarea v-if="fieldDraft.type === 'textarea'"
                             v-model="fieldDraft.defaultValue"
                             placeholder="选填"
                             maxlength="500"
                             border="surround"
                             height="72" />
                <view v-else-if="fieldDraft.type === 'date'"
                      class="picker-value-row picker-value-row--tap"
                      @click="openDefaultDatePicker">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }">
                    {{ fieldDraft.defaultValue || "选择日期" }}
                  </text>
                  <up-icon name="calendar"
                           size="18"
                           color="#909399" />
                </view>
                <view v-else-if="isDatetimerangeDraft"
                      class="daterange-default-wrap">
                  <view class="daterange-default-item"
                        @click="openDefaultRangePicker('start')">
                    <text class="daterange-default-label">开始时间</text>
                    <view class="picker-value-row picker-value-row--tap">
                      <text class="picker-value"
                            :class="{ 'picker-value--placeholder': !defaultRangeStart }">
                        {{ defaultRangeStart || "选择开始时间" }}
                      </text>
                      <up-icon name="calendar"
                               size="18"
                               color="#909399" />
                    </view>
                  </view>
                  <view class="daterange-default-item"
                        @click="openDefaultRangePicker('end')">
                    <text class="daterange-default-label">结束时间</text>
                    <view class="picker-value-row picker-value-row--tap">
                      <text class="picker-value"
                            :class="{ 'picker-value--placeholder': !defaultRangeEnd }">
                        {{ defaultRangeEnd || "选择结束时间" }}
                      </text>
                      <up-icon name="calendar"
                               size="18"
                               color="#909399" />
                    </view>
                  </view>
                </view>
                <view v-else-if="isSelectDraft"
                      class="picker-value-row picker-value-row--tap"
                      @click="openDefaultSelectSheet">
                  <text class="picker-value"
                        :class="{ 'picker-value--placeholder': !fieldDraft.defaultValue }">
                    {{ defaultSelectDisplayText || "选填" }}
                  </text>
                  <up-icon name="arrow-right"
                           size="14"
                           color="#b0b8c4" />
                </view>
                <view v-else
                      class="editor-input-box">
                  <up-input v-model="fieldDraft.defaultValue"
                            :type="fieldDraft.type === 'number' ? 'digit' : 'text'"
                            placeholder="选填"
                            border="none"
                            clearable />
                </view>
              </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>
        </scroll-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 v-if="inlinePickerShow"
              class="editor-picker-layer">
          <view class="editor-picker-mask"
                @click="closeInlinePicker" />
          <view class="editor-picker-panel">
            <view class="editor-picker-head">
              <text class="editor-picker-cancel"
                    @click="closeInlinePicker">取消</text>
              <text class="editor-picker-title">{{ inlinePickerTitle }}</text>
              <text class="editor-picker-placeholder" />
            </view>
            <scroll-view class="editor-picker-scroll"
                         scroll-y>
              <view v-for="(item, pickerIndex) in inlinePickerOptions"
                    :key="`${inlinePickerMode}-${pickerIndex}-${item.value}`"
                    class="editor-picker-item"
                    :class="{ 'editor-picker-item--active': isInlinePickerItemActive(item) }"
                    @click="onInlinePickerSelect(item)">
                <text>{{ item.name }}</text>
                <up-icon v-if="isInlinePickerItemActive(item)"
                         name="checkmark"
                         size="18"
                         color="#2979ff" />
              </view>
            </scroll-view>
          </view>
        </view>
      </view>
    </up-popup>
    <up-popup :show="showDefaultDatePicker"
              mode="bottom"
              @close="showDefaultDatePicker = false">
              @close="closeDefaultDatePicker">
      <up-datetime-picker :show="true"
                          v-model="defaultDateTs"
                          mode="date"
                          @confirm="onDefaultDateConfirm"
                          @cancel="showDefaultDatePicker = false" />
                          :mode="defaultDatePickerMode"
                          @confirm="onDefaultDatePickerConfirm"
                          @cancel="closeDefaultDatePicker" />
    </up-popup>
    <up-popup :show="showUserPicker"
@@ -326,67 +530,65 @@
  import FooterButtons from "@/components/FooterButtons.vue";
  import {
    addApprovalTemplate,
    getApprovalTemplateDetail,
    listApprovalTemplatePage,
    updateApprovalTemplate,
  } from "@/api/oa/approvalTemplate.js";
  import { getDept } from "@/api/collaborativeApproval/approvalProcess.js";
  import { userListNoPageByTenantId } from "@/api/system/user";
  import { formatDateToYMD } from "@/utils/ruoyi";
  import { fetchApprovalTemplateTypes } from "../../_utils/approvalTemplateType.js";
  import {
    buildFieldConfigPayload,
    createEmptyFieldOption,
    parseApprovalFormConfig,
    FIELD_EDITOR_TYPE_OPTIONS,
    FIELD_OPTION_SOURCE_OPTIONS,
    getFieldEditorTypeLabel,
    getFieldOptionLabel,
    getFieldOptionSource,
    getFieldOptionSourceLabel,
    isDatetimerangeField,
    isSelectField,
    formatDatetimerangeDisplay,
    formatFieldDateValue,
    joinDatetimerangeValue,
    parseDatetimerangeValue,
    parseFieldDateToTs,
    resolveFieldOptions,
  } from "../../_utils/approvalFormField.js";
  import {
    fetchApprovalTemplateTypes,
    isSystemApprovalTemplate,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
  const LEVEL_TEXT = ["", "一", "二", "三", "四", "五", "六", "七", "八", "九", "十"];
  const FORM_PRESETS = [
    {
      name: "通用报销",
      prompt: "请填写报销事由、金额等",
      fields: [
        { key: "reason", label: "报销事由", type: "textarea", required: true },
        { key: "amount", label: "报销金额(元)", type: "number", required: true },
        { key: "applyDate", label: "申请日期", type: "date", required: true },
      ],
    },
    {
      name: "请假申请",
      prompt: "请填写请假类型、起止时间等",
      fields: [
        { key: "leaveType", label: "请假类型", type: "text", required: true },
        { key: "startTime", label: "开始时间", type: "date", required: true },
        { key: "endTime", label: "结束时间", type: "date", required: true },
        { key: "reason", label: "请假事由", type: "textarea", required: true },
      ],
    },
    {
      name: "采购申请",
      prompt: "请填写采购事由、预估金额等",
      fields: [
        { key: "title", label: "采购事由", type: "textarea", required: true },
        { key: "amount", label: "预估金额(元)", type: "number", required: true },
      ],
    },
  ];
  const FIELD_TYPE_OPTIONS = [
    { name: "单行文本", value: "text" },
    { name: "多行文本", value: "textarea" },
    { name: "数字", value: "number" },
    { name: "日期", value: "date" },
  ];
  const formRef = ref();
  const submitting = ref(false);
  const userList = ref([]);
  const templateId = ref(null);
  const showPresetSheet = ref(false);
  const showTemplateImportSheet = ref(false);
  const importTemplateList = ref([]);
  const showFieldEditor = ref(false);
  const inlinePickerShow = ref(false);
  const inlinePickerTitle = ref("");
  const inlinePickerOptions = ref([]);
  const inlinePickerMode = ref("");
  const showUserPicker = ref(false);
  const showDefaultDatePicker = ref(false);
  const defaultDatePickerMode = ref("date");
  const defaultRangePickerPart = ref("start");
  const defaultDateTs = ref(Date.now());
  const deptList = ref([]);
  const editingFieldIndex = ref(-1);
  const editingNodeIndex = ref(-1);
  const pickerSelectedIds = ref([]);
  /** ç³»ç»Ÿæ¨¡æ¿åŠ è½½æ—¶é”å®šçš„å¡«æŠ¥é¡¹ key,不可编辑/删除 */
  const lockedFieldKeys = ref(new Set());
  const form = reactive({
    templateName: "",
@@ -403,9 +605,12 @@
  const fieldDraft = reactive({
    label: "",
    key: "",
    type: "text",
    defaultValue: "",
    required: true,
    optionSource: "manual",
    options: [createEmptyFieldOption()],
  });
  let nodeKeySeed = 1;
@@ -451,10 +656,57 @@
    return matched?.name || "";
  });
  const presetActions = FORM_PRESETS.map(item => ({
    name: item.name,
    value: item.name,
  }));
  const canImportTemplate = computed(() => !isSystemTemplate.value);
  const templateImportActions = computed(() =>
    importTemplateList.value.map(item => {
      const typeTag = isSystemApprovalTemplate(item) ? "系统" : "自定义";
      return {
        name: `【${typeTag}】${item.templateName || `模板${item.id}`}`,
        value: String(item.id),
      };
    })
  );
  const isSelectDraft = computed(() => isSelectField(fieldDraft));
  const isDatetimerangeDraft = computed(() => isDatetimerangeField(fieldDraft));
  const defaultRangeParts = computed(() =>
    parseDatetimerangeValue(fieldDraft.defaultValue)
  );
  const defaultRangeStart = computed(() => defaultRangeParts.value.start);
  const defaultRangeEnd = computed(() => defaultRangeParts.value.end);
  const fieldDraftTypeText = computed(() => getFieldEditorTypeLabel(fieldDraft.type));
  const fieldDraftOptionSourceText = computed(() =>
    getFieldOptionSourceLabel(fieldDraft.optionSource)
  );
  const defaultSelectActions = computed(() => {
    const options = resolveFieldOptions(fieldDraft, {
      users: userList.value,
      depts: deptList.value,
    });
    return [
      { name: "不设置", value: "" },
      ...options.map(opt => ({
        name: opt.label,
        value: opt.value,
      })),
    ];
  });
  const defaultSelectDisplayText = computed(() => {
    if (!fieldDraft.defaultValue) return "";
    return (
      getFieldOptionLabel(fieldDraft, fieldDraft.defaultValue) ||
      String(fieldDraft.defaultValue)
    );
  });
  const enabledBool = computed({
    get: () => form.enabled === "1",
@@ -465,22 +717,14 @@
  const isEditMode = computed(() => templateId.value != null && templateId.value !== "");
  const isSystemTemplate = computed(() => isSystemApprovalTemplate(form));
  const isFieldLocked = field =>
    isSystemTemplate.value && lockedFieldKeys.value.has(field?.key);
  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) {
@@ -515,9 +759,12 @@
    form.enabled = String(row.enabled ?? "1");
    form.description = row.description || "";
    const config = parseFormConfig(row.formConfig);
    const config = parseApprovalFormConfig(row.formConfig);
    formConfig.prompt = config.prompt;
    formConfig.fields = config.fields;
    lockedFieldKeys.value = isSystemApprovalTemplate(row)
      ? new Set(config.fields.map(f => f.key).filter(Boolean))
      : new Set();
    flowNodes.value = mapNodesFromRow(row.nodes);
  };
@@ -530,8 +777,14 @@
  const levelLabel = n => LEVEL_TEXT[n] || String(n);
  const fieldTypeLabel = type =>
    FIELD_TYPE_OPTIONS.find(item => item.value === type)?.name || type;
  const fieldTypeLabel = type => getFieldEditorTypeLabel(type);
  const formatFieldDefaultPreview = field => {
    if (isDatetimerangeField(field)) {
      return formatDatetimerangeDisplay(field.defaultValue) || field.defaultValue;
    }
    return field.defaultValue;
  };
  const fieldTypeTagClass = type => {
    const map = {
@@ -539,6 +792,8 @@
      textarea: "type-tag--area",
      number: "type-tag--num",
      date: "type-tag--date",
      datetimerange: "type-tag--date",
      select: "type-tag--select",
    };
    return map[type] || "type-tag--text";
  };
@@ -548,6 +803,7 @@
  };
  const openBusinessTypeSheet = () => {
    if (isSystemTemplate.value) return;
    if (!businessTypeOptions.value.length) {
      uni.showToast({ title: "审批类型加载中", icon: "none" });
      return;
@@ -561,51 +817,316 @@
    formRef.value?.validateField?.("businessType");
  };
  const onSelectPreset = action => {
    const preset = FORM_PRESETS.find(item => item.name === action.value);
    if (!preset) return;
    formConfig.prompt = preset.prompt;
    formConfig.fields = preset.fields.map(field => ({ ...field }));
    showPresetSheet.value = false;
    uni.showToast({ title: "已导入预设", icon: "success" });
  const applyImportedFormConfig = (config, sourceName = "") => {
    const parsed = {
      prompt: config?.prompt || "",
      fields: (config?.fields || []).map(field => ({ ...field })),
    };
    formConfig.prompt = parsed.prompt;
    formConfig.fields = parsed.fields;
    const tip = sourceName ? `已导入「${sourceName}」` : "已导入填报配置";
    uni.showToast({ title: tip, icon: "success" });
  };
  const selectFieldType = type => {
    if (fieldDraft.type === type) return;
    fieldDraft.type = type;
  const doImportFormConfig = (config, sourceName) => {
    const hasExisting =
      !!formConfig.prompt?.trim() || formConfig.fields.length > 0;
    if (!hasExisting) {
      applyImportedFormConfig(config, sourceName);
      return;
    }
    uni.showModal({
      title: "导入确认",
      content: `将使用「${sourceName}」的填报配置覆盖当前内容,是否继续?`,
      success: res => {
        if (res.confirm) {
          applyImportedFormConfig(config, sourceName);
        }
      },
    });
  };
  const applyTemplateImport = templateIdValue => {
    const row = importTemplateList.value.find(
      item => String(item.id) === String(templateIdValue)
    );
    const sourceName = row?.templateName || "所选模板";
    const applyFromDetail = detail => {
      const config = parseApprovalFormConfig(detail?.formConfig);
      if (!config.fields.length && !config.prompt) {
        uni.showToast({ title: "该模板无填报配置", icon: "none" });
        return;
      }
      doImportFormConfig(config, sourceName);
    };
    if (row?.formConfig) {
      applyFromDetail(row);
      return;
    }
    uni.showLoading({ title: "加载配置...", mask: true });
    getApprovalTemplateDetail(templateIdValue)
      .then(res => applyFromDetail(res?.data))
      .catch(() => {
        uni.showToast({ title: "获取模板配置失败", icon: "none" });
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  const openTemplateImport = () => {
    if (!canImportTemplate.value) {
      uni.showToast({ title: "系统内置模板不可导入", icon: "none" });
      return;
    }
    uni.showLoading({ title: "加载中...", mask: true });
    listApprovalTemplatePage({
      page: { current: 1, size: 200 },
      approvalTemplateDto: {},
    })
      .then(res => {
        const records = res?.data?.records || [];
        importTemplateList.value = records.filter(
          item =>
            item?.id != null && String(item.id) !== String(templateId.value)
        );
        if (!importTemplateList.value.length) {
          uni.showToast({ title: "暂无可导入的模板", icon: "none" });
          return;
        }
        showTemplateImportSheet.value = true;
      })
      .catch(() => {
        uni.showToast({ title: "加载模板列表失败", icon: "none" });
      })
      .finally(() => {
        uni.hideLoading();
      });
  };
  const onSelectImportTemplate = action => {
    showTemplateImportSheet.value = false;
    const value = String(action?.value ?? "");
    if (!value) return;
    applyTemplateImport(value);
  };
  const resetFieldDraft = () => {
    fieldDraft.label = "";
    fieldDraft.key = "";
    fieldDraft.type = "text";
    fieldDraft.defaultValue = "";
    fieldDraft.required = true;
    fieldDraft.optionSource = "manual";
    fieldDraft.options = [createEmptyFieldOption()];
  };
  const onDefaultDateConfirm = e => {
    fieldDraft.defaultValue = formatDateToYMD(e.value);
  const resolveActionValue = (action, options) => {
    if (action?.value !== undefined && action?.value !== null) {
      return action.value;
    }
    const name = action?.name;
    if (name == null) return undefined;
    return options.find(opt => opt.name === name)?.value;
  };
  const onSelectFieldType = action => {
    const nextType = resolveActionValue(action, FIELD_EDITOR_TYPE_OPTIONS);
    if (!nextType || fieldDraft.type === nextType) return;
    fieldDraft.type = nextType;
    fieldDraft.defaultValue = "";
    if (!isSelectField(fieldDraft)) {
      fieldDraft.optionSource = "manual";
      fieldDraft.options = [createEmptyFieldOption()];
    } else if (!fieldDraft.options?.length) {
      fieldDraft.options = [createEmptyFieldOption()];
    }
  };
  const openInlinePicker = (title, options, mode) => {
    inlinePickerTitle.value = title;
    inlinePickerOptions.value = options;
    inlinePickerMode.value = mode;
    inlinePickerShow.value = true;
  };
  const closeInlinePicker = () => {
    inlinePickerShow.value = false;
    inlinePickerMode.value = "";
    inlinePickerOptions.value = [];
  };
  const isInlinePickerItemActive = item => {
    if (inlinePickerMode.value === "fieldType") {
      return String(fieldDraft.type) === String(item.value);
    }
    if (inlinePickerMode.value === "optionSource") {
      return String(fieldDraft.optionSource) === String(item.value);
    }
    if (inlinePickerMode.value === "defaultValue") {
      const val = fieldDraft.defaultValue;
      if (val === "" || val === undefined || val === null) {
        return item.value === "" || item.value === undefined || item.value === null;
      }
      return String(val) === String(item.value);
    }
    return false;
  };
  const onInlinePickerSelect = item => {
    if (inlinePickerMode.value === "fieldType") {
      onSelectFieldType(item);
    } else if (inlinePickerMode.value === "optionSource") {
      onSelectOptionSource(item);
    } else if (inlinePickerMode.value === "defaultValue") {
      onSelectDefaultOption(item);
    }
    closeInlinePicker();
  };
  const openFieldTypePicker = () => {
    openInlinePicker(
      "控件类型",
      FIELD_EDITOR_TYPE_OPTIONS.map(item => ({
        name: item.name,
        value: item.value,
      })),
      "fieldType"
    );
  };
  const onSelectOptionSource = action => {
    const nextSource = resolveActionValue(action, FIELD_OPTION_SOURCE_OPTIONS);
    if (!nextSource) return;
    fieldDraft.optionSource = nextSource;
    fieldDraft.defaultValue = "";
    if (nextSource === "manual" && !fieldDraft.options?.length) {
      fieldDraft.options = [createEmptyFieldOption()];
    }
  };
  const openOptionSourcePicker = () => {
    openInlinePicker(
      "选项来源",
      FIELD_OPTION_SOURCE_OPTIONS.map(item => ({
        name: item.name,
        value: item.value,
      })),
      "optionSource"
    );
  };
  const addDraftOption = () => {
    fieldDraft.options.push(createEmptyFieldOption());
  };
  const removeDraftOption = index => {
    if (fieldDraft.options.length <= 1) {
      fieldDraft.options[0] = createEmptyFieldOption();
      return;
    }
    fieldDraft.options.splice(index, 1);
  };
  const openDefaultSelectSheet = () => {
    const options = resolveFieldOptions(fieldDraft, {
      users: userList.value,
      depts: deptList.value,
    });
    if (!options.length) {
      uni.showToast({ title: "请先配置下拉选项", icon: "none" });
      return;
    }
    openInlinePicker("默认值", defaultSelectActions.value, "defaultValue");
  };
  const onSelectDefaultOption = action => {
    fieldDraft.defaultValue =
      action.value === undefined || action.value === null
        ? ""
        : String(action.value);
  };
  const closeDefaultDatePicker = () => {
    showDefaultDatePicker.value = false;
    defaultDatePickerMode.value = "date";
    defaultRangePickerPart.value = "start";
  };
  const openDefaultDatePicker = () => {
    defaultDatePickerMode.value = "date";
    const parsed = Date.parse(fieldDraft.defaultValue);
    defaultDateTs.value = Number.isNaN(parsed) ? Date.now() : parsed;
    showDefaultDatePicker.value = true;
  };
  const openDefaultRangePicker = part => {
    defaultDatePickerMode.value = "datetime";
    defaultRangePickerPart.value = part;
    const parts = parseDatetimerangeValue(fieldDraft.defaultValue);
    const val = part === "start" ? parts.start : parts.end;
    defaultDateTs.value = parseFieldDateToTs(val) ?? Date.now();
    showDefaultDatePicker.value = true;
  };
  const onDefaultDatePickerConfirm = e => {
    const ts = e?.value ?? defaultDateTs.value;
    if (defaultDatePickerMode.value === "datetime") {
      const parts = parseDatetimerangeValue(fieldDraft.defaultValue);
      const formatted = formatFieldDateValue({ type: "datetime" }, ts);
      fieldDraft.defaultValue = joinDatetimerangeValue(
        defaultRangePickerPart.value === "start" ? formatted : parts.start,
        defaultRangePickerPart.value === "end" ? formatted : parts.end
      );
    } else {
      fieldDraft.defaultValue = formatDateToYMD(ts);
    }
    closeDefaultDatePicker();
  };
  const onFieldItemClick = (field, index) => {
    if (isFieldLocked(field)) return;
    openFieldEditor(field, index);
  };
  const openFieldEditor = (field, index = -1) => {
    if (field && isFieldLocked(field)) {
      uni.showToast({ title: "系统内置填报项不可修改", icon: "none" });
      return;
    }
    editingFieldIndex.value = index;
    if (field) {
      fieldDraft.label = field.label;
      fieldDraft.label = field.label || "";
      fieldDraft.key = field.key || "";
      fieldDraft.type = field.type || "text";
      fieldDraft.defaultValue = field.defaultValue ?? "";
      fieldDraft.required = !!field.required;
      fieldDraft.optionSource = getFieldOptionSource(field);
      fieldDraft.options = normalizeDraftOptions(field);
    } else {
      fieldDraft.label = "";
      fieldDraft.type = "text";
      fieldDraft.defaultValue = "";
      fieldDraft.required = true;
      resetFieldDraft();
    }
    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();
    }
    defaultDateTs.value = Date.now();
    showFieldEditor.value = true;
  };
  const closeFieldEditor = () => {
    closeInlinePicker();
    showFieldEditor.value = false;
    editingFieldIndex.value = -1;
  };
  const normalizeDraftOptions = field => {
    const options = field?.options;
    if (!Array.isArray(options) || !options.length) {
      return [createEmptyFieldOption()];
    }
    return options.map(opt => ({
      label: opt?.label ?? "",
      value: opt?.value != null ? String(opt.value) : "",
    }));
  };
  const buildFieldKey = label => {
@@ -622,22 +1143,46 @@
  };
  const confirmFieldEditor = () => {
    if (!fieldDraft.label?.trim()) {
      uni.showToast({ title: "请输入字段名称", icon: "none" });
    if (
      editingFieldIndex.value >= 0 &&
      isFieldLocked(formConfig.fields[editingFieldIndex.value])
    ) {
      uni.showToast({ title: "系统内置填报项不可修改", icon: "none" });
      return;
    }
    const defaultValue = String(fieldDraft.defaultValue ?? "").trim();
    if (!fieldDraft.label?.trim()) {
      uni.showToast({ title: "请输入显示名称", icon: "none" });
      return;
    }
    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,
    };
    const draftKey = fieldDraft.key?.trim() || existingKey || buildFieldKey(fieldDraft.label);
    if (!draftKey) {
      uni.showToast({ title: "请输入字段标识", icon: "none" });
      return;
    }
    const duplicateKey = formConfig.fields.some(
      (item, idx) => item.key === draftKey && idx !== editingFieldIndex.value
    );
    if (duplicateKey) {
      uni.showToast({ title: "字段标识已存在", icon: "none" });
      return;
    }
    if (isSelectField(fieldDraft) && fieldDraft.optionSource === "manual") {
      const validOptions = (fieldDraft.options || []).filter(
        opt => opt.label?.trim() && opt.value?.trim()
      );
      if (!validOptions.length) {
        uni.showToast({ title: "请至少配置一个下拉选项", icon: "none" });
        return;
      }
    }
    const payload = buildFieldConfigPayload(
      { ...fieldDraft, key: draftKey },
      existingKey
    );
    if (editingFieldIndex.value >= 0) {
      formConfig.fields.splice(editingFieldIndex.value, 1, payload);
    } else {
@@ -647,6 +1192,11 @@
  };
  const removeField = index => {
    const field = formConfig.fields[index];
    if (isFieldLocked(field)) {
      uni.showToast({ title: "系统内置填报项不可删除", icon: "none" });
      return;
    }
    formConfig.fields.splice(index, 1);
  };
@@ -850,6 +1400,13 @@
      .catch(() => {
        userList.value = [];
      });
    getDept()
      .then(res => {
        deptList.value = res?.data || [];
      })
      .catch(() => {
        deptList.value = [];
      });
  });
</script>
@@ -899,6 +1456,12 @@
    }
  }
  .section-head-left {
    display: flex;
    flex-direction: column;
    gap: 4px;
  }
  .section-title {
    font-size: 15px;
    font-weight: 600;
@@ -906,6 +1469,12 @@
    padding-left: 10px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .section-count {
    font-size: 12px;
    color: $text-muted;
    padding-left: 13px;
  }
  .head-actions {
@@ -918,10 +1487,34 @@
    font-size: 14px;
    color: $text-secondary;
    &--primary {
      color: $primary;
      font-weight: 500;
    &--import {
      color: $text-secondary;
      padding: 6px 12px;
      border: 1px solid #dce3ed;
      border-radius: 8px;
      background: #fff;
      font-size: 13px;
    }
    &--disabled {
      color: #c0c4cc;
      border-color: #ebeef5;
      background: #f5f7fa;
    }
    &--primary {
      color: #fff;
      font-weight: 500;
      padding: 6px 14px;
      border: none;
      border-radius: 8px;
      background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%);
      box-shadow: 0 2px 8px rgba(41, 121, 255, 0.25);
    }
  }
  :deep(.form-item-select--disabled .u-form-item__body) {
    opacity: 0.65;
  }
  .section-body {
@@ -1141,11 +1734,36 @@
  .field-item {
    display: flex;
    align-items: center;
    gap: 10px;
    padding: 10px 12px;
    background: #f8fafc;
    gap: 12px;
    padding: 14px;
    background: #fff;
    border-radius: $radius-md;
    border: 1px solid #eef2f6;
    border: 1px solid #e8eef5;
    box-shadow: 0 1px 4px rgba(31, 45, 61, 0.04);
    transition: border-color 0.2s, box-shadow 0.2s;
    &:active:not(.field-item--locked) {
      border-color: #c6daf5;
      box-shadow: 0 2px 8px rgba(41, 121, 255, 0.08);
    }
    &--locked {
      background: #fafbfd;
    }
  }
  .field-order {
    width: 28px;
    height: 28px;
    border-radius: 8px;
    background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
    color: #fff;
    font-size: 13px;
    font-weight: 600;
    display: flex;
    align-items: center;
    justify-content: center;
    flex-shrink: 0;
  }
  .field-main {
@@ -1156,14 +1774,40 @@
  .field-title-row {
    display: flex;
    align-items: center;
    flex-wrap: wrap;
    gap: 6px;
    justify-content: space-between;
    gap: 8px;
    margin-bottom: 4px;
  }
  .field-name {
    font-size: 15px;
    font-weight: 600;
    color: $text;
    flex: 1;
    min-width: 0;
  }
  .field-tags {
    display: flex;
    align-items: center;
    gap: 6px;
    flex-shrink: 0;
  }
  .field-key {
    display: block;
    font-size: 12px;
    color: $text-muted;
    font-family: ui-monospace, monospace;
  }
  .field-lock-tag {
    flex-shrink: 0;
    font-size: 11px;
    color: #909399;
    padding: 4px 8px;
    background: #f0f2f5;
    border-radius: 4px;
  }
  .type-tag {
@@ -1189,6 +1833,11 @@
    &--date {
      color: #18a058;
      background: #e8faf0;
    }
    &--select {
      color: #9c27b0;
      background: #f6edfc;
    }
  }
@@ -1231,10 +1880,13 @@
  }
  .empty-mini {
    padding: 20px 0;
    padding: 32px 16px;
    text-align: center;
    font-size: 13px;
    color: $text-muted;
    background: #fafbfd;
    border: 1px dashed #dce8f5;
    border-radius: 10px;
  }
  .flow-wrap {
@@ -1410,65 +2062,433 @@
  }
  .sheet-handle {
    width: 40px;
    width: 36px;
    height: 4px;
    margin: 10px auto 6px;
    background: #e4e7ed;
    margin: 10px auto 4px;
    background: #d8dde6;
    border-radius: 2px;
  }
  .field-editor,
  .field-editor .sheet-handle {
    background: #c8ced8;
  }
  .field-editor {
    position: relative;
    display: flex;
    flex-direction: column;
    max-height: 88vh;
    background: #f5f7fb;
    border-radius: 16px 16px 0 0;
    overflow: hidden;
  }
  .user-picker {
    position: relative;
    padding: 0 18px calc(18px + env(safe-area-inset-bottom));
    background: #fff;
    max-height: 85vh;
  }
  .editor-title {
  .editor-header {
    padding: 4px 20px 12px;
    background: #fff;
    text-align: center;
    border-bottom: 1px solid #f0f2f5;
  }
  .editor-subtitle {
    display: block;
    margin-top: 4px;
    font-size: 12px;
    color: $text-muted;
  }
  .editor-picker-layer {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    z-index: 20;
    display: flex;
    flex-direction: column;
    justify-content: flex-end;
  }
  .editor-picker-mask {
    position: absolute;
    left: 0;
    right: 0;
    top: 0;
    bottom: 0;
    background: rgba(0, 0, 0, 0.45);
  }
  .editor-picker-panel {
    position: relative;
    z-index: 1;
    background: #fff;
    border-radius: 16px 16px 0 0;
    max-height: 55vh;
    padding-bottom: env(safe-area-inset-bottom);
  }
  .editor-picker-head {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 14px 18px;
    border-bottom: 1px solid #f0f0f0;
  }
  .editor-picker-cancel {
    font-size: 15px;
    color: #909399;
    min-width: 48px;
  }
  .editor-picker-title {
    font-size: 16px;
    font-weight: 600;
    color: $text;
    text-align: center;
    margin-bottom: 14px;
  }
  .editor-picker-placeholder {
    min-width: 48px;
  }
  .editor-picker-scroll {
    max-height: calc(55vh - 52px);
  }
  .editor-picker-item {
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 16px 18px;
    font-size: 16px;
    color: $text;
    border-bottom: 1px solid #f5f7fa;
    &--active {
      color: $primary;
      background: #f5f9ff;
    }
    &:last-child {
      border-bottom: none;
    }
  }
  .editor-scroll {
    flex: 1;
    height: 0;
    max-height: 62vh;
  }
  .editor-form {
    display: flex;
    flex-direction: column;
    gap: 12px;
    gap: 10px;
    padding: 12px 16px 16px;
  }
  .editor-row {
    display: flex;
    flex-direction: column;
    gap: 10px;
  .editor-section-card {
    background: #fff;
    border-radius: 12px;
    padding: 14px 14px 4px;
    box-shadow: 0 1px 6px rgba(31, 45, 61, 0.05);
  }
  .editor-section-head {
    margin-bottom: 10px;
    padding-bottom: 10px;
    border-bottom: 1px solid #f2f4f7;
  }
  .editor-section-title {
    font-size: 14px;
    font-weight: 600;
    color: $text;
    padding-left: 8px;
    border-left: 3px solid $primary;
    line-height: 1.2;
  }
  .editor-cell {
    margin-bottom: 14px;
    &--tap:active .picker-value-row {
      background: #eef4ff;
      border-color: #c6daf5;
    }
    &--switch {
      flex-direction: row;
      display: flex;
      align-items: center;
      justify-content: space-between;
      padding: 4px 0;
      gap: 12px;
      padding: 4px 0 10px;
      margin-bottom: 4px;
    }
    &--value {
      margin-bottom: 10px;
    }
  }
  .switch-label-wrap {
    display: flex;
    flex-direction: column;
    gap: 2px;
  }
  .switch-hint {
    font-size: 12px;
    color: $text-muted;
  }
  .editor-input-box {
    background: #f7f9fc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
    overflow: hidden;
  }
  :deep(.editor-input-box .u-input) {
    background: transparent !important;
  }
  .default-hint {
    display: block;
    font-size: 12px;
    color: $text-muted;
    line-height: 1.5;
    margin: -4px 0 10px;
    padding: 0 2px;
  }
  .manual-options {
    margin: 4px 0 12px;
    padding-top: 4px;
  }
  .manual-options-title {
    display: block;
    font-size: 12px;
    color: $text-muted;
    margin-bottom: 10px;
  }
  .manual-options-table {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .option-table-head {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 0 4px 4px;
  }
  .option-col {
    font-size: 12px;
    color: $text-muted;
    font-weight: 500;
    &--idx {
      width: 22px;
      flex-shrink: 0;
    }
    &--label {
      flex: 1.4;
      min-width: 0;
    }
    &--value {
      flex: 0.9;
      min-width: 72px;
    }
    &--action {
      width: 32px;
      flex-shrink: 0;
    }
  }
  .option-card {
    display: flex;
    align-items: center;
    gap: 8px;
    padding: 8px 10px;
    background: #f8fafc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
  }
  .option-idx {
    width: 22px;
    height: 22px;
    flex-shrink: 0;
    border-radius: 6px;
    background: #eef2f8;
    color: $text-muted;
    font-size: 12px;
    font-weight: 600;
    line-height: 22px;
    text-align: center;
  }
  .option-input-wrap {
    flex: 1.4;
    min-width: 0;
    background: #fff;
    border: 1px solid #e4e8ef;
    border-radius: 8px;
    overflow: hidden;
    &--value {
      flex: 0.9;
      min-width: 72px;
    }
  }
  :deep(.option-input-wrap .u-input) {
    background: transparent !important;
  }
  :deep(.option-input-wrap input),
  :deep(.option-input-wrap .u-input__content__field-wrapper__field) {
    font-size: 14px !important;
    height: 36px !important;
    min-height: 36px !important;
    padding: 0 10px !important;
  }
  .option-del {
    flex-shrink: 0;
    width: 32px;
    height: 32px;
    border-radius: 8px;
    background: #fff;
    border: 1px solid #fde2e2;
    display: flex;
    align-items: center;
    justify-content: center;
  }
  .option-del--active {
    background: #fef0f0;
  }
  .add-option-btn {
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 6px;
    margin-top: 10px;
    padding: 11px;
    border: 1.5px dashed #b8d4ff;
    border-radius: 10px;
    background: linear-gradient(180deg, #f8fbff 0%, #f0f6ff 100%);
    color: $primary;
    font-size: 14px;
    font-weight: 500;
  }
  .add-option-btn--active {
    background: #e8f2ff;
    border-color: $primary;
  }
  .option-source-tip {
    display: flex;
    align-items: flex-start;
    gap: 6px;
    padding: 10px 12px;
    margin-bottom: 10px;
    background: #f5f7fa;
    border-radius: 8px;
    font-size: 12px;
    color: $text-muted;
    line-height: 1.5;
  }
  .editor-title {
    display: block;
    font-size: 17px;
    font-weight: 600;
    color: $text;
  }
  .picker-value-row {
    display: flex;
    align-items: center;
    justify-content: space-between;
    min-height: 44px;
    padding: 0 14px;
    background: #f7f9fc;
    border: 1px solid #e8ecf2;
    border-radius: 10px;
    gap: 8px;
    transition: background 0.15s, border-color 0.15s;
    &--tap:active {
      background: #eef4ff;
      border-color: #c6daf5;
    }
  }
  .picker-value {
    flex: 1;
    min-width: 0;
    font-size: 15px;
    color: $text;
    text-align: left;
    line-height: 1.4;
    &--placeholder {
      color: #c0c4cc;
    }
  }
  .editor-label {
    font-size: 14px;
    display: block;
    font-size: 13px;
    font-weight: 500;
    color: $text-secondary;
    margin-bottom: 8px;
    &.required::before {
      content: "*";
      color: #f56c6c;
      margin-right: 4px;
      margin-right: 3px;
    }
  }
  .editor-row .input-box,
  .editor-row .textarea-box {
    background: #f7f9fc;
    border-radius: 10px;
    border: 1px solid #eef1f6;
  .editor-cell--switch .editor-label {
    margin-bottom: 0;
  }
  .daterange-default-wrap {
    display: flex;
    flex-direction: column;
    gap: 12px;
  }
  .daterange-default-item {
    display: flex;
    flex-direction: column;
    gap: 8px;
  }
  .daterange-default-label {
    font-size: 13px;
    color: $text-secondary;
  }
  .type-chip-grid {
@@ -1494,40 +2514,37 @@
    }
  }
  .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;
    gap: 12px;
    padding: 12px 16px calc(12px + env(safe-area-inset-bottom));
    background: #fff;
    border-top: 1px solid #eef0f4;
    box-shadow: 0 -4px 12px rgba(31, 45, 61, 0.06);
  }
  .editor-btn {
    flex: 1;
    text-align: center;
    padding: 11px 0;
    border-radius: 8px;
    padding: 12px 0;
    border-radius: 10px;
    font-size: 15px;
    font-weight: 500;
    &--cancel {
      color: $text-secondary;
      background: #f5f7fa;
      border: 1px solid #e4e7ed;
    }
    &--confirm {
      color: #fff;
      background: $primary;
      background: linear-gradient(135deg, #4d8dff 0%, #2979ff 100%);
      box-shadow: 0 4px 12px rgba(41, 121, 255, 0.35);
    }
    &--confirm:active {
      opacity: 0.9;
    }
  }
src/pages/oa/ApproveManage/approve-template/index.vue
@@ -80,7 +80,8 @@
                       @click.stop="goEdit(item)">
              ç¼–辑
            </up-button>
            <up-button class="action-btn"
            <up-button v-if="!isSystemTemplate(item)"
                       class="action-btn"
                       size="small"
                       type="error"
                       plain
@@ -119,6 +120,7 @@
    buildTypeLabelMap,
    fetchApprovalTemplateTypes,
    getTemplateTypeLabel,
    isSystemApprovalTemplate,
  } from "../../_utils/approvalTemplateType.js";
  const EDIT_STORAGE_KEY = "oa_approve_template_edit_row";
@@ -163,6 +165,8 @@
  const businessTypeText = type =>
    getTemplateTypeLabel(type, typeLabelMap.value);
  const isSystemTemplate = isSystemApprovalTemplate;
  const loadTemplateTypes = () =>
    fetchApprovalTemplateTypes()
@@ -250,6 +254,10 @@
  const handleDelete = item => {
    if (!item?.id) return;
    if (isSystemTemplate(item)) {
      uni.showToast({ title: "系统内置模板不可删除", icon: "none" });
      return;
    }
    const name = item.templateName || "该模板";
    uni.showModal({
      title: "删除确认",
src/pages/oa/_utils/approvalFormField.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,363 @@
import { parseTime } from "@/utils/ruoyi";
/** å¡«æŠ¥å­—段类型:下拉 */
export const SELECT_FIELD_TYPES = new Set(["select", "dropdown", "picker"]);
/** æ—¥æœŸæ—¶é—´ç±» type */
const DATE_KIND_BY_TYPE = {
  date: "date",
  time: "time",
  datetime: "datetime",
  datetimerange: "datetime",
};
const DEFAULT_FORMAT = {
  date: "YYYY-MM-DD",
  time: "HH:mm",
  datetime: "YYYY-MM-DD HH:mm:ss",
};
/** è§£æž formConfig JSON */
export function parseApprovalFormConfig(raw) {
  if (!raw) return { prompt: "", fields: [] };
  try {
    const obj = typeof raw === "string" ? JSON.parse(raw) : raw;
    return {
      prompt: obj?.prompt || "",
      fields: Array.isArray(obj?.fields) ? obj.fields : [],
    };
  } catch {
    return { prompt: "", fields: [] };
  }
}
/**
 * ä¿®æ”¹å®¡æ‰¹ï¼šæ¨¡æ¿å­—段定义 + å®žä¾‹å·²å¡«å€¼åˆå¹¶
 */
export function mergeFormConfigForEdit(templateRaw, instanceRaw) {
  const template = parseApprovalFormConfig(templateRaw);
  const instance = parseApprovalFormConfig(instanceRaw);
  const valueMap = {};
  instance.fields.forEach(field => {
    if (!field?.key) return;
    const val = field.value ?? field.defaultValue;
    if (val !== undefined && val !== null && val !== "") {
      valueMap[field.key] = val;
    }
  });
  const baseFields = template.fields.length ? template.fields : instance.fields;
  return {
    prompt: instance.prompt || template.prompt,
    fields: baseFields.map(field => ({
      ...field,
      value: valueMap[field.key] ?? field.value ?? field.defaultValue ?? "",
    })),
  };
}
/** æ˜¯å¦ä¸ºä¸‹æ‹‰ç±»å­—段 */
export function isSelectField(field) {
  const type = String(field?.type ?? "").toLowerCase();
  return SELECT_FIELD_TYPES.has(type);
}
/** æ˜¯å¦ä¸ºå¤šè¡Œæ–‡æœ¬ */
export function isTextareaField(field) {
  return String(field?.type ?? "").toLowerCase() === "textarea";
}
/** è¯»å–字段配置的日期格式(兼容 format / dateFormat / timeFormat) */
export function getFieldFormatStr(field) {
  return (
    field?.format ?? field?.dateFormat ?? field?.timeFormat ?? ""
  ).trim();
}
/** æ—¥æœŸæ—¶é—´å­—段种类:date | time | datetime | null */
export function getDateFieldKind(field) {
  const type = String(field?.type ?? "").toLowerCase();
  if (DATE_KIND_BY_TYPE[type]) return DATE_KIND_BY_TYPE[type];
  const fmt = getFieldFormatStr(field);
  if (!fmt) return null;
  const hasDate = /Y{2,4}|D{1,2}/i.test(fmt);
  const hasTime = /H{1,2}|h{1,2}|m{1,2}|s{1,2}/i.test(fmt);
  if (hasDate && hasTime) return "datetime";
  if (hasTime && !hasDate) return "time";
  if (hasDate) return "date";
  return null;
}
/** æ˜¯å¦ä¸ºæ—¥æœŸ/时间类字段(不含日期时间范围) */
export function isDateLikeField(field) {
  if (isDatetimerangeField(field)) return false;
  return !!getDateFieldKind(field);
}
/** @deprecated ä½¿ç”¨ isDateLikeField */
export function isDateField(field) {
  return isDateLikeField(field);
}
/** uView datetime-picker çš„ mode */
export function getDatePickerMode(field) {
  const kind = getDateFieldKind(field);
  if (kind === "time") return "time";
  if (kind === "datetime") return "datetime";
  return "date";
}
/** moment é£Žæ ¼æ ¼å¼ â†’ parseTime æ¨¡æ¿ */
export function momentFormatToParsePattern(fmt) {
  if (!fmt) return null;
  return fmt
    .replace(/YYYY/g, "{y}")
    .replace(/YY/g, "{y}")
    .replace(/DD/g, "{d}")
    .replace(/dd/g, "{d}")
    .replace(/MM/g, "{m}")
    .replace(/HH/g, "{h}")
    .replace(/hh/g, "{h}")
    .replace(/mm/g, "{i}")
    .replace(/ss/g, "{s}");
}
/** å°†æ—¶é—´æˆ³/Date æ ¼å¼åŒ–为字段配置格式 */
export function formatFieldDateValue(field, dateSource) {
  const kind = getDateFieldKind(field);
  if (!kind) return "";
  const fmt = getFieldFormatStr(field) || DEFAULT_FORMAT[kind];
  const pattern = momentFormatToParsePattern(fmt);
  let date;
  if (typeof dateSource === "number") date = new Date(dateSource);
  else if (dateSource instanceof Date) date = dateSource;
  else return String(dateSource ?? "");
  return parseTime(date, pattern) || "";
}
/** å±•示用:将已存值按配置格式回显 */
export function formatFieldDisplayValue(field, storedValue) {
  if (storedValue === undefined || storedValue === null || storedValue === "") {
    return "";
  }
  if (!getDateFieldKind(field)) return String(storedValue);
  const ts = parseFieldDateToTs(storedValue);
  if (ts) return formatFieldDateValue(field, ts);
  return String(storedValue);
}
/** å°†å·²å­˜æ—¥æœŸå­—符串转为时间戳(供选择器初始值) */
export function parseFieldDateToTs(value) {
  if (value === undefined || value === null || value === "") return null;
  if (typeof value === "number") return value;
  const str = String(value).trim();
  const normalized = str.replace(/-/g, "/").replace("T", " ");
  const t = new Date(normalized).getTime();
  return Number.isNaN(t) ? null : t;
}
/** æ˜¯å¦ä¸ºæ•°å­— */
export function isNumberField(field) {
  return String(field?.type ?? "").toLowerCase() === "number";
}
/**
 * å°†å­—段配置中的选项规范为 { label, value }[]
 * æ”¯æŒï¼šoptions / optionList;项为字符串或 { label|name|text, value|key|code }
 */
export function normalizeFieldOptions(field) {
  const raw =
    field?.options ?? field?.optionList ?? field?.dictOptions ?? field?.items;
  if (!Array.isArray(raw) || !raw.length) return [];
  return raw
    .map((item, index) => {
      if (item == null) return null;
      if (typeof item === "string" || typeof item === "number") {
        const text = String(item);
        return { label: text, value: text };
      }
      if (typeof item !== "object") return null;
      const label =
        item.label ??
        item.name ??
        item.text ??
        item.dictLabel ??
        item.title;
      const rawValue =
        item.value ?? item.key ?? item.code ?? item.dictValue ?? item.id;
      if (label == null && rawValue == null) return null;
      const value =
        rawValue !== undefined && rawValue !== null ? rawValue : label ?? index;
      return {
        label: String(label ?? value),
        value,
      };
    })
    .filter(Boolean);
}
/** æŒ‰å­˜å‚¨å€¼åŒ¹é…é€‰é¡¹å±•示文案 */
export function getFieldOptionLabel(field, storedValue) {
  if (storedValue === undefined || storedValue === null || storedValue === "") {
    return "";
  }
  const options = normalizeFieldOptions(field);
  const strVal = String(storedValue);
  const matched = options.find(
    opt =>
      String(opt.value) === strVal ||
      String(opt.label) === strVal
  );
  return matched?.label ?? "";
}
/** åˆå§‹åŒ–填报值:优先已填 value,其次 defaultValue */
export function getFieldInitialValue(field) {
  if (field?.value !== undefined && field?.value !== null && field?.value !== "") {
    return field.value;
  }
  if (
    field?.defaultValue !== undefined &&
    field?.defaultValue !== null &&
    field?.defaultValue !== ""
  ) {
    return field.defaultValue;
  }
  return "";
}
/** æ¨¡æ¿ç¼–辑:控件类型选项(与 Web ç«¯ä¸€è‡´ï¼‰ */
export const FIELD_EDITOR_TYPE_OPTIONS = [
  { name: "单行文本", value: "text" },
  { name: "多行文本", value: "textarea" },
  { name: "数字", value: "number" },
  { name: "日期", value: "date" },
  { name: "日期时间范围", value: "datetimerange" },
  { name: "下拉选择", value: "select" },
];
/** ä¸‹æ‹‰é€‰é¡¹æ¥æº */
export const FIELD_OPTION_SOURCE_OPTIONS = [
  { name: "手动配置", value: "manual" },
  { name: "人员列表", value: "user" },
  { name: "部门列表", value: "dept" },
];
const OPTION_SOURCE_ALIASES = {
  manual: "manual",
  user: "user",
  personnel: "user",
  userlist: "user",
  dept: "dept",
  department: "dept",
  deptlist: "dept",
};
export function getFieldEditorTypeLabel(type) {
  const found = FIELD_EDITOR_TYPE_OPTIONS.find(
    item => String(item.value) === String(type)
  );
  return found?.name || type || "-";
}
export function getFieldOptionSourceLabel(source) {
  const key = getFieldOptionSource(source);
  const found = FIELD_OPTION_SOURCE_OPTIONS.find(item => item.value === key);
  return found?.name || "手动配置";
}
/** è§£æžé€‰é¡¹æ¥æºï¼šmanual | user | dept */
export function getFieldOptionSource(fieldOrSource) {
  const raw =
    typeof fieldOrSource === "object"
      ? fieldOrSource?.optionSource
      : fieldOrSource;
  const key = String(raw ?? "manual")
    .trim()
    .toLowerCase();
  return OPTION_SOURCE_ALIASES[key] || "manual";
}
export function isDatetimerangeField(field) {
  return String(field?.type ?? "").toLowerCase() === "datetimerange";
}
/** è§£æžæ—¥æœŸæ—¶é—´èŒƒå›´é»˜è®¤å€¼ï¼šstart,end */
export function parseDatetimerangeValue(stored) {
  if (stored === undefined || stored === null || stored === "") {
    return { start: "", end: "" };
  }
  const parts = String(stored)
    .split(",")
    .map(s => s.trim());
  return { start: parts[0] || "", end: parts[1] || "" };
}
export function joinDatetimerangeValue(start, end) {
  const s = String(start ?? "").trim();
  const e = String(end ?? "").trim();
  if (!s && !e) return "";
  return `${s},${e}`;
}
export function formatDatetimerangeDisplay(stored) {
  const { start, end } = parseDatetimerangeValue(stored);
  if (!start && !end) return "";
  if (start && end) return `${start} è‡³ ${end}`;
  return start || end;
}
/**
 * è§£æžä¸‹æ‹‰é€‰é¡¹ï¼ˆå«äººå‘˜/部门动态来源)
 * @param {object} field
 * @param {{ users?: array, depts?: array }} context
 */
export function resolveFieldOptions(field, context = {}) {
  const source = getFieldOptionSource(field);
  if (source === "user") {
    return (context.users || []).map(user => ({
      label: user.nickName || user.userName || String(user.userId ?? ""),
      value: user.userId,
    }));
  }
  if (source === "dept") {
    return (context.depts || []).map(dept => ({
      label: dept.deptName || dept.name || String(dept.deptId ?? dept.id ?? ""),
      value: dept.deptId ?? dept.id,
    }));
  }
  return normalizeFieldOptions(field);
}
export function createEmptyFieldOption() {
  return { label: "", value: "" };
}
/** å°†ç¼–辑草稿规范为可提交的字段对象 */
export function buildFieldConfigPayload(draft, existingKey) {
  const payload = {
    key: (draft.key || existingKey || "").trim(),
    label: (draft.label || "").trim(),
    type: draft.type || "text",
    required: !!draft.required,
    defaultValue: String(draft.defaultValue ?? "").trim(),
  };
  if (isSelectField(payload)) {
    payload.optionSource = getFieldOptionSource(draft.optionSource);
    if (payload.optionSource === "manual") {
      payload.options = (draft.options || [])
        .map(opt => ({
          label: String(opt?.label ?? "").trim(),
          value: String(opt?.value ?? "").trim(),
        }))
        .filter(opt => opt.label && opt.value);
    } else {
      delete payload.options;
    }
  }
  return payload;
}
src/pages/oa/_utils/approvalTemplateType.js
@@ -6,6 +6,13 @@
 */
export const CUSTOM_TEMPLATE_LIST_TYPE = 1;
/** ç³»ç»Ÿå†…置模板(不可删除,填报项等受限) */
export const SYSTEM_TEMPLATE_TYPE = 0;
export function isSystemApprovalTemplate(item) {
  return Number(item?.templateType) === SYSTEM_TEMPLATE_TYPE;
}
/** ä¸šåŠ¡ç±»åž‹æžšä¸¾å…œåº•ï¼ˆapproveType:1公出 2请假 â€¦ï¼‰ */
export const FALLBACK_BUSINESS_TYPE_OPTIONS = [
  { name: "公出管理", value: 1 },