已添加5个文件
已修改18个文件
已删除3个文件
2862 ■■■■■ 文件已修改
src/api/officeProcessAutomation/finReimbursement.js 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 2 ●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 103 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 102 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue 93 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 24 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 27 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue 255 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js 109 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js 328 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue 5 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js 281 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue 70 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js 152 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js 623 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js 124 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue 7 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue 15 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js 7 ●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js 275 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/officeProcessAutomation/finReimbursement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
import request from "@/utils/request";
/** åˆ†é¡µæŸ¥è¯¢è´¢åŠ¡æŠ¥é”€ GET /finReimbursement/listPage */
export function listFinReimbursementPage(params) {
  return request({
    url: "/finReimbursement/listPage",
    method: "get",
    params,
  });
}
/** è¯¦æƒ… query:Spring ç»‘定 finReimbursementDto.id,勿用 finReimbursementDto[id] */
function buildFinReimbursementDetailParams(idOrDto) {
  const raw =
    typeof idOrDto === "object" && idOrDto !== null
      ? idOrDto.id ?? idOrDto.reimbursementId
      : idOrDto;
  return {
    "finReimbursementDto.id": raw,
    id: raw,
  };
}
/** æŸ¥è¯¢è´¢åŠ¡æŠ¥é”€è¯¦æƒ… GET /finReimbursement/detail */
export function getFinReimbursementDetail(idOrDto) {
  return request({
    url: "/finReimbursement/detail",
    method: "get",
    params: buildFinReimbursementDetailParams(idOrDto),
  });
}
/** æ–°å¢žè´¢åŠ¡æŠ¥é”€ POST /finReimbursement/save */
export function saveFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/save",
    method: "post",
    data: finReimbursementDto,
  });
}
/** ä¿®æ”¹è´¢åŠ¡æŠ¥é”€ POST /finReimbursement/update */
export function updateFinReimbursement(finReimbursementDto) {
  return request({
    url: "/finReimbursement/update",
    method: "post",
    data: finReimbursementDto,
  });
}
/** åˆ é™¤è´¢åŠ¡æŠ¥é”€ DELETE /finReimbursement/delete(body ä¸º ID æ•°ç»„) */
export function deleteFinReimbursement(ids) {
  const idList = (Array.isArray(ids) ? ids : [ids]).filter(
    (id) => id != null && id !== ""
  );
  return request({
    url: "/finReimbursement/delete",
    method: "delete",
    data: idList,
  });
}
/** æ–°å¢žèµ° save,修改走 update(与接口文档一致) */
export function persistFinReimbursement(finReimbursementDto, isEdit = false) {
  if (isEdit) {
    return updateFinReimbursement(finReimbursementDto);
  }
  const payload = { ...finReimbursementDto };
  delete payload.id;
  return saveFinReimbursement(payload);
}
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
@@ -475,7 +475,7 @@
    applicantId: row.applicantId,
    applicantNo: row.applicantId != null ? String(row.applicantId) : "",
    applicantName: row.applicantName || "",
    approvalType: row.templateName || "",
    approvalType: row.approvalType || row.templateName || "",
    unread: Boolean(row.isApprove) && approvalStatus === "pending",
    isApprove: Boolean(row.isApprove),
    approvalStatus,
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
@@ -223,6 +223,64 @@
      </template>
    </el-dialog>
    <!-- å·®æ—…/费用报销详情(审批列表) -->
    <el-dialog
      v-model="reimburseDialog.visible"
      :title="reimburseDialog.mode === 'approve' ? reimburseApproveTitle : reimburseDetailTitle"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <FinReimburseApprovePanel
        :mode="reimburseDialog.mode"
        :module-key="reimburseDialog.moduleKey"
        :reimburse-row="reimburseDialog.reimburseRow"
        :loading="reimburseDialog.loading"
        v-model:approve-opinion="approveOpinion"
      />
      <template #footer>
        <template v-if="reimburseDialog.mode === 'approve'">
          <el-button
            type="success"
            :loading="approveSubmitting"
            @click="onReimburseApprove('approved')"
          >
            é€š è¿‡
          </el-button>
          <el-button
            type="danger"
            :loading="approveSubmitting"
            @click="onReimburseApprove('rejected')"
          >
            é©³ å›ž
          </el-button>
          <el-button :disabled="approveSubmitting" @click="reimburseDialog.visible = false">
            å– æ¶ˆ
          </el-button>
        </template>
        <template v-else>
          <el-button
            v-if="reimburseDialog.instanceRow?.approvalStatus === 'pending'"
            @click="openEditFromReimburseDetail"
          >
            ä¿® æ”¹
          </el-button>
          <el-button
            v-if="
              reimburseDialog.instanceRow?.approvalStatus === 'pending' &&
              reimburseDialog.instanceRow?.isApprove
            "
            type="primary"
            @click="openReimburseApproveFromDetail"
          >
            åŽ»å®¡æ‰¹
          </el-button>
          <el-button type="primary" @click="reimburseDialog.visible = false">关 é—­</el-button>
        </template>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹æ“ä½œ -->
    <el-dialog
      v-model="approveDialog.visible"
@@ -277,7 +335,9 @@
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { computed, onMounted, ref } from "vue";
import { APPROVAL_MODULE_KEYS } from "../approve-shared/approvalModuleRegistry.js";
import FinReimburseApprovePanel from "../../ReimburseManage/shared/components/FinReimburseApprovePanel.vue";
import ApprovalTemplateFormSection from "../approve-shared/components/ApprovalTemplateFormSection.vue";
import ApprovalTemplatePicker from "../approve-shared/components/ApprovalTemplatePicker.vue";
import { useFlowUserOptions } from "../approve-shared/useFlowUserOptions.js";
@@ -309,9 +369,11 @@
  tableColumn,
  detailDialog,
  detailRow,
  reimburseDialog,
  approveDialog,
  approveOpinion,
  approveSubmitting,
  submitReimburseApprove,
  submitDialog,
  isSubmitEdit,
  submitDialogTitle,
@@ -342,8 +404,30 @@
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "审批已提交");
}
const reimburseDetailTitle = computed(() =>
  reimburseDialog.moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
    ? "费用报销详情"
    : "差旅报销详情"
);
const reimburseApproveTitle = computed(() =>
  reimburseDialog.moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
    ? "费用报销审批"
    : "差旅报销审批"
);
async function onApprove(result) {
  const ret = await submitApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
  }
  if (ret?.ok) {
    ElMessage.success(result === "approved" ? "已通过" : "已驳回");
  }
}
async function onReimburseApprove(result) {
  const ret = await submitReimburseApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
@@ -357,10 +441,10 @@
  return formatDisplayTime(time) || "—";
}
function openApproveFromDetail() {
async function openApproveFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openApprove(row);
  await openApprove(row);
}
function openEditFromDetail() {
@@ -369,6 +453,19 @@
  openEditDialog(row);
}
function openEditFromReimburseDetail() {
  const row = reimburseDialog.instanceRow;
  reimburseDialog.visible = false;
  if (row) openEditDialog(row);
}
async function openReimburseApproveFromDetail() {
  const row = reimburseDialog.instanceRow;
  if (!row) return;
  reimburseDialog.mode = "approve";
  approveOpinion.value = "";
}
onMounted(() => {
  loadFlowUsers();
  loadSearchBusinessTypeOptions();
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
@@ -13,7 +13,13 @@
import useUserStore from "@/store/modules/user";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { computed, reactive, ref } from "vue";
import { computed, getCurrentInstance, reactive, ref } from "vue";
import {
  inferReimburseModuleKeyFromInstance,
  loadReimburseDetailForInstance,
  navigateToReimburseManageForEdit,
  resolveFinReimbursementIdFromInstance,
} from "../../ReimburseManage/shared/reimburseApproveBridge.js";
import {
  fetchBusinessTypeOptions,
  formatDisplayTime,
@@ -43,6 +49,7 @@
} from "./approveListConstants.js";
export function useApproveList() {
  const { proxy } = getCurrentInstance() || {};
  const userStore = useUserStore();
  const tableData = ref([]);
@@ -74,6 +81,16 @@
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const approveSubmitting = ref(false);
  /** å·®æ—…/费用报销专用详情、审批弹窗 */
  const reimburseDialog = reactive({
    visible: false,
    mode: "detail",
    moduleKey: "",
    loading: false,
    reimburseRow: {},
    instanceRow: null,
  });
  const submitDialog = reactive({ visible: false, step: 1, mode: "add" });
  const submitEditRow = ref(null);
@@ -242,15 +259,52 @@
    fetchApprovalList();
  }
  function openDetail(row) {
  async function openReimburseDetail(row, mode) {
    const moduleKey = inferReimburseModuleKeyFromInstance(row);
    if (!moduleKey) return false;
    reimburseDialog.mode = mode;
    reimburseDialog.moduleKey = moduleKey;
    reimburseDialog.instanceRow = row;
    reimburseDialog.visible = true;
    reimburseDialog.loading = true;
    reimburseDialog.reimburseRow = {};
    try {
      const { reimburseRow, moduleKey: resolvedMk } =
        await loadReimburseDetailForInstance(row, moduleKey);
      reimburseDialog.moduleKey = resolvedMk || moduleKey;
      reimburseDialog.reimburseRow = reimburseRow;
      return true;
    } catch {
      ElMessage.error("加载报销详情失败");
      reimburseDialog.visible = false;
      return false;
    } finally {
      reimburseDialog.loading = false;
    }
  }
  async function openDetail(row) {
    if (isReimburseApprovalInstance(row)) {
      await openReimburseDetail(row, "detail");
      return;
    }
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openApprove(row) {
  async function openApprove(row) {
    if (inferReimburseModuleKeyFromInstance(row)) {
      approveOpinion.value = "";
      await openReimburseDetail(row, "approve");
      return;
    }
    approveDialog.row = { ...row };
    approveOpinion.value = "";
    approveDialog.visible = true;
  }
  function isReimburseApprovalInstance(row) {
    return Boolean(inferReimburseModuleKeyFromInstance(row));
  }
  function resetSubmitDialogState() {
@@ -267,9 +321,23 @@
    loadSubmitTemplates();
  }
  function openEditDialog(row) {
  async function openEditDialog(row) {
    if (row?.approvalStatus !== "pending") {
      ElMessage.warning("仅审核中的审批可修改");
      return;
    }
    const moduleKey = inferReimburseModuleKeyFromInstance(row);
    if (moduleKey) {
      const rid = resolveFinReimbursementIdFromInstance(row);
      if (rid == null) {
        ElMessage.warning("无法修改:缺少报销单 ID");
        return;
      }
      try {
        await navigateToReimburseManageForEdit(proxy?.$router, moduleKey, rid);
      } catch {
        ElMessage.warning("未找到差旅/费用报销菜单路由,请从左侧菜单进入后再编辑");
      }
      return;
    }
    if (!row?.id) {
@@ -444,6 +512,29 @@
    }
  }
  async function submitReimburseApprove(result) {
    const row = reimburseDialog.instanceRow;
    if (!row?.id) return { ok: false };
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      return { needOpinion: true };
    }
    if (approveSubmitting.value) return { ok: false };
    approveSubmitting.value = true;
    try {
      await approveApprovalInstance(
        buildApproveInstanceDto(row, result, approveOpinion.value)
      );
      reimburseDialog.visible = false;
      await fetchApprovalList();
      return { ok: true, result };
    } catch {
      ElMessage.error("审批操作失败");
      return { ok: false };
    } finally {
      approveSubmitting.value = false;
    }
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row?.id) return { ok: false };
@@ -495,9 +586,12 @@
    tableColumn,
    detailDialog,
    detailRow,
    reimburseDialog,
    approveDialog,
    approveOpinion,
    approveSubmitting,
    submitReimburseApprove,
    isReimburseApprovalInstance,
    submitDialog,
    isSubmitEdit,
    submitDialogTitle,
src/views/officeProcessAutomation/ApproveManage/approve-shared/approvalModuleRegistry.js
@@ -1,12 +1,8 @@
import { matchBusinessTypeValue } from "../approve-list/approveListConstants.js";
/**
 * å„业务模块与审批模板类型的映射(配置化入口) *
 * ä½¿ç”¨æ–¹å¼ï¼š
 * 1. åœ¨ä¸šåС页引入 ApprovalTemplateBindDialog,传入 moduleKey
 * 2. æˆ–在表单内嵌 ApprovalTemplateFormSection + useApprovalTemplateBinding({ moduleKey })
 *
 * businessType:若后端 TypeEnums å·²å›ºå®š code,可直接写死 value;否则用 typeLabels æŒ‰åç§°åŒ¹é…
 * å„业务模块与审批模板类型的映射(配置化入口)
 * businessType ä¸ŽåŽç«¯ TypeEnums / listPage çº¦å®šä¸€è‡´ï¼ˆå†™æ­»æžšä¸¾å€¼ï¼‰
 */
export const APPROVAL_MODULE_KEYS = {
  REGULAR: "regular",
@@ -20,16 +16,30 @@
  ENTERPRISE_NEWS: "enterprise_news",
};
/** å®¡æ‰¹å®žä¾‹ listPage / ä¿å­˜ ä½¿ç”¨çš„ businessType æžšä¸¾ */
export const APPROVAL_BUSINESS_TYPE = {
  [APPROVAL_MODULE_KEYS.REGULAR]: 10,
  [APPROVAL_MODULE_KEYS.TRANSFER]: 11,
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: 13,
  [APPROVAL_MODULE_KEYS.LEAVE]: 14,
  [APPROVAL_MODULE_KEYS.OVERTIME]: 15,
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: 16,
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: 17,
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: 18,
};
/** @type {Record<string, import('./approvalModuleRegistry.js').ApprovalModuleConfig>} */
export const APPROVAL_MODULE_REGISTRY = {
  [APPROVAL_MODULE_KEYS.REGULAR]: {
    label: "转正申请",
    approvalType: "regular",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.REGULAR],
    typeLabels: ["转正", "转正申请"],
  },
  [APPROVAL_MODULE_KEYS.TRANSFER]: {
    label: "调岗申请",
    approvalType: "transfer",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRANSFER],
    typeLabels: ["调岗", "调动", "调岗申请", "调动申请"],
  },
  [APPROVAL_MODULE_KEYS.RESIGN]: {
@@ -40,31 +50,37 @@
  [APPROVAL_MODULE_KEYS.WORK_HANDOVER]: {
    label: "工作交接",
    approvalType: "work_handover",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.WORK_HANDOVER],
    typeLabels: ["工作交接", "交接", "工作交接审批"],
  },
  [APPROVAL_MODULE_KEYS.LEAVE]: {
    label: "请假申请",
    approvalType: "leave",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.LEAVE],
    typeLabels: ["请假", "请假申请", "请假审批"],
  },
  [APPROVAL_MODULE_KEYS.OVERTIME]: {
    label: "加班申请",
    approvalType: "overtime",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.OVERTIME],
    typeLabels: ["加班", "加班申请", "加班审批"],
  },
  [APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE]: {
    label: "差旅报销",
    approvalType: "travel_reimburse",
    typeLabels: ["差旅", "差旅报销"],
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE],
    typeLabels: ["差旅", "差旅报销", "出差报销"],
  },
  [APPROVAL_MODULE_KEYS.COST_REIMBURSE]: {
    label: "费用报销",
    approvalType: "cost_reimburse",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.COST_REIMBURSE],
    typeLabels: ["费用", "费用报销"],
  },
  [APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS]: {
    label: "企业新闻",
    approvalType: "enterprise_news",
    businessType: APPROVAL_BUSINESS_TYPE[APPROVAL_MODULE_KEYS.ENTERPRISE_NEWS],
    typeLabels: ["企业新闻", "新闻", "新闻发布"],
  },
};
@@ -72,9 +88,9 @@
/**
 * @typedef {object} ApprovalModuleConfig
 * @property {string} label
 * @property {string} [approvalType] åˆ—表样式用
 * @property {string|number} [businessType] ä¸Ž TypeEnums value ä¸€è‡´æ—¶å¯å†™æ­»
 * @property {string[]} [typeLabels] ä¸Ž TypeEnums label æ¨¡ç³ŠåŒ¹é…
 * @property {string} [approvalType]
 * @property {string|number} [businessType]
 * @property {string[]} [typeLabels]
 */
export function getApprovalModuleConfig(moduleKey) {
@@ -82,23 +98,25 @@
  return APPROVAL_MODULE_REGISTRY[moduleKey] || null;
}
/** åˆ—表查询默认 businessType(与审批列表 listPage çº¦å®šä¸€è‡´ï¼‰ */
/** åˆ—表查询 businessType(优先配置枚举,不再回退 approvalType å­—符串) */
export function getModuleListBusinessType(moduleKey) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return "";
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  return cfg.approvalType || "";
  return APPROVAL_BUSINESS_TYPE[moduleKey] ?? "";
}
/** ä»Ž TypeEnums é€‰é¡¹ä¸­è§£æžæœ¬æ¨¡å—çš„ businessType(与审批列表下拉一致) */
/** ä»Ž TypeEnums è§£æžæœ¬æ¨¡å— businessType;已配置枚举时直接返回 */
export function resolveModuleBusinessType(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return null;
  if (cfg.businessType != null && cfg.businessType !== "") return cfg.businessType;
  const fixed = getModuleListBusinessType(moduleKey);
  if (fixed != null && fixed !== "") return fixed;
  const labels = [cfg.label, ...(cfg.typeLabels || [])].filter(Boolean);
  const hitByLabel = (typeOptions || []).find((opt) => {
    const optLabel = String(opt?.label || "").trim();
    const optLabel = String(opt?.label || opt?.name || "").trim();
    if (!optLabel) return false;
    return labels.some(
      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
@@ -106,31 +124,24 @@
  });
  if (hitByLabel?.value != null && hitByLabel.value !== "") return hitByLabel.value;
  if (cfg.approvalType) {
    const hitByValue = (typeOptions || []).find(
      (opt) =>
        matchBusinessTypeValue(opt?.value, cfg.approvalType) ||
        matchBusinessTypeValue(opt?.code, cfg.approvalType)
    );
    if (hitByValue?.value != null && hitByValue.value !== "") return hitByValue.value;
  }
  return cfg.approvalType || null;
}
/** æ”¶é›†ä¸Žæ¨¡å—相关的全部 businessType å–值(枚举值 + approvalType),用于模板列表过滤 */
/** åˆ—表/模板过滤用的 businessType é›†åˆ */
export function getModuleMatchingBusinessTypes(moduleKey, typeOptions = []) {
  const cfg = getApprovalModuleConfig(moduleKey);
  if (!cfg) return [];
  const fixed = getModuleListBusinessType(moduleKey);
  if (fixed != null && fixed !== "") return [fixed];
  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();
    const optLabel = String(opt?.label || opt?.name || "").trim();
    if (!optLabel) continue;
    const matched = labels.some(
      (l) => optLabel === l || optLabel.includes(l) || l.includes(optLabel)
src/views/officeProcessAutomation/ApproveManage/approve-shared/useApprovalInstanceModule.js
@@ -57,11 +57,13 @@
  const moduleConfig = computed(() => getApprovalModuleConfig(moduleKey));
  const businessTypeOptions = ref([]);
  /** ä¸Žå®¡æ‰¹åˆ—表一致:优先用 TypeEnums çš„ value,匹配不到再回退 approvalType */
  /** åˆ—表查询 businessType:优先 registry å†™æ­»æžšä¸¾ï¼Œå†å›žé€€ TypeEnums */
  const defaultListBusinessType = computed(() => {
    const fixed = getModuleListBusinessType(moduleKey);
    if (fixed != null && fixed !== "") return fixed;
    const resolved = resolveModuleBusinessType(moduleKey, businessTypeOptions.value);
    if (resolved != null && resolved !== "") return resolved;
    return getModuleListBusinessType(moduleKey);
    return "";
  });
  async function loadBusinessTypeOptions() {
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
@@ -1,6 +1,9 @@
import dayjs from "dayjs";
import { getTypeEnums } from "@/api/basicData/enum.js";
import { TEMPLATE_TYPE_CUSTOM } from "@/api/officeProcessAutomation/approvalTemplate.js";
import {
  TEMPLATE_TYPE_BUILTIN,
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { APPROVAL_TYPE_OPTIONS } from "../approve-list/approveListConstants.js";
import {
  buildFormConfigJson,
@@ -42,6 +45,11 @@
  } catch {
    return [];
  }
}
/** æ˜¯å¦ä¸ºç³»ç»Ÿå†…置模板(templateType === 0) */
export function isBuiltinTemplate(row) {
  return Number(row?.templateType) === TEMPLATE_TYPE_BUILTIN;
}
/** èŠ‚ç‚¹å†…å®¡æ‰¹æ–¹å¼ï¼šä¼šç­¾ / æˆ–ç­¾ */
@@ -222,7 +230,8 @@
    templateName: (form.templateName || "").trim(),
    description: (form.description || "").trim(),
    enabled: mapEnabledToApi(form.enabled),
    templateType: TEMPLATE_TYPE_CUSTOM,
    templateType:
      form.templateType != null ? Number(form.templateType) : TEMPLATE_TYPE_CUSTOM,
    businessType: form.businessType ?? "",
    formConfig: buildFormConfigJson(form.formConfigData),
    nodes: nodes.map((n, i) => {
@@ -254,13 +263,10 @@
  return dto;
}
export function buildApprovalTemplateListParams({ page, searchForm, templateType = TEMPLATE_TYPE_CUSTOM }) {
export function buildApprovalTemplateListParams({ page, searchForm }) {
  const params = {
    current: page.current,
    size: page.size,
    templateType: searchForm?.templateType != null && searchForm.templateType !== ""
      ? searchForm.templateType
      : templateType,
  };
  const kw = (searchForm?.keyword || "").trim();
  if (kw) params.templateName = kw;
@@ -290,6 +296,7 @@
    templateName: "",
    description: "",
    templateType: TEMPLATE_TYPE_CUSTOM,
    lockedFormFieldUids: [],
    businessType: "",
    formConfig: "",
    formConfigData: createEmptyFormConfigData(),
src/views/officeProcessAutomation/ApproveManage/approve-template/components/FormConfigEditor.vue
@@ -21,8 +21,15 @@
          </el-tag>
        </div>
        <div class="fce-toolbar-actions">
          <el-dropdown trigger="click" @visible-change="onImportDropdownVisible" @command="importFromTemplate">
            <el-button size="small" :loading="templateImportLoading">从已有模板导入</el-button>
          <el-dropdown
            trigger="click"
            :disabled="disableImport"
            @visible-change="onImportDropdownVisible"
            @command="importFromTemplate"
          >
            <el-button size="small" :loading="templateImportLoading" :disabled="disableImport">
              ä»Žå·²æœ‰æ¨¡æ¿å¯¼å…¥
            </el-button>
            <template #dropdown>
              <el-dropdown-menu>
                <el-dropdown-item v-if="!templateImportOptions.length" disabled>
@@ -55,7 +62,10 @@
          v-for="(field, index) in inner.fields"
          :key="field._uid"
          class="fce-card"
          :class="{ 'fce-card--required': field.required }"
          :class="{
            'fce-card--required': field.required,
            'fce-card--locked': isFieldLocked(field),
          }"
        >
          <div class="fce-card-badge">{{ index + 1 }}</div>
@@ -64,8 +74,9 @@
              <span class="fce-card-name">{{ field.label || `填报项 ${index + 1}` }}</span>
              <el-tag size="small" effect="light" type="primary">{{ typeLabel(field.type) }}</el-tag>
              <el-tag v-if="field.required" size="small" type="danger" effect="plain">必填</el-tag>
              <el-tag v-if="isFieldLocked(field)" size="small" type="info" effect="plain">内置项</el-tag>
            </div>
            <div class="fce-card-btns">
            <div v-if="!isFieldLocked(field)" class="fce-card-btns">
              <el-tooltip content="上移" placement="top">
                <el-button circle size="small" :disabled="index === 0" @click="moveField(index, -1)">
                  <el-icon><Top /></el-icon>
@@ -98,18 +109,30 @@
                    v-model="field.label"
                    placeholder="如:报销说明"
                    maxlength="50"
                    :disabled="isFieldLocked(field)"
                    @input="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item label="字段标识" required class="fce-field-item">
                  <el-input v-model="field.key" placeholder="如:summary" maxlength="50" @input="emitOut" />
                  <el-input
                    v-model="field.key"
                    placeholder="如:summary"
                    maxlength="50"
                    :disabled="isFieldLocked(field)"
                    @input="emitOut"
                  />
                </el-form-item>
              </el-col>
              <el-col :span="8">
                <el-form-item label="控件类型" class="fce-field-item">
                  <el-select v-model="field.type" style="width: 100%" @change="onTypeChange(field)">
                  <el-select
                    v-model="field.type"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="onTypeChange(field)"
                  >
                    <el-option
                      v-for="t in FORM_FIELD_TYPE_OPTIONS"
                      :key="t.value"
@@ -132,6 +155,7 @@
                    inline-prompt
                    active-text="必填"
                    inactive-text="选填"
                    :disabled="isFieldLocked(field)"
                    @change="emitOut"
                  />
                </el-form-item>
@@ -144,6 +168,7 @@
                    :max="10"
                    controls-position="right"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="emitOut"
                  />
                </el-form-item>
@@ -155,6 +180,7 @@
                      v-model="field.min"
                      controls-position="right"
                      style="width: 100%"
                      :disabled="isFieldLocked(field)"
                      @change="emitOut"
                    />
                  </el-form-item>
@@ -167,6 +193,7 @@
                      :max="4"
                      controls-position="right"
                      style="width: 100%"
                      :disabled="isFieldLocked(field)"
                      @change="emitOut"
                    />
                  </el-form-item>
@@ -184,6 +211,7 @@
              :type="field.type === 'textarea' ? 'textarea' : 'text'"
              :rows="field.type === 'textarea' ? 2 : undefined"
              :placeholder="defaultPlaceholder(field)"
              :disabled="isFieldLocked(field)"
              clearable
              @input="emitOut"
            />
@@ -195,6 +223,7 @@
              controls-position="right"
              placeholder="选填"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              @change="emitOut"
            />
            <el-date-picker
@@ -205,6 +234,7 @@
              format="YYYY-MM-DD"
              value-format="YYYY-MM-DD"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              clearable
              @change="emitOut"
            />
@@ -218,6 +248,7 @@
              format="YYYY-MM-DD HH:mm:ss"
              value-format="YYYY-MM-DD HH:mm:ss"
              style="width: 100%"
              :disabled="isFieldLocked(field)"
              clearable
              @change="emitOut"
            />
@@ -229,6 +260,7 @@
              clearable
              filterable
              :loading="optionSourceLoading"
              :disabled="isFieldLocked(field)"
              @change="emitOut"
            >
              <el-option
@@ -248,6 +280,7 @@
                  <el-select
                    v-model="field.optionSource"
                    style="width: 100%"
                    :disabled="isFieldLocked(field)"
                    @change="onOptionSourceChange(field)"
                  >
                    <el-option
@@ -266,7 +299,14 @@
            <template v-if="!isDynamicOptionSource(field.optionSource)">
              <div class="fce-options-head">
                <span class="fce-section-subtitle">手动选项</span>
                <el-button type="primary" link size="small" :icon="Plus" @click="addOption(field)">
                <el-button
                  type="primary"
                  link
                  size="small"
                  :icon="Plus"
                  :disabled="isFieldLocked(field)"
                  @click="addOption(field)"
                >
                  æ·»åР选项
                </el-button>
              </div>
@@ -276,13 +316,24 @@
                class="fce-option-row"
              >
                <span class="fce-option-index">{{ oi + 1 }}</span>
                <el-input v-model="opt.label" placeholder="显示文本" @input="emitOut" />
                <el-input v-model="opt.value" placeholder="选项值" class="fce-option-value" @input="emitOut" />
                <el-input
                  v-model="opt.label"
                  placeholder="显示文本"
                  :disabled="isFieldLocked(field)"
                  @input="emitOut"
                />
                <el-input
                  v-model="opt.value"
                  placeholder="选项值"
                  class="fce-option-value"
                  :disabled="isFieldLocked(field)"
                  @input="emitOut"
                />
                <el-button
                  type="danger"
                  link
                  :icon="Delete"
                  :disabled="field.options.length <= 1"
                  :disabled="isFieldLocked(field) || field.options.length <= 1"
                  @click="removeOption(field, oi)"
                />
              </div>
@@ -303,7 +354,7 @@
  TEMPLATE_TYPE_CUSTOM,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { ElMessage, ElMessageBox } from "element-plus";
import { reactive, ref, watch } from "vue";
import { computed, reactive, ref, watch } from "vue";
import {
  mapEnabledFromApi,
  unwrapTemplateDetail,
@@ -327,6 +378,10 @@
  modelValue: { type: Object, default: () => createEmptyFormConfigData() },
  /** ç¼–辑当前模板时排除自身,避免从自己导入 */
  excludeTemplateId: { type: [String, Number], default: null },
  /** ç¦ç”¨ã€Œä»Žå·²æœ‰æ¨¡æ¿å¯¼å…¥ã€ */
  disableImport: { type: Boolean, default: false },
  /** ç³»ç»Ÿå†…置模板编辑时,打开弹窗即存在的填报项 _uid,不可改删 */
  lockedFieldUids: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue"]);
@@ -337,6 +392,14 @@
const templateImportOptions = ref([]);
const templateImportLoading = ref(false);
const lockedUidSet = computed(
  () => new Set((props.lockedFieldUids || []).filter(Boolean))
);
function isFieldLocked(field) {
  return field?._uid != null && lockedUidSet.value.has(field._uid);
}
function typeLabel(type) {
  return formFieldTypeLabel(type);
@@ -408,13 +471,16 @@
}
function removeField(index) {
  if (isFieldLocked(inner.fields[index])) return;
  inner.fields.splice(index, 1);
  emitOut();
}
function moveField(index, delta) {
  if (isFieldLocked(inner.fields[index])) return;
  const next = index + delta;
  if (next < 0 || next >= inner.fields.length) return;
  if (isFieldLocked(inner.fields[next])) return;
  const t = inner.fields[index];
  inner.fields[index] = inner.fields[next];
  inner.fields[next] = t;
@@ -486,6 +552,7 @@
}
function onImportDropdownVisible(visible) {
  if (props.disableImport) return;
  if (visible) loadTemplateImportOptions();
}
@@ -610,6 +677,10 @@
  border-left: 3px solid var(--el-color-danger-light-3);
}
.fce-card--locked {
  background: var(--el-fill-color-light);
}
.fce-card-badge {
  position: absolute;
  top: -10px;
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
@@ -114,7 +114,13 @@
            <el-form-item label="模板名称" prop="templateName">
              <el-input v-model="form.templateName" placeholder="如:项目立项审批" maxlength="50" show-word-limit />
              <el-input
                v-model="form.templateName"
                placeholder="如:项目立项审批"
                maxlength="50"
                show-word-limit
                :disabled="isEditingBuiltin"
              />
            </el-form-item>
@@ -124,7 +130,12 @@
            <el-form-item label="模板类型" prop="businessType">
              <el-select v-model="form.businessType" placeholder="请选择" style="width: 100%">
              <el-select
                v-model="form.businessType"
                placeholder="请选择"
                style="width: 100%"
                :disabled="isEditingBuiltin"
              >
                <el-option
@@ -178,7 +189,12 @@
        <el-form-item label="填报配置">
          <FormConfigEditor v-model="form.formConfigData" :exclude-template-id="form.id" />
          <FormConfigEditor
            v-model="form.formConfigData"
            :exclude-template-id="form.id"
            :disable-import="isEditingBuiltin"
            :locked-field-uids="isEditingBuiltin ? form.lockedFormFieldUids : []"
          />
          <p class="flow-tip">配置提交审批时需填写的表单项,保存后写入 formConfig(JSON)。</p>
@@ -459,6 +475,8 @@
  formRules,
  isEditingBuiltin,
  detailDialog,
  detailRow,
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
@@ -3,17 +3,18 @@
  deleteApprovalTemplate,
  getApprovalTemplateDetail,
  listApprovalTemplatePage,
  TEMPLATE_TYPE_CUSTOM,
  TEMPLATE_TYPE_BUILTIN,
  updateApprovalTemplate,
} from "@/api/officeProcessAutomation/approvalTemplate.js";
import { Search } from "@element-plus/icons-vue";
import { ElMessage, ElMessageBox } from "element-plus";
import { reactive, ref } from "vue";
import { computed, reactive, ref } from "vue";
import {
  buildApprovalTemplateListParams,
  createEmptyTemplateForm,
  fetchBusinessTypeOptions,
  flowNodesSummary,
  isBuiltinTemplate,
  mapTemplateFromApi,
  mapTemplateToApi,
  nodeSignModeLabel,
@@ -58,6 +59,10 @@
  const formDialog = reactive({ visible: false, title: "", mode: "add" });
  const form = reactive(createEmptyTemplateForm());
  const formRef = ref();
  const isEditingBuiltin = computed(
    () => formDialog.mode === "edit" && Number(form.templateType) === TEMPLATE_TYPE_BUILTIN
  );
  async function loadTemplateTypeOptions() {
    try {
@@ -140,6 +145,7 @@
          name: "删除",
          type: "danger",
          link: true,
          disabled: (row) => isBuiltinTemplate(row),
          clickFun: (row) => removeTemplate(row),
        },
      ],
@@ -186,16 +192,22 @@
      Object.assign(form, base);
      return;
    }
    const formConfigData = JSON.parse(
      JSON.stringify(row.formConfigData || parseFormConfigToData(row.formConfig))
    );
    const builtin = isBuiltinTemplate(row);
    Object.assign(form, {
      ...base,
      id: row.id,
      templateName: row.templateName || "",
      description: row.description || "",
      templateType: row.templateType != null ? Number(row.templateType) : base.templateType,
      businessType: row.businessType ?? "",
      formConfig: row.formConfig || "",
      formConfigData: JSON.parse(
        JSON.stringify(row.formConfigData || parseFormConfigToData(row.formConfig))
      ),
      formConfigData,
      lockedFormFieldUids: builtin
        ? (formConfigData.fields || []).map((f) => f._uid).filter(Boolean)
        : [],
      enabled: row.enabled !== false,
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
      storageBlobDTOs: JSON.parse(JSON.stringify(row.storageBlobDTOs || [])),
@@ -258,6 +270,10 @@
  }
  async function removeTemplate(row) {
    if (isBuiltinTemplate(row)) {
      ElMessage.warning("系统内置模板不允许删除");
      return;
    }
    if (row?.id == null || row.id === "") {
      ElMessage.warning("无法删除:缺少模板 ID");
      return;
@@ -304,6 +320,7 @@
    form,
    formRef,
    formRules,
    isEditingBuiltin,
    detailDialog,
    detailRow,
    detailLoading,
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue
ÎļþÒÑɾ³ý
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
@@ -1,253 +1,12 @@
<!--OA模块:NoticeAnnouncement é€šçŸ¥å…¬å‘Š-->
<!--
  æ¨¡å—中文名:通知公告
  ç›®å½•标识:NoticeAnnouncement/notice-manage
  å¤ç”¨é¡µé¢ï¼š@/views/collaborativeApproval/noticeManagement/index.vue(协同审批-通知公告)
-->
<template>
  <div class="app-container notice-announcement-page">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">关键词:</span>
        <el-input
          v-model="searchForm.keyword"
          style="width: 200px"
          placeholder="标题 / ç¼–号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">类型:</span>
        <el-select v-model="searchForm.noticeType" placeholder="全部" clearable style="width: 130px">
          <el-option v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">优先级:</span>
        <el-select v-model="searchForm.priority" placeholder="全部" clearable style="width: 110px">
          <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">状态:</span>
        <el-select v-model="searchForm.publishStatus" placeholder="全部" clearable style="width: 110px">
          <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">发布日期:</span>
        <el-date-picker
          v-model="searchForm.publishDateRange"
          type="daterange"
          range-separator="-"
          start-placeholder="开始"
          end-placeholder="结束"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 260px"
          clearable
        />
        <el-button type="primary" :icon="Search" class="ml10" @click="handleQuery">搜索</el-button>
        <el-button :icon="RefreshRight" @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" :icon="Plus" @click="openFormDialog('add')">添加公告</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      >
        <template #noticeType="{ row }">
          <span class="notice-type-tag" :style="{ color: noticeTypeColor(row.noticeType) }">
            {{ noticeTypeLabel(row.noticeType) }}
          </span>
        </template>
      </PIMTable>
    </div>
    <!-- æ·»åŠ  / ä¿®æ”¹ -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="800px"
      append-to-body
      destroy-on-close
      class="notice-form-dialog"
      @closed="formRef?.resetFields?.()"
    >
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="100px"
        :disabled="formDialog.readonly"
      >
        <el-form-item label="标题" prop="title">
          <el-input v-model="form.title" placeholder="请输入公告标题" maxlength="100" show-word-limit />
        </el-form-item>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="公告类型" prop="noticeType">
              <el-select v-model="form.noticeType" placeholder="请选择" style="width: 100%" @change="onNoticeTypeChange">
                <el-option v-for="opt in NOTICE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="优先级">
              <el-select v-model="form.priority" placeholder="请选择" style="width: 100%">
                <el-option v-for="opt in PRIORITY_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="发布日期" prop="publishDate">
              <el-date-picker
                v-model="form.publishDate"
                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="过期日期">
              <el-date-picker
                v-model="form.expireDate"
                type="date"
                placeholder="可选,留空为长期有效"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
                clearable
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="阅读范围">
          <el-radio-group v-model="form.readScope">
            <el-radio v-for="opt in READ_SCOPE_OPTIONS" :key="opt.value" :value="opt.value">
              {{ opt.label }}
            </el-radio>
          </el-radio-group>
        </el-form-item>
        <el-form-item v-if="form.readScope === 'department'" label="可见部门">
          <el-select v-model="form.targetDeptIds" multiple placeholder="选择部门" style="width: 100%">
            <el-option v-for="d in DEPT_OPTIONS" :key="d.value" :label="d.label" :value="d.value" />
          </el-select>
        </el-form-item>
        <el-form-item v-if="form.noticeType === 'emergency'" label="必读确认">
          <el-switch v-model="form.requireReadConfirm" active-text="紧急通知需员工确认已读" />
        </el-form-item>
        <el-form-item label="内容" prop="contentHtml">
          <Editor v-model="form.contentHtml" :min-height="280" placeholder="请输入内容" />
        </el-form-item>
        <el-form-item label="发布人">
          <el-input v-model="form.publisherName" placeholder="如:行政部" maxlength="50" />
        </el-form-item>
      </el-form>
      <template v-if="!formDialog.readonly" #footer>
        <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        <el-button @click="onSave(false)">存草稿</el-button>
        <el-button type="primary" @click="onSave(true)">ç¡® å®š</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="公告详情" width="800px" append-to-body destroy-on-close>
      <NoticeDetailPanel :row="detailRow" />
      <template #footer>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
  </div>
  <NoticeManagement />
</template>
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted } from "vue";
import Editor from "@/components/Editor/index.vue";
import { noticeTypeColor } from "./noticeAnnouncementUtils.js";
import NoticeDetailPanel from "./components/NoticeDetailPanel.vue";
import { useNoticeAnnouncement } from "./useNoticeAnnouncement.js";
const {
  Search,
  NOTICE_TYPE_OPTIONS,
  PRIORITY_OPTIONS,
  PUBLISH_STATUS_OPTIONS,
  READ_SCOPE_OPTIONS,
  DEPT_OPTIONS,
  noticeTypeLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  formDialog,
  form,
  formRef,
  formRules,
  detailDialog,
  detailRow,
  handleQuery,
  resetSearch,
  pagination,
  openFormDialog,
  saveForm,
} = useNoticeAnnouncement();
function onNoticeTypeChange(type) {
  if (type === "emergency") {
    form.priority = "urgent";
    form.requireReadConfirm = true;
  }
}
function onSave(publish) {
  const ret = saveForm(publish);
  if (ret?.message) {
    ElMessage.warning(ret.message);
    return;
  }
  if (ret?.ok) {
    ElMessage.success(publish ? "公告已发布" : "已保存草稿");
  }
}
onMounted(() => {
  handleQuery();
});
import NoticeManagement from "@/views/collaborativeApproval/noticeManagement/index.vue";
</script>
<style scoped>
.notice-announcement-page .search_form {
  display: flex;
  flex-wrap: wrap;
  justify-content: space-between;
  align-items: flex-start;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  flex-shrink: 0;
}
.notice-type-tag {
  font-weight: 600;
  font-size: 13px;
}
.ml10 {
  margin-left: 10px;
}
.mb20 {
  margin-bottom: 20px;
}
</style>
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js
ÎļþÒÑɾ³ý
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
ÎļþÒÑɾ³ý
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
@@ -50,7 +50,10 @@
});
const attachmentFiles = computed(() => {
  const list = props.row?.attachmentList?.length ? props.row.attachmentList : props.row?.invoiceAttachments;
  const list =
    props.row?.attachmentList ||
    props.row?.storageBlobVOList ||
    props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
@@ -107,14 +107,19 @@
}
export function statusLabel(v) {
  if (v === "draft") return "草稿";
  if (v === "approved") return "已通过";
  if (v === "paid") return "已付款";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "draft") return "info";
  if (v === "approved" || v === "paid") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "warning";
}
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
@@ -1,4 +1,4 @@
<!--OA模块:费用报销-->
<!--OA模块:费用报销(列表 /finReimbursement/listPage,reimbursementType=2)-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
@@ -318,13 +318,21 @@
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">提 äº¤</el-button>
        <el-button
          v-if="!formDialog.readonly"
          type="primary"
          :loading="submitSaving"
          @click="submitForm"
        >
          æ äº¤
        </el-button>
        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 é—­" : "取 æ¶ˆ" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="费用报销详情" width="900px" append-to-body destroy-on-close>
      <div v-loading="detailLoading">
      <DetailPanel :row="detailRow" />
      <el-divider content-position="left">审批流程</el-divider>
      <ApprovalFlowProgress :nodes="detailRow.approvalFlowNodes" :current-index="detailRow.currentNodeIndex ?? 0" />
@@ -340,6 +348,7 @@
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      </div>
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
@@ -406,6 +415,7 @@
  formDialog,
  formRules,
  detailDialog,
  detailLoading,
  detailRow,
  approveDialog,
  approveOpinion,
@@ -431,6 +441,7 @@
  openFormDialog,
  onFormClosed,
  submitForm,
  submitSaving,
  approvalActionLabel,
  submitApprove,
  handleExport,
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
@@ -1,7 +1,29 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import {
  deleteFinReimbursement,
  getFinReimbursementDetail,
  listFinReimbursementPage,
  persistFinReimbursement,
} from "@/api/officeProcessAutomation/finReimbursement.js";
import { ElMessageBox } from "element-plus";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
import {
  buildCostReimbursementSaveDto,
  buildFinReimbursementListParams,
  canDeleteReimbursementRow,
  canEditReimbursementRow,
  filterRowsByReimbursementType,
  FIN_REIMBURSEMENT_TYPE,
  mapCostReimbursementRow,
  mapFinReimbursementDetailRow,
  resolveReimbursementDeleteId,
  unwrapFinReimbursementDetail,
  unwrapFinReimbursementPage,
  validateReimbursementPersistDto,
} from "../shared/finReimbursementMappers.js";
import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
import {
  EXPENSE_CATEGORY_OPTIONS,
  CATEGORY_TEMPLATES,
@@ -59,52 +81,43 @@
  const form = reactive(createEmptyForm());
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const detailDialog = reactive({ visible: false });
  const detailLoading = ref(false);
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const submitSaving = ref(false);
  const filteredList = computed(() => {
    let list = [...allRows.value];
    const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.applicantName || r.employeeName || "").toLowerCase();
        const no = (r.applicantNo || r.employeeNo || "").toLowerCase();
        return name.includes(kw) || no.includes(kw);
      });
    }
    if (searchForm.applyTimeFrom) {
      list = list.filter((r) => {
        const t = (r.applyTime || r.createTime || "").slice(0, 10);
        return !t || t >= searchForm.applyTimeFrom;
      });
    }
    if (searchForm.applyTimeTo) {
      list = list.filter((r) => {
        const t = (r.applyTime || r.createTime || "").slice(0, 10);
        return !t || t <= searchForm.applyTimeTo;
      });
    }
    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
  });
  watch(
    filteredList,
    (list) => {
      page.total = list.length;
      const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
      if (page.current > maxPage) page.current = maxPage;
    },
    { immediate: true }
  );
  const tableData = computed(() => {
    const start = (page.current - 1) * page.size;
    return filteredList.value.slice(start, start + page.size).map((r) => ({
  const tableData = computed(() =>
    allRows.value.map((r) => ({
      ...r,
      approvalFlowSummary: formatApprovalFlowSummary(r),
    }));
  });
    }))
  );
  async function fetchList() {
    tableLoading.value = true;
    try {
      const res = await listFinReimbursementPage(
        buildFinReimbursementListParams({
          page,
          searchForm,
          reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
        })
      );
      const { records, total } = unwrapFinReimbursementPage(res);
      allRows.value = filterRowsByReimbursementType(
        records,
        FIN_REIMBURSEMENT_TYPE.COST
      ).map(mapCostReimbursementRow);
      page.total = total;
    } catch {
      allRows.value = [];
      page.total = 0;
      proxy?.$modal?.msgError?.("费用报销列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
@@ -149,15 +162,15 @@
        {
          name: "编辑",
          type: "text",
          disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved",
          disabled: (row) => !canEditReimbursementRow(row),
          clickFun: (row) => openFormDialog("edit", row),
        },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "审批",
          type: "text",
          disabled: (row) => row.approvalResult !== "pending",
          clickFun: (row) => openApprove(row),
          name: "删除",
          type: "danger",
          disabled: (row) => !canDeleteReimbursementRow(row),
          clickFun: (row) => confirmRemoveRow(row),
        },
      ],
    },
@@ -295,10 +308,7 @@
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => {
      tableLoading.value = false;
    }, 150);
    return fetchList();
  }
  function resetSearch() {
@@ -311,11 +321,70 @@
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
    return fetchList();
  }
  function openDetail(row) {
    detailRow.value = { ...row };
  async function loadCostDetailRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      throw new Error("missing id");
    }
    const res = await getFinReimbursementDetail(id);
    const raw = unwrapFinReimbursementDetail(res);
    return mapFinReimbursementDetailRow(raw, FIN_REIMBURSEMENT_TYPE.COST);
  }
  async function openDetail(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法查看详情:缺少报销单 ID");
      return;
    }
    detailDialog.visible = true;
    detailLoading.value = true;
    detailRow.value = { ...row };
    try {
      detailRow.value = await loadCostDetailRow(row);
    } catch {
      proxy?.$modal?.msgError?.("加载详情失败");
      detailDialog.visible = false;
    } finally {
      detailLoading.value = false;
    }
  }
  async function confirmRemoveRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法删除:缺少报销单 ID");
      return;
    }
    const title = row.reimburseNo || row.billNo || row.reimburseReason || "该报销单";
    try {
      await ElMessageBox.confirm(
        `确定要删除「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteFinReimbursement([id]);
      proxy?.$modal?.msgSuccess?.("删除成功");
      if (detailDialog.visible && resolveReimbursementDeleteId(detailRow.value) === id) {
        detailDialog.visible = false;
      }
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.("删除失败");
    }
  }
  function openApprove(row) {
@@ -336,16 +405,24 @@
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      let editRow = row;
      try {
        editRow = await loadCostDetailRow(row);
      } catch {
        proxy?.$modal?.msgError?.("加载报销详情失败");
        return;
      }
      Object.assign(form, {
        ...JSON.parse(JSON.stringify(row)),
        attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
        ...JSON.parse(JSON.stringify(editRow)),
        reimbursementId: editRow.reimbursementId ?? editRow.id,
        attachmentList: JSON.parse(JSON.stringify(editRow.attachmentList || editRow.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(editRow.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(editRow.expenseDetails || [])),
      });
      const u = userById(row.applicantId);
      const u = userById(editRow.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
    } else {
      form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
      remoteSearchApplicantForm("");
@@ -373,64 +450,25 @@
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
    const payload = {
      reimburseNo: form.reimburseNo || `CR${dayjs().format("YYYYMMDDHHmmss")}`,
      applicantId: form.applicantId,
      employeeNo: form.employeeNo,
      employeeName: form.employeeName,
      applicantNo: form.employeeNo,
      applicantName: form.employeeName,
      expenseCategory: form.expenseCategory,
      reimburseReason: form.reimburseReason,
      applyAmount: form.applyAmount,
      payee: form.payee,
      payeeAccount: form.payeeAccount,
      bankBranch: form.bankBranch,
      expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
      attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
      invoiceAttachments: (form.attachmentList || []).map((f, i) => ({
        id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
        name: f.name || f.fileName || "未命名",
        url: f.url || f.downloadURL || "",
      })),
      approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
      currentNodeIndex: 0,
      deptId: form.deptId,
      deptName: form.deptName,
    };
    if (formDialog.mode === "add") {
      const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
      allRows.value.unshift({
        id: `local_${Date.now()}`,
        ...payload,
        approvalResult: "pending",
        rejectReason: "",
        approvalRecords: [],
        applyTime: now,
        createTime: now,
      });
      proxy?.$modal?.msgSuccess?.("提交成功");
    } else {
      const idx = allRows.value.findIndex((r) => r.id === form.id);
      if (idx !== -1) {
        const prev = allRows.value[idx];
        allRows.value[idx] = {
          ...prev,
          ...payload,
          id: form.id,
          approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult,
          approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
          currentNodeIndex: 0,
          rejectReason: prev.approvalResult === "rejected" ? "" : prev.rejectReason,
          applyTime: prev.applyTime,
          createTime: prev.createTime,
        };
      }
      proxy?.$modal?.msgSuccess?.("保存成功");
    if (submitSaving.value) return;
    const isEdit = formDialog.mode === "edit";
    const dto = buildCostReimbursementSaveDto(form);
    const check = validateReimbursementPersistDto(dto, isEdit);
    if (!check.ok) {
      proxy?.$modal?.msgWarning?.(check.message);
      return;
    }
    formDialog.visible = false;
    handleQuery();
    submitSaving.value = true;
    try {
      await persistFinReimbursement(dto, isEdit);
      proxy?.$modal?.msgSuccess?.(isEdit ? "保存成功" : "提交成功");
      formDialog.visible = false;
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.(isEdit ? "保存失败" : "提交失败");
    } finally {
      submitSaving.value = false;
    }
  }
  async function submitApprove(result) {
@@ -471,7 +509,7 @@
  }
  function handleExport() {
    const data = filteredList.value;
    const data = allRows.value;
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
@@ -509,7 +547,14 @@
    reader.readAsText(file, "utf-8");
  }
  onMounted(() => loadUserPool());
  onMounted(async () => {
    loadUserPool();
    await fetchList();
    const editPayload = consumeReimburseEditFromApprove();
    if (editPayload?.reimbursementId != null) {
      await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
    }
  });
  return {
    Search,
@@ -529,6 +574,7 @@
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
@@ -554,6 +600,7 @@
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    approvalActionLabel,
    submitApprove,
src/views/officeProcessAutomation/ReimburseManage/shared/components/FinReimburseApprovePanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,70 @@
<!-- å·®æ—…/费用报销:审批列表内详情/审批弹窗内容(与报销页弹窗一致) -->
<template>
  <div v-loading="loading">
    <TravelDetailPanel v-if="isTravel" :row="reimburseRow" />
    <CostDetailPanel v-else :row="reimburseRow" />
    <el-divider content-position="left">流程进度</el-divider>
    <ApprovalFlowProgress
      :nodes="reimburseRow.approvalFlowNodes"
      :current-index="reimburseRow.currentNodeIndex ?? 0"
    />
    <template v-if="mode === 'detail'">
      <el-divider content-position="left">审批记录(全流程留痕)</el-divider>
      <el-timeline v-if="reimburseRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in reimburseRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ actionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
    </template>
    <el-form v-else label-width="100px" class="mt16">
      <el-form-item label="审批意见">
        <el-input
          :model-value="approveOpinion"
          type="textarea"
          :rows="3"
          maxlength="500"
          show-word-limit
          :placeholder="isTravel ? '通过可留空;驳回请填写原因' : '通过可留空;驳回请填写具体原因(如:发票模糊需重传)'"
          @update:model-value="$emit('update:approveOpinion', $event)"
        />
      </el-form-item>
    </el-form>
  </div>
</template>
<script setup>
import { computed } from "vue";
import { isTravelReimbursementType } from "../finReimbursementMappers.js";
import ApprovalFlowProgress from "../../travel-reimburse/components/ApprovalFlowProgress.vue";
import CostDetailPanel from "../../cost-reimburse/components/DetailPanel.vue";
import TravelDetailPanel from "../../travel-reimburse/components/DetailPanel.vue";
const props = defineProps({
  mode: { type: String, default: "detail" },
  moduleKey: { type: String, default: "" },
  reimburseRow: { type: Object, default: () => ({}) },
  loading: { type: Boolean, default: false },
  approveOpinion: { type: String, default: "" },
});
defineEmits(["update:approveOpinion"]);
const isTravel = computed(() =>
  isTravelReimbursementType(props.reimburseRow?.reimbursementType ?? props.moduleKey)
);
function actionLabel(v) {
  if (v === "approved") return "通过";
  if (v === "rejected") return "驳回";
  return "提交";
}
</script>
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementDetailExtras.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,152 @@
import { formatDisplayTime } from "../../ApproveManage/approve-template/approveTemplateConstants.js";
import {
  mapRecordResultFromApi,
  mapRecordsFromApi,
  mapTasksToFlowNodes,
} from "../../ApproveManage/approve-list/approveListConstants.js";
function taskStatusToNodeStatus(taskStatus) {
  const s = String(taskStatus ?? "").toUpperCase();
  if (["APPROVED", "COMPLETED", "FINISHED", "PASSED", "AGREE"].includes(s)) {
    return "finish";
  }
  if (["REJECTED", "REJECT", "REFUSE", "REFUSED"].includes(s)) {
    return "error";
  }
  if (["PENDING", "IN_APPROVAL", "PROCESS", "PROCESSING"].includes(s)) {
    return "process";
  }
  return "wait";
}
/** storageBlobVOList â†’ é¡µé¢é™„件列表 */
export function mapReimbursementAttachments(source = {}) {
  const list =
    source.storageBlobVOList ||
    source.storageBlobDTOs ||
    source.storageBlobDTOS ||
    source.storageBlobVOS ||
    source.attachmentList ||
    source.invoiceAttachments ||
    [];
  if (!Array.isArray(list)) return [];
  return list.map((b, i) => ({
    ...b,
    id: b.id ?? b.blobId ?? `att_${i}`,
    name:
      b.fileName ||
      b.originalFilename ||
      b.originalFileName ||
      b.blobName ||
      b.name ||
      "附件",
    url:
      b.url ||
      b.fileUrl ||
      b.downloadUrl ||
      b.downloadURL ||
      b.previewUrl ||
      b.previewURL ||
      b.link ||
      "",
  }));
}
/** å®¡æ‰¹è®°å½•来自 tasks(每条任务一条留痕) */
export function mapTasksToApprovalRecords(tasks) {
  const list = Array.isArray(tasks) ? tasks : [];
  return list
    .map((t, index) => ({
      id: t.id ?? index,
      operatorName: t.approverName || t.operatorName || t.createUserName || "—",
      result: mapRecordResultFromApi(
        t.approveAction ?? t.taskStatus ?? t.status
      ),
      opinion: t.approveComment || t.comment || t.opinion || "",
      time: formatDisplayTime(
        t.approveTime || t.finishTime || t.updateTime || t.createTime || ""
      ),
      levelNo: t.levelNo ?? t.taskLevel,
      raw: t,
    }))
    .sort((a, b) => {
      const la = Number(a.levelNo ?? 0);
      const lb = Number(b.levelNo ?? 0);
      if (la !== lb) return la - lb;
      return String(a.time).localeCompare(String(b.time));
    });
}
/** tasks â†’ ApprovalFlowProgress èŠ‚ç‚¹ */
export function mapTasksToApprovalFlowNodes(tasks) {
  const grouped = mapTasksToFlowNodes(tasks);
  return grouped.map((node, i) => {
    const approvers = node.approvers || [];
    const statuses = approvers.map(a =>
      taskStatusToNodeStatus(a.taskStatus ?? a.status)
    );
    let nodeStatus = "wait";
    if (statuses.includes("error")) nodeStatus = "error";
    else if (statuses.length && statuses.every(s => s === "finish")) {
      nodeStatus = "finish";
    } else if (statuses.includes("process")) nodeStatus = "process";
    const names = approvers.map(a => a.approverName).filter(Boolean).join("、");
    const opinions = approvers
      .map(a => a.approveComment)
      .filter(Boolean)
      .join(";");
    return {
      nodeOrder: node.nodeOrder ?? node.levelNo ?? i + 1,
      sortOrder: node.nodeOrder ?? node.levelNo ?? i + 1,
      approverName: names || "—",
      approveOpinion: opinions,
      approveTime: approvers.find(a => a.approveTime)?.approveTime || "",
      nodeStatus,
      signMode: node.signMode,
    };
  });
}
export function computeApprovalFlowCurrentIndex(approvalFlowNodes = []) {
  const list = approvalFlowNodes || [];
  const processing = list.findIndex(n => n.nodeStatus === "process");
  if (processing >= 0) return processing;
  const errorIdx = list.findIndex(n => n.nodeStatus === "error");
  if (errorIdx >= 0) return errorIdx;
  return list.filter(n => n.nodeStatus === "finish").length;
}
/** è¯¦æƒ… DTO è¡¥å…… tasks / é™„ä»¶ / å®¡æ‰¹è®°å½• */
export function applyFinReimbursementDetailEnrichment(mapped, raw = {}) {
  if (!mapped || typeof mapped !== "object") return mapped;
  const source = { ...raw, ...mapped };
  const tasks = Array.isArray(source.tasks) ? source.tasks : [];
  const attachments = mapReimbursementAttachments(source);
  const approvalRecords = tasks.length
    ? mapTasksToApprovalRecords(tasks)
    : mapRecordsFromApi(source.records || source.approvalRecords);
  const approvalFlowNodes = tasks.length
    ? mapTasksToApprovalFlowNodes(tasks)
    : mapped.approvalFlowNodes || [];
  const currentNodeIndex = computeApprovalFlowCurrentIndex(approvalFlowNodes);
  const rejectReason =
    approvalRecords.find(r => r.result === "rejected")?.opinion ||
    source.rejectReason ||
    "";
  return {
    ...mapped,
    tasks,
    storageBlobVOList: attachments,
    attachmentList: attachments,
    invoiceAttachments: attachments,
    approvalRecords,
    records: tasks.length ? tasks : source.records,
    approvalFlowNodes,
    currentNodeIndex,
    rejectReason,
    flowNodes: tasks.length ? mapTasksToFlowNodes(tasks) : mapped.flowNodes,
  };
}
src/views/officeProcessAutomation/ReimburseManage/shared/finReimbursementMappers.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,623 @@
import dayjs from "dayjs";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { mapSignModeToApi } from "../../ApproveManage/approve-template/approveTemplateConstants.js";
import { EXPENSE_CATEGORY_OPTIONS } from "../cost-reimburse/costReimburseUtils.js";
import { EXPENSE_SUBJECT_OPTIONS } from "../travel-reimburse/travelReimburseUtils.js";
import { applyFinReimbursementDetailEnrichment } from "./finReimbursementDetailExtras.js";
/** æŠ¥é”€ç±»åž‹ï¼š1-差旅报销,2-费用报销 */
export const FIN_REIMBURSEMENT_TYPE = {
  TRAVEL: "1",
  COST: "2",
};
const REIMBURSEMENT_TYPE_LABEL = {
  [FIN_REIMBURSEMENT_TYPE.TRAVEL]: "差旅报销",
  [FIN_REIMBURSEMENT_TYPE.COST]: "费用报销",
};
/** å½’一化报销类型:1-差旅,2-费用 */
export function normalizeReimbursementType(val) {
  const s = String(val ?? "").trim();
  if (s === "1" || s === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (s === "2" || s === FIN_REIMBURSEMENT_TYPE.COST) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function reimbursementTypeLabel(type) {
  return REIMBURSEMENT_TYPE_LABEL[normalizeReimbursementType(type)] || "—";
}
export function getModuleKeyByReimbursementType(type) {
  const t = normalizeReimbursementType(type);
  if (t === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    return APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE;
  }
  if (t === FIN_REIMBURSEMENT_TYPE.COST) {
    return APPROVAL_MODULE_KEYS.COST_REIMBURSE;
  }
  return "";
}
/** ä¼˜å…ˆæŽ¥å£ reimbursementType,其次页面 moduleKey / å…¥å‚ */
export function resolveReimbursementType(raw, fallback) {
  const fromApi = normalizeReimbursementType(raw?.reimbursementType);
  if (fromApi) return fromApi;
  return (
    normalizeReimbursementType(fallback) ||
    getReimbursementTypeByModuleKey(fallback) ||
    ""
  );
}
export function isTravelReimbursementType(type) {
  return (
    resolveReimbursementType({ reimbursementType: type }, type) ===
    FIN_REIMBURSEMENT_TYPE.TRAVEL
  );
}
export function filterRowsByReimbursementType(rows, expectedType) {
  const expected = normalizeReimbursementType(expectedType);
  if (!expected) return rows || [];
  return (rows || []).filter((row) => {
    const t = resolveReimbursementType(row, expected);
    return t === expected;
  });
}
export function getReimbursementTypeByModuleKey(moduleKey) {
  if (moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.TRAVEL;
  }
  if (moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE) {
    return FIN_REIMBURSEMENT_TYPE.COST;
  }
  return "";
}
export function unwrapFinReimbursementPage(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") {
    return { records: [], total: 0 };
  }
  if (Array.isArray(data.records)) {
    return { records: data.records, total: Number(data.total ?? 0) };
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && Array.isArray(nested.records)) {
    return { records: nested.records, total: Number(nested.total ?? 0) };
  }
  return { records: [], total: 0 };
}
/** è¯¦æƒ…接口 data è§£åŒ… */
export function unwrapFinReimbursementDetail(res) {
  const data = res?.data ?? res;
  if (!data || typeof data !== "object") return {};
  if (data.billNo != null || data.id != null || data.reimbursementType != null) {
    return data;
  }
  const nested = data.data;
  if (nested && typeof nested === "object" && !Array.isArray(nested)) {
    return nested;
  }
  if (data.finReimbursementDto && typeof data.finReimbursementDto === "object") {
    return data.finReimbursementDto;
  }
  return data;
}
/** è¯¦æƒ…查询参数(query finReimbursementDto) */
export function buildFinReimbursementDetailParams(id) {
  const raw = id?.id != null ? id.id : id;
  const n = toNumber(raw);
  return { finReimbursementDto: { id: n != null ? n : raw } };
}
/** è¯¦æƒ… DTO â†’ é¡µé¢è¡Œï¼ˆæŒ‰ reimbursementType æ˜ å°„,含 tasks / storageBlobVOList) */
export function mapFinReimbursementDetailRow(raw, reimbursementTypeOrModuleKey) {
  const type = resolveReimbursementType(raw, reimbursementTypeOrModuleKey);
  let mapped = {};
  if (type === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    mapped = mapTravelReimbursementRow(raw);
  } else if (type === FIN_REIMBURSEMENT_TYPE.COST) {
    mapped = mapCostReimbursementRow(raw);
  } else {
    mapped = raw || {};
  }
  return {
    ...applyFinReimbursementDetailEnrichment(mapped, raw),
    reimbursementType: type,
    reimbursementTypeLabel: reimbursementTypeLabel(type),
    moduleKey: getModuleKeyByReimbursementType(type),
  };
}
/** å•据状态 â†’ é¡µé¢ approvalResult(兼容 statusLabel) */
export function mapBillStatusToApprovalResult(billStatus) {
  const upper = String(billStatus ?? "").trim().toUpperCase();
  if (upper === "DRAFT") return "draft";
  if (upper === "IN_APPROVAL") return "pending";
  if (upper === "APPROVED") return "approved";
  if (upper === "REJECTED") return "rejected";
  if (upper === "WITHDRAWN") return "cancelled";
  if (upper === "PAID") return "paid";
  return "pending";
}
function pickApplicantQuery(searchForm = {}) {
  const kw = (searchForm.applicantKeyword || "").trim();
  if (!kw) return {};
  if (/[\u4e00-\u9fa5]/.test(kw)) return { applicantName: kw };
  return { applicantCode: kw };
}
/** ç»„装 listPage æŸ¥è¯¢å‚数(page + finReimbursementDto) */
export function buildFinReimbursementListParams({
  page,
  searchForm,
  reimbursementType,
  extraDto = {},
}) {
  const dto = {
    reimbursementType,
    ...pickApplicantQuery(searchForm),
    ...(extraDto && typeof extraDto === "object" ? extraDto : {}),
  };
  if (searchForm?.billStatus) {
    dto.billStatus = searchForm.billStatus;
  }
  const range =
    searchForm?.createTimeRange ??
    searchForm?.applyDateRange ??
    (searchForm?.applyTimeFrom || searchForm?.applyTimeTo
      ? [searchForm.applyTimeFrom, searchForm.applyTimeTo]
      : null);
  if (Array.isArray(range) && range[0]) {
    dto.createTimeStart = range[0];
  }
  if (Array.isArray(range) && range[1]) {
    dto.createTimeEnd = range[1];
  }
  if (reimbursementType === FIN_REIMBURSEMENT_TYPE.TRAVEL) {
    if (searchForm?.travelStartFrom) {
      dto.startTimeStart = searchForm.travelStartFrom;
    }
    if (searchForm?.travelEndTo) {
      dto.endTimeEnd = searchForm.travelEndTo;
    }
  }
  return {
    page: {
      current: page.current,
      size: page.size,
    },
    finReimbursementDto: dto,
  };
}
function pickTravelField(obj, keys) {
  if (!obj || typeof obj !== "object") return "";
  for (const key of keys) {
    const v = obj[key];
    if (v != null && v !== "") return v;
  }
  return "";
}
/** å…¼å®¹ list/detail å¤šç§å·®æ—…子对象结构 */
export function pickTravelFromRow(row) {
  if (!row || typeof row !== "object") return {};
  const nested =
    (row.travel && typeof row.travel === "object" ? row.travel : null) ||
    row.finReimbursementTravel ||
    row.finReimbursementTravelDto ||
    row.travelDto ||
    row.travelVO ||
    {};
  const src =
    nested && typeof nested === "object" && Object.keys(nested).length
      ? nested
      : row;
  return {
    startTime: pickTravelField(src, [
      "startTime",
      "travelStartTime",
      "startDate",
      "travelStartDate",
      "departureTime",
    ]),
    endTime: pickTravelField(src, [
      "endTime",
      "travelEndTime",
      "endDate",
      "travelEndDate",
      "returnTime",
    ]),
    travelDays: src.travelDays,
    departureCity: pickTravelField(src, [
      "departureCity",
      "departurePlace",
      "departure",
    ]),
    destinationCity: pickTravelField(src, [
      "destinationCity",
      "destination",
      "destinationPlace",
    ]),
    hotelStandard: src.hotelStandard,
    lodgingDays: src.lodgingDays ?? src.hotelDays,
    mealAllowance: src.mealAllowance ?? src.livingSubsidy,
    transportAllowance: src.transportAllowance ?? src.transportSubsidy,
    lodgingLimit: src.lodgingLimit,
    withinStandard: src.withinStandard,
    standardTag: src.standardTag || "",
    id: src.id,
    reimbursementId: src.reimbursementId,
  };
}
/** åˆ—表/详情时间展示(ISO â†’ YYYY-MM-DD HH:mm:ss) */
export function formatReimbursementDateTime(val) {
  if (val == null || val === "") return "";
  const d = dayjs(val);
  if (!d.isValid()) return String(val);
  const raw = String(val);
  const hasTime = raw.includes("T") || /:\d{2}/.test(raw);
  return hasTime ? d.format("YYYY-MM-DD HH:mm:ss") : d.format("YYYY-MM-DD");
}
/** æŽ¥å£è¡Œ â†’ å·®æ—…报销列表行(兼容 useTravelReimburse å­—段) */
export function mapTravelReimbursementRow(row) {
  if (!row) return {};
  const travel = pickTravelFromRow(row);
  const details = Array.isArray(row.details) ? row.details : [];
  const base = {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    travelStartTime: formatReimbursementDateTime(travel.startTime),
    travelEndTime: formatReimbursementDateTime(travel.endTime),
    travelDays: travel.travelDays,
    departurePlace: travel.departureCity || "",
    destination: travel.destinationCity || "",
    hotelStandard: travel.hotelStandard,
    hotelDays: travel.lodgingDays,
    livingSubsidy: travel.mealAllowance,
    transportSubsidy: travel.transportAllowance,
    lodgingLimit: travel.lodgingLimit,
    needSpecialApproval: travel.withinStandard === "0" || travel.withinStandard === 0,
    standardTag: travel.standardTag || "",
    applyAmount: row.applyAmount,
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    payeeBank: row.payeeBank || "",
    billStatus: row.billStatus,
    approvalResult: mapBillStatusToApprovalResult(row.billStatus),
    createTime: formatReimbursementDateTime(row.createTime),
    expenseDetails: details.map((d) => ({
      ...d,
      expenseSubject: d.expenseCategory,
    })),
    travel:
      row.travel && typeof row.travel === "object" && Object.keys(row.travel).length
        ? row.travel
        : travel,
    details,
    nodes: row.nodes || [],
    approvalFlowNodes: mapNodesToFormFlow(row.nodes),
    tasks: row.tasks || [],
  };
  return base;
}
/** æŽ¥å£è¡Œ â†’ è´¹ç”¨æŠ¥é”€åˆ—表行(兼容 useCostReimburse å­—段) */
export function mapCostReimbursementRow(row) {
  if (!row) return {};
  const details = Array.isArray(row.details) ? row.details : [];
  return {
    ...row,
    id: row.id,
    reimbursementId: row.id,
    approvalInstanceId: row.approvalInstanceId,
    reimburseNo: row.billNo || "",
    applicantId: row.applicantId,
    applicantNo: row.applicantCode || "",
    applicantName: row.applicantName || "",
    employeeNo: row.applicantCode || "",
    employeeName: row.applicantName || "",
    applicantDeptName: row.applicantDeptName || "",
    reimburseReason: row.reason || "",
    expenseCategory: row.expenseType || "",
    applyAmount: row.applyAmount,
    applyTime: row.createTime || "",
    payee: row.payeeName || "",
    payeeAccount: row.payeeAccount || "",
    bankBranch: row.payeeBank || "",
    billStatus: row.billStatus,
    approvalResult: mapBillStatusToApprovalResult(row.billStatus),
    createTime: formatReimbursementDateTime(row.createTime),
    expenseDetails: details.map((d) => ({
      ...d,
      expenseSubject: d.expenseCategory,
    })),
    details,
    nodes: row.nodes || [],
    approvalFlowNodes: mapNodesToFormFlow(row.nodes),
    tasks: row.tasks || [],
  };
}
function toNumber(val) {
  if (val == null || val === "") return undefined;
  const n = Number(val);
  return Number.isNaN(n) ? undefined : n;
}
function expenseSubjectToCategory(subject) {
  const hit = EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === subject);
  return hit?.label || subject || "";
}
function expenseCategoryToType(category) {
  const hit = EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === category);
  return hit?.label || category || "";
}
/** æŽ¥å£ nodes â†’ é¡µé¢å®¡æ‰¹æµï¼ˆå•审批人节点) */
export function mapNodesToFormFlow(nodes = []) {
  return (Array.isArray(nodes) ? nodes : []).map((n, i) => {
    const first = Array.isArray(n.approvers) ? n.approvers[0] : null;
    return {
      ...n,
      nodeOrder: n.levelNo ?? n.nodeOrder ?? i + 1,
      signMode: String(n.approveType || "").toUpperCase() === "OR" ? "or_sign" : "countersign",
      approverId: first?.approverId ?? n.approverId ?? null,
      approverName: first?.approverName ?? n.approverName ?? "",
    };
  });
}
/** é¡µé¢å®¡æ‰¹èŠ‚ç‚¹ â†’ æŽ¥å£ nodes */
export function mapApprovalFlowNodesToApi(nodes = [], templateId) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list
    .map((n, i) => {
      let approvers = [];
      if (Array.isArray(n.approvers) && n.approvers.length) {
        approvers = n.approvers
          .filter((a) => a?.approverId != null && a.approverId !== "")
          .map((a, idx) => ({
            id: a.id,
            nodeId: a.nodeId,
            templateId: a.templateId ?? templateId,
            approverId: toNumber(a.approverId) ?? a.approverId,
            approverName: a.approverName || "",
            sortNo: a.sortNo ?? idx + 1,
          }));
      } else if (n.approverId != null && n.approverId !== "") {
        approvers = [
          {
            approverId: toNumber(n.approverId) ?? n.approverId,
            approverName: n.approverName || "",
            sortNo: 1,
          },
        ];
      }
      if (!approvers.length) return null;
      const node = {
        levelNo: n.levelNo ?? n.nodeOrder ?? i + 1,
        approveType: n.approveType || mapSignModeToApi(n.signMode),
        approvers,
      };
      if (n.id != null) node.id = n.id;
      if (n.templateId != null) node.templateId = n.templateId;
      else if (templateId != null) node.templateId = templateId;
      return node;
    })
    .filter(Boolean);
}
function mapDetailsToApi(details = []) {
  return (details || []).map((d, i) => {
    const item = {
      rowNo: d.rowNo ?? i + 1,
      invoiceDate: d.invoiceDate || undefined,
      expenseCategory: expenseSubjectToCategory(d.expenseSubject ?? d.expenseCategory),
      amount: toNumber(d.amount),
      description: d.description || "",
      invoiceNo: d.invoiceNo || undefined,
      invoiceType: d.invoiceType || undefined,
      invoiceAmount: toNumber(d.invoiceAmount),
      taxRate: toNumber(d.taxRate),
      taxAmount: toNumber(d.taxAmount),
      remark: d.remark || undefined,
    };
    if (d.id != null && !String(d.id).startsWith("ed_")) {
      item.id = toNumber(d.id) ?? d.id;
    }
    if (d.reimbursementId != null) item.reimbursementId = toNumber(d.reimbursementId);
    return item;
  });
}
function sumDetailAmount(details = []) {
  const sum = (details || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
  return Math.round(sum * 100) / 100;
}
/** ä¿®æ”¹æ—¶è¡¥é½ä¸»è¡¨ä¸Žå­è¡¨å…³è” ID */
function applyReimbursementRelations(dto) {
  const rid = dto?.id;
  if (rid == null) return dto;
  if (dto.travel && typeof dto.travel === "object") {
    dto.travel.reimbursementId = rid;
  }
  if (Array.isArray(dto.details)) {
    dto.details.forEach((d) => {
      d.reimbursementId = rid;
    });
  }
  return dto;
}
function resolveReimbursementId(form) {
  const rawId = form?.reimbursementId ?? form?.id;
  if (rawId == null || rawId === "" || String(rawId).startsWith("local_")) {
    return undefined;
  }
  return toNumber(rawId) ?? rawId;
}
/** å·®æ—…表单 â†’ FinReimbursementDto */
export function buildTravelReimbursementSaveDto(form, { computeTravelDays } = {}) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const travelDays =
    form.travelDays != null
      ? toNumber(form.travelDays)
      : computeTravelDays?.(form.travelStartTime, form.travelEndTime);
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
    expenseType: "差旅费",
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || undefined,
    payeeBank: form.payeeBank || undefined,
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    travel: {
      startTime: form.travelStartTime || undefined,
      endTime: form.travelEndTime || undefined,
      travelDays,
      departureCity: form.departurePlace || "",
      destinationCity: form.destination || "",
      hotelStandard: toNumber(form.hotelStandard),
      lodgingDays: toNumber(form.hotelDays),
      mealAllowance: toNumber(form.livingSubsidy),
      transportAllowance: toNumber(form.transportSubsidy),
      lodgingLimit: toNumber(form.lodgingLimit),
      standardTag: form.standardTag || (form.needSpecialApproval ? "超标特批" : "在标准范围内"),
      withinStandard: form.needSpecialApproval ? "0" : "1",
    },
    details,
    nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) {
    dto.billNo = form.billNo || form.reimburseNo;
  }
  if (form.approvalInstanceId != null) {
    dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  }
  if (form.approveProcessId != null) {
    dto.approveProcessId = toNumber(form.approveProcessId);
  }
  if (form.travel?.id != null) dto.travel.id = toNumber(form.travel.id);
  return applyReimbursementRelations(dto);
}
/** è´¹ç”¨è¡¨å• â†’ FinReimbursementDto */
export function buildCostReimbursementSaveDto(form) {
  const details = mapDetailsToApi(form.expenseDetails);
  const detailTotal = sumDetailAmount(form.expenseDetails);
  const applyAmount = toNumber(form.applyAmount) ?? detailTotal;
  const dto = {
    reimbursementType: FIN_REIMBURSEMENT_TYPE.COST,
    expenseType: expenseCategoryToType(form.expenseCategory),
    applicantId: toNumber(form.applicantId),
    applicantCode: form.employeeNo || form.applicantNo || "",
    applicantName: form.employeeName || form.applicantName || "",
    applicantDeptId: toNumber(form.applicantDeptId),
    applicantDeptName: form.applicantDeptName || form.deptName || "",
    reason: (form.reimburseReason || "").trim(),
    applyAmount,
    detailTotalAmount: detailTotal,
    payeeName: form.payee || "",
    payeeAccount: form.payeeAccount || "",
    payeeBank: form.bankBranch || form.payeeBank || "",
    billStatus: "IN_APPROVAL",
    deptId: toNumber(form.deptId),
    details,
    nodes: mapApprovalFlowNodesToApi(form.approvalFlowNodes, form.templateId),
  };
  const id = resolveReimbursementId(form);
  if (id != null) dto.id = id;
  if (form.billNo || form.reimburseNo) {
    dto.billNo = form.billNo || form.reimburseNo;
  }
  if (form.approvalInstanceId != null) {
    dto.approvalInstanceId = toNumber(form.approvalInstanceId);
  }
  if (form.approveProcessId != null) {
    dto.approveProcessId = toNumber(form.approveProcessId);
  }
  return applyReimbursementRelations(dto);
}
/** åˆ—表行主键(删除/修改用 fin_reimbursement.id) */
export function resolveReimbursementDeleteId(row) {
  const raw = row?.reimbursementId ?? row?.id;
  if (raw == null || raw === "" || String(raw).startsWith("local_")) {
    return undefined;
  }
  const n = toNumber(raw);
  return n != null ? n : raw;
}
/** æ˜¯å¦å…è®¸åˆ é™¤ï¼ˆå®¡æ‰¹ä¸­ã€å·²é€šè¿‡ã€å·²ä»˜æ¬¾ä¸å¯åˆ ï¼‰ */
export function canDeleteReimbursementRow(row) {
  const key = mapBillStatusToApprovalResult(
    row?.billStatus ?? row?.approvalResult ?? row?.status
  );
  return key !== "pending" && key !== "approved" && key !== "paid";
}
/** æ˜¯å¦å…è®¸ç¼–辑(与删除规则一致) */
export function canEditReimbursementRow(row) {
  return canDeleteReimbursementRow(row);
}
/** ä¿®æ”¹åœºæ™¯å¿…须带主键 ID */
export function validateReimbursementPersistDto(dto, isEdit) {
  if (!isEdit) return { ok: true };
  if (dto?.id != null && dto.id !== "") return { ok: true };
  return { ok: false, message: "无法修改:缺少报销单 ID" };
}
src/views/officeProcessAutomation/ReimburseManage/shared/reimburseApproveBridge.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,124 @@
import { getFinReimbursementDetail } from "@/api/officeProcessAutomation/finReimbursement.js";
import { matchBusinessTypeValue } from "../../ApproveManage/approve-list/approveListConstants.js";
import {
  APPROVAL_MODULE_KEYS,
  getApprovalModuleConfig,
} from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import {
  getModuleKeyByReimbursementType,
  mapFinReimbursementDetailRow,
  resolveReimbursementType,
  unwrapFinReimbursementDetail,
} from "./finReimbursementMappers.js";
export const REIMBURSE_EDIT_FROM_APPROVE_KEY = "oa_reimburse_edit_from_approve";
const REIMBURSE_MODULE_KEYS = [
  APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE,
  APPROVAL_MODULE_KEYS.COST_REIMBURSE,
];
/** å®¡æ‰¹å®žä¾‹æ˜¯å¦å·®æ—…/费用报销 */
export function inferReimburseModuleKeyFromInstance(row) {
  if (!row) return "";
  for (const moduleKey of REIMBURSE_MODULE_KEYS) {
    const cfg = getApprovalModuleConfig(moduleKey);
    if (!cfg) continue;
    if (
      cfg.businessType != null &&
      cfg.businessType !== "" &&
      matchBusinessTypeValue(row.businessType, cfg.businessType)
    ) {
      return moduleKey;
    }
    if (matchBusinessTypeValue(row.businessType, cfg.approvalType)) {
      return moduleKey;
    }
    const text = `${row.templateName || ""}${row.title || ""}${row.businessName || ""}`;
    if ((cfg.typeLabels || []).some((l) => l && text.includes(l))) {
      return moduleKey;
    }
  }
  return "";
}
export function isReimburseApprovalInstance(row) {
  return Boolean(inferReimburseModuleKeyFromInstance(row));
}
/** å®¡æ‰¹å®žä¾‹å…³è”çš„ fin_reimbursement.id */
export function resolveFinReimbursementIdFromInstance(row) {
  const raw = row?.businessId ?? row?.formPayload?.reimbursementId;
  if (raw == null || raw === "") return undefined;
  const n = Number(raw);
  return Number.isNaN(n) ? raw : n;
}
/** æ‹‰å–报销详情并映射为差旅/费用页面行(以接口 reimbursementType ä¸ºå‡†ï¼‰ */
export async function loadReimburseDetailForInstance(instanceRow, moduleKey) {
  const mk = moduleKey || inferReimburseModuleKeyFromInstance(instanceRow);
  const id = resolveFinReimbursementIdFromInstance(instanceRow);
  if (id == null) {
    throw new Error("missing reimbursement id");
  }
  const res = await getFinReimbursementDetail(id);
  const raw = unwrapFinReimbursementDetail(res);
  const reimburseRow = mapFinReimbursementDetailRow(raw, mk);
  const reimbursementType = resolveReimbursementType(raw, mk);
  const resolvedMk =
    getModuleKeyByReimbursementType(reimbursementType) || mk;
  return {
    reimburseRow,
    instanceRow,
    moduleKey: resolvedMk,
    reimbursementType,
  };
}
export function stashReimburseEditFromApprove(moduleKey, reimbursementId) {
  sessionStorage.setItem(
    REIMBURSE_EDIT_FROM_APPROVE_KEY,
    JSON.stringify({ moduleKey, reimbursementId })
  );
}
export function consumeReimburseEditFromApprove() {
  const raw = sessionStorage.getItem(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  if (!raw) return null;
  sessionStorage.removeItem(REIMBURSE_EDIT_FROM_APPROVE_KEY);
  try {
    return JSON.parse(raw);
  } catch {
    return null;
  }
}
/** ä»Žå·²æ³¨å†Œè·¯ç”±è§£æžå·®æ—…/费用报销菜单 path(避免写死 path å¯¼è‡´ 404) */
export function resolveReimburseManageRoutePath(router, moduleKey) {
  if (!router?.getRoutes) return "";
  const needle =
    moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE
      ? "travel-reimburse"
      : moduleKey === APPROVAL_MODULE_KEYS.COST_REIMBURSE
        ? "cost-reimburse"
        : "";
  if (!needle) return "";
  const labelHint =
    moduleKey === APPROVAL_MODULE_KEYS.TRAVEL_REIMBURSE ? "差旅" : "费用";
  const hit = router.getRoutes().find((r) => {
    const path = r.path || "";
    if (path.includes(needle)) return true;
    const title = r.meta?.title || "";
    return title.includes(labelHint) && title.includes("报销");
  });
  return hit?.path || "";
}
export async function navigateToReimburseManageForEdit(router, moduleKey, reimbursementId) {
  stashReimburseEditFromApprove(moduleKey, reimbursementId);
  const path = resolveReimburseManageRoutePath(router, moduleKey);
  if (!path) {
    throw new Error("route not found");
  }
  await router.push(path);
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
@@ -58,9 +58,10 @@
});
const attachmentFiles = computed(() => {
  const list = props.row?.attachmentList?.length
    ? props.row.attachmentList
    : props.row?.invoiceAttachments;
  const list =
    props.row?.attachmentList ||
    props.row?.storageBlobVOList ||
    props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
@@ -1,4 +1,4 @@
<!--OA模块:差旅报销-->
<!--OA模块:差旅报销(列表 /finReimbursement/listPage,reimbursementType=1)-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
@@ -369,13 +369,21 @@
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @click="submitForm">提 äº¤</el-button>
        <el-button
          v-if="!formDialog.readonly"
          type="primary"
          :loading="submitSaving"
          @click="submitForm"
        >
          æ äº¤
        </el-button>
        <el-button @click="formDialog.visible = false">{{ formDialog.readonly ? "关 é—­" : "取 æ¶ˆ" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="差旅报销详情" width="900px" append-to-body destroy-on-close>
      <div v-loading="detailLoading">
      <DetailPanel :row="detailRow" />
      <ApprovalFlowProgress
        class="mt16"
@@ -394,6 +402,7 @@
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      </div>
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
@@ -458,6 +467,7 @@
  formDialog,
  formRules,
  detailDialog,
  detailLoading,
  detailRow,
  approveDialog,
  approveOpinion,
@@ -488,6 +498,7 @@
  openFormDialog,
  onFormClosed,
  submitForm,
  submitSaving,
  openDetail,
  approvalActionLabel,
  submitApprove,
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
@@ -15,14 +15,19 @@
}
export function statusLabel(v) {
  if (v === "draft") return "草稿";
  if (v === "approved") return "通过";
  if (v === "paid") return "已付款";
  if (v === "rejected") return "驳回";
  if (v === "cancelled") return "已撤回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "draft") return "info";
  if (v === "approved" || v === "paid") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "warning";
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
@@ -1,7 +1,29 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import {
  deleteFinReimbursement,
  getFinReimbursementDetail,
  listFinReimbursementPage,
  persistFinReimbursement,
} from "@/api/officeProcessAutomation/finReimbursement.js";
import { ElMessageBox } from "element-plus";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref } from "vue";
import {
  buildFinReimbursementListParams,
  buildTravelReimbursementSaveDto,
  canDeleteReimbursementRow,
  canEditReimbursementRow,
  filterRowsByReimbursementType,
  FIN_REIMBURSEMENT_TYPE,
  mapFinReimbursementDetailRow,
  mapTravelReimbursementRow,
  resolveReimbursementDeleteId,
  unwrapFinReimbursementDetail,
  unwrapFinReimbursementPage,
  validateReimbursementPersistDto,
} from "../shared/finReimbursementMappers.js";
import { consumeReimburseEditFromApprove } from "../shared/reimburseApproveBridge.js";
import {
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
@@ -48,43 +70,38 @@
  const form = reactive(createEmptyForm());
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const detailDialog = reactive({ visible: false });
  const detailLoading = ref(false);
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const submitSaving = ref(false);
  const filteredList = computed(() => {
    let list = [...allRows.value];
    const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.applicantName || r.employeeName || "").toLowerCase();
        const no = (r.applicantNo || r.employeeNo || "").toLowerCase();
        return name.includes(kw) || no.includes(kw);
      });
    }
    if (searchForm.travelStartFrom) {
      list = list.filter((r) => !r.travelStartTime || r.travelStartTime.slice(0, 10) >= searchForm.travelStartFrom);
    }
    if (searchForm.travelEndTo) {
      list = list.filter((r) => !r.travelEndTime || r.travelEndTime.slice(0, 10) <= searchForm.travelEndTo);
    }
    return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
  });
  const tableData = computed(() => allRows.value);
  watch(
    filteredList,
    (list) => {
      page.total = list.length;
      const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
      if (page.current > maxPage) page.current = maxPage;
    },
    { immediate: true }
  );
  const tableData = computed(() => {
    const start = (page.current - 1) * page.size;
    return filteredList.value.slice(start, start + page.size);
  });
  async function fetchList() {
    tableLoading.value = true;
    try {
      const res = await listFinReimbursementPage(
        buildFinReimbursementListParams({
          page,
          searchForm,
          reimbursementType: FIN_REIMBURSEMENT_TYPE.TRAVEL,
        })
      );
      const { records, total } = unwrapFinReimbursementPage(res);
      allRows.value = filterRowsByReimbursementType(
        records,
        FIN_REIMBURSEMENT_TYPE.TRAVEL
      ).map(mapTravelReimbursementRow);
      page.total = total;
    } catch {
      allRows.value = [];
      page.total = 0;
      proxy?.$modal?.msgError?.("差旅报销列表加载失败");
    } finally {
      tableLoading.value = false;
    }
  }
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
@@ -156,11 +173,21 @@
      label: "操作",
      align: "center",
      fixed: "right",
      width: 200,
      width: 220,
      operation: [
        { name: "编辑", type: "text", disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", clickFun: (row) => openFormDialog("edit", row) },
        {
          name: "编辑",
          type: "text",
          disabled: (row) => !canEditReimbursementRow(row),
          clickFun: (row) => openFormDialog("edit", row),
        },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        { name: "审批", type: "text", disabled: (row) => row.approvalResult !== "pending", clickFun: (row) => openApprove(row) },
        {
          name: "删除",
          type: "danger",
          disabled: (row) => !canDeleteReimbursementRow(row),
          clickFun: (row) => confirmRemoveRow(row),
        },
      ],
    },
  ]);
@@ -334,8 +361,7 @@
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => { tableLoading.value = false; }, 150);
    return fetchList();
  }
  function resetSearch() {
@@ -348,11 +374,70 @@
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
    return fetchList();
  }
  function openDetail(row) {
    detailRow.value = { ...row };
  async function loadTravelDetailRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      throw new Error("missing id");
    }
    const res = await getFinReimbursementDetail(id);
    const raw = unwrapFinReimbursementDetail(res);
    return mapFinReimbursementDetailRow(raw, FIN_REIMBURSEMENT_TYPE.TRAVEL);
  }
  async function openDetail(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法查看详情:缺少报销单 ID");
      return;
    }
    detailDialog.visible = true;
    detailLoading.value = true;
    detailRow.value = { ...row };
    try {
      detailRow.value = await loadTravelDetailRow(row);
    } catch {
      proxy?.$modal?.msgError?.("加载详情失败");
      detailDialog.visible = false;
    } finally {
      detailLoading.value = false;
    }
  }
  async function confirmRemoveRow(row) {
    const id = resolveReimbursementDeleteId(row);
    if (id == null) {
      proxy?.$modal?.msgWarning?.("无法删除:缺少报销单 ID");
      return;
    }
    const title = row.reimburseNo || row.billNo || row.reimburseReason || "该报销单";
    try {
      await ElMessageBox.confirm(
        `确定要删除「${title}」吗?删除后不可恢复。`,
        "删除确认",
        {
          type: "warning",
          confirmButtonText: "确定删除",
          cancelButtonText: "取消",
          distinguishCancelAndClose: true,
          autofocus: false,
        }
      );
    } catch {
      return;
    }
    try {
      await deleteFinReimbursement([id]);
      proxy?.$modal?.msgSuccess?.("删除成功");
      if (detailDialog.visible && resolveReimbursementDeleteId(detailRow.value) === id) {
        detailDialog.visible = false;
      }
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.("删除失败");
    }
  }
  function openApprove(row) {
@@ -373,14 +458,24 @@
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      let editRow = row;
      try {
        editRow = await loadTravelDetailRow(row);
      } catch {
        proxy?.$modal?.msgError?.("加载报销详情失败");
        return;
      }
      Object.assign(form, {
        ...JSON.parse(JSON.stringify(row)),
        attachmentList: JSON.parse(JSON.stringify(row.attachmentList || row.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(row.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(row.expenseDetails || [])),
        ...JSON.parse(JSON.stringify(editRow)),
        reimbursementId: editRow.reimbursementId ?? editRow.id,
        attachmentList: JSON.parse(JSON.stringify(editRow.attachmentList || editRow.invoiceAttachments || [])),
        approvalFlowNodes: JSON.parse(JSON.stringify(editRow.approvalFlowNodes || [])),
        expenseDetails: JSON.parse(JSON.stringify(editRow.expenseDetails || [])),
      });
      const u = userById(row.applicantId);
      applicantFormOptions.value = u ? [u] : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
      const u = userById(editRow.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: editRow.applicantId, nickName: editRow.employeeName, userName: editRow.employeeNo }];
    } else {
      form.approvalFlowNodes = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }];
      remoteSearchApplicantForm("");
@@ -414,63 +509,25 @@
        return;
      }
    }
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
    const payload = {
      reimburseNo: form.reimburseNo || `TR${dayjs().format("YYYYMMDDHHmmss")}`,
      applicantId: form.applicantId,
      employeeNo: form.employeeNo,
      employeeName: form.employeeName,
      applicantNo: form.employeeNo,
      applicantName: form.employeeName,
      reimburseReason: form.reimburseReason,
      travelStartTime: form.travelStartTime,
      travelEndTime: form.travelEndTime,
      travelDays: days,
      departurePlace: form.departurePlace,
      destination: form.destination,
      hotelStandard: form.hotelStandard,
      hotelDays: form.hotelDays,
      livingSubsidy: form.livingSubsidy,
      applyAmount: form.applyAmount,
      payee: form.payee,
      expenseDetails: JSON.parse(JSON.stringify(form.expenseDetails)),
      attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
      invoiceAttachments: mapAttachmentList(form.attachmentList),
      approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
      currentNodeIndex: 0,
      needSpecialApproval: form.needSpecialApproval,
      deptId: form.deptId,
      deptName: form.deptName,
      travelTier: form.travelTier,
    };
    if (formDialog.mode === "add") {
      allRows.value.unshift({
        id: `local_${Date.now()}`,
        ...payload,
        approvalResult: "pending",
        rejectReason: "",
        approvalRecords: [],
        createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
      });
      proxy?.$modal?.msgSuccess?.("提交成功");
    } else {
      const idx = allRows.value.findIndex((r) => r.id === form.id);
      if (idx !== -1) {
        const prev = allRows.value[idx];
        allRows.value[idx] = {
          ...prev,
          ...payload,
          id: form.id,
          approvalResult: prev.approvalResult === "rejected" ? "pending" : prev.approvalResult,
          approvalFlowNodes: initApprovalFlowNodes(form.approvalFlowNodes),
          currentNodeIndex: 0,
          createTime: prev.createTime,
        };
      }
      proxy?.$modal?.msgSuccess?.("保存成功");
    if (submitSaving.value) return;
    const isEdit = formDialog.mode === "edit";
    const dto = buildTravelReimbursementSaveDto(form, { computeTravelDays });
    const check = validateReimbursementPersistDto(dto, isEdit);
    if (!check.ok) {
      proxy?.$modal?.msgWarning?.(check.message);
      return;
    }
    formDialog.visible = false;
    handleQuery();
    submitSaving.value = true;
    try {
      await persistFinReimbursement(dto, isEdit);
      proxy?.$modal?.msgSuccess?.(isEdit ? "保存成功" : "提交成功");
      formDialog.visible = false;
      await handleQuery();
    } catch {
      proxy?.$modal?.msgError?.(isEdit ? "保存失败" : "提交失败");
    } finally {
      submitSaving.value = false;
    }
  }
  async function submitApprove(result) {
@@ -511,7 +568,7 @@
  }
  function handleExport() {
    const data = filteredList.value;
    const data = allRows.value;
    const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
    const url = URL.createObjectURL(blob);
    const a = document.createElement("a");
@@ -549,7 +606,14 @@
    reader.readAsText(file, "utf-8");
  }
  onMounted(() => loadUserPool());
  onMounted(async () => {
    loadUserPool();
    await fetchList();
    const editPayload = consumeReimburseEditFromApprove();
    if (editPayload?.reimbursementId != null) {
      await openFormDialog("edit", { reimbursementId: editPayload.reimbursementId });
    }
  });
  return {
    Search,
@@ -566,6 +630,7 @@
    formDialog,
    formRules,
    detailDialog,
    detailLoading,
    detailRow,
    approveDialog,
    approveOpinion,
@@ -596,7 +661,9 @@
    openFormDialog,
    onFormClosed,
    submitForm,
    submitSaving,
    openDetail,
    confirmRemoveRow,
    openApprove,
    approvalActionLabel,
    submitApprove,