yyb
12 小时以前 0a58164ce2ea3f1a2b46781757d78b94b212883b
工作交接/调岗申请/转正申请/请假申请/加班申请新增调用模板
已修改13个文件
3002 ■■■■■ 文件已修改
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js 32 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue 40 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue 10 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js 38 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue 741 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue 670 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js 18 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue 398 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue 508 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/work-handover/index.vue 495 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -33,22 +33,22 @@
  [APPROVAL_MODULE_KEYS.RESIGN]: {
    label: "离职申请",
    approvalType: "resign",
    typeLabels: ["离职", "离职申请"],
    typeLabels: ["离职", "离职申请", "离职审批"],
  },
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
    label: "工作交接",
    approvalType: "work_handover",
    typeLabels: ["工作交接", "交接"],
    typeLabels: ["工作交接", "交接", "工作交接审批"],
  },
  [APPROVAL_MODULE_KEYS.LEAVE]: {
    label: "请假申请",
    approvalType: "leave",
    typeLabels: ["请假", "请假申请"],
    typeLabels: ["请假", "请假申请", "请假审批"],
  },
  [APPROVAL_MODULE_KEYS.OVERTIME]: {
    label: "加班申请",
    approvalType: "overtime",
    typeLabels: ["加班", "加班申请"],
    typeLabels: ["加班", "加班申请", "加班审批"],
  },
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
    label: "差旅报销",
@@ -100,3 +100,27 @@
  return cfg.approvalType || null;
}
/** 收集与模块相关的全部 businessType 取值(枚举值 + approvalType),用于模板列表过滤 */
export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return [];
  const values = new Set();
  const primary = resolveModuleBusinessType(moduleKey, typeOptions);
  if (primary != null && primary !== "") values.add(primary);
  if (cfg.approvalType) values.add(cfg.approvalType);
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  for (const opt of typeOptions || []) {
    const optLabel = String(opt?.label || "").trim();
    if (!optLabel) continue;
    const matched = labels.some(
      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
    );
    if (matched && opt.value != null && opt.value !== "") {
      values.add(opt.value);
    }
  }
  return [...values];
}
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue
@@ -14,17 +14,18 @@
    :title="dialogTitle"
    :width="step === formStep ? 720 : 640"
    append-to-body
    destroy-on-close
    class="approval-template-bind-dialog"
    @closed="onClosed"
  >
    <template v-if="step === 1">
      <ApprovalTemplatePicker
        :cards="templateCards"
        :loading="templatesLoading"
        :hint="pickerHint"
        @pick="onPickTemplate"
      />
      <div v-loading="templatesLoading || confirming">
        <ApprovalTemplatePicker
          :cards="templateCards"
          :loading="false"
          :hint="pickerHint"
          @pick="onPickTemplate"
        />
      </div>
    </template>
    <template v-else>
@@ -73,9 +74,11 @@
  visible: { type: Boolean, default: false },
  /** approvalModuleRegistry 中的 moduleKey */
  moduleKey: { type: String, required: true },
  /** 为 true 时选模板后直接确认,跳过「确认审批信息」填报步骤 */
  skipFormConfirm: { type: Boolean, default: false },
});
const emit = defineEmits(["update:visible", "confirm"]);
const emit = defineEmits(["update:visible", "confirm", "closed"]);
const dialogVisible = computed({
  get: () => props.visible,
@@ -122,19 +125,33 @@
    step.value = 1;
    await Promise.all([loadTemplates(), loadFlowUsers()]);
    const cfg = getApprovalModuleConfig(props.moduleKey);
    if (!cfg) ElMessage.warning(`未配置模块「${props.moduleKey}」,请检查 approvalModuleRegistry`);
    if (!cfg) {
      ElMessage.warning(`未配置模块「${props.moduleKey}」,请检查 approvalModuleRegistry`);
      return;
    }
    if (!templateCards.value.length) {
      ElMessage.warning(
        `「${cfg.label}」下暂无已启用的审批模板,请先在审批模板管理中创建并启用对应类型的模板`
      );
    }
  }
);
async function onPickTemplate(card) {
  const ok = await pickTemplate(card);
  if (ok) step.value = formStep;
  if (!ok) return;
  if (props.skipFormConfirm) {
    step.value = 1;
    await onConfirm();
    return;
  }
  step.value = formStep;
}
async function onConfirm() {
  confirming.value = true;
  try {
    const check = await validateBinding(formRef.value);
    const check = await validateBinding(props.skipFormConfirm ? null : formRef.value);
    if (!check.ok) {
      if (check.message) ElMessage.warning(check.message);
      return;
@@ -148,6 +165,7 @@
function onClosed() {
  resetBinding();
  emit("closed");
}
</script>
src/views/officeProcessAutomation/ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue
@@ -1,14 +1,14 @@
<!-- 模板绑定表单区:填报项 + 审批流程 + 附件(须挂在外层 el-form 下) -->
<template>
  <template v-if="activeTemplate">
    <el-form-item v-if="showTemplateName" label="审批模板">
    <el-form-item v-if="showTemplateName && !hideTemplateName && !flowAttachmentsOnly" label="审批模板">
      <span class="template-name">{{ activeTemplate.label }}</span>
      <el-button v-if="allowChangeTemplate" type="primary" link class="ml12" @click="emit('change-template')">
        更换模板
      </el-button>
    </el-form-item>
    <FormPayloadFields :fields="fields" :form-payload="formPayload" />
    <FormPayloadFields v-if="!hideFormFields && !flowAttachmentsOnly" :fields="fields" :form-payload="formPayload" />
    <el-form-item label="审批流程" required>
      <TemplateFlowEditor v-model="flowNodesModel" :user-options="userOptions" />
@@ -59,6 +59,12 @@
  userOptions: { type: Array, default: () => [] },
  showTemplateName: { type: Boolean, default: true },
  allowChangeTemplate: { type: Boolean, default: true },
  /** 为 true 时不展示模板自定义填报项(仅保留审批流程与附件) */
  hideFormFields: { type: Boolean, default: false },
  /** 为 true 时不展示审批模板名称行(由父级置顶展示) */
  hideTemplateName: { type: Boolean, default: false },
  /** 为 true 时仅展示审批流程与附件(填报项由父级单独渲染) */
  flowAttachmentsOnly: { type: Boolean, default: false },
  uploadLimit: { type: Number, default: 10 },
});
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalTemplateBinding.js
@@ -17,6 +17,7 @@
} from "../approve-list/approveListConstants.js";
import {
  getApprovalModuleConfig,
  getModuleMatchingBusinessTypes,
  resolveModuleBusinessType,
} from "./approvalModuleRegistry.js";
import {
@@ -60,10 +61,29 @@
    return "";
  });
  const matchingBusinessTypes = computed(() => {
    if (fixedBusinessType != null && fixedBusinessType !== "") return [fixedBusinessType];
    if (isUniversal) {
      const t = selectedBusinessType.value;
      return t != null && t !== "" ? [t] : [];
    }
    if (moduleKey) {
      return getModuleMatchingBusinessTypes(moduleKey, businessTypeOptions.value);
    }
    const t = resolvedBusinessType.value;
    return t != null && t !== "" ? [t] : [];
  });
  const templateCards = computed(() => {
    const type = resolvedBusinessType.value;
    if (type == null || type === "") return [];
    return allTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type));
    const types = matchingBusinessTypes.value;
    if (!types.length) return [];
    return allTemplates.value.filter((card) =>
      types.some(
        (t) =>
          matchBusinessTypeValue(card.businessType, t) ||
          matchBusinessTypeValue(card.approvalType, t)
      )
    );
  });
  const activeTemplate = computed(() => bindingForm.templateSnapshot || null);
@@ -87,7 +107,17 @@
  const selectedBusinessTypeLabel = computed(() => businessTypeLabel(resolvedBusinessType.value));
  function countTemplatesByBusinessType(type) {
    return allTemplates.value.filter((card) => matchBusinessTypeValue(card.businessType, type)).length;
    const types =
      moduleKey && !fixedBusinessType
        ? getModuleMatchingBusinessTypes(moduleKey, businessTypeOptions.value)
        : [type];
    return allTemplates.value.filter((card) =>
      types.some(
        (t) =>
          matchBusinessTypeValue(card.businessType, t) ||
          matchBusinessTypeValue(card.approvalType, t)
      )
    ).length;
  }
  async function loadTemplates() {
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
@@ -20,7 +20,7 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增请假申请</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增请假申请</el-button>
      </div>
    </div>
    <div class="table_list">
@@ -38,6 +38,7 @@
    <!-- 新增 / 编辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
@@ -47,39 +48,21 @@
      @closed="onFormClosed"
    >
      <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" />
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="请假类型" prop="leaveType">
              <el-select v-model="form.leaveType" placeholder="请选择请假类型" clearable filterable style="width: 100%">
                <el-option v-for="opt in LEAVE_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="假期余额" prop="leaveBalanceDays">
              <el-input-number
@@ -94,94 +77,26 @@
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="请假开始时间" prop="leaveStartTime">
              <el-date-picker
                v-model="form.leaveStartTime"
                type="datetime"
                placeholder="请选择开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onLeaveRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="请假结束时间" prop="leaveEndTime">
              <el-date-picker
                v-model="form.leaveEndTime"
                type="datetime"
                placeholder="请选择结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onLeaveRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="请假时长">
              <el-input :model-value="leaveDurationDisplay" readonly placeholder="根据起止时间自动计算">
              <el-input :model-value="leaveDurationDisplay" readonly placeholder="根据模板中请假时间自动计算">
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="or_sign">或签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="请假事由" prop="leaveReason">
              <el-input
                v-model="form.leaveReason"
                type="textarea"
                :rows="4"
                placeholder="请填写请假事由"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
                <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="点击选择文件" />
              </div>
            </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">
@@ -190,6 +105,14 @@
        </div>
      </template>
    </el-dialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.LEAVE"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- 详情 -->
    <el-dialog v-model="detailDialog.visible" title="请假申请详情" width="720px" append-to-body>
@@ -207,8 +130,8 @@
        <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="detailRow.attachmentList?.length">
            <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info">
          <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>
@@ -224,7 +147,7 @@
    <!-- 附件列表 -->
    <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 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">
@@ -246,9 +169,20 @@
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.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 FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  attachmentDisplayName,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
/** 请假类型(value 与后端对齐占位) */
const LEAVE_TYPE_OPTIONS = [
@@ -277,11 +211,17 @@
  leaveBalanceDays: undefined,
  leaveStartTime: "",
  leaveEndTime: "",
  leaveDurationDays: null,
  leaveReason: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
  attachmentList: [],
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const { proxy } = getCurrentInstance();
@@ -293,193 +233,10 @@
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
}
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
const approverTreeData = ref([]);
const approverLabelMap = ref({});
async function loadApproverTree() {
  try {
    const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    const users = unwrapArray(userRes);
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
  if (mode === "or_sign") return "或签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
/** 按起止时间计算请假天数(含时分秒,结果保留两位小数) */
function computeLeaveDays(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 days = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000);
  return Math.round(days * 100) / 100;
}
function formatDuration(v) {
  if (v == null || v === "") return "—";
  return `${v} 天`;
}
function formatBalance(v) {
  if (v == null || v === "") return "—";
  return `${v} 天`;
}
/** 系统用户缓存 */
const allUsersCache = ref([]);
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
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) {
@@ -503,43 +260,113 @@
  return undefined;
}
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);
  });
function isLeaveBalanceField(field) {
  const label = String(field?.label || "");
  return label.includes("假期余额") || field?.key === "leaveBalanceDays";
}
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
function isLeaveDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("请假时长") || field?.key === "leaveDurationDays";
}
function onApplicantChange(uid) {
function findLeaveTimeTemplateField(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 resolveLeaveTimeRange(payload, leaveTimeField) {
  if (!leaveTimeField?.key) return { start: "", end: "" };
  const val = payload?.[leaveTimeField.key];
  if (!Array.isArray(val) || val.length < 2) return { start: "", end: "" };
  return { start: val[0] || "", end: val[1] || "" };
}
/** 按起止时间计算请假天数(含时分秒,结果保留两位小数) */
function computeLeaveDays(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 days = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000);
  return Math.round(days * 100) / 100;
}
function formatDuration(v) {
  if (v == null || v === "") return "—";
  return `${v} 天`;
}
function formatBalance(v) {
  if (v == null || v === "") return "—";
  return `${v} 天`;
}
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 = "";
    form.leaveBalanceDays = undefined;
    if (uid == null || uid === "") {
      form.leaveBalanceDays = undefined;
    }
  }
}
/** 系统用户缓存 */
const allUsersCache = ref([]);
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
@@ -654,21 +481,25 @@
});
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 d = computeLeaveDays(form.leaveStartTime, form.leaveEndTime);
  const { start, end } = resolveLeaveTimeRange(form.formPayload, leaveTimeTemplateField.value);
  const d = computeLeaveDays(start, end);
  return d == null ? "" : String(d);
});
function onLeaveRangeChange() {
  nextTick(() => {
    formRef.value?.validateField?.("leaveEndTime");
  });
}
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  leaveType: [{ required: true, message: "请选择请假类型", trigger: "change" }],
const formRules = computed(() => ({
  ...buildFormPayloadRules(templateDisplayFields.value),
  leaveBalanceDays: [
    {
      required: true,
@@ -676,36 +507,35 @@
      trigger: "blur",
    },
  ],
  leaveStartTime: [{ required: true, message: "请选择请假开始时间", trigger: "change" }],
  leaveEndTime: [
    { required: true, message: "请选择请假结束时间", trigger: "change" },
    {
      validator: (_rule, val, callback) => {
        if (!form.leaveStartTime || !val) {
          callback();
          return;
        }
        const d = computeLeaveDays(form.leaveStartTime, val);
        if (d == null) {
          callback(new Error("结束时间须晚于开始时间"));
        } else {
          callback();
        }
      },
      trigger: "change",
    },
  ],
  leaveReason: [{ required: true, message: "请填写请假事由", trigger: "blur" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [
    {
      type: "array",
      required: true,
      message: "请选择审批人",
      trigger: "change",
    },
  ],
};
}));
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);
  }
);
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 detailDialog = reactive({ visible: false });
const detailRow = ref({});
@@ -750,13 +580,55 @@
  proxy?.$modal?.msgWarning?.("暂无下载地址");
}
async function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增请假申请" : "编辑请假申请";
  await loadApproverTree();
  if (!allUsersCache.value.length) {
    await loadUserPool();
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, {
@@ -768,26 +640,24 @@
      leaveBalanceDays: row.leaveBalanceDays,
      leaveStartTime: row.leaveStartTime,
      leaveEndTime: row.leaveEndTime,
      leaveDurationDays: row.leaveDurationDays,
      leaveReason: row.leaveReason,
      approvalMode: row.approvalMode === "countersign" ? "or_sign" : row.approvalMode || "parallel",
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
      attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
      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 || [])),
    });
    const u = userById(row.applicantId);
    if (u) {
      applicantFormOptions.value = [u];
    } else if (row.applicantId) {
      applicantFormOptions.value = [
        {
          userId: row.applicantId,
          nickName: row.applicantName,
          userName: row.applicantNo,
        },
      ];
    await loadUserPool();
    const applicantKey = applicantTemplateField.value?.key;
    if (applicantKey) {
      syncApplicantFromUser(form.formPayload[applicantKey]);
    }
  } else {
    remoteSearchApplicantForm("");
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
@@ -797,18 +667,65 @@
  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 days = computeLeaveDays(form.leaveStartTime, form.leaveEndTime);
  if (days == null) {
    proxy?.$modal?.msgWarning?.("请检查请假起止时间,结束时间须晚于开始时间");
  const flowCheck = validateTemplateBinding({ flowNodes: form.flowNodes });
  if (!flowCheck.ok) {
    proxy?.$modal?.msgWarning?.(flowCheck.message || "请完善审批流程");
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  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,
@@ -817,12 +734,18 @@
    leaveBalanceDays: form.leaveBalanceDays,
    leaveStartTime: form.leaveStartTime,
    leaveEndTime: form.leaveEndTime,
    leaveDurationDays: days,
    leaveDurationDays: form.leaveDurationDays,
    leaveReason: form.leaveReason,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
    attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
    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()}`;
@@ -852,7 +775,7 @@
}
onMounted(() => {
  loadApproverTree();
  loadFlowUsers();
});
</script>
@@ -871,9 +794,6 @@
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.upload-block {
  width: 100%;
}
.mr6 {
  margin-right: 6px;
}
@@ -886,6 +806,13 @@
.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;
}
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
@@ -22,7 +22,7 @@
      <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="openFormDialog('add')">新增加班申请</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" />
@@ -42,133 +42,51 @@
    <!-- 新增 / 编辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1040px"
      width="960px"
      append-to-body
      destroy-on-close
      class="overtime-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="overtime-apply-form">
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班类型" prop="overtimeType">
              <el-select v-model="form.overtimeType" placeholder="请选择加班类型" clearable filterable style="width: 100%">
                <el-option v-for="opt in OVERTIME_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="加班日期" prop="overtimeDate">
              <el-date-picker
                v-model="form.overtimeDate"
                type="date"
                placeholder="请选择加班日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班开始日期" prop="overtimeStartTime">
              <el-date-picker
                v-model="form.overtimeStartTime"
                type="datetime"
                placeholder="请选择开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onOvertimeRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="加班结束日期" prop="overtimeEndTime">
              <el-date-picker
                v-model="form.overtimeEndTime"
                type="datetime"
                placeholder="请选择结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onOvertimeRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <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" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班时长">
              <el-input :model-value="overtimeHoursDisplay" readonly placeholder="根据起止时间自动计算">
              <el-input :model-value="overtimeHoursDisplay" readonly placeholder="根据模板中加班时间自动计算">
                <template #append>小时</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批流程" prop="approvalFlowNodes">
              <ApprovalFlowEditor
                v-model="form.approvalFlowNodes"
                :user-options="flowUserOptions"
                @update:model-value="onApprovalFlowChange"
              />
              <p class="flow-tip">至少保留一个节点;每个节点选择一名审批人;可新增、删除或调整顺序。</p>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="加班事由" prop="overtimeReason">
              <el-input
                v-model="form.overtimeReason"
                type="textarea"
                :rows="4"
                placeholder="请填写加班事由"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
                <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="点击选择文件" />
              </div>
            </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">
@@ -177,6 +95,14 @@
        </div>
      </template>
    </el-dialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.OVERTIME"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- 详情 -->
    <el-dialog v-model="detailDialog.visible" title="加班申请详情" width="720px" append-to-body>
@@ -190,11 +116,11 @@
        <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="sortedApprovalNodes(detailRow).length">
          <template v-if="detailFlowSteps(detailRow).length">
            <div class="detail-flow-chain">
              <template v-for="(n, i) in sortedApprovalNodes(detailRow)" :key="i">
                <span class="detail-flow-step">{{ i + 1 }}. {{ approvalNodeLabel(n) }}</span>
                <span v-if="i < sortedApprovalNodes(detailRow).length - 1" class="detail-flow-sep">→</span>
              <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>
@@ -203,8 +129,8 @@
        <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="detailRow.attachmentList?.length">
            <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info">
          <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>
@@ -220,7 +146,7 @@
    <!-- 附件列表 -->
    <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 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">
@@ -242,10 +168,20 @@
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "./components/ApprovalFlowEditor.vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from "vue";
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 FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  attachmentDisplayName,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
/** 加班类型(value 与后端对齐占位) */
const OVERTIME_TYPE_OPTIONS = [
@@ -253,26 +189,6 @@
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
/** 本地演示:两条空节点,提交前须为每节点选择审批人 */
function demoApprovalFlowNodes() {
  return [
    { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" },
    { approverId: null, approverName: "", sortOrder: 2, nodeOrder: 2, roleName: "", roleCode: "" },
  ];
}
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();
  if (name) return name;
  return "未选择审批人";
}
function overtimeTypeLabel(v) {
  const hit = OVERTIME_TYPE_OPTIONS.find((x) => x.value === v);
@@ -288,11 +204,17 @@
  overtimeDate: "",
  overtimeStartTime: "",
  overtimeEndTime: "",
  overtimeHours: null,
  overtimeReason: "",
  attachmentList: [],
  approvalFlowNodes: [
    { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" },
  ],
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const { proxy } = getCurrentInstance();
@@ -302,52 +224,6 @@
  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 approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
/** 按起止时间计算加班时长(小时,保留两位小数) */
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;
}
function formatHours(v) {
  if (v == null || v === "") return "—";
  return `${v} 小时`;
}
const allUsersCache = ref([]);
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
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) {
@@ -366,41 +242,120 @@
  );
}
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);
  });
function isOvertimeHoursField(field) {
  const label = String(field?.label || "");
  return label.includes("加班时长") || field?.key === "overtimeHours";
}
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
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
  );
}
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
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];
  if (!Array.isArray(val) || val.length < 2) return { start: "", end: "" };
  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;
}
function formatHours(v) {
  if (v == null || v === "") return "—";
  return `${v} 小时`;
}
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 onApplicantChange(uid) {
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 = [];
  }
}
@@ -511,77 +466,60 @@
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const flowUserOptions = computed(() => allUsersCache.value.filter((u) => isActiveUser(u)));
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 h = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
  const { start, end } = resolveOvertimeTimeRange(form.formPayload, overtimeTimeTemplateField.value);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
});
function onOvertimeRangeChange() {
  nextTick(() => {
    formRef.value?.validateField?.("overtimeEndTime");
  });
}
const formRules = computed(() => buildFormPayloadRules(templateDisplayFields.value));
function onApprovalFlowChange() {
  nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
}
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);
  }
);
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  overtimeType: [{ required: true, message: "请选择加班类型", trigger: "change" }],
  overtimeDate: [{ required: true, message: "请选择加班日期", trigger: "change" }],
  overtimeStartTime: [{ required: true, message: "请选择加班开始时间", trigger: "change" }],
  overtimeEndTime: [
    { required: true, message: "请选择加班结束时间", trigger: "change" },
    {
      validator: (_rule, val, callback) => {
        if (!form.overtimeStartTime || !val) {
          callback();
          return;
        }
        const h = computeOvertimeHours(form.overtimeStartTime, val);
        if (h == null) {
          callback(new Error("结束时间须晚于开始时间"));
        } else {
          callback();
        }
      },
      trigger: "change",
    },
  ],
  overtimeReason: [{ required: true, message: "请填写加班事由", trigger: "blur" }],
  approvalFlowNodes: [
    {
      validator: (_rule, _val, callback) => {
        const nodes = form.approvalFlowNodes || [];
        if (!nodes.length) {
          callback(new Error("请至少保留一个审批节点"));
          return;
        }
        if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
          callback(new Error("每个审批节点必须选择一名审批人"));
          return;
        }
        const ids = nodes.map((n) => String(n.approverId));
        if (new Set(ids).size !== ids.length) {
          callback(new Error("同一审批人不能重复出现在多个节点"));
          return;
        }
        callback();
      },
      trigger: "change",
    },
  ],
};
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 detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
const importInputRef = ref(null);
function handleQuery() {
@@ -655,6 +593,7 @@
    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 }))
      : [],
@@ -695,12 +634,56 @@
  reader.readAsText(file, "utf-8");
}
async function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增加班申请" : "编辑加班申请";
  if (!allUsersCache.value.length) {
    await loadUserPool();
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, {
@@ -712,26 +695,24 @@
      overtimeDate: row.overtimeDate,
      overtimeStartTime: row.overtimeStartTime,
      overtimeEndTime: row.overtimeEndTime,
      overtimeHours: row.overtimeHours,
      overtimeReason: row.overtimeReason,
      attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
      approvalFlowNodes: row.approvalFlowNodes?.length
        ? JSON.parse(JSON.stringify(row.approvalFlowNodes))
        : [],
      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 || [])),
    });
    const u = userById(row.applicantId);
    if (u) {
      applicantFormOptions.value = [u];
    } else if (row.applicantId) {
      applicantFormOptions.value = [
        {
          userId: row.applicantId,
          nickName: row.applicantName,
          userName: row.applicantNo,
        },
      ];
    await loadUserPool();
    const applicantKey = applicantTemplateField.value?.key;
    if (applicantKey) {
      syncApplicantFromUser(form.formPayload[applicantKey]);
    }
  } else {
    remoteSearchApplicantForm("");
    loadFlowUsers();
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
@@ -741,17 +722,71 @@
  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 hours = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
  if (hours == null) {
    proxy?.$modal?.msgWarning?.("请检查加班起止时间,结束时间须晚于开始时间");
  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,
@@ -760,18 +795,18 @@
    overtimeDate: form.overtimeDate,
    overtimeStartTime: form.overtimeStartTime,
    overtimeEndTime: form.overtimeEndTime,
    overtimeHours: hours,
    overtimeHours: form.overtimeHours,
    overtimeReason: form.overtimeReason,
    approvalFlowNodes: (form.approvalFlowNodes || []).map((n, i) => ({
      approverId: n.approverId,
      approverName:
        n.approverName || userById(n.approverId)?.nickName || userById(n.approverId)?.userName || "",
      sortOrder: i + 1,
      nodeOrder: i + 1,
      roleName: n.roleName || "",
      roleCode: n.roleCode || "",
    })),
    attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
    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()}`;
@@ -800,6 +835,9 @@
  handleQuery();
}
onMounted(() => {
  loadFlowUsers();
});
</script>
<style scoped>
@@ -834,9 +872,6 @@
  white-space: nowrap;
  border: 0;
}
.upload-block {
  width: 100%;
}
.mr6 {
  margin-right: 6px;
}
@@ -849,14 +884,15 @@
.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;
}
.flow-tip {
  margin: 10px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--el-text-color-secondary);
}
.detail-flow-chain {
  display: flex;
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
@@ -144,25 +144,19 @@
  return [];
}
/** 不再使用前端本地缓存,列表数据以接口为准;并清除历史 localStorage 数据 */
export function loadStoredNews() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const data = JSON.parse(raw);
    return Array.isArray(data) ? data : null;
  } catch {
    return null;
  }
}
export function saveStoredNews(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
    localStorage.removeItem(STORAGE_KEY);
  } catch {
    /* ignore */
  }
  return [];
}
/** @deprecated 保留空实现,避免旧调用报错;不做任何持久化 */
export function saveStoredNews() {}
/** 按阅读范围解析目标受众 */
export function resolveTargetAudience(row) {
  const scope = row.readScope || "all";
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js
@@ -1,7 +1,7 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import {
  NEWS_TYPE_OPTIONS,
  PUBLISH_STATUS_OPTIONS,
@@ -11,7 +11,6 @@
  DEPT_OPTIONS,
  createEmptyForm,
  loadStoredNews,
  saveStoredNews,
  getUnreadEmployees,
  readRate,
  nextNewsNo,
@@ -22,8 +21,7 @@
} from "./enterpriseNewsUtils.js";
export function useEnterpriseNews() {
  const stored = loadStoredNews();
  const allRows = ref(stored?.length ? stored : []);
  const allRows = ref([]);
  const searchForm = reactive({
    keyword: "",
@@ -176,9 +174,9 @@
    },
  ]);
  function persist() {
    saveStoredNews(allRows.value);
  }
  onMounted(() => {
    loadStoredNews();
  });
  function handleQuery() {
    tableLoading.value = true;
@@ -255,7 +253,6 @@
      if (!hit.readRecords?.length) {
        hit.readRecords = [];
      }
      persist();
      return true;
    } catch {
      return false;
@@ -306,7 +303,6 @@
      }
      allRows.value[idx] = { ...prev, ...payload };
    }
    persist();
    formDialog.visible = false;
    return { ok: true };
  }
@@ -316,7 +312,6 @@
    if (hit) {
      hit.publishStatus = "archived";
      hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
      persist();
    }
  }
@@ -355,7 +350,6 @@
    });
    hit.readRecords = records;
    hit.updateTime = now;
    persist();
    unreadDialog.visible = false;
    return { ok: true, count: selectedIds.length };
  }
@@ -370,7 +364,6 @@
    } else {
      hit.likes.push({ userId, name: userName, time: dayjs().format("YYYY-MM-DD HH:mm:ss") });
    }
    persist();
    if (detailRow.value?.id === row.id) {
      detailRow.value = { ...hit };
    }
@@ -389,7 +382,6 @@
      content: text,
      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    persist();
    if (detailRow.value?.id === row.id) {
      detailRow.value = { ...hit };
    }
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
@@ -46,6 +46,7 @@
    <!-- 新增 / 编辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
@@ -55,102 +56,31 @@
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="regular-apply-form">
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="申请人" prop="applicantName">
              <el-input v-model="form.applicantName" placeholder="请输入申请人" maxlength="50" show-word-limit />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="申请日期" prop="applyDate">
              <el-date-picker
                v-model="form.applyDate"
                type="date"
                placeholder="请选择申请日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="转正日期" prop="regularizationDate">
              <el-date-picker
                v-model="form.regularizationDate"
                type="date"
                placeholder="请选择转正日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col v-if="!form.hasTemplateBinding" :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="countersign">会签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <template v-if="form.hasTemplateBinding">
          <ApprovalTemplateFormSection
            :active-template="form.templateSnapshot"
            :fields="form.formFieldDefs"
            :form-payload="form.formPayload"
            v-model:flow-nodes="form.flowNodes"
            v-model:attachments="form.storageBlobDTOs"
            :template-attachments="form.templateAttachments"
            :user-options="flowUserOptions"
            :allow-change-template="formDialog.mode === 'add'"
            @change-template="reopenTemplateBind"
          />
        </template>
        <el-row v-else :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="试用期工作总结" prop="probationSummary">
              <el-input
                v-model="form.probationSummary"
                type="textarea"
                :rows="4"
                placeholder="请填写试用期工作总结"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row v-if="!form.hasTemplateBinding" :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
                <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="点击选择文件" />
              </div>
            </el-form-item>
          </el-col>
        </el-row>
        <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">
@@ -163,7 +93,9 @@
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.REGULAR"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- 详情(只读) -->
@@ -220,15 +152,15 @@
<script setup>
import { Search } from "@element-plus/icons-vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import ApprovalTemplateFormSection from "../../ApproveManage/approve-shared/components/ApprovalTemplateFormSection.vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
@@ -239,10 +171,6 @@
  applyDate: "",
  regularizationDate: "",
  probationSummary: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
  attachmentList: [],
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
@@ -255,162 +183,6 @@
});
const { proxy } = getCurrentInstance();
/** 审批人树:部门树 + 系统用户(与 staff-archive / user-manage 同源接口) */
const approverTreeData = ref([]);
const approverLabelMap = ref({});
/** 接口返回统一拆成数组(兼容 axios 拦截器已解包为 { data } 或直接数组等情况) */
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 filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return (
    u.deptId ??
    u.sysDeptId ??
    u.dept?.deptId ??
    u.dept?.id ??
    u.dept_id
  );
}
/** 部门树节点主键(若依一般为 id,部分场景为 value) */
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
/** 按部门 id 分组;无部门或 id 为 0 的用户进入未分配列表 */
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
/** 部门节点 id 加前缀,避免与 userId 数值冲突;可选节点为真实 userId 字符串 */
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
async function loadApproverTree() {
  try {
    const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    const users = unwrapArray(userRes);
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
@@ -522,26 +294,10 @@
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const formRules = computed(() => {
  const base = {
    applicantName: [{ required: true, message: "请输入申请人", trigger: "blur" }],
    applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
    regularizationDate: [{ required: true, message: "请选择转正日期", trigger: "change" }],
    probationSummary: [{ required: true, message: "请填写试用期工作总结", trigger: "blur" }],
  };
  if (form.hasTemplateBinding) {
    return { ...base, ...buildFormPayloadRules(form.formFieldDefs) };
  }
  return {
    ...base,
    approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
    approverIds: [
      { type: "array", required: true, message: "请选择审批人", trigger: "change" },
    ],
  };
});
const formRules = computed(() => buildFormPayloadRules(form.formFieldDefs));
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
@@ -587,16 +343,28 @@
}
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 = "新增转正申请";
  loadApproverTree();
  loadFlowUsers();
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
@@ -604,13 +372,17 @@
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 = mode === "add" ? "新增转正申请" : "编辑转正申请";
  loadApproverTree();
  formDialog.title = "编辑转正申请";
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
@@ -619,11 +391,17 @@
      applyDate: row.applyDate,
      regularizationDate: row.regularizationDate,
      probationSummary: row.probationSummary,
      approvalMode: row.approvalMode,
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
      attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
      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?.());
@@ -639,16 +417,27 @@
  } catch {
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  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,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
    attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
    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()}`;
@@ -671,8 +460,29 @@
  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(() => {
  loadApproverTree();
  loadFlowUsers();
});
</script>
@@ -692,9 +502,6 @@
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.upload-block {
  width: 100%;
}
.mr6 {
  margin-right: 6px;
}
@@ -707,6 +514,13 @@
.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;
}
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue
@@ -38,7 +38,7 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增调岗申请</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增调岗申请</el-button>
      </div>
    </div>
    <div class="table_list">
@@ -56,103 +56,49 @@
    <!-- 新增 / 编辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="720px"
      width="960px"
      append-to-body
      destroy-on-close
      class="transfer-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="transfer-apply-form">
      <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>
        </el-form-item>
        <FormPayloadFields :fields="templateDisplayFields" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="转岗日期" prop="transferDate">
              <el-date-picker
                v-model="form.transferDate"
                type="date"
                placeholder="请选择转岗日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <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>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="转入岗位" prop="targetPostId">
              <el-select v-model="form.targetPostId" placeholder="请选择转入岗位" clearable filterable style="width: 100%">
                <el-option
                  v-for="p in targetPostOptions"
                  :key="p.postId"
                  :label="p.postName"
                  :value="p.postId"
                  :disabled="p.status === '1' || p.status === 1"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="countersign">会签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </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">
@@ -161,6 +107,14 @@
        </div>
      </template>
    </el-dialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.TRANSFER"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- 详情 -->
    <el-dialog v-model="detailDialog.visible" title="调岗申请详情" width="560px" append-to-body>
@@ -184,8 +138,19 @@
<script setup>
import { findPostOptions } from "@/api/system/post.js";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.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 FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
import { SELECT_OPTION_SOURCE } from "../../ApproveManage/approve-template/selectOptionSource.js";
const { proxy } = getCurrentInstance();
@@ -199,10 +164,50 @@
  originalPostName: "",
  targetPostId: "",
  targetPostName: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
function isOriginalPostField(field) {
  const label = String(field?.label || "");
  return (
    label.includes("原岗位") ||
    field?.key === "originalPost" ||
    field?.key === "originalPostName" ||
    field?.key === "originalPostId"
  );
}
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 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 = "";
  }
}
/** 系统用户缓存(/system/user/userListNoPageByTenantId,与转正申请等一致) */
const allUsersCache = ref([]);
@@ -322,39 +327,6 @@
  }
}
/** 表单内申请人下拉 */
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
}
function onApplicantChange(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantName = u.nickName || u.userName || "";
    const { originalPostId, originalPostName } = resolveOriginalPost(u);
    form.originalPostId = originalPostId;
    form.originalPostName = originalPostName;
  } else {
    form.applicantName = "";
    form.originalPostId = "";
    form.originalPostName = "";
  }
}
/** 审批人树 */
const approverTreeData = ref([]);
const approverLabelMap = ref({});
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
@@ -363,146 +335,10 @@
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
}
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
async function loadApproverTree() {
  try {
    const needFetchUsers = !allUsersCache.value.length;
    const [deptRes, userRes] = await Promise.all([
      deptTreeSelect(),
      needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null),
    ]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value];
    if (needFetchUsers && users.length) {
      allUsersCache.value = users;
    }
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
@@ -600,15 +436,33 @@
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  transferDate: [{ required: true, message: "请选择转岗日期", trigger: "change" }],
  originalPostName: [{ required: true, message: "原岗位不能为空", trigger: "change" }],
  targetPostId: [{ required: true, message: "请选择转入岗位", trigger: "change" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [{ type: "array", required: true, message: "请选择审批人", trigger: "change" }],
};
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;
  },
  async (uid) => {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    syncApplicantFromUser(uid);
  }
);
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
@@ -638,29 +492,53 @@
  detailDialog.visible = true;
}
function ensureApplicantInFormOptions(row) {
  if (!row?.applicantId) return;
  const id = String(row.applicantId);
  if (!applicantFormOptions.value.some((u) => String(u.userId ?? u.id) === id)) {
    applicantFormOptions.value = [
      {
        userId: row.applicantId,
        nickName: row.applicantName,
        userName: row.applicantUserName,
      },
      ...applicantFormOptions.value,
    ];
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 = mode === "add" ? "新增调岗申请" : "编辑调岗申请";
  loadApproverTree();
  formDialog.title = "编辑调岗申请";
  Object.assign(form, createEmptyForm());
  await remoteSearchApplicantForm("");
  if (mode === "edit" && row) {
    ensureApplicantInFormOptions(row);
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
@@ -670,10 +548,22 @@
      originalPostName: row.originalPostName,
      targetPostId: row.targetPostId,
      targetPostName: row.targetPostName,
      approvalMode: row.approvalMode,
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
      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?.());
@@ -689,7 +579,17 @@
  } catch {
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  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,
@@ -699,9 +599,15 @@
    originalPostName: form.originalPostName,
    targetPostId: form.targetPostId,
    targetPostName: form.targetPostName,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
    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()}`;
@@ -727,10 +633,27 @@
  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);
    }
  }
}
onMounted(async () => {
  await Promise.all([loadUserPool(), loadPostOptions()]);
  rebuildPostIdMap();
  loadApproverTree();
  loadFlowUsers();
  await remoteSearchApplicant("");
});
</script>
@@ -759,4 +682,11 @@
.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
@@ -34,7 +34,7 @@
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增工作交接</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增工作交接</el-button>
      </div>
    </div>
    <div class="table_list">
@@ -52,122 +52,41 @@
    <!-- 新增 / 编辑 -->
    <el-dialog
      v-if="formDialog.visible"
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="720px"
      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="120px" class="work-handover-form">
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="离职日期" prop="leaveDate">
              <el-date-picker
                v-model="form.leaveDate"
                type="date"
                placeholder="请选择离职日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="交接状态" prop="handoverStatus">
              <el-select v-model="form.handoverStatus" placeholder="请选择交接状态" style="width: 100%">
                <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="交接类型" prop="handoverType">
              <el-select v-model="form.handoverType" placeholder="请选择交接类型" style="width: 100%">
                <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="交接人" prop="handoverPersonId">
              <el-select
                v-model="form.handoverPersonId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索交接人"
                style="width: 100%"
                :remote-method="remoteSearchHandoverPerson"
                :loading="handoverPersonSearchLoading"
                @change="onHandoverPersonChange"
              >
                <el-option
                  v-for="u in handoverPersonOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="countersign">会签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
      <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">
@@ -176,6 +95,14 @@
        </div>
      </template>
    </el-dialog>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.WORK_HANDOVER"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- 详情 -->
    <el-dialog v-model="detailDialog.visible" title="工作交接详情" width="560px" append-to-body>
@@ -199,8 +126,18 @@
</template>
<script setup>
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.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 FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  applyBindingToForm,
  buildFormPayloadRules,
  validateTemplateBinding,
} from "../../ApproveManage/approve-shared/approvalTemplateBindingUtils.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
const { proxy } = getCurrentInstance();
@@ -233,9 +170,15 @@
  handoverType: "resignation",
  handoverPersonId: "",
  handoverPersonName: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
  hasTemplateBinding: false,
  templateId: "",
  templateName: "",
  templateSnapshot: null,
  formFieldDefs: [],
  formPayload: {},
  flowNodes: [],
  templateAttachments: [],
  storageBlobDTOs: [],
});
const allUsersCache = ref([]);
@@ -288,49 +231,6 @@
  }
}
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
}
function onApplicantChange(uid) {
  const u = userById(uid);
  form.applicantName = u ? u.nickName || u.userName || "" : "";
}
const handoverPersonSearchLoading = ref(false);
const handoverPersonOptions = ref([]);
async function remoteSearchHandoverPerson(query) {
  handoverPersonSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    handoverPersonOptions.value = filterUsersByQuery(query);
  } finally {
    handoverPersonSearchLoading.value = false;
  }
}
function onHandoverPersonChange(uid) {
  const u = userById(uid);
  form.handoverPersonName = u ? u.nickName || u.userName || "" : "";
}
const approverTreeData = ref([]);
const approverLabelMap = ref({});
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
@@ -338,146 +238,10 @@
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
}
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
async function loadApproverTree() {
  try {
    const needFetchUsers = !allUsersCache.value.length;
    const [deptRes, userRes] = await Promise.all([
      deptTreeSelect(),
      needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null),
    ]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value];
    if (needFetchUsers && users.length) {
      allUsersCache.value = users;
    }
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
@@ -602,16 +366,11 @@
});
const formRef = ref();
const form = reactive(createEmptyForm());
const templateBindVisible = ref(false);
const pendingTemplateBinding = ref(null);
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  leaveDate: [{ required: true, message: "请选择离职日期", trigger: "change" }],
  handoverStatus: [{ required: true, message: "请选择交接状态", trigger: "change" }],
  handoverType: [{ required: true, message: "请选择交接类型", trigger: "change" }],
  handoverPersonId: [{ required: true, message: "请选择交接人", trigger: "change" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [{ type: "array", required: true, message: "请选择审批人", trigger: "change" }],
};
const formRules = computed(() => buildFormPayloadRules(form.formFieldDefs));
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
@@ -642,31 +401,83 @@
  detailDialog.visible = true;
}
function ensureUserInOptions(optionsRef, row, idKey, nameKey) {
  const id = row?.[idKey];
  if (id == null || id === "") return;
  const sid = String(id);
  if (!optionsRef.value.some((u) => String(u.userId ?? u.id) === sid)) {
    optionsRef.value = [
      {
        userId: id,
        nickName: row[nameKey],
        userName: row.applicantUserName,
      },
      ...optionsRef.value,
    ];
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 = mode === "add" ? "新增工作交接" : "编辑工作交接";
  loadApproverTree();
  formDialog.title = "编辑工作交接";
  Object.assign(form, createEmptyForm());
  await Promise.all([remoteSearchApplicantForm(""), remoteSearchHandoverPerson("")]);
  if (mode === "edit" && row) {
    ensureUserInOptions(applicantFormOptions, row, "applicantId", "applicantName");
    ensureUserInOptions(handoverPersonOptions, row, "handoverPersonId", "handoverPersonName");
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
@@ -676,10 +487,19 @@
      handoverType: row.handoverType,
      handoverPersonId: row.handoverPersonId,
      handoverPersonName: row.handoverPersonName,
      approvalMode: row.approvalMode,
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
      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?.());
@@ -695,7 +515,13 @@
  } catch {
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  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,
@@ -704,9 +530,15 @@
    handoverType: form.handoverType,
    handoverPersonId: form.handoverPersonId,
    handoverPersonName: form.handoverPersonName,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
    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()}`;
@@ -734,7 +566,7 @@
onMounted(async () => {
  await loadUserPool();
  loadApproverTree();
  loadFlowUsers();
  await remoteSearchApplicant("");
});
</script>
@@ -763,4 +595,11 @@
.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>
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js
@@ -95,25 +95,19 @@
  return [];
}
/** 不再使用前端本地缓存,列表数据以接口为准;并清除历史 localStorage 数据 */
export function loadStoredNotices() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const data = JSON.parse(raw);
    return Array.isArray(data) ? data : null;
  } catch {
    return null;
  }
}
export function saveStoredNotices(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
    localStorage.removeItem(STORAGE_KEY);
  } catch {
    /* ignore */
  }
  return [];
}
/** @deprecated 保留空实现,避免旧调用报错;不做任何持久化 */
export function saveStoredNotices() {}
export function nextNoticeNo() {
  return `NA${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
}
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
@@ -1,7 +1,7 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import { computed, onMounted, reactive, ref, watch } from "vue";
import {
  NOTICE_TYPE_OPTIONS,
  PRIORITY_OPTIONS,
@@ -10,7 +10,6 @@
  DEPT_OPTIONS,
  createEmptyForm,
  loadStoredNotices,
  saveStoredNotices,
  nextNoticeNo,
  validateNoticeForm,
  noticeTypeLabel,
@@ -20,8 +19,7 @@
} from "./noticeAnnouncementUtils.js";
export function useNoticeAnnouncement() {
  const stored = loadStoredNotices();
  const allRows = ref(stored?.length ? stored : []);
  const allRows = ref([]);
  const searchForm = reactive({
    keyword: "",
@@ -151,9 +149,9 @@
    },
  ]);
  function persist() {
    saveStoredNotices(allRows.value);
  }
  onMounted(() => {
    loadStoredNotices();
  });
  function handleQuery() {
    tableLoading.value = true;
@@ -239,7 +237,6 @@
      }
      allRows.value[idx] = { ...prev, ...payload };
    }
    persist();
    formDialog.visible = false;
    return { ok: true };
  }
@@ -258,7 +255,6 @@
      hit.publishTime = now;
      hit.updateTime = now;
      if (hit.noticeType === "emergency") hit.priority = "urgent";
      persist();
      return true;
    } catch {
      return false;
@@ -276,7 +272,6 @@
      if (!hit) return;
      hit.publishStatus = "withdrawn";
      hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
      persist();
      return true;
    } catch {
      return false;
@@ -291,7 +286,6 @@
        cancelButtonText: "取消",
      });
      allRows.value = allRows.value.filter((r) => r.id !== row.id);
      persist();
      return true;
    } catch {
      return false;