yyb
15 小时以前 352f7bbb74f1b6c57b3d3e576849d0565932fbd4
src/views/officeProcessAutomation/AttendManage/overtime-apply/index.vue
@@ -1,4 +1,4 @@
<!--OA模块:加班申请(字段为前端占位,后期与后端接口对齐)-->
<!--OA模块:加班申请-->
<template>
  <div class="app-container">
    <div class="search_form mb20">
@@ -10,22 +10,20 @@
          placeholder="姓名或编号"
          clearable
          :prefix-icon="Search"
          @keyup.enter="handleQuery"
          @keyup.enter="onSearch"
        />
        <span class="search_title" style="margin-left: 12px">加班类型:</span>
        <el-select v-model="searchForm.overtimeType" placeholder="全部" clearable style="width: 180px">
          <el-option v-for="opt in OVERTIME_TYPE_OPTIONS" :key="opt.value" :label="opt.label" :value="opt.value" />
        </el-select>
        <el-button type="primary" style="margin-left: 10px" @click="handleQuery">搜索</el-button>
        <el-button type="primary" style="margin-left: 10px" @click="onSearch">搜索</el-button>
        <el-button @click="resetSearch">重置</el-button>
      </div>
      <div class="search_actions">
        <el-button type="success" plain @click="handleImportClick">导入</el-button>
        <el-button type="warning" plain @click="handleExport">导出</el-button>
        <el-button type="primary" @click="openFormDialog('add')">新增加班申请</el-button>
        <el-button type="primary" @click="openAddWithTemplate">新增加班申请</el-button>
      </div>
    </div>
    <input ref="importInputRef" type="file" accept="application/json,.json" class="sr-only-input" @change="onImportFile" />
    <div class="table_list">
      <PIMTable
@@ -35,611 +33,188 @@
        :page="page"
        :isSelection="false"
        :tableLoading="tableLoading"
        @pagination="pagination"
        @pagination="onPagination"
        :total="page.total"
      />
    </div>
    <!-- 新增 / 编辑 -->
    <el-dialog
      v-model="formDialog.visible"
      :title="formDialog.title"
      width="960px"
      append-to-body
      destroy-on-close
      class="overtime-apply-form-dialog"
      @closed="onFormClosed"
    <ApprovalInstanceSubmitDialog
      v-model="submitDialog.visible"
      :title="submitDialogTitle"
      :form="submitForm"
      :rules="submitFormRules"
      :fields="submitFormFields"
      :active-template="activeTemplate"
      :user-options="flowUserOptions"
      :is-edit="isSubmitEdit"
      :saving="submitSaving"
      :form-ref="submitFormRef"
      flow-attachments-only
      @submit="onSubmit"
    >
      <el-form ref="formRef" :model="form" :rules="formRules" label-width="140px" class="overtime-apply-form">
        <el-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>
      <template #before="{ form, fields }">
        <FormPayloadFields :fields="displayTemplateFields(fields)" :form-payload="form.formPayload" />
        <el-row :gutter="24">
          <el-col :span="12">
            <el-form-item label="加班时长">
              <el-input :model-value="overtimeHoursDisplay" readonly placeholder="根据起止时间自动计算">
              <el-input :model-value="overtimeHoursDisplay(form)" readonly placeholder="根据模板中加班时间自动计算">
                <template #append>小时</template>
              </el-input>
            </el-form-item>
          </el-col>
        </el-row>
        <el-row :gutter="24">
          <el-col :span="24">
            <el-form-item label="预设审批流">
              <div class="approval-flow-preview">
                <div
                  v-for="(node, index) in PRESET_APPROVAL_FLOW_NODES"
                  :key="node.roleCode"
                  class="flow-node-wrap"
                >
                  <div class="flow-node">
                    <span class="flow-node-order">{{ index + 1 }}</span>
                    <span class="flow-node-name">{{ node.roleName }}</span>
                  </div>
                  <el-icon v-if="index < PRESET_APPROVAL_FLOW_NODES.length - 1" class="flow-arrow">
                    <ArrowRight />
                  </el-icon>
                </div>
              </div>
              <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>
    </ApprovalInstanceSubmitDialog>
    <!-- 详情 -->
    <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="预设审批流">
          <div class="approval-flow-preview approval-flow-detail">
            <div
              v-for="(node, index) in detailApprovalFlowNodes"
              :key="node.roleCode"
              class="flow-node-wrap"
            >
              <div class="flow-node flow-node--compact">
                <span class="flow-node-order">{{ index + 1 }}</span>
                <span class="flow-node-name">{{ node.roleName }}</span>
              </div>
              <el-icon v-if="index < detailApprovalFlowNodes.length - 1" class="flow-arrow">
                <ArrowRight />
              </el-icon>
            </div>
          </div>
        </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>
    <ApprovalTemplateBindDialog
      v-model:visible="templateBindVisible"
      :module-key="APPROVAL_MODULE_KEYS.OVERTIME"
      skip-form-confirm
      @confirm="onTemplateBound"
      @closed="onTemplateBindClosed"
    />
    <!-- 附件列表 -->
    <el-dialog v-model="filesDialog.visible" title="附件" width="520px" append-to-body>
      <el-table v-if="filesDialog.row?.attachmentList?.length" :data="filesDialog.row.attachmentList" border>
        <el-table-column type="index" label="序号" width="60" align="center" />
        <el-table-column prop="name" label="文件名" min-width="200" show-overflow-tooltip />
        <el-table-column label="操作" width="100" align="center">
          <template #default="{ row }">
            <el-button link type="primary" @click="mockDownload(row)">下载</el-button>
          </template>
        </el-table-column>
      </el-table>
      <el-empty v-else description="暂无附件" />
      <template #footer>
        <div class="dialog-footer">
          <el-button type="primary" @click="filesDialog.visible = false">关 闭</el-button>
        </div>
      </template>
    </el-dialog>
    <ApprovalInstanceDetailDialog
      v-model="detailDialog.visible"
      title="加班申请详情"
      :row="detailRow"
      @edit="openEditFromDetail"
    />
  </div>
</template>
<script setup>
import { ArrowRight, Search } from "@element-plus/icons-vue";
import { Search } from "@element-plus/icons-vue";
import dayjs from "dayjs";
import FileUpload from "@/components/AttachmentUpload/file/index.vue";
import { userListNoPageByTenantId } from "@/api/system/user.js";
import { computed, getCurrentInstance, nextTick, reactive, ref, watch } from "vue";
import { ElMessage } from "element-plus";
import { getCurrentInstance, onMounted, reactive, ref } from "vue";
import FormPayloadFields from "../../ApproveManage/approve-list/components/FormPayloadFields.vue";
import ApprovalInstanceDetailDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceDetailDialog.vue";
import ApprovalInstanceSubmitDialog from "../../ApproveManage/approve-shared/components/ApprovalInstanceSubmitDialog.vue";
import ApprovalTemplateBindDialog from "../../ApproveManage/approve-shared/components/ApprovalTemplateBindDialog.vue";
import { buildInstanceTableColumns } from "../../ApproveManage/approve-shared/approvalInstanceFormConfigTable.js";
import { APPROVAL_MODULE_KEYS } from "../../ApproveManage/approve-shared/approvalModuleRegistry.js";
import { useApprovalInstanceModule } from "../../ApproveManage/approve-shared/useApprovalInstanceModule.js";
import { useFlowUserOptions } from "../../ApproveManage/approve-shared/useFlowUserOptions.js";
/** 加班类型(value 与后端对齐占位) */
const OVERTIME_TYPE_OPTIONS = [
  { label: "工作日加班", value: "weekday" },
  { label: "休息日加班", value: "weekend" },
  { label: "法定节假日加班", value: "holiday" },
];
/** 预设审批流节点(与流程引擎配置对齐占位) */
const PRESET_APPROVAL_FLOW_NODES = [
  { roleCode: "direct_leader", roleName: "直属上级", sortOrder: 1 },
  { roleCode: "dept_leader", roleName: "部门负责人", sortOrder: 2 },
];
function resolveApprovalFlowNodes(row) {
  const nodes = row?.approvalFlowNodes;
  if (Array.isArray(nodes) && nodes.length) {
    return [...nodes].sort((a, b) => (a.sortOrder ?? 0) - (b.sortOrder ?? 0));
  }
  return PRESET_APPROVAL_FLOW_NODES;
function isOvertimeDurationField(field) {
  const label = String(field?.label || "");
  return label.includes("加班时长") || field?.key === "overtimeHours";
}
function cloneApprovalFlowNodes() {
  return PRESET_APPROVAL_FLOW_NODES.map((n) => ({ ...n }));
function displayTemplateFields(fields = []) {
  return (fields || []).filter((f) => !isOvertimeDurationField(f));
}
function overtimeTypeLabel(v) {
  const hit = OVERTIME_TYPE_OPTIONS.find((x) => x.value === v);
  return hit?.label || "—";
function findOvertimeTimeTemplateField(fields = []) {
  return (
    fields.find((f) => f?.type === "datetimerange" && String(f?.label || "").includes("加班时间")) ||
    fields.find((f) => f?.type === "datetimerange") ||
    null
  );
}
const createEmptyForm = () => ({
  id: undefined,
  applicantId: "",
  applicantNo: "",
  applicantName: "",
  overtimeType: "",
  overtimeDate: "",
  overtimeStartTime: "",
  overtimeEndTime: "",
  overtimeReason: "",
  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 resolveOvertimeTimeRange(payload, overtimeTimeField) {
  if (!overtimeTimeField?.key) return { start: "", end: "" };
  const val = payload?.[overtimeTimeField.key];
  if (!Array.isArray(val) || val.length < 2) return { start: "", end: "" };
  return { start: val[0] || "", end: val[1] || "" };
}
function 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;
  return Math.round((t1.diff(t0, "millisecond") / 3600000) * 100) / 100;
}
function formatHours(v) {
  if (v == null || v === "") return "—";
  return `${v} 小时`;
function overtimeHoursDisplay(form) {
  const field = findOvertimeTimeTemplateField(form.formFieldDefs);
  const { start, end } = resolveOvertimeTimeRange(form.formPayload, field);
  const h = computeOvertimeHours(start, end);
  return h == null ? "" : String(h);
}
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: cloneApprovalFlowNodes(),
    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: cloneApprovalFlowNodes(),
    approvalResult: "approved",
    attachmentList: [],
    createTime: "2026-05-10 16:00:00",
  },
]);
const { proxy } = getCurrentInstance();
const searchForm = reactive({
  applicantKeyword: "",
  overtimeType: "",
});
const tableLoading = ref(false);
const page = reactive({
  current: 1,
  size: 10,
  total: 0,
const mod = useApprovalInstanceModule({
  moduleKey: APPROVAL_MODULE_KEYS.OVERTIME,
  beforeSave: validateOvertimeBeforeSave,
});
const filteredList = computed(() => {
  let list = [...allRows.value];
  const kw = (searchForm.applicantKeyword || "").trim().toLowerCase();
  if (kw) {
    list = list.filter((r) => {
      const name = (r.applicantName || "").toLowerCase();
      const no = (r.applicantNo || "").toLowerCase();
      return name.includes(kw) || no.includes(kw);
    });
const {
  tableData,
  tableLoading,
  page,
  detailDialog,
  detailRow,
  submitDialog,
  submitForm,
  submitFormRef,
  submitSaving,
  isSubmitEdit,
  activeTemplate,
  submitFormFields,
  submitFormRules,
  submitDialogTitle,
  templateBindVisible,
  handleQuery,
  initModuleList,
  pagination,
  openAddWithTemplate,
  onTemplateBound,
  onTemplateBindClosed,
  openEditFromDetail,
  submitInstanceForm,
  buildTableActions,
} = mod;
const { flowUserOptions, loadFlowUsers } = useFlowUserOptions();
function validateOvertimeBeforeSave() {
  const field = findOvertimeTimeTemplateField(submitForm.formFieldDefs);
  const { start, end } = resolveOvertimeTimeRange(submitForm.formPayload, field);
  if (computeOvertimeHours(start, end) == null) {
    ElMessage.warning("请检查模板中的加班时间,结束时间须晚于开始时间");
    throw new Error("invalid overtime time");
  }
  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 overtimeHoursDisplay = computed(() => {
  const h = computeOvertimeHours(form.overtimeStartTime, form.overtimeEndTime);
  return h == null ? "" : String(h);
});
function onOvertimeRangeChange() {
  nextTick(() => {
    formRef.value?.validateField?.("overtimeEndTime");
  });
}
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" }],
};
const tableColumn = buildInstanceTableColumns(tableData, buildTableActions);
const detailDialog = reactive({ visible: false });
const detailRow = ref({});
const detailApprovalFlowNodes = computed(() => resolveApprovalFlowNodes(detailRow.value));
const filesDialog = reactive({ visible: false, row: null });
const importInputRef = ref(null);
function handleQuery() {
  page.current = 1;
  tableLoading.value = true;
  setTimeout(() => {
    tableLoading.value = false;
  }, 150);
function onSearch() {
  handleQuery(searchForm);
}
function resetSearch() {
  searchForm.applicantKeyword = "";
  searchForm.overtimeType = "";
  handleQuery();
  onSearch();
}
function pagination(obj) {
  page.current = obj.page;
  page.size = obj.limit;
function onPagination(obj) {
  pagination(obj, searchForm);
}
function openDetail(row) {
  detailRow.value = { ...row };
  detailDialog.visible = true;
}
function openFiles(row) {
  filesDialog.row = row;
  filesDialog.visible = true;
}
function mockDownload(row) {
  const url = row.url || row.downloadURL || row.previewURL || row.previewUrl;
  if (url) {
    window.open(url, "_blank");
    return;
  }
  proxy?.$modal?.msgSuccess?.(`已模拟下载:${row.name}`);
async function onSubmit() {
  const ok = await submitInstanceForm({ skipValidate: true });
  if (ok) ElMessage.success(isSubmitEdit.value ? "修改成功" : "提交成功");
}
function handleExport() {
  const data = filteredList.value;
  const data = tableData.value;
  const blob = new Blob([JSON.stringify(data, null, 2)], { type: "application/json;charset=utf-8" });
  const url = URL.createObjectURL(blob);
  const a = document.createElement("a");
@@ -647,164 +222,13 @@
  a.download = `加班申请导出_${dayjs().format("YYYYMMDDHHmmss")}.json`;
  a.click();
  URL.revokeObjectURL(url);
  proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} 条(当前筛选结果,JSON)`);
  proxy?.$modal?.msgSuccess?.(`已导出 ${data.length} 条(当前页列表数据)`);
}
function handleImportClick() {
  importInputRef.value?.click?.();
}
function normalizeImportedRow(raw, idx) {
  const id = raw.id != null && String(raw.id).length ? `imp_${String(raw.id)}_${idx}` : `imp_${Date.now()}_${idx}`;
  const hours =
    raw.overtimeHours != null && raw.overtimeHours !== ""
      ? Number(raw.overtimeHours)
      : computeOvertimeHours(raw.overtimeStartTime, raw.overtimeEndTime);
  return {
    id,
    applicantId: raw.applicantId != null ? String(raw.applicantId) : `imp_user_${idx}`,
    applicantNo: raw.applicantNo ?? "",
    applicantName: raw.applicantName ?? "未知",
    overtimeType: raw.overtimeType || "weekday",
    overtimeDate: raw.overtimeDate ?? "",
    overtimeStartTime: raw.overtimeStartTime ?? "",
    overtimeEndTime: raw.overtimeEndTime ?? "",
    overtimeHours: hours == null || Number.isNaN(hours) ? 0 : Math.round(hours * 100) / 100,
    overtimeReason: raw.overtimeReason ?? "",
    approvalFlowNodes: Array.isArray(raw.approvalFlowNodes) && raw.approvalFlowNodes.length
      ? raw.approvalFlowNodes.map((n) => ({ ...n }))
      : cloneApprovalFlowNodes(),
    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 || [])),
    });
    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: cloneApprovalFlowNodes(),
    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(async () => {
  loadFlowUsers();
  await initModuleList(searchForm);
});
</script>
<style scoped>
@@ -821,96 +245,10 @@
.search_actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
}
.search_title {
  font-size: 14px;
  color: var(--el-text-color-regular);
}
.sr-only-input {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}
.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;
}
.approval-flow-preview {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 8px;
  width: 100%;
}
.approval-flow-detail {
  padding: 4px 0;
}
.flow-node-wrap {
  display: flex;
  align-items: center;
  gap: 8px;
}
.flow-node {
  display: flex;
  align-items: center;
  gap: 10px;
  min-width: 140px;
  padding: 10px 16px;
  background: var(--el-fill-color-light);
  border: 1px solid var(--el-border-color-lighter);
  border-radius: 8px;
}
.flow-node--compact {
  min-width: 120px;
  padding: 8px 12px;
}
.flow-node-order {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 22px;
  height: 22px;
  font-size: 12px;
  font-weight: 600;
  color: #fff;
  background: var(--el-color-primary);
  border-radius: 50%;
  flex-shrink: 0;
}
.flow-node-name {
  font-size: 14px;
  color: var(--el-text-color-primary);
}
.flow-arrow {
  font-size: 18px;
  color: var(--el-text-color-secondary);
}
.flow-tip {
  margin: 10px 0 0;
  font-size: 12px;
  line-height: 1.5;
  color: var(--el-text-color-secondary);
}
</style>