liu
昨天 f7e06aaee32674791f63d6e5111c0d0a3f4de4a3
feat(hrm): 新增班次/排班页面,优化薪酬核算

- 班次管理页:新增/编辑/查看/删除/导出
- 排班管理页:员工×日期网格、周/月视图、分页、勾选自动排班、按工号/姓名/手机号搜索
- 薪酬核算页:分页、多选确认/保存、撤销确认、待确认删除
已添加7个文件
已修改3个文件
1426 ■■■■■ 文件已修改
src/api/hrm/salary/calculation/index.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/schedule/index.ts 43 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/shift/index.ts 62 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/data.ts 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/salary/calculation/index.vue 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/schedule/data.ts 46 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/schedule/index.vue 743 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/shift/data.ts 186 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/shift/index.vue 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/shift/modules/form.vue 96 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/salary/calculation/index.ts
@@ -26,6 +26,8 @@
    actualSalary?: number;
    workDays?: number;
    overtimeHours?: number;
    /** æŽ’班总工时(小时) */
    totalWorkHours?: number;
    status?: number;
    statusName?: string;
    remark?: string;
src/api/hrm/schedule/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,43 @@
import { requestClient } from '#/api/request';
export namespace HrmEmployeeScheduleApi {
  /** æŽ’班记录 */
  export interface EmployeeSchedule {
    id?: number;
    employeeId?: number;
    employeeName?: string;
    employeeNo?: string;
    /** æŽ’班日期 yyyy-MM-dd */
    date?: string;
    shiftId?: number;
    shiftName?: string;
    shiftColor?: string;
    remark?: string;
  }
  /** ä¿å­˜é¡¹ï¼ˆæ•´æœˆå…¨é‡æäº¤ï¼ŒshiftId ä¸ºç©ºè¡¨ç¤ºä¼‘息/清除) */
  export interface ScheduleItem {
    employeeId: number;
    /** æŽ’班日期 yyyy-MM-dd */
    date: string;
    shiftId?: number | null;
    remark?: string;
  }
}
/** æŸ¥è¯¢æŸæœˆæŽ’班列表 */
export function getEmployeeScheduleList(params: {
  yearMonth: string;
  deptId?: number;
  employeeId?: number;
}) {
  return requestClient.get<HrmEmployeeScheduleApi.EmployeeSchedule[]>(
    '/hrm/employee-schedule/list',
    { params },
  );
}
/** æ‰¹é‡ä¿å­˜æŽ’班(整月覆盖式:有班次 upsert,无班次删除) */
export function saveEmployeeSchedule(data: HrmEmployeeScheduleApi.ScheduleItem[]) {
  return requestClient.post('/hrm/employee-schedule/save', data);
}
src/api/hrm/shift/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,62 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmShiftApi {
  /** ç­æ¬¡å®šä¹‰ */
  export interface Shift {
    id?: number;
    name?: string;
    /** ä¸Šç­æ—¶é—´ HH:mm:ss */
    startTime?: string;
    /** ä¸‹ç­æ—¶é—´ HH:mm:ss */
    endTime?: string;
    /** æ ‡å‡†å·¥æ—¶ï¼ˆå°æ—¶ï¼‰ */
    workHours?: number;
    /** æ˜¯å¦è·¨å¤© */
    crossDay?: boolean;
    /** æ ‡ç­¾é¢œè‰²ï¼ˆåå…­è¿›åˆ¶ï¼‰ */
    color?: string;
    /** çŠ¶æ€ï¼š0-启用,1-禁用 */
    status?: number;
    remark?: string;
    createTime?: string;
  }
}
/** æŸ¥è¯¢ç­æ¬¡åˆ†é¡µåˆ—表 */
export function getShiftPage(params: PageParam) {
  return requestClient.get<PageResult<HrmShiftApi.Shift>>('/hrm/shift/page', {
    params,
  });
}
/** æŸ¥è¯¢ç­æ¬¡åˆ—表(启用状态,供排班页选择) */
export function getShiftList() {
  return requestClient.get<HrmShiftApi.Shift[]>('/hrm/shift/list');
}
/** æŸ¥è¯¢ç­æ¬¡è¯¦æƒ… */
export function getShift(id: number) {
  return requestClient.get<HrmShiftApi.Shift>(`/hrm/shift/get?id=${id}`);
}
/** æ–°å¢žç­æ¬¡ */
export function createShift(data: HrmShiftApi.Shift) {
  return requestClient.post('/hrm/shift/create', data);
}
/** æ›´æ–°ç­æ¬¡ */
export function updateShift(data: HrmShiftApi.Shift) {
  return requestClient.put('/hrm/shift/update', data);
}
/** åˆ é™¤ç­æ¬¡ */
export function deleteShift(id: number) {
  return requestClient.delete(`/hrm/shift/delete?id=${id}`);
}
/** å¯¼å‡ºç­æ¬¡ Excel */
export function exportShift(params: any) {
  return requestClient.download('/hrm/shift/export-excel', { params });
}
src/views/hrm/salary/calculation/data.ts
@@ -89,6 +89,7 @@
    { field: 'actualSalary', title: '实发工资', width: 100, formatter: 'formatAmount2' },
    { field: 'workDays', title: '出勤天数', width: 80 },
    { field: 'overtimeHours', title: '加班时长(h)', width: 100 },
    { field: 'totalWorkHours', title: '排班工时(h)', width: 100 },
    {
      field: 'status',
      title: '状态',
@@ -127,6 +128,7 @@
    { title: '请假扣款', dataIndex: 'leaveDeduction', width: 110 },
    { title: '出勤天数', dataIndex: 'workDays', width: 90 },
    { title: '加班时长', dataIndex: 'overtimeHours', width: 100 },
    { title: '排班工时(h)', dataIndex: 'totalWorkHours', width: 100 },
    { title: '实发工资', dataIndex: 'actualSalary', width: 100, fixed: 'right' },
  ];
}
src/views/hrm/salary/calculation/index.vue
@@ -16,9 +16,11 @@
import {
  calculateSalary,
  confirmSalary,
  deleteSalaryCalculation,
  exportSalaryCalculation,
  getSalaryCalculationPage,
  previewSalaryCalculation,
  revokeSalaryCalculation,
  saveSalaryCalculation,
} from '#/api/hrm/salary/calculation';
import { getDeptList } from '#/api/system/dept';
@@ -153,7 +155,7 @@
  handleRefresh();
}
/** æ‰¹é‡ç¡®è®¤ */
/** æ‰¹é‡ç¡®è®¤é€‰ä¸­çš„记录 */
async function handleBatchConfirm() {
  const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
  if (records.length === 0) {
@@ -164,6 +166,58 @@
  await confirmSalary(ids);
  message.success('确认成功');
  handleRefresh();
}
/** æ‰¹é‡ä¿å­˜é€‰ä¸­çš„æ ¸ç®—记录(仅待确认状态) */
async function handleBatchSave() {
  const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[];
  if (records.length === 0) {
    message.warning('请选择要保存的记录');
    return;
  }
  const pending = records.filter((r) => r.status === 0);
  if (pending.length === 0) {
    message.warning('选中的记录均非待确认状态,无需保存');
    return;
  }
  const first = pending[0];
  if (!first?.period) {
    message.warning('缺少核算周期');
    return;
  }
  await saveSalaryCalculation({ period: first.period, list: pending });
  message.success('批量保存成功');
  handleRefresh();
}
/** æ’¤é”€ç¡®è®¤ï¼ˆå·²ç¡®è®¤ â†’ å¾…确认,可重新核算) */
async function handleRevoke(row: HrmSalaryCalculationApi.SalaryCalculation) {
  Modal.confirm({
    title: '撤销确认',
    content: `确定撤销【${row.no}】的确认吗?撤销后可重新核算。`,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await revokeSalaryCalculation([row.id!]);
      message.success('撤销成功');
      handleRefresh();
    },
  });
}
/** åˆ é™¤å¾…确认的核算记录 */
async function handleDelete(row: HrmSalaryCalculationApi.SalaryCalculation) {
  Modal.confirm({
    title: '确认删除',
    content: `确定删除【${row.no}】的核算记录吗?删除后需重新核算。`,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await deleteSalaryCalculation(row.id!);
      message.success('删除成功');
      handleRefresh();
    },
  });
}
/** å¯¼å‡ºè–ªé…¬æ ¸ç®— */
@@ -182,7 +236,6 @@
    keepSource: true,
    checkboxConfig: {
      highlight: true,
      reserve: true,
    },
    proxyConfig: {
      ajax: {
@@ -193,6 +246,10 @@
            ...formValues,
          }),
      },
    },
    pagingConfig: {
      pageSize: 20,
      pageSizes: [20, 50, 100],
    },
    rowConfig: {
      keyField: 'id',
@@ -261,7 +318,7 @@
        v-if="previewData.length > 0"
        :columns="usePreviewColumns()"
        :data-source="previewData"
        :pagination="false"
        :pagination="{ pageSize: 20, showSizeChanger: true, showTotal: (t) => `共 ${t} äºº` }"
        :scroll="{ x: 1800, y: 400 }"
        size="small"
        row-key="userId"
@@ -430,6 +487,12 @@
              onClick: handleBatchConfirm,
            },
            {
              label: '批量保存',
              type: 'primary',
              auth: ['hrm:salary-calculation:save'],
              onClick: handleBatchSave,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
@@ -454,6 +517,21 @@
              ifShow: row.status === 0,
              onClick: handleConfirm.bind(null, row),
            },
            {
              label: '删除',
              type: 'link',
              danger: true,
              auth: ['hrm:salary-calculation:delete'],
              ifShow: row.status === 0,
              onClick: handleDelete.bind(null, row),
            },
            {
              label: '撤销确认',
              type: 'link',
              auth: ['hrm:salary-calculation:confirm'],
              ifShow: row.status === 10,
              onClick: handleRevoke.bind(null, row),
            },
          ]"
        />
      </template>
src/views/hrm/schedule/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,46 @@
/** æŽ’班网格的日期工具 */
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
/** è¿”回某月天数数组,如 2026-08 â†’ [1..31] */
export function getDaysOfMonth(yearMonth: string): number[] {
  const [yearStr, monthStr] = yearMonth.split('-');
  const days = new Date(Number(yearStr), Number(monthStr), 0).getDate();
  return Array.from({ length: days }, (_, i) => i + 1);
}
/** è¿”回某日的星期中文标签(日/一/二…) */
export function getWeekLabel(yearMonth: string, day: number): string {
  const date = new Date(`${yearMonth}-${String(day).padStart(2, '0')}`);
  return WEEK_LABELS[date.getDay()] ?? '';
}
/** åˆ¤æ–­æŸæ—¥æ˜¯å¦ä¸ºå‘¨æœ« */
export function isWeekend(yearMonth: string, day: number): boolean {
  const date = new Date(`${yearMonth}-${String(day).padStart(2, '0')}`);
  const week = date.getDay();
  return week === 0 || week === 6;
}
/** ç”ŸæˆæŸæœˆæŸæ—¥çš„æ—¥æœŸå­—符串 yyyy-MM-dd */
export function formatDate(yearMonth: string, day: number): string {
  return `${yearMonth}-${String(day).padStart(2, '0')}`;
}
/** è‡ªåŠ¨æŽ’ç­ä¼‘æ¯æ¨¡å¼ */
export const REST_MODE = {
  /** åŒä¼‘:周六、周日休息 */
  DOUBLE: 'double',
  /** å•休:周日休息 */
  SINGLE: 'single',
  /** ä¸ä¼‘息:每天排班 */
  NONE: 'none',
} as const;
export type RestMode = (typeof REST_MODE)[keyof typeof REST_MODE];
export const REST_MODE_OPTIONS: { label: string; value: RestMode }[] = [
  { label: '双休(六日休)', value: REST_MODE.DOUBLE },
  { label: '单休(周日休)', value: REST_MODE.SINGLE },
  { label: '不休息', value: REST_MODE.NONE },
];
src/views/hrm/schedule/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,743 @@
<script lang="ts" setup>
import type { HrmEmployeeApi } from '#/api/hrm/employee';
import type { HrmEmployeeScheduleApi } from '#/api/hrm/schedule';
import type { HrmShiftApi } from '#/api/hrm/shift';
import { computed, onMounted, ref, watch } from 'vue';
import dayjs from 'dayjs';
import {
  Button,
  Checkbox,
  DatePicker,
  Empty,
  Input,
  message,
  Modal,
  Pagination,
  Radio,
  Segmented,
  Select,
  TreeSelect,
} from 'ant-design-vue';
import { Page } from '#/packages/effects/common-ui/src';
import { getEmployeePage } from '#/api/hrm/employee';
import { getShiftList } from '#/api/hrm/shift';
import { getEmployeeScheduleList, saveEmployeeSchedule } from '#/api/hrm/schedule';
import { getDeptList } from '#/api/system/dept';
import { handleTree } from '#/packages/utils/src';
import {
  REST_MODE,
  REST_MODE_OPTIONS,
  formatDate,
  getDaysOfMonth,
} from './data';
import type { RestMode } from './data';
defineOptions({ name: 'HrmSchedule' });
// ========== æŸ¥è¯¢æ¡ä»¶ ==========
const month = ref<string>('');
const deptId = ref<number>();
const deptTree = ref<any[]>([]);
/** å‘˜å·¥åˆ†é¡µï¼ˆæŽ’班网格每次只显示一页员工) */
const employeePageNo = ref(1);
const employeePageSize = ref(20);
const employeeTotal = ref(0);
/** æŒ‰å‘˜å·¥ç¼–号/姓名/手机号搜索单个员工 */
const keyword = ref('');
/** ä¿å­˜æ—¶å•次请求的排班条目数(防止大批量请求过大) */
const SAVE_BATCH_SIZE = 1000;
// ========== æ•°æ® ==========
const employees = ref<HrmEmployeeApi.Employee[]>([]);
const shiftList = ref<HrmShiftApi.Shift[]>([]);
const loading = ref(false);
const saving = ref(false);
/** æ˜¯å¦å·²æ‰§è¡Œè¿‡æŸ¥è¯¢ */
const loaded = ref(false);
// ========== å‘˜å·¥å‹¾é€‰ ==========
/** Checkbox å˜æ›´äº‹ä»¶ï¼ˆä»…取 checked å­—段) */
interface CheckChangeEvent {
  target: { checked: boolean };
}
/** å‹¾é€‰çš„员工ID集合,为空时自动排班作用于全部员工 */
const checkedEmployeeIds = ref<Set<number>>(new Set());
const isAllChecked = computed(
  () =>
    employees.value.length > 0 &&
    checkedEmployeeIds.value.size === employees.value.length,
);
function isChecked(id: number): boolean {
  return checkedEmployeeIds.value.has(id);
}
function handleCheck(id: number, e: CheckChangeEvent) {
  const set = new Set(checkedEmployeeIds.value);
  if (e.target.checked) {
    set.add(id);
  } else {
    set.delete(id);
  }
  checkedEmployeeIds.value = set;
}
function handleCheckAll(e: CheckChangeEvent) {
  if (e.target.checked) {
    checkedEmployeeIds.value = new Set(
      employees.value
        .map((item) => item.id)
        .filter((id): id is number => id != null),
    );
  } else {
    checkedEmployeeIds.value = new Set();
  }
}
// ========== è‡ªåŠ¨æŽ’ç­ ==========
const autoOpen = ref(false);
const autoShiftId = ref<number>();
const autoRestMode = ref<RestMode>(REST_MODE.DOUBLE);
/** è‡ªåŠ¨æŽ’ç­ç­æ¬¡é€‰æ‹©é€‰é¡¹ */
const autoShiftOptions = computed(() =>
  shiftList.value.map((shift) => ({
    label: `${shift.name} (${shift.startTime}~${shift.endTime})`,
    value: shift.id,
  })),
);
/** æŒ‰ä¼‘息模式为当前员工生成整月排班(休息日清除,工作日填班次) */
function applyAutoSchedule() {
  if (!employees.value.length || !days.value.length) {
    message.warning('请先查询出员工');
    return;
  }
  if (autoShiftId.value == null) {
    message.warning('请选择排班班次');
    return;
  }
  // å‹¾é€‰äº†å‘˜å·¥åˆ™åªå¯¹å‹¾é€‰äººç”Ÿæ•ˆï¼Œæœªå‹¾é€‰åˆ™ä½œç”¨äºŽå…¨éƒ¨å‘˜å·¥
  const targets =
    checkedEmployeeIds.value.size > 0
      ? employees.value.filter(
          (item) =>
            item.id != null && checkedEmployeeIds.value.has(item.id),
        )
      : employees.value;
  if (!targets.length) {
    message.warning('请先勾选员工');
    return;
  }
  const map = new Map(scheduleMap.value);
  for (const emp of targets) {
    if (emp.id == null) {
      continue;
    }
    for (const day of days.value) {
      const date = formatDate(month.value, day);
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      let rest = false;
      if (autoRestMode.value === REST_MODE.DOUBLE) {
        rest = week === 0 || week === 6;
      } else if (autoRestMode.value === REST_MODE.SINGLE) {
        rest = week === 0;
      }
      map.set(`${emp.id}_${date}`, rest ? null : autoShiftId.value);
    }
  }
  scheduleMap.value = map;
  autoOpen.value = false;
  message.success('自动排班已生成,可手动微调后保存');
}
/** ç­æ¬¡ id â†’ ç­æ¬¡ æ˜ å°„ */
const shiftMap = computed(() => {
  const map = new Map<number, HrmShiftApi.Shift>();
  for (const shift of shiftList.value) {
    if (shift.id != null) {
      map.set(shift.id, shift);
    }
  }
  return map;
});
/**
 * æŽ’班数据。key = `${employeeId}_${date}`,value = shiftId(null è¡¨ç¤ºä¼‘息)
 */
const scheduleMap = ref(new Map<string, number | null>());
/** å½“月天数数组 */
const days = computed(() => (month.value ? getDaysOfMonth(month.value) : []));
// ========== è§†å›¾åˆ‡æ¢ï¼ˆå‘¨/月)==========
const WEEK_LABELS = ['日', '一', '二', '三', '四', '五', '六'];
interface ViewDay {
  day: number;
  date: string;
  weekLabel: string;
  isWeekend: boolean;
}
const viewMode = ref<'week' | 'month'>('week');
/** æœˆè§†å›¾æ˜¯å¦æ˜¾ç¤ºå‘¨æœ«åˆ—(默认隐藏,减列缓解拥挤) */
const showWeekend = ref(false);
/** å‘¨è§†å›¾é”šç‚¹æ—¥æœŸ */
const viewAnchor = ref<Date>(new Date());
/** å½“前视图要展示的日期列表 */
const viewDays = computed<ViewDay[]>(() => {
  if (viewMode.value === 'week') {
    // ä»¥å‘¨ä¸€ä¸ºä¸€å‘¨èµ·ç‚¹
    const base = dayjs(viewAnchor.value).day(1);
    return Array.from({ length: 7 }, (_, i) => {
      const date = base.add(i, 'day');
      const week = date.day();
      return {
        day: date.date(),
        date: date.format('YYYY-MM-DD'),
        weekLabel: WEEK_LABELS[week] ?? '',
        isWeekend: week === 0 || week === 6,
      };
    });
  }
  return days.value
    .filter((day) => {
      if (showWeekend.value) {
        return true;
      }
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      return week !== 0 && week !== 6;
    })
    .map((day) => {
      const week = new Date(
        `${month.value}-${String(day).padStart(2, '0')}`,
      ).getDay();
      return {
        day,
        date: formatDate(month.value, day),
        weekLabel: WEEK_LABELS[week] ?? '',
        isWeekend: week === 0 || week === 6,
      };
    });
});
/** å‘¨è§†å›¾å½“前显示范围标签 */
const weekRangeLabel = computed(() => {
  const list = viewDays.value;
  const first = list[0];
  const last = list[list.length - 1];
  if (!first || !last) {
    return '';
  }
  return `${first.date} ~ ${last.date}`;
});
function shiftWeek(delta: number) {
  viewAnchor.value = dayjs(viewAnchor.value).add(delta, 'week').toDate();
}
// æœˆä»½å˜åŒ–时,周视图锚点落到该月 1 å·æ‰€åœ¨å‘¨
watch(month, (value) => {
  if (value) {
    viewAnchor.value = new Date(`${value}-01`);
  }
});
function getShiftByCell(employeeId: number, date: string): HrmShiftApi.Shift | undefined {
  const shiftId = scheduleMap.value.get(`${employeeId}_${date}`);
  if (shiftId == null) {
    return undefined;
  }
  return shiftMap.value.get(shiftId);
}
// ========== ç­æ¬¡é€‰æ‹©å¼¹çª— ==========
const pickerOpen = ref(false);
const pickerEmployee = ref<HrmEmployeeApi.Employee>();
const pickerDate = ref<string>('');
const pickerTitle = computed(() => {
  if (!pickerEmployee.value || !pickerDate.value) {
    return '选择班次';
  }
  return `选择班次 Â· ${pickerEmployee.value.name} Â· ${pickerDate.value}`;
});
function openPicker(emp: HrmEmployeeApi.Employee, date: string) {
  pickerEmployee.value = emp;
  pickerDate.value = date;
  pickerOpen.value = true;
}
/** å½“前弹窗格子是否已选该班次 */
function isCurrentPick(shiftId: number): boolean {
  if (!pickerEmployee.value || !pickerDate.value) {
    return false;
  }
  return (
    scheduleMap.value.get(`${pickerEmployee.value.id}_${pickerDate.value}`) ===
    shiftId
  );
}
function pickShift(shiftId: number | null) {
  if (!pickerEmployee.value || !pickerDate.value) {
    return;
  }
  scheduleMap.value.set(`${pickerEmployee.value.id}_${pickerDate.value}`, shiftId);
  pickerOpen.value = false;
}
// ========== åŠ è½½ ==========
async function loadDeptTree() {
  const data = await getDeptList();
  deptTree.value = handleTree(data);
}
/**
 * åˆ†é¡µæ‹‰å–部门下的全部员工(单页上限 200,循环拼全),供排班网格整表展示
 */
/** åŠ è½½å½“å‰é¡µå‘˜å·¥ï¼ˆç¿»é¡µæ—¶åªé‡æ–°æ‹‰å‘˜å·¥ï¼ŒæŽ’ç­æ•°æ®ä¿ç•™ï¼‰ */
async function loadEmployeePage() {
  loading.value = true;
  try {
    const res = await getEmployeePage({
      pageNo: employeePageNo.value,
      pageSize: employeePageSize.value,
      deptId: deptId.value,
      keyword: keyword.value || undefined,
    });
    employees.value = res.list ?? [];
    employeeTotal.value = res.total ?? 0;
  } finally {
    loading.value = false;
  }
}
/** å‘˜å·¥ç¿»é¡µ */
function handlePageChange(page: number, pageSize: number) {
  if (pageSize !== employeePageSize.value) {
    employeePageSize.value = pageSize;
    employeePageNo.value = 1;
  } else {
    employeePageNo.value = page;
  }
  loadEmployeePage();
}
/** æŒ‰å…³é”®è¯æœç´¢å‘˜å·¥ */
function handleSearch() {
  employeePageNo.value = 1;
  loadEmployeePage();
}
async function loadData() {
  if (!month.value) {
    message.warning('请选择月份');
    return;
  }
  if (!deptId.value) {
    message.warning('请先选择部门');
    return;
  }
  loading.value = true;
  try {
    const [shifts, schedules] = await Promise.all([
      getShiftList(),
      getEmployeeScheduleList({
        yearMonth: month.value,
        deptId: deptId.value,
      }),
    ]);
    shiftList.value = shifts;
    const map = new Map<string, number | null>();
    for (const item of schedules) {
      if (item.employeeId != null && item.date) {
        map.set(`${item.employeeId}_${item.date}`, item.shiftId ?? null);
      }
    }
    scheduleMap.value = map;
    loaded.value = true;
  } finally {
    loading.value = false;
  }
  // åŠ è½½ç¬¬ä¸€é¡µå‘˜å·¥
  employeePageNo.value = 1;
  await loadEmployeePage();
}
// ========== ä¿å­˜ï¼ˆæ•´æœˆå…¨é‡ï¼‰ ==========
async function handleSave() {
  if (!month.value || !employees.value.length) {
    return;
  }
  Modal.confirm({
    title: '确认保存',
    content: `将保存当前页 ${employees.value.length} åå‘˜å·¥åœ¨ ${month.value} çš„全部排班,确定继续吗?`,
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      saving.value = true;
      try {
        const items: HrmEmployeeScheduleApi.ScheduleItem[] = [];
        for (const emp of employees.value) {
          if (emp.id == null) {
            continue;
          }
          for (const day of days.value) {
            const date = formatDate(month.value, day);
            items.push({
              employeeId: emp.id,
              date,
              shiftId: scheduleMap.value.get(`${emp.id}_${date}`) ?? null,
            });
          }
        }
        // å¤§æ‰¹é‡æ—¶åˆ†æ‰¹æäº¤ï¼Œé¿å…å•次请求过大
        for (let i = 0; i < items.length; i += SAVE_BATCH_SIZE) {
          await saveEmployeeSchedule(items.slice(i, i + SAVE_BATCH_SIZE));
        }
        message.success('排班保存成功');
      } finally {
        saving.value = false;
      }
    },
  });
}
onMounted(() => {
  month.value = dayjs().format('YYYY-MM');
  loadDeptTree();
});
</script>
<template>
  <Page auto-content-height>
    <!-- æŸ¥è¯¢å·¥å…·æ  -->
    <div
      class="mb-4 flex flex-wrap items-center gap-3 rounded-lg bg-white p-4"
    >
      <DatePicker
        v-model:value="month"
        picker="month"
        :allow-clear="false"
        value-format="YYYY-MM"
        format="YYYY-MM"
        style="width: 140px"
      />
      <TreeSelect
        v-model:value="deptId"
        :tree-data="deptTree"
        :field-names="{ label: 'name', value: 'id', children: 'children' }"
        allow-clear
        placeholder="请选择部门"
        tree-default-expand-all
        style="width: 200px"
      />
      <Input
        v-model:value="keyword"
        allow-clear
        placeholder="员工编号/姓名/手机号"
        style="width: 200px"
        @pressEnter="handleSearch"
      />
      <Button type="primary" :loading="loading" @click="loadData">
        æŸ¥è¯¢æŽ’班
      </Button>
      <Button :disabled="!employees.length" @click="autoOpen = true">
        è‡ªåŠ¨æŽ’ç­
      </Button>
      <Button
        type="primary"
        :loading="saving"
        :disabled="!employees.length"
        @click="handleSave"
      >
        ä¿å­˜æŽ’班
      </Button>
      <Segmented
        v-model:value="viewMode"
        :options="[
          { label: '周视图', value: 'week' },
          { label: '月视图', value: 'month' },
        ]"
      />
      <template v-if="viewMode === 'week'">
        <Button @click="shiftWeek(-1)">上一周</Button>
        <span class="text-sm text-gray-500">{{ weekRangeLabel }}</span>
        <Button @click="shiftWeek(1)">下一周</Button>
      </template>
      <template v-else>
        <Checkbox v-model:checked="showWeekend">显示周末</Checkbox>
      </template>
    </div>
    <!-- ç­æ¬¡å›¾ä¾‹ -->
    <div
      v-if="shiftList.length"
      class="mb-4 flex flex-wrap items-center gap-4 rounded-lg bg-white px-4 py-3"
    >
      <span class="text-sm text-gray-500">班次图例:</span>
      <span
        v-for="shift in shiftList"
        :key="shift.id"
        class="inline-flex items-center gap-1.5 text-sm"
      >
        <span
          class="inline-block h-3 w-3 rounded"
          :style="{ backgroundColor: shift.color || '#1677ff' }"
        />
        <span>{{ shift.name }}</span>
        <span class="text-xs text-gray-400"
          >{{ shift.startTime }}~{{ shift.endTime }}</span
        >
      </span>
    </div>
    <!-- æŽ’班网格 -->
    <div class="overflow-auto rounded-lg bg-white">
      <Empty
        v-if="!employees.length && !loading"
        :description="
          loaded
            ? '该部门暂无符合条件的员工,请调整部门或员工状态'
            : '请选择月份、部门后,点击「查询排班」'
        "
        class="py-20"
      />
      <table v-else class="schedule-table w-full text-sm">
        <thead>
          <tr>
            <th class="schedule-check-col bg-gray-100">
              <Checkbox :checked="isAllChecked" @change="handleCheckAll" />
            </th>
            <th class="schedule-name-col bg-gray-100 text-left">员工</th>
            <th
              v-for="vd in viewDays"
              :key="vd.date"
              :class="[
                'schedule-date-col text-center',
                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>
            </th>
          </tr>
        </thead>
        <tbody>
          <tr v-for="emp in employees" :key="emp.id">
            <td class="schedule-check-col" @click.stop>
              <Checkbox
                :checked="isChecked(emp.id!)"
                @change="handleCheck(emp.id!, $event)"
              />
            </td>
            <td class="schedule-name-col">
              <div class="font-medium">{{ emp.name }}</div>
              <div class="text-xs text-gray-400">{{ emp.employeeNo }}</div>
            </td>
            <td
              v-for="vd in viewDays"
              :key="vd.date"
              class="schedule-cell"
              :class="[vd.isWeekend ? 'bg-red-50/40' : '']"
              @click="openPicker(emp, vd.date)"
            >
              <span
                v-if="getShiftByCell(emp.id!, vd.date)"
                class="shift-chip"
                :title="getShiftByCell(emp.id!, vd.date)!.name"
                :style="{
                  backgroundColor:
                    getShiftByCell(emp.id!, vd.date)!.color || '#1677ff',
                }"
              >
                {{ getShiftByCell(emp.id!, vd.date)!.name }}
              </span>
            </td>
          </tr>
        </tbody>
      </table>
    </div>
    <!-- å‘˜å·¥åˆ†é¡µ -->
    <div
      v-if="loaded && employees.length"
      class="mt-4 flex items-center justify-end rounded-lg bg-white p-3"
    >
      <Pagination
        :current="employeePageNo"
        :page-size="employeePageSize"
        :total="employeeTotal"
        show-size-changer
        show-quick-jumper
        :show-total="(total: number) => `共 ${total} äºº`"
        @change="handlePageChange"
      />
    </div>
    <!-- ç­æ¬¡é€‰æ‹©å¼¹çª— -->
    <Modal
      v-model:open="pickerOpen"
      :title="pickerTitle"
      :footer="null"
      :width="440"
    >
      <div class="grid grid-cols-2 gap-3">
        <button
          v-for="shift in shiftList"
          :key="shift.id"
          type="button"
          class="flex cursor-pointer flex-col items-center gap-1 rounded-lg border px-2 py-3 transition-colors hover:border-blue-500"
          :class="[
            isCurrentPick(shift.id!)
              ? 'border-blue-500 bg-blue-50'
              : 'border-gray-200',
          ]"
          @click="pickShift(shift.id!)"
        >
          <span class="inline-flex items-center gap-2 text-sm">
            <span
              class="inline-block h-3 w-3 rounded"
              :style="{ backgroundColor: shift.color || '#1677ff' }"
            />
            <span class="font-medium">{{ shift.name }}</span>
          </span>
          <span class="text-xs text-gray-400"
            >{{ shift.startTime }} ~ {{ shift.endTime }}</span
          >
        </button>
      </div>
      <div class="mt-4 flex items-center justify-between">
        <Button danger @click="pickShift(null)">清除排班(休息)</Button>
        <Button @click="pickerOpen = false">取消</Button>
      </div>
    </Modal>
    <!-- è‡ªåŠ¨æŽ’ç­å¼¹çª— -->
    <Modal v-model:open="autoOpen" title="自动排班" :footer="null" :width="440">
      <div class="space-y-4">
        <div>
          <div class="mb-1.5 text-sm text-gray-500">排班班次</div>
          <Select
            v-model:value="autoShiftId"
            :options="autoShiftOptions"
            placeholder="请选择排班班次"
            style="width: 100%"
          />
        </div>
        <div>
          <div class="mb-1.5 text-sm text-gray-500">休息模式</div>
          <Radio.Group
            v-model:value="autoRestMode"
            :options="REST_MODE_OPTIONS"
            option-type="button"
          />
        </div>
        <div class="text-xs text-gray-400">
          å°†æŒ‰æ‰€é€‰æ¨¡å¼ä¸º{{
            checkedEmployeeIds.size > 0
              ? checkedEmployeeIds.size
              : employees.length
          }}
          åå‘˜å·¥ç”Ÿæˆæ•´æœˆæŽ’班{{
            checkedEmployeeIds.size > 0
              ? '(仅勾选的员工)'
              : '(未勾选时为当前页全部员工)'
          }},生成后可再手动调整。
        </div>
        <div class="flex justify-end gap-2">
          <Button @click="autoOpen = false">取消</Button>
          <Button type="primary" @click="applyAutoSchedule">生成排班</Button>
        </div>
      </div>
    </Modal>
  </Page>
</template>
<style lang="scss" scoped>
.schedule-table {
  width: 100%;
  min-width: max-content;
  border-collapse: separate;
  border-spacing: 0;
  th,
  td {
    height: 44px;
    border: 1px solid #f0f0f0;
  }
  .schedule-date-col {
    width: 52px;
    min-width: 52px;
  }
  .schedule-check-col,
  .schedule-name-col {
    position: sticky;
    z-index: 10;
    background-color: #fafafa;
  }
  .schedule-check-col {
    left: 0;
    width: 40px;
    min-width: 40px;
    padding: 0 8px;
    text-align: center;
  }
  .schedule-name-col {
    left: 40px;
    min-width: 180px;
    padding: 0 12px;
    line-height: 1.3;
  }
  thead .schedule-check-col,
  thead .schedule-name-col {
    z-index: 20;
  }
  .schedule-cell {
    padding: 2px;
    cursor: pointer;
    text-align: center;
    &:hover {
      background-color: #e6f4ff;
    }
  }
  .shift-chip {
    display: inline-block;
    max-width: 100%;
    padding: 1px 6px;
    overflow: hidden;
    color: #fff;
    font-size: 11px;
    text-overflow: ellipsis;
    white-space: nowrap;
    border-radius: 3px;
  }
}
</style>
src/views/hrm/shift/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,186 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmShiftApi } from '#/api/hrm/shift';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
/** è¡¨å•类型 */
export type FormType = 'create' | 'update' | 'detail';
/** ç­æ¬¡æ ‡ç­¾é¢œè‰²é¢„设 */
export const SHIFT_COLORS = [
  { label: '蓝色', value: '#1677ff' },
  { label: '绿色', value: '#52c41a' },
  { label: '橙色', value: '#fa8c16' },
  { label: '紫色', value: '#722ed1' },
  { label: '粉色', value: '#eb2f96' },
  { label: '黄色', value: '#faad14' },
  { label: '青色', value: '#13c2c2' },
  { label: '灰色', value: '#8c8c8c' },
];
/** æ–°å¢ž/编辑/查看班次的表单 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'name',
      label: '班次名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入班次名称,如:白班、夜班',
        maxlength: 50,
      },
      rules: 'required',
    },
    {
      fieldName: 'startTime',
      label: '上班时间',
      component: 'TimePicker',
      componentProps: {
        placeholder: '选择上班时间',
        valueFormat: 'HH:mm:ss',
        format: 'HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'endTime',
      label: '下班时间',
      component: 'TimePicker',
      componentProps: {
        placeholder: '选择下班时间',
        valueFormat: 'HH:mm:ss',
        format: 'HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'workHours',
      label: '标准工时(小时)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '如 8、8.5',
        min: 0.5,
        max: 24,
        precision: 2,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'crossDay',
      label: '是否跨天',
      component: 'Switch',
      componentProps: {
        checkedValue: true,
        unCheckedValue: false,
      },
    },
    {
      fieldName: 'color',
      label: '标签颜色',
      component: 'Select',
      componentProps: {
        options: SHIFT_COLORS,
        placeholder: '请选择颜色',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
        maxlength: 500,
        showCount: true,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '班次名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入班次名称',
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        placeholder: '请选择状态',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmShiftApi.Shift>['columns'] {
  return [
    { field: 'name', title: '班次名称', minWidth: 120 },
    { field: 'startTime', title: '上班时间', width: 100 },
    { field: 'endTime', title: '下班时间', width: 100 },
    { field: 'workHours', title: '标准工时(h)', width: 110 },
    {
      field: 'crossDay',
      title: '跨天',
      width: 80,
      formatter: ({ cellValue }) => (cellValue ? '是' : '否'),
    },
    {
      field: 'color',
      title: '标签颜色',
      width: 110,
      slots: {
        default: 'color',
      },
    },
    {
      field: 'status',
      title: '状态',
      width: 90,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
      },
    },
    {
      title: '操作',
      width: 150,
      fixed: 'right',
      slots: {
        default: 'actions',
      },
    },
  ];
}
src/views/hrm/shift/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmShiftApi } from '#/api/hrm/shift';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { message, Modal } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import {
  deleteShift,
  exportShift,
  getShiftPage,
} from '#/api/hrm/shift';
import { $t } from '#/locales';
import { SHIFT_COLORS, useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žç­æ¬¡ */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑班次 */
function handleUpdate(row: HrmShiftApi.Shift) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
}
/** æŸ¥çœ‹ç­æ¬¡ */
function handleDetail(row: HrmShiftApi.Shift) {
  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
}
/** åˆ é™¤ç­æ¬¡ */
async function handleDelete(id: number) {
  Modal.confirm({
    title: '确认删除',
    content: '确定要删除该班次吗?删除后已有排班将失去班次信息。',
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await deleteShift(id);
      message.success('删除成功');
      handleRefresh();
    },
  });
}
/** å¯¼å‡ºç­æ¬¡ */
async function handleExport() {
  const data = await exportShift(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '班次.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getShiftPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmShiftApi.Shift>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <Grid table-title="班次列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['班次']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:shift:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['hrm:shift:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
      <template #color="{ row }">
        <div class="flex items-center gap-2">
          <span
            class="inline-block h-4 w-4 rounded"
            :style="{ backgroundColor: row.color || '#8c8c8c' }"
          />
          <span>
            {{
              SHIFT_COLORS.find((item) => item.value === row.color)?.label ||
              row.color ||
              '-'
            }}
          </span>
        </div>
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '查看',
              type: 'link',
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('ui.actionTitle.edit'),
              type: 'link',
              auth: ['hrm:shift:update'],
              onClick: handleUpdate.bind(null, row),
            },
            {
              label: $t('ui.actionTitle.delete'),
              type: 'link',
              danger: true,
              auth: ['hrm:shift:delete'],
              onClick: handleDelete.bind(null, row.id!),
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/shift/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,96 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { HrmShiftApi } from '#/api/hrm/shift';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createShift, getShift, updateShift } from '#/api/hrm/shift';
import { $t } from '#/locales';
import { useFormSchema } from '../data';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const isDetail = computed(() => formType.value === 'detail');
const getTitle = computed(() => {
  if (formType.value === 'detail') {
    return '查看班次';
  }
  if (formType.value === 'update') {
    return '编辑班次';
  }
  return '新增班次';
});
const [Form, formApi] = useVbenForm({
  commonConfig: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (isDetail.value) {
      await modalApi.close();
      return;
    }
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as HrmShiftApi.Shift;
    try {
      if (formType.value === 'update') {
        await updateShift(data);
      } else {
        await createShift(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(data.formType) });
    formApi.setDisabled(data.formType === 'detail');
    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
    if (!data?.id) {
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(await getShift(data.id));
    } finally {
      modalApi.unlock();
    }
  },
});
</script>
<template>
  <Modal :title="getTitle" class="w-[720px]">
    <Form class="mx-4" />
  </Modal>
</template>