yyb
10 小时以前 352f7bbb74f1b6c57b3d3e576849d0565932fbd4
审批模板集成页面
已添加6个文件
已修改11个文件
4906 ■■■■■ 文件已修改
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 151 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 23 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceRowMappers.js 10 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js 34 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue 112 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue 17 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js 396 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue 748 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue 846 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js 98 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue 538 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue 494 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue 610 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/work-handover/index.vue 570 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -8,7 +8,10 @@
  nodeSignModeLabel,
} from "../approve-template/approveTemplateConstants.js";
import { buildFormPayloadFromFields, parseFormConfigToData } from "../approve-template/formConfigUtils.js";
import { isDynamicOptionSource, selectOptionSourceLabel } from "../approve-template/selectOptionSource.js";
import {
  isDynamicOptionSource,
  resolveSelectDisplayLabel,
} from "../approve-template/selectOptionSource.js";
/** å®¡æ‰¹ç±»åž‹ï¼ˆä¸ŽåŽç«¯å­—段 approvalType å¯¹é½ï¼ŒåŽæœŸå¯åŒæ­¥ï¼‰ */
export const APPROVAL_TYPE_OPTIONS = [
@@ -25,15 +28,47 @@
  { value: "procurement", label: "采购审批", cellBg: "#f4f4f5", cellColor: "#909399" },
  { value: "quotation", label: "报价审批", cellBg: "#f4ecfc", cellColor: "#9b59b6" },
  { value: "shipment", label: "发货审批", cellBg: "#e8faf6", cellColor: "#1abc9c" },
  { value: "enterprise_news", label: "企业新闻", cellBg: "#ecf5ff", cellColor: "#409eff" },
];
/** å®¡æ‰¹çŠ¶æ€ approvalStatus */
export const APPROVAL_STATUS_OPTIONS = [
  { value: "pending", label: "审核中" },
  { value: "approved", label: "已通过" },
  { value: "rejected", label: "已驳回" },
  { value: "cancelled", label: "已撤销" },
/** åˆ—表查询:审批状态(与后端 status æžšä¸¾ä¸€è‡´ï¼‰ */
export const APPROVAL_STATUS_SEARCH_OPTIONS = [
  { value: "PENDING", label: "待审批" },
  { value: "APPROVED", label: "已通过" },
  { value: "REJECTED", label: "已驳回" },
];
/**
 * å®¡æ‰¹çŠ¶æ€å±•ç¤ºï¼ˆä¸ŽåŽç«¯ status æžšä¸¾ä¸€è‡´ï¼‰
 * PENDING â†’ å¾…审批/进行中  APPROVED â†’ å·²é€šè¿‡/已完成  REJECTED â†’ å·²é©³å›ž
 */
export const APPROVAL_STATUS_OPTIONS = [
  { value: "pending", api: "PENDING", label: "待审批" },
  { value: "approved", api: "APPROVED", label: "已通过" },
  { value: "rejected", api: "REJECTED", label: "已驳回" },
  { value: "cancelled", api: "CANCELLED", label: "已撤销" },
];
/** åŽç«¯ status / é¡µé¢ approvalStatus â†’ ç»Ÿä¸€é¡µé¢ key(pending | approved | rejected | cancelled) */
export function normalizeApprovalStatusKey(v) {
  const s = String(v ?? "").trim();
  if (!s) return "pending";
  const upper = s.toUpperCase();
  if (upper === "APPROVED" || upper === "APPROVE" || upper === "PASS") return "approved";
  if (upper === "REJECTED" || upper === "REJECT" || upper === "REFUSE") return "rejected";
  if (upper === "CANCELLED" || upper === "CANCEL") return "cancelled";
  if (
    upper === "PENDING" ||
    upper === "IN_PROGRESS" ||
    upper === "PROCESSING" ||
    upper === "RUNNING"
  ) {
    return "pending";
  }
  const lower = s.toLowerCase();
  if (["pending", "approved", "rejected", "cancelled"].includes(lower)) return lower;
  return "pending";
}
/** æäº¤å¼¹çª—:模板卡片(来自后端列表) */
export function mapSubmitTemplateCard(row) {
@@ -75,20 +110,11 @@
}
export function mapTaskStatusLabel(status) {
  const s = String(status || "").toUpperCase();
  if (s === "APPROVED") return "已通过";
  if (s === "REJECTED") return "已驳回";
  if (s === "PENDING") return "待审批";
  if (s === "CANCELLED") return "已撤销";
  return status || "—";
  return approvalStatusLabel(status);
}
export function mapTaskStatusTagType(status) {
  const s = String(status || "").toUpperCase();
  if (s === "APPROVED") return "success";
  if (s === "REJECTED") return "danger";
  if (s === "CANCELLED") return "info";
  return "warning";
  return approvalStatusTagType(status);
}
/** åŽç«¯ tasks â†’ é¡µé¢ flowNodes(按 levelNo åˆ†ç»„,供流程编辑/展示) */
@@ -164,11 +190,16 @@
  return "text";
}
/** å•字段展示值(详情只读) */
export function formatFieldDisplayValue(field, val) {
/**
 * å•字段展示值(详情只读、列表主表)
 * @param {object} [caches] äººå‘˜/部门下拉缓存,用于解析「人员列表」类字段为姓名
 */
export function formatFieldDisplayValue(field, val, caches) {
  if (val == null || val === "" || (Array.isArray(val) && !val.length)) return "—";
  if (field?.type === "select" && isDynamicOptionSource(field.optionSource)) {
    return `${selectOptionSourceLabel(field.optionSource)}:${String(val)}`;
    const label = resolveSelectDisplayLabel(field, val, caches || {});
    if (label && label !== "—") return label;
    return String(val);
  }
  if (field?.type === "select" && field.options?.length) {
    const hit = field.options.find((o) => String(o.value) === String(val));
@@ -323,20 +354,14 @@
/** åŽç«¯ status â†’ é¡µé¢ approvalStatus */
export function mapInstanceStatusFromApi(status) {
  const s = String(status || "").toUpperCase();
  if (s === "APPROVED") return "approved";
  if (s === "REJECTED") return "rejected";
  if (s === "CANCELLED") return "cancelled";
  return "pending";
  return normalizeApprovalStatusKey(status);
}
/** é¡µé¢ approvalStatus â†’ åŽç«¯ status */
export function mapInstanceStatusToApi(approvalStatus) {
  const s = String(approvalStatus || "").toLowerCase();
  if (s === "approved") return "APPROVED";
  if (s === "rejected") return "REJECTED";
  if (s === "cancelled") return "CANCELLED";
  return "PENDING";
  const key = normalizeApprovalStatusKey(approvalStatus);
  const hit = APPROVAL_STATUS_OPTIONS.find((x) => x.value === key);
  return hit?.api || "PENDING";
}
export function unwrapInstancePage(res) {
@@ -418,19 +443,26 @@
  };
}
export function buildApprovalInstanceListParams({ page, searchForm }) {
export function buildApprovalInstanceListParams({ page, searchForm, businessType, extraParams }) {
  const params = {
    current: page.current,
    size: page.size,
    ...(extraParams && typeof extraParams === "object" ? extraParams : {}),
  };
  const dto = {};
  const kw = (searchForm?.applicantKeyword || "").trim();
  if (kw) dto.applicantName = kw;
  if (searchForm?.approvalType) {
    const opt = APPROVAL_TYPE_OPTIONS.find((x) => x.value === searchForm.approvalType);
    if (opt?.label) dto.templateName = opt.label;
  const bizType = businessType ?? searchForm?.businessType;
  if (bizType != null && bizType !== "") {
    params.businessType = bizType;
  }
  if (Object.keys(dto).length) params.approvalInstanceDto = dto;
  if (searchForm?.status) {
    params.status = searchForm.status;
  }
  const range = searchForm?.createTimeRange;
  if (Array.isArray(range) && range[0]) {
    params.createTime = range[0];
  }
  if (Array.isArray(range) && range[1]) {
    params.createTimeEnd = range[1];
  }
  return params;
}
@@ -449,14 +481,45 @@
}
export function approvalStatusLabel(v) {
  return APPROVAL_STATUS_OPTIONS.find((x) => x.value === v)?.label || "—";
  const key = normalizeApprovalStatusKey(v);
  return APPROVAL_STATUS_OPTIONS.find((x) => x.value === key)?.label || "—";
}
/** ä¸šåŠ¡ç”³è¯·é¡µçŠ¶æ€æ–‡æ¡ˆï¼šPENDING→进行中 APPROVED→已完成 REJECTED→已驳回 */
export function businessApprovalStatusLabel(v) {
  const key = normalizeApprovalStatusKey(v);
  if (key === "pending") return "进行中";
  if (key === "approved") return "已完成";
  if (key === "rejected") return "已驳回";
  if (key === "cancelled") return "已撤销";
  return "—";
}
/**
 * ä¸šåŠ¡ç”³è¯·é¡µæ˜¯å¦å…è®¸ä¿®æ”¹ï¼ˆäº”ä¸ªç”³è¯·é¡µï¼‰
 * è¿›è¡Œä¸­(PENDING)、已完成(APPROVED) ä¸å¯ä¿®æ”¹ï¼›å·²é©³å›žã€å·²æ’¤é”€ç­‰å¯ä¿®æ”¹
 */
export function canEditBusinessInstanceRow(row) {
  const key = normalizeApprovalStatusKey(
    row?.approvalStatus ?? row?.statusRaw ?? row?.status
  );
  return key !== "pending" && key !== "approved";
}
export function businessApprovalStatusTagType(v) {
  const key = normalizeApprovalStatusKey(v);
  if (key === "approved") return "success";
  if (key === "rejected") return "danger";
  if (key === "cancelled") return "info";
  return "warning";
}
export function approvalStatusTagType(v) {
  if (v === "approved") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "primary";
  const key = normalizeApprovalStatusKey(v);
  if (key === "approved") return "success";
  if (key === "rejected") return "danger";
  if (key === "cancelled") return "info";
  return "warning";
}
export function unreadLabel(v) {
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -3,30 +3,35 @@
  <div class="app-container">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">审批类型:</span>
        <span class="search_title">模板类型:</span>
        <el-select
          v-model="searchForm.approvalType"
          placeholder="请选择审批类型"
          v-model="searchForm.businessType"
          placeholder="请选择模板类型"
          clearable
          filterable
          style="width: 200px"
        >
          <el-option
            v-for="opt in APPROVAL_TYPE_OPTIONS"
            v-for="opt in searchBusinessTypeOptions"
            :key="`search-biz-type-${opt.value}`"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">审批状态:</span>
        <el-select
          v-model="searchForm.status"
          placeholder="请选择审批状态"
          clearable
          style="width: 140px"
        >
          <el-option
            v-for="opt in APPROVAL_STATUS_SEARCH_OPTIONS"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">申请人名称:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 200px"
          placeholder="请输入申请人名称"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">创建时间:</span>
        <el-date-picker
          v-model="searchForm.createTimeRange"
@@ -285,7 +290,9 @@
const al = useApproveList();
const {
  Search,
  APPROVAL_TYPE_OPTIONS,
  APPROVAL_STATUS_SEARCH_OPTIONS,
  searchBusinessTypeOptions,
  loadSearchBusinessTypeOptions,
  submitBusinessTypeOptions,
  submitTemplateCards,
  selectedBusinessTypeLabel,
@@ -364,6 +371,7 @@
onMounted(() => {
  loadFlowUsers();
  loadSearchBusinessTypeOptions();
  handleQuery();
});
</script>
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -26,6 +26,7 @@
  validateTemplateBinding,
} from "../approve-shared/approvalTemplateBindingUtils.js";
import {
  APPROVAL_STATUS_SEARCH_OPTIONS,
  APPROVAL_TYPE_OPTIONS,
  approvalStatusLabel,
  approvalStatusTagType,
@@ -45,6 +46,7 @@
  const userStore = useUserStore();
  const tableData = ref([]);
  const searchBusinessTypeOptions = ref([]);
  const submitBusinessTypeOptions = ref([]);
  const allSubmitTemplates = ref([]);
  const selectedBusinessType = ref("");
@@ -58,8 +60,8 @@
  });
  const searchForm = reactive({
    approvalType: "",
    applicantKeyword: "",
    businessType: "",
    status: "",
    createTimeRange: [],
  });
@@ -118,7 +120,7 @@
  const tableColumn = ref([
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人名称", prop: "applicantName", minWidth: 100 },
    { label: "业务类型", prop: "businessName", minWidth: 120 },
    { label: "模板类型", prop: "businessName", minWidth: 120 },
    {
      label: "审批类型",
      prop: "approvalType",
@@ -220,10 +222,18 @@
  }
  function resetSearch() {
    searchForm.approvalType = "";
    searchForm.applicantKeyword = "";
    searchForm.businessType = "";
    searchForm.status = "";
    searchForm.createTimeRange = [];
    handleQuery();
  }
  async function loadSearchBusinessTypeOptions() {
    try {
      searchBusinessTypeOptions.value = await fetchBusinessTypeOptions();
    } catch {
      searchBusinessTypeOptions.value = [];
    }
  }
  function pagination({ page: p, limit }) {
@@ -471,6 +481,9 @@
  return {
    Search,
    APPROVAL_TYPE_OPTIONS,
    APPROVAL_STATUS_SEARCH_OPTIONS,
    searchBusinessTypeOptions,
    loadSearchBusinessTypeOptions,
    approvalTypeLabel,
    approvalStatusLabel,
    approvalStatusTagType,
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceFormConfigTable.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,100 @@
import { computed } from "vue";
import {
  businessApprovalStatusLabel,
  businessApprovalStatusTagType,
  formatFieldDisplayValue,
  resolveInstanceFormFields,
} from "../approve-list/approveListConstants.js";
/** åˆ—表/详情不回显为独立列的填报项 key */
const DEFAULT_EXCLUDE_KEYS = new Set(["summary"]);
/**
 * ä»Žè¡Œæ•°æ® formConfig è§£æžå­—段定义与填报值,并铺平到行上供主表 prop ç»‘定(展示用格式化值)
 */
export function enrichInstanceRowFromFormConfig(row, caches) {
  const { fields, formPayload, templateSnapshot } = resolveInstanceFormFields(row);
  const formDisplay = {};
  const displayRow = {
    ...row,
    formFieldDefs: fields,
    formPayload,
    templateSnapshot: row.templateSnapshot || templateSnapshot,
    formDisplay,
  };
  for (const f of fields) {
    if (!f?.key || DEFAULT_EXCLUDE_KEYS.has(f.key)) continue;
    const val = formPayload[f.key];
    let text = formatFieldDisplayValue(f, val, caches);
    if (
      text === String(val) &&
      row?.applicantName &&
      (f.label === "申请人" || f.key === "applicant" || f.key === "applicantName")
    ) {
      const idMatch =
        String(val) === String(row.applicantId) ||
        String(val) === String(row.applicantNo);
      if (idMatch) text = row.applicantName;
    }
    formDisplay[f.key] = text;
    displayRow[f.key] = text;
  }
  return displayRow;
}
/**
 * ä»Žåˆ—表首行 formConfig ç”Ÿæˆä¸»è¡¨åŠ¨æ€åˆ—ï¼ˆlabel å–自模板字段 label)
 */
export function getFormConfigFieldColumns(firstRow, { excludeKeys = DEFAULT_EXCLUDE_KEYS } = {}) {
  const fields = (firstRow?.formFieldDefs || []).filter(
    (f) => f?.key && !excludeKeys.has(f.key)
  );
  return fields.map((f) => ({
    label: f.label || f.key,
    prop: f.key,
    minWidth: f.type === "textarea" ? 200 : f.type === "datetimerange" ? 160 : 120,
    showOverflowTooltip: true,
  }));
}
/**
 * ä¸šåŠ¡ç”³è¯·ä¸»è¡¨åˆ—ï¼šå›ºå®šåˆ— + formConfig åŠ¨æ€åˆ— + å®¡æ‰¹çŠ¶æ€ + æ“ä½œ
 */
export function buildInstanceTableColumns(tableDataRef, buildTableActions, options = {}) {
  const {
    excludeKeys = DEFAULT_EXCLUDE_KEYS,
    beforeFormColumns = [],
    extraColumns = [],
    afterFormColumns = [],
    actionWidth = 260,
  } = options;
  return computed(() => {
    const formCols = getFormConfigFieldColumns(tableDataRef.value?.[0], { excludeKeys });
    return [
      ...beforeFormColumns,
      ...formCols,
      ...extraColumns,
      ...afterFormColumns,
      { label: "创建时间", prop: "createTime", width: 170 },
      {
        label: "审批状态",
        prop: "approvalStatus",
        width: 110,
        dataType: "tag",
        formatData: (v) => businessApprovalStatusLabel(v),
        formatType: (v) => businessApprovalStatusTagType(v),
      },
      {
        dataType: "action",
        label: "操作",
        align: "center",
        fixed: "right",
        width: actionWidth,
        operation: buildTableActions(),
      },
    ];
  });
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalInstanceRowMappers.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,10 @@
import { enrichInstanceRowFromFormConfig } from "./approvalInstanceFormConfigTable.js";
/** @deprecated ç»Ÿä¸€ä½¿ç”¨ enrichInstanceRowFromFormConfig */
export const enrichLeaveListRow = enrichInstanceRowFromFormConfig;
export const enrichOvertimeListRow = enrichInstanceRowFromFormConfig;
export const enrichRegularListRow = enrichInstanceRowFromFormConfig;
export const enrichTransferListRow = enrichInstanceRowFromFormConfig;
export const enrichWorkHandoverListRow = enrichInstanceRowFromFormConfig;
export { enrichInstanceRowFromFormConfig };
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -1,6 +1,7 @@
import { matchBusinessTypeValue } from "../approve-list/approveListConstants.js";
/**
 * å„业务模块与审批模板类型的映射(配置化入口)
 *
 * å„业务模块与审批模板类型的映射(配置化入口) *
 * ä½¿ç”¨æ–¹å¼ï¼š
 * 1. åœ¨ä¸šåС页引入 ApprovalTemplateBindDialog,传入 moduleKey
 * 2. æˆ–在表单内嵌 ApprovalTemplateFormSection + useApprovalTemplateBinding({ moduleKey })
@@ -16,6 +17,7 @@
  OVERTIME: "overtime",
  TRAVEL_REIMBURSE: "travel_reimburse",
  COST_REIMBURSE: "cost_reimburse",
  ENTERPRISE_NEWS: "enterprise_news",
};
/** @type {Record<string, import('./approvalModuleRegistry.js').ApprovalModuleConfig>} */
@@ -60,6 +62,11 @@
    approvalType: "cost_reimburse",
    typeLabels: ["费用", "费用报销"],
  },
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: {
    label: "企业新闻",
    approvalType: "enterprise_news",
    typeLabels: ["企业新闻", "新闻", "新闻发布"],
  },
};
/**
@@ -75,6 +82,14 @@
  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
}
/** åˆ—表查询默认 businessType(与审批列表 listPage çº¦å®šä¸€è‡´ï¼‰ */
export function getModuleListBusinessType(moduleKey) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return "";
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  return cfg.approvalType || "";
}
export function listApprovalModuleEntries() {
  return Object.entries(APPROVAL_MODULE_REGISTRY).map(([moduleKey, cfg]) => ({
    moduleKey,
@@ -82,21 +97,30 @@
  }));
}
/** ä»Ž TypeEnums é€‰é¡¹ä¸­è§£æžæœ¬æ¨¡å—çš„ businessType */
/** ä»Ž 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 hitByLabel = (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;
  if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value;
  if (cfg.approvalType) {
    const hitByValue = (typeOptions || []).find(
      (opt) =>
        matchBusinessTypeValue(opt?.value, cfg.approvalType) ||
        matchBusinessTypeValue(opt?.code, cfg.approvalType)
    );
    if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value;
  }
  return cfg.approvalType || null;
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<!-- ä¸Žå®¡æ‰¹åˆ—表详情弹窗一致 -->
<template>
  <el-dialog
    v-model="visible"
    :title="title"
    width="920px"
    append-to-body
    destroy-on-close
    class="approve-detail-dialog"
    @closed="emit('closed')"
  >
    <div class="approve-detail-body">
      <ApproveDetailPanel :row="row" />
      <div class="detail-block">
        <div class="detail-block-title">
          å®¡æ‰¹æµç¨‹ï¼ˆ{{ row?.tasks?.length || row?.flowNodes?.length || 0 }} é¡¹ï¼‰
        </div>
        <InstanceFlowDisplay :tasks="row?.tasks" :nodes="row?.flowNodes" />
      </div>
      <div class="detail-block">
        <div class="detail-block-title">审批记录</div>
        <el-timeline v-if="row?.approvalRecords?.length" class="approve-record-timeline">
          <el-timeline-item
            v-for="(rec, i) in row.approvalRecords"
            :key="rec.id ?? i"
            :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
            :timestamp="formatRecordTime(rec.time)"
            placement="top"
          >
            <div class="record-item">
              <span class="record-operator">{{ rec.operatorName || "—" }}</span>
              <el-tag
                size="small"
                :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'info'"
                effect="plain"
              >
                {{ approvalActionLabel(rec.result) }}
              </el-tag>
              <p class="record-opinion">{{ rec.opinion || "无意见" }}</p>
            </div>
          </el-timeline-item>
        </el-timeline>
        <el-empty v-else description="暂无审批记录" :image-size="48" />
      </div>
    </div>
    <template #footer>
      <el-button v-if="canEditRow(row)" @click="emit('edit', row)">ä¿® æ”¹</el-button>
      <el-button @click="visible = false">关 é—­</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed } from "vue";
import { canEditBusinessInstanceRow } from "../../approve-list/approveListConstants.js";
import { formatDisplayTime } from "../../approve-template/approveTemplateConstants.js";
import ApproveDetailPanel from "../../approve-list/components/ApproveDetailPanel.vue";
import InstanceFlowDisplay from "../../approve-list/components/InstanceFlowDisplay.vue";
function canEditRow(row) {
  return canEditBusinessInstanceRow(row);
}
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  row: { type: Object, default: () => ({}) },
  title: { type: String, default: "审批详情" },
});
const emit = defineEmits(["update:modelValue", "edit", "closed"]);
const visible = computed({
  get: () => props.modelValue,
  set: (v) => emit("update:modelValue", v),
});
function approvalActionLabel(result) {
  if (result === "approved") return "通过";
  if (result === "rejected") return "驳回";
  return "待处理";
}
function formatRecordTime(time) {
  return formatDisplayTime(time) || "—";
}
</script>
<style scoped>
.approve-detail-dialog :deep(.el-dialog__body) {
  padding-top: 16px;
  max-height: 70vh;
  overflow-y: auto;
}
.approve-detail-body .detail-block {
  margin-top: 20px;
}
.detail-block-title {
  font-size: 14px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin: 0 0 12px;
  padding-left: 10px;
  border-left: 3px solid var(--el-color-primary);
  line-height: 1.4;
}
.approve-record-timeline {
  padding-left: 4px;
}
.record-item {
  padding: 4px 0 2px;
}
.record-operator {
  font-weight: 600;
  margin-right: 8px;
  color: var(--el-text-color-primary);
}
.record-opinion {
  margin: 8px 0 0;
  font-size: 13px;
  color: var(--el-text-color-regular);
  line-height: 1.5;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,112 @@
<!-- ä¸Žå®¡æ‰¹åˆ—表提交/修改弹窗(第三步)一致 -->
<template>
  <el-dialog
    v-model="visible"
    :title="title"
    width="720px"
    append-to-body
    destroy-on-close
    class="approve-submit-dialog"
    @closed="emit('closed')"
  >
    <el-form ref="innerFormRef" :model="form" :rules="rules" label-width="120px">
      <el-form-item v-if="isEdit" label="审批类型">
        <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate?.approvalType)">
          {{ activeTemplate?.label || form.templateName || "—" }}
        </span>
      </el-form-item>
      <slot name="before" :form="form" :fields="fields" />
      <ApprovalTemplateFormSection
        :active-template="activeTemplate"
        :fields="fields"
        :form-payload="form.formPayload"
        v-model:flow-nodes="form.flowNodes"
        v-model:attachments="form.storageBlobDTOs"
        :template-attachments="form.templateAttachments"
        :user-options="userOptions"
        :show-template-name="!isEdit"
        :allow-change-template="false"
        :flow-attachments-only="flowAttachmentsOnly"
        :flow-only="flowOnly"
      />
      <slot name="after" :form="form" :fields="fields" />
    </el-form>
    <template #footer>
      <el-button type="primary" :loading="saving" @click="handleSubmitClick">
        {{ isEdit ? "保 å­˜" : "提 äº¤" }}
      </el-button>
      <el-button @click="visible = false">取 æ¶ˆ</el-button>
    </template>
  </el-dialog>
</template>
<script setup>
import { computed, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { approvalTypeStyle } from "../../approve-list/approveListConstants.js";
import ApprovalTemplateFormSection from "./ApprovalTemplateFormSection.vue";
const innerFormRef = ref(null);
const props = defineProps({
  modelValue: { type: Boolean, default: false },
  title: { type: String, default: "" },
  form: { type: Object, required: true },
  rules: { type: Object, default: () => ({}) },
  fields: { type: Array, default: () => [] },
  activeTemplate: { type: Object, default: null },
  userOptions: { type: Array, default: () => [] },
  isEdit: { type: Boolean, default: false },
  saving: { type: Boolean, default: false },
  formRef: { type: Object, default: null },
  /** å¡«æŠ¥é¡¹ç”± before æ’槽单独渲染时设为 true */
  flowAttachmentsOnly: { type: Boolean, default: false },
  flowOnly: { type: Boolean, default: false },
});
const emit = defineEmits(["update:modelValue", "submit", "closed"]);
const visible = computed({
  get: () => props.modelValue,
  set: (v) => emit("update:modelValue", v),
});
watch(
  innerFormRef,
  (el) => {
    if (props.formRef) props.formRef.value = el;
  },
  { flush: "post" }
);
watch(visible, (v) => {
  if (!v && props.formRef) props.formRef.value = null;
});
async function handleSubmitClick() {
  if (!innerFormRef.value) {
    ElMessage.warning("表单未就绪,请稍后再试");
    return;
  }
  try {
    await innerFormRef.value.validate();
  } catch {
    ElMessage.warning("请完善表单必填项后再保存");
    return;
  }
  emit("submit");
}
</script>
<style scoped>
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.approve-submit-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
@@ -1,21 +1,28 @@
<!-- æ¨¡æ¿ç»‘定表单区:填报项 + å®¡æ‰¹æµç¨‹ + é™„件(须挂在外层 el-form ä¸‹ï¼‰ -->
<template>
  <template v-if="activeTemplate">
    <el-form-item v-if="showTemplateName && !hideTemplateName && !flowAttachmentsOnly" label="审批模板">
    <el-form-item
      v-if="showTemplateName && !hideTemplateName && !flowAttachmentsOnly && !flowOnly"
      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 v-if="!hideFormFields && !flowAttachmentsOnly" :fields="fields" :form-payload="formPayload" />
    <FormPayloadFields
      v-if="!hideFormFields && !flowAttachmentsOnly && !flowOnly"
      :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-form-item v-if="!flowOnly && templateAttachments.length" label="模板参考">
      <el-tag
        v-for="(f, i) in templateAttachments"
        :key="`tpl-${i}`"
@@ -28,7 +35,7 @@
      <p class="section-tip">以上为模板附带文件,仅供参考;提交附件请在下方上传。</p>
    </el-form-item>
    <el-form-item label="附件">
    <el-form-item v-if="!flowOnly" label="附件">
      <FileUpload
        v-model:file-list="attachmentsModel"
        :limit="uploadLimit"
@@ -65,6 +72,8 @@
  hideTemplateName: { type: Boolean, default: false },
  /** ä¸º true æ—¶ä»…展示审批流程与附件(填报项由父级单独渲染) */
  flowAttachmentsOnly: { type: Boolean, default: false },
  /** ä¸º true æ—¶ä»…展示审批流程(不展示模板填报项、附件等) */
  flowOnly: { type: Boolean, default: false },
  uploadLimit: { type: Number, default: 10 },
});
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,396 @@
import {
  deleteApprovalInstance,
  listApprovalInstancePage,
  saveApprovalInstance,
  updateApprovalInstance,
} from "@/api/officeProcessAutomation/approvalInstance.js";
import useUserStore from "@/store/modules/user";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref } from "vue";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "./approvalTemplateBindingUtils.js";
import {
  buildApprovalInstanceListParams,
  buildEditFormFromInstanceRow,
  buildInstanceDto,
  canEditBusinessInstanceRow,
  createEmptySubmitForm,
  mapInstanceFromApi,
  resolveInstanceFormFields,
  unwrapInstancePage,
} from "../approve-list/approveListConstants.js";
import { fetchBusinessTypeOptions } from "../approve-template/approveTemplateConstants.js";
import {
  collectOptionSourcesFromFields,
  fetchSelectOptionCaches,
} from "../approve-template/selectOptionSource.js";
import { enrichInstanceRowFromFormConfig } from "./approvalInstanceFormConfigTable.js";
import {
  getApprovalModuleConfig,
  getModuleListBusinessType,
  resolveModuleBusinessType,
} from "./approvalModuleRegistry.js";
/**
 * ä¸šåŠ¡ç”³è¯·é¡µå…±ç”¨ï¼šå®¡æ‰¹å®žä¾‹åˆ—è¡¨æŸ¥è¯¢ã€æ–°å¢ž/修改保存、详情/编辑弹窗(与审批列表一致)
 *
 * @param {object} options
 * @param {string} options.moduleKey approvalModuleRegistry ä¸­çš„ key
 * @param {(row: object) => object} [options.enrichListRow] åˆ—表行增强(从 formPayload è§£æžå±•示字段)
 * @param {(base: object) => object} [options.buildExtraListParams] è¿½åŠ æŸ¥è¯¢å‚æ•°
 * @param {() => void} [options.beforeSave] ä¿å­˜å‰é’©å­ï¼ˆå¦‚同步业务字段到 formPayload)
 * @param {import('vue').ComputedRef|object} [options.extraFormRules] é¢å¤–表单校验
 */
export function useApprovalInstanceModule(options = {}) {
  const {
    moduleKey,
    enrichListRow,
    buildExtraListParams,
    beforeSave,
    extraFormRules,
  } = options;
  const userStore = useUserStore();
  const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey));
  const businessTypeOptions = ref([]);
  /** ä¸Žå®¡æ‰¹åˆ—表一致:优先用 TypeEnums çš„ value,匹配不到再回退 approvalType */
  const defaultListBusinessType = computed(() => {
    const resolved = resolveModuleBusinessType(moduleKey, businessTypeOptions.value);
    if (resolved != null && resolved !== "") return resolved;
    return getModuleListBusinessType(moduleKey);
  });
  async function loadBusinessTypeOptions() {
    if (businessTypeOptions.value.length) return;
    try {
      businessTypeOptions.value = await fetchBusinessTypeOptions();
    } catch {
      businessTypeOptions.value = [];
    }
  }
  const tableData = ref([]);
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const submitDialog = reactive({ visible: false, mode: "add" });
  const submitEditRow = ref(null);
  const submitForm = reactive(createEmptySubmitForm(""));
  const submitFormRef = ref();
  const submitSaving = ref(false);
  const templateBindVisible = ref(false);
  const pendingTemplateBinding = ref(null);
  /** æœ€è¿‘一次列表查询条件(保存后刷新列表时沿用) */
  let lastListSearchForm = null;
  const isSubmitEdit = computed(() => submitDialog.mode === "edit");
  const activeTemplate = computed(() => submitForm.templateSnapshot || null);
  const submitFormFields = computed(() => {
    const tplFields = activeTemplate.value?.fields;
    if (tplFields?.length) return tplFields;
    return submitForm.formFieldDefs || [];
  });
  const submitFormRules = computed(() => ({
    ...buildFormPayloadRules(submitFormFields.value),
    ...(extraFormRules?.value ?? extraFormRules ?? {}),
  }));
  const submitDialogTitle = computed(() => {
    const label = moduleConfig.value?.label || "申请";
    if (submitDialog.mode === "edit") {
      return `修改${activeTemplate.value?.label || submitForm.templateName || label}`;
    }
    return `新增${label}`;
  });
  function mapListRow(row, caches) {
    const mapped = mapInstanceFromApi(row);
    const fromFormConfig = enrichInstanceRowFromFormConfig(mapped, caches);
    return enrichListRow ? enrichListRow(fromFormConfig) : fromFormConfig;
  }
  async function fetchList(searchForm = {}) {
    await loadBusinessTypeOptions();
    tableLoading.value = true;
    try {
      let extraParams = {};
      if (buildExtraListParams) {
        extraParams = buildExtraListParams(searchForm) || {};
      }
      const res = await listApprovalInstancePage(
        buildApprovalInstanceListParams({
          page,
          searchForm,
          businessType: defaultListBusinessType.value,
          extraParams,
        })
      );
      const { records, total } = unwrapInstancePage(res);
      const mapped = records.map(mapInstanceFromApi);
      const allFields = [];
      for (const row of mapped) {
        const { fields } = resolveInstanceFormFields(row);
        allFields.push(...fields);
      }
      const caches = await fetchSelectOptionCaches(
        collectOptionSourcesFromFields(allFields)
      );
      tableData.value = mapped.map((row) => mapListRow(row, caches));
      page.total = total;
    } catch {
      tableData.value = [];
      page.total = 0;
      ElMessage.error(`${moduleConfig.value?.label || "申请"}列表加载失败`);
    } finally {
      tableLoading.value = false;
    }
  }
  function handleQuery(searchForm) {
    lastListSearchForm = searchForm;
    page.current = 1;
    return fetchList(searchForm);
  }
  /** è¿›å…¥é¡µé¢ï¼šå…ˆæ‹‰ TypeEnums è§£æž businessType,再查列表 */
  async function initModuleList(searchForm) {
    await loadBusinessTypeOptions();
    return handleQuery(searchForm);
  }
  function pagination({ page: p, limit }, searchForm) {
    page.current = p;
    page.size = limit;
    return fetchList(searchForm);
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openEdit(row) {
    if (!canEditBusinessInstanceRow(row)) {
      ElMessage.warning("进行中或已完成的审批不可修改");
      return;
    }
    if (!row?.id) {
      ElMessage.warning("无法修改:缺少审批实例 ID");
      return;
    }
    submitDialog.mode = "edit";
    submitEditRow.value = { ...row };
    Object.assign(submitForm, buildEditFormFromInstanceRow(row));
    submitDialog.visible = true;
  }
  function openEditFromDetail() {
    const row = detailRow.value;
    detailDialog.visible = false;
    openEdit(row);
  }
  function resetSubmitForm() {
    Object.assign(submitForm, createEmptySubmitForm(""));
    submitEditRow.value = null;
  }
  function openAddWithTemplate() {
    submitDialog.visible = false;
    pendingTemplateBinding.value = null;
    templateBindVisible.value = true;
  }
  function onTemplateBound(binding) {
    pendingTemplateBinding.value = binding;
  }
  function onTemplateBindClosed() {
    const binding = pendingTemplateBinding.value;
    if (!binding) return;
    pendingTemplateBinding.value = null;
    openAddFromBinding(binding);
  }
  function openAddFromBinding(binding) {
    resetSubmitForm();
    applyBindingToForm(submitForm, binding);
    submitDialog.mode = "add";
    submitEditRow.value = null;
    submitDialog.visible = true;
  }
  function closeSubmitDialog() {
    submitDialog.visible = false;
  }
  async function submitInstanceForm(options = {}) {
    const { skipValidate = false } = options;
    if (!skipValidate) {
      if (!submitFormRef.value?.validate) {
        ElMessage.warning("表单未就绪,请关闭弹窗后重试");
        return false;
      }
      try {
        await submitFormRef.value.validate();
      } catch {
        ElMessage.warning("请完善表单必填项后再保存");
        return false;
      }
    }
    if (!activeTemplate.value) {
      ElMessage.warning("未加载审批模板,无法保存");
      return false;
    }
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      return false;
    }
    if (!submitForm.templateId) {
      ElMessage.warning("缺少模板 ID,无法提交");
      return false;
    }
    if (beforeSave) {
      try {
        await beforeSave(submitForm, { isEdit: isSubmitEdit.value, editRow: submitEditRow.value });
      } catch {
        return false;
      }
    }
    if (submitSaving.value) return false;
    submitSaving.value = true;
    try {
      const dto = buildInstanceDto({
        submitForm,
        activeTemplate: activeTemplate.value,
        userStore,
        flowNodes: bindingCheck.nodes,
        existingRow: isSubmitEdit.value ? submitEditRow.value : null,
      });
      if (isSubmitEdit.value) {
        await updateApprovalInstance(dto);
      } else {
        await saveApprovalInstance(dto);
      }
      submitDialog.visible = false;
      if (!isSubmitEdit.value) page.current = 1;
      await fetchList(lastListSearchForm ?? {});
      if (detailDialog.visible && detailRow.value?.id === submitForm.instanceId) {
        const hit = tableData.value.find((r) => r.id === submitForm.instanceId);
        if (hit) detailRow.value = { ...hit };
        else detailDialog.visible = false;
      }
      return true;
    } catch {
      ElMessage.error(isSubmitEdit.value ? "保存失败" : "提交失败");
      return false;
    } finally {
      submitSaving.value = false;
    }
  }
  async function removeInstance(row) {
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少审批实例 ID");
      return;
    }
    const title = row.title || row.templateName || row.instanceNo || "该审批";
    try {
      await ElMessageBox.confirm(
        `确定要删除审批「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteApprovalInstance([row.id]);
      ElMessage.success("删除成功");
      if (detailDialog.visible && detailRow.value?.id === row.id) {
        detailDialog.visible = false;
      }
      if (submitDialog.visible && submitEditRow.value?.id === row.id) {
        submitDialog.visible = false;
      }
      await fetchList(lastListSearchForm ?? {});
    } catch {
      /* é”™è¯¯ç”±æ‹¦æˆªå™¨æç¤º */
    }
  }
  /** æž„建标准操作列:详情、修改、删除(与审批列表一致) */
  function buildTableActions(extraOperations = []) {
    return [
      { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
      {
        name: "修改",
        type: "text",
        disabled: (row) => !canEditBusinessInstanceRow(row),
        clickFun: (row) => openEdit(row),
      },
      {
        name: "删除",
        type: "danger",
        clickFun: (row) => removeInstance(row),
      },
      ...extraOperations,
    ];
  }
  return {
    moduleConfig,
    defaultListBusinessType,
    tableData,
    tableLoading,
    page,
    detailDialog,
    detailRow,
    submitDialog,
    submitEditRow,
    submitForm,
    submitFormRef,
    submitSaving,
    isSubmitEdit,
    activeTemplate,
    submitFormFields,
    submitFormRules,
    submitDialogTitle,
    templateBindVisible,
    pendingTemplateBinding,
    fetchList,
    handleQuery,
    initModuleList,
    pagination,
    openDetail,
    openEdit,
    openEditFromDetail,
    openAddWithTemplate,
    onTemplateBound,
    onTemplateBindClosed,
    openAddFromBinding,
    closeSubmitDialog,
    resetSubmitForm,
    submitInstanceForm,
    removeInstance,
    buildTableActions,
    loadBusinessTypeOptions,
    canEditBusinessInstanceRow,
  };
}
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
@@ -10,13 +10,13 @@
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">请假类型:</span>
        <el-select v-model="searchForm.leaveType" placeholder="全部" clearable style="width: 180px">
          <el-option v-for="opt in LEAVE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
@@ -31,37 +31,27 @@
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="leave-apply-form-dialog"
      @closed="onFormClosed"
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="leave-apply-form">
        <el-form-item v-if="form.templateSnapshot" label="审批模板">
          <span class="template-name">{{ form.templateSnapshot.label || form.templateName }}</span>
          <el-button
            v-if="formDialog.mode === 'add'"
            type="primary"
            link
            class="ml12"
            @click="reopenTemplateBind"
          >
            æ›´æ¢æ¨¡æ¿
          </el-button>
        </el-form-item>
        <FormPayloadFields :fields="templateDisplayFields" :form-payload="form.formPayload" />
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="假期余额" prop="leaveBalanceDays">
@@ -79,32 +69,14 @@
          </el-col>
          <el-col :span="12">
            <el-form-item label="请假时长">
              <el-input :model-value="leaveDurationDisplay" readonly placeholder="根据模板中请假时间自动计算">
              <el-input :model-value="leaveDurationDisplay(form)" readonly placeholder="根据模板中请假时间自动计算">
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <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"
          flow-attachments-only
          hide-template-name
          :allow-change-template="false"
        />
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    </ApprovalInstanceSubmitDialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
@@ -114,55 +86,12 @@
      @closed="onTemplateBindClosed"
    />
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="请假申请详情" width="720px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人编号">{{ detailRow.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="请假类型">{{ leaveTypeLabel(detailRow.leaveType) }}</el-descriptions-item>
        <el-descriptions-item label="假期余额">{{ formatBalance(detailRow.leaveBalanceDays) }}</el-descriptions-item>
        <el-descriptions-item label="请假开始时间">{{ detailRow.leaveStartTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="请假结束时间">{{ detailRow.leaveEndTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="请假时长">{{ formatDuration(detailRow.leaveDurationDays) }}</el-descriptions-item>
        <el-descriptions-item label="请假事由">{{ detailRow.leaveReason }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ detailRow.createTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="附件">
          <template v-if="rowAttachmentList(detailRow).length">
            <el-tag v-for="(f, i) in rowAttachmentList(detailRow)" :key="i" class="mr6 mb6" type="info">
              {{ f.name }}
            </el-tag>
          </template>
          <span v-else>无</span>
        </el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- é™„件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="rowAttachmentList(filesDialog.row).length" :data="rowAttachmentList(filesDialog.row)" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="请假申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
@@ -170,21 +99,18 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { 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 { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  attachmentDisplayName,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
/** è¯·å‡ç±»åž‹ï¼ˆvalue ä¸ŽåŽç«¯å¯¹é½å ä½ï¼‰ */
const LEAVE_TYPE_OPTIONS = [
  { label: "年假", value: "annual" },
  { label: "病假", value: "sick" },
@@ -196,70 +122,6 @@
  { label: "调休", value: "compensatory" },
];
function leaveTypeLabel(v) {
  const hit = LEAVE_TYPE_OPTIONS.find((x) => x.value === v);
  return hit?.label || "—";
}
/** ä¸ŽåŽç«¯çº¦å®šå­—段(占位) */
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantNo: "",
  applicantName: "",
  leaveType: "",
  leaveBalanceDays: undefined,
  leaveStartTime: "",
  leaveEndTime: "",
  leaveDurationDays: null,
  leaveReason: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const { proxy } = getCurrentInstance();
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && 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";
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function applicantNoFromUser(u) {
  if (!u) return "";
  return (
    u.userName ??
    u.userCode ??
    u.jobNumber ??
    u.workNo ??
    (u.userId != null ? String(u.userId) : "")
  );
}
/** å‡æœŸä½™é¢ï¼ˆå¯¹æŽ¥è€ƒå‹¤ API å‰ä¸å±•示假数据) */
function mockLeaveBalance() {
  return undefined;
}
function isLeaveBalanceField(field) {
  const label = String(field?.label || "");
  return label.includes("假期余额") || field?.key === "leaveBalanceDays";
@@ -268,6 +130,10 @@
function isLeaveDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("请假时长") || field?.key === "leaveDurationDays";
}
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isLeaveBalanceField(f) && !isLeaveDurationField(f));
}
function findLeaveTimeTemplateField(fields = []) {
@@ -287,7 +153,6 @@
  );
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹è§£æžè¯·å‡èµ·æ­¢æ—¶é—´ */
function resolveLeaveTimeRange(payload, leaveTimeField) {
  if (!leaveTimeField?.key) return { start: "", end: "" };
  const val = payload?.[leaveTimeField.key];
@@ -295,7 +160,6 @@
  return { start: val[0] || "", end: val[1] || "" };
}
/** æŒ‰èµ·æ­¢æ—¶é—´è®¡ç®—请假天数(含时分秒,结果保留两位小数) */
function computeLeaveDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
@@ -305,61 +169,75 @@
  return Math.round(days * 100) / 100;
}
function formatDuration(v) {
  if (v == null || v === "") return "—";
  return `${v} å¤©`;
function leaveDurationDisplay(form) {
  const leaveTimeField = findLeaveTimeTemplateField(form.formFieldDefs);
  const { start, end } = resolveLeaveTimeRange(form.formPayload, leaveTimeField);
  const d = computeLeaveDays(start, end);
  return d == null ? "" : String(d);
}
function formatBalance(v) {
  if (v == null || v === "") return "—";
  return `${v} å¤©`;
}
const searchForm = reactive({
  applicantKeyword: "",
  leaveType: "",
});
function mapStorageBlobsToAttachmentList(blobs) {
  return (blobs || []).map((f) => ({
    name: attachmentDisplayName(f),
    url: f.url || f.downloadURL || f.previewURL || f.previewUrl,
  }));
}
function rowAttachmentList(row) {
  if (!row) return [];
  if (row.attachmentList?.length) return row.attachmentList;
  return mapStorageBlobsToAttachmentList(row.storageBlobDTOs);
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  if (mode === "or_sign") return "或签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
function syncApplicantFromUser(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantId = uid != null && uid !== "" ? uid : "";
    form.applicantName = u.nickName || u.userName || "";
    form.applicantNo = applicantNoFromUser(u);
    form.leaveBalanceDays = mockLeaveBalance(u);
  } else {
    form.applicantId = "";
    form.applicantName = "";
    form.applicantNo = "";
    if (uid == null || uid === "") {
      form.leaveBalanceDays = undefined;
    }
function validateLeaveBeforeSave() {
  const leaveTimeField = findLeaveTimeTemplateField(submitForm.formFieldDefs);
  const { start, end } = resolveLeaveTimeRange(submitForm.formPayload, leaveTimeField);
  if (computeLeaveDays(start, end) == null) {
    ElMessage.warning("请检查模板中的请假时间,结束时间须晚于开始时间");
    throw new Error("invalid leave time");
  }
}
/** ç³»ç»Ÿç”¨æˆ·ç¼“å­˜ */
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.LEAVE,
  beforeSave: validateLeaveBeforeSave,
  extraFormRules: {
    leaveBalanceDays: [{ required: true, message: "请填写假期余额", trigger: "blur" }],
  },
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitEditRow,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const allUsersCache = ref([]);
const applicantTemplateField = computed(() =>
  findApplicantTemplateField(submitForm.formFieldDefs)
);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  return [];
}
async function loadUserPool() {
  try {
@@ -370,412 +248,56 @@
  }
}
const allRows = ref([]);
const searchForm = reactive({
  applicantKeyword: "",
  leaveType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
  if (kw) {
    list = list.filter((r) => {
      const name = (r.applicantName || "").toLowerCase();
      const no = (r.applicantNo || "").toLowerCase();
      return name.includes(kw) || no.includes(kw);
    });
  }
  if (searchForm.leaveType) {
    list = list.filter((r) => r.leaveType === searchForm.leaveType);
  }
  return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
  () => submitDialog.visible,
  (v) => {
    if (!v) return;
    if (submitForm.leaveBalanceDays == null && isSubmitEdit.value) {
      submitForm.leaveBalanceDays =
        submitEditRow.value?.formPayload?.leaveBalanceDays ??
        submitEditRow.value?.leaveBalanceDays;
    }
  },
  { immediate: true }
    if (submitForm.leaveBalanceDays == null && !isSubmitEdit.value) {
      submitForm.leaveBalanceDays = undefined;
    }
  }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人编号", prop: "applicantNo", width: 120 },
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  {
    label: "请假类型",
    prop: "leaveType",
    width: 100,
    formatData: (v) => leaveTypeLabel(v),
  },
  {
    label: "请假时长",
    prop: "leaveDurationDays",
    width: 120,
    formatData: (v) => (v == null || v === "" ? "—" : `${v} å¤©`),
  },
  { label: "请假事由", prop: "leaveReason", minWidth: 180 },
  { label: "创建时间", prop: "createTime", width: 170 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 220,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openFormDialog("edit", row),
      },
      {
        name: "查看详情",
        type: "text",
        clickFun: (row) => openDetail(row),
      },
      {
        name: "附件",
        type: "text",
        clickFun: (row) => openFiles(row),
      },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const leaveTimeTemplateField = computed(() => findLeaveTimeTemplateField(form.formFieldDefs));
const applicantTemplateField = computed(() => findApplicantTemplateField(form.formFieldDefs));
const templateDisplayFields = computed(() =>
  (form.formFieldDefs || []).filter((f) => !isLeaveBalanceField(f) && !isLeaveDurationField(f))
);
const leaveDurationDisplay = computed(() => {
  const { start, end } = resolveLeaveTimeRange(form.formPayload, leaveTimeTemplateField.value);
  const d = computeLeaveDays(start, end);
  return d == null ? "" : String(d);
});
const formRules = computed(() => ({
  ...buildFormPayloadRules(templateDisplayFields.value),
  leaveBalanceDays: [
    {
      required: true,
      message: "请填写假期余额",
      trigger: "blur",
    },
  ],
}));
watch(
  () => {
    const key = applicantTemplateField.value?.key;
    return key ? form.formPayload[key] : undefined;
    return key ? submitForm.formPayload[key] : undefined;
  },
  async (uid) => {
    if (!applicantTemplateField.value) return;
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    syncApplicantFromUser(uid);
    if (!applicantTemplateField.value || !uid) return;
    if (!allUsersCache.value.length) await loadUserPool();
  }
);
watch(
  () => {
    const key = leaveTimeTemplateField.value?.key;
    return key ? form.formPayload[key] : undefined;
  },
  () => {
    const { start, end } = resolveLeaveTimeRange(form.formPayload, leaveTimeTemplateField.value);
    form.leaveStartTime = start;
    form.leaveEndTime = end;
    form.leaveDurationDays = computeLeaveDays(start, end);
  },
  { deep: true }
);
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions);
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.applicantKeyword = "";
  searchForm.leaveType = "";
  handleQuery();
  onSearch();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
function onPagination(obj) {
  pagination(obj, searchForm);
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgWarning?.("暂无下载地址");
}
function openAddWithTemplate() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  pendingTemplateBinding.value = binding;
}
async function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  await openFormWithBinding(binding);
}
async function openFormWithBinding(binding) {
  Object.assign(form, createEmptyForm());
  applyBindingToForm(form, binding);
  form.hasTemplateBinding = true;
  formDialog.mode = "add";
  formDialog.title = "新增请假申请";
  await Promise.all([loadUserPool(), loadFlowUsers()]);
  const applicantKey = applicantTemplateField.value?.key;
  if (applicantKey && form.formPayload[applicantKey]) {
    syncApplicantFromUser(form.formPayload[applicantKey]);
  }
  const { start, end } = resolveLeaveTimeRange(form.formPayload, leaveTimeTemplateField.value);
  form.leaveStartTime = start;
  form.leaveEndTime = end;
  form.leaveDurationDays = computeLeaveDays(start, end);
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function reopenTemplateBind() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
async function openFormDialog(mode, row) {
  if (mode === "edit" && row && !row.hasTemplateBinding) {
    proxy?.$modal?.msgWarning?.("该记录为旧版数据,请重新通过模板发起申请");
    return;
  }
  formDialog.mode = mode;
  formDialog.title = "编辑请假申请";
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantNo: row.applicantNo,
      applicantName: row.applicantName,
      leaveType: row.leaveType,
      leaveBalanceDays: row.leaveBalanceDays,
      leaveStartTime: row.leaveStartTime,
      leaveEndTime: row.leaveEndTime,
      leaveDurationDays: row.leaveDurationDays,
      leaveReason: row.leaveReason,
      hasTemplateBinding: true,
      templateId: row.templateId,
      templateName: row.templateName,
      templateSnapshot: row.templateSnapshot,
      formFieldDefs: row.formFieldDefs || [],
      formPayload: JSON.parse(JSON.stringify(row.formPayload || {})),
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [])),
      templateAttachments: JSON.parse(JSON.stringify(row.templateAttachments || [])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
    await loadUserPool();
    const applicantKey = applicantTemplateField.value?.key;
    if (applicantKey) {
      syncApplicantFromUser(form.formPayload[applicantKey]);
    }
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹åŒæ­¥åˆ—表展示字段 */
function syncLeaveFieldsFromPayload() {
  const defs = form.formFieldDefs || [];
  const payload = form.formPayload || {};
  const leaveTimeField = findLeaveTimeTemplateField(defs);
  for (const f of defs) {
    const label = String(f.label || "");
    const val = payload[f.key];
    if (label.includes("申请人") && !label.includes("日期") && !label.includes("时间")) {
      if (val != null && val !== "") {
        form.applicantId = val;
        const u = userById(val);
        if (u) {
          form.applicantName = u.nickName || u.userName || "";
          form.applicantNo = applicantNoFromUser(u);
        }
      }
    }
    if ((label.includes("请假类型") || f.key === "leaveType") && f.type === "select") {
      form.leaveType = val != null && val !== "" ? val : "";
    }
    if (label.includes("事由") || f.key === "summary" || label.includes("请假事由")) {
      form.leaveReason = val != null ? String(val) : "";
    }
  }
  const { start, end } = resolveLeaveTimeRange(payload, leaveTimeField);
  form.leaveStartTime = start;
  form.leaveEndTime = end;
  form.leaveDurationDays = computeLeaveDays(start, end);
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const flowCheck = validateTemplateBinding({ flowNodes: form.flowNodes });
  if (!flowCheck.ok) {
    proxy?.$modal?.msgWarning?.(flowCheck.message || "请完善审批流程");
    return;
  }
  form.flowNodes = flowCheck.nodes;
  const applicantKey = applicantTemplateField.value?.key;
  if (applicantKey) {
    syncApplicantFromUser(form.formPayload[applicantKey]);
  }
  syncLeaveFieldsFromPayload();
  if (form.leaveDurationDays == null) {
    proxy?.$modal?.msgWarning?.("请检查模板中的请假时间,结束时间须晚于开始时间");
    return;
  }
  const attachmentList = mapStorageBlobsToAttachmentList(form.storageBlobDTOs);
  const payload = {
    applicantId: form.applicantId,
    applicantNo: form.applicantNo,
    applicantName: form.applicantName,
    leaveType: form.leaveType,
    leaveBalanceDays: form.leaveBalanceDays,
    leaveStartTime: form.leaveStartTime,
    leaveEndTime: form.leaveEndTime,
    leaveDurationDays: form.leaveDurationDays,
    leaveReason: form.leaveReason,
    hasTemplateBinding: true,
    templateId: form.templateId,
    templateName: form.templateName,
    templateSnapshot: form.templateSnapshot,
    formFieldDefs: form.formFieldDefs,
    formPayload: JSON.parse(JSON.stringify(form.formPayload || {})),
    flowNodes: JSON.parse(JSON.stringify(form.flowNodes || [])),
    templateAttachments: JSON.parse(JSON.stringify(form.templateAttachments || [])),
    storageBlobDTOs: JSON.parse(JSON.stringify(form.storageBlobDTOs || [])),
    attachmentList,
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    proxy?.$modal?.msgSuccess?.("新增成功");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    if (idx !== -1) {
      const prev = allRows.value[idx];
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
        approvalResult: prev.approvalResult ?? "pending",
        createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"),
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功");
  }
  formDialog.visible = false;
  handleQuery();
}
onMounted(() => {
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
@@ -793,27 +315,5 @@
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.mr6 {
  margin-right: 6px;
}
.mb6 {
  margin-bottom: 6px;
}
.leave-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.leave-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
.leave-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
@@ -10,22 +10,20 @@
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">加班类型:</span>
        <el-select v-model="searchForm.overtimeType" placeholder="全部" clearable style="width: 180px">
          <el-option v-for="opt in OVERTIME_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增加班申请</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
@@ -35,66 +33,38 @@
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="overtime-apply-form-dialog"
      @closed="onFormClosed"
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="overtime-apply-form">
        <el-form-item v-if="form.templateSnapshot" label="审批模板">
          <span class="template-name">{{ form.templateSnapshot.label || form.templateName }}</span>
          <el-button
            v-if="formDialog.mode === 'add'"
            type="primary"
            link
            class="ml12"
            @click="reopenTemplateBind"
          >
            æ›´æ¢æ¨¡æ¿
          </el-button>
        </el-form-item>
        <FormPayloadFields :fields="templateDisplayFields" :form-payload="form.formPayload" />
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班时长">
              <el-input :model-value="overtimeHoursDisplay" readonly placeholder="根据模板中加班时间自动计算">
              <el-input :model-value="overtimeHoursDisplay(form)" readonly placeholder="根据模板中加班时间自动计算">
                <template #append>小时</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <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"
          flow-attachments-only
          hide-template-name
          :allow-change-template="false"
        />
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    </ApprovalInstanceSubmitDialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
@@ -104,167 +74,52 @@
      @closed="onTemplateBindClosed"
    />
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="加班申请详情" width="720px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人编号">{{ detailRow.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="加班类型">{{ overtimeTypeLabel(detailRow.overtimeType) }}</el-descriptions-item>
        <el-descriptions-item label="加班日期">{{ detailRow.overtimeDate || "—" }}</el-descriptions-item>
        <el-descriptions-item label="加班开始日期">{{ detailRow.overtimeStartTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="加班结束日期">{{ detailRow.overtimeEndTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="加班时长">{{ formatHours(detailRow.overtimeHours) }}</el-descriptions-item>
        <el-descriptions-item label="加班事由">{{ detailRow.overtimeReason }}</el-descriptions-item>
        <el-descriptions-item label="审批流程">
          <template v-if="detailFlowSteps(detailRow).length">
            <div class="detail-flow-chain">
              <template v-for="(step, i) in detailFlowSteps(detailRow)" :key="i">
                <span class="detail-flow-step">{{ step }}</span>
                <span v-if="i < detailFlowSteps(detailRow).length - 1" class="detail-flow-sep">→</span>
              </template>
            </div>
          </template>
          <span v-else>—</span>
        </el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ detailRow.createTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="附件">
          <template v-if="rowAttachmentList(detailRow).length">
            <el-tag v-for="(f, i) in rowAttachmentList(detailRow)" :key="i" class="mr6 mb6" type="info">
              {{ f.name }}
            </el-tag>
          </template>
          <span v-else>无</span>
        </el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- é™„件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="rowAttachmentList(filesDialog.row).length" :data="rowAttachmentList(filesDialog.row)" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="加班申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { 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 { ElMessage } from "element-plus";
import { getCurrentInstance, onMounted, reactive, ref } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  attachmentDisplayName,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
/** åŠ ç­ç±»åž‹ï¼ˆvalue ä¸ŽåŽç«¯å¯¹é½å ä½ï¼‰ */
const OVERTIME_TYPE_OPTIONS = [
  { label: "工作日加班", value: "weekday" },
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
function overtimeTypeLabel(v) {
  const hit = OVERTIME_TYPE_OPTIONS.find((x) => x.value === v);
  return hit?.label || "—";
}
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantNo: "",
  applicantName: "",
  overtimeType: "",
  overtimeDate: "",
  overtimeStartTime: "",
  overtimeEndTime: "",
  overtimeHours: null,
  overtimeReason: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const { proxy } = getCurrentInstance();
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function applicantNoFromUser(u) {
  if (!u) return "";
  return (
    u.userName ??
    u.userCode ??
    u.jobNumber ??
    u.workNo ??
    (u.userId != null ? String(u.userId) : "")
  );
}
function isOvertimeHoursField(field) {
function isOvertimeDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("加班时长") || field?.key === "overtimeHours";
}
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isOvertimeDurationField(f));
}
function findOvertimeTimeTemplateField(fields = []) {
  return (
    fields.find((f) => f?.type === "datetimerange" && String(f?.label || "").includes("加班时间")) ||
    fields.find((f) => f?.type === "datetimerange" && f?.key === "dateRange") ||
    fields.find((f) => f?.type === "datetimerange") ||
    null
  );
}
function findApplicantTemplateField(fields = []) {
  return (
    fields.find((f) => String(f?.label || "").includes("申请人")) ||
    fields.find((f) => f?.type === "select" && f?.optionSource === SELECT_OPTION_SOURCE.USER) ||
    null
  );
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹è§£æžåŠ ç­èµ·æ­¢æ—¶é—´ */
function resolveOvertimeTimeRange(payload, overtimeTimeField) {
  if (!overtimeTimeField?.key) return { start: "", end: "" };
  const val = payload?.[overtimeTimeField.key];
@@ -272,296 +127,94 @@
  return { start: val[0] || "", end: val[1] || "" };
}
/** æŒ‰èµ·æ­¢æ—¶é—´è®¡ç®—加班时长(小时,保留两位小数) */
function computeOvertimeHours(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  const hours = t1.diff(t0, "millisecond") / (60 * 60 * 1000);
  return Math.round(hours * 100) / 100;
  return Math.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100;
}
function formatHours(v) {
  if (v == null || v === "") return "—";
  return `${v} å°æ—¶`;
function overtimeHoursDisplay(form) {
  const field = findOvertimeTimeTemplateField(form.formFieldDefs);
  const { start, end } = resolveOvertimeTimeRange(form.formPayload, field);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
}
function mapStorageBlobsToAttachmentList(blobs) {
  return (blobs || []).map((f) => ({
    name: attachmentDisplayName(f),
    url: f.url || f.downloadURL || f.previewURL || f.previewUrl,
  }));
}
function rowAttachmentList(row) {
  if (!row) return [];
  if (row.attachmentList?.length) return row.attachmentList;
  return mapStorageBlobsToAttachmentList(row.storageBlobDTOs);
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
function sortedApprovalNodes(row) {
  const list = row?.approvalFlowNodes;
  if (!Array.isArray(list) || !list.length) return [];
  return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
}
function approvalNodeLabel(n) {
  const name = (n.approverName || "").trim();
  return name || "未选择审批人";
}
/** è¯¦æƒ…审批流程:优先模板 flowNodes,兼容旧版 approvalFlowNodes */
function detailFlowSteps(row) {
  const nodes = row?.flowNodes;
  if (Array.isArray(nodes) && nodes.length) {
    return [...nodes]
      .sort((a, b) => (a.nodeOrder ?? 0) - (b.nodeOrder ?? 0))
      .map((n, i) => {
        const names = (n.approvers || [])
          .map((a) => (a.approverName || "").trim())
          .filter(Boolean)
          .join("、");
        return `${i + 1}. ${names || "未选择审批人"}`;
      });
  }
  return sortedApprovalNodes(row).map((n, i) => `${i + 1}. ${approvalNodeLabel(n)}`);
}
function syncApplicantFromUser(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantId = uid != null && uid !== "" ? uid : "";
    form.applicantName = u.nickName || u.userName || "";
    form.applicantNo = applicantNoFromUser(u);
  } else {
    form.applicantId = "";
    form.applicantName = "";
    form.applicantNo = "";
  }
}
const allUsersCache = ref([]);
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
const allRows = ref([]);
const { proxy } = getCurrentInstance();
const searchForm = reactive({
  applicantKeyword: "",
  overtimeType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.OVERTIME,
  beforeSave: validateOvertimeBeforeSave,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
  if (kw) {
    list = list.filter((r) => {
      const name = (r.applicantName || "").toLowerCase();
      const no = (r.applicantNo || "").toLowerCase();
      return name.includes(kw) || no.includes(kw);
    });
  }
  if (searchForm.overtimeType) {
    list = list.filter((r) => r.overtimeType === searchForm.overtimeType);
  }
  return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人编号", prop: "applicantNo", width: 120 },
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "加班日期", prop: "overtimeDate", width: 120 },
  { label: "加班开始日期", prop: "overtimeStartTime", width: 170 },
  { label: "加班结束日期", prop: "overtimeEndTime", width: 170 },
  {
    label: "加班时长",
    prop: "overtimeHours",
    width: 120,
    formatData: (v) => (v == null || v === "" ? "—" : `${v} å°æ—¶`),
  },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  { label: "创建时间", prop: "createTime", width: 170 },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 220,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openFormDialog("edit", row),
      },
      {
        name: "查看详情",
        type: "text",
        clickFun: (row) => openDetail(row),
      },
      {
        name: "附件",
        type: "text",
        clickFun: (row) => openFiles(row),
      },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const overtimeTimeTemplateField = computed(() => findOvertimeTimeTemplateField(form.formFieldDefs));
const applicantTemplateField = computed(() => findApplicantTemplateField(form.formFieldDefs));
const templateDisplayFields = computed(() =>
  (form.formFieldDefs || []).filter((f) => !isOvertimeHoursField(f))
);
const overtimeHoursDisplay = computed(() => {
  const { start, end } = resolveOvertimeTimeRange(form.formPayload, overtimeTimeTemplateField.value);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
});
const formRules = computed(() => buildFormPayloadRules(templateDisplayFields.value));
watch(
  () => {
    const key = applicantTemplateField.value?.key;
    return key ? form.formPayload[key] : undefined;
  },
  async (uid) => {
    if (!applicantTemplateField.value) return;
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    syncApplicantFromUser(uid);
function validateOvertimeBeforeSave() {
  const field = findOvertimeTimeTemplateField(submitForm.formFieldDefs);
  const { start, end } = resolveOvertimeTimeRange(submitForm.formPayload, field);
  if (computeOvertimeHours(start, end) == null) {
    ElMessage.warning("请检查模板中的加班时间,结束时间须晚于开始时间");
    throw new Error("invalid overtime time");
  }
);
}
watch(
  () => {
    const key = overtimeTimeTemplateField.value?.key;
    return key ? form.formPayload[key] : undefined;
  },
  () => {
    const { start, end } = resolveOvertimeTimeRange(form.formPayload, overtimeTimeTemplateField.value);
    form.overtimeStartTime = start;
    form.overtimeEndTime = end;
    form.overtimeHours = computeOvertimeHours(start, end);
    if (start) {
      form.overtimeDate = dayjs(start).format("YYYY-MM-DD");
    }
  },
  { deep: true }
);
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions);
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
const importInputRef = ref(null);
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.applicantKeyword = "";
  searchForm.overtimeType = "";
  handleQuery();
  onSearch();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
function onPagination(obj) {
  pagination(obj, searchForm);
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgWarning?.("暂无下载地址");
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
function handleExport() {
  const data = filteredList.value;
  const data = tableData.value;
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
@@ -569,274 +222,12 @@
  a.download = `加班申请导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
  a.click();
  URL.revokeObjectURL(url);
  proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡ï¼ˆå½“前筛选结果,JSON)`);
  proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡ï¼ˆå½“前页列表数据)`);
}
function handleImportClick() {
  importInputRef.value?.click?.();
}
function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const hours =
    raw.overtimeHours != null && raw.overtimeHours !== ""
      ? Number(raw.overtimeHours)
      : computeOvertimeHours(raw.overtimeStartTime, raw.overtimeEndTime);
  return {
    id,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    applicantNo: raw.applicantNo ?? "",
    applicantName: raw.applicantName ?? "未知",
    overtimeType: raw.overtimeType || "weekday",
    overtimeDate: raw.overtimeDate ?? "",
    overtimeStartTime: raw.overtimeStartTime ?? "",
    overtimeEndTime: raw.overtimeEndTime ?? "",
    overtimeHours: hours == null || Number.isNaN(hours) ? 0 : Math.round(hours * 100) / 100,
    overtimeReason: raw.overtimeReason ?? "",
    hasTemplateBinding: false,
    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
      ? raw.approvalFlowNodes.map((n) => ({ ...n }))
      : [],
    approvalResult: raw.approvalResult && ["pending", "approved", "rejected", "cancelled"].includes(raw.approvalResult)
      ? raw.approvalResult
      : "pending",
    attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [],
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
  };
}
function onImportFile(e) {
  const input = e.target;
  const file = input.files?.[0];
  input.value = "";
  if (!file) return;
  const reader = new FileReader();
  reader.onload = () => {
    try {
      const text = String(reader.result || "");
      const parsed = JSON.parse(text);
      const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
      if (!Array.isArray(arr) || !arr.length) {
        proxy?.$modal?.msgWarning?.("导入文件格式不正确,需为加班申请对象数组 JSON");
        return;
      }
      let n = 0;
      for (let i = 0; i < arr.length; i++) {
        allRows.value.unshift(normalizeImportedRow(arr[i], i));
        n++;
      }
      proxy?.$modal?.msgSuccess?.(`成功导入 ${n} æ¡ï¼ˆæœ¬åœ°åˆå¹¶ï¼‰`);
      handleQuery();
    } catch {
      proxy?.$modal?.msgError?.("解析失败,请使用导出文件或约定 JSON ç»“æž„");
    }
  };
  reader.readAsText(file, "utf-8");
}
function openAddWithTemplate() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  pendingTemplateBinding.value = binding;
}
async function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  await openFormWithBinding(binding);
}
async function openFormWithBinding(binding) {
  Object.assign(form, createEmptyForm());
  applyBindingToForm(form, binding);
  form.hasTemplateBinding = true;
  formDialog.mode = "add";
  formDialog.title = "新增加班申请";
  await Promise.all([loadUserPool(), loadFlowUsers()]);
  const applicantKey = applicantTemplateField.value?.key;
  if (applicantKey && form.formPayload[applicantKey]) {
    syncApplicantFromUser(form.formPayload[applicantKey]);
  }
  const { start, end } = resolveOvertimeTimeRange(form.formPayload, overtimeTimeTemplateField.value);
  form.overtimeStartTime = start;
  form.overtimeEndTime = end;
  form.overtimeHours = computeOvertimeHours(start, end);
  if (start) form.overtimeDate = dayjs(start).format("YYYY-MM-DD");
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function reopenTemplateBind() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
async function openFormDialog(mode, row) {
  if (mode === "edit" && row && !row.hasTemplateBinding) {
    proxy?.$modal?.msgWarning?.("该记录为旧版数据,请重新通过模板发起申请");
    return;
  }
  formDialog.mode = mode;
  formDialog.title = "编辑加班申请";
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantNo: row.applicantNo,
      applicantName: row.applicantName,
      overtimeType: row.overtimeType,
      overtimeDate: row.overtimeDate,
      overtimeStartTime: row.overtimeStartTime,
      overtimeEndTime: row.overtimeEndTime,
      overtimeHours: row.overtimeHours,
      overtimeReason: row.overtimeReason,
      hasTemplateBinding: true,
      templateId: row.templateId,
      templateName: row.templateName,
      templateSnapshot: row.templateSnapshot,
      formFieldDefs: row.formFieldDefs || [],
      formPayload: JSON.parse(JSON.stringify(row.formPayload || {})),
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [])),
      templateAttachments: JSON.parse(JSON.stringify(row.templateAttachments || [])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
    await loadUserPool();
    const applicantKey = applicantTemplateField.value?.key;
    if (applicantKey) {
      syncApplicantFromUser(form.formPayload[applicantKey]);
    }
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹åŒæ­¥åˆ—表展示字段 */
function syncOvertimeFieldsFromPayload() {
  const defs = form.formFieldDefs || [];
  const payload = form.formPayload || {};
  const overtimeTimeField = findOvertimeTimeTemplateField(defs);
  for (const f of defs) {
    const label = String(f.label || "");
    const val = payload[f.key];
    if (label.includes("申请人") && !label.includes("日期") && !label.includes("时间")) {
      if (val != null && val !== "") {
        form.applicantId = val;
        const u = userById(val);
        if (u) {
          form.applicantName = u.nickName || u.userName || "";
          form.applicantNo = applicantNoFromUser(u);
        }
      }
    }
    if ((label.includes("加班类型") || f.key === "overtimeType") && f.type === "select") {
      form.overtimeType = val != null && val !== "" ? val : "";
    }
    if (label.includes("加班日期") && f.type === "date") {
      form.overtimeDate = val || "";
    }
    if (label.includes("事由") || f.key === "summary" || label.includes("加班事由")) {
      form.overtimeReason = val != null ? String(val) : "";
    }
  }
  const { start, end } = resolveOvertimeTimeRange(payload, overtimeTimeField);
  form.overtimeStartTime = start;
  form.overtimeEndTime = end;
  form.overtimeHours = computeOvertimeHours(start, end);
  if (!form.overtimeDate && start) {
    form.overtimeDate = dayjs(start).format("YYYY-MM-DD");
  }
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const flowCheck = validateTemplateBinding({ flowNodes: form.flowNodes });
  if (!flowCheck.ok) {
    proxy?.$modal?.msgWarning?.(flowCheck.message || "请完善审批流程");
    return;
  }
  form.flowNodes = flowCheck.nodes;
  const applicantKey = applicantTemplateField.value?.key;
  if (applicantKey) {
    syncApplicantFromUser(form.formPayload[applicantKey]);
  }
  syncOvertimeFieldsFromPayload();
  if (form.overtimeHours == null) {
    proxy?.$modal?.msgWarning?.("请检查模板中的加班时间,结束时间须晚于开始时间");
    return;
  }
  const attachmentList = mapStorageBlobsToAttachmentList(form.storageBlobDTOs);
  const payload = {
    applicantId: form.applicantId,
    applicantNo: form.applicantNo,
    applicantName: form.applicantName,
    overtimeType: form.overtimeType,
    overtimeDate: form.overtimeDate,
    overtimeStartTime: form.overtimeStartTime,
    overtimeEndTime: form.overtimeEndTime,
    overtimeHours: form.overtimeHours,
    overtimeReason: form.overtimeReason,
    hasTemplateBinding: true,
    templateId: form.templateId,
    templateName: form.templateName,
    templateSnapshot: form.templateSnapshot,
    formFieldDefs: form.formFieldDefs,
    formPayload: JSON.parse(JSON.stringify(form.formPayload || {})),
    flowNodes: JSON.parse(JSON.stringify(form.flowNodes || [])),
    templateAttachments: JSON.parse(JSON.stringify(form.templateAttachments || [])),
    storageBlobDTOs: JSON.parse(JSON.stringify(form.storageBlobDTOs || [])),
    attachmentList,
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    proxy?.$modal?.msgSuccess?.("新增成功");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    if (idx !== -1) {
      const prev = allRows.value[idx];
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
        approvalResult: prev.approvalResult ?? "pending",
        createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"),
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功");
  }
  formDialog.visible = false;
  handleQuery();
}
onMounted(() => {
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
@@ -854,59 +245,10 @@
.search_actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.mr6 {
  margin-right: 6px;
}
.mb6 {
  margin-bottom: 6px;
}
.overtime-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.overtime-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
.overtime-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.detail-flow-chain {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 8px;
  line-height: 1.6;
}
.detail-flow-step {
  font-size: 14px;
  color: var(--el-text-color-primary);
}
.detail-flow-sep {
  color: var(--el-text-color-secondary);
  font-size: 13px;
}
</style>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsApprovalBridge.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,98 @@
import {
  createEmptyForm,
  publishStatusLabel,
  PUBLISH_STATUS_OPTIONS,
} from "./enterpriseNewsUtils.js";
import { normalizeApprovalStatusKey } from "../../ApproveManage/approve-list/approveListConstants.js";
/** formPayload ä¸­å­˜æ”¾å®Œæ•´ä¼ä¸šæ–°é—»ä¸šåŠ¡æ•°æ®çš„é”® */
export const ENTERPRISE_NEWS_PAYLOAD_KEY = "enterpriseNews";
export function extractEnterpriseNewsFromRow(row) {
  const payload = row?.formPayload || {};
  const raw = payload[ENTERPRISE_NEWS_PAYLOAD_KEY];
  if (raw && typeof raw === "object") {
    return { ...createEmptyForm(), ...raw };
  }
  return {
    ...createEmptyForm(),
    title: payload.title || row?.title || "",
    summary: payload.summary || "",
    newsType: payload.newsType || "announcement",
    contentHtml: payload.contentHtml || "",
  };
}
/** åˆ—表行增强:主表展示新闻字段 */
export function enrichEnterpriseNewsListRow(row) {
  const news = extractEnterpriseNewsFromRow(row);
  const publishStatus =
    news.publishStatus || mapApprovalStatusToPublishStatus(row?.approvalStatus);
  return {
    ...row,
    newsNo: news.newsNo || row.instanceNo || "—",
    title: news.title || row.title || "—",
    summary: news.summary,
    newsType: news.newsType,
    publisherName: news.publisherName || row.applicantName || "—",
    publishTime: news.publishTime || row.createTime || "",
    updateTime: news.updateTime || row.createTime || "",
    publishStatus,
    _news: news,
  };
}
function mapApprovalStatusToPublishStatus(approvalStatus) {
  const key = normalizeApprovalStatusKey(approvalStatus);
  if (key === "approved") return "published";
  if (key === "pending") return "pending_review";
  if (key === "rejected") return "draft";
  return "draft";
}
/** ä¼ä¸šæ–°é—»è¡¨å• â†’ å®¡æ‰¹å®žä¾‹ formPayload */
export function syncNewsFormToSubmitPayload(newsForm, submitForm) {
  const snapshot = JSON.parse(JSON.stringify(newsForm));
  submitForm.formPayload = {
    ...(submitForm.formPayload || {}),
    [ENTERPRISE_NEWS_PAYLOAD_KEY]: snapshot,
    title: snapshot.title,
    summary: snapshot.summary,
  };
}
export function buildEnterpriseNewsTableColumns(buildTableActions) {
  return [
    { label: "编号", prop: "newsNo", width: 150 },
    { label: "标题", prop: "title", minWidth: 180, showOverflowTooltip: true },
    {
      label: "分类",
      prop: "newsType",
      width: 100,
      dataType: "slot",
      slot: "newsType",
    },
    {
      label: "状态",
      prop: "publishStatus",
      width: 90,
      dataType: "tag",
      formatData: (v) => publishStatusLabel(v),
      formatType: (v) => {
        const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
        return hit?.tag || "info";
      },
    },
    { label: "发布人", prop: "publisherName", width: 110 },
    { label: "发布时间", prop: "publishTime", width: 170 },
    { label: "更新时间", prop: "updateTime", width: 170 },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: buildTableActions(),
    },
  ];
}
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
@@ -1,7 +1,6 @@
<!--OA模块:EnterpriseNews ä¼ä¸šæ–°é—»-->
<!--OA模块:EnterpriseNews ä¼ä¸šæ–°é—»ï¼ˆåˆ—表走审批实例,新增/修改保留原表单 + æ¨¡æ¿å®¡æ‰¹æµç¨‹ï¼‰-->
<template>
  <div class="app-container enterprise-news-page">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">关键词:</span>
@@ -11,19 +10,24 @@
          placeholder="标题 / ç¼–号 / æ‘˜è¦"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">分类:</span>
        <el-select v-model="searchForm.newsType" placeholder="全部" clearable style="width: 140px">
          <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">状态:</span>
        <el-select v-model="searchForm.publishStatus" placeholder="全部" clearable style="width: 120px">
          <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        <span class="search_title" style="margin-left: 12px">审批状态:</span>
        <el-select v-model="searchForm.status" placeholder="全部" clearable style="width: 120px">
          <el-option
            v-for="opt in APPROVAL_STATUS_SEARCH_OPTIONS"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">发布时间:</span>
        <span class="search_title" style="margin-left: 12px">申请日期:</span>
        <el-date-picker
          v-model="searchForm.publishTimeRange"
          v-model="searchForm.createTimeRange"
          type="daterange"
          range-separator="-"
          start-placeholder="开始"
@@ -33,11 +37,11 @@
          style="width: 260px"
          clearable
        />
        <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">搜索</el-button>
        <el-button type="primary" :icon="Search" class="ml10" @click="onSearch">搜索</el-button>
        <el-button :icon="RefreshRight" @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">新建新闻</el-button>
        <el-button type="primary" :icon="Plus" @click="openAddWithTemplate">新建新闻</el-button>
      </div>
    </div>
@@ -50,7 +54,7 @@
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
        @pagination="onPagination"
      >
        <template #newsType="{ row }">
          <span class="news-type-tag" :style="{ color: newsTypeColor(row.newsType) }">
@@ -60,34 +64,42 @@
      </PIMTable>
    </div>
    <!-- æ–°å»º / ç¼–辑 -->
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- æ–°å»º / ç¼–辑:原企业新闻表单 + æ¨¡æ¿å®¡æ‰¹æµç¨‹ -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      v-model="newsFormDialog.visible"
      :title="newsFormDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="news-form-dialog"
      @closed="formRef?.resetFields?.()"
      @closed="onNewsFormClosed"
    >
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        ref="newsFormRef"
        :model="newsForm"
        :rules="newsFormRules"
        label-width="110px"
        :disabled="formDialog.readonly"
        :disabled="newsFormDialog.readonly"
      >
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="新闻分类" prop="newsType">
              <el-select v-model="form.newsType" placeholder="请选择" style="width: 100%">
              <el-select v-model="newsForm.newsType" placeholder="请选择" style="width: 100%">
                <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="排版模板">
              <el-select v-model="form.layoutTemplate" style="width: 100%">
              <el-select v-model="newsForm.layoutTemplate" style="width: 100%">
                <el-option
                  v-for="opt in LAYOUT_TEMPLATE_OPTIONS"
                  :key="opt.value"
@@ -99,29 +111,29 @@
          </el-col>
        </el-row>
        <el-form-item label="标题" prop="title">
          <el-input v-model="form.title" placeholder="新闻标题" maxlength="100" show-word-limit />
          <el-input v-model="newsForm.title" placeholder="新闻标题" maxlength="100" show-word-limit />
        </el-form-item>
        <el-form-item label="摘要">
          <el-input v-model="form.summary" type="textarea" :rows="2" maxlength="300" show-word-limit />
          <el-input v-model="newsForm.summary" type="textarea" :rows="2" maxlength="300" show-word-limit />
        </el-form-item>
        <el-form-item label="正文" prop="contentHtml">
          <Editor v-model="form.contentHtml" :min-height="280" />
          <Editor v-model="newsForm.contentHtml" :min-height="280" />
        </el-form-item>
        <el-form-item label="附件">
          <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="上传 PDF / æ–‡æ¡£" />
          <FileUpload v-model:file-list="newsForm.attachmentList" :limit="10" button-text="上传 PDF / æ–‡æ¡£" />
        </el-form-item>
        <el-form-item v-if="form.layoutTemplate === 'gallery'" label="图集/视频">
        <el-form-item v-if="newsForm.layoutTemplate === 'gallery'" label="图集/视频">
          <el-input
            v-model="galleryInput"
            placeholder="输入资源名称后回车添加(演示)"
            @keyup.enter="addGalleryItem"
          />
          <el-tag
            v-for="(m, i) in form.mediaList"
            v-for="(m, i) in newsForm.mediaList"
            :key="i"
            closable
            class="media-tag"
            @close="form.mediaList.splice(i, 1)"
            @close="newsForm.mediaList.splice(i, 1)"
          >
            {{ m.name }}
          </el-tag>
@@ -131,276 +143,326 @@
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="编辑角色">
              <el-select v-model="form.editorRole" style="width: 100%">
              <el-select v-model="newsForm.editorRole" style="width: 100%">
                <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="审核角色">
              <el-select v-model="form.reviewerRole" style="width: 100%">
              <el-select v-model="newsForm.reviewerRole" style="width: 100%">
                <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="阅读范围" prop="readScope">
          <el-radio-group v-model="form.readScope">
          <el-radio-group v-model="newsForm.readScope">
            <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value">
              {{ opt.label }}
            </el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item v-if="form.readScope === 'department'" label="可见部门">
          <el-select v-model="form.targetDeptIds" multiple placeholder="选择部门" style="width: 100%">
        <el-form-item v-if="newsForm.readScope === 'department'" label="可见部门">
          <el-select v-model="newsForm.targetDeptIds" multiple placeholder="选择部门" style="width: 100%">
            <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" />
          </el-select>
        </el-form-item>
        <el-form-item label="政策类必读">
          <el-switch v-model="form.requireReadConfirm" active-text="需阅读确认(便于统计未读)" />
          <el-switch v-model="newsForm.requireReadConfirm" active-text="需阅读确认(便于统计未读)" />
        </el-form-item>
        <el-form-item label="发布人">
          <el-input v-model="form.publisherName" placeholder="如:人力资源部" maxlength="50" />
          <el-input v-model="newsForm.publisherName" placeholder="如:人力资源部" maxlength="50" />
        </el-form-item>
        <template v-if="activeTemplate">
          <el-divider content-position="left">审批流程</el-divider>
          <el-form-item label="审批模板">
            <span class="template-name">{{ activeTemplate.label || submitForm.templateName }}</span>
          </el-form-item>
          <el-form-item label="审批流程" required>
            <TemplateFlowEditor v-model="submitForm.flowNodes" :user-options="flowUserOptions" />
            <p class="section-tip">流程与审批人由模板预置,可按需微调节点审批人。</p>
          </el-form-item>
        </template>
        <el-alert v-else type="warning" show-icon :closable="false" title="请先通过「新建新闻」选择审批模板" />
      </el-form>
      <template v-if="!formDialog.readonly" #footer>
        <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        <el-button @click="onSave('save')">存草稿</el-button>
        <el-button type="warning" @click="onSave('submit_review')">提交审核</el-button>
        <el-button type="primary" @click="onSave('publish')">直接发布</el-button>
      <template v-if="!newsFormDialog.readonly" #footer>
        <el-button @click="newsFormDialog.visible = false">取 æ¶ˆ</el-button>
        <el-button :loading="submitSaving" @click="onNewsSave('draft')">存草稿</el-button>
        <el-button type="warning" :loading="submitSaving" @click="onNewsSave('submit_review')">
          æäº¤å®¡æ ¸
        </el-button>
        <el-button type="primary" :loading="submitSaving" @click="onNewsSave('submit_review')">
          ä¿ å­˜
        </el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="新闻详情" width="880px" append-to-body destroy-on-close>
      <NewsDetailPanel
        :row="detailRow"
        @like="onDetailLike"
        @comment="onDetailComment"
      />
      <NewsDetailPanel :row="detailNewsRow" @like="onDetailLike" @comment="onDetailComment" />
      <el-divider content-position="left">审批信息</el-divider>
      <ApproveDetailPanel :row="detailRow" />
      <template #footer>
        <el-button
          v-if="detailRow.publishStatus === 'published' && getUnreadEmployees(detailRow).length"
          type="warning"
          @click="openUnreadFromDetail"
          v-if="canEditBusinessInstanceRow(detailRow)"
          type="primary"
          @click="openNewsEditFromDetail"
        >
          æœªè¯»æé†’
          ä¿®æ”¹
        </el-button>
        <el-button @click="openVersionFromDetail">版本留证</el-button>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- æœªè¯»æé†’ -->
    <el-dialog
      v-model="unreadDialog.visible"
      :title="`未阅读员工 Â· ${unreadDialog.row?.title || ''}`"
      width="720px"
      append-to-body
      destroy-on-close
    >
      <el-alert type="warning" show-icon :closable="false" class="mb12">
        æ”¿ç­–传达场景:发布新考勤制度等必读信息后,可勾选未读员工由 HR å®šå‘提醒(演示数据,后期对接消息中心)。
      </el-alert>
      <div class="unread-toolbar mb12">
        <el-button size="small" @click="selectAllUnread">全选未读</el-button>
        <span class="unread-stat">共 {{ unreadList.length }} äººæœªè¯»</span>
      </div>
      <el-table
        :data="unreadList"
        border
        size="small"
        max-height="360"
        @selection-change="onUnreadSelectionChange"
      >
        <el-table-column type="selection" width="48" />
        <el-table-column prop="employeeNo" label="工号" width="100" />
        <el-table-column prop="name" label="姓名" width="90" />
        <el-table-column prop="deptName" label="部门" min-width="120" />
      </el-table>
      <el-divider v-if="unreadDialog.row?.remindLogs?.length" content-position="left">提醒记录</el-divider>
      <el-timeline v-if="unreadDialog.row?.remindLogs?.length">
        <el-timeline-item
          v-for="(log, i) in unreadDialog.row.remindLogs"
          :key="i"
          :timestamp="log.time"
        >
          {{ log.operator }} å·²å‘ {{ log.count }} äººå‘送阅读提醒
        </el-timeline-item>
      </el-timeline>
      <template #footer>
        <el-button type="primary" @click="onSendRemind">发送定向提醒</el-button>
        <el-button @click="unreadDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- ç‰ˆæœ¬ç•™è¯ -->
    <el-dialog
      v-model="versionDialog.visible"
      :title="`历史版本留证 Â· ${versionDialog.row?.title || ''}`"
      width="800px"
      append-to-body
      destroy-on-close
    >
      <el-alert type="info" show-icon :closable="false" class="mb12">
        äº‰è®®å‘生时可查阅历史版本,证明当时发布内容与发布时间(合规留证)。
      </el-alert>
      <el-descriptions :column="2" border class="mb16">
        <el-descriptions-item label="当前版本">v{{ versionDialog.row?.versionNo || 1 }}</el-descriptions-item>
        <el-descriptions-item label="最近发布">{{ versionDialog.row?.publishTime || "—" }}</el-descriptions-item>
      </el-descriptions>
      <el-table :data="versionList" border size="small" empty-text="暂无历史版本">
        <el-table-column prop="versionNo" label="版本" width="70" align="center" />
        <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
        <el-table-column prop="changeNote" label="变更说明" width="120" />
        <el-table-column prop="publishTime" label="发布时间" width="170" />
        <el-table-column prop="archivedAt" label="归档时间" width="170" />
        <el-table-column label="操作" width="90" align="center">
          <template #default="{ row: ver }">
            <el-button type="primary" link @click="previewVersion(ver)">查看</el-button>
          </template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button @click="versionDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- ç‰ˆæœ¬é¢„览 -->
    <el-dialog v-model="versionPreview.visible" title="历史版本内容" width="640px" append-to-body>
      <p class="version-meta">
        v{{ versionPreview.data?.versionNo }} Â· {{ versionPreview.data?.changeNote }} Â·
        {{ versionPreview.data?.publishTime }}
      </p>
      <div class="version-html" v-html="versionPreview.data?.contentHtml || ''" />
    </el-dialog>
  </div>
</template>
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { Plus, RefreshRight, Search } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import useUserStore from "@/store/modules/user";
import Editor from "@/components/Editor/index.vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { newsTypeColor } from "./enterpriseNewsUtils.js";
import { APPROVAL_STATUS_SEARCH_OPTIONS } from "../../ApproveManage/approve-list/approveListConstants.js";
import ApproveDetailPanel from "../../ApproveManage/approve-list/components/ApproveDetailPanel.vue";
import { buildEditFormFromInstanceRow } from "../../ApproveManage/approve-list/approveListConstants.js";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import TemplateFlowEditor from "../../ApproveManage/approve-template/components/TemplateFlowEditor.vue";
import {
  applyBindingToForm,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import NewsDetailPanel from "./components/NewsDetailPanel.vue";
import { useEnterpriseNews } from "./useEnterpriseNews.js";
const {
  Search,
import {
  NEWS_TYPE_OPTIONS,
  PUBLISH_STATUS_OPTIONS,
  LAYOUT_TEMPLATE_OPTIONS,
  READ_SCOPE_OPTIONS,
  PUBLISH_ROLE_OPTIONS,
  DEPT_OPTIONS,
  createEmptyForm,
  newsTypeColor,
  newsTypeLabel,
  searchForm,
  validateNewsForm,
} from "./enterpriseNewsUtils.js";
import {
  enrichEnterpriseNewsListRow,
  extractEnterpriseNewsFromRow,
  syncNewsFormToSubmitPayload,
  buildEnterpriseNewsTableColumns,
} from "./enterpriseNewsApprovalBridge.js";
const userStore = useUserStore();
const searchForm = reactive({
  keyword: "",
  newsType: "",
  status: "",
  createTimeRange: null,
});
const newsFormDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
const newsForm = reactive(createEmptyForm());
const newsFormRef = ref();
const galleryInput = ref("");
const newsFormRules = {
  title: [{ required: true, message: "请输入新闻标题", trigger: "blur" }],
  newsType: [{ required: true, message: "请选择新闻分类", trigger: "change" }],
  readScope: [{ required: true, message: "请选择阅读范围", trigger: "change" }],
};
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS,
  enrichListRow: enrichEnterpriseNewsListRow,
  buildExtraListParams(sf) {
    const extra = {};
    const kw = (sf?.keyword || "").trim();
    if (kw) extra.title = kw;
    if (sf?.newsType) extra.newsType = sf.newsType;
    return extra;
  },
  async beforeSave(submitForm) {
    const v = validateNewsForm(newsForm);
    if (!v.ok) {
      ElMessage.warning(v.message);
      throw new Error(v.message);
    }
    if (!activeTemplate.value) {
      ElMessage.warning("请先选择审批模板");
      throw new Error("no template");
    }
    const bindingCheck = validateTemplateBinding({ flowNodes: submitForm.flowNodes });
    if (!bindingCheck.ok) {
      ElMessage.warning(bindingCheck.message);
      throw new Error(bindingCheck.message);
    }
    syncNewsFormToSubmitPayload(newsForm, submitForm);
  },
});
const {
  tableData,
  tableLoading,
  page,
  tableData,
  tableColumn,
  formDialog,
  form,
  formRef,
  formRules,
  detailDialog,
  detailRow,
  unreadDialog,
  unreadList,
  versionDialog,
  getUnreadEmployees,
  submitDialog,
  submitForm,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  templateBindVisible,
  pendingTemplateBinding,
  submitEditRow,
  handleQuery,
  resetSearch,
  initModuleList,
  pagination,
  openFormDialog,
  openDetail,
  openUnreadRemind,
  openVersionHistory,
  saveForm,
  sendUnreadRemind,
  toggleLike,
  addComment,
} = useEnterpriseNews();
  openAddWithTemplate,
  onTemplateBound,
  resetSubmitForm,
  submitInstanceForm,
  removeInstance,
  canEditBusinessInstanceRow,
} = mod;
const galleryInput = ref("");
const unreadSelected = ref([]);
const versionPreview = reactive({ visible: false, data: null });
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const versionList = computed(() => {
  const row = versionDialog.row;
  if (!row) return [];
  const history = [...(row.versions || [])];
  return history.sort((a, b) => (b.versionNo || 0) - (a.versionNo || 0));
const detailNewsRow = computed(() => {
  if (!detailRow.value?.id) return {};
  return extractEnterpriseNewsFromRow(detailRow.value);
});
const tableColumn = ref(
  buildEnterpriseNewsTableColumns(() => [
    { name: "详情", type: "text", clickFun: (row) => openNewsDetail(row) },
    {
      name: "修改",
      type: "text",
      disabled: (row) => !canEditBusinessInstanceRow(row),
      clickFun: (row) => openNewsEdit(row),
    },
    {
      name: "删除",
      type: "danger",
      clickFun: (row) => removeInstance(row),
    },
  ])
);
function resetNewsForm(target = createEmptyForm()) {
  Object.assign(newsForm, createEmptyForm(), target);
}
function openNewsFormDialog(mode, row) {
  newsFormDialog.mode = mode;
  newsFormDialog.readonly = mode === "view";
  newsFormDialog.title =
    mode === "add" ? "新建企业新闻" : mode === "edit" ? "编辑企业新闻" : "查看企业新闻";
  if (mode === "add") {
    resetNewsForm({
      publisherName: userStore?.nickName || userStore?.name || "当前用户",
    });
  } else if (row) {
    resetNewsForm(extractEnterpriseNewsFromRow(row));
  }
  newsFormDialog.visible = true;
}
function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  resetSubmitForm();
  applyBindingToForm(submitForm, binding);
  submitDialog.mode = "add";
  submitEditRow.value = null;
  openNewsFormDialog("add");
}
function openNewsEdit(row) {
  if (!canEditBusinessInstanceRow(row)) {
    ElMessage.warning("进行中或已完成的审批不可修改");
    return;
  }
  submitDialog.mode = "edit";
  submitEditRow.value = { ...row };
  Object.assign(submitForm, buildEditFormFromInstanceRow(row));
  openNewsFormDialog("edit", row);
}
function openNewsDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openNewsEditFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openNewsEdit(row);
}
function onNewsFormClosed() {
  newsFormRef.value?.resetFields?.();
}
function addGalleryItem() {
  const name = (galleryInput.value || "").trim();
  if (!name) return;
  form.mediaList = form.mediaList || [];
  form.mediaList.push({ type: "image", name, url: "" });
  newsForm.mediaList = newsForm.mediaList || [];
  newsForm.mediaList.push({ type: "image", name, url: "" });
  galleryInput.value = "";
}
function onSave(action) {
  const ret = saveForm(action);
  if (ret?.message) {
    ElMessage.warning(ret.message);
async function onNewsSave(action = "submit_review") {
  try {
    await newsFormRef.value?.validate();
  } catch {
    ElMessage.warning("请完善表单必填项后再保存");
    return;
  }
  if (ret?.ok) {
    ElMessage.success(action === "publish" ? "已发布" : action === "submit_review" ? "已提交审核" : "已保存");
  if (action === "draft") newsForm.publishStatus = "draft";
  else newsForm.publishStatus = "pending_review";
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) {
    newsFormDialog.visible = false;
    const msg =
      action === "draft" ? "已保存草稿" : isSubmitEdit.value ? "修改成功" : "已提交审核";
    ElMessage.success(msg);
  }
}
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.keyword = "";
  searchForm.newsType = "";
  searchForm.status = "";
  searchForm.createTimeRange = null;
  onSearch();
}
function onPagination(obj) {
  pagination(obj, searchForm);
}
function onDetailLike() {
  toggleLike(detailRow.value);
  /* è¯¦æƒ…互动仍走行内数据,刷新列表后更新 */
}
function onDetailComment(text) {
  const ret = addComment(detailRow.value, text);
  if (ret?.message) ElMessage.warning(ret.message);
  else if (ret?.ok) ElMessage.success("评论已发布");
function onDetailComment() {
  ElMessage.info("评论已记录(演示)");
}
function openUnreadFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openUnreadRemind(row);
}
function openVersionFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openVersionHistory(row);
}
function onUnreadSelectionChange(rows) {
  unreadSelected.value = rows.map((r) => r.userId);
}
function selectAllUnread() {
  unreadSelected.value = unreadList.value.map((u) => u.userId);
}
function onSendRemind() {
  const ids = unreadSelected.value;
  const ret = sendUnreadRemind(ids);
  if (ret?.message) {
    ElMessage.warning(ret.message);
    return;
  }
  if (ret?.ok) ElMessage.success(`已向 ${ret.count} åå‘˜å·¥å‘送阅读提醒`);
}
function previewVersion(ver) {
  versionPreview.data = ver;
  versionPreview.visible = true;
}
onMounted(() => {
  handleQuery();
onMounted(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
@@ -421,6 +483,10 @@
.search_actions {
  flex-shrink: 0;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.news-type-tag {
  font-weight: 600;
  font-size: 13px;
@@ -428,32 +494,18 @@
.media-tag {
  margin: 6px 8px 0 0;
}
.unread-toolbar {
  display: flex;
  align-items: center;
  gap: 12px;
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.unread-stat {
.section-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  font-size: 13px;
  margin: 8px 0 0;
  line-height: 1.5;
}
.version-meta {
  color: var(--el-text-color-secondary);
  font-size: 13px;
  margin-bottom: 12px;
}
.version-html {
  padding: 12px;
  background: var(--el-fill-color-light);
  border-radius: 6px;
  max-height: 400px;
  overflow-y: auto;
}
.mb16 {
  margin-bottom: 16px;
}
.mb12 {
  margin-bottom: 12px;
.mb20 {
  margin-bottom: 20px;
}
.ml10 {
  margin-left: 10px;
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
@@ -10,7 +10,7 @@
          placeholder="请输入申请人"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">申请日期:</span>
        <el-date-picker
@@ -24,7 +24,7 @@
          style="width: 260px"
          clearable
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
@@ -39,56 +39,24 @@
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="regular-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="regular-apply-form">
        <el-form-item v-if="form.templateSnapshot" label="审批模板">
          <span class="template-name">{{ form.templateSnapshot.label || form.templateName }}</span>
          <el-button
            v-if="formDialog.mode === 'add'"
            type="primary"
            link
            class="ml12"
            @click="reopenTemplateBind"
          >
            æ›´æ¢æ¨¡æ¿
          </el-button>
        </el-form-item>
        <FormPayloadFields :fields="form.formFieldDefs" :form-payload="form.formPayload" />
        <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"
          flow-attachments-only
          hide-template-name
          :allow-change-template="false"
        />
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      @submit="onSubmit"
    />
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
@@ -98,392 +66,96 @@
      @closed="onTemplateBindClosed"
    />
    <!-- è¯¦æƒ…(只读) -->
    <el-dialog v-model="detailDialog.visible" title="转正申请详情" width="640px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="申请日期">{{ detailRow.applyDate }}</el-descriptions-item>
        <el-descriptions-item label="转正日期">{{ detailRow.regularizationDate }}</el-descriptions-item>
        <el-descriptions-item label="试用期工作总结">{{ detailRow.probationSummary }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
        <el-descriptions-item label="附件">
          <template v-if="detailRow.attachmentList?.length">
            <el-tag
              v-for="(f, i) in detailRow.attachmentList"
              :key="i"
              class="mr6 mb6"
              type="info"
            >
              {{ f.name }}
            </el-tag>
          </template>
          <span v-else>无</span>
        </el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- é™„件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="转正申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { onMounted, reactive } from "vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import ApprovalTemplateFormSection from "../../ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
/** ä¸ŽåŽç«¯çº¦å®šå­—段(占位) */
const createEmptyForm = () => ({
  id: undefined,
  applicantName: "",
  applyDate: "",
  regularizationDate: "",
  probationSummary: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const { proxy } = getCurrentInstance();
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
const allRows = ref([]);
const searchForm = reactive({
  applicantName: "",
  applyDateRange: null,
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const name = (searchForm.applicantName || "").trim();
  if (name) {
    list = list.filter((r) => r.applicantName.includes(name));
  }
  const range = searchForm.applyDateRange;
  if (range && range.length === 2) {
    const [start, end] = range;
    list = list.filter((r) => r.applyDate >= start && r.applyDate <= end);
  }
  return list.sort((a, b) => (a.applyDate < b.applyDate ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.REGULAR,
  buildExtraListParams(sf) {
    const range = sf?.applyDateRange;
    if (Array.isArray(range) && range[0]) {
      return { createTime: range[0], createTimeEnd: range[1] };
    }
    return {};
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "申请日期", prop: "applyDate", width: 120 },
  { label: "转正日期", prop: "regularizationDate", width: 120 },
  { label: "试用期工作总结", prop: "probationSummary", minWidth: 200 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 200,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openFormDialog("edit", row),
      },
      {
        name: "查看详情",
        type: "text",
        clickFun: (row) => openDetail(row),
      },
      {
        name: "附件",
        type: "text",
        clickFun: (row) => openFiles(row),
      },
    ],
  },
]);
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const formRules = computed(() => buildFormPayloadRules(form.formFieldDefs));
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions);
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.applicantName = "";
  searchForm.applyDateRange = null;
  handleQuery();
  onSearch();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
function onPagination(obj) {
  pagination(obj, searchForm);
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgWarning?.("暂无下载地址");
}
function openAddWithTemplate() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  pendingTemplateBinding.value = binding;
}
function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  openFormWithBinding(binding);
}
function openFormWithBinding(binding) {
  Object.assign(form, createEmptyForm());
  applyBindingToForm(form, binding);
  form.hasTemplateBinding = true;
  formDialog.mode = "add";
  formDialog.title = "新增转正申请";
onMounted(async () => {
  loadFlowUsers();
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function reopenTemplateBind() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function openFormDialog(mode, row) {
  if (mode === "edit" && row && !row.hasTemplateBinding) {
    proxy?.$modal?.msgWarning?.("该记录为旧版数据,请重新通过模板发起申请");
    return;
  }
  formDialog.mode = mode;
  formDialog.title = "编辑转正申请";
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantName: row.applicantName,
      applyDate: row.applyDate,
      regularizationDate: row.regularizationDate,
      probationSummary: row.probationSummary,
      hasTemplateBinding: true,
      templateId: row.templateId,
      templateName: row.templateName,
      templateSnapshot: row.templateSnapshot,
      formFieldDefs: row.formFieldDefs || [],
      formPayload: JSON.parse(JSON.stringify(row.formPayload || {})),
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [])),
      templateAttachments: JSON.parse(JSON.stringify(row.templateAttachments || [])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const flowCheck = validateTemplateBinding({ flowNodes: form.flowNodes });
  if (!flowCheck.ok) {
    proxy?.$modal?.msgWarning?.(flowCheck.message || "请完善审批流程");
    return;
  }
  form.flowNodes = flowCheck.nodes;
  syncRegularFieldsFromPayload();
  const payload = {
    applicantName: form.applicantName,
    applyDate: form.applyDate,
    regularizationDate: form.regularizationDate,
    probationSummary: form.probationSummary,
    hasTemplateBinding: true,
    templateId: form.templateId,
    templateName: form.templateName,
    templateSnapshot: form.templateSnapshot,
    formFieldDefs: form.formFieldDefs,
    formPayload: JSON.parse(JSON.stringify(form.formPayload || {})),
    flowNodes: JSON.parse(JSON.stringify(form.flowNodes || [])),
    templateAttachments: JSON.parse(JSON.stringify(form.templateAttachments || [])),
    storageBlobDTOs: JSON.parse(JSON.stringify(form.storageBlobDTOs || [])),
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({ id, ...payload, approvalResult: "pending" });
    proxy?.$modal?.msgSuccess?.("新增成功");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    if (idx !== -1) {
      const prev = allRows.value[idx];
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
        approvalResult: prev.approvalResult ?? "pending",
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功");
  }
  formDialog.visible = false;
  handleQuery();
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹åŒæ­¥åˆ—表展示字段 */
function syncRegularFieldsFromPayload() {
  const defs = form.formFieldDefs || [];
  const payload = form.formPayload || {};
  for (const f of defs) {
    const label = String(f.label || "");
    const val = payload[f.key];
    if (label.includes("申请人") && !label.includes("日期")) {
      form.applicantName = val != null && val !== "" ? String(val) : form.applicantName;
    }
    if (label.includes("申请日期") && f.type === "date") {
      form.applyDate = val || "";
    }
    if (label.includes("转正") && (label.includes("日期") || label.includes("时间")) && f.type === "date") {
      form.regularizationDate = val || "";
    }
    if (label.includes("试用期") || label.includes("工作总结")) {
      form.probationSummary = val != null ? String(val) : "";
    }
  }
}
onMounted(() => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
@@ -501,27 +173,5 @@
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.mr6 {
  margin-right: 6px;
}
.mb6 {
  margin-bottom: 6px;
}
.regular-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.regular-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
.regular-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue
@@ -34,7 +34,7 @@
          style="width: 260px"
          clearable
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
@@ -49,64 +49,32 @@
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="transfer-apply-form-dialog"
      @closed="onFormClosed"
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="transfer-apply-form">
        <el-form-item v-if="form.templateSnapshot" label="审批模板">
          <span class="template-name">{{ form.templateSnapshot.label || form.templateName }}</span>
          <el-button
            v-if="formDialog.mode === 'add'"
            type="primary"
            link
            class="ml12"
            @click="reopenTemplateBind"
          >
            æ›´æ¢æ¨¡æ¿
          </el-button>
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-form-item label="原岗位">
          <el-input :model-value="originalPostName" placeholder="选择申请人后自动带出" disabled />
        </el-form-item>
        <FormPayloadFields :fields="templateDisplayFields" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="原岗位" prop="originalPostName">
              <el-input v-model="form.originalPostName" placeholder="选择申请人后自动带出" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <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"
            flow-attachments-only
            hide-template-name
            :allow-change-template="false"
          />
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    </ApprovalInstanceSubmitDialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
@@ -116,64 +84,29 @@
      @closed="onTemplateBindClosed"
    />
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="调岗申请详情" width="560px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="转岗日期">{{ detailRow.transferDate }}</el-descriptions-item>
        <el-descriptions-item label="原岗位">{{ detailRow.originalPostName }}</el-descriptions-item>
        <el-descriptions-item label="转入岗位">{{ detailRow.targetPostName }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="调岗申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { findPostOptions } from "@/api/system/post.js";
import { 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 { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref, watch } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
const { proxy } = getCurrentInstance();
/** ä¸ŽåŽç«¯çº¦å®šå­—段(本地占位,后期接口对齐) */
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantName: "",
  transferDate: "",
  originalPostId: "",
  originalPostName: "",
  targetPostId: "",
  targetPostName: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
function isOriginalPostField(field) {
  const label = String(field?.label || "");
@@ -185,6 +118,10 @@
  );
}
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isOriginalPostField(f));
}
function findApplicantTemplateField(fields = []) {
  return (
    fields.find((f) => String(f?.label || "").includes("申请人")) ||
@@ -193,45 +130,73 @@
  );
}
function syncApplicantFromUser(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantId = uid != null && uid !== "" ? uid : "";
    form.applicantName = u.nickName || u.userName || "";
    const { originalPostId, originalPostName } = resolveOriginalPost(u);
    form.originalPostId = originalPostId;
    form.originalPostName = originalPostName;
  } else {
    form.applicantId = "";
    form.applicantName = "";
    form.originalPostId = "";
    form.originalPostName = "";
  }
}
const searchForm = reactive({
  applicantId: "",
  transferDateRange: null,
});
/** ç³»ç»Ÿç”¨æˆ·ç¼“存(/system/user/userListNoPageByTenantId,与转正申请等一致) */
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.TRANSFER,
  buildExtraListParams(sf) {
    const range = sf?.transferDateRange;
    if (Array.isArray(range) && range[0]) {
      return { createTime: range[0], createTimeEnd: range[1] };
    }
    return {};
  },
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const allUsersCache = ref([]);
/** å²—位字典 postId -> postName(/system/post/optionselect,与员工档案入职表单一致) */
const postIdToName = ref({});
const targetPostOptions = ref([]);
const applicantSearchLoading = ref(false);
const applicantSearchOptions = ref([]);
const originalPostName = ref("");
function rebuildPostIdMap() {
  const m = {};
  for (const p of targetPostOptions.value || []) {
    const id = p.postId ?? p.value ?? p.id;
    if (id != null && id !== "") m[String(id)] = p.postName ?? p.label ?? "";
  }
  postIdToName.value = m;
const applicantTemplateField = computed(() =>
  findApplicantTemplateField(submitForm.formFieldDefs)
);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function targetPostNameById(postId) {
  if (postId == null || postId === "") return "";
  const k = String(postId);
  return (
    postIdToName.value[k] ||
    targetPostOptions.value.find((x) => String(x.postId ?? x.id ?? x.value) === k)?.postName ||
    ""
  );
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userSelectLabel(u) {
@@ -248,30 +213,19 @@
  return undefined;
}
/** ä»Žç”¨æˆ·å¯¹è±¡è§£æžã€ŒåŽŸå²—ä½ã€ï¼ˆå…¼å®¹ postName / postIds / posts ç­‰å¸¸è§è¿”回) */
function resolveOriginalPost(user) {
  if (!user) return { originalPostId: "", originalPostName: "" };
  if (!user) return { originalPostName: "" };
  const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
  if (nameStr) {
    const pid = firstPostId(user);
    return { originalPostId: pid != null && pid !== "" ? String(pid) : "", originalPostName: nameStr };
  }
  if (nameStr) return { originalPostName: nameStr };
  if (Array.isArray(user.posts) && user.posts.length) {
    const p0 = user.posts[0];
    return {
      originalPostId: p0.postId != null ? String(p0.postId) : "",
      originalPostName: (p0.postName ?? "").toString() || "未命名岗位",
    };
    return { originalPostName: (user.posts[0].postName ?? "").toString() || "未命名岗位" };
  }
  const pid = firstPostId(user);
  if (pid != null && pid !== "") {
    const n = postIdToName.value[String(pid)] || "";
    return {
      originalPostId: String(pid),
      originalPostName: n || "当前岗位(未在岗位字典中)",
    };
    return { originalPostName: n || "当前岗位(未在岗位字典中)" };
  }
  return { originalPostId: "", originalPostName: "未分配岗位" };
  return { originalPostName: "未分配岗位" };
}
function userById(id) {
@@ -308,353 +262,79 @@
  } catch {
    targetPostOptions.value = [];
  }
  rebuildPostIdMap();
  const m = {};
  for (const p of targetPostOptions.value) {
    const id = p.postId ?? p.value ?? p.id;
    if (id != null && id !== "") m[String(id)] = p.postName ?? p.label ?? "";
  }
  postIdToName.value = m;
}
/** æŸ¥è¯¢åŒºï¼šä¸‹æ‹‰è¿œç¨‹æ¨¡ç³Šï¼ˆæ•°æ®æ¥è‡ª userListNoPageByTenantId,前端过滤) */
const applicantSearchLoading = ref(false);
const applicantSearchOptions = ref([]);
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    if (!allUsersCache.value.length) await loadUserPool();
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
}
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
function syncOriginalPostFromApplicant(uid) {
  const u = userById(uid);
  originalPostName.value = resolveOriginalPost(u).originalPostName;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
const allRows = ref([]);
const searchForm = reactive({
  applicantId: "",
  transferDateRange: null,
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  if (searchForm.applicantId) {
    list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId));
  }
  const range = searchForm.transferDateRange;
  if (range && range.length === 2) {
    const [start, end] = range;
    list = list.filter((r) => r.transferDate >= start && r.transferDate <= end);
  }
  return list.sort((a, b) => (a.transferDate < b.transferDate ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "转岗日期", prop: "transferDate", width: 120 },
  { label: "原岗位", prop: "originalPostName", minWidth: 140 },
  { label: "转入岗位", prop: "targetPostName", minWidth: 160 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 180,
    operation: [
      { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
      { name: "查看详情", type: "text", clickFun: (row) => openDetail(row) },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const templateDisplayFields = computed(() =>
  (form.formFieldDefs || []).filter((f) => !isOriginalPostField(f))
);
const applicantTemplateField = computed(() => findApplicantTemplateField(form.formFieldDefs));
const formRules = computed(() => ({
  ...buildFormPayloadRules(templateDisplayFields.value),
  originalPostName: [{ required: true, message: "请选择申请人以带出原岗位", trigger: "change" }],
}));
watch(
  () => {
    const key = applicantTemplateField.value?.key;
    return key ? form.formPayload[key] : undefined;
    return key ? submitForm.formPayload[key] : undefined;
  },
  async (uid) => {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    syncApplicantFromUser(uid);
    if (!applicantTemplateField.value) return;
    if (!allUsersCache.value.length) await loadUserPool();
    syncOriginalPostFromApplicant(uid);
  }
);
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
watch(
  () => submitDialog.visible,
  async (v) => {
    if (!v) return;
    const key = applicantTemplateField.value?.key;
    if (key && submitForm.formPayload[key]) {
      syncOriginalPostFromApplicant(submitForm.formPayload[key]);
    }
  }
);
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions);
function onSearch() {
  handleQuery(searchForm);
}
async function resetSearch() {
  searchForm.applicantId = "";
  searchForm.transferDateRange = null;
  handleQuery();
  onSearch();
  await remoteSearchApplicant("");
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
function onPagination(obj) {
  pagination(obj, searchForm);
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openAddWithTemplate() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  pendingTemplateBinding.value = binding;
}
async function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  await openFormWithBinding(binding);
}
async function openFormWithBinding(binding) {
  Object.assign(form, createEmptyForm());
  applyBindingToForm(form, binding);
  form.hasTemplateBinding = true;
  formDialog.mode = "add";
  formDialog.title = "新增调岗申请";
  await Promise.all([loadUserPool(), loadPostOptions(), loadFlowUsers()]);
  const applicantKey = applicantTemplateField.value?.key;
  if (applicantKey && form.formPayload[applicantKey]) {
    syncApplicantFromUser(form.formPayload[applicantKey]);
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function reopenTemplateBind() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
async function openFormDialog(mode, row) {
  if (mode === "edit" && row && !row.hasTemplateBinding) {
    proxy?.$modal?.msgWarning?.("该记录为旧版数据,请重新通过模板发起申请");
    return;
  }
  formDialog.mode = mode;
  formDialog.title = "编辑调岗申请";
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      transferDate: row.transferDate,
      originalPostId: row.originalPostId,
      originalPostName: row.originalPostName,
      targetPostId: row.targetPostId,
      targetPostName: row.targetPostName,
      hasTemplateBinding: true,
      templateId: row.templateId,
      templateName: row.templateName,
      templateSnapshot: row.templateSnapshot,
      formFieldDefs: row.formFieldDefs || [],
      formPayload: JSON.parse(JSON.stringify(row.formPayload || {})),
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [])),
      templateAttachments: JSON.parse(JSON.stringify(row.templateAttachments || [])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
    await loadUserPool();
    const applicantKey = applicantTemplateField.value?.key;
    if (applicantKey) {
      syncApplicantFromUser(form.formPayload[applicantKey]);
    }
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const flowCheck = validateTemplateBinding({ flowNodes: form.flowNodes });
  if (!flowCheck.ok) {
    proxy?.$modal?.msgWarning?.(flowCheck.message || "请完善审批流程");
    return;
  }
  form.flowNodes = flowCheck.nodes;
  const applicantKey = applicantTemplateField.value?.key;
  if (applicantKey) {
    syncApplicantFromUser(form.formPayload[applicantKey]);
  }
  syncTransferFieldsFromPayload();
  form.targetPostName = targetPostNameById(form.targetPostId);
  const payload = {
    applicantId: form.applicantId,
    applicantName: form.applicantName,
    transferDate: form.transferDate,
    originalPostId: form.originalPostId,
    originalPostName: form.originalPostName,
    targetPostId: form.targetPostId,
    targetPostName: form.targetPostName,
    hasTemplateBinding: true,
    templateId: form.templateId,
    templateName: form.templateName,
    templateSnapshot: form.templateSnapshot,
    formFieldDefs: form.formFieldDefs,
    formPayload: JSON.parse(JSON.stringify(form.formPayload || {})),
    flowNodes: JSON.parse(JSON.stringify(form.flowNodes || [])),
    templateAttachments: JSON.parse(JSON.stringify(form.templateAttachments || [])),
    storageBlobDTOs: JSON.parse(JSON.stringify(form.storageBlobDTOs || [])),
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
    });
    proxy?.$modal?.msgSuccess?.("新增成功");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    const prev = idx !== -1 ? allRows.value[idx] : {};
    if (idx !== -1) {
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功");
  }
  formDialog.visible = false;
  handleQuery();
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹åŒæ­¥è½¬å²—日期、转入岗位到列表字段 */
function syncTransferFieldsFromPayload() {
  const defs = form.formFieldDefs || [];
  const payload = form.formPayload || {};
  for (const f of defs) {
    const label = String(f.label || "");
    const val = payload[f.key];
    if (label.includes("转岗") && (label.includes("日期") || label.includes("时间")) && f.type === "date") {
      form.transferDate = val || "";
    }
    if (label.includes("转入岗位") && f.type === "select") {
      form.targetPostId = val != null && val !== "" ? val : "";
      form.targetPostName = targetPostNameById(form.targetPostId);
    }
  }
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  await Promise.all([loadUserPool(), loadPostOptions()]);
  rebuildPostIdMap();
  loadFlowUsers();
  await remoteSearchApplicant("");
  await initModuleList(searchForm);
});
</script>
@@ -672,21 +352,5 @@
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.transfer-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.transfer-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.transfer-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
</style>
src/views/officeProcessAutomation/HrManage/work-handover/index.vue
@@ -30,7 +30,7 @@
        <el-select v-model="searchForm.handoverType" placeholder="全部" clearable style="width: 140px">
          <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
@@ -45,56 +45,24 @@
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="work-handover-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="work-handover-form">
        <el-form-item v-if="form.templateSnapshot" label="审批模板">
          <span class="template-name">{{ form.templateSnapshot.label || form.templateName }}</span>
          <el-button
            v-if="formDialog.mode === 'add'"
            type="primary"
            link
            class="ml12"
            @click="reopenTemplateBind"
          >
            æ›´æ¢æ¨¡æ¿
          </el-button>
        </el-form-item>
        <FormPayloadFields :fields="form.formFieldDefs" :form-payload="form.formPayload" :columns="2" />
        <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"
          flow-attachments-only
          hide-template-name
          :allow-change-template="false"
        />
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      @submit="onSubmit"
    />
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
@@ -104,42 +72,26 @@
      @closed="onTemplateBindClosed"
    />
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="工作交接详情" width="560px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="离职日期">{{ detailRow.leaveDate || "—" }}</el-descriptions-item>
        <el-descriptions-item label="交接状态">{{ handoverStatusLabel(detailRow.handoverStatus) }}</el-descriptions-item>
        <el-descriptions-item label="交接类型">{{ handoverTypeLabel(detailRow.handoverType) }}</el-descriptions-item>
        <el-descriptions-item label="交接人">{{ detailRow.handoverPersonName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="工作交接详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { onMounted, reactive, ref } from "vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import ApprovalTemplateFormSection from "../../ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
const { proxy } = getCurrentInstance();
const handoverStatusOptions = [
  { value: "in_progress", label: "进行中" },
@@ -152,84 +104,48 @@
  { value: "transfer", label: "调岗交接" },
];
function handoverStatusLabel(v) {
  return handoverStatusOptions.find((o) => o.value === v)?.label || "—";
}
function handoverTypeLabel(v) {
  return handoverTypeOptions.find((o) => o.value === v)?.label || "—";
}
/** ä¸ŽåŽç«¯çº¦å®šå­—段(本地占位,后期接口对齐) */
const createEmptyForm = () => ({
  id: undefined,
const searchForm = reactive({
  applicantId: "",
  applicantName: "",
  leaveDate: "",
  handoverStatus: "in_progress",
  handoverType: "resignation",
  handoverPersonId: "",
  handoverPersonName: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
  handoverStatus: "",
  handoverType: "",
});
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.WORK_HANDOVER,
});
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const allUsersCache = ref([]);
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return [...list];
  return list.filter((u) => {
    const nick = (u.nickName || "").toLowerCase();
    const uname = (u.userName || "").toLowerCase();
    const phone = (u.phonenumber || u.phone || "").toString();
    return nick.includes(q) || uname.includes(q) || phone.includes(q);
  });
}
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
const applicantSearchLoading = ref(false);
const applicantSearchOptions = ref([]);
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
}
const applicantSearchLoading = ref(false);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
@@ -244,330 +160,74 @@
  return String(u.status) === "0";
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  return "与签";
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return list.slice(0, 50);
  return list
    .filter((u) => {
      const nick = (u.nickName || "").toLowerCase();
      const name = (u.userName || "").toLowerCase();
      const id = String(u.userId ?? u.id ?? "");
      return nick.includes(q) || name.includes(q) || id.includes(q);
    })
    .slice(0, 50);
}
function handoverStatusTagType(v) {
  if (v === "completed") return "success";
  if (v === "returned") return "danger";
  return "warning";
}
function handoverTypeTagType(v) {
  return v === "transfer" ? "info" : "";
}
const allRows = ref([]);
const searchForm = reactive({
  applicantId: "",
  handoverStatus: "",
  handoverType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  if (searchForm.applicantId) {
    list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId));
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
  if (searchForm.handoverStatus) {
    list = list.filter((r) => r.handoverStatus === searchForm.handoverStatus);
}
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) await loadUserPool();
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
  if (searchForm.handoverType) {
    list = list.filter((r) => r.handoverType === searchForm.handoverType);
  }
  return list.sort((a, b) => (a.leaveDate < b.leaveDate ? 1 : -1));
});
}
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "离职日期", prop: "leaveDate", width: 120 },
  {
    label: "交接状态",
    prop: "handoverStatus",
    width: 110,
    dataType: "tag",
    formatData: (v) => handoverStatusLabel(v),
    formatType: (v) => handoverStatusTagType(v),
  },
  {
    label: "交接类型",
    prop: "handoverType",
    width: 110,
    dataType: "tag",
    formatData: (v) => handoverTypeLabel(v),
    formatType: (v) => handoverTypeTagType(v),
  },
  { label: "交接人", prop: "handoverPersonName", minWidth: 100 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 200,
    operation: [
      { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
      { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const formRules = computed(() => buildFormPayloadRules(form.formFieldDefs));
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
function onSearch() {
  handleQuery(searchForm);
}
async function resetSearch() {
  searchForm.applicantId = "";
  searchForm.handoverStatus = "";
  searchForm.handoverType = "";
  handleQuery();
  onSearch();
  await remoteSearchApplicant("");
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
function onPagination(obj) {
  pagination(obj, searchForm);
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openAddWithTemplate() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function onTemplateBound(binding) {
  pendingTemplateBinding.value = binding;
}
async function onTemplateBindClosed() {
  const binding = pendingTemplateBinding.value;
  if (!binding) return;
  pendingTemplateBinding.value = null;
  await openFormWithBinding(binding);
}
async function openFormWithBinding(binding) {
  Object.assign(form, createEmptyForm());
  applyBindingToForm(form, binding);
  form.hasTemplateBinding = true;
  formDialog.mode = "add";
  formDialog.title = "新增工作交接";
  await Promise.all([loadUserPool(), loadFlowUsers()]);
  await syncHandoverFieldsFromPayload();
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function reopenTemplateBind() {
  formDialog.visible = false;
  pendingTemplateBinding.value = null;
  templateBindVisible.value = true;
}
function syncApplicantFromUser(uid) {
  const u = userById(uid);
  form.applicantId = uid != null && uid !== "" ? uid : "";
  form.applicantName = u ? u.nickName || u.userName || "" : "";
}
function syncHandoverPersonFromUser(uid) {
  const u = userById(uid);
  form.handoverPersonId = uid != null && uid !== "" ? uid : "";
  form.handoverPersonName = u ? u.nickName || u.userName || "" : "";
}
/** ä»Žæ¨¡æ¿å¡«æŠ¥é¡¹åŒæ­¥åˆ—表展示字段 */
async function syncHandoverFieldsFromPayload() {
  const defs = form.formFieldDefs || [];
  const payload = form.formPayload || {};
  for (const f of defs) {
    const label = String(f.label || "");
    const val = payload[f.key];
    if (label.includes("申请人") && !label.includes("交接人")) {
      syncApplicantFromUser(val);
    } else if (label.includes("交接人")) {
      syncHandoverPersonFromUser(val);
    } else if (label.includes("离职") && f.type === "date") {
      form.leaveDate = val || "";
    } else if (label.includes("交接状态")) {
      form.handoverStatus = val != null && val !== "" ? val : form.handoverStatus;
    } else if (label.includes("交接类型")) {
      form.handoverType = val != null && val !== "" ? val : form.handoverType;
    }
  }
}
async function openFormDialog(mode, row) {
  if (mode === "edit" && row && !row.hasTemplateBinding) {
    proxy?.$modal?.msgWarning?.("该记录为旧版数据,请重新通过模板发起申请");
    return;
  }
  formDialog.mode = mode;
  formDialog.title = "编辑工作交接";
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      leaveDate: row.leaveDate,
      handoverStatus: row.handoverStatus,
      handoverType: row.handoverType,
      handoverPersonId: row.handoverPersonId,
      handoverPersonName: row.handoverPersonName,
      hasTemplateBinding: true,
      templateId: row.templateId,
      templateName: row.templateName,
      templateSnapshot: row.templateSnapshot,
      formFieldDefs: row.formFieldDefs || [],
      formPayload: JSON.parse(JSON.stringify(row.formPayload || {})),
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [])),
      templateAttachments: JSON.parse(JSON.stringify(row.templateAttachments || [])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
    });
    await loadUserPool();
    await syncHandoverFieldsFromPayload();
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const flowCheck = validateTemplateBinding({ flowNodes: form.flowNodes });
  if (!flowCheck.ok) {
    proxy?.$modal?.msgWarning?.(flowCheck.message || "请完善审批流程");
    return;
  }
  form.flowNodes = flowCheck.nodes;
  await syncHandoverFieldsFromPayload();
  const payload = {
    applicantId: form.applicantId,
    applicantName: form.applicantName,
    leaveDate: form.leaveDate,
    handoverStatus: form.handoverStatus,
    handoverType: form.handoverType,
    handoverPersonId: form.handoverPersonId,
    handoverPersonName: form.handoverPersonName,
    hasTemplateBinding: true,
    templateId: form.templateId,
    templateName: form.templateName,
    templateSnapshot: form.templateSnapshot,
    formFieldDefs: form.formFieldDefs,
    formPayload: JSON.parse(JSON.stringify(form.formPayload || {})),
    flowNodes: JSON.parse(JSON.stringify(form.flowNodes || [])),
    templateAttachments: JSON.parse(JSON.stringify(form.templateAttachments || [])),
    storageBlobDTOs: JSON.parse(JSON.stringify(form.storageBlobDTOs || [])),
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
    });
    proxy?.$modal?.msgSuccess?.("新增成功");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    const prev = idx !== -1 ? allRows.value[idx] : {};
    if (idx !== -1) {
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功");
  }
  formDialog.visible = false;
  handleQuery();
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
onMounted(async () => {
  await loadUserPool();
  loadFlowUsers();
  await remoteSearchApplicant("");
  await initModuleList(searchForm);
});
</script>
@@ -585,21 +245,5 @@
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.work-handover-form :deep(.el-row) {
  margin-bottom: 0;
}
.work-handover-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.work-handover-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.template-name {
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.ml12 {
  margin-left: 12px;
}
</style>