yyb
5 小时以前 5b248a9716688d8132cfb02b4ba0abecd4060b06
审批模板流程化
已添加9个文件
已修改10个文件
1548 ■■■■ 文件已修改
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 83 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 61 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js 91 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue 158 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue 95 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js 229 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js 35 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue 130 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js 30 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js 140 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 1 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js 45 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue 101 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -8,6 +8,7 @@
  nodeSignModeLabel,
} from "../approve-template/approveTemplateConstants.js";
import { buildFormPayloadFromFields, parseFormConfigToData } from "../approve-template/formConfigUtils.js";
import { isDynamicOptionSource, selectOptionSourceLabel } from "../approve-template/selectOptionSource.js";
/** å®¡æ‰¹ç±»åž‹ï¼ˆä¸ŽåŽç«¯å­—段 approvalType å¯¹é½ï¼ŒåŽæœŸå¯åŒæ­¥ï¼‰ */
export const APPROVAL_TYPE_OPTIONS = [
@@ -176,6 +177,9 @@
/** å•字段展示值(详情只读) */
export function formatFieldDisplayValue(field, val) {
  if (val == null || val === "" || (Array.isArray(val) && !val.length)) return "—";
  if (field?.type === "select" && isDynamicOptionSource(field.optionSource)) {
    return `${selectOptionSourceLabel(field.optionSource)}:${String(val)}`;
  }
  if (field?.type === "select" && field.options?.length) {
    const hit = field.options.find((o) => String(o.value) === String(val));
    return hit?.label || String(val);
@@ -286,6 +290,12 @@
    formConfig: buildInstanceFormConfigJson({ ...tpl, fields: tpl.fields || submitForm?.formFieldDefs }, payload),
    tasks: taskList,
  };
  const attachments =
    (Array.isArray(submitForm?.storageBlobDTOs) && submitForm.storageBlobDTOs.length
      ? submitForm.storageBlobDTOs
      : null) || tpl.storageBlobDTOs;
  if (attachments?.length) dto.storageBlobDTOs = attachments;
  if (isUpdate) {
    dto.id = existingRow?.id ?? submitForm?.instanceId;
@@ -487,6 +497,10 @@
    formFieldDefs: fields,
    formPayload,
    flowNodes,
    templateAttachments: initTemplateAttachmentsFromSnapshot(templateSnapshot),
    storageBlobDTOs: row?.storageBlobDTOs?.length
      ? JSON.parse(JSON.stringify(row.storageBlobDTOs))
      : [],
  };
}
@@ -511,5 +525,14 @@
    formFieldDefs: tpl?.fields || [],
    formPayload: payload,
    flowNodes,
    templateAttachments: tpl?.storageBlobDTOs
      ? JSON.parse(JSON.stringify(tpl.storageBlobDTOs))
      : [],
    storageBlobDTOs: [],
  };
}
export function initTemplateAttachmentsFromSnapshot(templateSnapshot) {
  const list = templateSnapshot?.storageBlobDTOs;
  return list?.length ? JSON.parse(JSON.stringify(list)) : [];
}
src/views/officeProcessAutomation/ApproveManage/approve-list/components/FormPayloadFields.vue
@@ -17,12 +17,17 @@
      </el-descriptions-item>
    </el-descriptions>
    <el-form v-else label-width="120px" class="form-payload-edit">
    <div
      v-else
      class="form-payload-edit"
      v-loading="optionSourceLoading"
    >
      <el-form-item
        v-for="field in fields"
        :key="field.key"
        :label="field.label"
        :prop="`formPayload.${field.key}`"
        :required="Boolean(field.required)"
      >
        <el-input
          v-if="field.type === 'text'"
@@ -73,17 +78,25 @@
          :placeholder="`请选择${field.label}`"
          style="width: 100%"
          clearable
          filterable
        >
          <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
          <el-option
            v-for="o in getOptions(field)"
            :key="String(o.value)"
            :label="o.label"
            :value="o.value"
          />
        </el-select>
        <span v-else class="field-value">{{ displayValue(field) }}</span>
      </el-form-item>
    </el-form>
    </div>
  </template>
  <el-empty v-else description="暂无填报项" :image-size="48" />
</template>
<script setup>
import { onMounted, watch } from "vue";
import { useSelectOptionSources } from "../../approve-template/useSelectOptionSources.js";
import { formatFieldDisplayValue } from "../approveListConstants.js";
const props = defineProps({
@@ -92,8 +105,31 @@
  readonly: { type: Boolean, default: false },
});
const { loading: optionSourceLoading, ensureForFields, getOptions, getDisplayLabel } =
  useSelectOptionSources();
async function loadOptionCaches() {
  await ensureForFields(props.fields);
}
onMounted(() => {
  loadOptionCaches();
});
watch(
  () => props.fields,
  () => {
    loadOptionCaches();
  },
  { deep: true }
);
function displayValue(field) {
  return formatFieldDisplayValue(field, props.formPayload?.[field.key]);
  const val = props.formPayload?.[field.key];
  if (field.type === "select" && field.optionSource && field.optionSource !== "static") {
    return getDisplayLabel(field, val);
  }
  return formatFieldDisplayValue(field, val);
}
</script>
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -105,54 +105,33 @@
          å½“前类型:{{ selectedBusinessTypeLabel || "—" }},请选择具体审批模板。
          <el-button type="primary" link class="ml8" @click="backToBusinessTypePick">更换类型</el-button>
        </p>
        <div v-loading="submitTemplatesLoading" class="template-grid">
          <div
            v-for="card in submitTemplateCards"
            :key="card.key"
            class="template-card"
            @click="onTemplatePick(card)"
          >
            <span class="template-card-type" :style="approvalTypeStyle(card.approvalType)">
              {{ card.label }}
            </span>
            <span class="template-card-desc">{{ card.summaryPlaceholder }}</span>
          </div>
          <el-empty
            v-if="!submitTemplatesLoading && !submitTemplateCards.length"
            description="该类型下暂无可用审批模板"
            :image-size="80"
            class="template-empty"
          />
        </div>
        <ApprovalTemplatePicker
          :cards="submitTemplateCards"
          :loading="submitTemplatesLoading"
          @pick="onTemplatePick"
        />
      </template>
      <template v-else>
        <div v-loading="submitTemplatesLoading && !isSubmitEdit">
        <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
          <el-form-item label="审批类型">
          <el-form-item v-if="isSubmitEdit" label="审批类型">
            <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
              {{ activeTemplate.label }}
            </span>
            <el-button
              v-if="!isSubmitEdit"
              type="primary"
              link
              class="ml12"
              @click="backToTemplatePick"
            >
              æ›´æ¢æ¨¡æ¿
            </el-button>
          </el-form-item>
          <FormPayloadFields
          <ApprovalTemplateFormSection
            :active-template="activeTemplate"
            :fields="submitFormFields"
            :form-payload="submitForm.formPayload"
            v-model:flow-nodes="submitForm.flowNodes"
            v-model:attachments="submitForm.storageBlobDTOs"
            :template-attachments="submitForm.templateAttachments"
            :user-options="flowUserOptions"
            :show-template-name="!isSubmitEdit"
            :allow-change-template="!isSubmitEdit"
            @change-template="backToTemplatePick"
          />
          <el-form-item label="审批流程" required>
            <TemplateFlowEditor v-model="submitForm.flowNodes" :user-options="flowUserOptions" />
            <p class="flow-tip">
              æŒ‰é¡ºåºæµè½¬ï¼šå¯ä¸ºæ¯ä¸ªèŠ‚ç‚¹æ·»åŠ å¤šåå®¡æ‰¹äººï¼›ä¼šç­¾éœ€å…¨éƒ¨é€šè¿‡ï¼Œæˆ–ç­¾ä»»ä¸€äººé€šè¿‡å³å¯è¿›å…¥ä¸‹ä¸€èŠ‚ç‚¹ã€‚
            </p>
          </el-form-item>
        </el-form>
        </div>
      </template>
@@ -294,9 +273,9 @@
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import TemplateFlowEditor from "../approve-template/components/TemplateFlowEditor.vue";
import FormPayloadFields from "./components/FormPayloadFields.vue";
import ApprovalTemplateFormSection from "../approve-shared/components/ApprovalTemplateFormSection.vue";
import ApprovalTemplatePicker from "../approve-shared/components/ApprovalTemplatePicker.vue";
import { useFlowUserOptions } from "../approve-shared/useFlowUserOptions.js";
import { formatDisplayTime } from "../approve-template/approveTemplateConstants.js";
import { approvalTypeStyle } from "./approveListConstants.js";
import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
@@ -349,29 +328,7 @@
  openApprove,
} = al;
const flowUserOptions = ref([]);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
async function loadUsers() {
  try {
    const res = await userListNoPageByTenantId();
    flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
  } catch {
    flowUserOptions.value = [];
  }
}
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
async function onSubmitInstance() {
  const ok = await submitInstanceForm();
@@ -406,7 +363,7 @@
}
onMounted(() => {
  loadUsers();
  loadFlowUsers();
  handleQuery();
});
</script>
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -18,11 +18,13 @@
  fetchBusinessTypeOptions,
  formatDisplayTime,
  mapEnabledFromApi,
  mapTemplateFromApi,
  unwrapTemplateDetail,
  unwrapTemplateList,
} from "../approve-template/approveTemplateConstants.js";
import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
import {
  buildFormPayloadRules,
  buildTemplateBindingFromDetail,
  validateTemplateBinding,
} from "../approve-shared/approvalTemplateBindingUtils.js";
import {
  APPROVAL_TYPE_OPTIONS,
  approvalStatusLabel,
@@ -37,7 +39,6 @@
  mapInstanceFromApi,
  mapSubmitTemplateCard,
  matchBusinessTypeValue,
  validateSubmitFlowNodes,
  unwrapInstancePage,
} from "./approveListConstants.js";
@@ -111,22 +112,10 @@
    return submitForm.formFieldDefs || [];
  });
  const submitFormRules = computed(() => {
    const rules = {
      templateKey: [{ required: true, message: "请选择审批类型", trigger: "change" }],
    };
    submitFormFields.value.forEach((f) => {
      if (!f.required) return;
      if (f.type === "number") {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
      } else if (f.type === "datetimerange") {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请选择${f.label}`, trigger: "change" }];
      } else {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
      }
    });
    return rules;
  });
  const submitFormRules = computed(() => ({
    templateKey: [{ required: true, message: "请选择审批类型", trigger: "change" }],
    ...buildFormPayloadRules(submitFormFields.value),
  }));
  const tableColumn = ref([
    { label: "申请人编号", prop: "applicantNo", width: 110 },
@@ -291,18 +280,12 @@
    submitTemplatesLoading.value = true;
    try {
      const res = await getApprovalTemplateDetail(card.id);
      const mapped = mapTemplateFromApi(unwrapTemplateDetail(res));
      const tpl = {
        ...buildSubmitTemplateFromRow(mapped),
        templateId: mapped.id,
      };
      const base = createEmptySubmitForm(String(card.id), tpl, mapped.flowNodes);
      const applied = buildTemplateBindingFromDetail(res);
      Object.assign(submitForm, {
        ...base,
        templateName: mapped.templateName || tpl.label || "",
        businessType: mapped.businessType ?? card.businessType ?? selectedBusinessType.value,
        templateSnapshot: tpl,
        formFieldDefs: tpl.fields || [],
        templateKey: String(card.id),
        ...applied,
        businessType:
          applied.businessType ?? card.businessType ?? selectedBusinessType.value,
      });
      submitDialog.step = 3;
    } catch {
@@ -343,9 +326,9 @@
      return false;
    }
    if (!activeTemplate.value) return false;
    const flowCheck = validateSubmitFlowNodes(submitForm.flowNodes);
    if (!flowCheck.ok) {
      ElMessage.warning(flowCheck.message);
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      return false;
    }
    if (!submitForm.templateId) {
@@ -360,7 +343,7 @@
          submitForm,
          activeTemplate: activeTemplate.value,
          userStore,
          flowNodes: flowCheck.nodes,
          flowNodes: bindingCheck.nodes,
        })
      );
      submitDialog.visible = false;
@@ -382,9 +365,9 @@
      return false;
    }
    if (!activeTemplate.value) return false;
    const flowCheck = validateSubmitFlowNodes(submitForm.flowNodes);
    if (!flowCheck.ok) {
      ElMessage.warning(flowCheck.message);
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      return false;
    }
    if (!submitForm.instanceId) {
@@ -398,7 +381,7 @@
        buildInstanceDto({
          submitForm,
          activeTemplate: activeTemplate.value,
          flowNodes: flowCheck.nodes,
          flowNodes: bindingCheck.nodes,
          existingRow: submitEditRow.value,
        })
      );
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,102 @@
/**
 * å„业务模块与审批模板类型的映射(配置化入口)
 *
 * ä½¿ç”¨æ–¹å¼ï¼š
 * 1. åœ¨ä¸šåС页引入 ApprovalTemplateBindDialog,传入 moduleKey
 * 2. æˆ–在表单内嵌 ApprovalTemplateFormSection + useApprovalTemplateBinding({ moduleKey })
 *
 * businessType:若后端 TypeEnums å·²å›ºå®š code,可直接写死 value;否则用 typeLabels æŒ‰åç§°åŒ¹é…
 */
export const APPROVAL_MODULE_KEYS = {
  REGULAR: "regular",
  TRANSFER: "transfer",
  RESIGN: "resign",
  WORK_HANDOVER: "work_handover",
  LEAVE: "leave",
  OVERTIME: "overtime",
  TRAVEL_REIMBURSE: "travel_reimburse",
  COST_REIMBURSE: "cost_reimburse",
};
/** @type {Record<string, import('./approvalModuleRegistry.js').ApprovalModuleConfig>} */
export const APPROVAL_MODULE_REGISTRY = {
  [APPROVAL_MODULE_KEYS.REGULAR]: {
    label: "转正申请",
    approvalType: "regular",
    typeLabels: ["转正", "转正申请"],
  },
  [APPROVAL_MODULE_KEYS.TRANSFER]: {
    label: "调岗申请",
    approvalType: "transfer",
    typeLabels: ["调岗", "调动", "调岗申请", "调动申请"],
  },
  [APPROVAL_MODULE_KEYS.RESIGN]: {
    label: "离职申请",
    approvalType: "resign",
    typeLabels: ["离职", "离职申请"],
  },
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
    label: "工作交接",
    approvalType: "work_handover",
    typeLabels: ["工作交接", "交接"],
  },
  [APPROVAL_MODULE_KEYS.LEAVE]: {
    label: "请假申请",
    approvalType: "leave",
    typeLabels: ["请假", "请假申请"],
  },
  [APPROVAL_MODULE_KEYS.OVERTIME]: {
    label: "加班申请",
    approvalType: "overtime",
    typeLabels: ["加班", "加班申请"],
  },
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
    label: "差旅报销",
    approvalType: "travel_reimburse",
    typeLabels: ["差旅", "差旅报销"],
  },
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: {
    label: "费用报销",
    approvalType: "cost_reimburse",
    typeLabels: ["费用", "费用报销"],
  },
};
/**
 * @typedef {object} ApprovalModuleConfig
 * @property {string} label
 * @property {string} [approvalType] åˆ—表样式用
 * @property {string|number} [businessType] ä¸Ž TypeEnums value ä¸€è‡´æ—¶å¯å†™æ­»
 * @property {string[]} [typeLabels] ä¸Ž TypeEnums label æ¨¡ç³ŠåŒ¹é…
 */
export function getApprovalModuleConfig(moduleKey) {
  if (!moduleKey) return null;
  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
}
export function listApprovalModuleEntries() {
  return Object.entries(APPROVAL_MODULE_REGISTRY).map(([moduleKey, cfg]) => ({
    moduleKey,
    ...cfg,
  }));
}
/** ä»Ž TypeEnums é€‰é¡¹ä¸­è§£æžæœ¬æ¨¡å—çš„ businessType */
export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return null;
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  const hit = (typeOptions || []).find((opt) => {
    const optLabel = String(opt?.label || "").trim();
    if (!optLabel) return false;
    return labels.some(
      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
  });
  if (hit?.value != null && hit.value !== "") return hit.value;
  return cfg.approvalType || null;
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalTemplateBindingUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,91 @@
import {
  mapAttachmentsFromApi,
  mapTemplateFromApi,
  unwrapTemplateDetail,
} from "../approve-template/approveTemplateConstants.js";
import { buildSubmitTemplateFromRow } from "../approve-template/formConfigUtils.js";
import {
  createEmptySubmitForm,
  validateSubmitFlowNodes,
} from "../approve-list/approveListConstants.js";
export function attachmentDisplayName(file) {
  return (
    file?.fileName ||
    file?.originalFilename ||
    file?.name ||
    file?.blobName ||
    "附件"
  );
}
/** æŽ¥å£è¯¦æƒ… â†’ æäº¤ç»‘定快照(含流程、附件、填报项) */
export function buildTemplateBindingFromDetail(detailRow) {
  const mapped = mapTemplateFromApi(unwrapTemplateDetail(detailRow));
  const templateAttachments = mapAttachmentsFromApi(mapped);
  const tpl = {
    ...buildSubmitTemplateFromRow(mapped),
    templateId: mapped.id,
    businessType: mapped.businessType,
    storageBlobDTOs: templateAttachments,
  };
  const base = createEmptySubmitForm(String(mapped.id ?? ""), tpl, mapped.flowNodes);
  return {
    templateId: mapped.id,
    templateName: mapped.templateName || tpl.label || "",
    businessType: mapped.businessType ?? "",
    templateSnapshot: tpl,
    formFieldDefs: tpl.fields || [],
    formPayload: base.formPayload,
    flowNodes: base.flowNodes,
    templateAttachments: JSON.parse(JSON.stringify(templateAttachments)),
    storageBlobDTOs: [],
  };
}
/** æ ¹æ®æ¨¡æ¿ fields ç”Ÿæˆ el-form rules(prop ä¸º formPayload.xxx) */
export function buildFormPayloadRules(fields = []) {
  const rules = {};
  (fields || []).forEach((f) => {
    if (!f.required || !f.key) return;
    const prop = `formPayload.${f.key}`;
    if (f.type === "number") {
      rules[prop] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
    } else if (f.type === "datetimerange" || f.type === "date" || f.type === "select") {
      rules[prop] = [{ required: true, message: `请选择${f.label}`, trigger: "change" }];
    } else {
      rules[prop] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
    }
  });
  return rules;
}
/** æ ¡éªŒæ¨¡æ¿ç»‘定:审批流程(附件选填,由用户自行上传) */
export function validateTemplateBinding({ flowNodes }) {
  const flowCheck = validateSubmitFlowNodes(flowNodes);
  if (!flowCheck.ok) return flowCheck;
  return { ok: true, nodes: flowCheck.nodes };
}
/** åˆå¹¶ç»‘定结果到业务表单对象(字段名可按业务覆盖) */
export function applyBindingToForm(target, binding, fieldMap = {}) {
  if (!target || !binding) return target;
  const map = {
    templateId: "templateId",
    templateName: "templateName",
    businessType: "businessType",
    templateSnapshot: "templateSnapshot",
    formFieldDefs: "formFieldDefs",
    formPayload: "formPayload",
    flowNodes: "flowNodes",
    templateAttachments: "templateAttachments",
    storageBlobDTOs: "storageBlobDTOs",
    ...fieldMap,
  };
  Object.entries(map).forEach(([srcKey, destKey]) => {
    if (binding[srcKey] !== undefined) {
      target[destKey] = binding[srcKey];
    }
  });
  return target;
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,158 @@
<!--
  ä¸šåŠ¡æ¨¡å—ã€Œæ–°å¢žã€æ—¶å¯¼å…¥å®¡æ‰¹æ¨¡æ¿ï¼ˆå›ºå®š moduleKey,仅展示该类型下模板)
  ç”¨æ³•:
  <ApprovalTemplateBindDialog
    v-model:visible="visible"
    module-key="regular"
    @confirm="onTemplateBound"
  />
-->
<template>
  <el-dialog
    v-model="dialogVisible"
    :title="dialogTitle"
    :width="step === formStep ? 720 : 640"
    append-to-body
    destroy-on-close
    class="approval-template-bind-dialog"
    @closed="onClosed"
  >
    <template v-if="step === 1">
      <ApprovalTemplatePicker
        :cards="templateCards"
        :loading="templatesLoading"
        :hint="pickerHint"
        @pick="onPickTemplate"
      />
    </template>
    <template v-else>
      <div v-loading="templatesLoading">
        <el-form
          ref="formRef"
          :model="bindingForm"
          :rules="mergedRules"
          label-width="120px"
        >
          <ApprovalTemplateFormSection
            :active-template="activeTemplate"
            :fields="formFields"
            :form-payload="bindingForm.formPayload"
            v-model:flow-nodes="bindingForm.flowNodes"
            v-model:attachments="bindingForm.storageBlobDTOs"
            :template-attachments="bindingForm.templateAttachments"
            :user-options="flowUserOptions"
            allow-change-template
            @change-template="step = 1"
          />
        </el-form>
      </div>
    </template>
    <template #footer>
      <el-button v-if="step === formStep" type="primary" :loading="confirming" @click="onConfirm">
        ç¡® å®š
      </el-button>
      <el-button v-if="step === formStep" @click="step = 1">重选模板</el-button>
      <el-button @click="dialogVisible = false">{{ step === 1 ? "取 æ¶ˆ" : "关 é—­" }}</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import ApprovalTemplatePicker from "./ApprovalTemplatePicker.vue";
import ApprovalTemplateFormSection from "./ApprovalTemplateFormSection.vue";
import { useApprovalTemplateBinding } from "../useApprovalTemplateBinding.js";
import { useFlowUserOptions } from "../useFlowUserOptions.js";
import { getApprovalModuleConfig } from "../approvalModuleRegistry.js";
const props = defineProps({
  visible: { type: Boolean, default: false },
  /** approvalModuleRegistry ä¸­çš„ moduleKey */
  moduleKey: { type: String, required: true },
});
const emit = defineEmits(["update:visible", "confirm"]);
const dialogVisible = computed({
  get: () => props.visible,
  set: (v) => emit("update:visible", v),
});
const {
  step,
  bindingForm,
  templateCards,
  activeTemplate,
  formFields,
  formRules,
  templatesLoading,
  loadTemplates,
  resetBinding,
  pickTemplate,
  validateBinding,
  getBindingPayload,
  moduleConfig,
} = useApprovalTemplateBinding({ moduleKey: props.moduleKey, mode: "module" });
const formStep = 2;
const formRef = ref();
const confirming = ref(false);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const mergedRules = computed(() => ({ ...formRules.value }));
const dialogTitle = computed(() => {
  const label = moduleConfig.value?.label || "审批";
  return step.value === 1 ? `选择${label}模板` : `确认${label}审批信息`;
});
const pickerHint = computed(
  () => `请选择「${moduleConfig.value?.label || "—"}」类型下已启用的审批模板,审批流程将自动带入。`
);
watch(
  () => props.visible,
  async (v) => {
    if (!v) return;
    resetBinding();
    step.value = 1;
    await Promise.all([loadTemplates(), loadFlowUsers()]);
    const cfg = getApprovalModuleConfig(props.moduleKey);
    if (!cfg) ElMessage.warning(`未配置模块「${props.moduleKey}」,请检查 approvalModuleRegistry`);
  }
);
async function onPickTemplate(card) {
  const ok = await pickTemplate(card);
  if (ok) step.value = formStep;
}
async function onConfirm() {
  confirming.value = true;
  try {
    const check = await validateBinding(formRef.value);
    if (!check.ok) {
      if (check.message) ElMessage.warning(check.message);
      return;
    }
    emit("confirm", { ...getBindingPayload(), flowNodes: check.nodes });
    dialogVisible.value = false;
  } finally {
    confirming.value = false;
  }
}
function onClosed() {
  resetBinding();
}
</script>
<style scoped>
.approval-template-bind-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,95 @@
<!-- æ¨¡æ¿ç»‘定表单区:填报项 + å®¡æ‰¹æµç¨‹ + é™„件(须挂在外层 el-form ä¸‹ï¼‰ -->
<template>
  <template v-if="activeTemplate">
    <el-form-item v-if="showTemplateName" label="审批模板">
      <span class="template-name">{{ activeTemplate.label }}</span>
      <el-button v-if="allowChangeTemplate" type="primary" link class="ml12" @click="emit('change-template')">
        æ›´æ¢æ¨¡æ¿
      </el-button>
    </el-form-item>
    <FormPayloadFields :fields="fields" :form-payload="formPayload" />
    <el-form-item label="审批流程" required>
      <TemplateFlowEditor v-model="flowNodesModel" :user-options="userOptions" />
      <p class="section-tip">流程与审批人由模板预置,可按需微调节点审批人。</p>
    </el-form-item>
    <el-form-item v-if="templateAttachments.length" label="模板参考">
      <el-tag
        v-for="(f, i) in templateAttachments"
        :key="`tpl-${i}`"
        class="attachment-tag"
        type="info"
        effect="plain"
      >
        {{ attachmentDisplayName(f) }}
      </el-tag>
      <p class="section-tip">以上为模板附带文件,仅供参考;提交附件请在下方上传。</p>
    </el-form-item>
    <el-form-item label="附件">
      <FileUpload
        v-model:file-list="attachmentsModel"
        :limit="uploadLimit"
        button-text="点击选择文件"
      />
      <p class="section-tip">选填,可上传与申请相关的说明材料。</p>
    </el-form-item>
  </template>
  <el-empty v-else description="请先选择审批模板" :image-size="64" />
</template>
<script setup>
import { computed } from "vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import TemplateFlowEditor from "../../approve-template/components/TemplateFlowEditor.vue";
import FormPayloadFields from "../../approve-list/components/FormPayloadFields.vue";
import { attachmentDisplayName } from "../approvalTemplateBindingUtils.js";
const props = defineProps({
  activeTemplate: { type: Object, default: null },
  fields: { type: Array, default: () => [] },
  formPayload: { type: Object, required: true },
  flowNodes: { type: Array, default: () => [] },
  /** ç”¨æˆ·è‡ªè¡Œä¸Šä¼ çš„附件 */
  attachments: { type: Array, default: () => [] },
  /** æ¨¡æ¿é¢„置附件(只读展示) */
  templateAttachments: { type: Array, default: () => [] },
  userOptions: { type: Array, default: () => [] },
  showTemplateName: { type: Boolean, default: true },
  allowChangeTemplate: { type: Boolean, default: true },
  uploadLimit: { type: Number, default: 10 },
});
const emit = defineEmits(["update:flowNodes", "update:attachments", "change-template"]);
const flowNodesModel = computed({
  get: () => props.flowNodes,
  set: (v) => emit("update:flowNodes", v),
});
const attachmentsModel = computed({
  get: () => props.attachments,
  set: (v) => emit("update:attachments", v),
});
</script>
<style scoped>
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
.section-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin: 8px 0 0;
  line-height: 1.5;
}
.attachment-tag {
  margin: 0 8px 8px 0;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplatePicker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,85 @@
<!-- å®¡æ‰¹æ¨¡æ¿å¡ç‰‡é€‰æ‹©ï¼ˆæŒ‰ businessType è¿‡æ»¤ï¼‰ -->
<template>
  <div class="approval-template-picker">
    <p v-if="hint" class="picker-hint">{{ hint }}</p>
    <div v-loading="loading" class="template-grid">
      <div
        v-for="card in cards"
        :key="card.key || card.id"
        class="template-card"
        @click="emit('pick', card)"
      >
        <span class="template-card-type" :style="typeStyle(card.approvalType)">
          {{ card.label }}
        </span>
        <span class="template-card-desc">{{ card.summaryPlaceholder }}</span>
      </div>
      <el-empty
        v-if="!loading && !cards.length"
        :description="emptyText"
        :image-size="80"
        class="template-empty"
      />
    </div>
  </div>
</template>
<script setup>
import { approvalTypeStyle } from "../../approve-list/approveListConstants.js";
defineProps({
  cards: { type: Array, default: () => [] },
  loading: { type: Boolean, default: false },
  hint: { type: String, default: "" },
  emptyText: { type: String, default: "该类型下暂无可用审批模板" },
});
const emit = defineEmits(["pick"]);
function typeStyle(approvalType) {
  return approvalTypeStyle(approvalType);
}
</script>
<style scoped>
.picker-hint {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  margin: 0 0 16px;
}
.template-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
  min-height: 120px;
}
.template-empty {
  grid-column: 1 / -1;
}
.template-card {
  padding: 14px 16px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: var(--radius-md, 8px);
  cursor: pointer;
  transition: border-color 0.2s, box-shadow 0.2s;
  background: var(--el-fill-color-blank);
}
.template-card:hover {
  border-color: var(--el-color-primary);
  box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
}
.template-card-type {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 8px;
}
.template-card-desc {
  display: block;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,229 @@
import {
  getApprovalTemplateDetail,
  listApprovalTemplate,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { computed, reactive, ref } from "vue";
import { ElMessage } from "element-plus";
import {
  fetchBusinessTypeOptions,
  mapEnabledFromApi,
  unwrapTemplateList,
} from "../approve-template/approveTemplateConstants.js";
import {
  createEmptySubmitForm,
  mapSubmitTemplateCard,
  matchBusinessTypeValue,
} from "../approve-list/approveListConstants.js";
import {
  getApprovalModuleConfig,
  resolveModuleBusinessType,
} from "./approvalModuleRegistry.js";
import {
  buildFormPayloadRules,
  buildTemplateBindingFromDetail,
  validateTemplateBinding,
} from "./approvalTemplateBindingUtils.js";
/**
 * å®¡æ‰¹æ¨¡æ¿ç»‘定(业务模块固定类型 / å®¡æ‰¹åˆ—表通用)
 *
 * @param {object} options
 * @param {string} [options.moduleKey] ä¸šåŠ¡æ¨¡å— key,见 approvalModuleRegistry
 * @param {string|number} [options.businessType] ç›´æŽ¥æŒ‡å®šç±»åž‹ï¼ˆä¼˜å…ˆçº§é«˜äºŽ moduleKey)
 * @param {'module'|'universal'} [options.mode] module=仅本类型模板;universal=需先选类型
 */
export function useApprovalTemplateBinding(options = {}) {
  const { moduleKey = null, businessType: fixedBusinessType = null, mode = moduleKey ? "module" : "universal" } =
    options;
  const isUniversal = mode === "universal" && !moduleKey && fixedBusinessType == null;
  const allTemplates = ref([]);
  const businessTypeOptions = ref([]);
  const selectedBusinessType = ref(fixedBusinessType ?? "");
  const templatesLoading = ref(false);
  const step = ref(isUniversal ? 1 : 1);
  const bindingForm = reactive(createEmptySubmitForm(""));
  const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey));
  const resolvedBusinessType = computed(() => {
    if (fixedBusinessType != null && fixedBusinessType !== "") return fixedBusinessType;
    if (selectedBusinessType.value != null && selectedBusinessType.value !== "") {
      return selectedBusinessType.value;
    }
    if (moduleKey) {
      return resolveModuleBusinessType(moduleKey, businessTypeOptions.value);
    }
    return "";
  });
  const templateCards = computed(() => {
    const type = resolvedBusinessType.value;
    if (type == null || type === "") return [];
    return allTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type));
  });
  const activeTemplate = computed(() => bindingForm.templateSnapshot || null);
  const formFields = computed(() => {
    const tplFields = activeTemplate.value?.fields;
    if (tplFields?.length) return tplFields;
    return bindingForm.formFieldDefs || [];
  });
  const formRules = computed(() => buildFormPayloadRules(formFields.value));
  const hasTemplateBound = computed(() => Boolean(activeTemplate.value?.templateId || bindingForm.templateId));
  function businessTypeLabel(type) {
    if (type == null || type === "") return "";
    const hit = businessTypeOptions.value.find((x) => matchBusinessTypeValue(x.value, type));
    return hit?.label || moduleConfig.value?.label || "";
  }
  const selectedBusinessTypeLabel = computed(() => businessTypeLabel(resolvedBusinessType.value));
  function countTemplatesByBusinessType(type) {
    return allTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type)).length;
  }
  async function loadTemplates() {
    templatesLoading.value = true;
    try {
      const [typeOptions, customRes] = await Promise.all([
        fetchBusinessTypeOptions(),
        listApprovalTemplate(TEMPLATE_TYPE_CUSTOM),
      ]);
      businessTypeOptions.value = typeOptions;
      allTemplates.value = unwrapTemplateList(customRes)
        .filter((row) => mapEnabledFromApi(row.enabled))
        .map(mapSubmitTemplateCard);
      if (moduleKey && !fixedBusinessType) {
        const resolved = resolveModuleBusinessType(moduleKey, typeOptions);
        if (resolved != null && resolved !== "") selectedBusinessType.value = resolved;
      }
    } catch {
      businessTypeOptions.value = [];
      allTemplates.value = [];
      ElMessage.error("加载审批模板失败");
    } finally {
      templatesLoading.value = false;
    }
  }
  function resetBinding() {
    step.value = isUniversal ? 1 : 1;
    if (!fixedBusinessType && !moduleKey) selectedBusinessType.value = "";
    else if (moduleKey) {
      selectedBusinessType.value =
        fixedBusinessType ?? resolveModuleBusinessType(moduleKey, businessTypeOptions.value) ?? "";
    }
    Object.assign(bindingForm, createEmptySubmitForm(""));
  }
  function pickBusinessType(type) {
    if (!countTemplatesByBusinessType(type)) {
      ElMessage.warning("该类型下暂无可用审批模板");
      return;
    }
    selectedBusinessType.value = type;
    step.value = 2;
  }
  function backToBusinessTypePick() {
    selectedBusinessType.value = "";
    step.value = 1;
  }
  function backToTemplatePick() {
    step.value = isUniversal ? 2 : 1;
  }
  async function pickTemplate(card) {
    if (!card?.id) return false;
    templatesLoading.value = true;
    try {
      const res = await getApprovalTemplateDetail(card.id);
      const applied = buildTemplateBindingFromDetail(res);
      Object.assign(bindingForm, {
        templateKey: String(card.id),
        ...applied,
      });
      step.value = isUniversal ? 3 : 2;
      return true;
    } catch {
      ElMessage.error("加载模板详情失败");
      return false;
    } finally {
      templatesLoading.value = false;
    }
  }
  /** ç›´æŽ¥ä»¥è¯¦æƒ…行绑定(编辑回显) */
  function applyBindingState(state) {
    if (!state) return;
    Object.assign(bindingForm, createEmptySubmitForm(""), state);
    step.value = isUniversal ? 3 : 2;
  }
  async function validateBinding(formRef) {
    if (formRef?.validate) {
      try {
        await formRef.validate();
      } catch {
        return { ok: false };
      }
    }
    if (!hasTemplateBound.value) {
      return { ok: false, message: "请选择审批模板" };
    }
    return validateTemplateBinding({ flowNodes: bindingForm.flowNodes });
  }
  function getBindingPayload() {
    return {
      templateId: bindingForm.templateId,
      templateName: bindingForm.templateName,
      businessType: bindingForm.businessType ?? resolvedBusinessType.value,
      templateSnapshot: bindingForm.templateSnapshot,
      formFieldDefs: bindingForm.formFieldDefs,
      formPayload: bindingForm.formPayload,
      flowNodes: bindingForm.flowNodes,
      templateAttachments: bindingForm.templateAttachments,
      storageBlobDTOs: bindingForm.storageBlobDTOs,
    };
  }
  return {
    isUniversal,
    moduleConfig,
    step,
    bindingForm,
    allTemplates,
    businessTypeOptions,
    selectedBusinessType,
    resolvedBusinessType,
    selectedBusinessTypeLabel,
    templateCards,
    activeTemplate,
    formFields,
    formRules,
    hasTemplateBound,
    templatesLoading,
    loadTemplates,
    resetBinding,
    pickBusinessType,
    backToBusinessTypePick,
    backToTemplatePick,
    pickTemplate,
    applyBindingState,
    validateBinding,
    getBindingPayload,
    countTemplatesByBusinessType,
    businessTypeLabel,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/useFlowUserOptions.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,35 @@
import { ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
/** å®¡æ‰¹æµç¨‹é€‰äººä¸‹æ‹‰ï¼ˆæ¨¡æ¿/实例共用) */
export function useFlowUserOptions() {
  const flowUserOptions = ref([]);
  const loading = ref(false);
  async function loadFlowUsers() {
    loading.value = true;
    try {
      const res = await userListNoPageByTenantId();
      flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
    } catch {
      flowUserOptions.value = [];
    } finally {
      loading.value = false;
    }
  }
  return { flowUserOptions, loading, loadFlowUsers };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -178,6 +178,18 @@
  return data;
}
/** åŽç«¯é™„件字段 â†’ é¡µé¢ storageBlobDTOs */
export function mapAttachmentsFromApi(row) {
  const list =
    row?.storageBlobDTOs ||
    row?.storageBlobDTOS ||
    row?.storageBlobVOS ||
    row?.storageBlobVOList ||
    row?.attachmentList ||
    [];
  return Array.isArray(list) ? list : [];
}
/** åˆ†é¡µåˆ—表项 â†’ é¡µé¢è¡Œæ•°æ®ï¼ˆä¸»è¡¨ + èŠ‚ç‚¹ï¼‰ */
export function mapTemplateFromApi(row) {
  if (!row) return {};
@@ -193,6 +205,7 @@
    businessType: row.businessType ?? "",
    formConfig: row.formConfig,
    formConfigData: parseFormConfigToData(row.formConfig),
    storageBlobDTOs: mapAttachmentsFromApi(row),
    createdUser: row.createdUser,
    createdUserName: row.createdUserName,
    ...times,
@@ -236,6 +249,8 @@
    }),
  };
  if (templateId) dto.id = templateId;
  const attachments = Array.isArray(form.storageBlobDTOs) ? form.storageBlobDTOs : [];
  if (attachments.length) dto.storageBlobDTOs = attachments;
  return dto;
}
@@ -280,6 +295,7 @@
    formConfigData: createEmptyFormConfigData(),
    enabled: true,
    flowNodes: [createEmptyNode(1)],
    storageBlobDTOs: [],
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
@@ -219,10 +219,12 @@
              placeholder="选填"
              style="width: 100%"
              clearable
              filterable
              :loading="optionSourceLoading"
              @change="emitOut"
            >
              <el-option
                v-for="o in field.options.filter((x) => x.value !== '' && x.value != null)"
                v-for="o in resolvedSelectOptions(field)"
                :key="String(o.value)"
                :label="o.label || o.value"
                :value="o.value"
@@ -231,28 +233,52 @@
          </div>
          <div v-if="field.type === 'select'" class="fce-section fce-section--options">
            <div class="fce-options-head">
              <span class="fce-section-title">下拉选项</span>
              <el-button type="primary" link size="small" :icon="Plus" @click="addOption(field)">
                æ·»åР选项
              </el-button>
            </div>
            <div
              v-for="(opt, oi) in field.options"
              :key="oi"
              class="fce-option-row"
            >
              <span class="fce-option-index">{{ oi + 1 }}</span>
              <el-input v-model="opt.label" placeholder="显示文本" @input="emitOut" />
              <el-input v-model="opt.value" placeholder="选项值" class="fce-option-value" @input="emitOut" />
              <el-button
                type="danger"
                link
                :icon="Delete"
                :disabled="field.options.length <= 1"
                @click="removeOption(field, oi)"
              />
            </div>
            <span class="fce-section-title">下拉选项</span>
            <el-row :gutter="16" class="fce-source-row">
              <el-col :span="12">
                <el-form-item label="选项来源" class="fce-field-item">
                  <el-select
                    v-model="field.optionSource"
                    style="width: 100%"
                    @change="onOptionSourceChange(field)"
                  >
                    <el-option
                      v-for="s in SELECT_OPTION_SOURCE_OPTIONS"
                      :key="s.value"
                      :label="s.label"
                      :value="s.value"
                    />
                  </el-select>
                </el-form-item>
              </el-col>
            </el-row>
            <p v-if="isDynamicOptionSource(field.optionSource)" class="fce-source-tip">
              {{ optionSourceDesc(field.optionSource) }}。提交审批时将自动加载最新数据,无需手动维护选项。
            </p>
            <template v-if="!isDynamicOptionSource(field.optionSource)">
              <div class="fce-options-head">
                <span class="fce-section-subtitle">手动选项</span>
                <el-button type="primary" link size="small" :icon="Plus" @click="addOption(field)">
                  æ·»åР选项
                </el-button>
              </div>
              <div
                v-for="(opt, oi) in field.options"
                :key="oi"
                class="fce-option-row"
              >
                <span class="fce-option-index">{{ oi + 1 }}</span>
                <el-input v-model="opt.label" placeholder="显示文本" @input="emitOut" />
                <el-input v-model="opt.value" placeholder="选项值" class="fce-option-value" @input="emitOut" />
                <el-button
                  type="danger"
                  link
                  :icon="Delete"
                  :disabled="field.options.length <= 1"
                  @click="removeOption(field, oi)"
                />
              </div>
            </template>
          </div>
        </div>
      </div>
@@ -271,6 +297,12 @@
  createEmptyFormField,
  formFieldTypeLabel,
} from "../formConfigUtils.js";
import {
  SELECT_OPTION_SOURCE,
  SELECT_OPTION_SOURCE_OPTIONS,
  isDynamicOptionSource,
} from "../selectOptionSource.js";
import { useSelectOptionSources } from "../useSelectOptionSources.js";
const props = defineProps({
  modelValue: { type: Object, default: () => createEmptyFormConfigData() },
@@ -279,6 +311,8 @@
const emit = defineEmits(["update:modelValue"]);
const inner = reactive(createEmptyFormConfigData());
const { loading: optionSourceLoading, ensureForFields, getOptions } = useSelectOptionSources();
function typeLabel(type) {
  return formFieldTypeLabel(type);
@@ -289,6 +323,15 @@
  return `选填,选择模板时将预填${name}`;
}
function optionSourceDesc(source) {
  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.desc || "";
}
function resolvedSelectOptions(field) {
  if (field.type !== "select") return [];
  return getOptions(field);
}
function syncFromProps(v) {
  const src = v || createEmptyFormConfigData();
  inner.summaryPlaceholder = src.summaryPlaceholder || "";
@@ -296,8 +339,10 @@
    ...createEmptyFormField(),
    ...f,
    _uid: f._uid || createEmptyFormField()._uid,
    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
    options: (f.options || [{ label: "", value: "" }]).map((o) => ({ ...o })),
  }));
  ensureForFields(inner.fields);
}
function emitOut() {
@@ -313,6 +358,7 @@
      min: f.min,
      precision: f.precision,
      defaultValue: cloneDefaultValue(f),
      optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
      options: (f.options || []).map((o) => ({ label: o.label, value: o.value })),
    })),
  });
@@ -333,6 +379,7 @@
function addField() {
  inner.fields.push(createEmptyFormField());
  ensureForFields(inner.fields);
  emitOut();
}
@@ -357,10 +404,23 @@
}
function onTypeChange(field) {
  if (field.type === "select" && (!field.options || !field.options.length)) {
    field.options = [{ label: "", value: "" }];
  if (field.type === "select") {
    if (!field.optionSource) field.optionSource = SELECT_OPTION_SOURCE.STATIC;
    if (!field.options || !field.options.length) {
      field.options = [{ label: "", value: "" }];
    }
    ensureForFields(inner.fields);
  }
  resetDefaultValueForType(field);
  emitOut();
}
function onOptionSourceChange(field) {
  field.defaultValue = "";
  if (!isDynamicOptionSource(field.optionSource) && (!field.options || !field.options.length)) {
    field.options = [{ label: "", value: "" }];
  }
  ensureForFields(inner.fields);
  emitOut();
}
@@ -585,10 +645,28 @@
  margin-bottom: 10px;
}
.fce-options-head .fce-section-title {
.fce-options-head .fce-section-title,
.fce-options-head .fce-section-subtitle {
  margin-bottom: 0;
}
.fce-section-subtitle {
  font-size: 12px;
  font-weight: 600;
  color: var(--el-text-color-secondary);
}
.fce-source-row {
  margin-bottom: 4px;
}
.fce-source-tip {
  margin: 0 0 10px;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
.fce-option-row {
  display: flex;
  align-items: center;
src/views/officeProcessAutomation/ApproveManage/approve-template/formConfigUtils.js
@@ -1,3 +1,12 @@
import { mapAttachmentsFromApi } from "./approveTemplateConstants.js";
import {
  isDynamicOptionSource,
  SELECT_OPTION_SOURCE,
  selectOptionSourceLabel,
} from "./selectOptionSource.js";
export { selectOptionSourceLabel };
/** å¡«æŠ¥é¡¹ç±»åž‹ï¼ˆä¸Žå®¡æ‰¹æäº¤é¡µ field.type ä¸€è‡´ï¼‰ */
export const FORM_FIELD_TYPE_OPTIONS = [
  { value: "text", label: "单行文本" },
@@ -67,6 +76,7 @@
    min: 0,
    precision: 0,
    defaultValue: "",
    optionSource: SELECT_OPTION_SOURCE.STATIC,
    options: [{ label: "", value: "" }],
  };
}
@@ -154,6 +164,7 @@
    min: f.min ?? 0,
    precision: f.precision ?? 0,
    defaultValue: normalizeDefaultValueFromApi(f),
    optionSource: f.optionSource || SELECT_OPTION_SOURCE.STATIC,
    options: (f.options || []).length
      ? f.options.map((o) => ({ label: o.label || "", value: o.value ?? "" }))
      : [{ label: "", value: "" }],
@@ -180,9 +191,13 @@
      item.precision = f.precision ?? 0;
    }
    if (item.type === "select") {
      item.options = (f.options || [])
        .filter((o) => (o.label || "").trim() || o.value !== "" && o.value != null)
        .map((o) => ({ label: (o.label || "").trim(), value: o.value }));
      const source = f.optionSource || SELECT_OPTION_SOURCE.STATIC;
      item.optionSource = source;
      if (!isDynamicOptionSource(source)) {
        item.options = (f.options || [])
          .filter((o) => (o.label || "").trim() || (o.value !== "" && o.value != null))
          .map((o) => ({ label: (o.label || "").trim(), value: o.value }));
      }
    }
    if (hasDefaultValue(f)) {
      item.defaultValue =
@@ -223,6 +238,8 @@
    if (keys.has(key)) return { ok: false, message: `字段标识「${key}」重复,请修改` };
    keys.add(key);
    if (f.type === "select") {
      const source = f.optionSource || SELECT_OPTION_SOURCE.STATIC;
      if (isDynamicOptionSource(source)) continue;
      const opts = (f.options || []).filter((o) => (o.label || "").trim() && o.value !== "" && o.value != null);
      if (!opts.length) return { ok: false, message: `请为「${label}」配置至少一个下拉选项` };
    }
@@ -241,13 +258,16 @@
    return dv.length === 2 ? `${dv[0]} ~ ${dv[1]}` : "—";
  }
  if (field?.type === "select") {
    if (isDynamicOptionSource(field.optionSource)) {
      return `${selectOptionSourceLabel(field.optionSource)}:${String(dv)}`;
    }
    const opt = (field.options || []).find((o) => String(o.value) === String(dv));
    return opt?.label || String(dv);
  }
  return String(dv);
}
/** å°†åŽç«¯æ¨¡æ¿è¡Œè½¬ä¸ºæäº¤é¡µæ¨¡æ¿ç»“构(含 fields é»˜è®¤å€¼ï¼‰ */
/** å°†åŽç«¯æ¨¡æ¿è¡Œè½¬ä¸ºæäº¤é¡µæ¨¡æ¿ç»“构(含 fields é»˜è®¤å€¼ã€é™„件) */
export function buildSubmitTemplateFromRow(row) {
  const cfg = parseFormConfigToData(row?.formConfig);
  const fields = (cfg.fields || []).map(({ _uid, ...rest }) => ({
@@ -260,6 +280,7 @@
    min: rest.min,
    precision: rest.precision,
    defaultValue: rest.defaultValue,
    optionSource: rest.optionSource,
    options: rest.options,
  }));
  return {
@@ -269,6 +290,7 @@
    summaryPlaceholder: cfg.summaryPlaceholder || "",
    approvalMode: cfg.approvalMode || "parallel",
    fields,
    storageBlobDTOs: mapAttachmentsFromApi(row),
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -196,6 +196,18 @@
        </el-form-item>
        <el-form-item label="附件">
          <div class="upload-block">
            <FileUpload v-model:file-list="form.storageBlobDTOs" :limit="10" button-text="点击选择文件" />
          </div>
          <p class="flow-tip">可上传模板说明文档、制度文件等(选填)。</p>
        </el-form-item>
      </el-form>
      <template #footer>
@@ -274,6 +286,16 @@
        </el-table-column>
        <el-table-column label="选项来源" width="100">
          <template #default="{ row }">
            {{ row.type === 'select' ? selectOptionSourceLabel(row.optionSource) : '—' }}
          </template>
        </el-table-column>
        <el-table-column label="必填" width="70" align="center">
          <template #default="{ row }">{{ row.required !== false ? "是" : "否" }}</template>
@@ -338,6 +360,32 @@
      <el-empty v-else description="暂无流程节点" :image-size="60" />
      <el-divider content-position="left">附件({{ detailAttachments.length }} ä¸ªï¼‰</el-divider>
      <template v-if="detailAttachments.length">
        <el-tag
          v-for="(f, i) in detailAttachments"
          :key="i"
          class="detail-attachment-tag"
          type="info"
          effect="plain"
        >
          {{ attachmentDisplayName(f) }}
        </el-tag>
      </template>
      <el-empty v-else description="暂无附件" :image-size="48" />
      </div>
      <template #footer>
@@ -366,13 +414,16 @@
import { userListNoPageByTenantId } from "@/api/system/user.js";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import FormConfigEditor from "./components/FormConfigEditor.vue";
import TemplateFlowEditor from "./components/TemplateFlowEditor.vue";
import { formatDisplayTime } from "./approveTemplateConstants.js";
import { formatDisplayTime, mapAttachmentsFromApi } from "./approveTemplateConstants.js";
import { formatDefaultValueDisplay, formFieldTypeLabel, parseFormConfigToData } from "./formConfigUtils.js";
import { selectOptionSourceLabel } from "./selectOptionSource.js";
import { useApproveTemplate } from "./useApproveTemplate.js";
@@ -441,6 +492,20 @@
  parseFormConfigToData(detailRow.value?.formConfigData ?? detailRow.value?.formConfig)
);
const detailAttachments = computed(() => mapAttachmentsFromApi(detailRow.value));
function attachmentDisplayName(file) {
  if (!file) return "未命名";
  return file.name || file.originalFilename || file.fileName || "未命名";
}
@@ -706,6 +771,18 @@
}
.upload-block {
  width: 100%;
}
.detail-attachment-tag {
  margin: 0 8px 8px 0;
}
.text-muted {
  font-size: 12px;
src/views/officeProcessAutomation/ApproveManage/approve-template/selectOptionSource.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,140 @@
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
/** ä¸‹æ‹‰é€‰é¡¹æ¥æºï¼ˆå†™å…¥ formConfig,提交页按来源拉取数据) */
export const SELECT_OPTION_SOURCE = {
  STATIC: "static",
  USER: "user",
  DEPT: "dept",
};
export const SELECT_OPTION_SOURCE_OPTIONS = [
  { value: SELECT_OPTION_SOURCE.STATIC, label: "手动配置", desc: "在模板中自定义选项文本与值" },
  { value: SELECT_OPTION_SOURCE.USER, label: "人员列表", desc: "从系统用户中选择,值为用户 ID" },
  { value: SELECT_OPTION_SOURCE.DEPT, label: "部门列表", desc: "从组织架构中选择,值为部门 ID" },
];
export function selectOptionSourceLabel(source) {
  return SELECT_OPTION_SOURCE_OPTIONS.find((x) => x.value === source)?.label || "—";
}
export function isDynamicOptionSource(source) {
  return source === SELECT_OPTION_SOURCE.USER || source === SELECT_OPTION_SOURCE.DEPT;
}
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
/** ç”¨æˆ· â†’ ä¸‹æ‹‰ option */
export function mapUserToSelectOption(u) {
  const value = u.userId ?? u.id;
  return {
    label: u.nickName || u.userName || `用户${value}`,
    value,
  };
}
/** éƒ¨é—¨æ ‘拍平为下拉 option */
export function flattenDeptToSelectOptions(nodes, result = []) {
  (nodes || []).forEach((node) => {
    const value = node.id ?? node.deptId ?? node.value;
    if (value != null && value !== "") {
      result.push({
        label: node.label ?? node.deptName ?? node.name ?? String(value),
        value,
      });
    }
    if (node.children?.length) flattenDeptToSelectOptions(node.children, result);
  });
  return result;
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
/** æŒ‰å­—段配置解析下拉 options(需传入已加载的缓存) */
export function resolveFieldSelectOptions(field, caches = {}) {
  const source = field?.optionSource || SELECT_OPTION_SOURCE.STATIC;
  if (source === SELECT_OPTION_SOURCE.USER) {
    return (caches.users || []).map(mapUserToSelectOption);
  }
  if (source === SELECT_OPTION_SOURCE.DEPT) {
    return caches.deptOptions || [];
  }
  return (field?.options || []).filter((o) => o.value !== "" && o.value != null);
}
/** æ ¹æ®å·²è§£æžçš„ options åæŸ¥å±•示文本 */
export function resolveSelectDisplayLabel(field, val, caches = {}) {
  if (val == null || val === "") return "—";
  const options = resolveFieldSelectOptions(field, caches);
  const hit = options.find((o) => String(o.value) === String(val));
  return hit?.label || String(val);
}
/** åŠ è½½äººå‘˜ / éƒ¨é—¨ç¼“存(多处复用) */
export async function fetchSelectOptionCaches(sources = []) {
  const needUser = sources.includes(SELECT_OPTION_SOURCE.USER);
  const needDept = sources.includes(SELECT_OPTION_SOURCE.DEPT);
  const caches = { users: [], deptOptions: [] };
  if (!needUser && !needDept) return caches;
  const tasks = [];
  if (needUser) {
    tasks.push(
      userListNoPageByTenantId()
        .then((res) => {
          caches.users = unwrapArray(res).filter(isActiveUser);
        })
        .catch(() => {
          caches.users = [];
        })
    );
  }
  if (needDept) {
    tasks.push(
      deptTreeSelect()
        .then((res) => {
          let tree = unwrapArray(res);
          tree = tree.length ? filterDisabledDept(JSON.parse(JSON.stringify(tree))) : [];
          if (!tree.length) tree = unwrapArray(res);
          caches.deptOptions = flattenDeptToSelectOptions(tree);
        })
        .catch(() => {
          caches.deptOptions = [];
        })
    );
  }
  await Promise.all(tasks);
  return caches;
}
/** ä»Žå­—段列表收集需要预加载的动态来源 */
export function collectOptionSourcesFromFields(fields) {
  const set = new Set();
  (fields || []).forEach((f) => {
    if (f?.type === "select" && isDynamicOptionSource(f.optionSource)) {
      set.add(f.optionSource);
    }
  });
  return [...set];
}
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -210,6 +210,7 @@
      ),
      enabled: row.enabled !== false,
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
  }
src/views/officeProcessAutomation/ApproveManage/approve-template/useSelectOptionSources.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,45 @@
import { reactive, ref } from "vue";
import {
  collectOptionSourcesFromFields,
  fetchSelectOptionCaches,
  resolveFieldSelectOptions,
  resolveSelectDisplayLabel,
} from "./selectOptionSource.js";
/** ä¸‹æ‹‰åŠ¨æ€é€‰é¡¹ï¼šäººå‘˜ / éƒ¨é—¨ç¼“存与解析 */
export function useSelectOptionSources() {
  const loading = ref(false);
  const caches = reactive({
    users: [],
    deptOptions: [],
  });
  async function ensureForFields(fields) {
    const sources = collectOptionSourcesFromFields(fields);
    if (!sources.length) return;
    loading.value = true;
    try {
      const next = await fetchSelectOptionCaches(sources);
      caches.users = next.users;
      caches.deptOptions = next.deptOptions;
    } finally {
      loading.value = false;
    }
  }
  function getOptions(field) {
    return resolveFieldSelectOptions(field, caches);
  }
  function getDisplayLabel(field, val) {
    return resolveSelectDisplayLabel(field, val, caches);
  }
  return {
    loading,
    caches,
    ensureForFields,
    getOptions,
    getDisplayLabel,
  };
}
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
@@ -28,7 +28,7 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增转正申请</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增转正申请</el-button>
      </div>
    </div>
    <div class="table_list">
@@ -87,7 +87,7 @@
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
          <el-col v-if="!form.hasTemplateBinding" :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
@@ -96,7 +96,20 @@
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
        <template v-if="form.hasTemplateBinding">
          <ApprovalTemplateFormSection
            :active-template="form.templateSnapshot"
            :fields="form.formFieldDefs"
            :form-payload="form.formPayload"
            v-model:flow-nodes="form.flowNodes"
            v-model:attachments="form.storageBlobDTOs"
            :template-attachments="form.templateAttachments"
            :user-options="flowUserOptions"
            :allow-change-template="formDialog.mode === 'add'"
            @change-template="reopenTemplateBind"
          />
        </template>
        <el-row v-else :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
@@ -129,7 +142,7 @@
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
        <el-row v-if="!form.hasTemplateBinding" :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
@@ -146,6 +159,12 @@
        </div>
      </template>
    </el-dialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.REGULAR"
      @confirm="onTemplateBound"
    />
    <!-- è¯¦æƒ…(只读) -->
    <el-dialog v-model="detailDialog.visible" title="转正申请详情" width="640px" append-to-body>
@@ -204,6 +223,14 @@
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import ApprovalTemplateFormSection from "../../ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
/** ä¸ŽåŽç«¯çº¦å®šå­—段(占位) */
const createEmptyForm = () => ({
@@ -216,6 +243,15 @@
  approverIds: [],
  approverNames: "",
  attachmentList: [],
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const { proxy } = getCurrentInstance();
@@ -511,22 +547,27 @@
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const formRules = {
  applicantName: [{ required: true, message: "请输入申请人", trigger: "blur" }],
  applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
  regularizationDate: [{ required: true, message: "请选择转正日期", trigger: "change" }],
  probationSummary: [{ required: true, message: "请填写试用期工作总结", trigger: "blur" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [
    {
      type: "array",
      required: true,
      message: "请选择审批人",
      trigger: "change",
    },
  ],
};
const formRules = computed(() => {
  const base = {
    applicantName: [{ required: true, message: "请输入申请人", trigger: "blur" }],
    applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
    regularizationDate: [{ required: true, message: "请选择转正日期", trigger: "change" }],
    probationSummary: [{ required: true, message: "请填写试用期工作总结", trigger: "blur" }],
  };
  if (form.hasTemplateBinding) {
    return { ...base, ...buildFormPayloadRules(form.formFieldDefs) };
  }
  return {
    ...base,
    approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
    approverIds: [
      { type: "array", required: true, message: "请选择审批人", trigger: "change" },
    ],
  };
});
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
@@ -569,6 +610,27 @@
    return;
  }
  proxy?.$modal?.msgSuccess?.(`已模拟下载:${row.name}`);
}
function openAddWithTemplate() {
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  Object.assign(form, createEmptyForm());
  applyBindingToForm(form, binding);
  form.hasTemplateBinding = true;
  formDialog.mode = "add";
  formDialog.title = "新增转正申请";
  loadApproverTree();
  loadFlowUsers();
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function reopenTemplateBind() {
  formDialog.visible = false;
  templateBindVisible.value = true;
}
function openFormDialog(mode, row) {
@@ -637,6 +699,7 @@
onMounted(() => {
  loadApproverTree();
  loadFlowUsers();
});
</script>