gaoluyang
116 分钟以前 a1e9064bb4c0a41c9a1e389cdd0f0f9bab009309
feat(hrm): 请假批量导入、加班选申请人、批量排班与节假日区间联动

- 请假申请新增导入按钮与导入结果弹窗(下载模板、逐行失败原因)
- 加班申请申请人改为员工下拉选择,带出姓名/工号/部门/岗位
- 排班管理新增「批量排班(按人群)」,按部门/岗位/在职状态一次排班
- 节假日列表改为按连续区间展示(日期范围 + 天数),编辑/删除以区间为单位
- 排班日历标记节假日「休/班」,自动排班与批量排班优先遵循节假日配置

Co-Authored-By: Claude Code <noreply@anthropic.com>
已添加1个文件
已修改11个文件
868 ■■■■■ 文件已修改
src/api/hrm/attendance/holiday/index.ts 87 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/employee/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/leave/index.ts 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/schedule/index.ts 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/holiday/data.ts 17 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/holiday/index.vue 65 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/holiday/modules/form.vue 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/index.vue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/leave/modules/import-form.vue 164 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/data.ts 30 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/overtime/modules/form.vue 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/schedule/index.vue 313 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/holiday/index.ts
@@ -3,7 +3,7 @@
import { requestClient } from '#/api/request';
export namespace HrmAttendanceHolidayApi {
  /** èŠ‚å‡æ—¥ */
  /** èŠ‚å‡æ—¥ï¼ˆé€æ—¥ï¼‰ */
  export interface Holiday {
    id?: number;
    name?: string;
@@ -14,12 +14,60 @@
    remark?: string;
    createTime?: string;
  }
  /** èŠ‚å‡æ—¥ï¼ˆæŒ‰è¿žç»­æ—¥æœŸåŒºé—´åˆå¹¶åŽçš„è¡Œï¼‰ */
  export interface HolidayGroup {
    id?: number;
    /** åŒºé—´å†…全部记录ID,编辑/删除时整体提交 */
    ids?: number[];
    name?: string;
    /** åŒºé—´å¼€å§‹æ—¥æœŸ */
    date?: string;
    /** åŒºé—´ç»“束日期 */
    endDate?: string;
    /** åŒºé—´å¤©æ•° */
    dayCount?: number;
    type?: number;
    isLegal?: number;
    remark?: string;
    createTime?: string;
  }
  /** æŒ‰æ—¥æœŸåŒºé—´æ›´æ–°å‚æ•° */
  export interface HolidayRangeUpdateReq {
    ids: number[];
    name: string;
    startDate: string;
    endDate: string;
    type: number;
    isLegal?: boolean;
    remark?: string;
  }
}
/** æŸ¥è¯¢èŠ‚å‡æ—¥åˆ†é¡µåˆ—è¡¨ */
/** æŸ¥è¯¢èŠ‚å‡æ—¥åˆ†é¡µåˆ—è¡¨ï¼ˆé€æ—¥ï¼Œå¯¼å‡º/兼容用) */
export function getAttendanceHolidayPage(params: PageParam) {
  return requestClient.get<PageResult<HrmAttendanceHolidayApi.Holiday>>(
    '/hrm/attendance/holiday/page',
    { params },
  );
}
/** æŸ¥è¯¢èŠ‚å‡æ—¥åˆ†é¡µåˆ—è¡¨ï¼ˆæŒ‰è¿žç»­æ—¥æœŸåŒºé—´åˆå¹¶ï¼Œåˆ—è¡¨é¡µä½¿ç”¨ï¼‰ */
export function getAttendanceHolidayGroupPage(params: PageParam) {
  return requestClient.get<PageResult<HrmAttendanceHolidayApi.HolidayGroup>>(
    '/hrm/attendance/holiday/page-grouped',
    { params },
  );
}
/** æŸ¥è¯¢æŒ‡å®šæ—¥æœŸèŒƒå›´çš„节假日(逐日,排班日历使用) */
export function getAttendanceHolidayList(params: {
  startDate: string;
  endDate: string;
}) {
  return requestClient.get<HrmAttendanceHolidayApi.Holiday[]>(
    '/hrm/attendance/holiday/list',
    { params },
  );
}
@@ -36,9 +84,31 @@
  return requestClient.post('/hrm/attendance/holiday/create', data);
}
/** æŒ‰æ—¥æœŸèŒƒå›´æ–°å¢žèŠ‚å‡æ—¥ï¼ˆåŒºé—´å†…æ¯ä¸ªè‡ªç„¶æ—¥å„ä¸€æ¡ï¼‰ */
export function createAttendanceHolidayRange(data: {
  name: string;
  startDate: string;
  endDate: string;
  type: number;
  isLegal?: boolean;
  remark?: string;
}) {
  return requestClient.post<number>(
    '/hrm/attendance/holiday/create-range',
    data,
  );
}
/** æ›´æ–°èŠ‚å‡æ—¥ */
export function updateAttendanceHoliday(data: HrmAttendanceHolidayApi.Holiday) {
  return requestClient.put('/hrm/attendance/holiday/update', data);
}
/** æŒ‰æ—¥æœŸåŒºé—´æ›´æ–°èŠ‚å‡æ—¥ï¼ˆåˆ é™¤åŽŸåŒºé—´è®°å½•åŽæŒ‰æ–°èµ·æ­¢æ—¥æœŸé‡å†™ï¼‰ */
export function updateAttendanceHolidayRange(
  data: HrmAttendanceHolidayApi.HolidayRangeUpdateReq,
) {
  return requestClient.put('/hrm/attendance/holiday/update-range', data);
}
/** åˆ é™¤èŠ‚å‡æ—¥ */
@@ -46,7 +116,16 @@
  return requestClient.delete(`/hrm/attendance/holiday/delete?id=${id}`);
}
/** æŒ‰æ—¥æœŸåŒºé—´åˆ é™¤èŠ‚å‡æ—¥ï¼ˆåŒºé—´å†…å…¨éƒ¨æ—¥æœŸï¼‰ */
export function deleteAttendanceHolidayRange(ids: number[]) {
  return requestClient.delete(
    `/hrm/attendance/holiday/delete-range?ids=${ids.join(',')}`,
  );
}
/** å¯¼å‡ºèŠ‚å‡æ—¥ Excel */
export function exportAttendanceHoliday(params: any) {
  return requestClient.download('/hrm/attendance/holiday/export-excel', { params });
}
  return requestClient.download('/hrm/attendance/holiday/export-excel', {
    params,
  });
}
src/api/hrm/employee/index.ts
@@ -97,6 +97,8 @@
    name: string;
    deptName?: string;
    postName?: string;
    /** å‘˜å·¥çŠ¶æ€ï¼š1-在职,2-试用,3-离职 */
    employeeStatus?: number;
  }
  /** å‘˜å·¥å¯¼å…¥ç»“æžœ */
src/api/hrm/leave/index.ts
@@ -28,6 +28,14 @@
    key: string;
    name: string;
  }
  /** å¯¼å…¥ç»“æžœ */
  export interface ImportResult {
    /** åˆ›å»ºæˆåŠŸçš„è®°å½•ï¼ˆæ‰‹æœºå· + æ—¥æœŸï¼‰ */
    createNames?: string[];
    /** å¯¼å…¥å¤±è´¥é›†åˆï¼Œkey ä¸ºã€Œç¬¬N行」,value ä¸ºå¤±è´¥åŽŸå›  */
    failureNames?: Record<string, string>;
  }
}
/** æŸ¥è¯¢è¯·å‡ç”³è¯·åˆ†é¡µåˆ—表 */
@@ -75,4 +83,16 @@
  return requestClient.get<HrmLeaveApi.ApproveProcess[]>(
    '/hrm/leave/approve-process-definition-list',
  );
}
/** ä¸‹è½½è¯·å‡ç”³è¯·å¯¼å…¥æ¨¡æ¿ */
export function importLeaveTemplate() {
  return requestClient.download('/hrm/leave/get-import-template');
}
/** æ‰¹é‡å¯¼å…¥è¯·å‡ç”³è¯·ï¼ˆç”Ÿæˆè‰ç¨¿ï¼‰ */
export function importLeave(file: File) {
  return requestClient.upload<HrmLeaveApi.ImportResult>('/hrm/leave/import', {
    file,
  });
}
src/api/hrm/schedule/index.ts
@@ -23,6 +23,30 @@
    shiftId?: number | null;
    remark?: string;
  }
  /** æŒ‰ç¾¤ä½“批量排班请求 */
  export interface BatchSaveReq {
    /** éƒ¨é—¨ID(含下级部门) */
    deptId: number;
    /** å²—位ID,为空表示不限岗位 */
    postId?: number;
    /** å‘˜å·¥çŠ¶æ€ï¼š1-在职,2-试用,3-离职;为空表示在职及试用 */
    employeeStatus?: number;
    /** å¼€å§‹æ—¥æœŸ yyyy-MM-dd */
    startDate: string;
    /** ç»“束日期 yyyy-MM-dd */
    endDate: string;
    /** ç­æ¬¡ID,为空表示休息(清除排班) */
    shiftId?: number | null;
    /** none-不休息,single-周日休息,double-周六日休息 */
    restMode?: 'double' | 'none' | 'single';
  }
  /** æŒ‰ç¾¤ä½“批量排班结果 */
  export interface BatchSaveResp {
    employeeCount?: number;
    itemCount?: number;
  }
}
/** æŸ¥è¯¢æŸæœˆæŽ’班列表 */
@@ -41,3 +65,11 @@
export function saveEmployeeSchedule(data: HrmEmployeeScheduleApi.ScheduleItem[]) {
  return requestClient.post('/hrm/employee-schedule/save', data);
}
/** æŒ‰éƒ¨é—¨+岗位批量排班(一类人一次排完) */
export function batchSaveEmployeeSchedule(data: HrmEmployeeScheduleApi.BatchSaveReq) {
  return requestClient.post<HrmEmployeeScheduleApi.BatchSaveResp>(
    '/hrm/employee-schedule/batch-save',
    data,
  );
}
src/views/hrm/attendance/holiday/data.ts
@@ -128,10 +128,23 @@
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmAttendanceHolidayApi.Holiday>['columns'] {
export function useGridColumns(): VxeTableGridOptions<HrmAttendanceHolidayApi.HolidayGroup>['columns'] {
  return [
    { field: 'name', title: '节假日名称', minWidth: 150 },
    { field: 'date', title: '日期', width: 120 },
    {
      field: 'date',
      title: '日期',
      minWidth: 220,
      slots: {
        default: 'dateRange',
      },
    },
    {
      field: 'dayCount',
      title: '天数',
      width: 90,
      formatter: ({ cellValue }) => (cellValue ? `${cellValue} å¤©` : '-'),
    },
    {
      field: 'type',
      title: '类型',
src/views/hrm/attendance/holiday/index.vue
@@ -8,7 +8,12 @@
import { message, Modal, Tag } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteAttendanceHoliday, exportAttendanceHoliday, getAttendanceHolidayPage } from '#/api/hrm/attendance/holiday';
import {
  deleteAttendanceHoliday,
  deleteAttendanceHolidayRange,
  exportAttendanceHoliday,
  getAttendanceHolidayGroupPage,
} from '#/api/hrm/attendance/holiday';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
@@ -29,34 +34,58 @@
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑节假日 */
function handleUpdate(row: HrmAttendanceHolidayApi.Holiday) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
/** æ—¥æœŸåŒºé—´æ ‡é¢˜ï¼Œå¦‚ 2026-10-01 ~ 2026-10-03(共3天) */
function rangeText(row: HrmAttendanceHolidayApi.HolidayGroup) {
  if (!row.endDate || row.endDate === row.date) {
    return row.date;
  }
  return `${row.date} ~ ${row.endDate}`;
}
/** ç¼–辑节假日(整个区间一起编辑) */
function handleUpdate(row: HrmAttendanceHolidayApi.HolidayGroup) {
  formModalApi.setData({ id: row.id, row, formType: 'update' }).open();
}
/** æŸ¥çœ‹èŠ‚å‡æ—¥ */
function handleDetail(row: HrmAttendanceHolidayApi.Holiday) {
  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
function handleDetail(row: HrmAttendanceHolidayApi.HolidayGroup) {
  formModalApi.setData({ id: row.id, row, formType: 'detail' }).open();
}
/** åˆ é™¤èŠ‚å‡æ—¥ */
async function handleDelete(id: number) {
/** åˆ é™¤èŠ‚å‡æ—¥ï¼ˆæ•´ä¸ªåŒºé—´ä¸€èµ·åˆ é™¤ï¼‰ */
async function handleDelete(row: HrmAttendanceHolidayApi.HolidayGroup) {
  const content =
    row.ids && row.ids.length > 1
      ? `确定要删除「${row.name}」${rangeText(row)}(共 ${row.dayCount} å¤©ï¼‰å—?`
      : '确定要删除该节假日吗?';
  Modal.confirm({
    title: '确认删除',
    content: '确定要删除该节假日吗?',
    content,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await deleteAttendanceHoliday(id);
      if (row.ids && row.ids.length > 0) {
        await deleteAttendanceHolidayRange(row.ids);
      } else if (row.id != null) {
        await deleteAttendanceHoliday(row.id);
      }
      message.success('删除成功');
      handleRefresh();
    },
  });
}
/** æœç´¢æ¡ä»¶ï¼šæ—¥æœŸåŒºé—´å­—段 date è½¬æˆåŽç«¯æŽ¥æ”¶çš„ startDate/endDate */
function buildQueryParams(formValues: Record<string, any>) {
  const { date, ...rest } = formValues;
  const [startDate, endDate] = Array.isArray(date) ? date : [];
  return { ...rest, startDate, endDate };
}
/** å¯¼å‡ºèŠ‚å‡æ—¥ */
async function handleExport() {
  const data = await exportAttendanceHoliday(await gridApi.formApi.getValues());
  const params = buildQueryParams(await gridApi.formApi.getValues());
  const data = await exportAttendanceHoliday(params);
  downloadFileFromBlobPart({ fileName: '节假日配置.xls', source: data });
}
@@ -71,10 +100,10 @@
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getAttendanceHolidayPage({
          await getAttendanceHolidayGroupPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
            ...buildQueryParams(formValues),
          }),
      },
    },
@@ -86,7 +115,7 @@
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmAttendanceHolidayApi.Holiday>,
  } as VxeTableGridOptions<HrmAttendanceHolidayApi.HolidayGroup>,
});
</script>
@@ -114,6 +143,12 @@
          ]"
        />
      </template>
      <template #dateRange="{ row }">
        <span>{{ row.date }}</span>
        <span v-if="row.endDate && row.endDate !== row.date">
          ~ {{ row.endDate }}
        </span>
      </template>
      <template #isLegal="{ row }">
        <Tag v-if="row.isLegal === 1" color="red">法定</Tag>
        <span v-else>-</span>
@@ -137,7 +172,7 @@
              type: 'link',
              danger: true,
              auth: ['hrm:attendance-holiday:delete'],
              onClick: handleDelete.bind(null, row.id!),
              onClick: handleDelete.bind(null, row),
            },
          ]"
        />
src/views/hrm/attendance/holiday/modules/form.vue
@@ -9,13 +9,20 @@
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createAttendanceHoliday, getAttendanceHoliday, updateAttendanceHoliday } from '#/api/hrm/attendance/holiday';
import {
  createAttendanceHolidayRange,
  getAttendanceHoliday,
  updateAttendanceHoliday,
  updateAttendanceHolidayRange,
} from '#/api/hrm/attendance/holiday';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
/** å½“前编辑的区间内全部记录ID(列表按连续区间合并展示,编辑时整体重写) */
const rangeIds = ref<number[]>([]);
const isDetail = computed(() => formType.value === 'detail');
const getTitle = computed(() => {
@@ -57,14 +64,35 @@
      date: string | string[];
    };
    try {
      const dates = Array.isArray(data.date) ? data.date : [data.date];
      const startDate = dates[0]!;
      const endDate = dates[1] || dates[0]!;
      if (formType.value === 'update') {
        data.date = Array.isArray(data.date) ? data.date[0] : data.date;
        await updateAttendanceHoliday(data);
      } else {
        const dates = Array.isArray(data.date) ? data.date : [data.date];
        for (const date of dates) {
          await createAttendanceHoliday({ ...data, date });
        if (rangeIds.value.length > 0) {
          // ç¼–辑的是合并后的区间:整体删除原记录,按新起止日期重写
          await updateAttendanceHolidayRange({
            ids: rangeIds.value,
            name: data.name!,
            startDate,
            endDate,
            type: data.type!,
            isLegal: Boolean(data.isLegal),
            remark: data.remark,
          });
        } else {
          data.date = startDate;
          await updateAttendanceHoliday(data);
        }
      } else {
        // æŒ‰æ—¥æœŸåŒºé—´ä¸€æ¬¡æ€§å†™å…¥åŒºé—´å†…每一天(法定节假日 10-01~10-03 éœ€å†™æ»¡ 3 å¤©ï¼‰
        await createAttendanceHolidayRange({
          name: data.name!,
          startDate,
          endDate,
          type: data.type!,
          isLegal: Boolean(data.isLegal),
          remark: data.remark,
        });
      }
      await modalApi.close();
      emit('success');
@@ -77,11 +105,29 @@
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    const data = modalApi.getData<{
      formType: FormType;
      id?: number;
      row?: HrmAttendanceHolidayApi.HolidayGroup;
    }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(data.formType) });
    formApi.setDisabled(data.formType === 'detail');
    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
    // åˆ—表行已带区间信息(id/ids/date/endDate),直接带入,无需再查单日记录
    if (data?.row) {
      rangeIds.value = data.row.ids ?? (data.row.id != null ? [data.row.id] : []);
      await formApi.setValues({
        id: data.row.id,
        name: data.row.name,
        date: [data.row.date, data.row.endDate || data.row.date],
        type: data.row.type,
        isLegal: data.row.isLegal,
        remark: data.row.remark,
      });
      return;
    }
    rangeIds.value = [];
    if (!data?.id) {
      return;
    }
src/views/hrm/leave/index.vue
@@ -23,11 +23,22 @@
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
import ImportForm from './modules/import-form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
/** æ‰“开批量导入 */
function handleImport() {
  importModalApi.open();
}
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
@@ -148,6 +159,7 @@
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="请假申请列表">
      <template #toolbar-tools>
        <TableAction
@@ -159,6 +171,13 @@
              auth: ['hrm:leave:create'],
              onClick: handleCreate,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['hrm:leave:import'],
              onClick: handleImport,
            },
          ]"
        />
      </template>
src/views/hrm/leave/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,164 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import type { HrmLeaveApi } from '#/api/hrm/leave';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Progress, Upload } from 'ant-design-vue';
import { importLeave, importLeaveTemplate } from '#/api/hrm/leave';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const importResult = ref<HrmLeaveApi.ImportResult>();
const importing = ref(false);
const progressPercent = ref(0);
let progressTimer: ReturnType<typeof setInterval> | undefined;
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    let shouldClose = false;
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    if (importing.value) return;
    modalApi.lock();
    importing.value = true;
    startMockProgress();
    try {
      importResult.value = await importLeave(fileList.value[0] as File);
      stopMockProgress();
      progressPercent.value = 100;
      await delay(300);
      emit('success');
      const failedCount = failureCount.value;
      if (failedCount > 0) {
        message.warning(
          `导入完成:成功 ${createCount.value} æ¡ï¼Œå¤±è´¥ ${failedCount} æ¡ï¼Œè¯·æŸ¥çœ‹ä¸‹æ–¹å¤±è´¥æ˜Žç»†`,
        );
      } else {
        message.success('导入完成');
        shouldClose = true;
      }
    } finally {
      stopMockProgress();
      importing.value = false;
      modalApi.unlock();
      if (shouldClose) {
        await modalApi.close();
      }
    }
  },
});
/** æ¨¡æ‹Ÿè¿›åº¦æ¡ï¼šè¯·æ±‚期间从 0 èµ°åˆ° 90% */
function startMockProgress() {
  progressPercent.value = 0;
  progressTimer = setInterval(() => {
    if (progressPercent.value < 90) {
      progressPercent.value = Math.min(
        90,
        progressPercent.value + Math.random() * 12,
      );
    }
  }, 300);
}
function stopMockProgress() {
  if (progressTimer) {
    clearInterval(progressTimer);
    progressTimer = undefined;
  }
}
function delay(ms: number) {
  return new Promise((resolve) => setTimeout(resolve, ms));
}
function beforeUpload(file: FileType) {
  fileList.value = [file];
  importResult.value = undefined;
  return false;
}
function handleRemove() {
  fileList.value = [];
  importResult.value = undefined;
}
async function handleDownloadTemplate() {
  const data = await importLeaveTemplate();
  downloadFileFromBlobPart({ fileName: '请假申请导入模板.xls', source: data });
}
const createCount = computed(() => importResult.value?.createNames?.length ?? 0);
const failureCount = computed(
  () => Object.keys(importResult.value?.failureNames ?? {}).length,
);
</script>
<template>
  <Modal title="导入请假申请" class="w-[640px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-2 text-gray-500">
        ä»…允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶ï¼›ã€Œç”³è¯·äººæ‰‹æœºå·ã€éœ€å¯¹åº”系统内员工,「请假类型」填写字典名称(如年假、事假、病假)
      </div>
      <div class="mt-2 text-gray-500">
        å¯¼å…¥åŽç”Ÿæˆçš„æ˜¯è‰ç¨¿ï¼Œéœ€åœ¨åˆ—表中逐条提交审批
      </div>
      <!-- å¯¼å…¥è¿›åº¦æ¡ -->
      <div v-if="importing" class="mt-4">
        <div class="mb-2 text-sm text-gray-600">
          æ­£åœ¨å¯¼å…¥è¯·å‡ç”³è¯·æ•°æ®ï¼Œè¯·ç¨å€™...
        </div>
        <Progress :percent="progressPercent" status="active" />
      </div>
      <!-- å¯¼å…¥ç»“æžœ -->
      <div
        v-if="importResult"
        class="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3"
      >
        <div class="mb-2 font-medium">导入结果</div>
        <div class="mb-2 flex gap-4">
          <span class="text-green-600">成功创建:{{ createCount }} æ¡</span>
          <span class="text-red-600">失败:{{ failureCount }} æ¡</span>
        </div>
        <div v-if="createCount > 0" class="mb-2 max-h-24 overflow-y-auto">
          <div
            v-for="name in importResult.createNames"
            :key="name"
            class="text-sm leading-6 text-green-600"
          >
            {{ name }}
          </div>
        </div>
        <div v-if="failureCount > 0" class="max-h-40 overflow-y-auto">
          <div
            v-for="(reason, row) in importResult.failureNames"
            :key="row"
            class="text-sm leading-6 text-red-500"
          >
            {{ row }}:{{ reason }}
          </div>
        </div>
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/hrm/overtime/data.ts
@@ -1,7 +1,9 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import type { HrmOvertimeApi } from '#/api/hrm/overtime';
import { getEmployeeSimpleList } from '#/api/hrm/employee';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
@@ -33,8 +35,28 @@
    },
    {
      fieldName: 'employeeId',
      component: 'Input',
      dependencies: { triggerFields: [''], show: () => false },
      label: '申请人',
      component: 'ApiSelect',
      componentProps: {
        api: async () => {
          const list = await getEmployeeSimpleList();
          // ç¦»èŒå‘˜å·¥ä¸å¯é€‰ä½œåŠ ç­ç”³è¯·äºº
          return list.filter((item) => item.employeeStatus !== 3);
        },
        labelField: 'name',
        labelFn: (item: HrmEmployeeApi.EmployeeSimple) =>
          `${item.name}(${item.employeeNo})`,
        valueField: 'id',
        showSearch: true,
        filterOption: (input: string, option: any) =>
          String(option.label ?? '')
            .toLowerCase()
            .includes(input.toLowerCase()),
        placeholder: '请选择申请人',
        style: { width: '100%' },
      },
      rules: 'selectRequired',
      formItemClass: 'col-span-2',
    },
    {
      fieldName: 'baseInfoDivider',
@@ -44,9 +66,9 @@
    },
    {
      fieldName: 'employeeName',
      label: '申请人',
      label: '申请人姓名',
      component: 'Input',
      componentProps: { disabled: true, placeholder: '系统自动带出' },
      componentProps: { disabled: true, placeholder: '选择申请人后自动带出' },
    },
    {
      fieldName: 'employeeNo',
src/views/hrm/overtime/modules/form.vue
@@ -7,12 +7,9 @@
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import {
  createOvertime,
  getCurrentOvertimeApplicant,
  getOvertime,
  updateOvertime,
} from '#/api/hrm/overtime';
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import { getEmployeeSimpleList } from '#/api/hrm/employee';
import { createOvertime, getOvertime, updateOvertime } from '#/api/hrm/overtime';
import { $t } from '#/locales';
import { useVbenModal } from '#/packages/effects/common-ui/src';
@@ -73,24 +70,34 @@
      const result = calculateDuration(values);
      void formApi.setValues(result);
    }
    if (changedFields.includes('employeeId')) {
      void fillEmployeeInfo(values.employeeId);
    }
    if (changedFields.includes('relatedType') && !values.relatedType) {
      void formApi.setFieldValue('relatedCode', undefined);
    }
  },
});
async function setCurrentEmployee() {
  const employee = await getCurrentOvertimeApplicant();
/** å‘˜å·¥æ¡£æ¡ˆç¼“存:选择申请人时用于回填姓名/工号/部门/岗位 */
let employeeCache: HrmEmployeeApi.EmployeeSimple[] = [];
async function loadEmployeeCache() {
  if (employeeCache.length === 0) {
    employeeCache = await getEmployeeSimpleList();
  }
  return employeeCache;
}
/** é€‰æ‹©ç”³è¯·äººåŽå›žå¡«å…¶åŸºç¡€ä¿¡æ¯ */
async function fillEmployeeInfo(employeeId?: number) {
  const list = await loadEmployeeCache();
  const employee = list.find((item) => item.id === employeeId);
  await formApi.setValues({
    employeeId: employee.employeeId,
    employeeName: employee.employeeName,
    employeeNo: employee.employeeNo,
    deptName: employee.deptName,
    postName: employee.postName,
    mealDeduction: false,
    mealDeductionHours: 1,
    crossDay: false,
    duration: 0,
    employeeName: employee?.name,
    employeeNo: employee?.employeeNo,
    deptName: employee?.deptName,
    postName: employee?.postName,
  });
}
@@ -105,7 +112,7 @@
    const data = (await formApi.getValues()) as HrmOvertimeApi.Overtime;
    if (!data.employeeId) {
      message.warning('当前账号未关联员工档案,暂时无法提交加班申请');
      message.warning('请选择申请人');
      return;
    }
    if (!data.duration || data.duration <= 0) {
@@ -138,7 +145,19 @@
    modalApi.setState({ showConfirmButton: !isDetail.value });
    if (!data.id) {
      await setCurrentEmployee();
      // æ–°å¢žï¼šä¸é»˜è®¤å½“前账号,由用户选择申请人
      await loadEmployeeCache();
      await formApi.setValues({
        employeeId: undefined,
        employeeName: undefined,
        employeeNo: undefined,
        deptName: undefined,
        postName: undefined,
        mealDeduction: false,
        mealDeductionHours: 1,
        crossDay: false,
        duration: 0,
      });
      return;
    }
src/views/hrm/schedule/index.vue
@@ -17,6 +17,7 @@
  Modal,
  Pagination,
  Radio,
  RangePicker,
  Segmented,
  Select,
  TreeSelect,
@@ -24,10 +25,16 @@
import { Page } from '#/packages/effects/common-ui/src';
import { getAttendanceHolidayList } from '#/api/hrm/attendance/holiday';
import { getEmployeePage } from '#/api/hrm/employee';
import { getShiftList } from '#/api/hrm/shift';
import { getEmployeeScheduleList, saveEmployeeSchedule } from '#/api/hrm/schedule';
import {
  batchSaveEmployeeSchedule,
  getEmployeeScheduleList,
  saveEmployeeSchedule,
} from '#/api/hrm/schedule';
import { getDeptList } from '#/api/system/dept';
import { getSimplePostList } from '#/api/system/post';
import { handleTree } from '#/packages/utils/src';
import {
@@ -59,6 +66,10 @@
// ========== æ•°æ® ==========
const employees = ref<HrmEmployeeApi.Employee[]>([]);
const shiftList = ref<HrmShiftApi.Shift[]>([]);
/**
 * å½“前视图日期范围内的节假日配置:key = yyyy-MM-dd,value = 1-休息日 / 2-工作日
 */
const holidayMap = ref(new Map<string, number>());
const loading = ref(false);
const saving = ref(false);
/** æ˜¯å¦å·²æ‰§è¡Œè¿‡æŸ¥è¯¢ */
@@ -150,8 +161,14 @@
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      // èŠ‚å‡æ—¥é…ç½®ä¼˜å…ˆï¼šä¼‘æ¯æ—¥å¼ºåˆ¶ä¼‘æ¯ï¼Œå·¥ä½œæ—¥å¼ºåˆ¶æŽ’ç­ï¼ˆå«è°ƒä¼‘ä¸Šç­çš„å‘¨æœ«ï¼‰
      const holidayType = getHolidayType(date);
      let rest = false;
      if (autoRestMode.value === REST_MODE.DOUBLE) {
      if (holidayType === 1) {
        rest = true;
      } else if (holidayType === 2) {
        rest = false;
      } else if (autoRestMode.value === REST_MODE.DOUBLE) {
        rest = week === 0 || week === 6;
      } else if (autoRestMode.value === REST_MODE.SINGLE) {
        rest = week === 0;
@@ -162,6 +179,100 @@
  scheduleMap.value = map;
  autoOpen.value = false;
  message.success('自动排班已生成,可手动微调后保存');
}
// ========== æŒ‰ç¾¤ä½“批量排班(一类人一次排完) ==========
const batchOpen = ref(false);
const batchDeptId = ref<number>();
const batchPostId = ref<number>();
/** 0-在职及试用,1-在职,2-试用,3-离职 */
const batchEmployeeStatus = ref<number>(0);
/** 0 è¡¨ç¤ºä¼‘息(清除排班) */
const batchShiftId = ref<number>();
const batchRestMode = ref<RestMode>(REST_MODE.DOUBLE);
const batchDateRange = ref<[string, string]>();
const batchSaving = ref(false);
const postList = ref<{ label: string; value: number }[]>([]);
const batchEmployeeStatusOptions = [
  { label: '在职及试用', value: 0 },
  { label: '在职', value: 1 },
  { label: '试用', value: 2 },
  { label: '离职', value: 3 },
];
const batchShiftOptions = computed(() => [
  { label: '休息(清除排班)', value: 0 },
  ...shiftList.value.map((shift) => ({
    label: `${shift.name} (${shift.startTime}~${shift.endTime})`,
    value: shift.id!,
  })),
]);
async function loadPostList() {
  if (postList.value.length > 0) {
    return;
  }
  const posts = await getSimplePostList();
  postList.value = posts.map((post) => ({
    label: post.name,
    value: post.id!,
  }));
}
async function openBatch() {
  if (!month.value) {
    message.warning('请先选择月份');
    return;
  }
  if (shiftList.value.length === 0) {
    shiftList.value = await getShiftList();
  }
  batchDeptId.value = deptId.value;
  batchPostId.value = undefined;
  batchEmployeeStatus.value = 0;
  batchShiftId.value = shiftList.value[0]?.id;
  batchRestMode.value = REST_MODE.DOUBLE;
  batchDateRange.value = [
    `${month.value}-01`,
    dayjs(`${month.value}-01`).endOf('month').format('YYYY-MM-DD'),
  ];
  batchOpen.value = true;
  await loadPostList();
}
async function applyBatchSchedule() {
  if (!batchDeptId.value) {
    message.warning('请选择部门');
    return;
  }
  if (!batchDateRange.value?.[0] || !batchDateRange.value?.[1]) {
    message.warning('请选择排班日期范围');
    return;
  }
  if (batchShiftId.value == null) {
    message.warning('请选择排班班次或休息');
    return;
  }
  batchSaving.value = true;
  try {
    const result = await batchSaveEmployeeSchedule({
      deptId: batchDeptId.value,
      postId: batchPostId.value,
      employeeStatus: batchEmployeeStatus.value || undefined,
      startDate: batchDateRange.value[0],
      endDate: batchDateRange.value[1],
      shiftId: batchShiftId.value === 0 ? null : batchShiftId.value,
      restMode: batchRestMode.value,
    });
    message.success(
      `批量排班完成:共 ${result.employeeCount ?? 0} åå‘˜å·¥ï¼Œå†™å…¥/清除 ${result.itemCount ?? 0} æ¡æŽ’班`,
    );
    batchOpen.value = false;
    await loadData();
  } finally {
    batchSaving.value = false;
  }
}
/** ç­æ¬¡ id â†’ ç­æ¬¡ æ˜ å°„ */
@@ -237,6 +348,67 @@
      };
    });
});
/** æŸæ—¥èŠ‚å‡æ—¥ç±»åž‹ï¼š1-休息日,2-工作日,undefined-未配置 */
function getHolidayType(date: string): number | undefined {
  return holidayMap.value.get(date);
}
/** èŠ‚å‡æ—¥è§’æ ‡æ–‡æ¡ˆï¼šä¼‘ / ç­ */
function holidayLabel(date: string): string {
  const type = getHolidayType(date);
  if (type === 1) {
    return '休';
  }
  if (type === 2) {
    return '班';
  }
  return '';
}
/** åŠ è½½å½“å‰è§†å›¾æ—¥æœŸèŒƒå›´å†…çš„èŠ‚å‡æ—¥é…ç½® */
async function loadHolidays() {
  const list = viewDays.value;
  const first = list[0];
  const last = list[list.length - 1];
  if (!first || !last) {
    holidayMap.value = new Map();
    return;
  }
  const holidays = await getAttendanceHolidayList({
    startDate: first.date,
    endDate: last.date,
  });
  const map = new Map<string, number>();
  for (const item of holidays) {
    if (item.date) {
      map.set(item.date, item.type ?? 1);
    }
  }
  holidayMap.value = map;
}
/** è§†å›¾æ—¥æœŸå˜åŒ–(切月/切周/切视图)时重新拉取节假日 */
watch(
  () => viewDays.value.map((vd) => vd.date).join(','),
  (key) => {
    if (key) {
      loadHolidays();
    }
  },
);
/** èŠ‚å‡æ—¥æ ‡è®°çš„å•å…ƒæ ¼/表头配色 */
function holidayClass(date: string): string {
  const type = getHolidayType(date);
  if (type === 1) {
    return 'holiday-rest';
  }
  if (type === 2) {
    return 'holiday-work';
  }
  return '';
}
/** å‘¨è§†å›¾å½“前显示范围标签 */
const weekRangeLabel = computed(() => {
@@ -464,6 +636,9 @@
      <Button :disabled="!employees.length" @click="autoOpen = true">
        è‡ªåŠ¨æŽ’ç­
      </Button>
      <Button type="primary" ghost :disabled="!month" @click="openBatch">
        æ‰¹é‡æŽ’班(按人群)
      </Button>
      <Button
        type="primary"
        :loading="saving"
@@ -545,11 +720,22 @@
              :key="vd.date"
              :class="[
                'schedule-date-col text-center',
                vd.isWeekend ? 'bg-red-50' : 'bg-gray-50',
                holidayClass(vd.date)
                  ? holidayClass(vd.date)
                  : vd.isWeekend
                    ? 'bg-red-50'
                    : 'bg-gray-50',
              ]"
            >
              <div class="font-medium">{{ vd.day }}</div>
              <div class="text-xs text-gray-400">{{ vd.weekLabel }}</div>
              <div class="text-xs text-gray-400">
                {{ vd.weekLabel }}
                <span
                  v-if="holidayLabel(vd.date)"
                  class="holiday-tag"
                  >{{ holidayLabel(vd.date) }}</span
                >
              </div>
            </th>
          </tr>
        </thead>
@@ -569,7 +755,7 @@
              v-for="vd in viewDays"
              :key="vd.date"
              class="schedule-cell"
              :class="[vd.isWeekend ? 'bg-red-50/40' : '']"
              :class="[vd.isWeekend ? 'bg-red-50/40' : '', holidayClass(vd.date)]"
              @click="openPicker(emp, vd.date)"
            >
              <span
@@ -681,6 +867,96 @@
        </div>
      </div>
    </Modal>
    <!-- æŒ‰äººç¾¤æ‰¹é‡æŽ’班弹窗:选部门/岗位/在职状态 + æ—¥æœŸèŒƒå›´ + ç­æ¬¡ï¼Œä¸€æ¬¡æŽ’完一类人 -->
    <Modal
      v-model:open="batchOpen"
      title="批量排班(按人群)"
      :footer="null"
      :width="520"
    >
      <div class="space-y-4">
        <div>
          <div class="mb-1.5 text-sm text-gray-500">
            éƒ¨é—¨ï¼ˆå«ä¸‹çº§éƒ¨é—¨ï¼‰<span class="text-red-500">*</span>
          </div>
          <TreeSelect
            v-model:value="batchDeptId"
            :tree-data="deptTree"
            :field-names="{ label: 'name', value: 'id', children: 'children' }"
            allow-clear
            placeholder="请选择部门"
            tree-default-expand-all
            style="width: 100%"
          />
        </div>
        <div class="grid grid-cols-2 gap-3">
          <div>
            <div class="mb-1.5 text-sm text-gray-500">岗位</div>
            <Select
              v-model:value="batchPostId"
              :options="postList"
              allow-clear
              placeholder="不限岗位"
              show-search
              option-filter-prop="label"
              style="width: 100%"
            />
          </div>
          <div>
            <div class="mb-1.5 text-sm text-gray-500">员工状态</div>
            <Select
              v-model:value="batchEmployeeStatus"
              :options="batchEmployeeStatusOptions"
              style="width: 100%"
            />
          </div>
        </div>
        <div>
          <div class="mb-1.5 text-sm text-gray-500">
            æŽ’班日期范围<span class="text-red-500">*</span>
          </div>
          <RangePicker
            v-model:value="batchDateRange"
            value-format="YYYY-MM-DD"
            :allow-clear="false"
            style="width: 100%"
          />
        </div>
        <div>
          <div class="mb-1.5 text-sm text-gray-500">
            æŽ’班班次<span class="text-red-500">*</span>
          </div>
          <Select
            v-model:value="batchShiftId"
            :options="batchShiftOptions"
            placeholder="请选择班次,或选择休息"
            style="width: 100%"
          />
        </div>
        <div v-if="batchShiftId !== 0">
          <div class="mb-1.5 text-sm text-gray-500">休息模式</div>
          <Radio.Group
            v-model:value="batchRestMode"
            :options="REST_MODE_OPTIONS"
            option-type="button"
          />
        </div>
        <div class="rounded bg-blue-50 px-3 py-2 text-xs text-blue-600">
          å°†å¯¹æ‰€é€‰éƒ¨é—¨/岗位/在职状态的全部员工,在日期范围内逐日排班:{{
            batchShiftId === 0
              ? '选择「休息」将清除该范围内已有排班。'
              : '按上方休息模式跳过周末,其余日期排所选班次。'
          }}已配置节假日的日期以节假日为准:休息日强制休息,工作日强制排班(含调休上班的周末)。
        </div>
        <div class="flex justify-end gap-2">
          <Button @click="batchOpen = false">取消</Button>
          <Button type="primary" :loading="batchSaving" @click="applyBatchSchedule">
            å¼€å§‹æŽ’班
          </Button>
        </div>
      </div>
    </Modal>
  </Page>
</template>
@@ -740,6 +1016,33 @@
    }
  }
  /* èŠ‚å‡æ—¥æ ‡è®°ï¼šä¼‘æ¯æ—¥æ©™è‰²ã€è°ƒä¼‘å·¥ä½œæ—¥ç»¿è‰²ï¼Œä¼˜å…ˆäºŽå‘¨æœ«åº•è‰² */
  .holiday-rest {
    background-color: #fff1e6 !important;
  }
  .holiday-work {
    background-color: #e8f8ec !important;
  }
  .holiday-tag {
    display: inline-block;
    padding: 0 3px;
    margin-left: 2px;
    color: #fff;
    font-size: 10px;
    line-height: 14px;
    border-radius: 2px;
  }
  .holiday-rest .holiday-tag {
    background-color: #fa8c16;
  }
  .holiday-work .holiday-tag {
    background-color: #52c41a;
  }
  .shift-chip {
    display: inline-block;
    max-width: 100%;