feat(hrm): 新增班次/排班页面,优化薪酬核算
- 班次管理页:新增/编辑/查看/删除/导出
- 排班管理页:员工×日期网格、周/月视图、分页、勾选自动排班、按工号/姓名/手机号搜索
- 薪酬核算页:分页、多选确认/保存、撤销确认、待确认删除
| | |
| | | actualSalary?: number; |
| | | workDays?: number; |
| | | overtimeHours?: number; |
| | | /** æçæ»å·¥æ¶ï¼å°æ¶ï¼ */ |
| | | totalWorkHours?: number; |
| | | status?: number; |
| | | statusName?: string; |
| | | remark?: string; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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 }); |
| | | } |
| | |
| | | { 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: 'ç¶æ', |
| | |
| | | { 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' }, |
| | | ]; |
| | | } |
| | |
| | | import { |
| | | calculateSalary, |
| | | confirmSalary, |
| | | deleteSalaryCalculation, |
| | | exportSalaryCalculation, |
| | | getSalaryCalculationPage, |
| | | previewSalaryCalculation, |
| | | revokeSalaryCalculation, |
| | | saveSalaryCalculation, |
| | | } from '#/api/hrm/salary/calculation'; |
| | | import { getDeptList } from '#/api/system/dept'; |
| | |
| | | handleRefresh(); |
| | | } |
| | | |
| | | /** æ¹é确认 */ |
| | | /** æ¹é确认éä¸çè®°å½ */ |
| | | async function handleBatchConfirm() { |
| | | const records = gridApi.grid.getCheckboxRecords() as HrmSalaryCalculationApi.SalaryCalculation[]; |
| | | if (records.length === 0) { |
| | |
| | | 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(); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | /** 导åºèªé
¬æ ¸ç® */ |
| | |
| | | keepSource: true, |
| | | checkboxConfig: { |
| | | highlight: true, |
| | | reserve: true, |
| | | }, |
| | | proxyConfig: { |
| | | ajax: { |
| | |
| | | ...formValues, |
| | | }), |
| | | }, |
| | | }, |
| | | pagingConfig: { |
| | | pageSize: 20, |
| | | pageSizes: [20, 50, 100], |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | |
| | | 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" |
| | |
| | | onClick: handleBatchConfirm, |
| | | }, |
| | | { |
| | | label: 'æ¹éä¿å', |
| | | type: 'primary', |
| | | auth: ['hrm:salary-calculation:save'], |
| | | onClick: handleBatchSave, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.export'), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.DOWNLOAD, |
| | |
| | | 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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | /** æçç½æ ¼çæ¥æå·¥å
· */ |
| | | |
| | | 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 }, |
| | | ]; |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | 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', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <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> |