yyb
昨天 9bfda877a67bd2bdfe0c12bfca8ccf88f8db3f4b
合并OA流程页面文件夹 dev-new_pro_OA -> dev_NEW_pro
已添加58个文件
17519 ■■■■■ 文件已修改
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js 316 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue 94 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue 436 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js 343 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js 160 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue 356 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue 365 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js 260 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue 934 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue 360 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue 909 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue 12 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue 169 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js 375 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue 461 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js 440 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/post-manage/index.vue 292 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue 676 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue 347 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/resign-apply/index.vue 245 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue 181 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue 263 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue 115 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue 176 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue 304 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue 141 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue 73 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-archive/index.vue 407 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue 197 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/staff-contract/index.vue 314 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue 792 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/HrManage/work-handover/index.vue 810 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue 253 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/noticeAnnouncementUtils.js 194 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js 332 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue 71 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js 309 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue 556 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js 662 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue 49 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue 81 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue 623 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js 193 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js 689 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue 291 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue 315 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue 123 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue 550 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue 59 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue 168 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue 67 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue 134 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue 191 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/officeProcessAutomation/ApproveManage/approve-list/approveListConstants.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,316 @@
import dayjs from "dayjs";
/** å®¡æ‰¹ç±»åž‹ï¼ˆä¸ŽåŽç«¯å­—段 approvalType å¯¹é½ï¼ŒåŽæœŸå¯åŒæ­¥ï¼‰ */
export const APPROVAL_TYPE_OPTIONS = [
  { value: "cost_reimburse", label: "费用报销申请", cellBg: "#e8f8ef", cellColor: "#1a7f4b" },
  { value: "travel_reimburse", label: "差旅报销申请", cellBg: "#f0f2f5", cellColor: "#606266" },
  { value: "overtime", label: "加班申请", cellBg: "#fdf3e8", cellColor: "#c45c26" },
  { value: "leave", label: "请假申请", cellBg: "#fce8f0", cellColor: "#b84d7a" },
  { value: "work_handover", label: "工作交接申请", cellBg: "#f0e8fc", cellColor: "#6b4d9e" },
  { value: "regular", label: "转正申请", cellBg: "#e8f4fc", cellColor: "#2b6cb0" },
  { value: "resign", label: "离职申请", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" },
  { value: "transfer", label: "调岗申请", cellBg: "#ffffff", cellColor: "#303133", border: "1px solid #e4e7ed" },
  { value: "out_office", label: "公出申请", cellBg: "#e8f4ff", cellColor: "#409eff" },
  { value: "business_trip", label: "出差申请", cellBg: "#fdf6ec", cellColor: "#e6a23c" },
  { value: "procurement", label: "采购审批", cellBg: "#f4f4f5", cellColor: "#909399" },
  { value: "quotation", label: "报价审批", cellBg: "#f4ecfc", cellColor: "#9b59b6" },
  { value: "shipment", label: "发货审批", cellBg: "#e8faf6", cellColor: "#1abc9c" },
];
/** å®¡æ‰¹çŠ¶æ€ approvalStatus */
export const APPROVAL_STATUS_OPTIONS = [
  { value: "pending", label: "审核中" },
  { value: "approved", label: "已通过" },
  { value: "rejected", label: "已驳回" },
  { value: "cancelled", label: "已撤销" },
];
/** å®¡æ‰¹æ–¹å¼ approvalMode */
export const APPROVAL_MODE_OPTIONS = [
  { value: "parallel", label: "与签" },
  { value: "or_sign", label: "或签" },
];
/**
 * æäº¤å®¡æ‰¹æ¨¡æ¿ï¼ˆæŒ‰ç±»åž‹ä¸€é”®å¡«æŠ¥ï¼Œå­—段后期与后端模板同步)
 */
export const SUBMIT_TEMPLATES = {
  cost_reimburse: {
    approvalType: "cost_reimburse",
    label: "费用报销",
    summaryPlaceholder: "请填写报销事由、金额等",
    fields: [
      { key: "summary", label: "申请事由", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
    ],
    approvalMode: "parallel",
  },
  travel_reimburse: {
    approvalType: "travel_reimburse",
    label: "差旅报销",
    summaryPlaceholder: "出差行程与费用说明",
    fields: [
      { key: "summary", label: "差旅说明", type: "textarea", required: true, rows: 3 },
      { key: "amount", label: "报销金额(元)", type: "number", required: true, min: 0, precision: 2 },
      { key: "tripDays", label: "出差天数", type: "number", required: false, min: 0, precision: 0 },
    ],
    approvalMode: "parallel",
  },
  overtime: {
    approvalType: "overtime",
    label: "加班申请",
    fields: [
      { key: "summary", label: "加班事由", type: "textarea", required: true, rows: 3 },
      { key: "overtimeDate", label: "加班日期", type: "date", required: true },
      { key: "hours", label: "加班时长(小时)", type: "number", required: true, min: 0.5, precision: 1 },
    ],
    approvalMode: "parallel",
  },
  leave: {
    approvalType: "leave",
    label: "请假申请",
    fields: [
      { key: "leaveType", label: "请假类型", type: "select", required: true, options: [
        { label: "年假", value: "annual" },
        { label: "病假", value: "sick" },
        { label: "事假", value: "personal" },
        { label: "调休", value: "compensatory" },
      ] },
      { key: "summary", label: "请假事由", type: "textarea", required: true, rows: 2 },
      { key: "dateRange", label: "请假时间", type: "datetimerange", required: true },
    ],
    approvalMode: "parallel",
  },
  work_handover: {
    approvalType: "work_handover",
    label: "工作交接",
    fields: [
      { key: "summary", label: "交接说明", type: "textarea", required: true, rows: 3 },
      { key: "handoverTo", label: "交接对象", type: "text", required: true },
    ],
    approvalMode: "parallel",
  },
  regular: {
    approvalType: "regular",
    label: "转正申请",
    fields: [
      { key: "summary", label: "转正说明", type: "textarea", required: true, rows: 3 },
      { key: "regularDate", label: "拟转正日期", type: "date", required: true },
    ],
    approvalMode: "parallel",
  },
  resign: {
    approvalType: "resign",
    label: "离职申请",
    fields: [
      { key: "summary", label: "离职原因", type: "textarea", required: true, rows: 3 },
      { key: "lastWorkDay", label: "最后工作日", type: "date", required: true },
    ],
    approvalMode: "or_sign",
  },
  transfer: {
    approvalType: "transfer",
    label: "调岗申请",
    fields: [
      { key: "summary", label: "调岗说明", type: "textarea", required: true, rows: 2 },
      { key: "targetDept", label: "目标部门", type: "text", required: true },
      { key: "targetPost", label: "目标岗位", type: "text", required: true },
    ],
    approvalMode: "parallel",
  },
};
export const STORAGE_KEY = "oa_unified_approve_list_v1";
export function approvalTypeLabel(v) {
  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function approvalTypeStyle(v) {
  const hit = APPROVAL_TYPE_OPTIONS.find((x) => x.value === v);
  if (!hit) return {};
  return {
    backgroundColor: hit.cellBg,
    color: hit.cellColor,
    border: hit.border || "none",
  };
}
export function approvalStatusLabel(v) {
  return APPROVAL_STATUS_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function approvalStatusTagType(v) {
  if (v === "approved") return "success";
  if (v === "rejected") return "danger";
  if (v === "cancelled") return "info";
  return "primary";
}
export function approvalModeLabel(v) {
  if (v === "countersign") return "或签";
  return APPROVAL_MODE_OPTIONS.find((x) => x.value === v)?.label || "与签";
}
export function unreadLabel(v) {
  return v ? "是" : "否";
}
export function buildDefaultFlowNodes() {
  return [
    {
      approverId: "mock_supervisor",
      approverName: "直属上级",
      sortOrder: 1,
      nodeOrder: 1,
      nodeStatus: "process",
      approveOpinion: "",
      approveTime: "",
    },
    {
      approverId: "mock_manager",
      approverName: "部门经理",
      sortOrder: 2,
      nodeOrder: 2,
      nodeStatus: "wait",
      approveOpinion: "",
      approveTime: "",
    },
  ];
}
function demoRow(partial) {
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  return {
    id: partial.id,
    bizId: partial.bizId || partial.id,
    applicantNo: partial.applicantNo,
    applicantName: partial.applicantName,
    approvalType: partial.approvalType,
    approvalMode: partial.approvalMode || "parallel",
    unread: partial.unread ?? false,
    approvalStatus: partial.approvalStatus || "pending",
    createTime: partial.createTime || now,
    summary: partial.summary || "",
    formPayload: partial.formPayload || {},
    approvalFlowNodes: partial.approvalFlowNodes || buildDefaultFlowNodes(),
    currentNodeIndex: partial.currentNodeIndex ?? 0,
    approvalRecords: partial.approvalRecords || [],
    rejectReason: partial.rejectReason || "",
    sourceRoute: partial.sourceRoute || "",
  };
}
/** åˆå§‹æ¼”示数据(共 22 æ¡ï¼Œä¸ŽåŽŸåž‹æ•°é‡ä¸€è‡´ï¼‰ */
export function createInitialMockRows() {
  const types = [
    "cost_reimburse",
    "travel_reimburse",
    "overtime",
    "leave",
    "work_handover",
    "regular",
    "resign",
    "transfer",
    "cost_reimburse",
    "leave",
    "overtime",
    "travel_reimburse",
    "work_handover",
    "regular",
    "cost_reimburse",
    "leave",
    "transfer",
    "resign",
    "overtime",
    "travel_reimburse",
    "cost_reimburse",
    "leave",
  ];
  const applicants = [
    { no: "007", name: "苹果" },
    { no: "Guest001", name: "外部用户" },
    { no: "0056", name: "王五" },
    { no: "0042", name: "李四" },
    { no: "0088", name: "猫猫" },
    { no: "0012", name: "张三" },
    { no: "0033", name: "赵六" },
  ];
  const summaries = [
    "办公用品采购报销",
    "上海出差差旅费",
    "周末项目加班",
    "年假 3 å¤©",
    "离职工作交接",
    "试用期转正申请",
    "个人原因离职",
    "调至销售部",
    "客户接待餐费",
    "病假 1 å¤©",
    "节假日值班加班",
    "北京培训差旅",
    "项目文档交接",
    "研发岗转正",
    "通讯费报销",
    "事假半天",
    "调岗至市场部",
    "协商离职",
    "工作日延时加班",
    "成都展会差旅",
    "交通费报销",
    "调休 1 å¤©",
  ];
  const statuses = ["pending", "pending", "pending", "approved", "pending", "pending", "rejected", "pending"];
  return types.map((approvalType, i) => {
    const ap = applicants[i % applicants.length];
    const daysAgo = i % 14;
    return demoRow({
      id: `mock_${i + 1}`,
      bizId: `BIZ${String(2025031400 + i)}`,
      applicantNo: ap.no,
      applicantName: ap.name,
      approvalType,
      approvalMode: i % 5 === 0 ? "or_sign" : "parallel",
      unread: i % 3 === 0,
      approvalStatus: statuses[i % statuses.length],
      createTime: dayjs().subtract(daysAgo, "day").hour(9 + (i % 8)).minute((i * 7) % 60).second(0).format("YYYY-MM-DD HH:mm:ss"),
      summary: summaries[i],
      formPayload: { summary: summaries[i] },
    });
  });
}
export function loadStoredRows() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
}
export function saveStoredRows(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
  } catch {
    /* ignore quota */
  }
}
export function createEmptySubmitForm(templateKey) {
  const tpl = SUBMIT_TEMPLATES[templateKey];
  const payload = { summary: "" };
  (tpl?.fields || []).forEach((f) => {
    if (f.type === "number") payload[f.key] = undefined;
    else if (f.type === "datetimerange") payload[f.key] = [];
    else payload[f.key] = "";
  });
  return {
    templateKey: templateKey || "",
    approvalMode: tpl?.approvalMode || "parallel",
    formPayload: payload,
    approvalFlowNodes: buildDefaultFlowNodes(),
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-list/components/ApproveDetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,94 @@
<!-- ç»Ÿä¸€å®¡æ‰¹ï¼šä¸šåŠ¡æ‘˜è¦ -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="业务单号">{{ row.bizId || row.id || "—" }}</el-descriptions-item>
    <el-descriptions-item label="审批状态">
      <el-tag :type="approvalStatusTagType(row.approvalStatus)" size="small" effect="plain">
        {{ approvalStatusLabel(row.approvalStatus) }}
      </el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="审批类型">
      <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
        {{ approvalTypeLabel(row.approvalType) }}
      </span>
    </el-descriptions-item>
    <el-descriptions-item label="审批方式">
      <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="申请人编号">{{ row.applicantNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="申请人名称">{{ row.applicantName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="申请摘要" :span="2">{{ row.summary || "—" }}</el-descriptions-item>
    <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
      <span class="reject-text">{{ row.rejectReason }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="创建时间" :span="2">{{ row.createTime || "—" }}</el-descriptions-item>
  </el-descriptions>
  <template v-if="extraFields.length">
    <el-divider content-position="left">填报内容</el-divider>
    <el-descriptions :column="2" border size="small">
      <el-descriptions-item v-for="item in extraFields" :key="item.key" :label="item.label">
        {{ item.display }}
      </el-descriptions-item>
    </el-descriptions>
  </template>
</template>
<script setup>
import { computed } from "vue";
import {
  approvalTypeLabel,
  approvalTypeStyle,
  approvalModeLabel,
  approvalStatusLabel,
  approvalStatusTagType,
  SUBMIT_TEMPLATES,
} from "../approveListConstants.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const extraFields = computed(() => {
  const payload = props.row?.formPayload || {};
  const tpl = Object.values(SUBMIT_TEMPLATES).find((t) => t.approvalType === props.row?.approvalType);
  if (!tpl?.fields?.length) {
    return Object.keys(payload)
      .filter((k) => k !== "summary" && payload[k] != null && payload[k] !== "")
      .map((k) => ({ key: k, label: k, display: formatValue(payload[k]) }));
  }
  return tpl.fields
    .map((f) => {
      const val = payload[f.key];
      if (val == null || val === "" || (Array.isArray(val) && !val.length)) return null;
      let display = formatValue(val);
      if (f.type === "select" && f.options) {
        display = f.options.find((o) => o.value === val)?.label || display;
      }
      return { key: f.key, label: f.label, display };
    })
    .filter(Boolean);
});
function formatValue(val) {
  if (Array.isArray(val)) return val.join(" è‡³ ");
  return String(val);
}
</script>
<style scoped>
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.approval-method-text {
  color: var(--el-color-danger);
  font-weight: 500;
}
.reject-text {
  color: var(--el-color-danger);
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,436 @@
<!--OA模块:审批列表-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">审批类型:</span>
        <el-select
          v-model="searchForm.approvalType"
          placeholder="请选择审批类型"
          clearable
          filterable
          style="width: 200px"
        >
          <el-option
            v-for="opt in APPROVAL_TYPE_OPTIONS"
            :key="opt.value"
            :label="opt.label"
            :value="opt.value"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">申请人名称:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 200px"
          placeholder="请输入申请人名称"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">创建时间:</span>
        <el-date-picker
          v-model="searchForm.createTimeRange"
          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" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button :icon="RefreshRight" @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="primary" :icon="Plus" @click="openSubmitDialog">提交审批</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 #approveType="{ row }">
          <span class="approve-type-cell" :style="approvalTypeStyle(row.approvalType)">
            {{ approvalTypeLabel(row.approvalType) }}
          </span>
        </template>
        <template #approvalMethod="{ row }">
          <span class="approval-method-text">{{ approvalModeLabel(row.approvalMode) }}</span>
        </template>
      </PIMTable>
    </div>
    <!-- æäº¤å®¡æ‰¹ï¼ˆæŒ‰æ¨¡æ¿ï¼‰ -->
    <el-dialog
      v-model="submitDialog.visible"
      :title="submitDialog.step === 1 ? '选择审批模板' : `提交${activeTemplate?.label || '审批'}`"
      width="720px"
      append-to-body
      destroy-on-close
      class="approve-submit-dialog"
      @closed="submitDialog.step = 1"
    >
      <template v-if="submitDialog.step === 1">
        <p class="template-hint">请选择要提交的审批类型,系统将按对应模板引导填报(字段后期与后端同步)。</p>
        <div class="template-grid">
          <div
            v-for="(tpl, key) in SUBMIT_TEMPLATES"
            :key="key"
            class="template-card"
            @click="onTemplatePick(key)"
          >
            <span class="template-card-type" :style="approvalTypeStyle(tpl.approvalType)">
              {{ tpl.label }}
            </span>
            <span class="template-card-desc">{{ tpl.summaryPlaceholder || "点击填写并提交" }}</span>
          </div>
        </div>
      </template>
      <template v-else>
        <el-form ref="submitFormRef" :model="submitForm" :rules="submitFormRules" label-width="120px">
          <el-form-item label="审批类型">
            <span class="approve-type-cell" :style="approvalTypeStyle(activeTemplate.approvalType)">
              {{ activeTemplate.label }}
            </span>
            <el-button type="primary" link class="ml12" @click="backToTemplatePick">更换模板</el-button>
          </el-form-item>
          <el-form-item label="审批方式" prop="approvalMode">
            <el-radio-group v-model="submitForm.approvalMode">
              <el-radio value="parallel">与签</el-radio>
              <el-radio value="or_sign">或签</el-radio>
            </el-radio-group>
          </el-form-item>
          <template v-for="field in activeTemplate.fields" :key="field.key">
            <el-form-item :label="field.label" :prop="`formPayload.${field.key}`">
              <el-input
                v-if="field.type === 'text'"
                v-model="submitForm.formPayload[field.key]"
                :placeholder="`请输入${field.label}`"
                maxlength="200"
              />
              <el-input
                v-else-if="field.type === 'textarea'"
                v-model="submitForm.formPayload[field.key]"
                type="textarea"
                :rows="field.rows || 3"
                :placeholder="`请填写${field.label}`"
                maxlength="2000"
                show-word-limit
              />
              <el-input-number
                v-else-if="field.type === 'number'"
                v-model="submitForm.formPayload[field.key]"
                :min="field.min ?? 0"
                :precision="field.precision ?? 0"
                controls-position="right"
                style="width: 100%"
              />
              <el-date-picker
                v-else-if="field.type === 'date'"
                v-model="submitForm.formPayload[field.key]"
                type="date"
                :placeholder="`请选择${field.label}`"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
              <el-date-picker
                v-else-if="field.type === 'datetimerange'"
                v-model="submitForm.formPayload[field.key]"
                type="datetimerange"
                range-separator="至"
                start-placeholder="开始时间"
                end-placeholder="结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
              />
              <el-select
                v-else-if="field.type === 'select'"
                v-model="submitForm.formPayload[field.key]"
                :placeholder="`请选择${field.label}`"
                style="width: 100%"
                clearable
              >
                <el-option v-for="o in field.options" :key="o.value" :label="o.label" :value="o.value" />
              </el-select>
            </el-form-item>
          </template>
          <el-form-item label="审批流程">
            <ApprovalFlowEditor v-model="submitForm.approvalFlowNodes" :user-options="flowUserOptions" />
            <p class="flow-tip">至少保留一个审批节点;提交后进入「审核中」状态。</p>
          </el-form-item>
        </el-form>
      </template>
      <template #footer>
        <el-button v-if="submitDialog.step === 2" type="primary" @click="onSubmitNew">提 äº¤</el-button>
        <el-button @click="submitDialog.visible = false">{{ submitDialog.step === 1 ? "取 æ¶ˆ" : "关 é—­" }}</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog
      v-model="detailDialog.visible"
      title="审批详情"
      width="920px"
      append-to-body
      destroy-on-close
    >
      <ApproveDetailPanel :row="detailRow" />
      <el-divider content-position="left">审批流程</el-divider>
      <ApprovalFlowProgress
        :nodes="detailRow.approvalFlowNodes"
        :current-index="detailRow.currentNodeIndex ?? 0"
      />
      <el-divider content-position="left">审批记录</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      <template #footer>
        <el-button
          v-if="detailRow.approvalStatus === 'pending'"
          type="primary"
          @click="openApproveFromDetail"
        >
          åŽ»å®¡æ‰¹
        </el-button>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹æ“ä½œ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="审批处理"
      width="960px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <ApproveDetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见" required>
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写具体原因"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="onApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="onApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "@/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue";
import { approvalTypeStyle } from "./approveListConstants.js";
import ApproveDetailPanel from "./components/ApproveDetailPanel.vue";
import { useApproveList } from "./useApproveList.js";
const al = useApproveList();
const {
  Search,
  APPROVAL_TYPE_OPTIONS,
  SUBMIT_TEMPLATES,
  approvalTypeLabel,
  approvalModeLabel,
  approvalActionLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  detailDialog,
  detailRow,
  approveDialog,
  approveOpinion,
  submitDialog,
  submitForm,
  submitFormRef,
  activeTemplate,
  submitFormRules,
  handleQuery,
  resetSearch,
  pagination,
  openSubmitDialog,
  onTemplatePick,
  backToTemplatePick,
  submitNewApproval,
  submitApprove,
  openDetail,
  openApprove,
} = al;
const flowUserOptions = ref([]);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
async function loadUsers() {
  try {
    const res = await userListNoPageByTenantId();
    flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
  } catch {
    flowUserOptions.value = [];
  }
}
async function onSubmitNew() {
  const ok = await submitNewApproval();
  if (ok) ElMessage.success("审批已提交");
}
function onApprove(result) {
  const ret = submitApprove(result);
  if (ret?.needOpinion) {
    ElMessage.warning("驳回时请填写审批意见");
    return;
  }
  if (ret?.ok) {
    ElMessage.success(result === "approved" ? "已通过" : "已驳回");
  }
}
function openApproveFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openApprove(row);
}
onMounted(() => {
  loadUsers();
  handleQuery();
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.ml12 {
  margin-left: 12px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.approve-type-cell {
  display: inline-block;
  padding: 2px 10px;
  border-radius: 4px;
  font-size: 13px;
  line-height: 1.5;
}
.approval-method-text {
  color: var(--el-color-danger);
  font-weight: 500;
}
.template-hint {
  font-size: 13px;
  color: var(--el-text-color-secondary);
  margin: 0 0 16px;
}
.template-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(200px, 1fr));
  gap: 12px;
}
.template-card {
  padding: 14px 16px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: var(--radius-md, 8px);
  cursor: pointer;
  transition: border-color 0.2s, box-shadow 0.2s;
  background: var(--el-fill-color-blank);
}
.template-card:hover {
  border-color: var(--el-color-primary);
  box-shadow: var(--shadow-sm, 0 2px 8px rgba(0, 0, 0, 0.06));
}
.template-card-type {
  display: inline-block;
  padding: 2px 8px;
  border-radius: 4px;
  font-size: 13px;
  font-weight: 600;
  margin-bottom: 8px;
}
.template-card-desc {
  display: block;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.approve-submit-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-list/useApproveList.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,343 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import useUserStore from "@/store/modules/user";
import { computed, reactive, ref, watch } from "vue";
import {
  APPROVAL_TYPE_OPTIONS,
  SUBMIT_TEMPLATES,
  approvalModeLabel,
  approvalStatusLabel,
  approvalStatusTagType,
  approvalTypeLabel,
  createEmptySubmitForm,
  createInitialMockRows,
  loadStoredRows,
  saveStoredRows,
  buildDefaultFlowNodes,
} from "./approveListConstants.js";
function advanceFlow(row, result, opinion) {
  const nodes = row.approvalFlowNodes || [];
  const idx = row.currentNodeIndex ?? 0;
  const node = nodes[idx];
  if (!node) return;
  node.nodeStatus = result === "approved" ? "finish" : "error";
  node.approveOpinion = opinion || (result === "approved" ? "同意" : "驳回");
  node.approveTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
  row.approvalRecords = row.approvalRecords || [];
  row.approvalRecords.push({
    operatorName: node.approverName || "审批人",
    result,
    opinion: node.approveOpinion,
    time: node.approveTime,
  });
  if (result === "rejected") {
    row.approvalStatus = "rejected";
    row.rejectReason = opinion || node.approveOpinion;
    return;
  }
  const next = idx + 1;
  if (next < nodes.length) {
    row.currentNodeIndex = next;
    nodes[next].nodeStatus = "process";
    row.approvalStatus = "pending";
  } else {
    row.approvalStatus = "approved";
    row.rejectReason = "";
  }
}
export function useApproveList() {
  const userStore = useUserStore();
  const stored = loadStoredRows();
  const allRows = ref(stored?.length ? stored : createInitialMockRows());
  const searchForm = reactive({
    approvalType: "",
    applicantKeyword: "",
    createTimeRange: [],
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  const submitDialog = reactive({ visible: false, step: 1 });
  const submitForm = reactive(createEmptySubmitForm(""));
  const submitFormRef = ref();
  const filteredList = computed(() => {
    let list = [...allRows.value];
    if (searchForm.approvalType) {
      list = list.filter((r) => r.approvalType === searchForm.approvalType);
    }
    const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.applicantName || "").toLowerCase();
        const no = (r.applicantNo || "").toLowerCase();
        return name.includes(kw) || no.includes(kw);
      });
    }
    const range = searchForm.createTimeRange;
    if (range?.length === 2) {
      const [from, to] = range;
      list = list.filter((r) => {
        const t = (r.createTime || "").slice(0, 10);
        return t && t >= from && t <= to;
      });
    }
    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);
  });
  const activeTemplate = computed(() => SUBMIT_TEMPLATES[submitForm.templateKey] || null);
  const submitFormRules = computed(() => {
    const rules = {
      templateKey: [{ required: true, message: "请选择审批类型", trigger: "change" }],
    };
    (activeTemplate.value?.fields || []).forEach((f) => {
      if (!f.required) return;
      if (f.type === "number") {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
      } else if (f.type === "datetimerange") {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请选择${f.label}`, trigger: "change" }];
      } else {
        rules[`formPayload.${f.key}`] = [{ required: true, message: `请填写${f.label}`, trigger: "blur" }];
      }
    });
    return rules;
  });
  const tableColumn = ref([
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人名称", prop: "applicantName", minWidth: 100 },
    {
      label: "审批类型",
      prop: "approvalType",
      minWidth: 140,
      dataType: "slot",
      slot: "approveType",
    },
    {
      label: "审批方式",
      prop: "approvalMode",
      width: 90,
      dataType: "slot",
      slot: "approvalMethod",
    },
    {
      label: "是否未读",
      prop: "unread",
      width: 90,
      align: "center",
      formatData: (v) => (v ? "是" : "否"),
    },
    {
      label: "审批状态",
      prop: "approvalStatus",
      width: 100,
      dataType: "tag",
      formatData: (v) => approvalStatusLabel(v),
      formatType: (v) => approvalStatusTagType(v),
    },
    { label: "创建时间", prop: "createTime", width: 170 },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 160,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "审批",
          type: "text",
          disabled: (row) => row.approvalStatus !== "pending",
          clickFun: (row) => openApprove(row),
        },
      ],
    },
  ]);
  function persist() {
    saveStoredRows(allRows.value);
  }
  function handleQuery() {
    tableLoading.value = true;
    page.current = 1;
    setTimeout(() => {
      tableLoading.value = false;
    }, 200);
  }
  function resetSearch() {
    searchForm.approvalType = "";
    searchForm.applicantKeyword = "";
    searchForm.createTimeRange = [];
    handleQuery();
  }
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
  }
  function markRead(row) {
    if (!row.unread) return;
    const hit = allRows.value.find((r) => r.id === row.id);
    if (hit) {
      hit.unread = false;
      persist();
    }
  }
  function openDetail(row) {
    markRead(row);
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openApprove(row) {
    markRead(row);
    approveDialog.row = { ...row };
    approveOpinion.value = "";
    approveDialog.visible = true;
  }
  function openSubmitDialog() {
    Object.assign(submitForm, createEmptySubmitForm(""));
    submitDialog.step = 1;
    submitDialog.visible = true;
  }
  function onTemplatePick(key) {
    Object.assign(submitForm, createEmptySubmitForm(key));
    submitDialog.step = 2;
  }
  function backToTemplatePick() {
    submitDialog.step = 1;
  }
  async function submitNewApproval() {
    if (!submitFormRef.value) return false;
    try {
      await submitFormRef.value.validate();
    } catch {
      return false;
    }
    const tpl = activeTemplate.value;
    if (!tpl) return false;
    const id = `user_${Date.now()}`;
    const summary =
      submitForm.formPayload.summary ||
      submitForm.formPayload.handoverTo ||
      `${tpl.label}申请`;
    const row = {
      id,
      bizId: `BIZ${dayjs().format("YYYYMMDDHHmmss")}`,
      applicantNo: userStore.name || String(userStore.id || "当前用户"),
      applicantName: userStore.nickName || userStore.name || "当前用户",
      approvalType: tpl.approvalType,
      approvalMode: submitForm.approvalMode,
      unread: false,
      approvalStatus: "pending",
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
      summary,
      formPayload: { ...submitForm.formPayload },
      approvalFlowNodes: (submitForm.approvalFlowNodes?.length
        ? submitForm.approvalFlowNodes
        : buildDefaultFlowNodes()
      ).map((n, i) => ({ ...n, nodeStatus: i === 0 ? "process" : n.nodeStatus || "wait" })),
      currentNodeIndex: 0,
      approvalRecords: [],
      rejectReason: "",
    };
    allRows.value.unshift(row);
    persist();
    submitDialog.visible = false;
    page.current = 1;
    return true;
  }
  function submitApprove(result) {
    const row = approveDialog.row;
    if (!row) return;
    const hit = allRows.value.find((r) => r.id === row.id);
    if (!hit || hit.approvalStatus !== "pending") return;
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      return { needOpinion: true };
    }
    advanceFlow(hit, result, (approveOpinion.value || "").trim());
    hit.unread = false;
    persist();
    approveDialog.visible = false;
    if (detailDialog.visible && detailRow.value?.id === hit.id) {
      detailRow.value = { ...hit };
    }
    return { ok: true };
  }
  function approvalActionLabel(result) {
    if (result === "approved") return "通过";
    if (result === "rejected") return "驳回";
    return "待处理";
  }
  return {
    Search,
    APPROVAL_TYPE_OPTIONS,
    SUBMIT_TEMPLATES,
    approvalTypeLabel,
    approvalModeLabel,
    approvalStatusLabel,
    approvalStatusTagType,
    approvalActionLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    detailDialog,
    detailRow,
    approveDialog,
    approveOpinion,
    submitDialog,
    submitForm,
    submitFormRef,
    activeTemplate,
    submitFormRules,
    handleQuery,
    resetSearch,
    pagination,
    openSubmitDialog,
    onTemplatePick,
    backToTemplatePick,
    submitNewApproval,
    submitApprove,
    openDetail,
    openApprove,
  };
}
src/views/officeProcessAutomation/ApproveManage/approve-template/approveTemplateConstants.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,160 @@
import dayjs from "dayjs";
import { APPROVAL_TYPE_OPTIONS, SUBMIT_TEMPLATES } from "../approve-list/approveListConstants.js";
/** èŠ‚ç‚¹å†…å®¡æ‰¹æ–¹å¼ï¼šä¼šç­¾ / æˆ–ç­¾ */
export const NODE_SIGN_MODE_OPTIONS = [
  { value: "countersign", label: "会签", desc: "本节点所有审批人均需通过" },
  { value: "or_sign", label: "或签", desc: "本节点任一审批人通过即可" },
];
export const STORAGE_KEY = "oa_approve_template_custom_v1";
/** ç³»ç»Ÿå†…置常用审批(只读展示,来源于审批列表提交模板) */
export function getBuiltinTemplates() {
  return Object.entries(SUBMIT_TEMPLATES).map(([key, tpl]) => ({
    key,
    approvalType: tpl.approvalType,
    label: tpl.label,
    summary: tpl.summaryPlaceholder || "系统预置填报字段",
    fieldCount: (tpl.fields || []).length,
    defaultMode: tpl.approvalMode,
  }));
}
export function nodeSignModeLabel(mode) {
  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.label || "—";
}
export function approvalTypeLabel(type) {
  return APPROVAL_TYPE_OPTIONS.find((x) => x.value === type)?.label || type || "—";
}
export function createEmptyNode(order = 1) {
  return {
    nodeOrder: order,
    signMode: "countersign",
    approvers: [],
  };
}
export function createEmptyTemplateForm() {
  return {
    id: "",
    templateName: "",
    description: "",
    enabled: true,
    flowNodes: [createEmptyNode(1)],
  };
}
export function normalizeFlowNodes(nodes) {
  const list = Array.isArray(nodes) ? nodes : [];
  return list.map((n, i) => ({
    nodeOrder: i + 1,
    signMode: n.signMode === "or_sign" ? "or_sign" : "countersign",
    approvers: (n.approvers || [])
      .filter((a) => a?.approverId != null && a.approverId !== "")
      .map((a) => ({
        approverId: a.approverId,
        approverName: a.approverName || "",
      })),
  }));
}
export function validateTemplateForm(form) {
  const name = (form.templateName || "").trim();
  if (!name) return { ok: false, message: "请填写模板名称" };
  const nodes = normalizeFlowNodes(form.flowNodes);
  if (!nodes.length) return { ok: false, message: "请至少配置一个审批节点" };
  for (let i = 0; i < nodes.length; i++) {
    if (!nodes[i].approvers.length) {
      return { ok: false, message: `请为第 ${i + 1} ä¸ªèŠ‚ç‚¹é€‰æ‹©è‡³å°‘ä¸€åå®¡æ‰¹äºº` };
    }
  }
  return { ok: true, nodes, name };
}
export function flowNodesSummary(nodes) {
  const list = normalizeFlowNodes(nodes);
  if (!list.length) return "—";
  return list
    .map((n, i) => {
      const names = n.approvers.map((a) => a.approverName || "未命名").join("、") || "未配置";
      return `节点${i + 1}(${nodeSignModeLabel(n.signMode)}:${names})`;
    })
    .join(" â†’ ");
}
export function createInitialMockTemplates() {
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  return [
    {
      id: "tpl_demo_1",
      templateName: "项目立项审批",
      description: "跨部门项目立项,需技术、财务依次会签",
      enabled: true,
      createTime: dayjs().subtract(5, "day").format("YYYY-MM-DD HH:mm:ss"),
      updateTime: now,
      flowNodes: [
        {
          nodeOrder: 1,
          signMode: "countersign",
          approvers: [
            { approverId: "mock_tech_lead", approverName: "技术负责人" },
            { approverId: "mock_pm", approverName: "项目经理" },
          ],
        },
        {
          nodeOrder: 2,
          signMode: "or_sign",
          approvers: [
            { approverId: "mock_finance", approverName: "财务主管" },
            { approverId: "mock_cfo", approverName: "财务总监" },
          ],
        },
      ],
    },
    {
      id: "tpl_demo_2",
      templateName: "合同用印申请",
      description: "法务与行政或签后,总经理终审",
      enabled: true,
      createTime: dayjs().subtract(12, "day").format("YYYY-MM-DD HH:mm:ss"),
      updateTime: dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss"),
      flowNodes: [
        {
          nodeOrder: 1,
          signMode: "or_sign",
          approvers: [
            { approverId: "mock_legal", approverName: "法务专员" },
            { approverId: "mock_admin", approverName: "行政主管" },
          ],
        },
        {
          nodeOrder: 2,
          signMode: "countersign",
          approvers: [{ approverId: "mock_ceo", approverName: "总经理" }],
        },
      ],
    },
  ];
}
export function loadStoredTemplates() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed) ? parsed : null;
  } catch {
    return null;
  }
}
export function saveStoredTemplates(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
  } catch {
    /* ignore */
  }
}
src/views/officeProcessAutomation/ApproveManage/approve-template/components/TemplateFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,356 @@
<!-- å®¡æ‰¹æ¨¡æ¿ï¼šå¯é…ç½®èŠ‚ç‚¹æ•°ï¼Œæ¯èŠ‚ç‚¹å¤šäºº + ä¼šç­¾/或签 -->
<template>
  <div class="tfe">
    <div v-if="innerList.length" class="tfe-flow">
      <div v-for="(item, index) in innerList" :key="item._uid" class="tfe-flow-item">
        <div class="tfe-card" :class="{ 'tfe-card--empty': !item.approvers?.length }">
          <div class="tfe-badge">{{ index + 1 }}</div>
          <div class="tfe-head">
            <span class="tfe-level">{{ levelText(index) }}</span>
            <el-radio-group v-model="item.signMode" size="small" @change="emitOut">
              <el-radio-button value="countersign">会签</el-radio-button>
              <el-radio-button value="or_sign">或签</el-radio-button>
            </el-radio-group>
          </div>
          <p class="tfe-mode-tip">{{ signModeTip(item.signMode) }}</p>
          <div class="tfe-select">
            <el-select
              v-model="item.approverIds"
              multiple
              collapse-tags
              collapse-tags-tooltip
              :max-collapse-tags="2"
              filterable
              placeholder="请选择审批人(可多选)"
              style="width: 100%"
              @change="(ids) => onApproversChange(ids, item)"
            >
              <el-option
                v-for="u in userOptions"
                :key="String(u.userId ?? u.id)"
                :label="optionLabel(u)"
                :value="u.userId ?? u.id"
              />
            </el-select>
          </div>
          <div v-if="item.approvers?.length" class="tfe-chips">
            <el-tag
              v-for="a in item.approvers"
              :key="String(a.approverId)"
              size="small"
              type="info"
              effect="plain"
            >
              {{ a.approverName || "—" }}
            </el-tag>
          </div>
          <div class="tfe-actions">
            <el-button type="primary" circle size="small" :disabled="index === 0" title="前移" @click="moveLeft(index)">
              <el-icon><ArrowLeft /></el-icon>
            </el-button>
            <el-button
              type="primary"
              circle
              size="small"
              :disabled="index === innerList.length - 1"
              title="后移"
              @click="moveRight(index)"
            >
              <el-icon><ArrowRight /></el-icon>
            </el-button>
            <el-button type="danger" circle size="small" title="删除节点" @click="remove(index)">
              <el-icon><Delete /></el-icon>
            </el-button>
          </div>
        </div>
        <div v-if="index < innerList.length - 1" class="tfe-conn">
          <div class="tfe-conn-line"></div>
          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
        </div>
      </div>
      <div class="tfe-add-wrap">
        <div v-if="innerList.length" class="tfe-conn">
          <div class="tfe-conn-line"></div>
          <el-icon class="tfe-conn-icon"><ArrowRight /></el-icon>
        </div>
        <div class="tfe-add-card" @click="addNode">
          <div class="tfe-add-icon"><el-icon :size="26"><Plus /></el-icon></div>
          <span>新增节点</span>
        </div>
      </div>
    </div>
    <div v-else class="tfe-empty">
      <el-icon :size="44" color="#c0c4cc"><User /></el-icon>
      <p>暂无审批节点</p>
      <el-button type="primary" @click="addNode">添加第一个节点</el-button>
    </div>
  </div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue";
import { ref, watch } from "vue";
import { NODE_SIGN_MODE_OPTIONS, normalizeFlowNodes } from "../approveTemplateConstants.js";
const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  userOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue"]);
const innerList = ref([]);
function signModeTip(mode) {
  return NODE_SIGN_MODE_OPTIONS.find((x) => x.value === mode)?.desc || "";
}
function levelText(i) {
  const t = ["第一级", "第二级", "第三级", "第四级", "第五级", "第六级", "第七级", "第八级"];
  return t[i] || `第${i + 1}级`;
}
function optionLabel(u) {
  const nick = u.nickName || "";
  const un = u.userName || "";
  if (nick && un && nick !== un) return `${nick}(${un})`;
  return nick || un || `用户${u.userId ?? u.id ?? ""}`;
}
function newUid() {
  return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
function mapIn(rows) {
  const normalized = normalizeFlowNodes(rows);
  return normalized.map((n) => ({
    _uid: newUid(),
    nodeOrder: n.nodeOrder,
    signMode: n.signMode,
    approverIds: n.approvers.map((a) => a.approverId),
    approvers: [...n.approvers],
  }));
}
function publicShape(rows) {
  return normalizeFlowNodes(
    (rows || []).map((r) => ({
      nodeOrder: r.nodeOrder,
      signMode: r.signMode,
      approvers: r.approvers || [],
    }))
  );
}
function emitOut() {
  emit("update:modelValue", publicShape(innerList.value));
}
watch(
  () => props.modelValue,
  (v) => {
    const next = publicShape(v || []);
    if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return;
    innerList.value = mapIn(v || []);
  },
  { deep: true, immediate: true }
);
function findUser(id) {
  if (id == null || id === "") return null;
  return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null;
}
function onApproversChange(ids, row) {
  const idList = Array.isArray(ids) ? ids : [];
  row.approverIds = idList;
  row.approvers = idList.map((id) => {
    const u = findUser(id);
    return {
      approverId: id,
      approverName: u ? u.nickName || u.userName || "" : "",
    };
  });
  emitOut();
}
function addNode() {
  innerList.value.push({
    _uid: newUid(),
    nodeOrder: innerList.value.length + 1,
    signMode: "countersign",
    approverIds: [],
    approvers: [],
  });
  emitOut();
}
function remove(index) {
  innerList.value.splice(index, 1);
  emitOut();
}
function moveLeft(index) {
  if (index < 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index - 1];
  innerList.value[index - 1] = t;
  emitOut();
}
function moveRight(index) {
  if (index >= innerList.value.length - 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index + 1];
  innerList.value[index + 1] = t;
  emitOut();
}
</script>
<style scoped>
.tfe {
  width: 100%;
}
.tfe-flow {
  display: flex;
  align-items: flex-start;
  flex-wrap: nowrap;
  overflow-x: auto;
  padding: 6px 0 10px;
}
.tfe-flow-item {
  display: flex;
  align-items: center;
}
.tfe-card {
  width: 248px;
  flex-shrink: 0;
  border: 2px solid var(--el-border-color);
  border-radius: 12px;
  padding: 14px 12px 12px;
  position: relative;
  background: var(--el-bg-color);
}
.tfe-card--empty {
  border-style: dashed;
  background: var(--el-fill-color-lighter);
}
.tfe-badge {
  position: absolute;
  top: -8px;
  left: 12px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tfe-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  margin: 8px 0 4px;
}
.tfe-level {
  font-size: 13px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.tfe-mode-tip {
  font-size: 11px;
  color: var(--el-text-color-secondary);
  margin: 0 0 10px;
  line-height: 1.4;
  min-height: 30px;
}
.tfe-select {
  margin-bottom: 8px;
}
.tfe-chips {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
  margin-bottom: 8px;
  min-height: 24px;
}
.tfe-actions {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--el-border-color-lighter);
}
.tfe-conn {
  display: flex;
  align-items: center;
  width: 40px;
  flex-shrink: 0;
  align-self: center;
}
.tfe-conn-line {
  flex: 1;
  height: 2px;
  background: var(--el-border-color);
}
.tfe-conn-icon {
  font-size: 14px;
  color: var(--el-text-color-placeholder);
  margin-left: -2px;
}
.tfe-add-wrap {
  display: flex;
  align-items: center;
}
.tfe-add-card {
  width: 120px;
  min-height: 200px;
  flex-shrink: 0;
  border: 2px dashed var(--el-border-color);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  cursor: pointer;
  color: var(--el-text-color-regular);
  font-size: 13px;
  background: var(--el-fill-color-lighter);
  transition: border-color 0.2s, background 0.2s;
}
.tfe-add-card:hover {
  border-color: var(--el-color-primary);
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
}
.tfe-add-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
}
.tfe-empty {
  text-align: center;
  padding: 28px 16px;
  border: 1px dashed var(--el-border-color);
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
}
.tfe-empty p {
  margin: 10px 0 14px;
  color: var(--el-text-color-secondary);
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-template/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,365 @@
<!--OA模块:审批模板(系统常用 + è‡ªå®šä¹‰å¤šèŠ‚ç‚¹æµç¨‹ï¼‰-->
<template>
  <div class="app-container approve-template-page">
    <el-tabs v-model="activeTab" class="template-tabs">
      <el-tab-pane label="系统常用审批" name="builtin">
        <el-alert type="info" show-icon :closable="false" class="mb16">
          <template #title>系统预置模板</template>
          <template #default>
            ä»¥ä¸‹ä¸º OA æ¨¡å—内置的常用审批类型,填报字段与默认审批方式由系统维护;提交审批时可直接选用。
          </template>
        </el-alert>
        <div class="builtin-grid">
          <div v-for="item in builtinTemplates" :key="item.key" class="builtin-card">
            <span class="builtin-label">{{ item.label }}</span>
            <p class="builtin-summary">{{ item.summary }}</p>
            <div class="builtin-meta">
              <el-tag size="small" effect="plain">{{ item.fieldCount }} ä¸ªå¡«æŠ¥é¡¹</el-tag>
              <el-tag size="small" type="warning" effect="plain">
                é»˜è®¤{{ item.defaultMode === "or_sign" ? "或签" : "与签" }}
              </el-tag>
              <el-tag size="small" type="info" effect="plain">只读</el-tag>
            </div>
          </div>
        </div>
      </el-tab-pane>
      <el-tab-pane label="自定义审批模板" name="custom">
        <div class="search_form mb20">
          <div class="search_fields">
            <span class="search_title">模板名称:</span>
            <el-input
              v-model="searchForm.keyword"
              style="width: 220px"
              placeholder="搜索名称或说明"
              clearable
              :prefix-icon="Search"
              @keyup.enter="handleQuery"
            />
            <el-checkbox v-model="searchForm.enabledOnly" class="ml12" @change="handleQuery">
              ä»…显示启用
            </el-checkbox>
            <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"
          />
        </div>
      </el-tab-pane>
    </el-tabs>
    <!-- æ–°å»º / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="template-form-dialog"
      @closed="formRef?.resetFields?.()"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="100px">
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="模板名称" prop="templateName">
              <el-input v-model="form.templateName" placeholder="如:项目立项审批" maxlength="50" show-word-limit />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="启用状态">
              <el-switch v-model="form.enabled" active-text="启用" inactive-text="停用" />
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="模板说明">
          <el-input
            v-model="form.description"
            type="textarea"
            :rows="2"
            placeholder="简要说明该模板的适用场景"
            maxlength="200"
            show-word-limit
          />
        </el-form-item>
        <el-form-item label="审批流程" required>
          <TemplateFlowEditor v-model="form.flowNodes" :user-options="flowUserOptions" />
          <p class="flow-tip">
            æŒ‰é¡ºåºæµè½¬ï¼šå¯ä¸ºæ¯ä¸ªèŠ‚ç‚¹æ·»åŠ å¤šåå®¡æ‰¹äººï¼›ä¼šç­¾éœ€å…¨éƒ¨é€šè¿‡ï¼Œæˆ–ç­¾ä»»ä¸€äººé€šè¿‡å³å¯è¿›å…¥ä¸‹ä¸€èŠ‚ç‚¹ã€‚
          </p>
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="primary" @click="onSubmitForm">保 å­˜</el-button>
        <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="模板详情" width="880px" append-to-body destroy-on-close>
      <el-descriptions :column="2" border>
        <el-descriptions-item label="模板名称">{{ detailRow.templateName }}</el-descriptions-item>
        <el-descriptions-item label="状态">
          <el-tag :type="detailRow.enabled !== false ? 'success' : 'info'" size="small">
            {{ detailRow.enabled !== false ? "启用" : "停用" }}
          </el-tag>
        </el-descriptions-item>
        <el-descriptions-item label="说明" :span="2">{{ detailRow.description || "—" }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ detailRow.createTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="更新时间">{{ detailRow.updateTime || "—" }}</el-descriptions-item>
      </el-descriptions>
      <el-divider content-position="left">审批流程({{ detailRow.flowNodes?.length || 0 }} ä¸ªèŠ‚ç‚¹ï¼‰</el-divider>
      <div v-if="detailRow.flowNodes?.length" class="detail-flow">
        <div v-for="(node, index) in detailRow.flowNodes" :key="index" class="detail-node">
          <div class="detail-node-head">
            <span class="detail-node-order">节点 {{ index + 1 }}</span>
            <el-tag size="small" :type="node.signMode === 'or_sign' ? 'warning' : 'primary'">
              {{ nodeSignModeLabel(node.signMode) }}
            </el-tag>
          </div>
          <div class="detail-approvers">
            <el-tag
              v-for="a in node.approvers"
              :key="String(a.approverId)"
              class="detail-approver-tag"
              effect="plain"
            >
              {{ a.approverName || "—" }}
            </el-tag>
            <span v-if="!node.approvers?.length" class="text-muted">未配置审批人</span>
          </div>
          <el-icon v-if="index < detailRow.flowNodes.length - 1" class="detail-arrow"><ArrowRight /></el-icon>
        </div>
      </div>
      <el-empty v-else description="暂无流程节点" :image-size="60" />
      <template #footer>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
        <el-button type="primary" @click="editFromDetail">编 è¾‘</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { ArrowRight, Document, Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { onMounted, ref } from "vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import TemplateFlowEditor from "./components/TemplateFlowEditor.vue";
import { useApproveTemplate } from "./useApproveTemplate.js";
const at = useApproveTemplate();
const {
  Search,
  activeTab,
  builtinTemplates,
  nodeSignModeLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  formDialog,
  form,
  formRef,
  formRules,
  detailDialog,
  detailRow,
  handleQuery,
  resetSearch,
  pagination,
  openFormDialog,
  openDetail,
  submitForm,
} = at;
const flowUserOptions = ref([]);
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
async function loadUsers() {
  try {
    const res = await userListNoPageByTenantId();
    flowUserOptions.value = unwrapArray(res).filter(isActiveUser);
  } catch {
    flowUserOptions.value = [];
  }
}
async function onSubmitForm() {
  const ret = await submitForm();
  if (ret?.message) {
    ElMessage.warning(ret.message);
    return;
  }
  if (ret?.ok) ElMessage.success("保存成功");
}
function editFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openFormDialog("edit", row);
}
onMounted(() => {
  loadUsers();
  handleQuery();
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.ml10 {
  margin-left: 10px;
}
.ml12 {
  margin-left: 12px;
}
.page-header .header-title {
  display: flex;
  align-items: center;
  gap: 8px;
  font-size: 18px;
  font-weight: 600;
  color: var(--el-text-color-primary);
  margin-bottom: 8px;
}
.title-icon {
  font-size: 22px;
  color: var(--el-color-primary);
}
.header-desc {
  margin: 0;
  font-size: 13px;
  color: var(--el-text-color-secondary);
  line-height: 1.6;
  max-width: 920px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  display: flex;
  gap: 8px;
}
.builtin-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
  gap: 12px;
}
.builtin-card {
  padding: 14px 16px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: var(--radius-md, 8px);
  background: var(--el-fill-color-blank);
}
.builtin-label {
  font-size: 15px;
  font-weight: 600;
  color: var(--el-text-color-primary);
}
.builtin-summary {
  margin: 8px 0 10px;
  font-size: 12px;
  color: var(--el-text-color-secondary);
  line-height: 1.5;
  min-height: 36px;
}
.builtin-meta {
  display: flex;
  flex-wrap: wrap;
  gap: 6px;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin: 8px 0 0;
  line-height: 1.5;
}
.detail-flow {
  display: flex;
  flex-wrap: wrap;
  align-items: flex-start;
  gap: 8px;
}
.detail-node {
  position: relative;
  min-width: 180px;
  max-width: 240px;
  padding: 12px;
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 8px;
  background: var(--el-fill-color-lighter);
}
.detail-node-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  margin-bottom: 8px;
}
.detail-node-order {
  font-weight: 600;
  font-size: 13px;
}
.detail-approvers {
  display: flex;
  flex-wrap: wrap;
  gap: 4px;
}
.detail-approver-tag {
  margin: 0;
}
.detail-arrow {
  position: absolute;
  right: -20px;
  top: 50%;
  transform: translateY(-50%);
  color: var(--el-text-color-placeholder);
}
.text-muted {
  font-size: 12px;
  color: var(--el-text-color-placeholder);
}
.template-form-dialog :deep(.el-dialog__body) {
  padding-top: 8px;
}
</style>
src/views/officeProcessAutomation/ApproveManage/approve-template/useApproveTemplate.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,260 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import {
  createEmptyTemplateForm,
  createInitialMockTemplates,
  flowNodesSummary,
  getBuiltinTemplates,
  loadStoredTemplates,
  nodeSignModeLabel,
  saveStoredTemplates,
  validateTemplateForm,
} from "./approveTemplateConstants.js";
export function useApproveTemplate() {
  const stored = loadStoredTemplates();
  const allTemplates = ref(stored?.length ? stored : createInitialMockTemplates());
  const activeTab = ref("custom");
  const builtinTemplates = getBuiltinTemplates();
  const searchForm = reactive({
    keyword: "",
    enabledOnly: false,
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const formDialog = reactive({ visible: false, title: "", mode: "add" });
  const form = reactive(createEmptyTemplateForm());
  const formRef = ref();
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const filteredList = computed(() => {
    let list = [...allTemplates.value];
    const kw = (searchForm.keyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const name = (r.templateName || "").toLowerCase();
        const desc = (r.description || "").toLowerCase();
        return name.includes(kw) || desc.includes(kw);
      });
    }
    if (searchForm.enabledOnly) {
      list = list.filter((r) => r.enabled !== false);
    }
    return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 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);
  });
  const formRules = {
    templateName: [{ required: true, message: "请输入模板名称", trigger: "blur" }],
  };
  const tableColumn = ref([
    { label: "模板名称", prop: "templateName", minWidth: 140 },
    { label: "说明", prop: "description", minWidth: 160, showOverflowTooltip: true },
    {
      label: "节点数",
      prop: "flowNodes",
      width: 80,
      align: "center",
      formatData: (v) => (Array.isArray(v) ? v.length : 0),
    },
    {
      label: "流程概要",
      prop: "flowNodes",
      minWidth: 220,
      showOverflowTooltip: true,
      formatData: (v) => flowNodesSummary(v),
    },
    {
      label: "状态",
      prop: "enabled",
      width: 90,
      align: "center",
      dataType: "tag",
      formatData: (v) => (v !== false ? "启用" : "停用"),
      formatType: (v) => (v !== false ? "success" : "info"),
    },
    { label: "更新时间", prop: "updateTime", width: 170 },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 200,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
        { name: "删除", type: "text", clickFun: (row) => removeTemplate(row) },
      ],
    },
  ]);
  function persist() {
    saveStoredTemplates(allTemplates.value);
  }
  function handleQuery() {
    tableLoading.value = true;
    page.current = 1;
    setTimeout(() => {
      tableLoading.value = false;
    }, 150);
  }
  function resetSearch() {
    searchForm.keyword = "";
    searchForm.enabledOnly = false;
    handleQuery();
  }
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
  }
  function resetForm(row) {
    const base = createEmptyTemplateForm();
    if (!row) {
      Object.assign(form, base);
      return;
    }
    Object.assign(form, {
      ...base,
      id: row.id,
      templateName: row.templateName || "",
      description: row.description || "",
      enabled: row.enabled !== false,
      flowNodes: JSON.parse(JSON.stringify(row.flowNodes || [base.flowNodes[0]])),
    });
  }
  function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.title = mode === "add" ? "新建自定义审批模板" : "编辑自定义审批模板";
    resetForm(mode === "edit" ? row : null);
    formDialog.visible = true;
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function isNameDuplicate(name, excludeId) {
    const n = (name || "").trim();
    return allTemplates.value.some((t) => t.templateName?.trim() === n && t.id !== excludeId);
  }
  async function submitForm() {
    if (!formRef.value) return false;
    try {
      await formRef.value.validate();
    } catch {
      return false;
    }
    const validated = validateTemplateForm(form);
    if (!validated.ok) {
      return { message: validated.message };
    }
    if (isNameDuplicate(validated.name, form.id)) {
      return { message: "模板名称已存在,请更换名称" };
    }
    const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
    if (formDialog.mode === "add") {
      allTemplates.value.unshift({
        id: `tpl_${Date.now()}`,
        templateName: validated.name,
        description: (form.description || "").trim(),
        enabled: form.enabled !== false,
        createTime: now,
        updateTime: now,
        flowNodes: validated.nodes,
      });
    } else {
      const hit = allTemplates.value.find((t) => t.id === form.id);
      if (!hit) return { message: "模板不存在或已删除" };
      hit.templateName = validated.name;
      hit.description = (form.description || "").trim();
      hit.enabled = form.enabled !== false;
      hit.flowNodes = validated.nodes;
      hit.updateTime = now;
    }
    persist();
    formDialog.visible = false;
    page.current = 1;
    return { ok: true };
  }
  async function removeTemplate(row) {
    try {
      await ElMessageBox.confirm(`确定删除模板「${row.templateName}」吗?`, "提示", {
        type: "warning",
        confirmButtonText: "删除",
        cancelButtonText: "取消",
      });
    } catch {
      return;
    }
    const idx = allTemplates.value.findIndex((t) => t.id === row.id);
    if (idx >= 0) {
      allTemplates.value.splice(idx, 1);
      persist();
    }
  }
  function toggleEnabled(row) {
    const hit = allTemplates.value.find((t) => t.id === row.id);
    if (!hit) return;
    hit.enabled = !hit.enabled;
    hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
    persist();
  }
  return {
    Search,
    activeTab,
    builtinTemplates,
    nodeSignModeLabel,
    flowNodesSummary,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    formDialog,
    form,
    formRef,
    formRules,
    detailDialog,
    detailRow,
    handleQuery,
    resetSearch,
    pagination,
    openFormDialog,
    openDetail,
    submitForm,
    toggleEnabled,
  };
}
src/views/officeProcessAutomation/AttendManage/leave-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,934 @@
<!--OA模块:请假申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">请假类型:</span>
        <el-select v-model="searchForm.leaveType" placeholder="全部" clearable style="width: 180px">
          <el-option v-for="opt in LEAVE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增请假申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="leave-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="leave-apply-form">
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="请假类型" prop="leaveType">
              <el-select v-model="form.leaveType" placeholder="请选择请假类型" clearable filterable style="width: 100%">
                <el-option v-for="opt in LEAVE_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="假期余额" prop="leaveBalanceDays">
              <el-input-number
                v-model="form.leaveBalanceDays"
                :min="0"
                :max="999"
                :precision="2"
                :step="0.5"
                controls-position="right"
                placeholder="天"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="请假开始时间" prop="leaveStartTime">
              <el-date-picker
                v-model="form.leaveStartTime"
                type="datetime"
                placeholder="请选择开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onLeaveRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="请假结束时间" prop="leaveEndTime">
              <el-date-picker
                v-model="form.leaveEndTime"
                type="datetime"
                placeholder="请选择结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onLeaveRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="请假时长">
              <el-input :model-value="leaveDurationDisplay" readonly placeholder="根据起止时间自动计算">
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="or_sign">或签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="请假事由" prop="leaveReason">
              <el-input
                v-model="form.leaveReason"
                type="textarea"
                :rows="4"
                placeholder="请填写请假事由"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
                <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="点击选择文件" />
              </div>
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="请假申请详情" width="720px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人编号">{{ detailRow.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="请假类型">{{ leaveTypeLabel(detailRow.leaveType) }}</el-descriptions-item>
        <el-descriptions-item label="假期余额">{{ formatBalance(detailRow.leaveBalanceDays) }}</el-descriptions-item>
        <el-descriptions-item label="请假开始时间">{{ detailRow.leaveStartTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="请假结束时间">{{ detailRow.leaveEndTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="请假时长">{{ formatDuration(detailRow.leaveDurationDays) }}</el-descriptions-item>
        <el-descriptions-item label="请假事由">{{ detailRow.leaveReason }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ detailRow.createTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="附件">
          <template v-if="detailRow.attachmentList?.length">
            <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info">
              {{ f.name }}
            </el-tag>
          </template>
          <span v-else>无</span>
        </el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- é™„件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
/** è¯·å‡ç±»åž‹ï¼ˆvalue ä¸ŽåŽç«¯å¯¹é½å ä½ï¼‰ */
const LEAVE_TYPE_OPTIONS = [
  { label: "年假", value: "annual" },
  { label: "病假", value: "sick" },
  { label: "事假", value: "personal" },
  { label: "婚假", value: "marriage" },
  { label: "产假", value: "maternity" },
  { label: "哺乳假", value: "nursing" },
  { label: "慰唁假", value: "condolence" },
  { label: "调休", value: "compensatory" },
];
function leaveTypeLabel(v) {
  const hit = LEAVE_TYPE_OPTIONS.find((x) => x.value === v);
  return hit?.label || "—";
}
/** ä¸ŽåŽç«¯çº¦å®šå­—段(占位) */
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantNo: "",
  applicantName: "",
  leaveType: "",
  leaveBalanceDays: undefined,
  leaveStartTime: "",
  leaveEndTime: "",
  leaveReason: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
  attachmentList: [],
});
const { proxy } = getCurrentInstance();
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
}
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
const approverTreeData = ref([]);
const approverLabelMap = ref({});
async function loadApproverTree() {
  try {
    const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    const users = unwrapArray(userRes);
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
  if (mode === "or_sign") return "或签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
/** æŒ‰èµ·æ­¢æ—¶é—´è®¡ç®—请假天数(含时分秒,结果保留两位小数) */
function computeLeaveDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  const days = t1.diff(t0, "millisecond") / (24 * 60 * 60 * 1000);
  return Math.round(days * 100) / 100;
}
function formatDuration(v) {
  if (v == null || v === "") return "—";
  return `${v} å¤©`;
}
function formatBalance(v) {
  if (v == null || v === "") return "—";
  return `${v} å¤©`;
}
/** ç³»ç»Ÿç”¨æˆ·ç¼“å­˜ */
const allUsersCache = ref([]);
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function applicantNoFromUser(u) {
  if (!u) return "";
  return (
    u.userName ??
    u.userCode ??
    u.jobNumber ??
    u.workNo ??
    (u.userId != null ? String(u.userId) : "")
  );
}
/** æœ¬åœ°æ¨¡æ‹Ÿï¼šæ ¹æ®ç”¨æˆ·ç”Ÿæˆç¨³å®šã€Œå‡æœŸä½™é¢ã€å ä½ */
function mockLeaveBalance(u) {
  if (!u) return undefined;
  const idStr = String(u.userId ?? u.id ?? "0");
  let s = 0;
  for (let i = 0; i < idStr.length; i++) s += idStr.charCodeAt(i);
  return Math.round(((s % 130) / 10 + 5) * 100) / 100;
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return [...list];
  return list.filter((u) => {
    const nick = (u.nickName || "").toLowerCase();
    const uname = (u.userName || "").toLowerCase();
    const phone = (u.phonenumber || u.phone || "").toString();
    return nick.includes(q) || uname.includes(q) || phone.includes(q);
  });
}
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
}
function onApplicantChange(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantName = u.nickName || u.userName || "";
    form.applicantNo = applicantNoFromUser(u);
    form.leaveBalanceDays = mockLeaveBalance(u);
  } else {
    form.applicantName = "";
    form.applicantNo = "";
    form.leaveBalanceDays = undefined;
  }
}
/** æœ¬åœ°æ¨¡æ‹Ÿåˆ—表数据 */
const allRows = ref([
  {
    id: "1",
    applicantId: "mock_1",
    applicantNo: "zhangsan",
    applicantName: "张三",
    leaveType: "annual",
    leaveBalanceDays: 12,
    leaveStartTime: "2026-05-10 09:00:00",
    leaveEndTime: "2026-05-12 18:00:00",
    leaveDurationDays: 2.38,
    leaveReason: "年休假返乡探亲。",
    approvalMode: "parallel",
    approverIds: [],
    approverNames: "",
    approvalResult: "pending",
    attachmentList: [{ name: "车票订单.pdf" }],
    createTime: "2026-05-09 10:20:00",
  },
  {
    id: "2",
    applicantId: "mock_2",
    applicantNo: "lisi",
    applicantName: "李四",
    leaveType: "sick",
    leaveBalanceDays: 0,
    leaveStartTime: "2026-05-14 08:30:00",
    leaveEndTime: "2026-05-14 12:00:00",
    leaveDurationDays: 0.15,
    leaveReason: "上午门诊复查。",
    approvalMode: "or_sign",
    approverIds: [],
    approverNames: "",
    approvalResult: "approved",
    attachmentList: [],
    createTime: "2026-05-13 16:00:00",
  },
]);
const searchForm = reactive({
  applicantKeyword: "",
  leaveType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
  if (kw) {
    list = list.filter((r) => {
      const name = (r.applicantName || "").toLowerCase();
      const no = (r.applicantNo || "").toLowerCase();
      return name.includes(kw) || no.includes(kw);
    });
  }
  if (searchForm.leaveType) {
    list = list.filter((r) => r.leaveType === searchForm.leaveType);
  }
  return list.sort((a, b) => (String(a.createTime) < String(b.createTime) ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人编号", prop: "applicantNo", width: 120 },
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  {
    label: "请假类型",
    prop: "leaveType",
    width: 100,
    formatData: (v) => leaveTypeLabel(v),
  },
  {
    label: "请假时长",
    prop: "leaveDurationDays",
    width: 120,
    formatData: (v) => (v == null || v === "" ? "—" : `${v} å¤©`),
  },
  { label: "请假事由", prop: "leaveReason", minWidth: 180 },
  { label: "创建时间", prop: "createTime", width: 170 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 220,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openFormDialog("edit", row),
      },
      {
        name: "查看详情",
        type: "text",
        clickFun: (row) => openDetail(row),
      },
      {
        name: "附件",
        type: "text",
        clickFun: (row) => openFiles(row),
      },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const leaveDurationDisplay = computed(() => {
  const d = computeLeaveDays(form.leaveStartTime, form.leaveEndTime);
  return d == null ? "" : String(d);
});
function onLeaveRangeChange() {
  nextTick(() => {
    formRef.value?.validateField?.("leaveEndTime");
  });
}
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  leaveType: [{ required: true, message: "请选择请假类型", trigger: "change" }],
  leaveBalanceDays: [
    {
      required: true,
      message: "请填写假期余额",
      trigger: "blur",
    },
  ],
  leaveStartTime: [{ required: true, message: "请选择请假开始时间", trigger: "change" }],
  leaveEndTime: [
    { required: true, message: "请选择请假结束时间", trigger: "change" },
    {
      validator: (_rule, val, callback) => {
        if (!form.leaveStartTime || !val) {
          callback();
          return;
        }
        const d = computeLeaveDays(form.leaveStartTime, val);
        if (d == null) {
          callback(new Error("结束时间须晚于开始时间"));
        } else {
          callback();
        }
      },
      trigger: "change",
    },
  ],
  leaveReason: [{ required: true, message: "请填写请假事由", trigger: "blur" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [
    {
      type: "array",
      required: true,
      message: "请选择审批人",
      trigger: "change",
    },
  ],
};
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
}
function resetSearch() {
  searchForm.applicantKeyword = "";
  searchForm.leaveType = "";
  handleQuery();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgSuccess?.(`已模拟下载:${row.name}`);
}
async function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增请假申请" : "编辑请假申请";
  await loadApproverTree();
  if (!allUsersCache.value.length) {
    await loadUserPool();
  }
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantNo: row.applicantNo,
      applicantName: row.applicantName,
      leaveType: row.leaveType,
      leaveBalanceDays: row.leaveBalanceDays,
      leaveStartTime: row.leaveStartTime,
      leaveEndTime: row.leaveEndTime,
      leaveReason: row.leaveReason,
      approvalMode: row.approvalMode === "countersign" ? "or_sign" : row.approvalMode || "parallel",
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
      attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
    });
    const u = userById(row.applicantId);
    if (u) {
      applicantFormOptions.value = [u];
    } else if (row.applicantId) {
      applicantFormOptions.value = [
        {
          userId: row.applicantId,
          nickName: row.applicantName,
          userName: row.applicantNo,
        },
      ];
    }
  } else {
    remoteSearchApplicantForm("");
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const days = computeLeaveDays(form.leaveStartTime, form.leaveEndTime);
  if (days == null) {
    proxy?.$modal?.msgWarning?.("请检查请假起止时间,结束时间须晚于开始时间");
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  const payload = {
    applicantId: form.applicantId,
    applicantNo: form.applicantNo,
    applicantName: form.applicantName,
    leaveType: form.leaveType,
    leaveBalanceDays: form.leaveBalanceDays,
    leaveStartTime: form.leaveStartTime,
    leaveEndTime: form.leaveEndTime,
    leaveDurationDays: days,
    leaveReason: form.leaveReason,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
    attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    proxy?.$modal?.msgSuccess?.("新增成功(本地模拟)");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    if (idx !== -1) {
      const prev = allRows.value[idx];
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
        approvalResult: prev.approvalResult ?? "pending",
        createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"),
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)");
  }
  formDialog.visible = false;
  handleQuery();
}
onMounted(() => {
  loadApproverTree();
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.upload-block {
  width: 100%;
}
.mr6 {
  margin-right: 6px;
}
.mb6 {
  margin-bottom: 6px;
}
.leave-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.leave-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.leave-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,360 @@
<!-- åŠ ç­ç”³è¯·æ¨¡å—å†…ï¼šå¯å¢žåˆ å®¡æ‰¹èŠ‚ç‚¹ï¼Œæ¯èŠ‚ç‚¹å¿…é€‰ 1 äºº -->
<template>
  <div class="afe">
    <div v-if="innerList.length" class="afe-flow">
      <div v-for="(item, index) in innerList" :key="item._uid" class="afe-flow-item">
        <div class="afe-card" :class="{ 'afe-card--empty': !item.approverId }">
          <div class="afe-badge">{{ index + 1 }}</div>
          <div class="afe-avatar-wrap">
            <div
              class="afe-avatar"
              :class="{ 'afe-avatar--on': item.approverId }"
              :style="item.approverId ? { backgroundColor: avatarColor(item.approverName) } : {}"
            >
              <span v-if="item.approverId">{{ (item.approverName || '?').charAt(0) }}</span>
              <el-icon v-else :size="22"><User /></el-icon>
            </div>
            <div class="afe-level">{{ levelText(index) }}</div>
          </div>
          <div class="afe-select">
            <el-select
              v-model="item.approverId"
              placeholder="请选择审批人"
              filterable
              clearable
              style="width: 100%"
              @change="(v) => onPick(v, item)"
            >
              <el-option
                v-for="u in userOptions"
                :key="String(u.userId ?? u.id)"
                :label="optionLabel(u)"
                :value="u.userId ?? u.id"
              />
            </el-select>
          </div>
          <div class="afe-actions">
            <el-button type="primary" circle size="small" :disabled="index === 0" title="前移" @click="moveLeft(index)">
              <el-icon><ArrowLeft /></el-icon>
            </el-button>
            <el-button
              type="primary"
              circle
              size="small"
              :disabled="index === innerList.length - 1"
              title="后移"
              @click="moveRight(index)"
            >
              <el-icon><ArrowRight /></el-icon>
            </el-button>
            <el-button type="danger" circle size="small" title="删除节点" @click="remove(index)">
              <el-icon><Delete /></el-icon>
            </el-button>
          </div>
        </div>
        <div v-if="index < innerList.length - 1" class="afe-conn">
          <div class="afe-conn-line"></div>
          <el-icon class="afe-conn-icon"><ArrowRight /></el-icon>
        </div>
      </div>
      <div class="afe-add-wrap">
        <div class="afe-conn" v-if="innerList.length">
          <div class="afe-conn-line"></div>
          <el-icon class="afe-conn-icon"><ArrowRight /></el-icon>
        </div>
        <div class="afe-add-card" @click="addNode">
          <div class="afe-add-icon"><el-icon :size="26"><Plus /></el-icon></div>
          <span>新增节点</span>
        </div>
      </div>
    </div>
    <div v-else class="afe-empty">
      <el-icon :size="44" color="#c0c4cc"><User /></el-icon>
      <p>暂无审批节点</p>
      <el-button type="primary" @click="addNode">添加第一个节点</el-button>
    </div>
  </div>
</template>
<script setup>
import { ArrowLeft, ArrowRight, Delete, Plus, User } from "@element-plus/icons-vue";
import { ref, watch } from "vue";
const props = defineProps({
  modelValue: { type: Array, default: () => [] },
  /** ä¸Žçˆ¶é¡µ userList ç»“构一致:userId / id、nickName、userName */
  userOptions: { type: Array, default: () => [] },
});
const emit = defineEmits(["update:modelValue"]);
const innerList = ref([]);
const palette = ["#409EFF", "#67C23A", "#E6A23C", "#F56C6C", "#9B59B6", "#1ABC9C"];
function avatarColor(name) {
  if (!name) return "#c0c4cc";
  let h = 0;
  for (let i = 0; i < name.length; i++) h = name.charCodeAt(i) + ((h << 5) - h);
  return palette[Math.abs(h) % palette.length];
}
function levelText(i) {
  const t = ["第一级", "第二级", "第三级", "第四级", "第五级", "第六级", "第七级", "第八级"];
  return t[i] || `第${i + 1}级`;
}
function optionLabel(u) {
  const nick = u.nickName || "";
  const un = u.userName || "";
  if (nick && un && nick !== un) return `${nick}(${un})`;
  return nick || un || `用户${u.userId ?? u.id ?? ""}`;
}
function newUid() {
  return `n_${Date.now()}_${Math.random().toString(36).slice(2, 9)}`;
}
function mapIn(rows) {
  if (!Array.isArray(rows)) return [];
  return rows.map((r, i) => ({
    _uid: newUid(),
    approverId: r.approverId ?? r.approver_id ?? null,
    approverName: r.approverName ?? r.approver_name ?? "",
    sortOrder: r.sortOrder ?? r.nodeOrder ?? i + 1,
    nodeOrder: r.nodeOrder ?? r.sortOrder ?? i + 1,
    roleName: r.roleName ?? "",
    roleCode: r.roleCode ?? "",
  }));
}
function publicShape(rows) {
  const arr = Array.isArray(rows) ? rows : [];
  return arr.map((r, i) => ({
    approverId: r.approverId ?? null,
    approverName: r.approverName ?? "",
    roleName: r.roleName ?? "",
    roleCode: r.roleCode ?? "",
    sortOrder: i + 1,
  }));
}
function emitOut() {
  const out = innerList.value.map((r, i) => ({
    approverId: r.approverId ?? null,
    approverName: r.approverName ?? "",
    sortOrder: i + 1,
    nodeOrder: i + 1,
    roleName: r.roleName ?? "",
    roleCode: r.roleCode ?? "",
  }));
  emit("update:modelValue", out);
}
watch(
  () => props.modelValue,
  (v) => {
    const next = publicShape(v || []);
    if (JSON.stringify(next) === JSON.stringify(publicShape(innerList.value))) return;
    innerList.value = mapIn(v || []);
  },
  { deep: true, immediate: true }
);
function findUser(id) {
  if (id == null || id === "") return null;
  return props.userOptions.find((u) => String(u.userId ?? u.id) === String(id)) ?? null;
}
function onPick(userId, row) {
  if (!userId) {
    row.approverName = "";
    emitOut();
    return;
  }
  const u = findUser(userId);
  row.approverName = u ? u.nickName || u.userName || "" : "";
  emitOut();
}
function addNode() {
  innerList.value.push({
    _uid: newUid(),
    approverId: null,
    approverName: "",
    roleName: "",
    roleCode: "",
  });
  emitOut();
}
function remove(index) {
  innerList.value.splice(index, 1);
  emitOut();
}
function moveLeft(index) {
  if (index < 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index - 1];
  innerList.value[index - 1] = t;
  emitOut();
}
function moveRight(index) {
  if (index >= innerList.value.length - 1) return;
  const t = innerList.value[index];
  innerList.value[index] = innerList.value[index + 1];
  innerList.value[index + 1] = t;
  emitOut();
}
</script>
<style scoped>
.afe {
  width: 100%;
}
.afe-flow {
  display: flex;
  align-items: flex-start;
  flex-wrap: nowrap;
  overflow-x: auto;
  padding: 6px 0 10px;
  gap: 0;
}
.afe-flow-item {
  display: flex;
  align-items: center;
}
.afe-card {
  width: 200px;
  flex-shrink: 0;
  border: 2px solid var(--el-border-color);
  border-radius: 12px;
  padding: 14px 12px 12px;
  position: relative;
  background: var(--el-bg-color);
}
.afe-card--empty {
  border-style: dashed;
  background: var(--el-fill-color-lighter);
}
.afe-badge {
  position: absolute;
  top: -8px;
  left: 12px;
  width: 22px;
  height: 22px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  font-size: 12px;
  font-weight: 700;
  display: flex;
  align-items: center;
  justify-content: center;
}
.afe-avatar-wrap {
  display: flex;
  flex-direction: column;
  align-items: center;
  margin: 6px 0 10px;
}
.afe-avatar {
  width: 48px;
  height: 48px;
  border-radius: 50%;
  background: var(--el-fill-color);
  display: flex;
  align-items: center;
  justify-content: center;
  color: var(--el-text-color-placeholder);
  margin-bottom: 6px;
  font-size: 18px;
  font-weight: 600;
}
.afe-avatar--on {
  color: #fff;
}
.afe-level {
  font-size: 12px;
  color: var(--el-text-color-secondary);
}
.afe-select {
  margin-bottom: 10px;
}
.afe-actions {
  display: flex;
  justify-content: center;
  gap: 8px;
  padding-top: 10px;
  border-top: 1px solid var(--el-border-color-lighter);
}
.afe-conn {
  display: flex;
  align-items: center;
  width: 40px;
  flex-shrink: 0;
  align-self: center;
}
.afe-conn-line {
  flex: 1;
  height: 2px;
  background: var(--el-border-color);
}
.afe-conn-icon {
  font-size: 14px;
  color: var(--el-text-color-placeholder);
  margin-left: -2px;
}
.afe-add-wrap {
  display: flex;
  align-items: center;
}
.afe-add-card {
  width: 120px;
  min-height: 168px;
  flex-shrink: 0;
  border: 2px dashed var(--el-border-color);
  border-radius: 12px;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: 10px;
  cursor: pointer;
  color: var(--el-text-color-regular);
  font-size: 13px;
  background: var(--el-fill-color-lighter);
  transition: border-color 0.2s, background 0.2s;
}
.afe-add-card:hover {
  border-color: var(--el-color-primary);
  background: var(--el-color-primary-light-9);
  color: var(--el-color-primary);
}
.afe-add-icon {
  width: 44px;
  height: 44px;
  border-radius: 50%;
  background: var(--el-color-primary);
  color: #fff;
  display: flex;
  align-items: center;
  justify-content: center;
}
.afe-empty {
  text-align: center;
  padding: 28px 16px;
  border: 1px dashed var(--el-border-color);
  border-radius: 12px;
  background: var(--el-fill-color-lighter);
}
.afe-empty p {
  margin: 10px 0 14px;
  color: var(--el-text-color-secondary);
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,909 @@
<!--OA模块:加班申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">加班类型:</span>
        <el-select v-model="searchForm.overtimeType" placeholder="全部" clearable style="width: 180px">
          <el-option v-for="opt in OVERTIME_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增加班申请</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1040px"
      append-to-body
      destroy-on-close
      class="overtime-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="overtime-apply-form">
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班类型" prop="overtimeType">
              <el-select v-model="form.overtimeType" placeholder="请选择加班类型" clearable filterable style="width: 100%">
                <el-option v-for="opt in OVERTIME_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="加班日期" prop="overtimeDate">
              <el-date-picker
                v-model="form.overtimeDate"
                type="date"
                placeholder="请选择加班日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班开始日期" prop="overtimeStartTime">
              <el-date-picker
                v-model="form.overtimeStartTime"
                type="datetime"
                placeholder="请选择开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onOvertimeRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="加班结束日期" prop="overtimeEndTime">
              <el-date-picker
                v-model="form.overtimeEndTime"
                type="datetime"
                placeholder="请选择结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onOvertimeRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班时长">
              <el-input :model-value="overtimeHoursDisplay" readonly placeholder="根据起止时间自动计算">
                <template #append>小时</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批流程" prop="approvalFlowNodes">
              <ApprovalFlowEditor
                v-model="form.approvalFlowNodes"
                :user-options="flowUserOptions"
                @update:model-value="onApprovalFlowChange"
              />
              <p class="flow-tip">至少保留一个节点;每个节点选择一名审批人;可新增、删除或调整顺序。</p>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="加班事由" prop="overtimeReason">
              <el-input
                v-model="form.overtimeReason"
                type="textarea"
                :rows="4"
                placeholder="请填写加班事由"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
                <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="点击选择文件" />
              </div>
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="加班申请详情" width="720px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人编号">{{ detailRow.applicantNo || "—" }}</el-descriptions-item>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="加班类型">{{ overtimeTypeLabel(detailRow.overtimeType) }}</el-descriptions-item>
        <el-descriptions-item label="加班日期">{{ detailRow.overtimeDate || "—" }}</el-descriptions-item>
        <el-descriptions-item label="加班开始日期">{{ detailRow.overtimeStartTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="加班结束日期">{{ detailRow.overtimeEndTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="加班时长">{{ formatHours(detailRow.overtimeHours) }}</el-descriptions-item>
        <el-descriptions-item label="加班事由">{{ detailRow.overtimeReason }}</el-descriptions-item>
        <el-descriptions-item label="审批流程">
          <template v-if="sortedApprovalNodes(detailRow).length">
            <div class="detail-flow-chain">
              <template v-for="(n, i) in sortedApprovalNodes(detailRow)" :key="i">
                <span class="detail-flow-step">{{ i + 1 }}. {{ approvalNodeLabel(n) }}</span>
                <span v-if="i < sortedApprovalNodes(detailRow).length - 1" class="detail-flow-sep">→</span>
              </template>
            </div>
          </template>
          <span v-else>—</span>
        </el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="创建时间">{{ detailRow.createTime || "—" }}</el-descriptions-item>
        <el-descriptions-item label="附件">
          <template v-if="detailRow.attachmentList?.length">
            <el-tag v-for="(f, i) in detailRow.attachmentList" :key="i" class="mr6 mb6" type="info">
              {{ f.name }}
            </el-tag>
          </template>
          <span v-else>无</span>
        </el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- é™„件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "./components/ApprovalFlowEditor.vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from "vue";
/** åŠ ç­ç±»åž‹ï¼ˆvalue ä¸ŽåŽç«¯å¯¹é½å ä½ï¼‰ */
const OVERTIME_TYPE_OPTIONS = [
  { label: "工作日加班", value: "weekday" },
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
/** æœ¬åœ°æ¼”示:两条空节点,提交前须为每节点选择审批人 */
function demoApprovalFlowNodes() {
  return [
    { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" },
    { approverId: null, approverName: "", sortOrder: 2, nodeOrder: 2, roleName: "", roleCode: "" },
  ];
}
function sortedApprovalNodes(row) {
  const list = row?.approvalFlowNodes;
  if (!Array.isArray(list) || !list.length) return [];
  return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
}
function approvalNodeLabel(n) {
  const name = (n.approverName || "").trim();
  if (name) return name;
  return "未选择审批人";
}
function overtimeTypeLabel(v) {
  const hit = OVERTIME_TYPE_OPTIONS.find((x) => x.value === v);
  return hit?.label || "—";
}
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantNo: "",
  applicantName: "",
  overtimeType: "",
  overtimeDate: "",
  overtimeStartTime: "",
  overtimeEndTime: "",
  overtimeReason: "",
  attachmentList: [],
  approvalFlowNodes: [
    { approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1, roleName: "", roleCode: "" },
  ],
});
const { proxy } = getCurrentInstance();
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
/** æŒ‰èµ·æ­¢æ—¶é—´è®¡ç®—加班时长(小时,保留两位小数) */
function computeOvertimeHours(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  const hours = t1.diff(t0, "millisecond") / (60 * 60 * 1000);
  return Math.round(hours * 100) / 100;
}
function formatHours(v) {
  if (v == null || v === "") return "—";
  return `${v} å°æ—¶`;
}
const allUsersCache = ref([]);
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function applicantNoFromUser(u) {
  if (!u) return "";
  return (
    u.userName ??
    u.userCode ??
    u.jobNumber ??
    u.workNo ??
    (u.userId != null ? String(u.userId) : "")
  );
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return [...list];
  return list.filter((u) => {
    const nick = (u.nickName || "").toLowerCase();
    const uname = (u.userName || "").toLowerCase();
    const phone = (u.phonenumber || u.phone || "").toString();
    return nick.includes(q) || uname.includes(q) || phone.includes(q);
  });
}
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
}
function onApplicantChange(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantName = u.nickName || u.userName || "";
    form.applicantNo = applicantNoFromUser(u);
  } else {
    form.applicantName = "";
    form.applicantNo = "";
  }
}
const allRows = ref([
  {
    id: "1",
    applicantId: "mock_1",
    applicantNo: "zhangsan",
    applicantName: "张三",
    overtimeType: "weekday",
    overtimeDate: "2026-05-10",
    overtimeStartTime: "2026-05-10 18:00:00",
    overtimeEndTime: "2026-05-10 21:30:00",
    overtimeHours: 3.5,
    overtimeReason: "项目上线保障。",
    approvalFlowNodes: demoApprovalFlowNodes(),
    approvalResult: "pending",
    attachmentList: [{ name: "任务单.pdf" }],
    createTime: "2026-05-09 10:20:00",
  },
  {
    id: "2",
    applicantId: "mock_2",
    applicantNo: "lisi",
    applicantName: "李四",
    overtimeType: "weekend",
    overtimeDate: "2026-05-11",
    overtimeStartTime: "2026-05-11 09:00:00",
    overtimeEndTime: "2026-05-11 12:15:00",
    overtimeHours: 3.25,
    overtimeReason: "客户现场支持。",
    approvalFlowNodes: demoApprovalFlowNodes(),
    approvalResult: "approved",
    attachmentList: [],
    createTime: "2026-05-10 16:00:00",
  },
]);
const searchForm = reactive({
  applicantKeyword: "",
  overtimeType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
  if (kw) {
    list = list.filter((r) => {
      const name = (r.applicantName || "").toLowerCase();
      const no = (r.applicantNo || "").toLowerCase();
      return name.includes(kw) || no.includes(kw);
    });
  }
  if (searchForm.overtimeType) {
    list = list.filter((r) => r.overtimeType === searchForm.overtimeType);
  }
  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 list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人编号", prop: "applicantNo", width: 120 },
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "加班日期", prop: "overtimeDate", width: 120 },
  { label: "加班开始日期", prop: "overtimeStartTime", width: 170 },
  { label: "加班结束日期", prop: "overtimeEndTime", width: 170 },
  {
    label: "加班时长",
    prop: "overtimeHours",
    width: 120,
    formatData: (v) => (v == null || v === "" ? "—" : `${v} å°æ—¶`),
  },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  { label: "创建时间", prop: "createTime", width: 170 },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 220,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openFormDialog("edit", row),
      },
      {
        name: "查看详情",
        type: "text",
        clickFun: (row) => openDetail(row),
      },
      {
        name: "附件",
        type: "text",
        clickFun: (row) => openFiles(row),
      },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const flowUserOptions = computed(() => allUsersCache.value.filter((u) => isActiveUser(u)));
const overtimeHoursDisplay = computed(() => {
  const h = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
  return h == null ? "" : String(h);
});
function onOvertimeRangeChange() {
  nextTick(() => {
    formRef.value?.validateField?.("overtimeEndTime");
  });
}
function onApprovalFlowChange() {
  nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
}
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  overtimeType: [{ required: true, message: "请选择加班类型", trigger: "change" }],
  overtimeDate: [{ required: true, message: "请选择加班日期", trigger: "change" }],
  overtimeStartTime: [{ required: true, message: "请选择加班开始时间", trigger: "change" }],
  overtimeEndTime: [
    { required: true, message: "请选择加班结束时间", trigger: "change" },
    {
      validator: (_rule, val, callback) => {
        if (!form.overtimeStartTime || !val) {
          callback();
          return;
        }
        const h = computeOvertimeHours(form.overtimeStartTime, val);
        if (h == null) {
          callback(new Error("结束时间须晚于开始时间"));
        } else {
          callback();
        }
      },
      trigger: "change",
    },
  ],
  overtimeReason: [{ required: true, message: "请填写加班事由", trigger: "blur" }],
  approvalFlowNodes: [
    {
      validator: (_rule, _val, callback) => {
        const nodes = form.approvalFlowNodes || [];
        if (!nodes.length) {
          callback(new Error("请至少保留一个审批节点"));
          return;
        }
        if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
          callback(new Error("每个审批节点必须选择一名审批人"));
          return;
        }
        const ids = nodes.map((n) => String(n.approverId));
        if (new Set(ids).size !== ids.length) {
          callback(new Error("同一审批人不能重复出现在多个节点"));
          return;
        }
        callback();
      },
      trigger: "change",
    },
  ],
};
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
const importInputRef = ref(null);
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
}
function resetSearch() {
  searchForm.applicantKeyword = "";
  searchForm.overtimeType = "";
  handleQuery();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgSuccess?.(`已模拟下载:${row.name}`);
}
function handleExport() {
  const data = filteredList.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");
  a.href = url;
  a.download = `加班申请导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
  a.click();
  URL.revokeObjectURL(url);
  proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡ï¼ˆå½“前筛选结果,JSON)`);
}
function handleImportClick() {
  importInputRef.value?.click?.();
}
function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const hours =
    raw.overtimeHours != null && raw.overtimeHours !== ""
      ? Number(raw.overtimeHours)
      : computeOvertimeHours(raw.overtimeStartTime, raw.overtimeEndTime);
  return {
    id,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    applicantNo: raw.applicantNo ?? "",
    applicantName: raw.applicantName ?? "未知",
    overtimeType: raw.overtimeType || "weekday",
    overtimeDate: raw.overtimeDate ?? "",
    overtimeStartTime: raw.overtimeStartTime ?? "",
    overtimeEndTime: raw.overtimeEndTime ?? "",
    overtimeHours: hours == null || Number.isNaN(hours) ? 0 : Math.round(hours * 100) / 100,
    overtimeReason: raw.overtimeReason ?? "",
    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
      ? raw.approvalFlowNodes.map((n) => ({ ...n }))
      : [],
    approvalResult: raw.approvalResult && ["pending", "approved", "rejected", "cancelled"].includes(raw.approvalResult)
      ? raw.approvalResult
      : "pending",
    attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [],
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
  };
}
function onImportFile(e) {
  const input = e.target;
  const file = input.files?.[0];
  input.value = "";
  if (!file) return;
  const reader = new FileReader();
  reader.onload = () => {
    try {
      const text = String(reader.result || "");
      const parsed = JSON.parse(text);
      const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
      if (!Array.isArray(arr) || !arr.length) {
        proxy?.$modal?.msgWarning?.("导入文件格式不正确,需为加班申请对象数组 JSON");
        return;
      }
      let n = 0;
      for (let i = 0; i < arr.length; i++) {
        allRows.value.unshift(normalizeImportedRow(arr[i], i));
        n++;
      }
      proxy?.$modal?.msgSuccess?.(`成功导入 ${n} æ¡ï¼ˆæœ¬åœ°åˆå¹¶ï¼‰`);
      handleQuery();
    } catch {
      proxy?.$modal?.msgError?.("解析失败,请使用导出文件或约定 JSON ç»“æž„");
    }
  };
  reader.readAsText(file, "utf-8");
}
async function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增加班申请" : "编辑加班申请";
  if (!allUsersCache.value.length) {
    await loadUserPool();
  }
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantNo: row.applicantNo,
      applicantName: row.applicantName,
      overtimeType: row.overtimeType,
      overtimeDate: row.overtimeDate,
      overtimeStartTime: row.overtimeStartTime,
      overtimeEndTime: row.overtimeEndTime,
      overtimeReason: row.overtimeReason,
      attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
      approvalFlowNodes: row.approvalFlowNodes?.length
        ? JSON.parse(JSON.stringify(row.approvalFlowNodes))
        : [],
    });
    const u = userById(row.applicantId);
    if (u) {
      applicantFormOptions.value = [u];
    } else if (row.applicantId) {
      applicantFormOptions.value = [
        {
          userId: row.applicantId,
          nickName: row.applicantName,
          userName: row.applicantNo,
        },
      ];
    }
  } else {
    remoteSearchApplicantForm("");
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  const hours = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
  if (hours == null) {
    proxy?.$modal?.msgWarning?.("请检查加班起止时间,结束时间须晚于开始时间");
    return;
  }
  const payload = {
    applicantId: form.applicantId,
    applicantNo: form.applicantNo,
    applicantName: form.applicantName,
    overtimeType: form.overtimeType,
    overtimeDate: form.overtimeDate,
    overtimeStartTime: form.overtimeStartTime,
    overtimeEndTime: form.overtimeEndTime,
    overtimeHours: hours,
    overtimeReason: form.overtimeReason,
    approvalFlowNodes: (form.approvalFlowNodes || []).map((n, i) => ({
      approverId: n.approverId,
      approverName:
        n.approverName || userById(n.approverId)?.nickName || userById(n.approverId)?.userName || "",
      sortOrder: i + 1,
      nodeOrder: i + 1,
      roleName: n.roleName || "",
      roleCode: n.roleCode || "",
    })),
    attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
      createTime: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    proxy?.$modal?.msgSuccess?.("新增成功(本地模拟)");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    if (idx !== -1) {
      const prev = allRows.value[idx];
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
        approvalResult: prev.approvalResult ?? "pending",
        createTime: prev.createTime ?? dayjs().format("YYYY-MM-DD HH:mm:ss"),
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)");
  }
  formDialog.visible = false;
  handleQuery();
}
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.upload-block {
  width: 100%;
}
.mr6 {
  margin-right: 6px;
}
.mb6 {
  margin-bottom: 6px;
}
.overtime-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.overtime-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.overtime-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.flow-tip {
  margin: 10px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--el-text-color-secondary);
}
.detail-flow-chain {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 6px 8px;
  line-height: 1.6;
}
.detail-flow-step {
  font-size: 14px;
  color: var(--el-text-color-primary);
}
.detail-flow-sep {
  color: var(--el-text-color-secondary);
  font-size: 13px;
}
</style>
src/views/officeProcessAutomation/ContractManage/purchase-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  æ¨¡å—中文名:采购合同
  ç›®å½•标识:ContractManage/purchase-contract(purchase-contract â†’ ä¸­æ–‡ï¼šé‡‡è´­åˆåŒï¼‰
  å¤ç”¨é¡µé¢ï¼š@/views/procurementManagement/procurementLedger/index.vue(采购台账;文件名 index.vue â†’ å…¥å£é¡µï¼‰
-->
<template>
  <ProcurementLedger />
</template>
<script setup>
import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
</script>
src/views/officeProcessAutomation/ContractManage/sale-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,12 @@
<!--
  æ¨¡å—中文名:销售合同
  ç›®å½•标识:ContractManage/sale-contract(sale-contract â†’ ä¸­æ–‡ï¼šé”€å”®åˆåŒï¼‰
  å¤ç”¨é¡µé¢ï¼š@/views/procurementManagement/procurementLedger/index.vue(采购台账;文件名 index.vue â†’ å…¥å£é¡µï¼‰
-->
<template>
  <ProcurementLedger />
</template>
<script setup>
import ProcurementLedger from '@/views/procurementManagement/procurementLedger/index.vue'
</script>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/components/NewsDetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,169 @@
<!-- EnterpriseNews:详情只读面板(含互动) -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="新闻编号">{{ row.newsNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="发布状态">
      <el-tag :type="publishStatusTag(row.publishStatus)" size="small">
        {{ publishStatusLabel(row.publishStatus) }}
      </el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="新闻分类">
      <span class="type-badge" :style="{ color: newsTypeColor(row.newsType) }">
        {{ newsTypeLabel(row.newsType) }}
      </span>
    </el-descriptions-item>
    <el-descriptions-item label="排版模板">{{ layoutTemplateLabel(row.layoutTemplate) }}</el-descriptions-item>
    <el-descriptions-item label="标题" :span="2">{{ row.title || "—" }}</el-descriptions-item>
    <el-descriptions-item label="摘要" :span="2">{{ row.summary || "—" }}</el-descriptions-item>
    <el-descriptions-item label="阅读范围">{{ readScopeLabel(row.readScope) }}</el-descriptions-item>
    <el-descriptions-item label="阅读率">
      {{ readRate(row) }}%(未读 {{ unreadCount }} äººï¼‰
    </el-descriptions-item>
    <el-descriptions-item label="编辑权限">{{ publishRoleLabel(row.editorRole) }}</el-descriptions-item>
    <el-descriptions-item label="审核角色">{{ publishRoleLabel(row.reviewerRole) }}</el-descriptions-item>
    <el-descriptions-item label="发布人">{{ row.publisherName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="发布时间">{{ row.publishTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="当前版本">v{{ row.versionNo || 1 }}</el-descriptions-item>
    <el-descriptions-item label="需阅读确认">
      {{ row.requireReadConfirm ? "是" : "否" }}
    </el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">正文内容</el-divider>
  <div v-if="row.contentHtml" class="news-html-body" v-html="row.contentHtml" />
  <el-empty v-else description="暂无正文" :image-size="48" />
  <template v-if="row.mediaList?.length">
    <el-divider content-position="left">图集 / è§†é¢‘</el-divider>
    <div class="media-grid">
      <div v-for="(m, i) in row.mediaList" :key="i" class="media-item">
        <el-tag size="small" type="info">{{ m.type === "video" ? "视频" : "图片" }}</el-tag>
        <span class="media-name">{{ m.name }}</span>
      </div>
    </div>
  </template>
  <el-divider content-position="left">附件</el-divider>
  <template v-if="row.attachmentList?.length">
    <el-tag
      v-for="(f, i) in row.attachmentList"
      :key="i"
      class="file-tag"
      type="info"
      @click="openFile(f)"
    >
      {{ f.name }}
    </el-tag>
  </template>
  <el-empty v-else description="暂无附件" :image-size="48" />
  <template v-if="row.newsType === 'culture' && row.publishStatus === 'published'">
    <el-divider content-position="left">互动(点赞 {{ likeCount }} Â· è¯„论 {{ commentCount }})</el-divider>
    <div class="interaction-bar">
      <el-button type="primary" plain size="small" @click="$emit('like')">
        {{ likedByMe ? "取消点赞" : "点赞" }}
      </el-button>
    </div>
    <el-input
      v-model="commentDraft"
      type="textarea"
      :rows="2"
      maxlength="300"
      show-word-limit
      placeholder="写下你的评论…"
      class="mb8"
    />
    <el-button type="primary" size="small" @click="submitComment">发表评论</el-button>
    <el-timeline v-if="row.comments?.length" class="comment-timeline mt12">
      <el-timeline-item v-for="c in row.comments" :key="c.id" :timestamp="c.time">
        <strong>{{ c.name }}</strong>:{{ c.content }}
      </el-timeline-item>
    </el-timeline>
    <el-empty v-else description="暂无评论" :image-size="40" />
  </template>
</template>
<script setup>
import { computed, ref } from "vue";
import {
  newsTypeLabel,
  newsTypeColor,
  publishStatusLabel,
  publishStatusTag,
  layoutTemplateLabel,
  readScopeLabel,
  publishRoleLabel,
  readRate,
  getUnreadEmployees,
} from "../enterpriseNewsUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const emit = defineEmits(["like", "comment"]);
const commentDraft = ref("");
const unreadCount = computed(() => getUnreadEmployees(props.row).length);
const likeCount = computed(() => props.row?.likes?.length || 0);
const commentCount = computed(() => props.row?.comments?.length || 0);
const likedByMe = computed(() => (props.row?.likes || []).some((l) => l.userId === "u1"));
function openFile(f) {
  const url = f?.url || f?.downloadURL;
  if (url) window.open(url, "_blank");
}
function submitComment() {
  emit("comment", commentDraft.value);
  commentDraft.value = "";
}
</script>
<style scoped>
.type-badge {
  font-weight: 600;
}
.news-html-body {
  padding: 12px 16px;
  background: var(--el-fill-color-light);
  border-radius: 6px;
  line-height: 1.7;
  max-height: 320px;
  overflow-y: auto;
}
.media-grid {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
}
.media-item {
  display: flex;
  align-items: center;
  gap: 8px;
  padding: 8px 12px;
  background: var(--el-fill-color-lighter);
  border-radius: 4px;
}
.media-name {
  font-size: 13px;
}
.file-tag {
  margin: 0 8px 8px 0;
  cursor: pointer;
}
.interaction-bar {
  margin-bottom: 8px;
}
.comment-timeline {
  max-height: 200px;
  overflow-y: auto;
}
.mb8 {
  margin-bottom: 8px;
}
.mt12 {
  margin-top: 12px;
}
</style>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/enterpriseNewsUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,375 @@
import dayjs from "dayjs";
/** æ–°é—»åˆ†ç±»ï¼šç»Ÿä¸€ä¿¡æ¯å‡ºå£ */
export const NEWS_TYPE_OPTIONS = [
  { value: "announcement", label: "企业公告", color: "#409eff" },
  { value: "policy", label: "政策解读", color: "#e6a23c" },
  { value: "industry", label: "行业动态", color: "#909399" },
  { value: "culture", label: "文化活动", color: "#67c23a" },
];
/** å‘布状态 */
export const PUBLISH_STATUS_OPTIONS = [
  { value: "draft", label: "草稿", tag: "info" },
  { value: "pending_review", label: "待审核", tag: "warning" },
  { value: "published", label: "已发布", tag: "success" },
  { value: "archived", label: "已归档", tag: "" },
];
/** æŽ’版模板 */
export const LAYOUT_TEMPLATE_OPTIONS = [
  { value: "standard", label: "标准图文" },
  { value: "policy", label: "政策条文" },
  { value: "gallery", label: "图集相册" },
  { value: "briefing", label: "简报摘要" },
];
/** é˜…读可见范围 */
export const READ_SCOPE_OPTIONS = [
  { value: "all", label: "全员可见" },
  { value: "management", label: "管理层" },
  { value: "department", label: "指定部门" },
  { value: "custom", label: "自定义名单" },
];
/** ç¼–辑/审核角色(发布权限) */
export const PUBLISH_ROLE_OPTIONS = [
  { value: "hr", label: "HR(人事政策)" },
  { value: "admin", label: "管理员(外部新闻审核)" },
  { value: "dept_manager", label: "部门负责人" },
  { value: "editor", label: "内容编辑" },
];
export const STORAGE_KEY = "oa_enterprise_news_v1";
/** æ¼”示用目标受众(后期对接组织架构) */
export const MOCK_AUDIENCE = [
  { userId: "u1", employeeNo: "zhangsan", name: "张三", deptName: "研发部", isManagement: false },
  { userId: "u2", employeeNo: "lisi", name: "李四", deptName: "研发部", isManagement: false },
  { userId: "u3", employeeNo: "wangwu", name: "王五", deptName: "行政部", isManagement: false },
  { userId: "u4", employeeNo: "zhaoliu", name: "赵六", deptName: "销售部", isManagement: false },
  { userId: "u5", employeeNo: "sunqi", name: "孙七", deptName: "财务部", isManagement: false },
  { userId: "u6", employeeNo: "zhouba", name: "周八", deptName: "总经办", isManagement: true },
  { userId: "u7", employeeNo: "wujiu", name: "吴九", deptName: "总经办", isManagement: true },
  { userId: "u8", employeeNo: "zhengshi", name: "郑十", deptName: "人力资源部", isManagement: false },
];
const DEPT_OPTIONS = [
  { value: "101", label: "研发部" },
  { value: "102", label: "销售部" },
  { value: "103", label: "行政部" },
  { value: "104", label: "财务部" },
  { value: "105", label: "总经办" },
  { value: "106", label: "人力资源部" },
];
export { DEPT_OPTIONS };
export function newsTypeLabel(v) {
  return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function newsTypeColor(v) {
  return NEWS_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399";
}
export function publishStatusLabel(v) {
  return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function publishStatusTag(v) {
  return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info";
}
export function layoutTemplateLabel(v) {
  return LAYOUT_TEMPLATE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function readScopeLabel(v) {
  return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function publishRoleLabel(v) {
  return PUBLISH_ROLE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function createEmptyForm() {
  return {
    id: "",
    newsNo: "",
    title: "",
    summary: "",
    newsType: "announcement",
    layoutTemplate: "standard",
    contentHtml: "",
    coverImage: "",
    mediaList: [],
    attachmentList: [],
    editorRole: "hr",
    reviewerRole: "admin",
    readScope: "all",
    targetDeptIds: [],
    targetUserIds: [],
    publishStatus: "draft",
    publisherName: "",
    publishTime: "",
    readRecords: [],
    remindLogs: [],
    likes: [],
    comments: [],
    versions: [],
    versionNo: 1,
    requireReadConfirm: false,
  };
}
function buildReadRecords(readUserIds = []) {
  const set = new Set(readUserIds);
  return MOCK_AUDIENCE.map((u) => ({
    userId: u.userId,
    employeeNo: u.employeeNo,
    name: u.name,
    deptName: u.deptName,
    readAt: set.has(u.userId) ? dayjs().subtract(2, "day").format("YYYY-MM-DD HH:mm:ss") : "",
    lastRemindAt: "",
  }));
}
function createVersionSnapshot(row, changeNote = "发布") {
  return {
    versionNo: row.versionNo || 1,
    title: row.title,
    summary: row.summary,
    contentHtml: row.contentHtml,
    newsType: row.newsType,
    publishTime: row.publishTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
    archivedAt: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    changeNote,
    publisherName: row.publisherName || "系统",
  };
}
export function createInitialMockNews() {
  const policyContent =
    "<p><strong>2026 å¹´è€ƒå‹¤ç®¡ç†åˆ¶åº¦ï¼ˆè¯•行)</strong></p><p>一、上班时间 9:00,弹性打卡窗口 8:30–9:30。</p><p>二、请假须提前在 OA æäº¤å®¡æ‰¹ã€‚</p><p>三、本制度自 2026-06-01 èµ·æ‰§è¡Œã€‚</p>";
  const cultureContent =
    "<p>2026 ä¼ä¸šå¹´ä¼šåœ†æ»¡è½å¹•!感谢每一位同事的参与,以下为精彩瞬间图集。</p>";
  const strategyContent =
    "<p><strong>2026 ä¸‹åŠå¹´æˆ˜ç•¥æ–¹å‘(内部)</strong></p><p>聚焦核心产品线升级与海外市场拓展,具体指标见附件。</p>";
  const policyRow = {
    id: "news_1",
    newsNo: "EN202605150001",
    title: "关于发布新考勤制度的通知",
    summary: "请全体员工认真阅读并确认知悉,自 2026-06-01 èµ·æ‰§è¡Œã€‚",
    newsType: "policy",
    layoutTemplate: "policy",
    contentHtml: policyContent,
    coverImage: "",
    mediaList: [],
    attachmentList: [{ name: "考勤制度2026.pdf", url: "/mock/attendance-policy.pdf" }],
    editorRole: "hr",
    reviewerRole: "admin",
    readScope: "all",
    targetDeptIds: [],
    targetUserIds: [],
    publishStatus: "published",
    publisherName: "人力资源部",
    publishTime: "2026-05-15 10:00:00",
    readRecords: buildReadRecords(["u6", "u7", "u8"]),
    remindLogs: [],
    likes: [],
    comments: [],
    versions: [
      {
        versionNo: 1,
        title: "关于发布新考勤制度的通知(征求意见稿)",
        summary: "征求意见稿",
        contentHtml: "<p>征求意见稿:上班时间 9:00……</p>",
        newsType: "policy",
        publishTime: "2026-05-10 09:00:00",
        archivedAt: "2026-05-15 09:55:00",
        changeNote: "定稿发布",
        publisherName: "人力资源部",
      },
    ],
    versionNo: 2,
    requireReadConfirm: true,
    createTime: "2026-05-10 09:00:00",
    updateTime: "2026-05-15 10:00:00",
  };
  const cultureRow = {
    id: "news_2",
    newsNo: "EN202605200002",
    title: "2026 ä¼ä¸šå¹´ä¼šç²¾å½©çž¬é—´",
    summary: "年会图集上线,欢迎点赞留言,共建企业文化。",
    newsType: "culture",
    layoutTemplate: "gallery",
    contentHtml: cultureContent,
    coverImage: "/mock/annual-cover.jpg",
    mediaList: [
      { type: "image", name: "开场.jpg", url: "/mock/annual-1.jpg" },
      { type: "image", name: "颁奖.jpg", url: "/mock/annual-2.jpg" },
      { type: "video", name: "年会花絮.mp4", url: "/mock/annual.mp4" },
    ],
    attachmentList: [],
    editorRole: "dept_manager",
    reviewerRole: "admin",
    readScope: "all",
    targetDeptIds: [],
    targetUserIds: [],
    publishStatus: "published",
    publisherName: "行政部",
    publishTime: "2026-05-20 14:30:00",
    readRecords: buildReadRecords(["u1", "u2", "u3", "u4", "u5", "u6", "u7"]),
    remindLogs: [],
    likes: [
      { userId: "u1", name: "张三", time: "2026-05-20 15:01:00" },
      { userId: "u2", name: "李四", time: "2026-05-20 15:05:00" },
      { userId: "u4", name: "赵六", time: "2026-05-20 16:20:00" },
    ],
    comments: [
      { id: "c1", userId: "u1", name: "张三", content: "节目太精彩了!", time: "2026-05-20 15:10:00" },
      { id: "c2", userId: "u3", name: "王五", content: "期待明年再聚!", time: "2026-05-20 17:00:00" },
    ],
    versions: [],
    versionNo: 1,
    requireReadConfirm: false,
    createTime: "2026-05-20 14:00:00",
    updateTime: "2026-05-20 14:30:00",
  };
  const strategyRow = {
    id: "news_3",
    newsNo: "EN202605220003",
    title: "2026 ä¸‹åŠå¹´æˆ˜ç•¥è§„划要点",
    summary: "仅限管理层阅读,请勿对外传播。",
    newsType: "announcement",
    layoutTemplate: "briefing",
    contentHtml: strategyContent,
    coverImage: "",
    mediaList: [],
    attachmentList: [{ name: "战略指标.pdf", url: "/mock/strategy.pdf" }],
    editorRole: "admin",
    reviewerRole: "admin",
    readScope: "management",
    targetDeptIds: [],
    targetUserIds: [],
    publishStatus: "published",
    publisherName: "总经办",
    publishTime: "2026-05-22 09:00:00",
    readRecords: buildReadRecords(["u6", "u7"]),
    remindLogs: [],
    likes: [],
    comments: [],
    versions: [],
    versionNo: 1,
    requireReadConfirm: false,
    createTime: "2026-05-22 08:30:00",
    updateTime: "2026-05-22 09:00:00",
  };
  const industryDraft = {
    id: "news_4",
    newsNo: "EN202605250004",
    title: "制造业数字化转型趋势简报",
    summary: "行业动态草稿,待管理员审核后发布。",
    newsType: "industry",
    layoutTemplate: "standard",
    contentHtml: "<p>本期简报梳理工业互联网与 AI è´¨æ£€åº”用案例……</p>",
    coverImage: "",
    mediaList: [],
    attachmentList: [],
    editorRole: "editor",
    reviewerRole: "admin",
    readScope: "all",
    targetDeptIds: [],
    targetUserIds: [],
    publishStatus: "pending_review",
    publisherName: "市场部",
    publishTime: "",
    readRecords: [],
    remindLogs: [],
    likes: [],
    comments: [],
    versions: [],
    versionNo: 1,
    requireReadConfirm: false,
    createTime: "2026-05-25 11:00:00",
    updateTime: "2026-05-25 11:00:00",
  };
  return [policyRow, cultureRow, strategyRow, industryDraft];
}
export function loadStoredNews() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const data = JSON.parse(raw);
    return Array.isArray(data) ? data : null;
  } catch {
    return null;
  }
}
export function saveStoredNews(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
  } catch {
    /* ignore */
  }
}
/** æŒ‰é˜…读范围解析目标受众 */
export function resolveTargetAudience(row) {
  const scope = row.readScope || "all";
  if (scope === "management") {
    return MOCK_AUDIENCE.filter((u) => u.isManagement);
  }
  if (scope === "department" && row.targetDeptIds?.length) {
    const names = DEPT_OPTIONS.filter((d) => row.targetDeptIds.includes(d.value)).map((d) => d.label);
    return MOCK_AUDIENCE.filter((u) => names.includes(u.deptName));
  }
  if (scope === "custom" && row.targetUserIds?.length) {
    return MOCK_AUDIENCE.filter((u) => row.targetUserIds.includes(u.userId));
  }
  return [...MOCK_AUDIENCE];
}
export function getUnreadEmployees(row) {
  const audience = resolveTargetAudience(row);
  const readSet = new Set(
    (row.readRecords || []).filter((r) => r.readAt).map((r) => r.userId)
  );
  return audience.filter((u) => !readSet.has(u.userId));
}
export function readRate(row) {
  const audience = resolveTargetAudience(row);
  if (!audience.length) return 0;
  const readCount = (row.readRecords || []).filter((r) => r.readAt).length;
  return Math.round((readCount / audience.length) * 100);
}
export function nextNewsNo() {
  return `EN${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
}
export function pushVersionBeforeUpdate(row, changeNote) {
  const versions = row.versions || [];
  versions.unshift(createVersionSnapshot(row, changeNote));
  row.versions = versions;
  row.versionNo = (row.versionNo || 1) + 1;
}
export function validateNewsForm(form) {
  const title = (form.title || "").trim();
  if (!title) return { ok: false, message: "请填写新闻标题" };
  if (!form.newsType) return { ok: false, message: "请选择新闻分类" };
  if (form.readScope === "department" && !(form.targetDeptIds || []).length) {
    return { ok: false, message: "请选择可见部门" };
  }
  return { ok: true, title };
}
src/views/officeProcessAutomation/EnterpriseNews/news-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,461 @@
<!--OA模块:EnterpriseNews ä¼ä¸šæ–°é—»-->
<template>
  <div class="app-container enterprise-news-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.newsType" placeholder="全部" clearable style="width: 140px">
          <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">状态:</span>
        <el-select v-model="searchForm.publishStatus" placeholder="全部" clearable style="width: 120px">
          <el-option v-for="opt in PUBLISH_STATUS_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">发布时间:</span>
        <el-date-picker
          v-model="searchForm.publishTimeRange"
          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 #newsType="{ row }">
          <span class="news-type-tag" :style="{ color: newsTypeColor(row.newsType) }">
            {{ newsTypeLabel(row.newsType) }}
          </span>
        </template>
      </PIMTable>
    </div>
    <!-- æ–°å»º / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="news-form-dialog"
      @closed="formRef?.resetFields?.()"
    >
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="110px"
        :disabled="formDialog.readonly"
      >
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="新闻分类" prop="newsType">
              <el-select v-model="form.newsType" placeholder="请选择" style="width: 100%">
                <el-option v-for="opt in NEWS_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="排版模板">
              <el-select v-model="form.layoutTemplate" style="width: 100%">
                <el-option
                  v-for="opt in LAYOUT_TEMPLATE_OPTIONS"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="标题" prop="title">
          <el-input v-model="form.title" placeholder="新闻标题" maxlength="100" show-word-limit />
        </el-form-item>
        <el-form-item label="摘要">
          <el-input v-model="form.summary" type="textarea" :rows="2" maxlength="300" show-word-limit />
        </el-form-item>
        <el-form-item label="正文" prop="contentHtml">
          <Editor v-model="form.contentHtml" :min-height="280" />
        </el-form-item>
        <el-form-item label="附件">
          <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="上传 PDF / æ–‡æ¡£" />
        </el-form-item>
        <el-form-item v-if="form.layoutTemplate === 'gallery'" label="图集/视频">
          <el-input
            v-model="galleryInput"
            placeholder="输入资源名称后回车添加(演示)"
            @keyup.enter="addGalleryItem"
          />
          <el-tag
            v-for="(m, i) in form.mediaList"
            :key="i"
            closable
            class="media-tag"
            @close="form.mediaList.splice(i, 1)"
          >
            {{ m.name }}
          </el-tag>
        </el-form-item>
        <el-divider content-position="left">权限管控</el-divider>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="编辑角色">
              <el-select v-model="form.editorRole" style="width: 100%">
                <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="审核角色">
              <el-select v-model="form.reviewerRole" style="width: 100%">
                <el-option v-for="opt in PUBLISH_ROLE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-form-item label="阅读范围" prop="readScope">
          <el-radio-group v-model="form.readScope">
            <el-radio 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 label="政策类必读">
          <el-switch v-model="form.requireReadConfirm" active-text="需阅读确认(便于统计未读)" />
        </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('save')">存草稿</el-button>
        <el-button type="warning" @click="onSave('submit_review')">提交审核</el-button>
        <el-button type="primary" @click="onSave('publish')">直接发布</el-button>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="新闻详情" width="880px" append-to-body destroy-on-close>
      <NewsDetailPanel
        :row="detailRow"
        @like="onDetailLike"
        @comment="onDetailComment"
      />
      <template #footer>
        <el-button
          v-if="detailRow.publishStatus === 'published' && getUnreadEmployees(detailRow).length"
          type="warning"
          @click="openUnreadFromDetail"
        >
          æœªè¯»æé†’
        </el-button>
        <el-button @click="openVersionFromDetail">版本留证</el-button>
        <el-button @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- æœªè¯»æé†’ -->
    <el-dialog
      v-model="unreadDialog.visible"
      :title="`未阅读员工 Â· ${unreadDialog.row?.title || ''}`"
      width="720px"
      append-to-body
      destroy-on-close
    >
      <el-alert type="warning" show-icon :closable="false" class="mb12">
        æ”¿ç­–传达场景:发布新考勤制度等必读信息后,可勾选未读员工由 HR å®šå‘提醒(演示数据,后期对接消息中心)。
      </el-alert>
      <div class="unread-toolbar mb12">
        <el-button size="small" @click="selectAllUnread">全选未读</el-button>
        <span class="unread-stat">共 {{ unreadList.length }} äººæœªè¯»</span>
      </div>
      <el-table
        :data="unreadList"
        border
        size="small"
        max-height="360"
        @selection-change="onUnreadSelectionChange"
      >
        <el-table-column type="selection" width="48" />
        <el-table-column prop="employeeNo" label="工号" width="100" />
        <el-table-column prop="name" label="姓名" width="90" />
        <el-table-column prop="deptName" label="部门" min-width="120" />
      </el-table>
      <el-divider v-if="unreadDialog.row?.remindLogs?.length" content-position="left">提醒记录</el-divider>
      <el-timeline v-if="unreadDialog.row?.remindLogs?.length">
        <el-timeline-item
          v-for="(log, i) in unreadDialog.row.remindLogs"
          :key="i"
          :timestamp="log.time"
        >
          {{ log.operator }} å·²å‘ {{ log.count }} äººå‘送阅读提醒
        </el-timeline-item>
      </el-timeline>
      <template #footer>
        <el-button type="primary" @click="onSendRemind">发送定向提醒</el-button>
        <el-button @click="unreadDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- ç‰ˆæœ¬ç•™è¯ -->
    <el-dialog
      v-model="versionDialog.visible"
      :title="`历史版本留证 Â· ${versionDialog.row?.title || ''}`"
      width="800px"
      append-to-body
      destroy-on-close
    >
      <el-alert type="info" show-icon :closable="false" class="mb12">
        äº‰è®®å‘生时可查阅历史版本,证明当时发布内容与发布时间(合规留证)。
      </el-alert>
      <el-descriptions :column="2" border class="mb16">
        <el-descriptions-item label="当前版本">v{{ versionDialog.row?.versionNo || 1 }}</el-descriptions-item>
        <el-descriptions-item label="最近发布">{{ versionDialog.row?.publishTime || "—" }}</el-descriptions-item>
      </el-descriptions>
      <el-table :data="versionList" border size="small" empty-text="暂无历史版本">
        <el-table-column prop="versionNo" label="版本" width="70" align="center" />
        <el-table-column prop="title" label="标题" min-width="160" show-overflow-tooltip />
        <el-table-column prop="changeNote" label="变更说明" width="120" />
        <el-table-column prop="publishTime" label="发布时间" width="170" />
        <el-table-column prop="archivedAt" label="归档时间" width="170" />
        <el-table-column label="操作" width="90" align="center">
          <template #default="{ row: ver }">
            <el-button type="primary" link @click="previewVersion(ver)">查看</el-button>
          </template>
        </el-table-column>
      </el-table>
      <template #footer>
        <el-button @click="versionDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- ç‰ˆæœ¬é¢„览 -->
    <el-dialog v-model="versionPreview.visible" title="历史版本内容" width="640px" append-to-body>
      <p class="version-meta">
        v{{ versionPreview.data?.versionNo }} Â· {{ versionPreview.data?.changeNote }} Â·
        {{ versionPreview.data?.publishTime }}
      </p>
      <div class="version-html" v-html="versionPreview.data?.contentHtml || ''" />
    </el-dialog>
  </div>
</template>
<script setup>
import { Plus, RefreshRight } from "@element-plus/icons-vue";
import { ElMessage } from "element-plus";
import { computed, onMounted, reactive, ref } from "vue";
import Editor from "@/components/Editor/index.vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { newsTypeColor } from "./enterpriseNewsUtils.js";
import NewsDetailPanel from "./components/NewsDetailPanel.vue";
import { useEnterpriseNews } from "./useEnterpriseNews.js";
const {
  Search,
  NEWS_TYPE_OPTIONS,
  PUBLISH_STATUS_OPTIONS,
  LAYOUT_TEMPLATE_OPTIONS,
  READ_SCOPE_OPTIONS,
  PUBLISH_ROLE_OPTIONS,
  DEPT_OPTIONS,
  newsTypeLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  formDialog,
  form,
  formRef,
  formRules,
  detailDialog,
  detailRow,
  unreadDialog,
  unreadList,
  versionDialog,
  getUnreadEmployees,
  handleQuery,
  resetSearch,
  pagination,
  openFormDialog,
  openDetail,
  openUnreadRemind,
  openVersionHistory,
  saveForm,
  sendUnreadRemind,
  toggleLike,
  addComment,
} = useEnterpriseNews();
const galleryInput = ref("");
const unreadSelected = ref([]);
const versionPreview = reactive({ visible: false, data: null });
const versionList = computed(() => {
  const row = versionDialog.row;
  if (!row) return [];
  const history = [...(row.versions || [])];
  return history.sort((a, b) => (b.versionNo || 0) - (a.versionNo || 0));
});
function addGalleryItem() {
  const name = (galleryInput.value || "").trim();
  if (!name) return;
  form.mediaList = form.mediaList || [];
  form.mediaList.push({ type: "image", name, url: `/mock/${name}` });
  galleryInput.value = "";
}
function onSave(action) {
  const ret = saveForm(action);
  if (ret?.message) {
    ElMessage.warning(ret.message);
    return;
  }
  if (ret?.ok) {
    ElMessage.success(action === "publish" ? "已发布" : action === "submit_review" ? "已提交审核" : "已保存");
  }
}
function onDetailLike() {
  toggleLike(detailRow.value);
}
function onDetailComment(text) {
  const ret = addComment(detailRow.value, text);
  if (ret?.message) ElMessage.warning(ret.message);
  else if (ret?.ok) ElMessage.success("评论已发布");
}
function openUnreadFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openUnreadRemind(row);
}
function openVersionFromDetail() {
  const row = detailRow.value;
  detailDialog.visible = false;
  openVersionHistory(row);
}
function onUnreadSelectionChange(rows) {
  unreadSelected.value = rows.map((r) => r.userId);
}
function selectAllUnread() {
  unreadSelected.value = unreadList.value.map((u) => u.userId);
}
function onSendRemind() {
  const ids = unreadSelected.value;
  const ret = sendUnreadRemind(ids);
  if (ret?.message) {
    ElMessage.warning(ret.message);
    return;
  }
  if (ret?.ok) ElMessage.success(`已向 ${ret.count} åå‘˜å·¥å‘送阅读提醒`);
}
function previewVersion(ver) {
  versionPreview.data = ver;
  versionPreview.visible = true;
}
onMounted(() => {
  handleQuery();
});
</script>
<style scoped>
.enterprise-news-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;
}
.news-type-tag {
  font-weight: 600;
  font-size: 13px;
}
.media-tag {
  margin: 6px 8px 0 0;
}
.unread-toolbar {
  display: flex;
  align-items: center;
  gap: 12px;
}
.unread-stat {
  color: var(--el-text-color-secondary);
  font-size: 13px;
}
.version-meta {
  color: var(--el-text-color-secondary);
  font-size: 13px;
  margin-bottom: 12px;
}
.version-html {
  padding: 12px;
  background: var(--el-fill-color-light);
  border-radius: 6px;
  max-height: 400px;
  overflow-y: auto;
}
.mb16 {
  margin-bottom: 16px;
}
.mb12 {
  margin-bottom: 12px;
}
.ml10 {
  margin-left: 10px;
}
</style>
src/views/officeProcessAutomation/EnterpriseNews/news-manage/useEnterpriseNews.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,440 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import {
  NEWS_TYPE_OPTIONS,
  PUBLISH_STATUS_OPTIONS,
  LAYOUT_TEMPLATE_OPTIONS,
  READ_SCOPE_OPTIONS,
  PUBLISH_ROLE_OPTIONS,
  DEPT_OPTIONS,
  createEmptyForm,
  createInitialMockNews,
  loadStoredNews,
  saveStoredNews,
  getUnreadEmployees,
  readRate,
  nextNewsNo,
  pushVersionBeforeUpdate,
  validateNewsForm,
  newsTypeLabel,
  publishStatusLabel,
} from "./enterpriseNewsUtils.js";
export function useEnterpriseNews() {
  const stored = loadStoredNews();
  const allRows = ref(stored?.length ? stored : createInitialMockNews());
  const searchForm = reactive({
    keyword: "",
    newsType: "",
    publishStatus: "",
    publishTimeRange: [],
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const form = reactive(createEmptyForm());
  const formRef = ref();
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const unreadDialog = reactive({ visible: false, row: null });
  const unreadSelection = ref([]);
  const versionDialog = reactive({ visible: false, row: null });
  const filteredList = computed(() => {
    let list = [...allRows.value];
    const kw = (searchForm.keyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => {
        const title = (r.title || "").toLowerCase();
        const summary = (r.summary || "").toLowerCase();
        const no = (r.newsNo || "").toLowerCase();
        return title.includes(kw) || summary.includes(kw) || no.includes(kw);
      });
    }
    if (searchForm.newsType) {
      list = list.filter((r) => r.newsType === searchForm.newsType);
    }
    if (searchForm.publishStatus) {
      list = list.filter((r) => r.publishStatus === searchForm.publishStatus);
    }
    const range = searchForm.publishTimeRange;
    if (range?.length === 2 && range[0] && range[1]) {
      const start = dayjs(range[0]).startOf("day");
      const end = dayjs(range[1]).endOf("day");
      list = list.filter((r) => {
        if (!r.publishTime) return false;
        const t = dayjs(r.publishTime);
        return t.isAfter(start) && t.isBefore(end);
      });
    }
    return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 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);
  });
  const unreadList = computed(() => {
    if (!unreadDialog.row) return [];
    return getUnreadEmployees(unreadDialog.row);
  });
  const formRules = {
    title: [{ required: true, message: "请输入新闻标题", trigger: "blur" }],
    newsType: [{ required: true, message: "请选择新闻分类", trigger: "change" }],
    readScope: [{ required: true, message: "请选择阅读范围", trigger: "change" }],
  };
  const tableColumn = ref([
    { label: "编号", prop: "newsNo", width: 150 },
    { label: "标题", prop: "title", minWidth: 180, showOverflowTooltip: true },
    {
      label: "分类",
      prop: "newsType",
      width: 100,
      dataType: "slot",
      slot: "newsType",
    },
    {
      label: "状态",
      prop: "publishStatus",
      width: 90,
      dataType: "tag",
      formatData: (v) => publishStatusLabel(v),
      formatType: (v) => {
        const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
        return hit?.tag || "info";
      },
    },
    {
      label: "阅读率",
      prop: "readRecords",
      width: 90,
      align: "center",
      formatData: (_, row) => `${readRate(row)}%`,
    },
    {
      label: "未读",
      prop: "id",
      width: 70,
      align: "center",
      formatData: (_, row) => {
        if (row.publishStatus !== "published") return "—";
        return getUnreadEmployees(row).length;
      },
    },
    { label: "发布人", prop: "publisherName", width: 110 },
    { label: "发布时间", prop: "publishTime", width: 170 },
    { label: "更新时间", prop: "updateTime", width: 170 },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 280,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "编辑",
          type: "text",
          disabled: (row) => row.publishStatus === "archived",
          clickFun: (row) => openFormDialog("edit", row),
        },
        {
          name: "审核",
          type: "text",
          disabled: (row) => row.publishStatus !== "pending_review",
          clickFun: (row) => openReview(row),
        },
        {
          name: "未读提醒",
          type: "text",
          disabled: (row) =>
            row.publishStatus !== "published" || getUnreadEmployees(row).length === 0,
          clickFun: (row) => openUnreadRemind(row),
        },
        { name: "版本留证", type: "text", clickFun: (row) => openVersionHistory(row) },
      ],
    },
  ]);
  function persist() {
    saveStoredNews(allRows.value);
  }
  function handleQuery() {
    tableLoading.value = true;
    page.current = 1;
    setTimeout(() => {
      tableLoading.value = false;
    }, 200);
  }
  function resetSearch() {
    searchForm.keyword = "";
    searchForm.newsType = "";
    searchForm.publishStatus = "";
    searchForm.publishTimeRange = [];
    handleQuery();
  }
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
  }
  function resetForm(target = createEmptyForm()) {
    Object.assign(form, createEmptyForm(), target);
  }
  function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.readonly = mode === "view";
    formDialog.title =
      mode === "add" ? "新建企业新闻" : mode === "edit" ? "编辑企业新闻" : "查看企业新闻";
    if (mode === "add") {
      resetForm({ publisherName: "当前用户" });
    } else {
      resetForm({
        ...JSON.parse(JSON.stringify(row)),
        targetDeptIds: [...(row.targetDeptIds || [])],
        targetUserIds: [...(row.targetUserIds || [])],
        mediaList: [...(row.mediaList || [])],
        attachmentList: [...(row.attachmentList || [])],
      });
    }
    formDialog.visible = true;
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openUnreadRemind(row) {
    unreadDialog.row = row;
    unreadSelection.value = [];
    unreadDialog.visible = true;
  }
  function openVersionHistory(row) {
    versionDialog.row = row;
    versionDialog.visible = true;
  }
  async function openReview(row) {
    try {
      await ElMessageBox.confirm(
        `确认审核通过并发布「${row.title}」?外部/行业类新闻需管理员审核。`,
        "审核发布",
        { type: "warning", confirmButtonText: "通过并发布", cancelButtonText: "取消" }
      );
      const hit = allRows.value.find((r) => r.id === row.id);
      if (!hit) return;
      hit.publishStatus = "published";
      hit.publishTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
      hit.updateTime = hit.publishTime;
      if (!hit.readRecords?.length) {
        hit.readRecords = [];
      }
      persist();
      return true;
    } catch {
      return false;
    }
  }
  function saveForm(submitAction = "save") {
    const v = validateNewsForm(form);
    if (!v.ok) return { ok: false, message: v.message };
    const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
    const payload = {
      ...JSON.parse(JSON.stringify(form)),
      title: v.title,
      updateTime: now,
    };
    if (formDialog.mode === "add") {
      payload.id = `news_${Date.now()}`;
      payload.newsNo = nextNewsNo();
      payload.createTime = now;
      if (submitAction === "submit_review") {
        payload.publishStatus = "pending_review";
      } else if (submitAction === "publish") {
        payload.publishStatus = "published";
        payload.publishTime = now;
      } else {
        payload.publishStatus = "draft";
      }
      allRows.value.unshift(payload);
    } else {
      const idx = allRows.value.findIndex((r) => r.id === form.id);
      if (idx < 0) return { ok: false, message: "记录不存在" };
      const prev = allRows.value[idx];
      if (prev.publishStatus === "published" && submitAction !== "draft") {
        pushVersionBeforeUpdate(prev, submitAction === "publish" ? "修订发布" : "内容更新");
      }
      if (submitAction === "submit_review") {
        payload.publishStatus = "pending_review";
      } else if (submitAction === "publish") {
        payload.publishStatus = "published";
        payload.publishTime = payload.publishTime || now;
      }
      payload.versions = prev.versions || [];
      payload.versionNo = prev.versionNo || 1;
      if (prev.publishStatus === "published" && submitAction === "publish") {
        payload.versionNo = (prev.versionNo || 1) + 1;
      }
      allRows.value[idx] = { ...prev, ...payload };
    }
    persist();
    formDialog.visible = false;
    return { ok: true };
  }
  function archiveNews(row) {
    const hit = allRows.value.find((r) => r.id === row.id);
    if (hit) {
      hit.publishStatus = "archived";
      hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
      persist();
    }
  }
  function sendUnreadRemind(selectedIds) {
    const row = unreadDialog.row;
    if (!row || !selectedIds?.length) return { ok: false, message: "请选择要提醒的员工" };
    const hit = allRows.value.find((r) => r.id === row.id);
    if (!hit) return { ok: false };
    const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
    hit.remindLogs = hit.remindLogs || [];
    hit.remindLogs.push({
      time: now,
      count: selectedIds.length,
      operator: "HR",
      userIds: [...selectedIds],
    });
    const records = hit.readRecords || [];
    selectedIds.forEach((uid) => {
      let rec = records.find((r) => r.userId === uid);
      if (!rec) {
        const emp = getUnreadEmployees(hit).find((e) => e.userId === uid);
        rec = {
          userId: uid,
          employeeNo: emp?.employeeNo || "",
          name: emp?.name || "",
          deptName: emp?.deptName || "",
          readAt: "",
          lastRemindAt: now,
        };
        records.push(rec);
      } else {
        rec.lastRemindAt = now;
      }
    });
    hit.readRecords = records;
    hit.updateTime = now;
    persist();
    unreadDialog.visible = false;
    return { ok: true, count: selectedIds.length };
  }
  function toggleLike(row, userId = "u1", userName = "张三") {
    const hit = allRows.value.find((r) => r.id === row.id);
    if (!hit) return;
    hit.likes = hit.likes || [];
    const idx = hit.likes.findIndex((l) => l.userId === userId);
    if (idx >= 0) {
      hit.likes.splice(idx, 1);
    } else {
      hit.likes.push({ userId, name: userName, time: dayjs().format("YYYY-MM-DD HH:mm:ss") });
    }
    persist();
    if (detailRow.value?.id === row.id) {
      detailRow.value = { ...hit };
    }
  }
  function addComment(row, content, userId = "u1", userName = "张三") {
    const text = (content || "").trim();
    if (!text) return { ok: false, message: "请输入评论内容" };
    const hit = allRows.value.find((r) => r.id === row.id);
    if (!hit) return { ok: false };
    hit.comments = hit.comments || [];
    hit.comments.push({
      id: `c_${Date.now()}`,
      userId,
      name: userName,
      content: text,
      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    });
    persist();
    if (detailRow.value?.id === row.id) {
      detailRow.value = { ...hit };
    }
    return { ok: true };
  }
  return {
    Search,
    NEWS_TYPE_OPTIONS,
    PUBLISH_STATUS_OPTIONS,
    LAYOUT_TEMPLATE_OPTIONS,
    READ_SCOPE_OPTIONS,
    PUBLISH_ROLE_OPTIONS,
    DEPT_OPTIONS,
    newsTypeLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    formDialog,
    form,
    formRef,
    formRules,
    detailDialog,
    detailRow,
    unreadDialog,
    unreadList,
    unreadSelection,
    versionDialog,
    getUnreadEmployees,
    readRate,
    handleQuery,
    resetSearch,
    pagination,
    openFormDialog,
    openDetail,
    openUnreadRemind,
    openVersionHistory,
    openReview,
    saveForm,
    archiveNews,
    sendUnreadRemind,
    toggleLike,
    addComment,
  };
}
src/views/officeProcessAutomation/HrManage/post-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,292 @@
<!--OA模块:岗位管理-->
<template>
  <div class="app-container">
     <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch">
        <el-form-item label="岗位编码" prop="postCode">
           <el-input
              v-model="queryParams.postCode"
              placeholder="请输入岗位编码"
              clearable
              style="width: 200px"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="岗位名称" prop="postName">
           <el-input
              v-model="queryParams.postName"
              placeholder="请输入岗位名称"
              clearable
              style="width: 200px"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="状态" prop="status">
           <el-select v-model="queryParams.status" placeholder="岗位状态" clearable style="width: 200px">
              <el-option
                 v-for="dict in sys_normal_disable"
                 :key="dict.value"
                 :label="dict.label"
                 :value="dict.value"
              />
           </el-select>
        </el-form-item>
        <el-form-item>
           <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
           <el-button icon="Refresh" @click="resetQuery">重置</el-button>
        </el-form-item>
     </el-form>
     <el-row :gutter="10" class="mb8">
        <el-col :span="1.5">
           <el-button
              type="primary"
              plain
              icon="Plus"
              @click="handleAdd"
              v-hasPermi="['system:post:add']"
           >新增</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="success"
              plain
              icon="Edit"
              :disabled="single"
              @click="handleUpdate"
              v-hasPermi="['system:post:edit']"
           >修改</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="danger"
              plain
              icon="Delete"
              :disabled="multiple"
              @click="handleDelete"
              v-hasPermi="['system:post:remove']"
           >删除</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="warning"
              plain
              icon="Download"
              @click="handleExport"
              v-hasPermi="['system:post:export']"
           >导出</el-button>
        </el-col>
        <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
     <el-table v-loading="loading" :data="postList" @selection-change="handleSelectionChange">
        <el-table-column type="selection" width="55" align="center" />
        <el-table-column label="岗位编号" align="center" prop="postId" />
        <el-table-column label="岗位编码" align="center" prop="postCode" />
        <el-table-column label="岗位名称" align="center" prop="postName" />
        <el-table-column label="岗位排序" align="center" prop="postSort" />
        <el-table-column label="状态" align="center" prop="status">
           <template #default="scope">
              <dict-tag :options="sys_normal_disable" :value="scope.row.status" />
           </template>
        </el-table-column>
        <el-table-column label="创建时间" align="center" prop="createTime" width="180">
           <template #default="scope">
              <span>{{ parseTime(scope.row.createTime) }}</span>
           </template>
        </el-table-column>
        <el-table-column label="操作" width="180" align="center" class-name="small-padding fixed-width">
           <template #default="scope">
              <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:post:edit']">修改</el-button>
              <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:post:remove']">删除</el-button>
           </template>
        </el-table-column>
     </el-table>
     <pagination
        v-show="total > 0"
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
     />
     <!-- æ·»åŠ æˆ–ä¿®æ”¹å²—ä½å¯¹è¯æ¡† -->
     <el-dialog :title="title" v-model="open" width="500px" append-to-body>
        <el-form ref="postRef" :model="form" :rules="rules" label-width="80px">
           <el-form-item label="岗位名称" prop="postName">
              <el-input v-model="form.postName" placeholder="请输入岗位名称" />
           </el-form-item>
           <el-form-item label="岗位编码" prop="postCode">
              <el-input v-model="form.postCode" placeholder="请输入编码名称" />
           </el-form-item>
           <el-form-item label="岗位顺序" prop="postSort">
              <el-input-number v-model="form.postSort" controls-position="right" :min="0" />
           </el-form-item>
           <el-form-item label="岗位状态" prop="status">
              <el-radio-group v-model="form.status">
                 <el-radio
                    v-for="dict in sys_normal_disable"
                    :key="dict.value"
                    :value="dict.value"
                 >{{ dict.label }}</el-radio>
              </el-radio-group>
           </el-form-item>
           <el-form-item label="备注" prop="remark">
              <el-input v-model="form.remark" type="textarea" placeholder="请输入内容" />
           </el-form-item>
        </el-form>
        <template #footer>
           <div class="dialog-footer">
              <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
              <el-button @click="cancel">取 æ¶ˆ</el-button>
           </div>
        </template>
     </el-dialog>
  </div>
</template>
<script setup name="Post">
import { listPost, addPost, delPost, getPost, updatePost } from "@/api/system/post"
import {onMounted} from "vue";
const { proxy } = getCurrentInstance()
const { sys_normal_disable } = proxy.useDict("sys_normal_disable")
const postList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const data = reactive({
 form: {},
 queryParams: {
   pageNum: 1,
   pageSize: 10,
   postCode: undefined,
   postName: undefined,
   status: undefined
 },
 rules: {
   postName: [{ required: true, message: "岗位名称不能为空", trigger: "blur" }],
   postCode: [{ required: true, message: "岗位编码不能为空", trigger: "blur" }],
   postSort: [{ required: true, message: "岗位顺序不能为空", trigger: "blur" }],
 }
})
const { queryParams, form, rules } = toRefs(data)
/** æŸ¥è¯¢å²—位列表 */
function getList() {
 loading.value = true
 listPost(queryParams.value).then(response => {
   postList.value = response.rows
   total.value = response.total
   loading.value = false
 })
}
/** å–消按钮 */
function cancel() {
 open.value = false
 reset()
}
/** è¡¨å•重置 */
function reset() {
 form.value = {
   postId: undefined,
   postCode: undefined,
   postName: undefined,
   postSort: 0,
   status: "0",
   remark: undefined
 }
 proxy.resetForm("postRef")
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
 queryParams.value.pageNum = 1
 getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
 proxy.resetForm("queryRef")
 handleQuery()
}
/** å¤šé€‰æ¡†é€‰ä¸­æ•°æ® */
function handleSelectionChange(selection) {
 ids.value = selection.map(item => item.postId)
 single.value = selection.length != 1
 multiple.value = !selection.length
}
/** æ–°å¢žæŒ‰é’®æ“ä½œ */
function handleAdd() {
 reset()
 open.value = true
 title.value = "添加岗位"
}
/** ä¿®æ”¹æŒ‰é’®æ“ä½œ */
function handleUpdate(row) {
 reset()
 const postId = row.postId || ids.value
 getPost(postId).then(response => {
   form.value = response.data
   open.value = true
   title.value = "修改岗位"
 })
}
/** æäº¤æŒ‰é’® */
function submitForm() {
 proxy.$refs["postRef"].validate(valid => {
   if (valid) {
     if (form.value.postId != undefined) {
       updatePost(form.value).then(response => {
         proxy.$modal.msgSuccess("修改成功")
         open.value = false
         getList()
       })
     } else {
       addPost(form.value).then(response => {
         proxy.$modal.msgSuccess("新增成功")
         open.value = false
         getList()
       })
     }
   }
 })
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
 const postIds = row.postId || ids.value
 proxy.$modal.confirm('是否确认删除岗位编号为"' + postIds + '"的数据项?').then(function() {
   return delPost(postIds)
 }).then(() => {
   getList()
   proxy.$modal.msgSuccess("删除成功")
 }).catch(() => {})
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
function handleExport() {
 proxy.download("system/post/export", {
   ...queryParams.value
 }, `post_${new Date().getTime()}.xlsx`)
}
onMounted(() => {
 getList();
});
</script>
src/views/officeProcessAutomation/HrManage/regular-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,676 @@
<!--OA模块:转正申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantName"
          style="width: 220px"
          placeholder="请输入申请人"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">申请日期:</span>
        <el-date-picker
          v-model="searchForm.applyDateRange"
          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" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增转正申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="regular-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="regular-apply-form">
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="申请人" prop="applicantName">
              <el-input v-model="form.applicantName" placeholder="请输入申请人" maxlength="50" show-word-limit />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="申请日期" prop="applyDate">
              <el-date-picker
                v-model="form.applyDate"
                type="date"
                placeholder="请选择申请日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="转正日期" prop="regularizationDate">
              <el-date-picker
                v-model="form.regularizationDate"
                type="date"
                placeholder="请选择转正日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="countersign">会签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="试用期工作总结" prop="probationSummary">
              <el-input
                v-model="form.probationSummary"
                type="textarea"
                :rows="4"
                placeholder="请填写试用期工作总结"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="附件">
              <div class="upload-block">
                <FileUpload v-model:file-list="form.attachmentList" :limit="10" button-text="点击选择文件" />
              </div>
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ…(只读) -->
    <el-dialog v-model="detailDialog.visible" title="转正申请详情" width="640px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="申请日期">{{ detailRow.applyDate }}</el-descriptions-item>
        <el-descriptions-item label="转正日期">{{ detailRow.regularizationDate }}</el-descriptions-item>
        <el-descriptions-item label="试用期工作总结">{{ detailRow.probationSummary }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
        <el-descriptions-item label="附件">
          <template v-if="detailRow.attachmentList?.length">
            <el-tag
              v-for="(f, i) in detailRow.attachmentList"
              :key="i"
              class="mr6 mb6"
              type="info"
            >
              {{ f.name }}
            </el-tag>
          </template>
          <span v-else>无</span>
        </el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- é™„件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
/** ä¸ŽåŽç«¯çº¦å®šå­—段(占位) */
const createEmptyForm = () => ({
  id: undefined,
  applicantName: "",
  applyDate: "",
  regularizationDate: "",
  probationSummary: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
  attachmentList: [],
});
const { proxy } = getCurrentInstance();
/** å®¡æ‰¹äººæ ‘:部门树 + ç³»ç»Ÿç”¨æˆ·ï¼ˆä¸Ž staff-archive / user-manage åŒæºæŽ¥å£ï¼‰ */
const approverTreeData = ref([]);
const approverLabelMap = ref({});
/** æŽ¥å£è¿”回统一拆成数组(兼容 axios æ‹¦æˆªå™¨å·²è§£åŒ…为 { data } æˆ–直接数组等情况) */
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return (
    u.deptId ??
    u.sysDeptId ??
    u.dept?.deptId ??
    u.dept?.id ??
    u.dept_id
  );
}
/** éƒ¨é—¨æ ‘节点主键(若依一般为 id,部分场景为 value) */
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
/** æŒ‰éƒ¨é—¨ id åˆ†ç»„;无部门或 id ä¸º 0 çš„用户进入未分配列表 */
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
/** éƒ¨é—¨èŠ‚ç‚¹ id åŠ å‰ç¼€ï¼Œé¿å…ä¸Ž userId æ•°å€¼å†²çªï¼›å¯é€‰èŠ‚ç‚¹ä¸ºçœŸå®ž userId å­—符串 */
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
async function loadApproverTree() {
  try {
    const [deptRes, userRes] = await Promise.all([deptTreeSelect(), userListNoPageByTenantId()]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    const users = unwrapArray(userRes);
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
/** æœ¬åœ°æ¨¡æ‹Ÿæ•°æ®æº */
const allRows = ref([
  {
    id: "1",
    applicantName: "周明",
    applyDate: "2026-05-01",
    regularizationDate: "2026-06-01",
    probationSummary: "试用期内完成模块开发与联调,熟悉业务流程。",
    approvalMode: "parallel",
    approverIds: [],
    approverNames: "",
    approvalResult: "pending",
    attachmentList: [{ name: "工作总结.pdf" }, { name: "考核表.xlsx" }],
  },
  {
    id: "2",
    applicantName: "吴芳",
    applyDate: "2026-05-08",
    regularizationDate: "2026-06-10",
    probationSummary: "完成入职培训与岗位实践,达到岗位要求。",
    approvalMode: "countersign",
    approverIds: [],
    approverNames: "",
    approvalResult: "approved",
    attachmentList: [],
  },
]);
const searchForm = reactive({
  applicantName: "",
  applyDateRange: null,
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const name = (searchForm.applicantName || "").trim();
  if (name) {
    list = list.filter((r) => r.applicantName.includes(name));
  }
  const range = searchForm.applyDateRange;
  if (range && range.length === 2) {
    const [start, end] = range;
    list = list.filter((r) => r.applyDate >= start && r.applyDate <= end);
  }
  return list.sort((a, b) => (a.applyDate < b.applyDate ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "申请日期", prop: "applyDate", width: 120 },
  { label: "转正日期", prop: "regularizationDate", width: 120 },
  { label: "试用期工作总结", prop: "probationSummary", minWidth: 200 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 200,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => openFormDialog("edit", row),
      },
      {
        name: "查看详情",
        type: "text",
        clickFun: (row) => openDetail(row),
      },
      {
        name: "附件",
        type: "text",
        clickFun: (row) => openFiles(row),
      },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const formRules = {
  applicantName: [{ required: true, message: "请输入申请人", trigger: "blur" }],
  applyDate: [{ required: true, message: "请选择申请日期", trigger: "change" }],
  regularizationDate: [{ required: true, message: "请选择转正日期", trigger: "change" }],
  probationSummary: [{ required: true, message: "请填写试用期工作总结", trigger: "blur" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [
    {
      type: "array",
      required: true,
      message: "请选择审批人",
      trigger: "change",
    },
  ],
};
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const filesDialog = reactive({ visible: false, row: null });
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
}
function resetSearch() {
  searchForm.applicantName = "";
  searchForm.applyDateRange = null;
  handleQuery();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgSuccess?.(`已模拟下载:${row.name}`);
}
function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增转正申请" : "编辑转正申请";
  loadApproverTree();
  Object.assign(form, createEmptyForm());
  if (mode === "edit" && row) {
    Object.assign(form, {
      id: row.id,
      applicantName: row.applicantName,
      applyDate: row.applyDate,
      regularizationDate: row.regularizationDate,
      probationSummary: row.probationSummary,
      approvalMode: row.approvalMode,
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
      attachmentList: JSON.parse(JSON.stringify(row.attachmentList || [])),
    });
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  const payload = {
    applicantName: form.applicantName,
    applyDate: form.applyDate,
    regularizationDate: form.regularizationDate,
    probationSummary: form.probationSummary,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
    attachmentList: JSON.parse(JSON.stringify(form.attachmentList || [])),
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({ id, ...payload, approvalResult: "pending" });
    proxy?.$modal?.msgSuccess?.("新增成功(本地模拟)");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    if (idx !== -1) {
      const prev = allRows.value[idx];
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
        approvalResult: prev.approvalResult ?? "pending",
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)");
  }
  formDialog.visible = false;
  handleQuery();
}
onMounted(() => {
  loadApproverTree();
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.upload-block {
  width: 100%;
}
.mr6 {
  margin-right: 6px;
}
.mb6 {
  margin-bottom: 6px;
}
.regular-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.regular-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.regular-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/HrManage/resign-apply/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,347 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        :title="operationType === 'add' ? '新增离职' : '编辑离职'"
        width="70%"
        @close="closeDia"
    >
      <!-- å‘˜å·¥ä¿¡æ¯å±•示区域 -->
      <div class="info-section">
        <div class="info-title">员工信息</div>
        <el-form :model="form" label-width="200px" label-position="left" :rules="rules" ref="formRef" style="margin-top: 20px">
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="姓名:" prop="staffOnJobId">
                <el-select v-model="form.staffOnJobId"
                           placeholder="请选择人员"
                           style="width: 100%"
                           :disabled="operationType === 'edit'"
                           @change="handleSelect">
                  <el-option
                      v-for="item in personList"
                      :key="item.id"
                      :label="item.staffName"
                      :value="item.id"
                  />
                </el-select>
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="员工编号:">
                {{ currentStaffRecord.staffNo || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="性别:">
                {{ currentStaffRecord.sex || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="户籍住址:">
                {{ currentStaffRecord.nativePlace || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="岗位:">
                {{ currentStaffRecord.postName || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="现住址:">
                {{ currentStaffRecord.adress || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="第一学历:">
                {{ currentStaffRecord.firstStudy || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="专业:">
                {{ currentStaffRecord.profession || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="年龄:">
                {{ currentStaffRecord.age || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="联系电话:">
                {{ currentStaffRecord.phone || '-' }}
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="紧急联系人:">
                {{ currentStaffRecord.emergencyContact || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="紧急联系人联系电话:">
                {{ currentStaffRecord.emergencyContactPhone || '-' }}
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="离职日期:" prop="leaveDate">
                <el-date-picker
                    v-model="form.leaveDate"
                    type="date"
                    :disabled="operationType === 'edit'"
                    :disabled-date="disabledFutureDate"
                    placeholder="请选择离职日期"
                    value-format="YYYY-MM-DD"
                    format="YYYY-MM-DD"
                    style="width: 100%"
                />
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="离职原因:" prop="reason">
                <el-select v-model="form.reason" placeholder="请选择离职原因" style="width: 100%" @change="handleSelectDimissionReason">
                  <el-option
                      v-for="(item, index) in dimissionReasonOptions"
                      :key="index"
                      :label="item.label"
                      :value="item.value"
                  />
                </el-select>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="30">
            <el-col :span="12">
              <el-form-item label="备注:" prop="remark" v-if="form.reason === 'other'">
                <el-input
                    v-model="form.remark"
                    type="textarea"
                    :rows="3"
                    placeholder="备注"
                    maxlength="500"
                    show-word-limit
                />
              </el-form-item>
            </el-col>
          </el-row>
        </el-form>
<!--        <el-row :gutter="30">-->
<!--          <el-col :span="12">-->
<!--            <div class="info-item">-->
<!--              <span class="info-label">离职原因:</span>-->
<!--              <el-select v-model="form.reason" placeholder="请选择人员" style="width: 100%" @change="handleSelect">-->
<!--                <el-option-->
<!--                    v-for="(item, index) in dimissionReasonOptions"-->
<!--                    :key="index"-->
<!--                    :label="item.label"-->
<!--                    :value="item.value"-->
<!--                />-->
<!--              </el-select>-->
<!--            </div>-->
<!--          </el-col>-->
<!--          <el-col :span="12">-->
<!--            <div class="info-item">-->
<!--              <span class="info-label">员工编号:</span>-->
<!--              <span class="info-value">{{ form.staffNo || '-' }}</span>-->
<!--            </div>-->
<!--          </el-col>-->
<!--        </el-row>-->
      </div>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">确认</el-button>
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import {ref, reactive, toRefs, getCurrentInstance} from "vue";
import {staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js";
import {createStaffLeave, updateStaffLeave} from "@/api/personnelManagement/staffLeave.js";
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const dialogFormVisible = ref(false);
const operationType = ref('')
const getTodayDate = () => {
  const now = new Date();
  const year = now.getFullYear();
  const month = `${now.getMonth() + 1}`.padStart(2, '0');
  const day = `${now.getDate()}`.padStart(2, '0');
  return `${year}-${month}-${day}`;
};
const disabledFutureDate = (time) => {
  const todayEnd = new Date();
  todayEnd.setHours(23, 59, 59, 999);
  return time.getTime() > todayEnd.getTime();
};
const data = reactive({
  form: {
    staffOnJobId: undefined,
    leaveDate: "",
    reason: "",
    remark: "",
  },
  rules: {
    staffName: [{ required: true, message: "请选择人员" }],
    leaveDate: [{ required: true, message: "请选择离职日期", trigger: "change" }],
    reason: [{ required: true, message: "请选择离职原因"}],
  },
  dimissionReasonOptions: [
      {label: '薪资待遇', value: 'salary'},
      {label: '职业发展', value: 'career_development'},
      {label: '工作环境', value: 'work_environment'},
      {label: '个人原因', value: 'personal_reason'},
      {label: '其他', value: 'other'},
  ],
  currentStaffRecord: {},
});
const { form, rules, dimissionReasonOptions, currentStaffRecord } = toRefs(data);
// æ‰“开弹框
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  if (operationType.value === 'edit') {
    currentStaffRecord.value = row
    form.value.staffOnJobId = row.staffOnJobId
    form.value.leaveDate = row.leaveDate
    form.value.reason = row.reason
    form.value.remark = row.remark
    personList.value = [
      {
        staffName: row.staffName,
        id: row.staffOnJobId,
      }
    ]
  } else {
    form.value.leaveDate = getTodayDate()
    getList()
  }
}
const handleSelectDimissionReason = (val) => {
  if (val === 'other') {
    form.value.remark = ''
  }
}
// æäº¤äº§å“è¡¨å•
const submitForm = () => {
  form.value.staffState = 0
  if (form.value.reason !== 'other') {
    form.value.remark = ''
  }
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      if (operationType.value === "add") {
        createStaffLeave(form.value).then(res => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        })
      } else {
        updateStaffLeave(currentStaffRecord.value.id, form.value).then(res => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        })
      }
    }
  })
}
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  // è¡¨å•已注释,手动重置表单数据
  form.value = {
    staffOnJobId: undefined,
    leaveDate: "",
    reason: "",
    remark: "",
  };
  dialogFormVisible.value = false;
  emit('close')
};
const personList = ref([]);
/**
 * èŽ·å–å½“å‰åœ¨èŒäººå‘˜åˆ—è¡¨
 */
const getList = () => {
  staffOnJobListPage({
    current: -1,
    size: -1,
        staffState: 1
  }).then(res => {
    personList.value = res.data.records || []
  })
};
const handleSelect = (val) => {
  let obj = personList.value.find(item => item.id === val)
  currentStaffRecord.value = {}
  if (obj) {
    // ä¿ç•™ç¦»èŒæ—¥æœŸå’Œç¦»èŒåŽŸå› ï¼Œåªæ›´æ–°å‘˜å·¥ä¿¡æ¯
    currentStaffRecord.value = obj
  }
}
defineExpose({
  openDialog,
});
</script>
<style scoped>
.info-section {
  background: #f5f7fa;
  padding: 20px;
  border-radius: 8px;
  margin-bottom: 20px;
}
.info-title {
  font-size: 16px;
  font-weight: 600;
  color: #303133;
  margin-bottom: 20px;
  padding-bottom: 10px;
  border-bottom: 1px solid #e4e7ed;
}
.info-item {
  display: flex;
  align-items: center;
  margin-bottom: 16px;
  min-height: 32px;
}
.info-label {
  min-width: 140px;
  color: #606266;
  font-size: 14px;
  font-weight: 500;
}
.info-value {
  flex: 1;
  color: #303133;
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/HrManage/resign-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,245 @@
<!--OA模块:离职申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">姓名:</span>
        <el-input
            v-model="searchForm.staffName"
            style="width: 240px"
            placeholder="请输入姓名搜索"
            @change="handleQuery"
            clearable
            :prefix-icon="Search"
        />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px"
        >搜索</el-button
        >
      </div>
      <div>
        <el-button type="primary" @click="openForm('add')">新增离职</el-button>
        <el-button @click="handleOut">导出</el-button>
        <!-- <el-button type="danger" plain @click="handleDelete">删除</el-button> -->
      </div>
    </div>
    <div class="table_list">
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :page="page"
          :isSelection="true"
          @selection-change="handleSelectionChange"
          :tableLoading="tableLoading"
          @pagination="pagination"
          :total="page.total"
      ></PIMTable>
    </div>
    <form-dia ref="formDia" @close="handleQuery"></form-dia>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import {onMounted, ref} from "vue";
import FormDia from "@/views/personnelManagement/dimission/components/formDia.vue";
import {findStaffLeaveListPage, batchDeleteStaffLeaves} from "@/api/personnelManagement/staffLeave.js";
import {ElMessageBox} from "element-plus";
const data = reactive({
  searchForm: {
    staffName: "",
  },
});
const { searchForm } = toRefs(data);
const tableColumn = ref([
  {
    label: "状态",
    prop: "staffState",
    dataType: "tag",
    formatData: (params) => {
      if (params == 0) {
        return "离职";
      } else if (params == 1) {
        return "在职";
      } else {
        return null;
      }
    },
    formatType: (params) => {
      if (params == 0) {
        return "danger";
      } else if (params == 1) {
        return "primary";
      } else {
        return null;
      }
    },
  },
  {
    label: "离职日期",
    prop: "leaveDate",
  },
  {
    label: "员工编号",
    prop: "staffNo",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
  {
    label: "性别",
    prop: "sex",
  },
  {
    label: "户籍住址",
    prop: "nativePlace",
  },
  {
    label: "部门",
    prop: "deptName",
  },
  {
    label: "岗位",
    prop: "postName",
  },
  {
    label: "现住址",
    prop: "adress",
    width:200
  },
  {
    label: "第一学历",
    prop: "firstStudy",
  },
  {
    label: "专业",
    prop: "profession",
    width:100
  },
  {
    label: "年龄",
    prop: "age",
  },
  {
    label: "联系电话",
    prop: "phone",
    width:150
  },
  {
    label: "紧急联系人",
    prop: "emergencyContact",
    width: 120
  },
  {
    label: "紧急联系人电话",
    prop: "emergencyContactPhone",
    width:150
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        },
      },
    ],
  },
]);
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
  total: 0,
});
const formDia = ref()
const { proxy } = getCurrentInstance()
// æŸ¥è¯¢åˆ—表
/** æœç´¢æŒ‰é’®æ“ä½œ */
const handleQuery = () => {
  page.current = 1;
  getList();
};
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  findStaffLeaveListPage({...page, ...searchForm.value}).then(res => {
    tableLoading.value = false;
    tableData.value = res.data.records
    page.total = res.data.total;
  }).catch(err => {
    tableLoading.value = false;
  })
};
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// æ‰“开弹框
const openForm = (type, row) => {
  nextTick(() => {
    formDia.value?.openDialog(type, row)
  })
};
// åˆ é™¤
const handleDelete = () => {
  let ids = [];
  if (selectedRows.value.length > 0) {
    ids = selectedRows.value.map((item) => item.id);
  } else {
    proxy.$modal.msgWarning("请选择数据");
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
      .then(() => {
        batchDeleteStaffLeaves(ids).then((res) => {
          proxy.$modal.msgSuccess("删除成功");
          getList();
        });
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
};
// å¯¼å‡º
const handleOut = () => {
  ElMessageBox.confirm("选中的内容将被导出,是否确认导出?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
      .then(() => {
        proxy.download("/staff/staffLeave/export", {}, "人员离职.xlsx");
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
};
onMounted(() => {
  getList();
});
</script>
<style scoped></style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/BasicInfoSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,181 @@
<template>
  <el-card class="form-card" shadow="never">
    <template #header>
      <span class="card-title">
        <span class="card-title-line">|</span>
        åŸºæœ¬ä¿¡æ¯
      </span>
    </template>
    <el-row :gutter="24">
      <el-col :span="5">
        <el-form-item label="员工编号" prop="staffNo">
          <el-input
            v-model="form.staffNo"
            placeholder="请输入"
            clearable
            maxlength="20"
            show-word-limit
            :disabled="operationType !== 'add'"
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="姓名" prop="staffName">
          <el-input
            v-model="form.staffName"
            placeholder="请输入"
            clearable
            maxlength="50"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="别名" prop="alias">
          <el-input
            v-model="form.alias"
            placeholder="请输入"
            clearable
            maxlength="50"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="手机" prop="phone">
          <el-input
            v-model="form.phone"
            placeholder="请输入"
            clearable
            maxlength="11"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="4">
        <el-form-item label="性别" prop="sex">
          <el-select
            v-model="form.sex"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option label="男" value="男" />
            <el-option label="女" value="女" />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>
    <el-row :gutter="24">
      <el-col :span="5">
        <el-form-item label="出生日期" prop="birthDate">
          <el-date-picker
            v-model="form.birthDate"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="年龄" prop="age">
          <el-input-number
            v-model="form.age"
            :min="0"
            :max="150"
            :precision="0"
            :step="1"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="籍贯" prop="nativePlace">
          <el-input
            v-model="form.nativePlace"
            placeholder="请输入"
            clearable
            maxlength="50"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="5">
        <el-form-item label="民族" prop="nation">
          <el-input
            v-model="form.nation"
            placeholder="请输入"
            clearable
            maxlength="20"
            show-word-limit
          />
        </el-form-item>
      </el-col>
      <el-col :span="4">
        <el-form-item label="婚姻状况" prop="maritalStatus">
          <el-select
            v-model="form.maritalStatus"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option label="未婚" value="未婚" />
            <el-option label="已婚" value="已婚" />
            <el-option label="离异" value="离异" />
            <el-option label="丧偶" value="丧偶" />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>
    <el-row :gutter="24">
      <el-col :span="10">
        <el-form-item label="角色" prop="roleId">
          <el-select
            v-model="form.roleId"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option
              v-for="item in roleOptions"
              :key="item.roleId"
              :label="item.roleName"
              :value="item.roleId"
              :disabled="item.status == 1"
            />
          </el-select>
        </el-form-item>
      </el-col>
    </el-row>
  </el-card>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true },
  operationType: { type: String, default: "add" },
  roleOptions: { type: Array, default: () => [] },
});
const { form, operationType, roleOptions } = toRefs(props);
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/EducationWorkSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,263 @@
<template>
  <div>
    <!-- æ•™è‚²ç»åކ -->
    <el-card class="form-card" shadow="never">
      <template #header>
        <span class="card-title">
          <span class="card-title-line">|</span>
          æ•™è‚²ç»åކ
        </span>
      </template>
      <el-table :data="form.staffEducationList" border>
        <el-table-column label="学历" prop="education" width="120">
          <template #default="{ row }">
            <el-select
              v-model="row.education"
              placeholder="请选择"
              clearable
              style="width: 100%"
            >
              <el-option label="中专及以下" value="secondary" />
              <el-option label="大专" value="junior_college" />
              <el-option label="本科" value="bachelor" />
              <el-option label="硕士" value="master" />
              <el-option label="博士及以上" value="doctor" />
            </el-select>
          </template>
        </el-table-column>
        <el-table-column label="毕业院校" prop="schoolName" min-width="160">
          <template #default="{ row }">
            <el-input
              v-model="row.schoolName"
              placeholder="请输入"
              clearable
              maxlength="30"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="入学时间" prop="enrollTime" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.enrollTime"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="毕业时间" prop="graduateTime" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.graduateTime"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="专业" prop="major" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.major"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="学位" prop="degree" width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.degree"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center">
          <template #default="scope">
            <el-button
              v-if="form.staffEducationList.length > 1"
              type="primary"
              link
              @click="removeEducationRow(scope.$index)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="table-add-row" @click="addEducationRow">新建一行</div>
    </el-card>
    <!-- å·¥ä½œç»åކ -->
    <el-card class="form-card" shadow="never">
      <template #header>
        <span class="card-title">
          <span class="card-title-line">|</span>
          å·¥ä½œç»åކ
        </span>
      </template>
      <el-table :data="form.staffWorkExperienceList" border>
        <el-table-column label="前公司" prop="formerCompany" min-width="180">
          <template #default="{ row }">
            <el-input
              v-model="row.formerCompany"
              placeholder="请输入"
              clearable
              maxlength="30"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="前公司部门" prop="formerDept" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.formerDept"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="前公司职位" prop="formerPosition" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.formerPosition"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="开始日期" prop="startDate" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.startDate"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="结束日期" prop="endDate" width="150">
          <template #default="{ row }">
            <el-date-picker
              v-model="row.endDate"
              type="date"
              value-format="YYYY-MM-DD"
              format="YYYY-MM-DD"
              placeholder="请选择"
              style="width: 100%"
              clearable
            />
          </template>
        </el-table-column>
        <el-table-column label="工作描述" prop="workDesc" min-width="220">
          <template #default="{ row }">
            <el-input
              v-model="row.workDesc"
              type="textarea"
              :rows="2"
              placeholder="请输入"
              clearable
              maxlength="500"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center">
          <template #default="scope">
            <el-button
              v-if="form.staffWorkExperienceList.length > 1"
              type="primary"
              link
              @click="removeWorkRow(scope.$index)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="table-add-row" @click="addWorkRow">新建一行</div>
    </el-card>
  </div>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true },
});
const emit = defineEmits(["update:form"]);
const { form } = toRefs(props);
const addEducationRow = () => {
  form.value.staffEducationList.push({
    education: "",
    schoolName: "",
    enrollTime: "",
    graduateTime: "",
    major: "",
    degree: "",
  });
};
const removeEducationRow = (index) => {
  if (form.value.staffEducationList.length <= 1) return;
  form.value.staffEducationList.splice(index, 1);
};
const addWorkRow = () => {
  form.value.staffWorkExperienceList.push({
    formerCompany: "",
    formerDept: "",
    formerPosition: "",
    startDate: "",
    endDate: "",
    workDesc: "",
  });
};
const removeWorkRow = (index) => {
  if (form.value.staffWorkExperienceList.length <= 1) return;
  form.value.staffWorkExperienceList.splice(index, 1);
};
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.table-add-row {
  margin-top: 8px;
  color: #409eff;
  cursor: pointer;
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/EmergencyAndAttachmentSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,115 @@
<template>
  <div>
    <!-- ç´§æ€¥è”系人 -->
    <el-card class="form-card" shadow="never">
      <template #header>
        <span class="card-title">
          <span class="card-title-line">|</span>
          ç´§æ€¥è”系人
        </span>
      </template>
      <el-table :data="form.staffEmergencyContactList" border>
        <el-table-column label="紧急联系人姓名" prop="contactName" min-width="160">
          <template #default="{ row }">
            <el-input
              v-model="row.contactName"
              placeholder="请输入"
              clearable
              maxlength="50"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="紧急联系人关系" prop="contactRelation" min-width="140">
          <template #default="{ row }">
            <el-input
              v-model="row.contactRelation"
              placeholder="请输入"
              clearable
              maxlength="20"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="紧急联系人手机" prop="contactPhone" width="160">
          <template #default="{ row }">
            <el-input
              v-model="row.contactPhone"
              placeholder="请输入"
              clearable
              maxlength="11"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="紧急联系人住址" prop="contactAddress" min-width="220">
          <template #default="{ row }">
            <el-input
              v-model="row.contactAddress"
              placeholder="请输入"
              clearable
              maxlength="50"
              show-word-limit
            />
          </template>
        </el-table-column>
        <el-table-column label="操作" width="80" align="center">
          <template #default="scope">
            <el-button
              v-if="form.staffEmergencyContactList.length > 1"
              type="primary"
              link
              @click="removeEmergencyRow(scope.$index)"
            >
              åˆ é™¤
            </el-button>
          </template>
        </el-table-column>
      </el-table>
      <div class="table-add-row" @click="addEmergencyRow">新建一行</div>
    </el-card>
  </div>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true }
});
const { form } = toRefs(props);
const addEmergencyRow = () => {
  form.value.staffEmergencyContactList.push({
    contactName: "",
    contactRelation: "",
    contactPhone: "",
    contactAddress: "",
  });
};
const removeEmergencyRow = (index) => {
  if (form.value.staffEmergencyContactList.length <= 1) return;
  form.value.staffEmergencyContactList.splice(index, 1);
};
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.table-add-row {
  margin-top: 8px;
  color: #409eff;
  cursor: pointer;
  font-size: 14px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/JobInfoSection.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,176 @@
<template>
  <el-card class="form-card" shadow="never">
    <template #header>
      <span class="card-title">
        <span class="card-title-line">|</span>
        åœ¨èŒä¿¡æ¯
      </span>
    </template>
    <!-- ç¬¬ä¸€è¡Œï¼šåˆåŒå¼€å§‹ / åˆåŒç»“束 / è¯•用期 / è½¬æ­£ -->
    <el-row :gutter="24">
      <el-col :span="6">
        <el-form-item label="入职日期" prop="contractStartTime">
          <el-date-picker
            v-model="form.contractStartTime"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
            @change="calculateContractTerm"
          />
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item
          label="合同结束日期"
          prop="contractEndTime"
          required
          :rules="[
            {
              required: true,
              message: '请选择合同结束日期',
              trigger: 'change',
            },
          ]"
        >
          <el-date-picker
            v-model="form.contractEndTime"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
            @change="calculateContractTerm"
          />
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item label="试用期(月)" prop="probationPeriod">
          <el-input-number
            v-model="form.proTerm"
            :min="0"
            :max="24"
            :precision="0"
            :step="1"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="6">
        <el-form-item label="转正日期" prop="positiveDate">
          <el-date-picker
            v-model="form.positiveDate"
            type="date"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
            style="width: 100%"
            clearable
          />
        </el-form-item>
      </el-col>
    </el-row>
    <!-- ç¬¬äºŒè¡Œï¼šéƒ¨é—¨ / å²—位 / åŸºæœ¬å·¥èµ„ -->
    <el-row :gutter="24">
      <el-col :span="8">
        <el-form-item label="部门" prop="sysDeptId">
          <el-tree-select
            v-model="form.sysDeptId"
            :data="deptOptions"
            check-strictly
            :render-after-expand="false"
            placeholder="请选择"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
      <el-col :span="8">
        <el-form-item label="岗位" prop="sysPostId">
          <el-select
            v-model="form.sysPostId"
            placeholder="请选择"
            clearable
            style="width: 100%"
          >
            <el-option
              v-for="item in postOptions"
              :key="item.postId"
              :label="item.postName"
              :value="item.postId"
              :disabled="item.status === '1'"
            />
          </el-select>
        </el-form-item>
      </el-col>
      <el-col :span="8">
        <el-form-item label="基本工资" prop="basicSalary">
          <el-input-number
            v-model="form.basicSalary"
            :min="0"
            :max="999999"
            :precision="2"
            :step="100"
            style="width: 100%"
          />
        </el-form-item>
      </el-col>
    </el-row>
  </el-card>
</template>
<script setup>
import { toRefs } from "vue";
const props = defineProps({
  form: { type: Object, required: true },
  postOptions: { type: Array, default: () => [] },
  deptOptions: { type: Array, default: () => [] },
});
const { form, postOptions, deptOptions } = toRefs(props);
// è®¡ç®—合同年限
const calculateContractTerm = () => {
  if (form.value.contractStartTime && form.value.contractEndTime) {
    const startDate = new Date(form.value.contractStartTime);
    const endDate = new Date(form.value.contractEndTime);
    if (endDate > startDate) {
      // è®¡ç®—年份差
      const yearDiff = endDate.getFullYear() - startDate.getFullYear();
      const monthDiff = endDate.getMonth() - startDate.getMonth();
      const dayDiff = endDate.getDate() - startDate.getDate();
      let years = yearDiff;
      // å¦‚果结束日期的月日小于开始日期的月日,则减去1å¹´
      if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
        years = yearDiff - 1;
      }
      form.value.contractTerm = Math.max(0, years);
    } else {
      form.value.contractTerm = 0;
    }
  } else {
    form.value.contractTerm = 0;
  }
};
</script>
<style scoped>
.form-card {
  margin-bottom: 16px;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/NewOrEditFormDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,304 @@
<template>
  <FormDialog
    v-model="dialogFormVisible"
    :operation-type="operationType"
    :title="dialogTitle"
    width="90%"
    @close="closeDia"
    @confirm="submitForm"
    @cancel="closeDia"
  >
    <div class="form-dia-body">
      <el-form
        ref="formRef"
        :model="form"
        :rules="rules"
        label-position="top"
      >
        <BasicInfoSection
          :form="form"
          :operation-type="operationType"
          :role-options="roleOptions"
        />
        <JobInfoSection
          :form="form"
          :post-options="postOptions"
          :dept-options="deptOptions"
        />
        <EducationWorkSection :form="form" />
        <EmergencyAndAttachmentSection :form="form" />
      </el-form>
    </div>
  </FormDialog>
</template>
<script setup>
import {
  ref,
  reactive,
  toRefs,
  onMounted,
  getCurrentInstance,
  nextTick,
} from "vue";
import FormDialog from "@/components/Dialog/FormDialog.vue";
import { findPostOptions } from "@/api/system/post.js";
import { deptTreeSelect, getUser } from "@/api/system/user.js";
import {
  staffOnJobInfo,
  createStaffOnJob,
  updateStaffOnJob,
} from "@/api/personnelManagement/staffOnJob.js";
import BasicInfoSection from "./BasicInfoSection.vue";
import JobInfoSection from "./JobInfoSection.vue";
import EducationWorkSection from "./EducationWorkSection.vue";
import EmergencyAndAttachmentSection from "./EmergencyAndAttachmentSection.vue";
const { proxy } = getCurrentInstance();
const emit = defineEmits(["close"]);
const dialogFormVisible = ref(false);
const operationType = ref("add");
const id = ref(0);
const formRef = ref(null);
const dialogTitle = () =>
  operationType.value === "add" ? "新增入职" : "编辑人员";
const createEmptyEducation = () => ({
  education: "",
  schoolName: "",
  enrollTime: "",
  graduateTime: "",
  major: "",
  degree: "",
});
const createEmptyWork = () => ({
  formerCompany: "",
  formerDept: "",
  formerPosition: "",
  startDate: "",
  endDate: "",
  workDesc: "",
});
const createEmptyEmergency = () => ({
  contactName: "",
  contactRelation: "",
  contactPhone: "",
  contactAddress: "",
});
const createDefaultForm = () => ({
  id: undefined,
  // åŸºæœ¬ä¿¡æ¯
  staffNo: "",
  staffName: "",
  alias: "",
  phone: "",
  sex: "",
  birthDate: "",
  age: undefined,
  nativePlace: "",
  nation: "",
  maritalStatus: "",
  politicalStatus: "",
  firstWorkDate: "",
  workingYears: undefined,
  idCardNo: "",
  hukouType: "",
  email: "",
  currentAddress: "",
  // åœ¨èŒä¿¡æ¯
  contractStartTime: "",
  contractEndTime: "",
  proTerm: undefined,
  positiveDate: "",
  sysDeptId: undefined,
  sysPostId: undefined,
  basicSalary: undefined,
  // é“¶è¡Œå¡ä¿¡æ¯
  bankName: "",
  bankCardNo: "",
  // æ•™è‚²ç»åކ
  staffEducationList: [createEmptyEducation()],
  // å·¥ä½œç»åކ
  staffWorkExperienceList: [createEmptyWork()],
  // ç´§æ€¥è”系人
  staffEmergencyContactList: [createEmptyEmergency()],
  // è§’色(单选)
  roleId: undefined,
});
const state = reactive({
  form: createDefaultForm(),
  rules: {
    staffNo: [{ required: true, message: "请输入员工编号", trigger: "blur" }],
    staffName: [{ required: true, message: "请输入姓名", trigger: "blur" }],
    phone: [{ required: true, message: "请输入手机", trigger: "blur" }],
    sex: [{ required: true, message: "请选择性别", trigger: "change" }],
    birthDate: [
      { required: true, message: "请选择出生日期", trigger: "change" },
    ],
    contractStartTime: [
      { required: true, message: "请选择入职日期", trigger: "change" },
    ],
    contractEndTime: [
      { required: true, message: "请选择合同结束日期", trigger: "change" },
    ],
    sysDeptId: [
      { required: true, message: "请选择部门", trigger: "change" },
    ],
    roleId: [{ required: true, message: "请选择角色", trigger: "change" }],
  },
  postOptions: [],
  deptOptions: [],
});
const { form, rules, postOptions, deptOptions } = toRefs(state);
const roleOptions = ref([]);
const resetForm = () => {
  Object.assign(form.value, createDefaultForm());
  nextTick(() => {
    formRef.value?.clearValidate();
  });
};
const fetchPostOptions = () => {
  findPostOptions().then((res) => {
    postOptions.value = res.data || [];
  });
};
const fetchDeptOptions = () => {
  deptTreeSelect().then((response) => {
    deptOptions.value = filterDisabledDept(
      JSON.parse(JSON.stringify(response.data || []))
    );
  });
};
const fetchRoleOptions = () => {
  getUser().then((res) => {
    roleOptions.value = res.roles || [];
  });
};
function filterDisabledDept(deptList) {
  return deptList.filter((dept) => {
    if (dept.disabled) {
      return false;
    }
    if (dept.children && dept.children.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  fetchPostOptions();
  fetchDeptOptions();
  fetchRoleOptions();
  resetForm();
  if (type === "edit" && row?.id) {
    id.value = row.id;
    staffOnJobInfo(id.value, {}).then((res) => {
      const d = res.data || {};
      Object.assign(form.value, {
        ...form.value,
        ...d,
      });
      if (
        !Array.isArray(form.value.staffEducationList) ||
        !form.value.staffEducationList.length
      ) {
        form.value.staffEducationList = [createEmptyEducation()];
      }
      if (
        !Array.isArray(form.value.staffWorkExperienceList) ||
        !form.value.staffWorkExperienceList.length
      ) {
        form.value.staffWorkExperienceList = [createEmptyWork()];
      }
      if (
        !Array.isArray(form.value.staffEmergencyContactList) ||
        !form.value.staffEmergencyContactList.length
      ) {
        form.value.staffEmergencyContactList = [createEmptyEmergency()];
      }
      if (form.value.sysPostId === 0) {
        form.value.sysPostId = undefined;
      }
      if (form.value.sysDeptId === 0) {
        form.value.sysDeptId = undefined;
      }
    });
  }
};
onMounted(() => {
  fetchPostOptions();
  fetchDeptOptions();
});
const submitForm = () => {
  if (!form.value.sysPostId) {
    form.value.sysPostId = undefined;
  }
  if (!form.value.sysDeptId) {
    form.value.sysDeptId = undefined;
  }
  // å…¼å®¹åŽç«¯å¯èƒ½ä»ä½¿ç”¨ roleIds æ•°ç»„
  form.value.roleIds = form.value.roleId ? [form.value.roleId] : [];
  formRef.value?.validate((valid) => {
    if (valid) {
      if (operationType.value === "add") {
        createStaffOnJob(form.value).then(() => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        });
      } else {
        updateStaffOnJob(id.value, form.value).then(() => {
          proxy.$modal.msgSuccess("提交成功");
          closeDia();
        });
      }
    }
  });
};
const closeDia = () => {
  formRef.value?.resetFields();
  dialogFormVisible.value = false;
  emit("close");
};
defineExpose({
  openDialog,
});
</script>
<style scoped>
.form-dia-body {
  padding: 0;
}
.card-title-line {
  color: #f56c6c;
  margin-right: 4px;
}
.form-card {
  margin-bottom: 16px;
}
.dialog-footer {
  text-align: right;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/components/RenewContract.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,141 @@
<template>
  <el-dialog
      v-model="isShow"
      title="续签合同"
      width="800px"
      @close="closeModal"
  >
    <el-form :model="form" label-width="140px" label-position="top" :rules="rules" ref="formRef">
      <el-form-item label="合同开始日期:" prop="contractStartTime">
        <el-date-picker
            v-model="form.contractStartTime"
            type="date"
            placeholder="请选择日期"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            clearable
            style="width: 100%"
            @change="calculateContractTerm"
        />
      </el-form-item>
      <el-form-item label="合同结束日期:" prop="contractEndTime">
        <el-date-picker
            v-model="form.contractEndTime"
            type="date"
            placeholder="请选择日期"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            clearable
            style="width: 100%"
            @change="calculateContractTerm"
        />
      </el-form-item>
      <el-form-item label="合同年限:" prop="contractTerm">
        <el-input-number v-model="form.contractTerm" :precision="0" :step="1" style="width: 100%" :disabled="true"/>
      </el-form-item>
    </el-form>
    <template #footer>
      <div class="dialog-footer">
        <el-button type="primary" @click="submitForm">确认</el-button>
        <el-button @click="closeModal">取消</el-button>
      </div>
    </template>
  </el-dialog>
</template>
<script setup>
// ç»­ç­¾åˆåŒ
import { renewContract } from "@/api/personnelManagement/staffOnJob.js";
import {computed, getCurrentInstance,} from "vue";
const emit = defineEmits(['update:visible', 'completed']);
const data = reactive({
  form: {
    contractTerm: 0,
    contractStartTime: "",
    contractEndTime: "",
  },
  rules: {
    contractTerm: [{ required: true, message: "请输入", trigger: "blur" }],
    contractStartTime: [{ required: true, message: "请输入", trigger: "blur" }],
    contractEndTime: [{ required: true, message: "请输入", trigger: "blur" }],
  }
});
const { form, rules } = toRefs(data);
let { proxy } = getCurrentInstance()
const props = defineProps({
  id: {
    type: Number,
    default: 0,
  },
  visible: {
    type: Boolean,
    required: true,
  },
})
const isShow = computed({
  get() {
    return props.visible;
  },
  set(val) {
    emit('update:visible', val);
  },
});
// è®¡ç®—合同年限
const calculateContractTerm = () => {
  if (form.value.contractStartTime && form.value.contractEndTime) {
    const startDate = new Date(form.value.contractStartTime);
    const endDate = new Date(form.value.contractEndTime);
    if (endDate > startDate) {
      // è®¡ç®—年份差
      const yearDiff = endDate.getFullYear() - startDate.getFullYear();
      const monthDiff = endDate.getMonth() - startDate.getMonth();
      const dayDiff = endDate.getDate() - startDate.getDate();
      let years = yearDiff;
      // å¦‚果结束日期的月日小于开始日期的月日,则减去1å¹´
      if (monthDiff < 0 || (monthDiff === 0 && dayDiff < 0)) {
        years = yearDiff - 1;
      }
      form.value.contractTerm = Math.max(0, years);
    } else {
      form.value.contractTerm = 0;
    }
  } else {
    form.value.contractTerm = 0;
  }
};
const submitForm = () => {
  proxy.$refs["formRef"].validate(valid => {
    if (valid) {
      renewContract(props.id, form.value).then(res => {
        if (res.code === 200) {
          proxy.$modal.msgSuccess("续签合同成功");
          emit('completed');
          closeModal();
        }
      })
    }
  })
}
// å…³é—­å¼¹æ¡†
const closeModal = () => {
  // é‡ç½®è¡¨å•数据
  form.value = {
    contractTerm: 0,
    contractStartTime: "",
    contractEndTime: "",
  };
  isShow.value = false;
};
</script>
src/views/officeProcessAutomation/HrManage/staff-archive/components/Show.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,73 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        title="详情"
        width="70%"
        @close="closeDia"
    >
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :tableLoading="tableLoading"
          height="600"
      ></PIMTable>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import {ref} from "vue";
import {staffOnJobInfo} from "@/api/personnelManagement/staffOnJob.js";
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const dialogFormVisible = ref(false);
const operationType = ref('')
const tableColumn = ref([
  // {
  //   label: "合同年限",
  //   prop: "contractTerm",
  // },
  {
    label: "合同开始日期",
    prop: "contractStartTime",
  },
  {
    label: "合同结束日期",
    prop: "contractEndTime",
  },
]);
const tableData = ref([]);
const tableLoading = ref(false);
// æ‰“开弹框
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  if (operationType.value === 'edit') {
    staffOnJobInfo({staffNo: row.staffNo}).then(res => {
      tableData.value = res.data
    })
  }
}
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  dialogFormVisible.value = false;
  emit('close')
};
defineExpose({
  openDialog,
});
</script>
<style scoped>
</style>
src/views/officeProcessAutomation/HrManage/staff-archive/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,407 @@
<!--OA模块:员工档案-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">姓名:</span>
        <el-input
            v-model="searchForm.staffName"
            style="width: 240px"
            placeholder="请输入姓名搜索"
            @change="handleQuery"
            clearable
            :prefix-icon="Search"
        />
        <span class="search_title search_title2">部门:</span>
          <el-tree-select
            v-model="searchForm.sysDeptId"
            :data="deptOptions"
            check-strictly
            :render-after-expand="false"
            style="width: 240px"
            placeholder="请选择"
          />
          <span class="search_title search_title2">入职日期:</span>
          <el-date-picker
            v-model="searchForm.contractStartTime"
            value-format="YYYY-MM-DD"
            format="YYYY-MM-DD"
            placeholder="请选择"
          />
        <!-- <span  style="margin-left: 10px" class="search_title">合同结束日期:</span> -->
        <!-- <el-date-picker  v-model="searchForm.entryDate" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange"
                         placeholder="请选择" clearable @change="changeDaterange" /> -->
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px"
        >搜索</el-button
        >
      </div>
      <div>
        <el-button type="primary" @click="openFormNewOrEditFormDia('add')">新增入职</el-button>
        <el-button type="info" @click="handleImport">导入</el-button>
        <el-button @click="handleOut">导出</el-button>
        <!-- <el-button type="danger" plain @click="handleDelete">删除</el-button> -->
      </div>
    </div>
    <div class="table_list">
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :page="page"
          :isSelection="true"
          @selection-change="handleSelectionChange"
          :tableLoading="tableLoading"
          @pagination="pagination"
          :total="page.total"
      ></PIMTable>
    </div>
    <show-form-dia ref="formDia" @close="handleQuery"></show-form-dia>
    <new-or-edit-form-dia ref="formDiaNewOrEditFormDia" @close="handleQuery"></new-or-edit-form-dia>
    <renew-contract
        v-if="isShowRenewContractModal"
        v-model:visible="isShowRenewContractModal"
        :id="id"
        @completed="handleQuery"
    />
    <!-- å¯¼å…¥å¯¹è¯æ¡† -->
    <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body>
      <el-upload
        ref="uploadRef"
        :limit="1"
        accept=".xlsx, .xls"
        :headers="upload.headers"
        :action="upload.url"
        :disabled="upload.isUploading"
        :on-progress="handleFileUploadProgress"
        :on-success="handleFileSuccess"
        :auto-upload="false"
        drag
      >
        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
        <template #tip>
          <div class="el-upload__tip text-center">
            <span>仅允许导入xls、xlsx格式文件。</span>
            <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline; margin-left: 5px;" @click="importTemplate">下载模板</el-link>
          </div>
        </template>
      </el-upload>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitFileForm">ç¡® å®š</el-button>
          <el-button @click="upload.open = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { Search, UploadFilled } from "@element-plus/icons-vue";
import {onMounted, ref} from "vue";
import {ElMessageBox} from "element-plus";
import { deptTreeSelect } from "@/api/system/user.js";
import {batchDeleteStaffOnJobs, staffOnJobListPage} from "@/api/personnelManagement/staffOnJob.js";
import { getToken } from "@/utils/auth";
import dayjs from "dayjs";
const NewOrEditFormDia = defineAsyncComponent(() => import("@/views/personnelManagement/employeeRecord/components/NewOrEditFormDia.vue"));
const ShowFormDia = defineAsyncComponent(() => import( "@/views/personnelManagement/employeeRecord/components/Show.vue"));
const RenewContract = defineAsyncComponent(() => import( "@/views/personnelManagement/employeeRecord/components/RenewContract.vue"));
const data = reactive({
  searchForm: {
    staffName: "",
    entryDate: undefined, // å½•入日期
    entryDateStart: undefined,
    entryDateEnd: undefined,
  },
  deptOptions: [],
});
const { searchForm, deptOptions } = toRefs(data);
const isShowRenewContractModal = ref(false);
const id = ref(0);
const tableColumn = ref([
  {
    label: "状态",
    prop: "staffState",
    dataType: "tag",
    formatData: (params) => {
      if (params == 0) {
        return "离职";
      } else if (params == 1) {
        return "在职";
      } else {
        return null;
      }
    },
    formatType: (params) => {
      if (params == 0) {
        return "danger";
      } else if (params == 1) {
        return "primary";
      } else {
        return null;
      }
    },
  },
  {
    label: "员工编号",
    prop: "staffNo",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
  {
    label: "别名",
    prop: "alias",
  },
  {
    label: "手机",
    prop: "phone",
    width: 150,
  },
  {
    label: "性别",
    prop: "sex",
  },
  {
    label: "出生日期",
    prop: "birthDate",
    width: 120,
  },
  {
    label: "入职日期",
    prop: "contractStartTime",
    width: 120,
  },
  {
    label: "年龄",
    prop: "age",
  },
  {
    label: "籍贯",
    prop: "nativePlace",
  },
  {
    label: "民族",
    prop: "nation",
    width: 100,
  },
  {
    label: "婚姻状况",
    prop: "maritalStatus",
    width: 100,
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    width: 180,
    operation: [
      {
        name: "编辑",
        type: "text",
        clickFun: (row) => {
          openFormNewOrEditFormDia("edit", row);
        },
      },
      {
        name: "续签合同",
        type: "text",
        showHide: row => row.staffState === 1,
        clickFun: (row) => {
          isShowRenewContractModal.value = true;
          id.value = row.id;
        },
      },
      // {
      //   name: "详情",
      //   type: "text",
      //   clickFun: (row) => {
      //     openForm("edit", row);
      //   },
      // },
    ],
  },
]);
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
  total: 0
});
const formDia = ref()
const formDiaNewOrEditFormDia = ref()
const { proxy } = getCurrentInstance()
// å¯¼å…¥ç›¸å…³
const uploadRef = ref(null)
const upload = reactive({
  // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚
  open: false,
  // å¼¹å‡ºå±‚标题
  title: "",
  // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
  isUploading: false,
  // è®¾ç½®ä¸Šä¼ çš„请求头部
  headers: { Authorization: "Bearer " + getToken() },
  // ä¸Šä¼ çš„地址
  url: import.meta.env.VITE_APP_BASE_API + "/staff/staffOnJob/import"
})
const fetchDeptOptions = () => {
    deptTreeSelect().then(response => {
      console.log(response.data)
      deptOptions.value = filterDisabledDept(
        JSON.parse(JSON.stringify(response.data))
      );
    });
  };
const filterDisabledDept = deptList => {
    return deptList.filter(dept => {
      if (dept.disabled) {
        return false;
      }
      if (dept.children && dept.children.length) {
        dept.children = filterDisabledDept(dept.children);
      }
      return true;
    });
  };
const changeDaterange = (value) => {
  searchForm.value.entryDateStart = undefined;
  searchForm.value.entryDateEnd = undefined;
  if (value) {
    searchForm.value.entryDateStart = dayjs(value[0]).format("YYYY-MM-DD");
    searchForm.value.entryDateEnd = dayjs(value[1]).format("YYYY-MM-DD");
  }
  getList();
};
// æŸ¥è¯¢åˆ—表
/** æœç´¢æŒ‰é’®æ“ä½œ */
const handleQuery = () => {
  page.current = 1;
  getList();
};
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  fetchDeptOptions();
  tableLoading.value = true;
  const params = { ...searchForm.value, ...page };
  params.entryDate = undefined
  staffOnJobListPage({...params}).then(res => {
    tableLoading.value = false;
    tableData.value = res.data.records
    page.total = res.data.total;
  }).catch(err => {
    tableLoading.value = false;
  })
};
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// æ‰“开弹框
const openForm = (type, row) => {
  nextTick(() => {
    formDia.value?.openDialog(type, row)
  })
};
const openFormNewOrEditFormDia = (type, row) => {
  nextTick(() => {
    formDiaNewOrEditFormDia.value?.openDialog(type, row)
  })
};
// åˆ é™¤
const handleDelete = () => {
  let ids = [];
  if (selectedRows.value.length > 0) {
    ids = selectedRows.value.map((item) => item.id);
  } else {
    proxy.$modal.msgWarning("请选择数据");
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
      .then(() => {
        batchDeleteStaffOnJobs(ids).then((res) => {
          proxy.$modal.msgSuccess("删除成功");
          getList();
        });
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
};
// å¯¼å‡º
const handleOut = () => {
  ElMessageBox.confirm("选中的内容将被导出,是否确认导出?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
      .then(() => {
        proxy.download("/staff/staffOnJob/export", {staffState: 1}, "员工台账.xlsx");
      })
      .catch(() => {
        proxy.$modal.msg("已取消");
      });
};
// å¯¼å…¥æŒ‰é’®æ“ä½œ
const handleImport = () => {
  upload.title = "员工导入"
  upload.open = true
}
// ä¸‹è½½æ¨¡æ¿æ“ä½œ
const importTemplate = () => {
  proxy.download("/staff/staffOnJob/downloadTemplate", {}, `员工导入模板_${new Date().getTime()}.xlsx`)
}
// æ–‡ä»¶ä¸Šä¼ ä¸­å¤„理
const handleFileUploadProgress = (event, file, fileList) => {
  upload.isUploading = true
}
// æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç†
const handleFileSuccess = (response, file, fileList) => {
  upload.open = false
  upload.isUploading = false
  proxy.$refs["uploadRef"].handleRemove(file)
  proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true })
  getList()
}
// æäº¤ä¸Šä¼ æ–‡ä»¶
const submitFileForm = () => {
  proxy.$refs["uploadRef"].submit()
}
onMounted(() => {
  getList();
});
</script>
<style scoped>
.search_title2 {
  margin-left: 10px;
}
</style>
src/views/officeProcessAutomation/HrManage/staff-contract/components/formDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        title="详情"
        width="70%"
        @close="closeDia"
    >
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :tableLoading="tableLoading"
          height="600"
      ></PIMTable>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
    <Files ref="filesDia"></Files>
  </div>
</template>
<script setup>
import {ref} from "vue";
import {findStaffContractListPage} from "@/api/personnelManagement/staffContract.js";
const Files = defineAsyncComponent(() => import( "@/views/personnelManagement/contractManagement/filesDia.vue"));
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const filesDia = ref()
const dialogFormVisible = ref(false);
const operationType = ref('')
const tableColumn = ref([
  {
    label: "合同年限",
    prop: "contractTerm",
  },
  {
    label: "合同开始日期",
    prop: "contractStartTime",
  },
  {
    label: "合同结束日期",
    prop: "contractEndTime",
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    width: 120,
    operation: [
      {
        name: "上传附件",
        type: "text",
        clickFun: (row) => {
          filesDia.value.openDialog( row,'合同')
        },
      }
    ],
  },
]);
const tableData = ref([]);
const tableLoading = ref(false);
// æ‰“开弹框
const openDialog = (type, row) => {
  operationType.value = type;
  dialogFormVisible.value = true;
  if (operationType.value === 'edit') {
    findStaffContractListPage({staffOnJobId: row.id}).then(res => {
      tableData.value = res.data.records
    })
  }
}
const openUploadFile = (row) => {
  filesDia.value.open = true
  filesDia.value.row = row
}
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  dialogFormVisible.value = false;
  emit('close')
};
defineExpose({
  openDialog,
});
</script>
<style scoped>
</style>
src/views/officeProcessAutomation/HrManage/staff-contract/filesDia.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,197 @@
<template>
  <div>
    <el-dialog
        v-model="dialogFormVisible"
        title="上传附件"
        width="50%"
        @close="closeDia"
    >
      <div style="margin-bottom: 10px;text-align: right">
        <el-upload
            v-model:file-list="fileList"
            class="upload-demo"
            :action="uploadUrl"
            :on-success="handleUploadSuccess"
            :on-error="handleUploadError"
            name="file"
            :show-file-list="false"
            :headers="headers"
            style="display: inline;margin-right: 10px"
        >
          <el-button type="primary">上传附件</el-button>
        </el-upload>
        <el-button type="danger" plain @click="handleDelete">删除</el-button>
      </div>
      <PIMTable
          rowKey="id"
          :column="tableColumn"
          :tableData="tableData"
          :tableLoading="tableLoading"
          :isSelection="true"
          :page="page"
          @selection-change="handleSelectionChange"
          height="500"
          @pagination="paginationSearch"
          :total="page.total"
      >
      </PIMTable>
      <template #footer>
        <div class="dialog-footer">
          <el-button @click="closeDia">取消</el-button>
        </div>
      </template>
    </el-dialog>
    <filePreview ref="filePreviewRef" />
  </div>
</template>
<script setup>
import {ref} from "vue";
import {ElMessageBox} from "element-plus";
import {getToken} from "@/utils/auth.js";
import filePreview from '@/components/filePreview/index.vue'
import {
  fileAdd,
  fileDel,
  fileListPage
} from "@/api/financialManagement/revenueManagement.js";
import Pagination from "@/components/PIMTable/Pagination.vue";
const { proxy } = getCurrentInstance()
const emit = defineEmits(['close'])
const dialogFormVisible = ref(false);
const currentId = ref('')
const selectedRows = ref([]);
const filePreviewRef = ref()
const tableColumn = ref([
  {
    label: "文件名称",
    prop: "name",
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    operation: [
      {
        name: "下载",
        type: "text",
        clickFun: (row) => {
          downLoadFile(row);
        },
      },
      {
        name: "预览",
        type: "text",
        clickFun: (row) => {
          lookFile(row);
        },
      }
    ],
  },
]);
const page = reactive({
    current: 1,
    size: 100,
});
const total = ref(0);
const tableData = ref([]);
const fileList = ref([]);
const tableLoading = ref(false);
const accountType = ref('')
const headers = ref({
  Authorization: "Bearer " + getToken(),
});
const uploadUrl = ref(import.meta.env.VITE_APP_BASE_API + "/file/upload"); // ä¸Šä¼ çš„图片服务器地址
// æ‰“开弹框
const openDialog = (row,type) => {
  accountType.value = type;
  dialogFormVisible.value = true;
  currentId.value = row.id;
  getList()
}
const paginationSearch = (obj) => {
    page.current = obj.page;
    page.size = obj.limit;
    getList();
};
const getList = () => {
  fileListPage({accountId: currentId.value,accountType:accountType.value, ...page}).then(res => {
    tableData.value = res.data.records;
    page.total = res.data.total;
  })
}
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// å…³é—­å¼¹æ¡†
const closeDia = () => {
  dialogFormVisible.value = false;
  emit('close')
};
// ä¸Šä¼ æˆåŠŸå¤„ç†
function handleUploadSuccess(res, file) {
  // å¦‚果上传成功
  if (res.code == 200) {
    const fileRow = {}
    fileRow.name = res.data.originalName
    fileRow.url = res.data.tempPath
    uploadFile(fileRow)
  } else {
    proxy.$modal.msgError("文件上传失败");
  }
}
function uploadFile(file) {
  file.accountId = currentId.value;
  file.accountType = accountType.value;
  fileAdd(file).then(res => {
    proxy.$modal.msgSuccess("文件上传成功");
    getList()
  })
}
// ä¸Šä¼ å¤±è´¥å¤„理
function handleUploadError() {
  proxy.$modal.msgError("文件上传失败");
}
// ä¸‹è½½é™„ä»¶
const downLoadFile = (row) => {
    proxy.$download.byUrl(row.url, row.originalFilename);
}
// åˆ é™¤
const handleDelete = () => {
  let ids = [];
  if (selectedRows.value.length > 0) {
    ids = selectedRows.value.map((item) => item.id);
  } else {
    proxy.$modal.msgWarning("请选择数据");
    return;
  }
  ElMessageBox.confirm("选中的内容将被删除,是否确认删除?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  }).then(() => {
    fileDel(ids).then((res) => {
      proxy.$modal.msgSuccess("删除成功");
      getList();
    });
  }).catch(() => {
    proxy.$modal.msg("已取消");
  });
};
// é¢„览附件
const lookFile = (row) => {
  filePreviewRef.value.open(row.url)
}
defineExpose({
  openDialog,
});
</script>
<style scoped>
</style>
src/views/officeProcessAutomation/HrManage/staff-contract/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,314 @@
<!--OA模块:员工合同-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">姓名:</span>
        <el-input v-model="searchForm.staffName" style="width: 240px" placeholder="请输入姓名搜索" @change="handleQuery"
          clearable :prefix-icon="Search" />
        <span style="margin-left: 10px" class="search_title">合同结束日期:</span>
        <el-date-picker v-model="searchForm.entryDate" value-format="YYYY-MM-DD" format="YYYY-MM-DD" type="daterange"
          placeholder="请选择" clearable @change="changeDaterange" />
        <el-button type="primary" @click="handleQuery" style="margin-left: 10px">搜索</el-button>
      </div>
      <div>
        <el-button @click="handleOut">导出</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable rowKey="id" :column="tableColumn" :tableData="tableData" :page="page" :isSelection="true"
        @selection-change="handleSelectionChange" :tableLoading="tableLoading" @pagination="pagination"
        :total="page.total"></PIMTable>
    </div>
    <form-dia ref="formDia" @close="handleQuery"></form-dia>
    <!-- åˆåŒå¯¼å…¥å¯¹è¯æ¡† -->
    <el-dialog
      :title="upload.title"
      v-model="upload.open"
      width="400px"
      append-to-body
    >
      <el-upload
        ref="uploadRef"
        :limit="1"
        accept=".xlsx, .xls"
        :headers="upload.headers"
        :action="upload.url + '?updateSupport=' + upload.updateSupport"
        :disabled="upload.isUploading"
        :on-progress="handleFileUploadProgress"
        :on-success="handleFileSuccess"
        :auto-upload="false"
        drag
      >
        <el-icon class="el-icon--upload"><upload-filled /></el-icon>
        <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
        <template #tip>
          <div class="el-upload__tip text-center">
            <span>仅允许导入xls、xlsx格式文件。</span>
            <!-- <el-link
              type="primary"
              :underline="false"
              style="font-size: 12px; vertical-align: baseline"
              @click="importTemplate"
              >下载模板</el-link
            > -->
          </div>
        </template>
      </el-upload>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitFileForm">ç¡® å®š</el-button>
          <el-button @click="upload.open = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <files-dia ref="filesDia"></files-dia>
  </div>
</template>
<script setup>
import { Search } from "@element-plus/icons-vue";
import { onMounted, ref } from "vue";
import FormDia from "@/views/personnelManagement/contractManagement/components/formDia.vue";
import { ElMessageBox } from "element-plus";
import { staffOnJobListPage } from "@/api/personnelManagement/staffOnJob.js";
import dayjs from "dayjs";
import { getToken } from "@/utils/auth.js";
import FilesDia from "./filesDia.vue";
const data = reactive({
  searchForm: {
    staffName: "",
    entryDate: null, // å½•入日期
    entryDateStart: undefined,
    entryDateEnd: undefined,
  },
});
const { searchForm } = toRefs(data);
const tableColumn = ref([
  {
    label: "状态",
    prop: "staffState",
    dataType: "tag",
    formatData: (params) => {
      if (params == 0) {
        return "离职";
      } else if (params == 1) {
        return "在职";
      } else {
        return null;
      }
    },
    formatType: (params) => {
      if (params == 0) {
        return "danger";
      } else if (params == 1) {
        return "primary";
      } else {
        return null;
      }
    },
  },
  {
    label: "员工编号",
    prop: "staffNo",
  },
  {
    label: "姓名",
    prop: "staffName",
  },
  {
    label: "性别",
    prop: "sex",
  },
  {
    label: "户籍住址",
    prop: "nativePlace",
  },
  {
    label: "岗位",
    prop: "postJob",
  },
  {
    label: "现住址",
    prop: "adress",
    width: 200
  },
  {
    label: "第一学历",
    prop: "firstStudy",
  },
  {
    label: "专业",
    prop: "profession",
    width: 100
  },
  {
    label: "年龄",
    prop: "age",
  },
  {
    label: "联系电话",
    prop: "phone",
    width: 150
  },
  {
    label: "紧急联系人",
    prop: "emergencyContact",
    width: 120
  },
  {
    label: "紧急联系人电话",
    prop: "emergencyContactPhone",
    width: 150
  },
  // {
  //   label: "合同年限",
  //   prop: "contractTerm",
  // },
  // {
  //   label: "合同开始日期",
  //   prop: "contractStartTime",
  //   width: 120
  // },
  {
    label: "合同结束日期",
    prop: "contractExpireTime",
    width: 120
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: 'right',
    width: 120,
    operation: [
      {
        name: "详情",
        type: "text",
        clickFun: (row) => {
          openForm("edit", row);
        },
      }
    ],
  },
]);
const filesDia = ref()
const tableData = ref([]);
const selectedRows = ref([]);
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 100,
  total: 0,
});
const formDia = ref()
const { proxy } = getCurrentInstance()
const changeDaterange = (value) => {
  searchForm.value.entryDateStart = undefined;
  searchForm.value.entryDateEnd = undefined;
  if (value) {
    searchForm.value.entryDateStart = dayjs(value[0]).format("YYYY-MM-DD");
    searchForm.value.entryDateEnd = dayjs(value[1]).format("YYYY-MM-DD");
  }
  getList();
};
// æ‰“开附件弹框
const openFilesFormDia = (row) => {
  console.log(row)
  nextTick(() => {
    filesDia.value?.openDialog( row,'合同')
  })
};
// æŸ¥è¯¢åˆ—表
/** æœç´¢æŒ‰é’®æ“ä½œ */
const handleQuery = () => {
  page.current = 1;
  getList();
};
const pagination = (obj) => {
  page.current = obj.page;
  page.size = obj.limit;
  getList();
};
const getList = () => {
  tableLoading.value = true;
  const params = { ...searchForm.value, ...page };
  params.entryDate = undefined
  params.staffState = 1
  staffOnJobListPage(params).then(res => {
    tableLoading.value = false;
    tableData.value = res.data.records
    page.total = res.data.total;
  }).catch(err => {
    tableLoading.value = false;
  })
};
// è¡¨æ ¼é€‰æ‹©æ•°æ®
const handleSelectionChange = (selection) => {
  selectedRows.value = selection;
};
// æ‰“开弹框
const openForm = (type, row) => {
  nextTick(() => {
    formDia.value?.openDialog(type, row)
  })
};
// å¯¼å‡º
const handleOut = () => {
  ElMessageBox.confirm("选中的内容将被导出,是否确认导出?", "导出", {
    confirmButtonText: "确认",
    cancelButtonText: "取消",
    type: "warning",
  })
    .then(() => {
      proxy.download("/staff/staffOnJob/export", {staffState: 1}, "合同管理.xlsx");
    })
    .catch(() => {
      proxy.$modal.msg("已取消");
    });
};
const upload = reactive({
  // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚(合同导入)
  open: false,
  // å¼¹å‡ºå±‚标题(合同导入)
  title: "",
  // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
  isUploading: false,
  // æ˜¯å¦æ›´æ–°å·²ç»å­˜åœ¨çš„用户数据
  updateSupport: 1,
  // è®¾ç½®ä¸Šä¼ çš„请求头部
  headers: { Authorization: "Bearer " + getToken() },
  // ä¸Šä¼ çš„地址
  url: import.meta.env.VITE_APP_BASE_API + "/staff/staffOnJob/import",
});
/** å¯¼å…¥æŒ‰é’®æ“ä½œ */
function handleImport() {
  upload.title = "合同导入";
  upload.open = true;
}
/** æäº¤ä¸Šä¼ æ–‡ä»¶ */
function submitFileForm() {
  console.log(upload.url + '?updateSupport=' + upload.updateSupport)
  proxy.$refs["uploadRef"].submit();
}
/**文件上传中处理 */
const handleFileUploadProgress = (event, file, fileList) => {
  upload.isUploading = true;
};
/** æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç† */
const handleFileSuccess = (response, file, fileList) => {
  upload.open = false;
  upload.isUploading = false;
  proxy.$refs["uploadRef"].handleRemove(file);
  getList();
};
onMounted(() => {
  getList();
});
</script>
<style scoped></style>
src/views/officeProcessAutomation/HrManage/transfer-apply/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,792 @@
<!--OA模块:调岗申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-select
          v-model="searchForm.applicantId"
          filterable
          remote
          clearable
          reserve-keyword
          placeholder="请选择或搜索申请人"
          style="width: 220px"
          :remote-method="remoteSearchApplicant"
          :loading="applicantSearchLoading"
        >
          <el-option
            v-for="u in applicantSearchOptions"
            :key="u.userId"
            :label="userSelectLabel(u)"
            :value="u.userId"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">转岗时间:</span>
        <el-date-picker
          v-model="searchForm.transferDateRange"
          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" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增调岗申请</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="720px"
      append-to-body
      destroy-on-close
      class="transfer-apply-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="transfer-apply-form">
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="转岗日期" prop="transferDate">
              <el-date-picker
                v-model="form.transferDate"
                type="date"
                placeholder="请选择转岗日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="原岗位" prop="originalPostName">
              <el-input v-model="form.originalPostName" placeholder="选择申请人后自动带出" disabled />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="转入岗位" prop="targetPostId">
              <el-select v-model="form.targetPostId" placeholder="请选择转入岗位" clearable filterable style="width: 100%">
                <el-option
                  v-for="p in targetPostOptions"
                  :key="p.postId"
                  :label="p.postName"
                  :value="p.postId"
                  :disabled="p.status === '1' || p.status === 1"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="countersign">会签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="调岗申请详情" width="560px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="转岗日期">{{ detailRow.transferDate }}</el-descriptions-item>
        <el-descriptions-item label="原岗位">{{ detailRow.originalPostName }}</el-descriptions-item>
        <el-descriptions-item label="转入岗位">{{ detailRow.targetPostName }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { findPostOptions } from "@/api/system/post.js";
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
const { proxy } = getCurrentInstance();
/** ä¸ŽåŽç«¯çº¦å®šå­—段(本地占位,后期接口对齐) */
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantName: "",
  transferDate: "",
  originalPostId: "",
  originalPostName: "",
  targetPostId: "",
  targetPostName: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
});
/** ç³»ç»Ÿç”¨æˆ·ç¼“存(/system/user/userListNoPageByTenantId,与转正申请等一致) */
const allUsersCache = ref([]);
/** å²—位字典 postId -> postName(/system/post/optionselect,与员工档案入职表单一致) */
const postIdToName = ref({});
const targetPostOptions = ref([]);
function rebuildPostIdMap() {
  const m = {};
  for (const p of targetPostOptions.value || []) {
    const id = p.postId ?? p.value ?? p.id;
    if (id != null && id !== "") m[String(id)] = p.postName ?? p.label ?? "";
  }
  postIdToName.value = m;
}
function targetPostNameById(postId) {
  if (postId == null || postId === "") return "";
  const k = String(postId);
  return (
    postIdToName.value[k] ||
    targetPostOptions.value.find((x) => String(x.postId ?? x.id ?? x.value) === k)?.postName ||
    ""
  );
}
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function firstPostId(user) {
  if (!user) return undefined;
  if (Array.isArray(user.postIds) && user.postIds.length) return user.postIds[0];
  if (user.postId != null && user.postId !== "") return user.postId;
  return undefined;
}
/** ä»Žç”¨æˆ·å¯¹è±¡è§£æžã€ŒåŽŸå²—ä½ã€ï¼ˆå…¼å®¹ postName / postIds / posts ç­‰å¸¸è§è¿”回) */
function resolveOriginalPost(user) {
  if (!user) return { originalPostId: "", originalPostName: "" };
  const nameStr = (user.postName ?? user.postname ?? "").toString().trim();
  if (nameStr) {
    const pid = firstPostId(user);
    return { originalPostId: pid != null && pid !== "" ? String(pid) : "", originalPostName: nameStr };
  }
  if (Array.isArray(user.posts) && user.posts.length) {
    const p0 = user.posts[0];
    return {
      originalPostId: p0.postId != null ? String(p0.postId) : "",
      originalPostName: (p0.postName ?? "").toString() || "未命名岗位",
    };
  }
  const pid = firstPostId(user);
  if (pid != null && pid !== "") {
    const n = postIdToName.value[String(pid)] || "";
    return {
      originalPostId: String(pid),
      originalPostName: n || "当前岗位(未在岗位字典中)",
    };
  }
  return { originalPostId: "", originalPostName: "未分配岗位" };
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return [...list];
  return list.filter((u) => {
    const nick = (u.nickName || "").toLowerCase();
    const uname = (u.userName || "").toLowerCase();
    const phone = (u.phonenumber || u.phone || "").toString();
    return nick.includes(q) || uname.includes(q) || phone.includes(q);
  });
}
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
async function loadPostOptions() {
  try {
    const res = await findPostOptions();
    const rows = res.data ?? res.rows ?? [];
    targetPostOptions.value = Array.isArray(rows) ? rows : [];
  } catch {
    targetPostOptions.value = [];
  }
  rebuildPostIdMap();
}
/** æŸ¥è¯¢åŒºï¼šä¸‹æ‹‰è¿œç¨‹æ¨¡ç³Šï¼ˆæ•°æ®æ¥è‡ª userListNoPageByTenantId,前端过滤) */
const applicantSearchLoading = ref(false);
const applicantSearchOptions = ref([]);
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
}
/** è¡¨å•内申请人下拉 */
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
}
function onApplicantChange(uid) {
  const u = userById(uid);
  if (u) {
    form.applicantName = u.nickName || u.userName || "";
    const { originalPostId, originalPostName } = resolveOriginalPost(u);
    form.originalPostId = originalPostId;
    form.originalPostName = originalPostName;
  } else {
    form.applicantName = "";
    form.originalPostId = "";
    form.originalPostName = "";
  }
}
/** å®¡æ‰¹äººæ ‘ */
const approverTreeData = ref([]);
const approverLabelMap = ref({});
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
}
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
async function loadApproverTree() {
  try {
    const needFetchUsers = !allUsersCache.value.length;
    const [deptRes, userRes] = await Promise.all([
      deptTreeSelect(),
      needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null),
    ]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value];
    if (needFetchUsers && users.length) {
      allUsersCache.value = users;
    }
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
/** æœ¬åœ°æ¨¡æ‹Ÿåˆ—表数据 */
const allRows = ref([
  {
    id: "1",
    applicantId: "1001",
    applicantName: "周明",
    transferDate: "2026-05-20",
    originalPostId: "post_dev",
    originalPostName: "软件开发工程师",
    targetPostId: "post_senior_dev",
    targetPostName: "高级软件开发工程师",
    approvalResult: "pending",
    approvalMode: "parallel",
    approverIds: [],
    approverNames: "",
  },
  {
    id: "2",
    applicantId: "1002",
    applicantName: "吴芳",
    transferDate: "2026-05-10",
    originalPostId: "post_pm",
    originalPostName: "产品经理",
    targetPostId: "post_senior_pm",
    targetPostName: "高级产品经理",
    approvalResult: "approved",
    approvalMode: "countersign",
    approverIds: [],
    approverNames: "张三、李四",
  },
]);
const searchForm = reactive({
  applicantId: "",
  transferDateRange: null,
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  if (searchForm.applicantId) {
    list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId));
  }
  const range = searchForm.transferDateRange;
  if (range && range.length === 2) {
    const [start, end] = range;
    list = list.filter((r) => r.transferDate >= start && r.transferDate <= end);
  }
  return list.sort((a, b) => (a.transferDate < b.transferDate ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "转岗日期", prop: "transferDate", width: 120 },
  { label: "原岗位", prop: "originalPostName", minWidth: 140 },
  { label: "转入岗位", prop: "targetPostName", minWidth: 160 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 180,
    operation: [
      { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
      { name: "查看详情", type: "text", clickFun: (row) => openDetail(row) },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  transferDate: [{ required: true, message: "请选择转岗日期", trigger: "change" }],
  originalPostName: [{ required: true, message: "原岗位不能为空", trigger: "change" }],
  targetPostId: [{ required: true, message: "请选择转入岗位", trigger: "change" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [{ type: "array", required: true, message: "请选择审批人", trigger: "change" }],
};
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
}
async function resetSearch() {
  searchForm.applicantId = "";
  searchForm.transferDateRange = null;
  handleQuery();
  await remoteSearchApplicant("");
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function ensureApplicantInFormOptions(row) {
  if (!row?.applicantId) return;
  const id = String(row.applicantId);
  if (!applicantFormOptions.value.some((u) => String(u.userId ?? u.id) === id)) {
    applicantFormOptions.value = [
      {
        userId: row.applicantId,
        nickName: row.applicantName,
        userName: row.applicantUserName,
      },
      ...applicantFormOptions.value,
    ];
  }
}
async function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增调岗申请" : "编辑调岗申请";
  loadApproverTree();
  Object.assign(form, createEmptyForm());
  await remoteSearchApplicantForm("");
  if (mode === "edit" && row) {
    ensureApplicantInFormOptions(row);
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      transferDate: row.transferDate,
      originalPostId: row.originalPostId,
      originalPostName: row.originalPostName,
      targetPostId: row.targetPostId,
      targetPostName: row.targetPostName,
      approvalMode: row.approvalMode,
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
    });
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  form.targetPostName = targetPostNameById(form.targetPostId);
  const payload = {
    applicantId: form.applicantId,
    applicantName: form.applicantName,
    transferDate: form.transferDate,
    originalPostId: form.originalPostId,
    originalPostName: form.originalPostName,
    targetPostId: form.targetPostId,
    targetPostName: form.targetPostName,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
    });
    proxy?.$modal?.msgSuccess?.("新增成功(本地模拟)");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    const prev = idx !== -1 ? allRows.value[idx] : {};
    if (idx !== -1) {
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)");
  }
  formDialog.visible = false;
  handleQuery();
}
onMounted(async () => {
  await Promise.all([loadUserPool(), loadPostOptions()]);
  rebuildPostIdMap();
  loadApproverTree();
  await remoteSearchApplicant("");
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.transfer-apply-form :deep(.el-row) {
  margin-bottom: 0;
}
.transfer-apply-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.transfer-apply-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/HrManage/work-handover/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,810 @@
<!--OA模块:工作交接-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-select
          v-model="searchForm.applicantId"
          filterable
          remote
          clearable
          reserve-keyword
          placeholder="请选择或搜索申请人"
          style="width: 220px"
          :remote-method="remoteSearchApplicant"
          :loading="applicantSearchLoading"
        >
          <el-option
            v-for="u in applicantSearchOptions"
            :key="u.userId"
            :label="userSelectLabel(u)"
            :value="u.userId"
          />
        </el-select>
        <span class="search_title" style="margin-left: 12px">交接状态:</span>
        <el-select v-model="searchForm.handoverStatus" placeholder="全部" clearable style="width: 140px">
          <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" />
        </el-select>
        <span class="search_title" style="margin-left: 12px">交接类型:</span>
        <el-select v-model="searchForm.handoverType" placeholder="全部" clearable style="width: 140px">
          <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div>
        <el-button type="primary" @click="openFormDialog('add')">新增工作交接</el-button>
      </div>
    </div>
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        :total="page.total"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="720px"
      append-to-body
      destroy-on-close
      class="work-handover-form-dialog"
      @closed="onFormClosed"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="120px" class="work-handover-form">
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="申请人" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索申请人"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="离职日期" prop="leaveDate">
              <el-date-picker
                v-model="form.leaveDate"
                type="date"
                placeholder="请选择离职日期"
                format="YYYY-MM-DD"
                value-format="YYYY-MM-DD"
                style="width: 100%"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="交接状态" prop="handoverStatus">
              <el-select v-model="form.handoverStatus" placeholder="请选择交接状态" style="width: 100%">
                <el-option v-for="o in handoverStatusOptions" :key="o.value" :label="o.label" :value="o.value" />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="交接类型" prop="handoverType">
              <el-select v-model="form.handoverType" placeholder="请选择交接类型" style="width: 100%">
                <el-option v-for="o in handoverTypeOptions" :key="o.value" :label="o.label" :value="o.value" />
              </el-select>
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="交接人" prop="handoverPersonId">
              <el-select
                v-model="form.handoverPersonId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索交接人"
                style="width: 100%"
                :remote-method="remoteSearchHandoverPerson"
                :loading="handoverPersonSearchLoading"
                @change="onHandoverPersonChange"
              >
                <el-option
                  v-for="u in handoverPersonOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="审批方式" prop="approvalMode">
              <el-radio-group v-model="form.approvalMode">
                <el-radio value="parallel">与签</el-radio>
                <el-radio value="countersign">会签</el-radio>
              </el-radio-group>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="审批人" prop="approverIds">
              <el-tree-select
                v-model="form.approverIds"
                :data="approverTreeData"
                multiple
                collapse-tags
                collapse-tags-tooltip
                :max-collapse-tags="2"
                :render-after-expand="false"
                placeholder="请选择审批人(可多选)"
                style="width: 100%"
                :props="{ value: 'id', label: 'label', children: 'children', disabled: 'disabled' }"
                check-strictly
              />
            </el-form-item>
          </el-col>
        </el-row>
      </el-form>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
          <el-button @click="formDialog.visible = false">取 æ¶ˆ</el-button>
        </div>
      </template>
    </el-dialog>
    <!-- è¯¦æƒ… -->
    <el-dialog v-model="detailDialog.visible" title="工作交接详情" width="560px" append-to-body>
      <el-descriptions :column="1" border>
        <el-descriptions-item label="申请人">{{ detailRow.applicantName }}</el-descriptions-item>
        <el-descriptions-item label="离职日期">{{ detailRow.leaveDate || "—" }}</el-descriptions-item>
        <el-descriptions-item label="交接状态">{{ handoverStatusLabel(detailRow.handoverStatus) }}</el-descriptions-item>
        <el-descriptions-item label="交接类型">{{ handoverTypeLabel(detailRow.handoverType) }}</el-descriptions-item>
        <el-descriptions-item label="交接人">{{ detailRow.handoverPersonName || "—" }}</el-descriptions-item>
        <el-descriptions-item label="审批结果">{{ approvalResultLabel(detailRow.approvalResult) }}</el-descriptions-item>
        <el-descriptions-item label="审批方式">{{ approvalModeLabel(detailRow.approvalMode) }}</el-descriptions-item>
        <el-descriptions-item label="审批人">{{ detailRow.approverNames || "—" }}</el-descriptions-item>
      </el-descriptions>
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
        </div>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import { deptTreeSelect, userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
const { proxy } = getCurrentInstance();
const handoverStatusOptions = [
  { value: "in_progress", label: "进行中" },
  { value: "completed", label: "已完成" },
  { value: "returned", label: "已退回" },
];
const handoverTypeOptions = [
  { value: "resignation", label: "离职交接" },
  { value: "transfer", label: "调岗交接" },
];
function handoverStatusLabel(v) {
  return handoverStatusOptions.find((o) => o.value === v)?.label || "—";
}
function handoverTypeLabel(v) {
  return handoverTypeOptions.find((o) => o.value === v)?.label || "—";
}
/** ä¸ŽåŽç«¯çº¦å®šå­—段(本地占位,后期接口对齐) */
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantName: "",
  leaveDate: "",
  handoverStatus: "in_progress",
  handoverType: "resignation",
  handoverPersonId: "",
  handoverPersonName: "",
  approvalMode: "parallel",
  approverIds: [],
  approverNames: "",
});
const allUsersCache = ref([]);
function userSelectLabel(u) {
  const nick = u.nickName || "";
  const name = u.userName || "";
  if (nick && name && nick !== name) return `${nick}(${name})`;
  return nick || name || `用户${u.userId ?? u.id ?? ""}`;
}
function userById(id) {
  if (id == null || id === "") return undefined;
  return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
}
function filterUsersByQuery(query) {
  const list = allUsersCache.value.filter((u) => isActiveUser(u));
  const q = (query || "").trim().toLowerCase();
  if (!q) return [...list];
  return list.filter((u) => {
    const nick = (u.nickName || "").toLowerCase();
    const uname = (u.userName || "").toLowerCase();
    const phone = (u.phonenumber || u.phone || "").toString();
    return nick.includes(q) || uname.includes(q) || phone.includes(q);
  });
}
async function loadUserPool() {
  try {
    const res = await userListNoPageByTenantId();
    allUsersCache.value = unwrapArray(res);
  } catch {
    allUsersCache.value = [];
  }
}
const applicantSearchLoading = ref(false);
const applicantSearchOptions = ref([]);
async function remoteSearchApplicant(query) {
  applicantSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantSearchOptions.value = filterUsersByQuery(query);
  } finally {
    applicantSearchLoading.value = false;
  }
}
const applicantFormSearchLoading = ref(false);
const applicantFormOptions = ref([]);
async function remoteSearchApplicantForm(query) {
  applicantFormSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    applicantFormOptions.value = filterUsersByQuery(query);
  } finally {
    applicantFormSearchLoading.value = false;
  }
}
function onApplicantChange(uid) {
  const u = userById(uid);
  form.applicantName = u ? u.nickName || u.userName || "" : "";
}
const handoverPersonSearchLoading = ref(false);
const handoverPersonOptions = ref([]);
async function remoteSearchHandoverPerson(query) {
  handoverPersonSearchLoading.value = true;
  try {
    if (!allUsersCache.value.length) {
      await loadUserPool();
    }
    handoverPersonOptions.value = filterUsersByQuery(query);
  } finally {
    handoverPersonSearchLoading.value = false;
  }
}
function onHandoverPersonChange(uid) {
  const u = userById(uid);
  form.handoverPersonName = u ? u.nickName || u.userName || "" : "";
}
const approverTreeData = ref([]);
const approverLabelMap = ref({});
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload && Array.isArray(payload.data)) return payload.data;
  if (payload && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function filterDisabledDept(deptList) {
  if (!Array.isArray(deptList)) return [];
  return deptList.filter((dept) => {
    if (dept.disabled) return false;
    if (dept.children?.length) {
      dept.children = filterDisabledDept(dept.children);
    }
    return true;
  });
}
function getUserDeptId(u) {
  return u.deptId ?? u.sysDeptId ?? u.dept?.deptId ?? u.dept?.id ?? u.dept_id;
}
function getDeptNodeKey(node) {
  const k = node?.id ?? node?.value ?? node?.deptId;
  if (k == null || k === "") return null;
  return k;
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function userToTreeLeaf(u) {
  return {
    id: String(u.userId ?? u.id),
    label: u.nickName || u.userName || `用户${u.userId ?? u.id}`,
  };
}
function buildUsersByDeptId(users) {
  const map = new Map();
  const unassigned = [];
  for (const u of users) {
    if (!isActiveUser(u)) continue;
    const did = getUserDeptId(u);
    if (did == null || did === "" || did === 0 || did === "0") {
      unassigned.push(u);
      continue;
    }
    const k = String(did);
    if (!map.has(k)) map.set(k, []);
    map.get(k).push(u);
  }
  return { map, unassigned };
}
function collectUserLabels(nodes, map) {
  (nodes || []).forEach((n) => {
    if (n.children?.length) {
      collectUserLabels(n.children, map);
    } else if (n.id != null && !String(n.id).startsWith("dept_")) {
      map[String(n.id)] = n.label;
    }
  });
}
function mergeDeptTreeWithUsers(nodes, usersByDept) {
  if (!Array.isArray(nodes)) return [];
  const out = [];
  for (const node of nodes) {
    const deptIdRaw = getDeptNodeKey(node);
    if (deptIdRaw == null) continue;
    const sub = mergeDeptTreeWithUsers(node.children || [], usersByDept);
    const usersHere = usersByDept.get(String(deptIdRaw)) || [];
    const userChildren = usersHere.map(userToTreeLeaf);
    const children = [...sub, ...userChildren];
    if (!children.length) continue;
    out.push({
      id: `dept_${deptIdRaw}`,
      label: node.label ?? node.deptName ?? "部门",
      disabled: true,
      children,
    });
  }
  return out;
}
function buildFlatApproverTree(users) {
  const list = users.filter(isActiveUser).map(userToTreeLeaf);
  if (!list.length) return [];
  return [
    {
      id: "dept_all_users",
      label: "系统用户",
      disabled: true,
      children: list,
    },
  ];
}
async function loadApproverTree() {
  try {
    const needFetchUsers = !allUsersCache.value.length;
    const [deptRes, userRes] = await Promise.all([
      deptTreeSelect(),
      needFetchUsers ? userListNoPageByTenantId() : Promise.resolve(null),
    ]);
    let rawTree = unwrapArray(deptRes);
    rawTree = rawTree.length ? JSON.parse(JSON.stringify(rawTree)) : [];
    let deptTree = filterDisabledDept(JSON.parse(JSON.stringify(rawTree)));
    if (!deptTree.length && rawTree.length) {
      deptTree = JSON.parse(JSON.stringify(rawTree));
    }
    let users = needFetchUsers ? unwrapArray(userRes) : [...allUsersCache.value];
    if (needFetchUsers && users.length) {
      allUsersCache.value = users;
    }
    const { map: usersByDept, unassigned } = buildUsersByDeptId(users);
    let merged = mergeDeptTreeWithUsers(deptTree, usersByDept);
    if (unassigned.length) {
      merged.push({
        id: "dept_unassigned",
        label: "未分配部门",
        disabled: true,
        children: unassigned.map(userToTreeLeaf),
      });
    }
    if (!merged.length && users.length) {
      merged = buildFlatApproverTree(users);
    }
    approverTreeData.value = merged;
    const map = {};
    collectUserLabels(merged, map);
    approverLabelMap.value = map;
  } catch {
    approverTreeData.value = [];
    approverLabelMap.value = {};
    proxy?.$modal?.msgWarning?.("审批人数据加载失败,请检查网络或稍后重试");
  }
}
function resolveApproverNames(ids) {
  if (!ids?.length) return "";
  const map = approverLabelMap.value;
  return ids.map((id) => map[String(id)] || id).join("、");
}
function approvalModeLabel(mode) {
  if (mode === "countersign") return "会签";
  return "与签";
}
function approvalResultLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  if (v === "cancelled") return "已撤销";
  return "待审批";
}
function handoverStatusTagType(v) {
  if (v === "completed") return "success";
  if (v === "returned") return "danger";
  return "warning";
}
function handoverTypeTagType(v) {
  return v === "transfer" ? "info" : "";
}
/** æœ¬åœ°æ¨¡æ‹Ÿåˆ—表数据 */
const allRows = ref([
  {
    id: "1",
    applicantId: "1001",
    applicantName: "周明",
    leaveDate: "2026-05-28",
    handoverStatus: "in_progress",
    handoverType: "resignation",
    handoverPersonId: "1003",
    handoverPersonName: "王强",
    approvalResult: "pending",
    approvalMode: "parallel",
    approverIds: [],
    approverNames: "",
  },
  {
    id: "2",
    applicantId: "1002",
    applicantName: "吴芳",
    leaveDate: "2026-05-15",
    handoverStatus: "completed",
    handoverType: "transfer",
    handoverPersonId: "1004",
    handoverPersonName: "赵敏",
    approvalResult: "approved",
    approvalMode: "countersign",
    approverIds: [],
    approverNames: "张三、李四",
  },
  {
    id: "3",
    applicantId: "1005",
    applicantName: "陈浩",
    leaveDate: "2026-04-20",
    handoverStatus: "returned",
    handoverType: "resignation",
    handoverPersonId: "1006",
    handoverPersonName: "刘洋",
    approvalResult: "rejected",
    approvalMode: "parallel",
    approverIds: [],
    approverNames: "李四",
  },
]);
const searchForm = reactive({
  applicantId: "",
  handoverStatus: "",
  handoverType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  if (searchForm.applicantId) {
    list = list.filter((r) => String(r.applicantId) === String(searchForm.applicantId));
  }
  if (searchForm.handoverStatus) {
    list = list.filter((r) => r.handoverStatus === searchForm.handoverStatus);
  }
  if (searchForm.handoverType) {
    list = list.filter((r) => r.handoverType === searchForm.handoverType);
  }
  return list.sort((a, b) => (a.leaveDate < b.leaveDate ? 1 : -1));
});
watch(
  filteredList,
  (list) => {
    page.total = list.length;
    const maxPage = Math.max(1, Math.ceil(list.length / page.size) || 1);
    if (page.current > maxPage) {
      page.current = maxPage;
    }
  },
  { immediate: true }
);
const tableData = computed(() => {
  const list = filteredList.value;
  const start = (page.current - 1) * page.size;
  return list.slice(start, start + page.size);
});
const tableColumn = ref([
  { label: "申请人", prop: "applicantName", minWidth: 100 },
  { label: "离职日期", prop: "leaveDate", width: 120 },
  {
    label: "交接状态",
    prop: "handoverStatus",
    width: 110,
    dataType: "tag",
    formatData: (v) => handoverStatusLabel(v),
    formatType: (v) => handoverStatusTagType(v),
  },
  {
    label: "交接类型",
    prop: "handoverType",
    width: 110,
    dataType: "tag",
    formatData: (v) => handoverTypeLabel(v),
    formatType: (v) => handoverTypeTagType(v),
  },
  { label: "交接人", prop: "handoverPersonName", minWidth: 100 },
  {
    label: "审批结果",
    prop: "approvalResult",
    width: 110,
    dataType: "tag",
    formatData: (v) => approvalResultLabel(v),
    formatType: (v) => {
      if (v === "approved") return "success";
      if (v === "rejected") return "danger";
      if (v === "cancelled") return "info";
      return "warning";
    },
  },
  {
    dataType: "action",
    label: "操作",
    align: "center",
    fixed: "right",
    width: 200,
    operation: [
      { name: "编辑", type: "text", clickFun: (row) => openFormDialog("edit", row) },
      { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
    ],
  },
]);
const formDialog = reactive({
  visible: false,
  title: "",
  mode: "add",
});
const formRef = ref();
const form = reactive(createEmptyForm());
const formRules = {
  applicantId: [{ required: true, message: "请选择申请人", trigger: "change" }],
  leaveDate: [{ required: true, message: "请选择离职日期", trigger: "change" }],
  handoverStatus: [{ required: true, message: "请选择交接状态", trigger: "change" }],
  handoverType: [{ required: true, message: "请选择交接类型", trigger: "change" }],
  handoverPersonId: [{ required: true, message: "请选择交接人", trigger: "change" }],
  approvalMode: [{ required: true, message: "请选择审批方式", trigger: "change" }],
  approverIds: [{ type: "array", required: true, message: "请选择审批人", trigger: "change" }],
};
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
}
async function resetSearch() {
  searchForm.applicantId = "";
  searchForm.handoverStatus = "";
  searchForm.handoverType = "";
  handleQuery();
  await remoteSearchApplicant("");
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function ensureUserInOptions(optionsRef, row, idKey, nameKey) {
  const id = row?.[idKey];
  if (id == null || id === "") return;
  const sid = String(id);
  if (!optionsRef.value.some((u) => String(u.userId ?? u.id) === sid)) {
    optionsRef.value = [
      {
        userId: id,
        nickName: row[nameKey],
        userName: row.applicantUserName,
      },
      ...optionsRef.value,
    ];
  }
}
async function openFormDialog(mode, row) {
  formDialog.mode = mode;
  formDialog.title = mode === "add" ? "新增工作交接" : "编辑工作交接";
  loadApproverTree();
  Object.assign(form, createEmptyForm());
  await Promise.all([remoteSearchApplicantForm(""), remoteSearchHandoverPerson("")]);
  if (mode === "edit" && row) {
    ensureUserInOptions(applicantFormOptions, row, "applicantId", "applicantName");
    ensureUserInOptions(handoverPersonOptions, row, "handoverPersonId", "handoverPersonName");
    Object.assign(form, {
      id: row.id,
      applicantId: row.applicantId,
      applicantName: row.applicantName,
      leaveDate: row.leaveDate,
      handoverStatus: row.handoverStatus,
      handoverType: row.handoverType,
      handoverPersonId: row.handoverPersonId,
      handoverPersonName: row.handoverPersonName,
      approvalMode: row.approvalMode,
      approverIds: (row.approverIds || []).map((id) => String(id)),
      approverNames: row.approverNames,
    });
  }
  formDialog.visible = true;
  nextTick(() => formRef.value?.clearValidate?.());
}
function onFormClosed() {
  formRef.value?.resetFields?.();
}
async function submitForm() {
  try {
    await formRef.value?.validate?.();
  } catch {
    return;
  }
  form.approverNames = resolveApproverNames(form.approverIds);
  const payload = {
    applicantId: form.applicantId,
    applicantName: form.applicantName,
    leaveDate: form.leaveDate,
    handoverStatus: form.handoverStatus,
    handoverType: form.handoverType,
    handoverPersonId: form.handoverPersonId,
    handoverPersonName: form.handoverPersonName,
    approvalMode: form.approvalMode,
    approverIds: [...form.approverIds],
    approverNames: form.approverNames,
  };
  if (formDialog.mode === "add") {
    const id = `local_${Date.now()}`;
    allRows.value.unshift({
      id,
      ...payload,
      approvalResult: "pending",
    });
    proxy?.$modal?.msgSuccess?.("新增成功(本地模拟)");
  } else {
    const idx = allRows.value.findIndex((r) => r.id === form.id);
    const prev = idx !== -1 ? allRows.value[idx] : {};
    if (idx !== -1) {
      allRows.value[idx] = {
        ...prev,
        id: form.id,
        ...payload,
      };
    }
    proxy?.$modal?.msgSuccess?.("保存成功(本地模拟)");
  }
  formDialog.visible = false;
  handleQuery();
}
onMounted(async () => {
  await loadUserPool();
  loadApproverTree();
  await remoteSearchApplicant("");
});
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.work-handover-form :deep(.el-row) {
  margin-bottom: 0;
}
.work-handover-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.work-handover-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
</style>
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/components/NoticeDetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,77 @@
<!-- NoticeAnnouncement:公告详情只读面板 -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="公告编号">{{ row.noticeNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="发布状态">
      <el-tag :type="statusTag" size="small">{{ statusText }}</el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="公告类型">
      <span class="type-badge" :style="{ color: noticeTypeColor(row.noticeType) }">
        {{ noticeTypeLabel(row.noticeType) }}
      </span>
    </el-descriptions-item>
    <el-descriptions-item label="优先级">
      <el-tag :type="priorityTag(row.priority)" size="small">{{ priorityLabel(row.priority) }}</el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="标题" :span="2">{{ row.title || "—" }}</el-descriptions-item>
    <el-descriptions-item label="发布日期">{{ row.publishDate || "—" }}</el-descriptions-item>
    <el-descriptions-item label="过期日期">{{ row.expireDate || "长期有效" }}</el-descriptions-item>
    <el-descriptions-item label="阅读范围">{{ readScopeLabel(row.readScope) }}</el-descriptions-item>
    <el-descriptions-item label="需阅读确认">{{ row.requireReadConfirm ? "是" : "否" }}</el-descriptions-item>
    <el-descriptions-item label="发布人">{{ row.publisherName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="发布时间">{{ row.publishTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="阅读量">{{ row.readCount ?? 0 }}</el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">公告内容</el-divider>
  <div v-if="row.priority === 'urgent'" class="urgent-banner">
    <el-alert title="紧急通知" type="error" :closable="false" show-icon />
  </div>
  <div v-if="row.contentHtml" class="notice-html-body" v-html="row.contentHtml" />
  <el-empty v-else description="暂无内容" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import {
  noticeTypeLabel,
  noticeTypeColor,
  priorityLabel,
  priorityTag,
  publishStatusLabel,
  publishStatusTag,
  readScopeLabel,
  isExpired,
} from "../noticeAnnouncementUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const statusText = computed(() => {
  if (isExpired(props.row) && props.row.publishStatus === "published") return "已过期";
  return publishStatusLabel(props.row.publishStatus);
});
const statusTag = computed(() => {
  if (isExpired(props.row) && props.row.publishStatus === "published") return "";
  return publishStatusTag(props.row.publishStatus);
});
</script>
<style scoped>
.type-badge {
  font-weight: 600;
}
.urgent-banner {
  margin-bottom: 12px;
}
.notice-html-body {
  padding: 12px;
  background: var(--el-fill-color-light);
  border-radius: 6px;
  max-height: 400px;
  overflow-y: auto;
  line-height: 1.7;
}
</style>
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,253 @@
<!--OA模块:NoticeAnnouncement é€šçŸ¥å…¬å‘Š-->
<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>
</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();
});
</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
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,194 @@
import dayjs from "dayjs";
/** å…¬å‘Šç±»åž‹ */
export const NOTICE_TYPE_OPTIONS = [
  { value: "emergency", label: "紧急通知", color: "#f56c6c" },
  { value: "employee", label: "员工公告", color: "#409eff" },
  { value: "company", label: "企业公告", color: "#e6a23c" },
];
/** ä¼˜å…ˆçº§ */
export const PRIORITY_OPTIONS = [
  { value: "urgent", label: "紧急", tag: "danger" },
  { value: "high", label: "重要", tag: "warning" },
  { value: "normal", label: "普通", tag: "info" },
];
/** å‘布状态 */
export const PUBLISH_STATUS_OPTIONS = [
  { value: "draft", label: "草稿", tag: "info" },
  { value: "published", label: "已发布", tag: "success" },
  { value: "withdrawn", label: "已撤回", tag: "warning" },
  { value: "expired", label: "已过期", tag: "" },
];
/** é˜…读范围 */
export const READ_SCOPE_OPTIONS = [
  { value: "all", label: "全员可见" },
  { value: "department", label: "指定部门" },
  { value: "management", label: "管理层" },
];
export const DEPT_OPTIONS = [
  { value: "101", label: "研发部" },
  { value: "102", label: "销售部" },
  { value: "103", label: "行政部" },
  { value: "104", label: "财务部" },
  { value: "105", label: "总经办" },
  { value: "106", label: "人力资源部" },
];
export const STORAGE_KEY = "oa_notice_announcement_v1";
export function noticeTypeLabel(v) {
  return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function noticeTypeColor(v) {
  return NOTICE_TYPE_OPTIONS.find((x) => x.value === v)?.color || "#909399";
}
export function priorityLabel(v) {
  return PRIORITY_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function priorityTag(v) {
  return PRIORITY_OPTIONS.find((x) => x.value === v)?.tag || "info";
}
export function publishStatusLabel(v) {
  return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function publishStatusTag(v) {
  return PUBLISH_STATUS_OPTIONS.find((x) => x.value === v)?.tag || "info";
}
export function readScopeLabel(v) {
  return READ_SCOPE_OPTIONS.find((x) => x.value === v)?.label || v || "—";
}
export function createEmptyForm() {
  return {
    id: "",
    noticeNo: "",
    title: "",
    noticeType: "employee",
    priority: "normal",
    contentHtml: "",
    publishDate: dayjs().format("YYYY-MM-DD"),
    expireDate: "",
    readScope: "all",
    targetDeptIds: [],
    requireReadConfirm: false,
    publishStatus: "draft",
    publisherName: "",
    publishTime: "",
    readCount: 0,
    createTime: "",
    updateTime: "",
  };
}
export function createInitialMockNotices() {
  return [
    {
      id: "notice_1",
      noticeNo: "NA202605100001",
      title: "关于台风天气居家办公的紧急通知",
      noticeType: "emergency",
      priority: "urgent",
      contentHtml:
        "<p><strong>紧急通知</strong></p><p>受台风影响,明日(5月17日)全体员工居家办公,请各部门负责人做好工作安排与员工联络。</p>",
      publishDate: "2026-05-16",
      expireDate: "2026-05-20",
      readScope: "all",
      targetDeptIds: [],
      requireReadConfirm: true,
      publishStatus: "published",
      publisherName: "行政部",
      publishTime: "2026-05-16 08:30:00",
      readCount: 128,
      createTime: "2026-05-16 08:00:00",
      updateTime: "2026-05-16 08:30:00",
    },
    {
      id: "notice_2",
      noticeNo: "NA202605120002",
      title: "2026年端午节放假安排公告",
      noticeType: "employee",
      priority: "high",
      contentHtml:
        "<p>根据国家法定节假日安排,端午节放假时间为 6月8日至6月10日,共3天。6月7日(周六)正常上班。</p>",
      publishDate: "2026-05-12",
      expireDate: "2026-06-15",
      readScope: "all",
      targetDeptIds: [],
      requireReadConfirm: false,
      publishStatus: "published",
      publisherName: "人力资源部",
      publishTime: "2026-05-12 10:00:00",
      readCount: 256,
      createTime: "2026-05-12 09:30:00",
      updateTime: "2026-05-12 10:00:00",
    },
    {
      id: "notice_3",
      noticeNo: "NA202605140003",
      title: "办公区域消防演练通知",
      noticeType: "company",
      priority: "normal",
      contentHtml: "<p>定于 5月25日 14:00 åœ¨æ€»éƒ¨å¤§æ¥¼è¿›è¡Œæ¶ˆé˜²æ¼”练,请各部门提前安排人员参加。</p>",
      publishDate: "2026-05-14",
      expireDate: "2026-05-26",
      readScope: "department",
      targetDeptIds: ["101", "102", "103"],
      requireReadConfirm: false,
      publishStatus: "draft",
      publisherName: "行政部",
      publishTime: "",
      readCount: 0,
      createTime: "2026-05-14 15:00:00",
      updateTime: "2026-05-14 15:00:00",
    },
  ];
}
export function loadStoredNotices() {
  try {
    const raw = localStorage.getItem(STORAGE_KEY);
    if (!raw) return null;
    const data = JSON.parse(raw);
    return Array.isArray(data) ? data : null;
  } catch {
    return null;
  }
}
export function saveStoredNotices(rows) {
  try {
    localStorage.setItem(STORAGE_KEY, JSON.stringify(rows));
  } catch {
    /* ignore */
  }
}
export function nextNoticeNo() {
  return `NA${dayjs().format("YYYYMMDD")}${String(Math.floor(Math.random() * 9000) + 1000)}`;
}
export function validateNoticeForm(form) {
  const title = (form.title || "").trim();
  if (!title) return { ok: false, message: "请输入公告标题" };
  if (!form.publishDate) return { ok: false, message: "请选择发布日期" };
  if (!form.noticeType) return { ok: false, message: "请选择公告类型" };
  if (form.readScope === "department" && !(form.targetDeptIds || []).length) {
    return { ok: false, message: "请选择可见部门" };
  }
  return { ok: true, title };
}
export function isExpired(row) {
  if (!row.expireDate) return false;
  return dayjs(row.expireDate).endOf("day").isBefore(dayjs());
}
src/views/officeProcessAutomation/NoticeAnnouncement/notice-manage/useNoticeAnnouncement.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,332 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { ElMessageBox } from "element-plus";
import { computed, reactive, ref, watch } from "vue";
import {
  NOTICE_TYPE_OPTIONS,
  PRIORITY_OPTIONS,
  PUBLISH_STATUS_OPTIONS,
  READ_SCOPE_OPTIONS,
  DEPT_OPTIONS,
  createEmptyForm,
  createInitialMockNotices,
  loadStoredNotices,
  saveStoredNotices,
  nextNoticeNo,
  validateNoticeForm,
  noticeTypeLabel,
  priorityLabel,
  publishStatusLabel,
  isExpired,
} from "./noticeAnnouncementUtils.js";
export function useNoticeAnnouncement() {
  const stored = loadStoredNotices();
  const allRows = ref(stored?.length ? stored : createInitialMockNotices());
  const searchForm = reactive({
    keyword: "",
    noticeType: "",
    priority: "",
    publishStatus: "",
    publishDateRange: [],
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const form = reactive(createEmptyForm());
  const formRef = ref();
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const filteredList = computed(() => {
    let list = [...allRows.value];
    const kw = (searchForm.keyword || "").trim().toLowerCase();
    if (kw) {
      list = list.filter((r) => (r.title || "").toLowerCase().includes(kw) || (r.noticeNo || "").toLowerCase().includes(kw));
    }
    if (searchForm.noticeType) list = list.filter((r) => r.noticeType === searchForm.noticeType);
    if (searchForm.priority) list = list.filter((r) => r.priority === searchForm.priority);
    if (searchForm.publishStatus) list = list.filter((r) => r.publishStatus === searchForm.publishStatus);
    const range = searchForm.publishDateRange;
    if (range?.length === 2 && range[0] && range[1]) {
      const start = dayjs(range[0]).startOf("day");
      const end = dayjs(range[1]).endOf("day");
      list = list.filter((r) => {
        if (!r.publishDate) return false;
        const t = dayjs(r.publishDate);
        return !t.isBefore(start) && !t.isAfter(end);
      });
    }
    return list.sort((a, b) => (String(a.updateTime) < String(b.updateTime) ? 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);
  });
  const formRules = {
    title: [{ required: true, message: "请输入公告标题", trigger: "blur" }],
    publishDate: [{ required: true, message: "请选择发布日期", trigger: "change" }],
    noticeType: [{ required: true, message: "请选择公告类型", trigger: "change" }],
  };
  const tableColumn = ref([
    { label: "编号", prop: "noticeNo", width: 150 },
    { label: "标题", prop: "title", minWidth: 200, showOverflowTooltip: true },
    {
      label: "类型",
      prop: "noticeType",
      width: 100,
      dataType: "slot",
      slot: "noticeType",
    },
    {
      label: "优先级",
      prop: "priority",
      width: 90,
      dataType: "tag",
      formatData: (v) => priorityLabel(v),
      formatType: (v) => {
        const hit = PRIORITY_OPTIONS.find((x) => x.value === v);
        return hit?.tag || "info";
      },
    },
    {
      label: "状态",
      prop: "publishStatus",
      width: 90,
      dataType: "tag",
      formatData: (v, row) => (isExpired(row) && v === "published" ? "已过期" : publishStatusLabel(v)),
      formatType: (v, row) => {
        if (isExpired(row) && v === "published") return "";
        const hit = PUBLISH_STATUS_OPTIONS.find((x) => x.value === v);
        return hit?.tag || "info";
      },
    },
    { label: "发布日期", prop: "publishDate", width: 120 },
    { label: "发布人", prop: "publisherName", width: 110 },
    { label: "阅读量", prop: "readCount", width: 80, align: "center" },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: [
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "修改",
          type: "text",
          disabled: (row) => row.publishStatus === "withdrawn",
          clickFun: (row) => openFormDialog("edit", row),
        },
        {
          name: "发布",
          type: "text",
          disabled: (row) => row.publishStatus === "published",
          clickFun: (row) => publishNotice(row),
        },
        {
          name: "撤回",
          type: "text",
          disabled: (row) => row.publishStatus !== "published",
          clickFun: (row) => withdrawNotice(row),
        },
        { name: "删除", type: "text", clickFun: (row) => deleteNotice(row) },
      ],
    },
  ]);
  function persist() {
    saveStoredNotices(allRows.value);
  }
  function handleQuery() {
    tableLoading.value = true;
    page.current = 1;
    setTimeout(() => {
      tableLoading.value = false;
    }, 200);
  }
  function resetSearch() {
    searchForm.keyword = "";
    searchForm.noticeType = "";
    searchForm.priority = "";
    searchForm.publishStatus = "";
    searchForm.publishDateRange = [];
    handleQuery();
  }
  function pagination({ page: p, limit }) {
    page.current = p;
    page.size = limit;
  }
  function resetForm(target = createEmptyForm()) {
    Object.assign(form, createEmptyForm(), target);
  }
  function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.readonly = mode === "view";
    formDialog.title =
      mode === "add" ? "添加公告" : mode === "edit" ? "修改公告" : "查看公告";
    if (mode === "add") {
      resetForm({ publisherName: "当前用户", priority: "normal" });
    } else {
      resetForm({
        ...JSON.parse(JSON.stringify(row)),
        targetDeptIds: [...(row.targetDeptIds || [])],
      });
    }
    formDialog.visible = true;
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function saveForm(publish = false) {
    const v = validateNoticeForm(form);
    if (!v.ok) return { ok: false, message: v.message };
    const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
    const payload = {
      ...JSON.parse(JSON.stringify(form)),
      title: v.title,
      updateTime: now,
    };
    if (form.noticeType === "emergency" && payload.priority === "normal") {
      payload.priority = "urgent";
    }
    if (formDialog.mode === "add") {
      payload.id = `notice_${Date.now()}`;
      payload.noticeNo = nextNoticeNo();
      payload.createTime = now;
      payload.readCount = 0;
      if (publish) {
        payload.publishStatus = "published";
        payload.publishTime = now;
      } else {
        payload.publishStatus = "draft";
      }
      allRows.value.unshift(payload);
    } else {
      const idx = allRows.value.findIndex((r) => r.id === form.id);
      if (idx < 0) return { ok: false, message: "记录不存在" };
      const prev = allRows.value[idx];
      if (publish) {
        payload.publishStatus = "published";
        payload.publishTime = payload.publishTime || now;
      }
      allRows.value[idx] = { ...prev, ...payload };
    }
    persist();
    formDialog.visible = false;
    return { ok: true };
  }
  async function publishNotice(row) {
    try {
      await ElMessageBox.confirm(`确认发布「${row.title}」?`, "发布公告", {
        type: "warning",
        confirmButtonText: "发布",
        cancelButtonText: "取消",
      });
      const hit = allRows.value.find((r) => r.id === row.id);
      if (!hit) return;
      const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
      hit.publishStatus = "published";
      hit.publishTime = now;
      hit.updateTime = now;
      if (hit.noticeType === "emergency") hit.priority = "urgent";
      persist();
      return true;
    } catch {
      return false;
    }
  }
  async function withdrawNotice(row) {
    try {
      await ElMessageBox.confirm(`确认撤回「${row.title}」?撤回后员工端将不再展示。`, "撤回公告", {
        type: "warning",
        confirmButtonText: "撤回",
        cancelButtonText: "取消",
      });
      const hit = allRows.value.find((r) => r.id === row.id);
      if (!hit) return;
      hit.publishStatus = "withdrawn";
      hit.updateTime = dayjs().format("YYYY-MM-DD HH:mm:ss");
      persist();
      return true;
    } catch {
      return false;
    }
  }
  async function deleteNotice(row) {
    try {
      await ElMessageBox.confirm(`确认删除「${row.title}」?此操作不可恢复。`, "删除公告", {
        type: "warning",
        confirmButtonText: "删除",
        cancelButtonText: "取消",
      });
      allRows.value = allRows.value.filter((r) => r.id !== row.id);
      persist();
      return true;
    } catch {
      return false;
    }
  }
  return {
    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,
    isExpired,
    handleQuery,
    resetSearch,
    pagination,
    openFormDialog,
    openDetail,
    saveForm,
    publishNotice,
    withdrawNotice,
    deleteNotice,
  };
}
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/components/DetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,71 @@
<!-- è´¹ç”¨æŠ¥é”€ï¼šè¯¦æƒ…只读面板 -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="报销单号">{{ row.reimburseNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销状态">
      <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="费用类型">{{ expenseCategoryLabel(row.expenseCategory) }}</el-descriptions-item>
    <el-descriptions-item label="申请时间">{{ row.applyTime || row.createTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="员工编号">{{ row.employeeNo || row.applicantNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="员工姓名">{{ row.employeeName || row.applicantName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销原因" :span="2">{{ row.reimburseReason || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销金额">{{ row.applyAmount != null ? `${row.applyAmount} å…ƒ` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="收款人">{{ row.payee || "—" }}</el-descriptions-item>
    <el-descriptions-item label="收款账号">{{ row.payeeAccount || "—" }}</el-descriptions-item>
    <el-descriptions-item label="开户支行">{{ row.bankBranch || "—" }}</el-descriptions-item>
    <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
      <span class="reject-text">{{ row.rejectReason }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="创建时间" :span="2">{{ row.createTime || "—" }}</el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">报销明细</el-divider>
  <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small">
    <el-table-column type="index" label="序号" width="55" align="center" />
    <el-table-column prop="invoiceDate" label="发票日期" width="120" />
    <el-table-column label="费用科目" width="100">
      <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template>
    </el-table-column>
    <el-table-column prop="amount" label="金额" width="100" />
    <el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
  </el-table>
  <el-empty v-else description="暂无明细" :image-size="48" />
  <el-divider content-position="left">发票附件</el-divider>
  <template v-if="attachmentFiles.length">
    <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)">
      {{ f.name }}
    </el-tag>
  </template>
  <el-empty v-else description="暂无附件" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import { expenseCategoryLabel, expenseSubjectLabel, statusLabel, statusTagType } from "../costReimburseUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const attachmentFiles = computed(() => {
  const list = props.row?.attachmentList?.length ? props.row.attachmentList : props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
function openFile(f) {
  const url = f?.url || f?.downloadURL || f?.previewURL;
  if (url) window.open(url, "_blank");
}
</script>
<style scoped>
.reject-text {
  color: var(--el-color-danger);
}
.file-tag {
  margin: 0 8px 8px 0;
  cursor: pointer;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/costReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,309 @@
import dayjs from "dayjs";
/** è´¹ç”¨æŠ¥é”€å¤§ç±» */
export const EXPENSE_CATEGORY_OPTIONS = [
  { label: "差旅", value: "travel" },
  { label: "办公采购", value: "office_procurement" },
  { label: "业务招待", value: "business_entertainment" },
  { label: "交通费", value: "transport" },
  { label: "通讯费", value: "communication" },
  { label: "其他", value: "other" },
];
/** æ˜Žç»†è´¹ç”¨ç§‘ç›® */
export const EXPENSE_SUBJECT_OPTIONS = [
  { label: "交通费", value: "transport" },
  { label: "住宿费", value: "hotel" },
  { label: "餐饮费", value: "meal" },
  { label: "办公用品", value: "office_supply" },
  { label: "招待费", value: "entertainment" },
  { label: "通讯费", value: "phone" },
  { label: "其他", value: "other" },
];
/** åˆ†ç±»å¡«æŠ¥æ¨¡æ¿ï¼ˆä¸€é”®è°ƒç”¨ï¼‰ */
export const CATEGORY_TEMPLATES = {
  travel: {
    label: "差旅费用",
    reason: "因公出差产生的交通、住宿、餐饮等费用报销。",
    details: [
      { expenseSubject: "transport", description: "往返交通费" },
      { expenseSubject: "hotel", description: "住宿费" },
      { expenseSubject: "meal", description: "出差餐饮" },
    ],
  },
  office_procurement: {
    label: "办公采购",
    reason: "部门日常办公用品、耗材采购报销。",
    details: [
      { expenseSubject: "office_supply", description: "办公用品采购" },
      { expenseSubject: "office_supply", description: "打印耗材" },
    ],
  },
  business_entertainment: {
    label: "业务招待",
    reason: "客户接待、商务宴请等费用报销。",
    details: [
      { expenseSubject: "entertainment", description: "客户接待餐费" },
      { expenseSubject: "entertainment", description: "商务礼品" },
    ],
  },
  transport: {
    label: "交通费",
    reason: "市内通勤、打车、停车等交通费用报销。",
    details: [{ expenseSubject: "transport", description: "市内交通" }],
  },
  communication: {
    label: "通讯费",
    reason: "因公通讯、流量、话费补贴报销。",
    details: [{ expenseSubject: "phone", description: "话费/流量" }],
  },
  other: {
    label: "其他费用",
    reason: "其他因公支出费用报销。",
    details: [{ expenseSubject: "other", description: "其他费用" }],
  },
};
/** å®¡æ‰¹è§’色与模拟审批人 */
export const MOCK_APPROVERS_BY_ROLE = {
  direct_supervisor: { approverId: "mock_supervisor", approverName: "直属上级" },
  dept_manager: { approverId: "mock_manager", approverName: "部门经理" },
  cfo: { approverId: "mock_cfo", approverName: "财务总监" },
  compliance: { approverId: "mock_compliance", approverName: "合规审核" },
};
/** æŒ‰é‡‘额预设审批链 */
export const APPROVAL_AMOUNT_RULES = [
  {
    maxAmount: 500,
    description: "500元以内:直属上级审批",
    roles: ["direct_supervisor"],
  },
  {
    maxAmount: 5000,
    description: "500~5000元:直属上级 + éƒ¨é—¨ç»ç†",
    roles: ["direct_supervisor", "dept_manager"],
  },
  {
    maxAmount: Infinity,
    description: "超5000元:直属上级 + éƒ¨é—¨ç»ç† + è´¢åŠ¡æ€»ç›‘å¤æ ¸",
    roles: ["direct_supervisor", "dept_manager", "cfo"],
  },
];
/** éƒ¨åˆ†å“ç±»é¢å¤–审批节点 */
export const CATEGORY_EXTRA_APPROVAL = {
  business_entertainment: ["compliance"],
  office_procurement: [],
};
export function expenseCategoryLabel(v) {
  return EXPENSE_CATEGORY_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function statusLabel(v) {
  if (v === "approved") return "已通过";
  if (v === "rejected") return "已驳回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "rejected") return "danger";
  return "warning";
}
export function formatApprovalFlowSummary(row) {
  const nodes = row?.approvalFlowNodes || [];
  if (!nodes.length) return "—";
  return nodes
    .map((n, i) => {
      const name = (n.approverName || "").trim() || `节点${i + 1}`;
      if (n.nodeStatus === "finish") return `${name}✓`;
      if (n.nodeStatus === "error") return `${name}✗`;
      if (n.nodeStatus === "process") return `${name}…`;
      return name;
    })
    .join(" â†’ ");
}
export function resolveApprovalRoles(amount, expenseCategory) {
  const amt = Number(amount) || 0;
  let roles = [];
  for (const rule of APPROVAL_AMOUNT_RULES) {
    if (amt <= rule.maxAmount) {
      roles = [...rule.roles];
      break;
    }
  }
  if (!roles.length) roles = ["direct_supervisor"];
  const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || [];
  extra.forEach((r) => {
    if (!roles.includes(r)) roles.push(r);
  });
  return roles;
}
export function buildAutoApprovalFlow(amount, expenseCategory) {
  const roles = resolveApprovalRoles(amount, expenseCategory);
  return roles.map((role, i) => {
    const mock = MOCK_APPROVERS_BY_ROLE[role] || { approverId: `mock_${role}`, approverName: role };
    return {
      approverId: mock.approverId,
      approverName: mock.approverName,
      roleKey: role,
      sortOrder: i + 1,
      nodeOrder: i + 1,
      nodeStatus: i === 0 ? "process" : "wait",
      approveOpinion: "",
      approveTime: "",
    };
  });
}
export function getApprovalRuleHint(amount, expenseCategory) {
  const amt = Number(amount) || 0;
  const rule = APPROVAL_AMOUNT_RULES.find((r) => amt <= r.maxAmount) || APPROVAL_AMOUNT_RULES[APPROVAL_AMOUNT_RULES.length - 1];
  const extra = CATEGORY_EXTRA_APPROVAL[expenseCategory] || [];
  const extraText = extra.length
    ? `;${expenseCategoryLabel(expenseCategory)}类另需:${extra.map((r) => MOCK_APPROVERS_BY_ROLE[r]?.approverName || r).join("、")}`
    : "";
  return `${rule.description}${extraText}`;
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: undefined,
    description: "",
  };
}
export function createEmptyForm() {
  return {
    id: undefined,
    reimburseNo: "",
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    expenseCategory: "",
    reimburseReason: "",
    applyAmount: undefined,
    payee: "",
    payeeAccount: "",
    bankBranch: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    approvalResult: "pending",
    rejectReason: "",
    deptId: "",
    deptName: "",
  };
}
export function applyCategoryTemplate(form, category) {
  const tpl = CATEGORY_TEMPLATES[category];
  if (!tpl) return;
  form.expenseCategory = category;
  if (!form.reimburseReason?.trim()) form.reimburseReason = tpl.reason;
  form.expenseDetails = (tpl.details || []).map((d) => ({
    ...createEmptyExpenseDetail(),
    expenseSubject: d.expenseSubject,
    description: d.description,
    invoiceDate: dayjs().format("YYYY-MM-DD"),
  }));
}
export function initApprovalFlowNodes(nodes) {
  return (nodes || []).map((n, i) => ({
    ...n,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: n.approveOpinion || "",
    approveTime: n.approveTime || "",
  }));
}
export function advanceApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  nodes[idx] = {
    ...nodes[idx],
    nodeStatus: "finish",
    approveOpinion: opinion || "同意",
    approveTime: now,
  };
  const next = idx + 1;
  if (next >= nodes.length) {
    return { nodes, currentNodeIndex: idx, approvalResult: "approved", rejectReason: "" };
  }
  nodes[next] = { ...nodes[next], nodeStatus: "process" };
  return { nodes, currentNodeIndex: next, approvalResult: "pending", rejectReason: "" };
}
export function rejectApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  const reason = (opinion || "").trim() || "驳回";
  if (nodes[idx]) {
    nodes[idx] = {
      ...nodes[idx],
      nodeStatus: "error",
      approveOpinion: reason,
      approveTime: now,
    };
  }
  return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: reason };
}
export function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const expenseDetails = Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [];
  const applyAmount = raw.applyAmount ?? expenseDetails.reduce((s, d) => s + (Number(d.amount) || 0), 0);
  const expenseCategory = raw.expenseCategory || "other";
  const approvalFlowNodes =
    Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
      ? raw.approvalFlowNodes
      : buildAutoApprovalFlow(applyAmount, expenseCategory);
  return {
    id,
    reimburseNo: raw.reimburseNo || `CR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
    employeeName: raw.employeeName ?? raw.applicantName ?? "未知",
    applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
    applicantName: raw.employeeName ?? raw.applicantName ?? "未知",
    expenseCategory,
    reimburseReason: raw.reimburseReason ?? "",
    applyAmount,
    payee: raw.payee ?? "",
    payeeAccount: raw.payeeAccount ?? "",
    bankBranch: raw.bankBranch ?? "",
    expenseDetails,
    attachmentList: Array.isArray(raw.attachmentList) ? raw.attachmentList : [],
    invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
    approvalFlowNodes,
    currentNodeIndex: raw.currentNodeIndex ?? 0,
    approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
    rejectReason: raw.rejectReason ?? "",
    approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
    applyTime: raw.applyTime || raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
    deptId: raw.deptId ?? "",
    deptName: raw.deptName ?? "",
  };
}
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,556 @@
<!--OA模块:费用报销-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div class="search_fields">
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">申请时间:</span>
        <el-date-picker
          v-model="searchForm.applyTimeFrom"
          type="date"
          placeholder="开始日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 150px"
          clearable
        />
        <span class="search_title" style="margin-left: 8px">至</span>
        <el-date-picker
          v-model="searchForm.applyTimeTo"
          type="date"
          placeholder="结束日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 150px; margin-left: 8px"
          clearable
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增费用报销</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1120px"
      append-to-body
      destroy-on-close
      class="cost-reimburse-form-dialog"
      @closed="onFormClosed"
    >
      <el-alert type="info" show-icon :closable="false" class="mb16">
        <template #title>全品类费用报销 Â· åˆ†ç±»æ¨¡æ¿ä¸€é”®å¡«æŠ¥</template>
        <template #default>
          æ”¯æŒå·®æ—…、办公采购、业务招待、交通费、通讯费等;按金额自动匹配审批链(500元内直属上级,超5000元财务总监复核)。
        </template>
      </el-alert>
      <div v-if="!formDialog.readonly" class="template-bar mb16">
        <span class="template-label">分类模板:</span>
        <el-button
          v-for="(tpl, key) in CATEGORY_TEMPLATES"
          :key="key"
          size="small"
          :type="form.expenseCategory === key ? 'primary' : 'default'"
          plain
          @click="applyTemplate(key)"
        >
          {{ tpl.label }}
        </el-button>
      </div>
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="120px"
        class="cost-reimburse-form"
        :disabled="formDialog.readonly"
      >
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">基本信息</span></template>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="员工编号">
                <el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="员工姓名" prop="applicantId">
                <el-select
                  v-model="form.applicantId"
                  filterable
                  remote
                  clearable
                  reserve-keyword
                  placeholder="请选择或搜索员工"
                  style="width: 100%"
                  :remote-method="remoteSearchApplicantForm"
                  :loading="applicantFormSearchLoading"
                  @change="onApplicantChange"
                >
                  <el-option
                    v-for="u in applicantFormOptions"
                    :key="u.userId"
                    :label="userSelectLabel(u)"
                    :value="u.userId"
                  />
                </el-select>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="费用类型" prop="expenseCategory">
                <el-select
                  v-model="form.expenseCategory"
                  placeholder="请选择费用类型"
                  style="width: 100%"
                  @change="onExpenseCategoryChange"
                >
                  <el-option
                    v-for="opt in EXPENSE_CATEGORY_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-tag
                  :type="form.approvalResult === 'approved' ? 'success' : form.approvalResult === 'rejected' ? 'danger' : 'warning'"
                  effect="plain"
                >
                  {{
                    form.approvalResult === "approved"
                      ? "已通过"
                      : form.approvalResult === "rejected"
                        ? "已驳回"
                        : "审核中"
                  }}
                </el-tag>
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="24">
              <el-form-item label="报销原因" prop="reimburseReason">
                <el-input
                  v-model="form.reimburseReason"
                  type="textarea"
                  :rows="3"
                  placeholder="请填写报销原因"
                  maxlength="2000"
                  show-word-limit
                />
              </el-form-item>
            </el-col>
          </el-row>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="报销金额" prop="applyAmount">
                <div class="amount-row">
                  <el-input-number
                    v-model="form.applyAmount"
                    :min="0"
                    :precision="2"
                    controls-position="right"
                    class="amount-input"
                    @change="autoAssignApprovalFlow"
                  />
                  <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
                    æŒ‰æ˜Žç»†æ±‡æ€» {{ detailTotalAmount }} å…ƒ
                  </el-button>
                </div>
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">报销明细</span>
              <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">
                æ–°å¢žæ˜Žç»†
              </el-button>
            </div>
          </template>
          <el-table :data="form.expenseDetails" border size="small" class="detail-table">
            <el-table-column type="index" label="序号" width="55" align="center" />
            <el-table-column label="发票日期" width="150">
              <template #default="{ row }">
                <el-date-picker
                  v-if="!formDialog.readonly"
                  v-model="row.invoiceDate"
                  type="date"
                  value-format="YYYY-MM-DD"
                  size="small"
                  style="width: 100%"
                />
                <span v-else>{{ row.invoiceDate || "—" }}</span>
              </template>
            </el-table-column>
            <el-table-column label="费用科目" width="130">
              <template #default="{ row }">
                <el-select v-if="!formDialog.readonly" v-model="row.expenseSubject" size="small" style="width: 100%">
                  <el-option
                    v-for="opt in EXPENSE_SUBJECT_OPTIONS"
                    :key="opt.value"
                    :label="opt.label"
                    :value="opt.value"
                  />
                </el-select>
                <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
              </template>
            </el-table-column>
            <el-table-column label="金额" width="120">
              <template #default="{ row }">
                <el-input-number
                  v-if="!formDialog.readonly"
                  v-model="row.amount"
                  :min="0"
                  :precision="2"
                  size="small"
                  controls-position="right"
                  style="width: 100%"
                  @change="onDetailAmountChange"
                />
                <span v-else>{{ row.amount ?? "—" }}</span>
              </template>
            </el-table-column>
            <el-table-column label="描述" min-width="140">
              <template #default="{ row }">
                <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
                <span v-else>{{ row.description || "—" }}</span>
              </template>
            </el-table-column>
            <el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
              <template #default="{ $index }">
                <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
              </template>
            </el-table-column>
          </el-table>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">收款信息</span></template>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="收款人" prop="payee">
                <el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="收款账号" prop="payeeAccount">
                <el-input v-model="form.payeeAccount" placeholder="银行卡号" maxlength="30" />
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="开户支行" prop="bankBranch">
                <el-input v-model="form.bankBranch" placeholder="开户支行全称" maxlength="100" />
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">附件(发票)</span></template>
          <el-form-item label-width="0" class="attachment-form-item">
            <div class="upload-block">
              <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
            </div>
          </el-form-item>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">审批流程</span>
              <el-button v-if="!formDialog.readonly" type="primary" link size="small" @click="autoAssignApprovalFlow">
                æŒ‰è§„则重新分配
              </el-button>
            </div>
          </template>
          <el-alert type="success" :title="approvalRuleHint" show-icon :closable="false" class="mb12" />
          <el-form-item prop="approvalFlowNodes" label-width="0">
            <ApprovalFlowEditor
              v-if="!formDialog.readonly"
              v-model="form.approvalFlowNodes"
              :user-options="flowUserOptions"
              @update:model-value="onApprovalFlowChange"
            />
            <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
            <p v-if="!formDialog.readonly" class="flow-tip">系统已按金额与费用类型自动分配审批人,可手动调整。</p>
          </el-form-item>
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @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>
      <DetailPanel :row="detailRow" />
      <el-divider content-position="left">审批流程</el-divider>
      <ApprovalFlowProgress :nodes="detailRow.approvalFlowNodes" :current-index="detailRow.currentNodeIndex ?? 0" />
      <el-divider content-position="left">审批记录</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="费用报销审批"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <DetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见" required>
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写具体原因(如:发票模糊需重传)"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="submitApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="submitApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "../travel-reimburse/components/ApprovalFlowProgress.vue";
import DetailPanel from "./components/DetailPanel.vue";
import { useCostReimburse } from "./useCostReimburse.js";
const cr = useCostReimburse();
const {
  Search,
  EXPENSE_CATEGORY_OPTIONS,
  CATEGORY_TEMPLATES,
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  importInputRef,
  formRef,
  form,
  formDialog,
  formRules,
  detailDialog,
  detailRow,
  approveDialog,
  approveOpinion,
  applicantFormSearchLoading,
  applicantFormOptions,
  flowUserOptions,
  detailTotalAmount,
  approvalRuleHint,
  handleQuery,
  resetSearch,
  pagination,
  remoteSearchApplicantForm,
  userSelectLabel,
  onApplicantChange,
  onExpenseCategoryChange,
  applyTemplate,
  onDetailAmountChange,
  onApprovalFlowChange,
  addExpenseDetail,
  removeExpenseDetail,
  syncApplyAmountFromDetails,
  autoAssignApprovalFlow,
  openFormDialog,
  onFormClosed,
  submitForm,
  approvalActionLabel,
  submitApprove,
  handleExport,
  handleImportClick,
  onImportFile,
} = cr;
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.mb12 {
  margin-bottom: 12px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_fields {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 4px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.template-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}
.template-label {
  font-size: 14px;
  color: var(--el-text-color-secondary);
  flex-shrink: 0;
}
.form-section {
  margin-bottom: 16px;
  border: 1px solid var(--el-border-color-lighter);
}
.form-section :deep(.el-card__header) {
  padding: 12px 16px;
  background: var(--el-fill-color-lighter);
}
.form-section :deep(.el-card__body) {
  padding: 16px 16px 4px;
}
.card-header-title {
  font-size: 15px;
  font-weight: 600;
}
.card-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.amount-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
}
.amount-input {
  flex: 1;
  min-width: 160px;
}
.attachment-form-item {
  margin-bottom: 0;
}
.detail-table {
  margin-bottom: 0;
}
.upload-block {
  width: 100%;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.cost-reimburse-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.cost-reimburse-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.cost-reimburse-form :deep(.el-input-number) {
  width: 100%;
}
.cost-reimburse-form :deep(.el-row) {
  margin-bottom: 0;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/cost-reimburse/useCostReimburse.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,662 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import {
  EXPENSE_CATEGORY_OPTIONS,
  CATEGORY_TEMPLATES,
  EXPENSE_SUBJECT_OPTIONS,
  expenseCategoryLabel,
  expenseSubjectLabel,
  statusLabel,
  statusTagType,
  formatApprovalFlowSummary,
  buildAutoApprovalFlow,
  getApprovalRuleHint,
  createEmptyExpenseDetail,
  createEmptyForm,
  applyCategoryTemplate,
  initApprovalFlowNodes,
  advanceApprovalFlow,
  rejectApprovalFlow,
  normalizeImportedRow,
} from "./costReimburseUtils.js";
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function demoFlowNodes(amount = 1200, category = "transport") {
  return buildAutoApprovalFlow(amount, category);
}
export function useCostReimburse() {
  const { proxy } = getCurrentInstance();
  const allRows = ref([
    {
      id: "1",
      reimburseNo: "CR202605100001",
      applicantId: "mock_1",
      employeeNo: "zhangsan",
      employeeName: "张三",
      applicantNo: "zhangsan",
      applicantName: "张三",
      expenseCategory: "office_procurement",
      reimburseReason: "采购打印机硒鼓、A4纸等办公耗材。",
      applyAmount: 680,
      payee: "张三",
      payeeAccount: "6222 **** **** 1234",
      bankBranch: "中国工商银行杭州西湖支行",
      expenseDetails: [
        { id: "d1", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 380, description: "A4复印纸" },
        { id: "d2", invoiceDate: "2026-05-08", expenseSubject: "office_supply", amount: 300, description: "硒鼓" },
      ],
      attachmentList: [{ name: "采购发票.pdf", url: "/mock/invoice1.pdf" }],
      approvalFlowNodes: demoFlowNodes(680, "office_procurement"),
      currentNodeIndex: 0,
      approvalResult: "pending",
      rejectReason: "",
      approvalRecords: [],
      applyTime: "2026-05-10 09:15:00",
      createTime: "2026-05-10 09:15:00",
      deptId: "101",
      deptName: "行政部",
    },
    {
      id: "2",
      reimburseNo: "CR202605080002",
      applicantId: "mock_2",
      employeeNo: "lisi",
      employeeName: "李四",
      applicantNo: "lisi",
      applicantName: "李四",
      expenseCategory: "business_entertainment",
      reimburseReason: "接待重点客户商务宴请。",
      applyAmount: 3200,
      payee: "李四",
      payeeAccount: "6217 **** **** 5678",
      bankBranch: "招商银行武汉光谷支行",
      expenseDetails: [
        { id: "d3", invoiceDate: "2026-05-06", expenseSubject: "entertainment", amount: 3200, description: "客户宴请" },
      ],
      attachmentList: [],
      approvalFlowNodes: demoFlowNodes(3200, "business_entertainment").map((n, i) => ({
        ...n,
        nodeStatus: i === 0 ? "error" : "wait",
        approveOpinion: i === 0 ? "发票模糊需重传" : "",
        approveTime: i === 0 ? "2026-05-09 14:20:00" : "",
      })),
      currentNodeIndex: 0,
      approvalResult: "rejected",
      rejectReason: "发票模糊需重传",
      approvalRecords: [
        { operatorName: "直属上级", result: "rejected", opinion: "发票模糊需重传", time: "2026-05-09 14:20:00" },
      ],
      applyTime: "2026-05-07 16:30:00",
      createTime: "2026-05-07 16:30:00",
      deptId: "102",
      deptName: "销售部",
    },
    {
      id: "3",
      reimburseNo: "CR202605050003",
      applicantId: "mock_3",
      employeeNo: "wangwu",
      employeeName: "王五",
      applicantNo: "wangwu",
      applicantName: "王五",
      expenseCategory: "communication",
      reimburseReason: "5月因公话费报销。",
      applyAmount: 198,
      payee: "王五",
      payeeAccount: "6228 **** **** 9012",
      bankBranch: "中国建设银行成都高新支行",
      expenseDetails: [
        { id: "d4", invoiceDate: "2026-05-05", expenseSubject: "phone", amount: 198, description: "话费账单" },
      ],
      attachmentList: [{ name: "话费账单.jpg", url: "/mock/phone.jpg" }],
      approvalFlowNodes: demoFlowNodes(198, "communication").map((n) => ({
        ...n,
        nodeStatus: "finish",
        approveOpinion: "同意",
        approveTime: "2026-05-06 10:00:00",
      })),
      currentNodeIndex: 0,
      approvalResult: "approved",
      rejectReason: "",
      approvalRecords: [{ operatorName: "直属上级", result: "approved", opinion: "同意", time: "2026-05-06 10:00:00" }],
      applyTime: "2026-05-05 11:00:00",
      createTime: "2026-05-05 11:00:00",
      deptId: "103",
      deptName: "技术部",
    },
  ]);
  const searchForm = reactive({
    applicantKeyword: "",
    applyTimeFrom: "",
    applyTimeTo: "",
  });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const importInputRef = ref(null);
  const allUsersCache = ref([]);
  const applicantFormSearchLoading = ref(false);
  const applicantFormOptions = ref([]);
  const formRef = ref();
  const form = reactive(createEmptyForm());
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  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) => ({
      ...r,
      approvalFlowSummary: formatApprovalFlowSummary(r),
    }));
  });
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
  const detailTotalAmount = computed(() => {
    const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
    return Math.round(sum * 100) / 100;
  });
  const approvalRuleHint = computed(() =>
    getApprovalRuleHint(form.applyAmount ?? detailTotalAmount.value, form.expenseCategory)
  );
  const tableColumn = ref([
    { label: "报销单号", prop: "reimburseNo", width: 150 },
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人", prop: "applicantName", minWidth: 90 },
    { label: "报销金额(元)", prop: "applyAmount", width: 110 },
    { label: "报销原因", prop: "reimburseReason", minWidth: 160, showOverflowTooltip: true },
    { label: "申请时间", prop: "applyTime", width: 165 },
    { label: "创建时间", prop: "createTime", width: 165 },
    {
      label: "报销状态",
      prop: "approvalResult",
      width: 100,
      dataType: "tag",
      formatData: (v) => statusLabel(v),
      formatType: (v) => statusTagType(v),
    },
    {
      label: "审批流程",
      prop: "approvalFlowSummary",
      minWidth: 200,
      showOverflowTooltip: true,
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 220,
      operation: [
        {
          name: "编辑",
          type: "text",
          disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved",
          clickFun: (row) => openFormDialog("edit", row),
        },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        {
          name: "审批",
          type: "text",
          disabled: (row) => row.approvalResult !== "pending",
          clickFun: (row) => openApprove(row),
        },
      ],
    },
  ]);
  const formRules = {
    applicantId: [{ required: true, message: "请选择员工", trigger: "change" }],
    expenseCategory: [{ required: true, message: "请选择费用类型", trigger: "change" }],
    reimburseReason: [{ required: true, message: "请填写报销原因", trigger: "blur" }],
    applyAmount: [{ required: true, message: "请填写报销金额", trigger: "blur" }],
    payee: [{ required: true, message: "请填写收款人", trigger: "blur" }],
    payeeAccount: [{ required: true, message: "请填写收款账号", trigger: "blur" }],
    bankBranch: [{ required: true, message: "请填写开户支行", trigger: "blur" }],
    approvalFlowNodes: [
      {
        validator: (_r, _v, cb) => {
          const nodes = form.approvalFlowNodes || [];
          if (!nodes.length) {
            cb(new Error("请至少配置一个审批节点"));
            return;
          }
          if (nodes.some((n) => n.approverId == null || n.approverId === "")) {
            cb(new Error("每个节点须选择审批人"));
            return;
          }
          cb();
        },
        trigger: "change",
      },
    ],
  };
  async function loadUserPool() {
    try {
      allUsersCache.value = unwrapArray(await userListNoPageByTenantId());
    } catch {
      allUsersCache.value = [];
    }
  }
  function userSelectLabel(u) {
    const nick = u.nickName || "";
    const name = u.userName || "";
    if (nick && name && nick !== name) return `${nick}(${name})`;
    return nick || name || `用户${u.userId ?? u.id ?? ""}`;
  }
  function userById(id) {
    return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
  }
  function employeeNoFromUser(u) {
    if (!u) return "";
    return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : "");
  }
  function filterUsersByQuery(query) {
    const list = allUsersCache.value.filter(isActiveUser);
    const q = (query || "").trim().toLowerCase();
    if (!q) return [...list];
    return list.filter((u) => {
      const nick = (u.nickName || "").toLowerCase();
      const uname = (u.userName || "").toLowerCase();
      return nick.includes(q) || uname.includes(q);
    });
  }
  async function remoteSearchApplicantForm(query) {
    applicantFormSearchLoading.value = true;
    try {
      if (!allUsersCache.value.length) await loadUserPool();
      applicantFormOptions.value = filterUsersByQuery(query);
    } finally {
      applicantFormSearchLoading.value = false;
    }
  }
  function onApplicantChange(uid) {
    const u = userById(uid);
    if (u) {
      form.employeeName = u.nickName || u.userName || "";
      form.employeeNo = employeeNoFromUser(u);
      form.payee = form.payee || form.employeeName;
      form.deptId = String(u.deptId ?? u.sysDeptId ?? "");
      form.deptName = u.dept?.deptName ?? u.deptName ?? "";
    } else {
      form.employeeName = "";
      form.employeeNo = "";
    }
  }
  function autoAssignApprovalFlow() {
    const amount = Number(form.applyAmount) || detailTotalAmount.value || 0;
    form.approvalFlowNodes = buildAutoApprovalFlow(amount, form.expenseCategory || "other");
    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
  }
  function onExpenseCategoryChange(val) {
    if (val && !(form.expenseDetails || []).length) {
      applyCategoryTemplate(form, val);
      syncApplyAmountFromDetails();
    }
    autoAssignApprovalFlow();
  }
  function applyTemplate(category) {
    applyCategoryTemplate(form, category);
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
    proxy?.$modal?.msgSuccess?.(`已应用「${CATEGORY_TEMPLATES[category]?.label || category}」填报模板`);
  }
  function onDetailAmountChange() {
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
  }
  function onApprovalFlowChange() {
    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
  }
  function addExpenseDetail() {
    form.expenseDetails.push(createEmptyExpenseDetail());
  }
  function removeExpenseDetail(index) {
    form.expenseDetails.splice(index, 1);
    syncApplyAmountFromDetails();
    autoAssignApprovalFlow();
  }
  function syncApplyAmountFromDetails() {
    form.applyAmount = detailTotalAmount.value;
  }
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => {
      tableLoading.value = false;
    }, 150);
  }
  function resetSearch() {
    searchForm.applicantKeyword = "";
    searchForm.applyTimeFrom = "";
    searchForm.applyTimeTo = "";
    handleQuery();
  }
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openApprove(row) {
    approveDialog.row = { ...row };
    approveDialog.visible = true;
  }
  function approvalActionLabel(v) {
    if (v === "approved") return "通过";
    if (v === "rejected") return "驳回";
    return "提交";
  }
  async function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.readonly = false;
    formDialog.title = mode === "add" ? "新增费用报销" : "编辑费用报销";
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      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 || [])),
      });
      const u = userById(row.applicantId);
      applicantFormOptions.value = u
        ? [u]
        : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
    } else {
      form.approvalFlowNodes = buildAutoApprovalFlow(0, "other");
      remoteSearchApplicantForm("");
    }
    formDialog.visible = true;
    nextTick(() => {
      formRef.value?.clearValidate?.();
    });
  }
  function onFormClosed() {
    formRef.value?.resetFields?.();
  }
  async function submitForm() {
    try {
      await formRef.value?.validate?.();
    } catch {
      return;
    }
    if (!(form.expenseDetails || []).length) {
      proxy?.$modal?.msgWarning?.("请至少添加一条报销明细");
      return;
    }
    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?.("保存成功(本地模拟)");
    }
    formDialog.visible = false;
    handleQuery();
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row) return;
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      proxy?.$modal?.msgWarning?.("驳回须填写审批意见(如:发票模糊需重传)");
      return;
    }
    const idx = allRows.value.findIndex((r) => r.id === row.id);
    if (idx === -1) return;
    const cur = allRows.value[idx];
    const operatorName = "当前审批人";
    const record = {
      operatorName,
      result,
      opinion: approveOpinion.value || (result === "approved" ? "同意" : "驳回"),
      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    };
    const records = [...(cur.approvalRecords || []), record];
    let flowUpdate;
    if (result === "approved") {
      flowUpdate = advanceApprovalFlow(cur, approveOpinion.value);
    } else {
      flowUpdate = rejectApprovalFlow(cur, approveOpinion.value);
    }
    allRows.value[idx] = {
      ...cur,
      approvalFlowNodes: flowUpdate.nodes,
      currentNodeIndex: flowUpdate.currentNodeIndex,
      approvalResult: flowUpdate.approvalResult,
      rejectReason: flowUpdate.rejectReason ?? cur.rejectReason,
      approvalRecords: records,
    };
    proxy?.$modal?.msgSuccess?.(result === "approved" ? "已通过" : "已驳回");
    approveDialog.visible = false;
    handleQuery();
  }
  function handleExport() {
    const data = filteredList.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");
    a.href = url;
    a.download = `费用报销导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
    a.click();
    URL.revokeObjectURL(url);
    proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡`);
  }
  function handleImportClick() {
    importInputRef.value?.click?.();
  }
  function onImportFile(e) {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const parsed = JSON.parse(String(reader.result || ""));
        const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
        if (!Array.isArray(arr) || !arr.length) {
          proxy?.$modal?.msgWarning?.("导入格式须为费用报销 JSON æ•°ç»„");
          return;
        }
        arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i)));
        proxy?.$modal?.msgSuccess?.(`成功导入 ${arr.length} æ¡`);
        handleQuery();
      } catch {
        proxy?.$modal?.msgError?.("解析失败");
      }
    };
    reader.readAsText(file, "utf-8");
  }
  onMounted(() => loadUserPool());
  return {
    Search,
    EXPENSE_CATEGORY_OPTIONS,
    CATEGORY_TEMPLATES,
    EXPENSE_SUBJECT_OPTIONS,
    expenseCategoryLabel,
    expenseSubjectLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    importInputRef,
    formRef,
    form,
    formDialog,
    formRules,
    detailDialog,
    detailRow,
    approveDialog,
    approveOpinion,
    applicantFormSearchLoading,
    applicantFormOptions,
    flowUserOptions,
    detailTotalAmount,
    approvalRuleHint,
    handleQuery,
    resetSearch,
    pagination,
    remoteSearchApplicantForm,
    userSelectLabel,
    onApplicantChange,
    onExpenseCategoryChange,
    applyTemplate,
    onDetailAmountChange,
    onApprovalFlowChange,
    addExpenseDetail,
    removeExpenseDetail,
    syncApplyAmountFromDetails,
    autoAssignApprovalFlow,
    openFormDialog,
    onFormClosed,
    submitForm,
    openDetail,
    approvalActionLabel,
    submitApprove,
    handleExport,
    handleImportClick,
    onImportFile,
  };
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/ApprovalFlowProgress.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,49 @@
<!-- å·®æ—…报销:审批流程进度展示 -->
<template>
  <el-steps :active="activeStep" finish-status="success" align-center>
    <el-step
      v-for="(node, index) in sortedNodes"
      :key="index"
      :title="`节点 ${index + 1}`"
      :description="stepDescription(node)"
      :status="stepStatus(node, index)"
    />
  </el-steps>
</template>
<script setup>
import { computed } from "vue";
const props = defineProps({
  nodes: { type: Array, default: () => [] },
  currentIndex: { type: Number, default: 0 },
});
const sortedNodes = computed(() => {
  const list = props.nodes || [];
  return [...list].sort((a, b) => (a.sortOrder ?? a.nodeOrder ?? 0) - (b.sortOrder ?? b.nodeOrder ?? 0));
});
const activeStep = computed(() => {
  const list = sortedNodes.value;
  if (!list.length) return 0;
  const finished = list.filter((n) => n.nodeStatus === "finish").length;
  const hasError = list.some((n) => n.nodeStatus === "error");
  if (hasError) return Math.max(0, props.currentIndex);
  return finished;
});
function stepDescription(node) {
  const name = (node.approverName || "").trim() || "未指定";
  const opinion = (node.approveOpinion || "").trim();
  if (opinion) return `${name}:${opinion}`;
  return name;
}
function stepStatus(node, index) {
  if (node.nodeStatus === "error") return "error";
  if (node.nodeStatus === "finish") return "success";
  if (node.nodeStatus === "process" || index === props.currentIndex) return "process";
  return "wait";
}
</script>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/components/DetailPanel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,81 @@
<!-- å·®æ—…报销:详情只读面板 -->
<template>
  <el-descriptions :column="2" border>
    <el-descriptions-item label="报销单号">{{ row.reimburseNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="状态">
      <el-tag :type="statusTagType(row.approvalResult)" size="small">{{ statusLabel(row.approvalResult) }}</el-tag>
    </el-descriptions-item>
    <el-descriptions-item label="员工编号">{{ row.employeeNo || row.applicantNo || "—" }}</el-descriptions-item>
    <el-descriptions-item label="员工姓名">{{ row.employeeName || row.applicantName || "—" }}</el-descriptions-item>
    <el-descriptions-item label="报销原因" :span="2">{{ row.reimburseReason || "—" }}</el-descriptions-item>
    <el-descriptions-item label="出差开始">{{ row.travelStartTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="出差结束">{{ row.travelEndTime || "—" }}</el-descriptions-item>
    <el-descriptions-item label="出差地">{{ row.departurePlace || "—" }}</el-descriptions-item>
    <el-descriptions-item label="目的地">{{ row.destination || "—" }}</el-descriptions-item>
    <el-descriptions-item label="酒店标准">{{ row.hotelStandard != null ? `${row.hotelStandard} å…ƒ/晚` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="住宿天数">{{ row.hotelDays ?? "—" }}</el-descriptions-item>
    <el-descriptions-item label="生活补贴">{{ row.livingSubsidy != null ? `${row.livingSubsidy} å…ƒ` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="申请金额">{{ row.applyAmount != null ? `${row.applyAmount} å…ƒ` : "—" }}</el-descriptions-item>
    <el-descriptions-item label="收款人">{{ row.payee || "—" }}</el-descriptions-item>
    <el-descriptions-item label="特批">
      <el-tag :type="row.needSpecialApproval ? 'danger' : 'info'" size="small">
        {{ row.needSpecialApproval ? "超支需特批" : "标准内" }}
      </el-tag>
    </el-descriptions-item>
    <el-descriptions-item v-if="row.rejectReason" label="驳回原因" :span="2">
      <span class="reject-text">{{ row.rejectReason }}</span>
    </el-descriptions-item>
    <el-descriptions-item label="创建时间" :span="2">{{ row.createTime || "—" }}</el-descriptions-item>
  </el-descriptions>
  <el-divider content-position="left">报销明细</el-divider>
  <el-table v-if="row.expenseDetails?.length" :data="row.expenseDetails" border size="small">
    <el-table-column type="index" label="序号" width="55" align="center" />
    <el-table-column prop="invoiceDate" label="发票日期" width="120" />
    <el-table-column label="费用科目" width="100">
      <template #default="{ row: d }">{{ expenseSubjectLabel(d.expenseSubject) }}</template>
    </el-table-column>
    <el-table-column prop="amount" label="金额" width="100" />
    <el-table-column prop="description" label="描述" min-width="140" show-overflow-tooltip />
  </el-table>
  <el-empty v-else description="暂无明细" :image-size="48" />
  <el-divider content-position="left">发票附件</el-divider>
  <template v-if="attachmentFiles.length">
    <el-tag v-for="(f, i) in attachmentFiles" :key="i" class="file-tag" type="info" @click="openFile(f)">
      {{ f.name }}
    </el-tag>
  </template>
  <el-empty v-else description="暂无附件" :image-size="48" />
</template>
<script setup>
import { computed } from "vue";
import { expenseSubjectLabel, statusLabel, statusTagType } from "../travelReimburseUtils.js";
const props = defineProps({
  row: { type: Object, default: () => ({}) },
});
const attachmentFiles = computed(() => {
  const list = props.row?.attachmentList?.length
    ? props.row.attachmentList
    : props.row?.invoiceAttachments;
  return Array.isArray(list) ? list : [];
});
function openFile(f) {
  const url = f?.url || f?.downloadURL || f?.previewURL;
  if (url) window.open(url, "_blank");
}
</script>
<style scoped>
.reject-text {
  color: var(--el-color-danger);
}
.file-tag {
  margin: 0 8px 8px 0;
  cursor: pointer;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,623 @@
<!--OA模块:差旅报销-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
      <div>
        <span class="search_title">申请人:</span>
        <el-input
          v-model="searchForm.applicantKeyword"
          style="width: 220px"
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
        />
        <span class="search_title" style="margin-left: 12px">出差开始:</span>
        <el-date-picker
          v-model="searchForm.travelStartFrom"
          type="date"
          placeholder="开始日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 150px"
          clearable
        />
        <span class="search_title" style="margin-left: 8px">结束:</span>
        <el-date-picker
          v-model="searchForm.travelEndTo"
          type="date"
          placeholder="结束日期"
          format="YYYY-MM-DD"
          value-format="YYYY-MM-DD"
          style="width: 150px"
          clearable
        />
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增差旅报销</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
        rowKey="id"
        :column="tableColumn"
        :tableData="tableData"
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        :total="page.total"
        @pagination="pagination"
      />
    </div>
    <!-- æ–°å¢ž / ç¼–辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="1120px"
      append-to-body
      destroy-on-close
      class="travel-reimburse-form-dialog"
      @closed="onFormClosed"
    >
      <el-alert
        v-if="budgetHint.visible"
        :title="budgetHint.title"
        :type="budgetHint.type"
        :description="budgetHint.description"
        show-icon
        :closable="false"
        class="mb16"
      />
      <el-alert v-if="overBudgetWarnings.length" type="warning" show-icon :closable="false" class="mb16">
        <template #title>差旅标准超支提醒(需特批)</template>
        <ul class="warn-list">
          <li v-for="(w, i) in overBudgetWarnings" :key="i">{{ w }}</li>
        </ul>
      </el-alert>
      <el-form
        ref="formRef"
        :model="form"
        :rules="formRules"
        label-width="120px"
        class="travel-reimburse-form"
        :disabled="formDialog.readonly"
      >
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">基本信息</span></template>
          <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="员工编号">
              <el-input v-model="form.employeeNo" readonly placeholder="选择员工后自动带出" />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="员工姓名" prop="applicantId">
              <el-select
                v-model="form.applicantId"
                filterable
                remote
                clearable
                reserve-keyword
                placeholder="请选择或搜索员工"
                style="width: 100%"
                :remote-method="remoteSearchApplicantForm"
                :loading="applicantFormSearchLoading"
                @change="onApplicantChange"
              >
                <el-option
                  v-for="u in applicantFormOptions"
                  :key="u.userId"
                  :label="userSelectLabel(u)"
                  :value="u.userId"
                />
              </el-select>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="24">
            <el-form-item label="报销原因" prop="reimburseReason">
              <el-input
                v-model="form.reimburseReason"
                type="textarea"
                :rows="3"
                placeholder="请填写出差及报销原因"
                maxlength="2000"
                show-word-limit
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="12">
            <el-form-item label="出差开始" prop="travelStartTime">
              <el-date-picker
                v-model="form.travelStartTime"
                type="datetime"
                placeholder="开始时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onTravelRangeChange"
              />
            </el-form-item>
          </el-col>
          <el-col :span="12">
            <el-form-item label="出差结束" prop="travelEndTime">
              <el-date-picker
                v-model="form.travelEndTime"
                type="datetime"
                placeholder="结束时间"
                format="YYYY-MM-DD HH:mm:ss"
                value-format="YYYY-MM-DD HH:mm:ss"
                style="width: 100%"
                @change="onTravelRangeChange"
              />
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="20">
          <el-col :span="8">
            <el-form-item label="出差天数">
              <el-input :model-value="travelDaysDisplay" readonly>
                <template #append>天</template>
              </el-input>
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="出差地" prop="departurePlace">
              <el-input v-model="form.departurePlace" placeholder="出发城市" @blur="recalcTravelStandards" />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="目的地" prop="destination">
              <el-input v-model="form.destination" placeholder="目的城市" @blur="recalcTravelStandards" />
            </el-form-item>
          </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">差旅标准</span>
              <el-text type="info" size="small">{{ travelTierLabel }} Â· ç”Ÿæ´»è¡¥è´´å»ºè®® {{ suggestedLivingSubsidy }} å…ƒ</el-text>
            </div>
          </template>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="酒店标准">
              <el-input-number
                v-model="form.hotelStandard"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="住宿天数">
              <el-input-number
                v-model="form.hotelDays"
                :min="0"
                :max="365"
                :precision="0"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
          <el-col :span="8">
            <el-form-item label="生活补贴">
              <el-input-number
                v-model="form.livingSubsidy"
                :min="0"
                :precision="2"
                controls-position="right"
                style="width: 100%"
                @change="recalcTravelStandards"
              />
            </el-form-item>
          </el-col>
        </el-row>
          <el-row :gutter="20">
            <el-col :span="8">
              <el-form-item label="交通补贴">
                <el-input :model-value="String(suggestedTransportSubsidy)" readonly><template #append>元</template></el-input>
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="住宿限额">
                <el-input :model-value="String(suggestedHotelLimit)" readonly><template #append>元</template></el-input>
              </el-form-item>
            </el-col>
            <el-col :span="8">
              <el-form-item label="特批标记">
                <el-tag :type="form.needSpecialApproval ? 'danger' : 'success'" effect="plain">
                  {{ form.needSpecialApproval ? "超支需特批" : "在标准范围内" }}
                </el-tag>
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">金额与收款</span></template>
          <el-row :gutter="20">
            <el-col :span="12">
              <el-form-item label="申请金额" prop="applyAmount">
                <div class="amount-row">
                  <el-input-number v-model="form.applyAmount" :min="0" :precision="2" controls-position="right" class="amount-input" />
                  <el-button v-if="!formDialog.readonly" type="primary" link @click="syncApplyAmountFromDetails">
                    æŒ‰æ˜Žç»†æ±‡æ€» {{ detailTotalAmount }} å…ƒ
                  </el-button>
                </div>
              </el-form-item>
            </el-col>
            <el-col :span="12">
              <el-form-item label="收款人" prop="payee">
                <el-input v-model="form.payee" placeholder="请输入收款人" maxlength="50" />
              </el-form-item>
            </el-col>
          </el-row>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header>
            <div class="card-header-row">
              <span class="card-header-title">报销明细</span>
              <el-button v-if="!formDialog.readonly" type="primary" plain size="small" @click="addExpenseDetail">新增明细</el-button>
            </div>
          </template>
        <el-table :data="form.expenseDetails" border size="small" class="detail-table">
          <el-table-column type="index" label="序号" width="55" align="center" />
          <el-table-column label="发票日期" width="150">
            <template #default="{ row }">
              <el-date-picker
                v-if="!formDialog.readonly"
                v-model="row.invoiceDate"
                type="date"
                value-format="YYYY-MM-DD"
                size="small"
                style="width: 100%"
              />
              <span v-else>{{ row.invoiceDate || "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column label="费用科目" width="130">
            <template #default="{ row }">
              <el-select
                v-if="!formDialog.readonly"
                v-model="row.expenseSubject"
                size="small"
                style="width: 100%"
                @change="recalcTravelStandards"
              >
                <el-option
                  v-for="opt in EXPENSE_SUBJECT_OPTIONS"
                  :key="opt.value"
                  :label="opt.label"
                  :value="opt.value"
                />
              </el-select>
              <span v-else>{{ expenseSubjectLabel(row.expenseSubject) }}</span>
            </template>
          </el-table-column>
          <el-table-column label="金额" width="120">
            <template #default="{ row }">
              <el-input-number
                v-if="!formDialog.readonly"
                v-model="row.amount"
                :min="0"
                :precision="2"
                size="small"
                controls-position="right"
                style="width: 100%"
                @change="onDetailAmountChange"
              />
              <span v-else>{{ row.amount ?? "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column label="描述" min-width="140">
            <template #default="{ row }">
              <el-input v-if="!formDialog.readonly" v-model="row.description" size="small" placeholder="说明" />
              <span v-else>{{ row.description || "—" }}</span>
            </template>
          </el-table-column>
          <el-table-column v-if="!formDialog.readonly" label="操作" width="70" align="center">
            <template #default="{ $index }">
              <el-button type="danger" link size="small" @click="removeExpenseDetail($index)">删除</el-button>
            </template>
          </el-table-column>
        </el-table>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">附件(发票)</span></template>
          <el-form-item label-width="0" class="attachment-form-item">
            <div class="upload-block">
              <FileUpload v-model:file-list="form.attachmentList" :limit="20" button-text="点击选择文件" />
            </div>
          </el-form-item>
        </el-card>
        <el-card class="form-section" shadow="never">
          <template #header><span class="card-header-title">审批流程</span></template>
          <el-form-item prop="approvalFlowNodes" label-width="0">
          <ApprovalFlowEditor
            v-if="!formDialog.readonly"
            v-model="form.approvalFlowNodes"
            :user-options="flowUserOptions"
            @update:model-value="onApprovalFlowChange"
          />
          <ApprovalFlowProgress v-else :nodes="form.approvalFlowNodes" :current-index="form.currentNodeIndex" />
          <p v-if="!formDialog.readonly" class="flow-tip">至少保留一个节点;审核中、已通过的单据不可编辑。</p>
        </el-form-item>
        </el-card>
      </el-form>
      <template #footer>
        <el-button v-if="!formDialog.readonly" type="primary" @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>
      <DetailPanel :row="detailRow" />
      <ApprovalFlowProgress
        class="mt16"
        :nodes="detailRow.approvalFlowNodes"
        :current-index="detailRow.currentNodeIndex ?? 0"
      />
      <el-divider content-position="left">审批记录(全流程留痕)</el-divider>
      <el-timeline v-if="detailRow.approvalRecords?.length">
        <el-timeline-item
          v-for="(rec, i) in detailRow.approvalRecords"
          :key="i"
          :type="rec.result === 'approved' ? 'success' : rec.result === 'rejected' ? 'danger' : 'primary'"
          :timestamp="rec.time"
        >
          {{ rec.operatorName }} â€” {{ approvalActionLabel(rec.result) }}:{{ rec.opinion || "无意见" }}
        </el-timeline-item>
      </el-timeline>
      <el-empty v-else description="暂无审批记录" :image-size="60" />
      <template #footer>
        <el-button type="primary" @click="detailDialog.visible = false">关 é—­</el-button>
      </template>
    </el-dialog>
    <!-- å®¡æ‰¹ -->
    <el-dialog
      v-model="approveDialog.visible"
      title="差旅报销审批"
      width="1000px"
      append-to-body
      destroy-on-close
      @closed="approveOpinion = ''"
    >
      <DetailPanel :row="approveDialog.row" />
      <el-divider content-position="left">流程进度</el-divider>
      <ApprovalFlowProgress
        :nodes="approveDialog.row?.approvalFlowNodes"
        :current-index="approveDialog.row?.currentNodeIndex ?? 0"
      />
      <el-form label-width="100px" class="mt16">
        <el-form-item label="审批意见">
          <el-input
            v-model="approveOpinion"
            type="textarea"
            :rows="3"
            maxlength="500"
            show-word-limit
            placeholder="通过可留空;驳回请填写原因"
          />
        </el-form-item>
      </el-form>
      <template #footer>
        <el-button type="success" @click="submitApprove('approved')">通 è¿‡</el-button>
        <el-button type="danger" @click="submitApprove('rejected')">驳 å›ž</el-button>
        <el-button @click="approveDialog.visible = false">取 æ¶ˆ</el-button>
      </template>
    </el-dialog>
  </div>
</template>
<script setup>
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import ApprovalFlowEditor from "@/views/officeProcessAutomation/AttendManage/overtime-apply/components/ApprovalFlowEditor.vue";
import ApprovalFlowProgress from "./components/ApprovalFlowProgress.vue";
import DetailPanel from "./components/DetailPanel.vue";
import { useTravelReimburse } from "./useTravelReimburse.js";
const tr = useTravelReimburse();
const {
  Search,
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  searchForm,
  tableLoading,
  page,
  tableData,
  tableColumn,
  importInputRef,
  formRef,
  form,
  formDialog,
  formRules,
  detailDialog,
  detailRow,
  approveDialog,
  approveOpinion,
  applicantFormSearchLoading,
  applicantFormOptions,
  flowUserOptions,
  travelDaysDisplay,
  travelTierLabel,
  suggestedLivingSubsidy,
  suggestedTransportSubsidy,
  suggestedHotelLimit,
  detailTotalAmount,
  overBudgetWarnings,
  budgetHint,
  handleQuery,
  resetSearch,
  pagination,
  remoteSearchApplicantForm,
  userSelectLabel,
  onApplicantChange,
  recalcTravelStandards,
  onTravelRangeChange,
  onDetailAmountChange,
  onApprovalFlowChange,
  addExpenseDetail,
  removeExpenseDetail,
  syncApplyAmountFromDetails,
  openFormDialog,
  onFormClosed,
  submitForm,
  openDetail,
  approvalActionLabel,
  submitApprove,
  handleExport,
  handleImportClick,
  onImportFile,
} = tr;
</script>
<style scoped>
.mb20 {
  margin-bottom: 20px;
}
.mb16 {
  margin-bottom: 16px;
}
.mb8 {
  margin-bottom: 8px;
}
.mt16 {
  margin-top: 16px;
}
.search_form {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.search_actions {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.form-section {
  margin-bottom: 16px;
  border: 1px solid var(--el-border-color-lighter);
}
.form-section :deep(.el-card__header) {
  padding: 12px 16px;
  background: var(--el-fill-color-lighter);
}
.form-section :deep(.el-card__body) {
  padding: 16px 16px 4px;
}
.card-header-title {
  font-size: 15px;
  font-weight: 600;
}
.card-header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
}
.amount-row {
  display: flex;
  align-items: center;
  gap: 12px;
  width: 100%;
}
.amount-input {
  flex: 1;
  min-width: 160px;
}
.w-full {
  width: 100%;
}
.attachment-form-item {
  margin-bottom: 0;
}
.detail-table {
  margin-bottom: 0;
}
.section-title {
  font-size: 15px;
  font-weight: 600;
  margin: 8px 0 12px;
  color: var(--el-text-color-primary);
  border-left: 3px solid var(--el-color-primary);
  padding-left: 8px;
}
.field-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 4px;
}
.warn-list {
  margin: 0;
  padding-left: 18px;
}
.detail-toolbar {
  margin-bottom: 8px;
}
.upload-block {
  width: 100%;
}
.flow-tip {
  font-size: 12px;
  color: var(--el-text-color-secondary);
  margin-top: 8px;
}
.sync-btn {
  margin-top: 4px;
}
.travel-reimburse-form-dialog :deep(.el-dialog__body) {
  padding-top: 12px;
}
.travel-reimburse-form :deep(.el-form-item) {
  margin-bottom: 18px;
}
.travel-reimburse-form :deep(.el-input-number) {
  width: 100%;
}
.travel-reimburse-form :deep(.el-row) {
  margin-bottom: 0;
}
</style>
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/travelReimburseUtils.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,193 @@
import dayjs from "dayjs";
/** è´¹ç”¨ç§‘ç›® */
export const EXPENSE_SUBJECT_OPTIONS = [
  { label: "交通费", value: "transport" },
  { label: "住宿费", value: "hotel" },
  { label: "餐饮费", value: "meal" },
  { label: "其他", value: "other" },
];
const TIER1_CITIES = ["北京", "上海", "广州", "深圳"];
export function expenseSubjectLabel(v) {
  return EXPENSE_SUBJECT_OPTIONS.find((x) => x.value === v)?.label || "—";
}
export function statusLabel(v) {
  if (v === "approved") return "通过";
  if (v === "rejected") return "驳回";
  return "审核中";
}
export function statusTagType(v) {
  if (v === "approved") return "success";
  if (v === "rejected") return "danger";
  return "warning";
}
export function detectTravelTier(destination) {
  const city = (destination || "").trim();
  if (!city) return "tier3";
  if (TIER1_CITIES.some((c) => city.includes(c))) return "tier1";
  const tier2Keywords = ["杭州", "南京", "武汉", "成都", "重庆", "西安", "天津", "苏州", "长沙", "郑州"];
  if (tier2Keywords.some((c) => city.includes(c))) return "tier2";
  return "tier3";
}
export function getTravelStandardByTier(tier) {
  const map = {
    tier1: { hotelPerNight: 600, transportPerDay: 80, mealPerDay: 100, label: "一线城市" },
    tier2: { hotelPerNight: 450, transportPerDay: 60, mealPerDay: 80, label: "二线城市" },
    tier3: { hotelPerNight: 350, transportPerDay: 40, mealPerDay: 60, label: "其他城市" },
  };
  return map[tier] || map.tier3;
}
export function computeTravelDays(startStr, endStr) {
  if (!startStr || !endStr) return null;
  const t0 = dayjs(startStr);
  const t1 = dayjs(endStr);
  if (!t0.isValid() || !t1.isValid() || !t1.isAfter(t0)) return null;
  const days = Math.ceil(t1.diff(t0, "day", true));
  return Math.max(1, days);
}
export function createEmptyExpenseDetail() {
  return {
    id: `ed_${Date.now()}_${Math.random().toString(36).slice(2, 7)}`,
    invoiceDate: "",
    expenseSubject: "",
    amount: undefined,
    description: "",
  };
}
export function createEmptyForm() {
  return {
    id: undefined,
    reimburseNo: "",
    applicantId: "",
    employeeNo: "",
    employeeName: "",
    reimburseReason: "",
    travelStartTime: "",
    travelEndTime: "",
    travelDays: undefined,
    departurePlace: "",
    destination: "",
    hotelStandard: undefined,
    hotelDays: undefined,
    livingSubsidy: undefined,
    applyAmount: undefined,
    payee: "",
    expenseDetails: [],
    attachmentList: [],
    approvalFlowNodes: [],
    currentNodeIndex: 0,
    needSpecialApproval: false,
    deptId: "",
    deptName: "",
    travelTier: "tier3",
  };
}
export function initApprovalFlowNodes(nodes) {
  return (nodes || []).map((n, i) => ({
    ...n,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: n.approveOpinion || "",
    approveTime: n.approveTime || "",
  }));
}
export function advanceApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  if (!nodes.length) return { nodes, currentNodeIndex: idx, approvalResult: row.approvalResult };
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  nodes[idx] = {
    ...nodes[idx],
    nodeStatus: "finish",
    approveOpinion: opinion || "同意",
    approveTime: now,
  };
  const next = idx + 1;
  if (next >= nodes.length) {
    return { nodes, currentNodeIndex: idx, approvalResult: "approved" };
  }
  nodes[next] = { ...nodes[next], nodeStatus: "process" };
  return { nodes, currentNodeIndex: next, approvalResult: "pending" };
}
export function rejectApprovalFlow(row, opinion) {
  const nodes = [...(row.approvalFlowNodes || [])];
  const idx = row.currentNodeIndex ?? 0;
  const now = dayjs().format("YYYY-MM-DD HH:mm:ss");
  if (nodes[idx]) {
    nodes[idx] = {
      ...nodes[idx],
      nodeStatus: "error",
      approveOpinion: opinion || "驳回",
      approveTime: now,
    };
  }
  return { nodes, currentNodeIndex: idx, approvalResult: "rejected", rejectReason: opinion || "驳回" };
}
/** æ¨¡æ‹Ÿéƒ¨é—¨é¢„算(与预算系统联动占位) */
export function mockDeptBudget(deptId) {
  const id = String(deptId || "default");
  let s = 0;
  for (let i = 0; i < id.length; i++) s += id.charCodeAt(i);
  const total = 500000 + (s % 200) * 1000;
  const used = (s % 80) * 3500;
  return {
    deptId: id,
    totalBudget: total,
    usedAmount: used,
    remainingAmount: Math.max(0, total - used),
  };
}
export function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const travelDays =
    raw.travelDays != null
      ? Number(raw.travelDays)
      : computeTravelDays(raw.travelStartTime, raw.travelEndTime);
  return {
    id,
    reimburseNo: raw.reimburseNo || `TR${dayjs().format("YYYYMMDD")}${String(idx).padStart(4, "0")}`,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    employeeNo: raw.employeeNo ?? raw.applicantNo ?? "",
    employeeName: raw.employeeName ?? raw.applicantName ?? "未知",
    applicantNo: raw.employeeNo ?? raw.applicantNo ?? "",
    applicantName: raw.employeeName ?? raw.applicantName ?? "未知",
    reimburseReason: raw.reimburseReason ?? "",
    travelStartTime: raw.travelStartTime ?? "",
    travelEndTime: raw.travelEndTime ?? "",
    travelDays: travelDays == null || Number.isNaN(travelDays) ? 1 : travelDays,
    departurePlace: raw.departurePlace ?? "",
    destination: raw.destination ?? "",
    hotelStandard: raw.hotelStandard,
    hotelDays: raw.hotelDays,
    livingSubsidy: raw.livingSubsidy,
    applyAmount: raw.applyAmount ?? 0,
    payee: raw.payee ?? "",
    expenseDetails: Array.isArray(raw.expenseDetails) ? raw.expenseDetails : [],
    invoiceAttachments: Array.isArray(raw.invoiceAttachments) ? raw.invoiceAttachments : [],
    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) ? raw.approvalFlowNodes : [],
    currentNodeIndex: raw.currentNodeIndex ?? 0,
    approvalResult: ["pending", "approved", "rejected"].includes(raw.approvalResult) ? raw.approvalResult : "pending",
    rejectReason: raw.rejectReason ?? "",
    approvalRecords: Array.isArray(raw.approvalRecords) ? raw.approvalRecords : [],
    needSpecialApproval: !!raw.needSpecialApproval,
    deptId: raw.deptId ?? "",
    deptName: raw.deptName ?? "",
    travelTier: raw.travelTier || detectTravelTier(raw.destination),
    createTime: raw.createTime || dayjs().format("YYYY-MM-DD HH:mm:ss"),
  };
}
src/views/officeProcessAutomation/ReimburseManage/travel-reimburse/useTravelReimburse.js
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,689 @@
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, onMounted, reactive, ref, watch } from "vue";
import {
  EXPENSE_SUBJECT_OPTIONS,
  expenseSubjectLabel,
  statusLabel,
  statusTagType,
  detectTravelTier,
  getTravelStandardByTier,
  computeTravelDays,
  createEmptyExpenseDetail,
  createEmptyForm,
  initApprovalFlowNodes,
  advanceApprovalFlow,
  rejectApprovalFlow,
  mockDeptBudget,
  normalizeImportedRow,
} from "./travelReimburseUtils.js";
function unwrapArray(payload) {
  if (Array.isArray(payload)) return payload;
  if (payload?.data && Array.isArray(payload.data)) return payload.data;
  if (payload?.rows && Array.isArray(payload.rows)) return payload.rows;
  return [];
}
function isActiveUser(u) {
  if (u.delFlag === "2" || u.delFlag === 2) return false;
  if (u.status == null) return true;
  return String(u.status) === "0";
}
function demoFlowNodes(names = ["部门主管", "财务审核"]) {
  return names.map((name, i) => ({
    approverId: `mock_${i + 1}`,
    approverName: name,
    sortOrder: i + 1,
    nodeOrder: i + 1,
    nodeStatus: i === 0 ? "process" : "wait",
    approveOpinion: "",
    approveTime: "",
  }));
}
export function useTravelReimburse() {
  const { proxy } = getCurrentInstance();
  const allRows = ref([
    {
      id: "1",
      reimburseNo: "TR202605090001",
      applicantId: "mock_1",
      employeeNo: "zhangsan",
      employeeName: "张三",
      applicantNo: "zhangsan",
      applicantName: "张三",
      reimburseReason: "赴上海参加行业展会及客户拜访。",
      travelStartTime: "2026-05-10 08:00:00",
      travelEndTime: "2026-05-13 18:00:00",
      travelDays: 4,
      departurePlace: "杭州",
      destination: "上海",
      hotelStandard: 600,
      hotelDays: 3,
      livingSubsidy: 400,
      applyAmount: 4580,
      payee: "张三",
      expenseDetails: [
        { id: "d1", invoiceDate: "2026-05-10", expenseSubject: "transport", amount: 553, description: "高铁往返" },
        { id: "d2", invoiceDate: "2026-05-11", expenseSubject: "hotel", amount: 1680, description: "酒店住宿" },
      ],
      attachmentList: [{ name: "高铁票.pdf", url: "/mock/invoice1.pdf" }],
      invoiceAttachments: [{ name: "高铁票.pdf", url: "/mock/invoice1.pdf" }],
      approvalFlowNodes: demoFlowNodes(),
      currentNodeIndex: 0,
      approvalResult: "pending",
      rejectReason: "",
      approvalRecords: [],
      needSpecialApproval: false,
      deptId: "101",
      deptName: "销售部",
      travelTier: "tier1",
      createTime: "2026-05-09 10:20:00",
    },
    {
      id: "2",
      reimburseNo: "TR202605080002",
      applicantId: "mock_2",
      employeeNo: "lisi",
      employeeName: "李四",
      applicantNo: "lisi",
      applicantName: "李四",
      reimburseReason: "成都分公司技术支持。",
      travelStartTime: "2026-05-05 09:00:00",
      travelEndTime: "2026-05-07 17:00:00",
      travelDays: 3,
      departurePlace: "武汉",
      destination: "成都",
      hotelStandard: 450,
      hotelDays: 2,
      livingSubsidy: 240,
      applyAmount: 2100,
      payee: "李四",
      expenseDetails: [{ id: "d3", invoiceDate: "2026-05-06", expenseSubject: "meal", amount: 180, description: "工作餐" }],
      attachmentList: [],
      invoiceAttachments: [],
      approvalFlowNodes: demoFlowNodes().map((n, i) => ({ ...n, nodeStatus: "finish", approveOpinion: "同意", approveTime: "2026-05-08 11:00:00" })),
      currentNodeIndex: 1,
      approvalResult: "approved",
      rejectReason: "",
      approvalRecords: [{ operatorName: "部门主管", result: "approved", opinion: "同意", time: "2026-05-08 10:00:00" }],
      needSpecialApproval: false,
      deptId: "102",
      deptName: "技术部",
      travelTier: "tier2",
      createTime: "2026-05-07 16:00:00",
    },
  ]);
  const searchForm = reactive({ applicantKeyword: "", travelStartFrom: "", travelEndTo: "" });
  const tableLoading = ref(false);
  const page = reactive({ current: 1, size: 10, total: 0 });
  const importInputRef = ref(null);
  const allUsersCache = ref([]);
  const applicantFormSearchLoading = ref(false);
  const applicantFormOptions = ref([]);
  const formRef = ref();
  const form = reactive(createEmptyForm());
  const formDialog = reactive({ visible: false, title: "", mode: "add", readonly: false });
  const detailDialog = reactive({ visible: false });
  const detailRow = ref({});
  const approveDialog = reactive({ visible: false, row: null });
  const approveOpinion = ref("");
  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));
  });
  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);
  });
  const flowUserOptions = computed(() => allUsersCache.value.filter(isActiveUser));
  const travelDaysDisplay = computed(() => {
    const d = computeTravelDays(form.travelStartTime, form.travelEndTime);
    return d == null ? "" : String(d);
  });
  const travelTierLabel = computed(() => {
    const std = getTravelStandardByTier(form.travelTier || detectTravelTier(form.destination));
    return `按${std.label}标准`;
  });
  const suggestedLivingSubsidy = computed(() => {
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || form.hotelDays || 0;
    const std = getTravelStandardByTier(form.travelTier);
    return Math.round(std.mealPerDay * days * 100) / 100;
  });
  const suggestedTransportSubsidy = computed(() => {
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime) || 0;
    const std = getTravelStandardByTier(form.travelTier);
    return Math.round(std.transportPerDay * days * 100) / 100;
  });
  const suggestedHotelLimit = computed(() => {
    const nights = form.hotelDays || 0;
    const perNight = form.hotelStandard ?? getTravelStandardByTier(form.travelTier).hotelPerNight;
    return Math.round(perNight * nights * 100) / 100;
  });
  const detailTotalAmount = computed(() => {
    const sum = (form.expenseDetails || []).reduce((s, d) => s + (Number(d.amount) || 0), 0);
    return Math.round(sum * 100) / 100;
  });
  const overBudgetWarnings = computed(() => buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value));
  const budgetHint = computed(() => {
    if (!form.deptId) return { visible: false };
    const b = mockDeptBudget(form.deptId);
    const apply = Number(form.applyAmount) || detailTotalAmount.value || 0;
    const after = b.remainingAmount - apply;
    return {
      visible: true,
      type: after < 0 ? "error" : "info",
      title: `部门预算联动(${form.deptName || b.deptId})`,
      description: `年度预算 ${b.totalBudget} å…ƒï¼Œå·²ç”¨ ${b.usedAmount} å…ƒï¼Œå‰©ä½™ ${b.remainingAmount} å…ƒï¼›æœ¬å•申请后预计剩余 ${Math.round(after * 100) / 100} å…ƒã€‚`,
    };
  });
  const tableColumn = ref([
    { label: "报销单号", prop: "reimburseNo", width: 150 },
    { label: "申请人编号", prop: "applicantNo", width: 110 },
    { label: "申请人", prop: "applicantName", minWidth: 90 },
    { label: "出差开始", prop: "travelStartTime", width: 165 },
    { label: "出差结束", prop: "travelEndTime", width: 165 },
    { label: "创建时间", prop: "createTime", width: 165 },
    {
      label: "状态",
      prop: "approvalResult",
      width: 100,
      dataType: "tag",
      formatData: (v) => statusLabel(v),
      formatType: (v) => statusTagType(v),
    },
    {
      dataType: "action",
      label: "操作",
      align: "center",
      fixed: "right",
      width: 200,
      operation: [
        { name: "编辑", type: "text", disabled: (row) => row.approvalResult === "pending" || row.approvalResult === "approved", clickFun: (row) => openFormDialog("edit", row) },
        { name: "详情", type: "text", clickFun: (row) => openDetail(row) },
        { name: "审批", type: "text", disabled: (row) => row.approvalResult !== "pending", clickFun: (row) => openApprove(row) },
      ],
    },
  ]);
  const formRules = {
    applicantId: [{ required: true, message: "请选择员工", trigger: "change" }],
    reimburseReason: [{ required: true, message: "请填写报销原因", trigger: "blur" }],
    travelStartTime: [{ required: true, message: "请选择出差开始时间", trigger: "change" }],
    travelEndTime: [
      { required: true, message: "请选择出差结束时间", trigger: "change" },
      {
        validator: (_r, val, cb) => {
          if (!form.travelStartTime || !val) { cb(); return; }
          if (computeTravelDays(form.travelStartTime, val) == null) cb(new Error("结束时间须晚于开始时间"));
          else cb();
        },
        trigger: "change",
      },
    ],
    departurePlace: [{ required: true, message: "请填写出差地", trigger: "blur" }],
    destination: [{ required: true, message: "请填写目的地", trigger: "blur" }],
    applyAmount: [{ required: true, message: "请填写申请金额", trigger: "blur" }],
    payee: [{ required: true, message: "请填写收款人", trigger: "blur" }],
    approvalFlowNodes: [
      {
        validator: (_r, _v, cb) => {
          const nodes = form.approvalFlowNodes || [];
          if (!nodes.length) { cb(new Error("请至少配置一个审批节点")); return; }
          if (nodes.some((n) => n.approverId == null || n.approverId === "")) { cb(new Error("每个节点须选择审批人")); return; }
          cb();
        },
        trigger: "change",
      },
    ],
  };
  function buildOverBudgetWarnings(f, detailTotal, hotelLimit, transportLimit, mealLimit) {
    const warnings = [];
    const bySubject = { transport: 0, hotel: 0, meal: 0, other: 0 };
    (f.expenseDetails || []).forEach((d) => {
      const key = d.expenseSubject || "other";
      bySubject[key] = (bySubject[key] || 0) + (Number(d.amount) || 0);
    });
    if (bySubject.transport > transportLimit && transportLimit > 0) {
      warnings.push(`交通费 ${bySubject.transport} å…ƒè¶…出标准 ${transportLimit} å…ƒ`);
    }
    if (bySubject.hotel > hotelLimit && hotelLimit > 0) {
      warnings.push(`住宿费 ${bySubject.hotel} å…ƒè¶…出限额 ${hotelLimit} å…ƒ`);
    }
    if (bySubject.meal > mealLimit && mealLimit > 0) {
      warnings.push(`餐饮费 ${bySubject.meal} å…ƒè¶…出生活补贴建议 ${mealLimit} å…ƒ`);
    }
    const std = getTravelStandardByTier(f.travelTier);
    if (f.hotelStandard > std.hotelPerNight) {
      warnings.push(`酒店标准 ${f.hotelStandard} å…ƒ/晚高于${std.label}标准 ${std.hotelPerNight} å…ƒ/晚`);
    }
    const apply = Number(f.applyAmount) || detailTotal;
    const standardTotal = transportLimit + hotelLimit + mealLimit;
    if (apply > standardTotal && standardTotal > 0) {
      warnings.push(`申请总额 ${apply} å…ƒé«˜äºŽå·®æ—…标准合计约 ${standardTotal} å…ƒ`);
    }
    return warnings;
  }
  async function loadUserPool() {
    try {
      allUsersCache.value = unwrapArray(await userListNoPageByTenantId());
    } catch {
      allUsersCache.value = [];
    }
  }
  function userSelectLabel(u) {
    const nick = u.nickName || "";
    const name = u.userName || "";
    if (nick && name && nick !== name) return `${nick}(${name})`;
    return nick || name || `用户${u.userId ?? u.id ?? ""}`;
  }
  function userById(id) {
    return allUsersCache.value.find((u) => String(u.userId ?? u.id) === String(id));
  }
  function employeeNoFromUser(u) {
    if (!u) return "";
    return u.userName ?? u.userCode ?? u.jobNumber ?? u.workNo ?? (u.userId != null ? String(u.userId) : "");
  }
  function filterUsersByQuery(query) {
    const list = allUsersCache.value.filter(isActiveUser);
    const q = (query || "").trim().toLowerCase();
    if (!q) return [...list];
    return list.filter((u) => {
      const nick = (u.nickName || "").toLowerCase();
      const uname = (u.userName || "").toLowerCase();
      return nick.includes(q) || uname.includes(q);
    });
  }
  async function remoteSearchApplicantForm(query) {
    applicantFormSearchLoading.value = true;
    try {
      if (!allUsersCache.value.length) await loadUserPool();
      applicantFormOptions.value = filterUsersByQuery(query);
    } finally {
      applicantFormSearchLoading.value = false;
    }
  }
  function onApplicantChange(uid) {
    const u = userById(uid);
    if (u) {
      form.employeeName = u.nickName || u.userName || "";
      form.employeeNo = employeeNoFromUser(u);
      form.payee = form.payee || form.employeeName;
      form.deptId = String(u.deptId ?? u.sysDeptId ?? "");
      form.deptName = u.dept?.deptName ?? u.deptName ?? "";
    } else {
      form.employeeName = "";
      form.employeeNo = "";
    }
  }
  function recalcTravelStandards() {
    form.travelTier = detectTravelTier(form.destination);
    const std = getTravelStandardByTier(form.travelTier);
    if (form.hotelStandard == null || form.hotelStandard === 0) form.hotelStandard = std.hotelPerNight;
    const days = computeTravelDays(form.travelStartTime, form.travelEndTime);
    if (days != null) {
      form.travelDays = days;
      if (form.hotelDays == null) form.hotelDays = Math.max(0, days - 1);
      if (form.livingSubsidy == null || form.livingSubsidy === 0) form.livingSubsidy = suggestedLivingSubsidy.value;
    }
    form.needSpecialApproval = buildOverBudgetWarnings(form, detailTotalAmount.value, suggestedHotelLimit.value, suggestedTransportSubsidy.value, suggestedLivingSubsidy.value).length > 0;
  }
  function onTravelRangeChange() {
    recalcTravelStandards();
    nextTick(() => formRef.value?.validateField?.("travelEndTime"));
  }
  function onDetailAmountChange() {
    recalcTravelStandards();
  }
  function onApprovalFlowChange() {
    nextTick(() => formRef.value?.validateField?.("approvalFlowNodes"));
  }
  function addExpenseDetail() {
    form.expenseDetails.push(createEmptyExpenseDetail());
  }
  function removeExpenseDetail(index) {
    form.expenseDetails.splice(index, 1);
    recalcTravelStandards();
  }
  function mapAttachmentList(list) {
    return (list || []).map((f, i) => ({
      id: f.id ?? f.uid ?? `inv_${Date.now()}_${i}`,
      name: f.name || f.fileName || f.originalFilename || "未命名",
      url: f.url || f.downloadURL || f.previewURL || f.previewUrl || "",
    }));
  }
  function syncApplyAmountFromDetails() {
    form.applyAmount = detailTotalAmount.value;
    recalcTravelStandards();
  }
  function handleQuery() {
    page.current = 1;
    tableLoading.value = true;
    setTimeout(() => { tableLoading.value = false; }, 150);
  }
  function resetSearch() {
    searchForm.applicantKeyword = "";
    searchForm.travelStartFrom = "";
    searchForm.travelEndTo = "";
    handleQuery();
  }
  function pagination(obj) {
    page.current = obj.page;
    page.size = obj.limit;
  }
  function openDetail(row) {
    detailRow.value = { ...row };
    detailDialog.visible = true;
  }
  function openApprove(row) {
    approveDialog.row = { ...row };
    approveDialog.visible = true;
  }
  function approvalActionLabel(v) {
    if (v === "approved") return "通过";
    if (v === "rejected") return "驳回";
    return "提交";
  }
  async function openFormDialog(mode, row) {
    formDialog.mode = mode;
    formDialog.readonly = false;
    formDialog.title = mode === "add" ? "新增差旅报销" : "编辑差旅报销";
    if (!allUsersCache.value.length) await loadUserPool();
    Object.assign(form, createEmptyForm());
    if (mode === "edit" && row) {
      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 || [])),
      });
      const u = userById(row.applicantId);
      applicantFormOptions.value = u ? [u] : [{ userId: row.applicantId, nickName: row.employeeName, userName: row.employeeNo }];
    } else {
      form.approvalFlowNodes = [{ approverId: null, approverName: "", sortOrder: 1, nodeOrder: 1 }];
      remoteSearchApplicantForm("");
    }
    formDialog.visible = true;
    nextTick(() => {
      formRef.value?.clearValidate?.();
      recalcTravelStandards();
    });
  }
  function onFormClosed() {
    formRef.value?.resetFields?.();
  }
  async function submitForm() {
    try {
      await formRef.value?.validate?.();
    } catch {
      return;
    }
    if (!(form.expenseDetails || []).length) {
      proxy?.$modal?.msgWarning?.("请至少添加一条报销明细");
      return;
    }
    recalcTravelStandards();
    if (form.needSpecialApproval) {
      try {
        await proxy.$modal.confirm("存在超支项,提交后将标记为需特批,是否继续?");
      } catch {
        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?.("保存成功(本地模拟)");
    }
    formDialog.visible = false;
    handleQuery();
  }
  async function submitApprove(result) {
    const row = approveDialog.row;
    if (!row) return;
    if (result === "rejected" && !(approveOpinion.value || "").trim()) {
      proxy?.$modal?.msgWarning?.("驳回须填写审批意见");
      return;
    }
    const idx = allRows.value.findIndex((r) => r.id === row.id);
    if (idx === -1) return;
    const cur = allRows.value[idx];
    const operatorName = "当前审批人";
    const record = {
      operatorName,
      result,
      opinion: approveOpinion.value || (result === "approved" ? "同意" : "驳回"),
      time: dayjs().format("YYYY-MM-DD HH:mm:ss"),
    };
    const records = [...(cur.approvalRecords || []), record];
    let flowUpdate;
    if (result === "approved") {
      flowUpdate = advanceApprovalFlow(cur, approveOpinion.value);
    } else {
      flowUpdate = rejectApprovalFlow(cur, approveOpinion.value);
    }
    allRows.value[idx] = {
      ...cur,
      approvalFlowNodes: flowUpdate.nodes,
      currentNodeIndex: flowUpdate.currentNodeIndex,
      approvalResult: flowUpdate.approvalResult,
      rejectReason: flowUpdate.rejectReason ?? cur.rejectReason,
      approvalRecords: records,
    };
    proxy?.$modal?.msgSuccess?.(result === "approved" ? "已通过" : "已驳回");
    approveDialog.visible = false;
    handleQuery();
  }
  function handleExport() {
    const data = filteredList.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");
    a.href = url;
    a.download = `差旅报销导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
    a.click();
    URL.revokeObjectURL(url);
    proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} æ¡`);
  }
  function handleImportClick() {
    importInputRef.value?.click?.();
  }
  function onImportFile(e) {
    const file = e.target.files?.[0];
    e.target.value = "";
    if (!file) return;
    const reader = new FileReader();
    reader.onload = () => {
      try {
        const parsed = JSON.parse(String(reader.result || ""));
        const arr = Array.isArray(parsed) ? parsed : parsed?.rows || parsed?.data;
        if (!Array.isArray(arr) || !arr.length) {
          proxy?.$modal?.msgWarning?.("导入格式须为差旅报销 JSON æ•°ç»„");
          return;
        }
        arr.forEach((raw, i) => allRows.value.unshift(normalizeImportedRow(raw, i)));
        proxy?.$modal?.msgSuccess?.(`成功导入 ${arr.length} æ¡`);
        handleQuery();
      } catch {
        proxy?.$modal?.msgError?.("解析失败");
      }
    };
    reader.readAsText(file, "utf-8");
  }
  onMounted(() => loadUserPool());
  return {
    Search,
    EXPENSE_SUBJECT_OPTIONS,
    expenseSubjectLabel,
    searchForm,
    tableLoading,
    page,
    tableData,
    tableColumn,
    importInputRef,
    formRef,
    form,
    formDialog,
    formRules,
    detailDialog,
    detailRow,
    approveDialog,
    approveOpinion,
    applicantFormSearchLoading,
    applicantFormOptions,
    flowUserOptions,
    travelDaysDisplay,
    travelTierLabel,
    suggestedLivingSubsidy,
    suggestedTransportSubsidy,
    suggestedHotelLimit,
    detailTotalAmount,
    overBudgetWarnings,
    budgetHint,
    handleQuery,
    resetSearch,
    pagination,
    remoteSearchApplicantForm,
    userSelectLabel,
    onApplicantChange,
    recalcTravelStandards,
    onTravelRangeChange,
    onDetailAmountChange,
    onApprovalFlowChange,
    addExpenseDetail,
    removeExpenseDetail,
    syncApplyAmountFromDetails,
    openFormDialog,
    onFormClosed,
    submitForm,
    openDetail,
    openApprove,
    approvalActionLabel,
    submitApprove,
    handleExport,
    handleImportClick,
    onImportFile,
  };
}
src/views/officeProcessAutomation/SysAdmin/dept-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,291 @@
<!--OA模块:部门管理-->
<template>
    <div class="app-container">
        <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch">
            <el-form-item label="部门名称" prop="deptName">
                <el-input
                    v-model="queryParams.deptName"
                    placeholder="请输入部门名称"
                    clearable
                    style="width: 200px"
                    @keyup.enter="handleQuery"
                />
            </el-form-item>
            <el-form-item label="状态" prop="status">
                <el-select v-model="queryParams.status" placeholder="部门状态" clearable style="width: 200px">
                    <el-option
                        v-for="dict in sys_normal_disable"
                        :key="dict.value"
                        :label="dict.label"
                        :value="dict.value"
                    />
                </el-select>
            </el-form-item>
            <el-form-item>
                <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
                <el-button icon="Refresh" @click="resetQuery">重置</el-button>
            </el-form-item>
        </el-form>
        <el-row :gutter="10" class="mb8">
            <el-col :span="1.5">
                <el-button
                    type="primary"
                    plain
                    icon="Plus"
                    @click="handleAdd"
                    v-hasPermi="['system:dept:add']"
                >新增</el-button>
            </el-col>
            <el-col :span="1.5">
                <el-button
                    type="info"
                    plain
                    icon="Sort"
                    @click="toggleExpandAll"
                >展开/折叠</el-button>
            </el-col>
            <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
        </el-row>
        <el-table
            v-if="refreshTable"
            v-loading="loading"
            :data="deptList"
            row-key="deptId"
            :default-expand-all="isExpandAll"
            :tree-props="{ children: 'children', hasChildren: 'hasChildren' }"
        >
            <el-table-column prop="deptName" label="部门名称" width="260"></el-table-column>
            <el-table-column prop="orderNum" label="排序" width="200"></el-table-column>
            <el-table-column prop="status" label="状态" width="100">
                <template #default="scope">
                    <dict-tag :options="sys_normal_disable" :value="scope.row.status" />
                </template>
            </el-table-column>
            <el-table-column label="创建时间" align="center" prop="createTime" width="200">
                <template #default="scope">
                    <span>{{ parseTime(scope.row.createTime) }}</span>
                </template>
            </el-table-column>
            <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
                <template #default="scope">
                    <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:dept:edit']">修改</el-button>
                    <el-button link type="primary" icon="Plus" @click="handleAdd(scope.row)" v-hasPermi="['system:dept:add']">新增</el-button>
                    <el-button v-if="scope.row.parentId != 0" link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:dept:remove']">删除</el-button>
                </template>
            </el-table-column>
        </el-table>
        <!-- æ·»åŠ æˆ–ä¿®æ”¹éƒ¨é—¨å¯¹è¯æ¡† -->
        <el-dialog :title="title" v-model="open" width="600px" append-to-body>
            <el-form ref="deptRef" :model="form" :rules="rules" label-width="80px">
                <el-row>
                    <el-col :span="24" v-if="form.parentId !== 0">
                        <el-form-item label="上级部门" prop="parentId">
                            <el-tree-select
                                v-model="form.parentId"
                                :data="deptOptions"
                                :props="{ value: 'deptId', label: 'deptName', children: 'children' }"
                                value-key="deptId"
                                placeholder="选择上级部门"
                                check-strictly
                            />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="部门名称" prop="deptName">
                            <el-input v-model="form.deptName" placeholder="请输入部门名称" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="显示排序" prop="orderNum">
                            <el-input-number v-model="form.orderNum" controls-position="right" :min="0"/>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="负责人" prop="leader">
                            <el-input v-model="form.leader" placeholder="请输入负责人" maxlength="20" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="联系电话" prop="phone">
                            <el-input v-model="form.phone" placeholder="请输入联系电话" maxlength="11" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="邮箱" prop="email">
                            <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="部门状态">
                            <el-radio-group v-model="form.status">
                                <el-radio
                                    v-for="dict in sys_normal_disable"
                                    :key="dict.value"
                                    :value="dict.value"
                                >{{ dict.label }}</el-radio>
                            </el-radio-group>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="部门编号" prop="deptNick">
                            <el-input v-model="form.deptNick" placeholder="请输入部门编号" maxlength="50" />
                        </el-form-item>
                    </el-col>
                </el-row>
            </el-form>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
                    <el-button @click="cancel">取 æ¶ˆ</el-button>
                </div>
            </template>
        </el-dialog>
    </div>
</template>
<script setup name="Dept">
import { listDept, getDept, delDept, addDept, updateDept, listDeptExcludeChild } from "@/api/system/dept"
const { proxy } = getCurrentInstance()
const { sys_normal_disable } = proxy.useDict("sys_normal_disable")
const deptList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const title = ref("")
const deptOptions = ref([])
const isExpandAll = ref(true)
const refreshTable = ref(true)
const data = reactive({
    form: {},
    queryParams: {
        deptName: undefined,
        status: undefined
    },
    rules: {
        parentId: [{ required: true, message: "上级部门不能为空", trigger: "blur" }],
        deptName: [{ required: true, message: "部门名称不能为空", trigger: "blur" }],
        orderNum: [{ required: true, message: "显示排序不能为空", trigger: "blur" }],
        email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: ["blur", "change"] }],
        phone: [{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }],
        deptNick: [{ required: true, message: "部门编号不能为空", trigger: "blur" }],
    },
})
const { queryParams, form, rules } = toRefs(data)
/** æŸ¥è¯¢éƒ¨é—¨åˆ—表 */
function getList() {
    loading.value = true
    listDept(queryParams.value).then(response => {
        deptList.value = proxy.handleTree(response.data, "deptId")
        loading.value = false
    })
}
/** å–消按钮 */
function cancel() {
    open.value = false
    reset()
}
/** è¡¨å•重置 */
function reset() {
    form.value = {
        deptId: undefined,
        parentId: undefined,
        deptName: undefined,
        orderNum: 0,
        leader: undefined,
        phone: undefined,
        email: undefined,
        status: "0",
        deptNick: undefined,
    }
    proxy.resetForm("deptRef")
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
    getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
    proxy.resetForm("queryRef")
    handleQuery()
}
/** æ–°å¢žæŒ‰é’®æ“ä½œ */
function handleAdd(row) {
    reset()
    listDept().then(response => {
        deptOptions.value = proxy.handleTree(response.data, "deptId")
    })
    if (row != undefined) {
        form.value.parentId = row.deptId
    }
    open.value = true
    title.value = "添加部门"
}
/** å±•å¼€/折叠操作 */
function toggleExpandAll() {
    refreshTable.value = false
    isExpandAll.value = !isExpandAll.value
    nextTick(() => {
        refreshTable.value = true
    })
}
/** ä¿®æ”¹æŒ‰é’®æ“ä½œ */
function handleUpdate(row) {
    reset()
    listDeptExcludeChild(row.deptId).then(response => {
        deptOptions.value = proxy.handleTree(response.data, "deptId")
    })
    getDept(row.deptId).then(response => {
        form.value = response.data
        open.value = true
        title.value = "修改部门"
    })
}
/** æäº¤æŒ‰é’® */
function submitForm() {
    proxy.$refs["deptRef"].validate(valid => {
        if (valid) {
            if (form.value.deptId != undefined) {
                updateDept(form.value).then(response => {
                    proxy.$modal.msgSuccess("修改成功")
                    open.value = false
                    getList()
                })
            } else {
                addDept(form.value).then(response => {
                    proxy.$modal.msgSuccess("新增成功")
                    open.value = false
                    getList()
                })
            }
        }
    })
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
    proxy.$modal.confirm('是否确认删除名称为"' + row.deptName + '"的数据项?').then(function() {
        return delDept(row.deptId)
    }).then(() => {
        getList()
        proxy.$modal.msgSuccess("删除成功")
    }).catch(() => {})
}
getList()
</script>
src/views/officeProcessAutomation/SysAdmin/log-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,315 @@
<!--OA模块:日志管理-->
<template>
  <div class="app-container">
     <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
        <el-form-item label="操作地址" prop="operIp">
           <el-input
              v-model="queryParams.operIp"
              placeholder="请输入操作地址"
              clearable
              style="width: 240px;"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="系统模块" prop="title">
           <el-input
              v-model="queryParams.title"
              placeholder="请输入系统模块"
              clearable
              style="width: 240px;"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="操作人员" prop="operName">
           <el-input
              v-model="queryParams.operName"
              placeholder="请输入操作人员"
              clearable
              style="width: 240px;"
              @keyup.enter="handleQuery"
           />
        </el-form-item>
        <el-form-item label="类型" prop="businessType">
           <el-select
              v-model="queryParams.businessType"
              placeholder="操作类型"
              clearable
              style="width: 240px"
           >
              <el-option
                 v-for="dict in sys_oper_type"
                 :key="dict.value"
                 :label="dict.label"
                 :value="dict.value"
              />
           </el-select>
        </el-form-item>
        <el-form-item label="状态" prop="status">
           <el-select
              v-model="queryParams.status"
              placeholder="操作状态"
              clearable
              style="width: 240px"
           >
              <el-option
                 v-for="dict in sys_common_status"
                 :key="dict.value"
                 :label="dict.label"
                 :value="dict.value"
              />
           </el-select>
        </el-form-item>
        <el-form-item label="操作时间" style="width: 308px">
           <el-date-picker
              v-model="dateRange"
              value-format="YYYY-MM-DD HH:mm:ss"
              type="daterange"
              range-separator="-"
              start-placeholder="开始日期"
              end-placeholder="结束日期"
              :default-time="[new Date(2000, 1, 1, 0, 0, 0), new Date(2000, 1, 1, 23, 59, 59)]"
           ></el-date-picker>
        </el-form-item>
        <el-form-item>
           <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
           <el-button icon="Refresh" @click="resetQuery">重置</el-button>
        </el-form-item>
     </el-form>
     <el-row :gutter="10" class="mb8">
        <el-col :span="1.5">
           <el-button
              type="danger"
              plain
              icon="Delete"
              :disabled="multiple"
              @click="handleDelete"
              v-hasPermi="['monitor:operlog:remove']"
           >删除</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="danger"
              plain
              icon="Delete"
              @click="handleClean"
              v-hasPermi="['monitor:operlog:remove']"
           >清空</el-button>
        </el-col>
        <el-col :span="1.5">
           <el-button
              type="warning"
              plain
              icon="Download"
              @click="handleExport"
              v-hasPermi="['monitor:operlog:export']"
           >导出</el-button>
        </el-col>
        <right-toolbar v-model:showSearch="showSearch" @queryTable="getList"></right-toolbar>
     </el-row>
     <el-table ref="operlogRef" v-loading="loading" :data="operlogList" @selection-change="handleSelectionChange" :default-sort="defaultSort" @sort-change="handleSortChange">
        <el-table-column type="selection" width="50" align="center" />
        <el-table-column label="日志编号" align="center" prop="operId" />
        <el-table-column label="系统模块" align="center" prop="title" :show-overflow-tooltip="true" />
        <el-table-column label="操作类型" align="center" prop="businessType">
           <template #default="scope">
              <dict-tag :options="sys_oper_type" :value="scope.row.businessType" />
           </template>
        </el-table-column>
        <el-table-column label="操作人员" align="center" width="110" prop="operName" :show-overflow-tooltip="true" sortable="custom" :sort-orders="['descending', 'ascending']" />
        <el-table-column label="操作地址" align="center" prop="operIp" width="130" :show-overflow-tooltip="true" />
        <el-table-column label="操作状态" align="center" prop="status">
           <template #default="scope">
              <dict-tag :options="sys_common_status" :value="scope.row.status" />
           </template>
        </el-table-column>
        <el-table-column label="操作日期" align="center" prop="operTime" width="180" sortable="custom" :sort-orders="['descending', 'ascending']">
           <template #default="scope">
              <span>{{ parseTime(scope.row.operTime) }}</span>
           </template>
        </el-table-column>
        <el-table-column label="消耗时间" align="center" prop="costTime" width="110" :show-overflow-tooltip="true" sortable="custom" :sort-orders="['descending', 'ascending']">
           <template #default="scope">
              <span>{{ scope.row.costTime }}毫秒</span>
           </template>
        </el-table-column>
        <el-table-column label="操作" align="center" class-name="small-padding fixed-width">
           <template #default="scope">
              <el-button link type="primary" icon="View" @click="handleView(scope.row, scope.index)" v-hasPermi="['monitor:operlog:query']">详细</el-button>
           </template>
        </el-table-column>
     </el-table>
     <pagination
        v-show="total > 0"
        :total="total"
        v-model:page="queryParams.pageNum"
        v-model:limit="queryParams.pageSize"
        @pagination="getList"
     />
     <!-- æ“ä½œæ—¥å¿—详细 -->
     <el-dialog title="操作日志详细" v-model="open" width="800px" append-to-body>
        <el-form :model="form" label-width="100px">
           <el-row>
              <el-col :span="12">
                 <el-form-item label="操作模块:">{{ form.title }} / {{ typeFormat(form) }}</el-form-item>
                 <el-form-item
                   label="登录信息:"
                 >{{ form.operName }} / {{ form.operIp }} / {{ form.operLocation }}</el-form-item>
              </el-col>
              <el-col :span="12">
                 <el-form-item label="请求地址:">{{ form.operUrl }}</el-form-item>
                 <el-form-item label="请求方式:">{{ form.requestMethod }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="操作方法:">{{ form.method }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="请求参数:">{{ form.operParam }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="返回参数:">{{ form.jsonResult }}</el-form-item>
              </el-col>
              <el-col :span="8">
                 <el-form-item label="操作状态:">
                    <div v-if="form.status === 0">正常</div>
                    <div v-else-if="form.status === 1">失败</div>
                 </el-form-item>
              </el-col>
              <el-col :span="8">
                 <el-form-item label="消耗时间:">{{ form.costTime }}毫秒</el-form-item>
              </el-col>
              <el-col :span="8">
                 <el-form-item label="操作时间:">{{ parseTime(form.operTime) }}</el-form-item>
              </el-col>
              <el-col :span="24">
                 <el-form-item label="异常信息:" v-if="form.status === 1">{{ form.errorMsg }}</el-form-item>
              </el-col>
           </el-row>
        </el-form>
        <template #footer>
           <div class="dialog-footer">
              <el-button @click="open = false">关 é—­</el-button>
           </div>
        </template>
     </el-dialog>
  </div>
</template>
<script setup name="Operlog">
import { list, delOperlog, cleanOperlog } from "@/api/monitor/operlog"
import {onMounted} from "vue";
const { proxy } = getCurrentInstance()
const { sys_oper_type, sys_common_status } = proxy.useDict("sys_oper_type","sys_common_status")
const operlogList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const dateRange = ref([])
const defaultSort = ref({ prop: "operTime", order: "descending" })
const data = reactive({
 form: {},
 queryParams: {
   pageNum: 1,
   pageSize: 10,
   operIp: undefined,
   title: undefined,
   operName: undefined,
   businessType: undefined,
   status: undefined
 }
})
const { queryParams, form } = toRefs(data)
/** æŸ¥è¯¢ç™»å½•日志 */
function getList() {
 loading.value = true
 list(proxy.addDateRange(queryParams.value, dateRange.value)).then(response => {
   operlogList.value = response.rows
   total.value = response.total
   loading.value = false
 })
}
/** æ“ä½œæ—¥å¿—类型字典翻译 */
function typeFormat(row, column) {
 return proxy.selectDictLabel(sys_oper_type.value, row.businessType)
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
 queryParams.value.pageNum = 1
 getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
 dateRange.value = []
 proxy.resetForm("queryRef")
 queryParams.value.pageNum = 1
 proxy.$refs["operlogRef"].sort(defaultSort.value.prop, defaultSort.value.order)
}
/** å¤šé€‰æ¡†é€‰ä¸­æ•°æ® */
function handleSelectionChange(selection) {
 ids.value = selection.map(item => item.operId)
 multiple.value = !selection.length
}
/** æŽ’序触发事件 */
function handleSortChange(column, prop, order) {
 queryParams.value.orderByColumn = column.prop
 queryParams.value.isAsc = column.order
 getList()
}
/** è¯¦ç»†æŒ‰é’®æ“ä½œ */
function handleView(row) {
 open.value = true
 form.value = row
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
 const operIds = row.operId || ids.value
 proxy.$modal.confirm('是否确认删除日志编号为"' + operIds + '"的数据项?').then(function () {
   return delOperlog(operIds)
 }).then(() => {
   getList()
   proxy.$modal.msgSuccess("删除成功")
 }).catch(() => {})
}
/** æ¸…空按钮操作 */
function handleClean() {
 proxy.$modal.confirm("是否确认清空所有操作日志数据项?").then(function () {
   return cleanOperlog()
 }).then(() => {
   getList()
   proxy.$modal.msgSuccess("清空成功")
 }).catch(() => {})
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
function handleExport() {
 proxy.download("monitor/operlog/export",{
   ...queryParams.value,
 }, `config_${new Date().getTime()}.xlsx`)
}
onMounted(() => {
 getList();
});
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/authRole.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,123 @@
<template>
   <div class="app-container">
      <h4 class="form-header h4">基本信息</h4>
      <el-form :model="form" label-width="80px">
         <el-row>
            <el-col :span="8" :offset="2">
               <el-form-item label="用户昵称" prop="nickName">
                  <el-input v-model="form.nickName" disabled />
               </el-form-item>
            </el-col>
            <el-col :span="8" :offset="2">
               <el-form-item label="登录账号" prop="userName">
                  <el-input v-model="form.userName" disabled />
               </el-form-item>
            </el-col>
         </el-row>
      </el-form>
      <h4 class="form-header h4">角色信息</h4>
      <el-table v-loading="loading" :row-key="getRowKey" @row-click="clickRow" ref="roleRef" @selection-change="handleSelectionChange" :data="roles.slice((pageNum - 1) * pageSize, pageNum * pageSize)">
         <el-table-column label="序号" width="55" type="index" align="center">
            <template #default="scope">
               <span>{{ (pageNum - 1) * pageSize + scope.$index + 1 }}</span>
            </template>
         </el-table-column>
         <el-table-column type="selection" :reserve-selection="true" :selectable="checkSelectable" width="55"></el-table-column>
         <el-table-column label="角色编号" align="center" prop="roleId" />
         <el-table-column label="角色名称" align="center" prop="roleName" />
         <el-table-column label="权限字符" align="center" prop="roleKey" />
         <el-table-column label="创建时间" align="center" prop="createTime" width="180">
            <template #default="scope">
               <span>{{ parseTime(scope.row.createTime) }}</span>
            </template>
         </el-table-column>
      </el-table>
      <pagination v-show="total > 0" :total="total" v-model:page="pageNum" v-model:limit="pageSize" />
      <el-form label-width="100px">
         <div style="text-align: center;margin-left:-120px;margin-top:30px;">
            <el-button type="primary" @click="submitForm()">提交</el-button>
            <el-button @click="close()">返回</el-button>
         </div>
      </el-form>
   </div>
</template>
<script setup name="AuthRole">
import { getAuthRole, updateAuthRole } from "@/api/system/user"
const route = useRoute()
const { proxy } = getCurrentInstance()
const loading = ref(true)
const total = ref(0)
const pageNum = ref(1)
const pageSize = ref(10)
const roleIds = ref([])
const roles = ref([])
const form = ref({
  nickName: undefined,
  userName: undefined,
  userId: undefined
})
/** å•击选中行数据 */
function clickRow(row) {
  if (checkSelectable(row)) {
    proxy.$refs["roleRef"].toggleRowSelection(row)
  }
}
/** å¤šé€‰æ¡†é€‰ä¸­æ•°æ® */
function handleSelectionChange(selection) {
  roleIds.value = selection.map(item => item.roleId)
}
/** ä¿å­˜é€‰ä¸­çš„æ•°æ®ç¼–号 */
function getRowKey(row) {
  return row.roleId
}
// æ£€æŸ¥è§’色状态
function checkSelectable(row) {
  return row.status === "0" ? true : false
}
/** å…³é—­æŒ‰é’® */
function close() {
  const obj = { path: "/system/user" }
  proxy.$tab.closeOpenPage(obj)
}
/** æäº¤æŒ‰é’® */
function submitForm() {
  const userId = form.value.userId
  const rIds = roleIds.value.join(",")
  updateAuthRole({ userId: userId, roleIds: rIds }).then(response => {
    proxy.$modal.msgSuccess("授权成功")
    close()
  })
}
(() => {
  const userId = route.params && route.params.userId
  if (userId) {
    loading.value = true
    getAuthRole(userId).then(response => {
      form.value = response.user
      roles.value = response.roles
      total.value = roles.value.length
      nextTick(() => {
        roles.value.forEach(row => {
          if (row.flag) {
            proxy.$refs["roleRef"].toggleRowSelection(row)
          }
        })
      })
      loading.value = false
    })
  }
})()
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,550 @@
<!--OA模块:用户管理-->
<template>
    <div class="app-container">
        <el-row :gutter="20" style="height: calc(100vh - 8em)">
            <splitpanes :horizontal="appStore.device === 'mobile'" class="default-theme">
                <!--部门数据-->
                <pane size="16">
                    <el-col style="padding: 10px">
                        <div class="head-container">
                            <el-input v-model="deptNames" placeholder="请输入部门名称" clearable prefix-icon="Search" style="margin-bottom: 20px" />
                        </div>
                        <div class="head-container">
                            <el-tree :data="deptOptions" :props="{ label: 'label', children: 'children' }" :expand-on-click-node="false" :filter-node-method="filterNode" ref="deptTreeRef" node-key="id" highlight-current default-expand-all @node-click="handleNodeClick" />
                        </div>
                    </el-col>
                </pane>
                <!--用户数据-->
                <pane size="84">
                    <el-col style="padding: 10px; height: 100%; display: flex; flex-direction: column;">
                        <el-form :model="queryParams" ref="queryRef" :inline="true" v-show="showSearch" label-width="68px">
                            <el-form-item label="登录账号" prop="userName">
                                <el-input v-model="queryParams.userName" placeholder="请输入登录账号" clearable style="width: 240px" @keyup.enter="handleQuery" />
                            </el-form-item>
                            <el-form-item label="手机号码" prop="phonenumber">
                                <el-input v-model="queryParams.phonenumber" placeholder="请输入手机号码" clearable style="width: 240px" @keyup.enter="handleQuery" />
                            </el-form-item>
                            <el-form-item label="状态" prop="status">
                                <el-select v-model="queryParams.status" placeholder="用户状态" clearable style="width: 240px">
                                    <el-option v-for="dict in sys_normal_disable" :key="dict.value" :label="dict.label" :value="dict.value" />
                                </el-select>
                            </el-form-item>
                            <el-form-item label="创建时间" style="width: 308px">
                                <el-date-picker v-model="dateRange" value-format="YYYY-MM-DD" type="daterange" range-separator="-" start-placeholder="开始日期" end-placeholder="结束日期"></el-date-picker>
                            </el-form-item>
                            <el-form-item>
                                <el-button type="primary" icon="Search" @click="handleQuery">搜索</el-button>
                                <el-button icon="Refresh" @click="resetQuery">重置</el-button>
                            </el-form-item>
                        </el-form>
                        <el-row :gutter="10" class="mb8">
                            <el-col :span="1.5">
                                <el-button type="primary" plain icon="Plus" @click="handleAdd" v-hasPermi="['system:user:add']">新增</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="success" plain icon="Edit" :disabled="single" @click="handleUpdate" v-hasPermi="['system:user:edit']">修改</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="danger" plain icon="Delete" :disabled="multiple" @click="handleDelete" v-hasPermi="['system:user:remove']">删除</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="info" plain icon="Upload" @click="handleImport" v-hasPermi="['system:user:import']">导入</el-button>
                            </el-col>
                            <el-col :span="1.5">
                                <el-button type="warning" plain icon="Download" @click="handleExport" v-hasPermi="['system:user:export']">导出</el-button>
                            </el-col>
                            <right-toolbar v-model:showSearch="showSearch" @queryTable="getList" :columns="columns"></right-toolbar>
                        </el-row>
                        <div style="flex: 1; overflow: hidden;">
                            <el-table v-loading="loading" :data="userList" height="100%" @selection-change="handleSelectionChange">
                                <el-table-column type="selection" width="50" align="center" />
                                <el-table-column label="用户编号" align="center" key="userId" prop="userId" v-if="columns[0].visible" />
                                <el-table-column label="登录账号" align="center" key="userName" prop="userName" v-if="columns[1].visible" :show-overflow-tooltip="true" />
                                <el-table-column label="用户昵称" align="center" key="nickName" prop="nickName" v-if="columns[2].visible" :show-overflow-tooltip="true" />
                                <el-table-column label="部门" align="center" key="deptNames" prop="deptNames" v-if="columns[3].visible" :show-overflow-tooltip="true" />
                                <el-table-column label="手机号码" align="center" key="phonenumber" prop="phonenumber" v-if="columns[4].visible" width="120" />
                                <el-table-column label="状态" align="center" key="status" v-if="columns[5].visible">
                                    <template #default="scope">
                                        <el-switch
                                            v-model="scope.row.status"
                                            active-value="0"
                                            inactive-value="1"
                                            @change="handleStatusChange(scope.row)"
                                        ></el-switch>
                                    </template>
                                </el-table-column>
                                <el-table-column label="创建时间" align="center" prop="createTime" v-if="columns[6].visible" width="160">
                                    <template #default="scope">
                                        <span>{{ parseTime(scope.row.createTime) }}</span>
                                    </template>
                                </el-table-column>
                                <el-table-column label="操作" align="center" width="150" class-name="small-padding fixed-width">
                                    <template #default="scope">
                                        <el-tooltip content="修改" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="Edit" @click="handleUpdate(scope.row)" v-hasPermi="['system:user:edit']"></el-button>
                                        </el-tooltip>
                                        <el-tooltip content="删除" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="Delete" @click="handleDelete(scope.row)" v-hasPermi="['system:user:remove']"></el-button>
                                        </el-tooltip>
                                        <el-tooltip content="重置密码" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="Key" @click="handleResetPwd(scope.row)" v-hasPermi="['system:user:resetPwd']"></el-button>
                                        </el-tooltip>
                                        <el-tooltip content="分配角色" placement="top" v-if="scope.row.userId !== 1">
                                            <el-button link type="primary" icon="CircleCheck" @click="handleAuthRole(scope.row)" v-hasPermi="['system:user:edit']"></el-button>
                                        </el-tooltip>
                                    </template>
                                </el-table-column>
                            </el-table>
                        </div>
                        <pagination v-show="total > 0" :total="total" v-model:page="queryParams.pageNum" v-model:limit="queryParams.pageSize" @pagination="getList" />
                    </el-col>
                </pane>
            </splitpanes>
        </el-row>
        <!-- æ·»åŠ æˆ–ä¿®æ”¹ç”¨æˆ·é…ç½®å¯¹è¯æ¡† -->
        <el-dialog :title="title" v-model="open" width="600px" append-to-body>
            <el-form :model="form" :rules="rules" ref="userRef" label-width="80px">
                <el-row>
                    <el-col :span="12">
                        <el-form-item v-if="form.userId == undefined" label="登录账号" prop="userName">
                            <el-input v-model="form.userName" placeholder="请输入用户名称" maxlength="30" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item v-if="form.userId == undefined" label="用户密码" prop="password">
                            <el-input v-model="form.password" placeholder="请输入用户密码" type="password" maxlength="20" show-password />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="用户昵称" prop="nickName">
                            <el-input v-model="form.nickName" placeholder="请输入用户昵称" maxlength="30" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="归属部门" prop="deptId">
                            <el-tree-select v-model="form.deptId" :data="enabledDeptOptions" :props="{ value: 'id', label: 'label', children: 'children' }" value-key="id" placeholder="请选择归属部门" check-strictly />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="岗位" prop="postIds">
                            <el-select v-model="form.postIds" multiple placeholder="请选择">
                                <el-option v-for="item in postOptions" :key="item.postId" :label="item.postName" :value="item.postId" :disabled="item.status == 1"></el-option>
                            </el-select>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="角色" prop="roleIds">
                            <el-select v-model="form.roleIds" multiple placeholder="请选择">
                                <el-option v-for="item in roleOptions" :key="item.roleId" :label="item.roleName" :value="item.roleId" :disabled="item.status == 1"></el-option>
                            </el-select>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="手机号码" prop="phonenumber">
                            <el-input v-model="form.phonenumber" placeholder="请输入手机号码" maxlength="11" />
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="邮箱" prop="email">
                            <el-input v-model="form.email" placeholder="请输入邮箱" maxlength="50" />
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="12">
                        <el-form-item label="用户性别">
                            <el-select v-model="form.sex" placeholder="请选择">
                                <el-option v-for="dict in sys_user_sex" :key="dict.value" :label="dict.label" :value="dict.value"></el-option>
                            </el-select>
                        </el-form-item>
                    </el-col>
                    <el-col :span="12">
                        <el-form-item label="状态">
                            <el-radio-group v-model="form.status">
                                <el-radio v-for="dict in sys_normal_disable" :key="dict.value" :value="dict.value">{{ dict.label }}</el-radio>
                            </el-radio-group>
                        </el-form-item>
                    </el-col>
                </el-row>
                <el-row>
                    <el-col :span="24">
                        <el-form-item label="备注">
                            <el-input v-model="form.remark" type="textarea" placeholder="请输入内容"></el-input>
                        </el-form-item>
                    </el-col>
                </el-row>
            </el-form>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="primary" @click="submitForm">ç¡® å®š</el-button>
                    <el-button @click="cancel">取 æ¶ˆ</el-button>
                </div>
            </template>
        </el-dialog>
        <!-- ç”¨æˆ·å¯¼å…¥å¯¹è¯æ¡† -->
        <el-dialog :title="upload.title" v-model="upload.open" width="400px" append-to-body>
            <el-upload ref="uploadRef" :limit="1" accept=".xlsx, .xls" :headers="upload.headers" :action="upload.url + '?updateSupport=' + upload.updateSupport" :disabled="upload.isUploading" :on-progress="handleFileUploadProgress" :on-success="handleFileSuccess" :auto-upload="false" drag>
                <el-icon class="el-icon--upload"><upload-filled /></el-icon>
                <div class="el-upload__text">将文件拖到此处,或<em>点击上传</em></div>
                <template #tip>
                    <div class="el-upload__tip text-center">
                        <div class="el-upload__tip">
                            <el-checkbox v-model="upload.updateSupport" />是否更新已经存在的用户数据
                        </div>
                        <span>仅允许导入xls、xlsx格式文件。</span>
                        <el-link type="primary" :underline="false" style="font-size: 12px; vertical-align: baseline" @click="importTemplate">下载模板</el-link>
                    </div>
                </template>
            </el-upload>
            <template #footer>
                <div class="dialog-footer">
                    <el-button type="primary" @click="submitFileForm">ç¡® å®š</el-button>
                    <el-button @click="upload.open = false">取 æ¶ˆ</el-button>
                </div>
            </template>
        </el-dialog>
    </div>
</template>
<script setup name="User">
import { getToken } from "@/utils/auth"
import useAppStore from '@/store/modules/app'
import { changeUserStatus, listUser, resetUserPwd, delUser, getUser, updateUser, addUser, deptTreeSelect } from "@/api/system/user"
import { Splitpanes, Pane } from "splitpanes"
import "splitpanes/dist/splitpanes.css"
const router = useRouter()
const appStore = useAppStore()
const { proxy } = getCurrentInstance()
const { sys_normal_disable, sys_user_sex } = proxy.useDict("sys_normal_disable", "sys_user_sex")
const userList = ref([])
const open = ref(false)
const loading = ref(true)
const showSearch = ref(true)
const ids = ref([])
const single = ref(true)
const multiple = ref(true)
const total = ref(0)
const title = ref("")
const dateRange = ref([])
const deptNames = ref("")
const deptOptions = ref(undefined)
const enabledDeptOptions = ref(undefined)
const initPassword = ref(undefined)
const postOptions = ref([])
const roleOptions = ref([])
/*** ç”¨æˆ·å¯¼å…¥å‚æ•° */
const upload = reactive({
    // æ˜¯å¦æ˜¾ç¤ºå¼¹å‡ºå±‚(用户导入)
    open: false,
    // å¼¹å‡ºå±‚标题(用户导入)
    title: "",
    // æ˜¯å¦ç¦ç”¨ä¸Šä¼ 
    isUploading: false,
    // æ˜¯å¦æ›´æ–°å·²ç»å­˜åœ¨çš„用户数据
    updateSupport: 0,
    // è®¾ç½®ä¸Šä¼ çš„请求头部
    headers: { Authorization: "Bearer " + getToken() },
    // ä¸Šä¼ çš„地址
    url: import.meta.env.VITE_APP_BASE_API + "/system/user/importData"
})
// åˆ—显隐信息
const columns = ref([
    { key: 0, label: `用户编号`, visible: true },
    { key: 1, label: `登录账号`, visible: true },
    { key: 2, label: `用户昵称`, visible: true },
    { key: 3, label: `部门`, visible: true },
    { key: 4, label: `手机号码`, visible: true },
    { key: 5, label: `状态`, visible: true },
    { key: 6, label: `创建时间`, visible: true }
])
const data = reactive({
    form: {},
    queryParams: {
        pageNum: 1,
        pageSize: 10,
        userName: undefined,
        phonenumber: undefined,
        status: undefined,
        deptId: undefined
    },
    rules: {
        userName: [{ required: true, message: "用户名称不能为空", trigger: "blur" }, { min: 2, max: 20, message: "用户名称长度必须介于 2 å’Œ 20 ä¹‹é—´", trigger: "blur" }],
        nickName: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
        password: [{ required: true, message: "用户密码不能为空", trigger: "blur" }, { min: 5, max: 20, message: "用户密码长度必须介于 5 å’Œ 20 ä¹‹é—´", trigger: "blur" }, { pattern: /^[^<>"'|\\]+$/, message: "不能包含非法字符:< > \" ' \\\ |", trigger: "blur" }],
        email: [{ type: "email", message: "请输入正确的邮箱地址", trigger: ["blur", "change"] }],
        phonenumber: [{ pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }],
        deptId: [{ required: true, message: "归属部门不能为空", trigger: "change" }],
        postIds: [{ required: true, message: "岗位不能为空", trigger: "change" }],
        roleIds: [{ required: true, message: "角色不能为空", trigger: "change" }]
    }
})
const { queryParams, form, rules } = toRefs(data)
/** é€šè¿‡æ¡ä»¶è¿‡æ»¤èŠ‚ç‚¹  */
const filterNode = (value, data) => {
    if (!value) return true
    return data.label.indexOf(value) !== -1
}
/** æ ¹æ®åç§°ç­›é€‰éƒ¨é—¨æ ‘ */
watch(deptNames, val => {
    proxy.$refs["deptTreeRef"].filter(val)
})
/** æŸ¥è¯¢ç”¨æˆ·åˆ—表 */
function getList() {
    loading.value = true
    listUser(proxy.addDateRange(queryParams.value, dateRange.value)).then(res => {
        loading.value = false
        userList.value = res.rows
        total.value = res.total
    })
}
/** æŸ¥è¯¢éƒ¨é—¨ä¸‹æ‹‰æ ‘结构 */
function getDeptTree() {
    deptTreeSelect().then(response => {
        deptOptions.value = response.data
        enabledDeptOptions.value = filterDisabledDept(JSON.parse(JSON.stringify(response.data)))
    })
}
/** è¿‡æ»¤ç¦ç”¨çš„部门 */
function filterDisabledDept(deptList) {
    return deptList.filter(dept => {
        if (dept.disabled) {
            return false
        }
        if (dept.children && dept.children.length) {
            dept.children = filterDisabledDept(dept.children)
        }
        return true
    })
}
/** èŠ‚ç‚¹å•å‡»äº‹ä»¶ */
function handleNodeClick(data) {
    queryParams.value.deptId = data.id
    handleQuery()
}
/** æœç´¢æŒ‰é’®æ“ä½œ */
function handleQuery() {
    queryParams.value.pageNum = 1
    getList()
}
/** é‡ç½®æŒ‰é’®æ“ä½œ */
function resetQuery() {
    dateRange.value = []
    proxy.resetForm("queryRef")
    queryParams.value.deptId = undefined
    proxy.$refs.deptTreeRef.setCurrentKey(null)
    handleQuery()
}
/** åˆ é™¤æŒ‰é’®æ“ä½œ */
function handleDelete(row) {
    const userIds = row.userId || ids.value
    proxy.$modal.confirm('是否确认删除用户编号为"' + userIds + '"的数据项?').then(function () {
        return delUser(userIds)
    }).then(() => {
        getList()
        proxy.$modal.msgSuccess("删除成功")
    }).catch(() => {})
}
/** å¯¼å‡ºæŒ‰é’®æ“ä½œ */
function handleExport() {
    proxy.download("system/user/export", {
        ...queryParams.value,
    },`user_${new Date().getTime()}.xlsx`)
}
/** ç”¨æˆ·çŠ¶æ€ä¿®æ”¹  */
function handleStatusChange(row) {
    let text = row.status === "0" ? "启用" : "停用"
    proxy.$modal.confirm('确认要"' + text + '""' + row.userName + '"用户吗?').then(function () {
        return changeUserStatus(row.userId, row.status)
    }).then(() => {
        proxy.$modal.msgSuccess(text + "成功")
    }).catch(function () {
        row.status = row.status === "0" ? "1" : "0"
    })
}
/** æ›´å¤šæ“ä½œ */
function handleCommand(command, row) {
    switch (command) {
        case "handleResetPwd":
            handleResetPwd(row)
            break
        case "handleAuthRole":
            handleAuthRole(row)
            break
        default:
            break
    }
}
/** è·³è½¬è§’色分配 */
function handleAuthRole(row) {
    const userId = row.userId
    router.push("/system/user-auth/role/" + userId)
}
/** é‡ç½®å¯†ç æŒ‰é’®æ“ä½œ */
function handleResetPwd(row) {
    proxy.$prompt('请输入"' + row.userName + '"的新密码', "提示", {
        confirmButtonText: "确定",
        cancelButtonText: "取消",
        closeOnClickModal: false,
        inputPattern: /^.{5,20}$/,
        inputErrorMessage: "用户密码长度必须介于 5 å’Œ 20 ä¹‹é—´",
        inputValidator: (value) => {
            if (/<|>|"|'|\||\\/.test(value)) {
                return "不能包含非法字符:< > \" ' \\\ |"
            }
        },
    }).then(({ value }) => {
        resetUserPwd(row.userId, value).then(response => {
            proxy.$modal.msgSuccess("修改成功,新密码是:" + value)
        })
    }).catch(() => {})
}
/** é€‰æ‹©æ¡æ•°  */
function handleSelectionChange(selection) {
    ids.value = selection.map(item => item.userId)
    single.value = selection.length != 1
    multiple.value = !selection.length
}
/** å¯¼å…¥æŒ‰é’®æ“ä½œ */
function handleImport() {
    upload.title = "用户导入"
    upload.open = true
}
/** ä¸‹è½½æ¨¡æ¿æ“ä½œ */
function importTemplate() {
    proxy.download("system/user/importTemplate", {
    }, `user_template_${new Date().getTime()}.xlsx`)
}
/**文件上传中处理 */
const handleFileUploadProgress = (event, file, fileList) => {
    upload.isUploading = true
}
/** æ–‡ä»¶ä¸Šä¼ æˆåŠŸå¤„ç† */
const handleFileSuccess = (response, file, fileList) => {
    upload.open = false
    upload.isUploading = false
    proxy.$refs["uploadRef"].handleRemove(file)
    proxy.$alert("<div style='overflow: auto;overflow-x: hidden;max-height: 70vh;padding: 10px 20px 0;'>" + response.msg + "</div>", "导入结果", { dangerouslyUseHTMLString: true })
    getList()
}
/** æäº¤ä¸Šä¼ æ–‡ä»¶ */
function submitFileForm() {
    proxy.$refs["uploadRef"].submit()
}
/** é‡ç½®æ“ä½œè¡¨å• */
function reset() {
    form.value = {
        userId: undefined,
        deptId: undefined,
        userName: undefined,
        nickName: undefined,
        password: undefined,
        phonenumber: undefined,
        email: undefined,
        sex: undefined,
        status: "0",
        remark: undefined,
        postIds: [],
        roleIds: []
    }
    proxy.resetForm("userRef")
}
/** å–消按钮 */
function cancel() {
    open.value = false
    reset()
}
/** æ–°å¢žæŒ‰é’®æ“ä½œ */
function handleAdd() {
    reset()
    getUser().then(response => {
        postOptions.value = response.posts
        roleOptions.value = response.roles
        open.value = true
        title.value = "添加用户"
        form.value.password = initPassword.value
    })
}
/** ä¿®æ”¹æŒ‰é’®æ“ä½œ */
function handleUpdate(row) {
    reset()
    const userId = row.userId || ids.value
    getUser(userId).then(response => {
        form.value = response.data
        postOptions.value = response.posts
        roleOptions.value = response.roles
        form.value.postIds = response.postIds
        form.value.roleIds = response.roleIds
        open.value = true
        title.value = "修改用户"
        form.password = ""
    })
}
/** æäº¤æŒ‰é’® */
function submitForm() {
    proxy.$refs["userRef"].validate(valid => {
        if (valid) {
            // å½’属部门虽然是单选,但后端需要传数组字段 deptIds
            const payload = {
                ...form.value,
                deptIds: form.value.deptId ? [form.value.deptId] : []
            }
            if (form.value.userId != undefined) {
                updateUser(payload).then(response => {
                    proxy.$modal.msgSuccess("修改成功")
                    open.value = false
                    getList()
                })
            } else {
                addUser(payload).then(response => {
                    proxy.$modal.msgSuccess("新增成功")
                    open.value = false
                    getList()
                })
            }
        }
    })
}
getDeptTree()
getList()
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,87 @@
<template>
   <div class="app-container">
      <el-row :gutter="20">
         <el-col :span="6" :xs="24">
            <el-card class="box-card">
               <template v-slot:header>
                 <div class="clearfix">
                   <span>个人信息</span>
                 </div>
               </template>
               <div>
                  <div class="text-center">
                     <userAvatar />
                  </div>
                  <ul class="list-group list-group-striped">
                     <li class="list-group-item">
                        <svg-icon icon-class="user" />用户名称
                        <div class="pull-right">{{ state.user.userName }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="phone" />手机号码
                        <div class="pull-right">{{ state.user.phonenumber }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="email" />用户邮箱
                        <div class="pull-right">{{ state.user.email }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="tree" />所属部门
                        <div class="pull-right" v-if="state.user.dept">{{ state.user.dept.deptName }} / {{ state.postGroup }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="peoples" />所属角色
                        <div class="pull-right">{{ state.roleGroup }}</div>
                     </li>
                     <li class="list-group-item">
                        <svg-icon icon-class="date" />创建日期
                        <div class="pull-right">{{ state.user.createTime }}</div>
                     </li>
                  </ul>
               </div>
            </el-card>
         </el-col>
         <el-col :span="18" :xs="24">
            <el-card>
               <template v-slot:header>
                 <div class="clearfix">
                   <span>基本资料</span>
                 </div>
               </template>
               <el-tabs v-model="activeTab">
                  <el-tab-pane label="基本资料" name="userinfo">
                     <userInfo :user="state.user" />
                  </el-tab-pane>
                  <el-tab-pane label="修改密码" name="resetPwd">
                     <resetPwd />
                  </el-tab-pane>
               </el-tabs>
            </el-card>
         </el-col>
      </el-row>
   </div>
</template>
<script setup name="Profile">
import userAvatar from "./userAvatar"
import userInfo from "./userInfo"
import resetPwd from "./resetPwd"
import { getUserProfile } from "@/api/system/user"
const activeTab = ref("userinfo")
const state = reactive({
  user: {},
  roleGroup: {},
  postGroup: {}
})
function getUser() {
  getUserProfile().then(response => {
    state.user = response.data
    state.roleGroup = response.roleGroup
    state.postGroup = response.postGroup
  })
}
getUser()
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/resetPwd.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,59 @@
<template>
   <el-form ref="pwdRef" :model="user" :rules="rules" label-width="80px">
      <el-form-item label="旧密码" prop="oldPassword">
         <el-input v-model="user.oldPassword" placeholder="请输入旧密码" type="password" show-password />
      </el-form-item>
      <el-form-item label="新密码" prop="newPassword">
         <el-input v-model="user.newPassword" placeholder="请输入新密码" type="password" show-password />
      </el-form-item>
      <el-form-item label="确认密码" prop="confirmPassword">
         <el-input v-model="user.confirmPassword" placeholder="请确认新密码" type="password" show-password/>
      </el-form-item>
      <el-form-item>
      <el-button type="primary" @click="submit">保存</el-button>
      <el-button type="danger" @click="close">关闭</el-button>
      </el-form-item>
   </el-form>
</template>
<script setup>
import { updateUserPwd } from "@/api/system/user"
const { proxy } = getCurrentInstance()
const user = reactive({
  oldPassword: undefined,
  newPassword: undefined,
  confirmPassword: undefined
})
const equalToPassword = (rule, value, callback) => {
  if (user.newPassword !== value) {
    callback(new Error("两次输入的密码不一致"))
  } else {
    callback()
  }
}
const rules = ref({
  oldPassword: [{ required: true, message: "旧密码不能为空", trigger: "blur" }],
  newPassword: [{ required: true, message: "新密码不能为空", trigger: "blur" }, { min: 6, max: 20, message: "长度在 6 åˆ° 20 ä¸ªå­—符", trigger: "blur" }, { pattern: /^[^<>"'|\\]+$/, message: "不能包含非法字符:< > \" ' \\\ |", trigger: "blur" }],
  confirmPassword: [{ required: true, message: "确认密码不能为空", trigger: "blur" }, { required: true, validator: equalToPassword, trigger: "blur" }]
})
/** æäº¤æŒ‰é’® */
function submit() {
  proxy.$refs.pwdRef.validate(valid => {
    if (valid) {
      updateUserPwd(user.oldPassword, user.newPassword).then(response => {
        proxy.$modal.msgSuccess("修改成功")
      })
    }
  })
}
/** å…³é—­æŒ‰é’® */
function close() {
  proxy.$tab.closePage()
}
</script>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userAvatar.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,168 @@
<template>
  <div class="user-info-head" @click="editCropper()">
    <img :src="options.img" title="点击上传头像" class="img-circle img-lg" />
    <el-dialog :title="title" v-model="open" width="800px" append-to-body @opened="modalOpened" @close="closeDialog">
      <el-row>
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <vue-cropper ref="cropper" :img="options.img" :info="true" :autoCrop="options.autoCrop"
            :autoCropWidth="options.autoCropWidth" :autoCropHeight="options.autoCropHeight" :fixedBox="options.fixedBox"
            :outputType="options.outputType" @realTime="realTime" v-if="visible" />
        </el-col>
        <el-col :xs="24" :md="12" :style="{ height: '350px' }">
          <div class="avatar-upload-preview">
            <img :src="options.previews.url" :style="options.previews.img" />
          </div>
        </el-col>
      </el-row>
      <br />
      <el-row>
        <el-col :lg="2" :md="2">
          <el-upload action="#" :http-request="requestUpload" :show-file-list="false" :before-upload="beforeUpload">
            <el-button>
              é€‰æ‹©
              <el-icon class="el-icon--right">
                <Upload />
              </el-icon>
            </el-button>
          </el-upload>
        </el-col>
        <el-col :lg="{ span: 1, offset: 2 }" :md="2">
          <el-button icon="Plus" @click="changeScale(1)"></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
          <el-button icon="Minus" @click="changeScale(-1)"></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
          <el-button icon="RefreshLeft" @click="rotateLeft()"></el-button>
        </el-col>
        <el-col :lg="{ span: 1, offset: 1 }" :md="2">
          <el-button icon="RefreshRight" @click="rotateRight()"></el-button>
        </el-col>
        <el-col :lg="{ span: 2, offset: 6 }" :md="2">
          <el-button type="primary" @click="uploadImg()">提 äº¤</el-button>
        </el-col>
      </el-row>
    </el-dialog>
  </div>
</template>
<script setup>
import "vue-cropper/dist/index.css"
import { VueCropper } from "vue-cropper"
import { uploadAvatar } from "@/api/system/user"
import useUserStore from "@/store/modules/user"
const userStore = useUserStore()
const { proxy } = getCurrentInstance()
const open = ref(false)
const visible = ref(false)
const title = ref("修改头像")
//图片裁剪数据
const options = reactive({
  img: userStore.avatar,     // è£å‰ªå›¾ç‰‡çš„地址
  autoCrop: true,            // æ˜¯å¦é»˜è®¤ç”Ÿæˆæˆªå›¾æ¡†
  autoCropWidth: 200,        // é»˜è®¤ç”Ÿæˆæˆªå›¾æ¡†å®½åº¦
  autoCropHeight: 200,       // é»˜è®¤ç”Ÿæˆæˆªå›¾æ¡†é«˜åº¦
  fixedBox: true,            // å›ºå®šæˆªå›¾æ¡†å¤§å° ä¸å…è®¸æ”¹å˜
  outputType: "png",         // é»˜è®¤ç”Ÿæˆæˆªå›¾ä¸ºPNG格式
  filename: 'avatar',        // æ–‡ä»¶åç§°
  previews: {}               //预览数据
})
/** ç¼–辑头像 */
function editCropper() {
  open.value = true
}
/** æ‰“开弹出层结束时的回调 */
function modalOpened() {
  visible.value = true
}
/** è¦†ç›–默认上传行为 */
function requestUpload() { }
/** å‘左旋转 */
function rotateLeft() {
  proxy.$refs.cropper.rotateLeft()
}
/** å‘右旋转 */
function rotateRight() {
  proxy.$refs.cropper.rotateRight()
}
/** å›¾ç‰‡ç¼©æ”¾ */
function changeScale(num) {
  num = num || 1
  proxy.$refs.cropper.changeScale(num)
}
/** ä¸Šä¼ é¢„处理 */
function beforeUpload(file) {
  if (file.type.indexOf("image/") == -1) {
    proxy.$modal.msgError("文件格式错误,请上传图片类型,如:JPG,PNG后缀的文件。")
  } else {
    const reader = new FileReader()
    reader.readAsDataURL(file)
    reader.onload = () => {
      options.img = reader.result
      options.filename = file.name
    }
  }
}
/** ä¸Šä¼ å›¾ç‰‡ */
function uploadImg() {
  proxy.$refs.cropper.getCropBlob(data => {
    let formData = new FormData()
    formData.append("avatarfile", data, options.filename)
    uploadAvatar(formData).then(response => {
      open.value = false
      options.img = import.meta.env.VITE_APP_BASE_API + '/profile/' + response.imgUrl
      userStore.avatar = options.img
      proxy.$modal.msgSuccess("修改成功")
      visible.value = false
    })
  })
}
/** å®žæ—¶é¢„览 */
function realTime(data) {
  options.previews = data
}
/** å…³é—­çª—口 */
function closeDialog() {
  options.img = userStore.avatar
  options.visible = false
}
</script>
<style lang='scss' scoped>
.user-info-head {
  position: relative;
  display: inline-block;
  height: 120px;
}
.user-info-head:hover:after {
  content: "+";
  position: absolute;
  left: 0;
  right: 0;
  top: 0;
  bottom: 0;
  color: #eee;
  background: rgba(0, 0, 0, 0.5);
  font-size: 24px;
  font-style: normal;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  cursor: pointer;
  line-height: 110px;
  border-radius: 50%;
}
</style>
src/views/officeProcessAutomation/SysAdmin/user-manage/profile/userInfo.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,67 @@
<template>
   <el-form ref="userRef" :model="form" :rules="rules" label-width="80px">
      <el-form-item label="用户昵称" prop="nickName">
         <el-input v-model="form.nickName" maxlength="30" />
      </el-form-item>
      <el-form-item label="手机号码" prop="phonenumber">
         <el-input v-model="form.phonenumber" maxlength="11" />
      </el-form-item>
      <el-form-item label="邮箱" prop="email">
         <el-input v-model="form.email" maxlength="50" />
      </el-form-item>
      <el-form-item label="性别">
         <el-radio-group v-model="form.sex">
            <el-radio value="0">男</el-radio>
            <el-radio value="1">女</el-radio>
         </el-radio-group>
      </el-form-item>
      <el-form-item>
      <el-button type="primary" @click="submit">保存</el-button>
      <el-button type="danger" @click="close">关闭</el-button>
      </el-form-item>
   </el-form>
</template>
<script setup>
import { updateUserProfile } from "@/api/system/user"
const props = defineProps({
  user: {
    type: Object
  }
})
const { proxy } = getCurrentInstance()
const form = ref({})
const rules = ref({
  nickName: [{ required: true, message: "用户昵称不能为空", trigger: "blur" }],
  email: [{ required: true, message: "邮箱地址不能为空", trigger: "blur" }, { type: "email", message: "请输入正确的邮箱地址", trigger: ["blur", "change"] }],
  phonenumber: [{ required: true, message: "手机号码不能为空", trigger: "blur" }, { pattern: /^1[3|4|5|6|7|8|9][0-9]\d{8}$/, message: "请输入正确的手机号码", trigger: "blur" }],
})
/** æäº¤æŒ‰é’® */
function submit() {
  proxy.$refs.userRef.validate(valid => {
    if (valid) {
      updateUserProfile(form.value).then(response => {
        proxy.$modal.msgSuccess("修改成功")
        props.user.phonenumber = form.value.phonenumber
        props.user.email = form.value.email
      })
    }
  })
}
/** å…³é—­æŒ‰é’® */
function close() {
  proxy.$tab.closePage()
}
// å›žæ˜¾å½“前登录用户信息
watch(() => props.user, user => {
  if (user) {
    form.value = { nickName: user.nickName, phonenumber: user.phonenumber, email: user.email, sex: user.sex }
  }
},{ immediate: true })
</script>
src/views/officeProcessAutomation/SysMonitor/cache-monitor/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,134 @@
<!--OA模块:缓存监控-->
<template>
  <div class="app-container">
    <el-row :gutter="10">
      <el-col :span="24" class="card-box">
        <el-card>
          <template #header><Monitor style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">基本信息</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%">
              <tbody>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">Redis版本</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_version }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">运行模式</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.redis_mode == "standalone" ? "单机" : "集群" }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">端口</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.tcp_port }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">客户端数</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.connected_clients }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">运行时间(天)</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.uptime_in_days }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">使用内存</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.used_memory_human }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">使用CPU</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ parseFloat(cache.info.used_cpu_user_children).toFixed(2) }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">内存配置</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.maxmemory_human }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">AOF是否开启</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.aof_enabled == "0" ? "否" : "是" }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">RDB是否成功</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.rdb_last_bgsave_status }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">Key数量</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.dbSize">{{ cache.dbSize }} </div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">网络入口/出口</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="cache.info">{{ cache.info.instantaneous_input_kbps }}kps/{{cache.info.instantaneous_output_kbps}}kps</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
      <el-col :span="12" class="card-box">
        <el-card>
          <template #header><PieChart style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">命令统计</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <div ref="commandstats" style="height: 420px" />
          </div>
        </el-card>
      </el-col>
      <el-col :span="12" class="card-box">
        <el-card>
          <template #header><Odometer style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">内存信息</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <div ref="usedmemory" style="height: 420px" />
          </div>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>
<script setup name="Cache">
import { getCache } from '@/api/monitor/cache'
import * as echarts from 'echarts'
const cache = ref([])
const commandstats = ref(null)
const usedmemory = ref(null)
const { proxy } = getCurrentInstance()
function getList() {
  proxy.$modal.loading("正在加载缓存监控数据,请稍候!")
  getCache().then(response => {
    proxy.$modal.closeLoading()
    cache.value = response.data
    const commandstatsIntance = echarts.init(commandstats.value, "macarons")
    commandstatsIntance.setOption({
      tooltip: {
        trigger: "item",
        formatter: "{a} <br/>{b} : {c} ({d}%)"
      },
      series: [
        {
          name: "命令",
          type: "pie",
          roseType: "radius",
          radius: [15, 95],
          center: ["50%", "38%"],
          data: response.data.commandStats,
          animationEasing: "cubicInOut",
          animationDuration: 1000
        }
      ]
    })
    const usedmemoryInstance = echarts.init(usedmemory.value, "macarons")
    usedmemoryInstance.setOption({
      tooltip: {
        formatter: "{b} <br/>{a} : " + cache.value.info.used_memory_human
      },
      series: [
        {
          name: "峰值",
          type: "gauge",
          min: 0,
          max: 1000,
          detail: {
            formatter: cache.value.info.used_memory_human
          },
          data: [
            {
              value: parseFloat(cache.value.info.used_memory_human),
              name: "内存消耗"
            }
          ]
        }
      ]
    })
    window.addEventListener("resize", () => {
      commandstatsIntance.resize()
      usedmemoryInstance.resize()
    })
  })
}
getList()
</script>
src/views/officeProcessAutomation/SysMonitor/data-monitor/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,14 @@
<!--OA模块:数据监控-->
<template>
  <div>
     <i-frame v-model:src="url"></i-frame>
  </div>
</template>
<script setup>
import iFrame from '@/components/iFrame'
import { ref } from 'vue'
const url = ref(import.meta.env.VITE_APP_BASE_API + '/druid/login.html')
</script>
src/views/officeProcessAutomation/SysMonitor/server-monitor/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,191 @@
<!--OA模块:服务器监控-->
<template>
  <div class="app-container">
    <el-row :gutter="10">
      <el-col :span="12" class="card-box">
        <el-card>
          <template #header><Cpu style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">CPU</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%;">
              <thead>
                <tr>
                  <th class="el-table__cell is-leaf"><div class="cell">属性</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">值</div></th>
                </tr>
              </thead>
              <tbody>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">核心数</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.cpuNum }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">用户使用率</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.used }}%</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">系统使用率</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.sys }}%</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">当前空闲率</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.cpu">{{ server.cpu.free }}%</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
      <el-col :span="12" class="card-box">
        <el-card>
          <template #header><Tickets style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">内存</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%;">
              <thead>
                <tr>
                  <th class="el-table__cell is-leaf"><div class="cell">属性</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">内存</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">JVM</div></th>
                </tr>
              </thead>
              <tbody>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">总内存</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem">{{ server.mem.total }}G</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.total }}M</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">已用内存</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem">{{ server.mem.used}}G</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.used}}M</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">剩余内存</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem">{{ server.mem.free }}G</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.free }}M</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">使用率</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.mem" :class="{'text-danger': server.mem.usage > 80}">{{ server.mem.usage }}%</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm" :class="{'text-danger': server.jvm.usage > 80}">{{ server.jvm.usage }}%</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
      <el-col :span="24" class="card-box">
        <el-card>
          <template #header><Monitor style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">服务器信息</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%;">
              <tbody>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">服务器名称</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.computerName }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">操作系统</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.osName }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">服务器IP</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.computerIp }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">系统架构</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.osArch }}</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
      <el-col :span="24" class="card-box">
        <el-card>
          <template #header><CoffeeCup style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">Java虚拟机信息</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%;table-layout:fixed;">
              <tbody>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">Java名称</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.name }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">Java版本</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.version }}</div></td>
                </tr>
                <tr>
                  <td class="el-table__cell is-leaf"><div class="cell">启动时间</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.startTime }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">运行时长</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.runTime }}</div></td>
                </tr>
                <tr>
                  <td colspan="1" class="el-table__cell is-leaf"><div class="cell">安装路径</div></td>
                  <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.home }}</div></td>
                </tr>
                <tr>
                  <td colspan="1" class="el-table__cell is-leaf"><div class="cell">项目路径</div></td>
                  <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.sys">{{ server.sys.userDir }}</div></td>
                </tr>
                <tr>
                  <td colspan="1" class="el-table__cell is-leaf"><div class="cell">运行参数</div></td>
                  <td colspan="3" class="el-table__cell is-leaf"><div class="cell" v-if="server.jvm">{{ server.jvm.inputArgs }}</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
      <el-col :span="24" class="card-box">
        <el-card>
          <template #header><MessageBox style="width: 1em; height: 1em; vertical-align: middle;" /> <span style="vertical-align: middle;">磁盘状态</span></template>
          <div class="el-table el-table--enable-row-hover el-table--medium">
            <table cellspacing="0" style="width: 100%;">
              <thead>
                <tr>
                  <th class="el-table__cell el-table__cell is-leaf"><div class="cell">盘符路径</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">文件系统</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">盘符类型</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">总大小</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">可用大小</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">已用大小</div></th>
                  <th class="el-table__cell is-leaf"><div class="cell">已用百分比</div></th>
                </tr>
              </thead>
              <tbody v-if="server.sysFiles">
                <tr v-for="(sysFile, index) in server.sysFiles" :key="index">
                  <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.dirName }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.sysTypeName }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.typeName }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.total }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.free }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell">{{ sysFile.used }}</div></td>
                  <td class="el-table__cell is-leaf"><div class="cell" :class="{'text-danger': sysFile.usage > 80}">{{ sysFile.usage }}%</div></td>
                </tr>
              </tbody>
            </table>
          </div>
        </el-card>
      </el-col>
    </el-row>
  </div>
</template>
<script setup>
import { getServer } from '@/api/monitor/server'
import {onMounted} from "vue";
const server = ref([])
const { proxy } = getCurrentInstance()
function getList() {
  proxy.$modal.loading("正在加载服务监控数据,请稍候!")
  getServer().then(response => {
    server.value = response.data
    proxy.$modal.closeLoading()
  })
}
onMounted(() => {
    getList();
});
</script>