feat(hrm,bi): 移植长辉电力的考勤请假展示与 BI 大屏
- 打卡面板展示当天请假类型、请假工时,请假当天仍允许打卡
- 考勤记录接口补充 AttendanceMyStatus 与请假字段
- BI 大屏布局自适应(4K/宽屏/平板/手机),修正 12 列栅格下的卡片宽度
- 新增员工选择下拉组件
Co-Authored-By: Claude <noreply@anthropic.com>
| | |
| | | userName?: string; |
| | | deptId?: number; |
| | | deptName?: string; |
| | | phone?: string; |
| | | date?: string; |
| | | clockInTime?: string; |
| | | clockOutTime?: string; |
| | |
| | | workHours?: number; |
| | | overtimeHours?: number; |
| | | location?: string; |
| | | /** ä¸çæå¡ç»åº¦ï¼WGS84ï¼ */ |
| | | clockInLongitude?: number; |
| | | /** ä¸çæå¡çº¬åº¦ï¼WGS84ï¼ */ |
| | | clockInLatitude?: number; |
| | | /** ä¸çå®ä½ç²¾åº¦ï¼ç±³ */ |
| | | clockInAccuracy?: number; |
| | | /** ä¸çè·æå¡å°ç¹è·ç¦»ï¼ç±³ */ |
| | | clockInDistance?: number; |
| | | /** ä¸çæå¡å°ç¹å°åå¿«ç
§ */ |
| | | clockInAddress?: string; |
| | | /** ä¸çæå¡ç»åº¦ï¼WGS84ï¼ */ |
| | | clockOutLongitude?: number; |
| | | /** ä¸çæå¡çº¬åº¦ï¼WGS84ï¼ */ |
| | | clockOutLatitude?: number; |
| | | /** ä¸çå®ä½ç²¾åº¦ï¼ç±³ */ |
| | | clockOutAccuracy?: number; |
| | | /** ä¸çè·æå¡å°ç¹è·ç¦»ï¼ç±³ */ |
| | | clockOutDistance?: number; |
| | | /** ä¸çæå¡å°ç¹å°åå¿«ç
§ */ |
| | | clockOutAddress?: string; |
| | | dataSource?: number; |
| | | dataSourceName?: string; |
| | | /** å½å¤©æ¯å¦è¯·åï¼åå¨å®¡æ¹éè¿ä¸ä¸å·¥ä½æ¶æ®µéå ç请ååï¼ */ |
| | | leaveFlag?: boolean; |
| | | /** å½å¤©è¯·åå·¥æ¶ï¼å°æ¶ï¼ï¼æªè¯·å为 0 */ |
| | | leaveHours?: number; |
| | | /** å½å¤©è¯·åç±»åï¼éå·åéç HrmLeaveTypeEnum.typeï¼å¦ "1,2" */ |
| | | leaveTypes?: string; |
| | | remark?: string; |
| | | createTime?: string; |
| | | } |
| | | |
| | | /** æçèå¤ç¶æï¼/hrm/attendance/record/my-statusï¼ */ |
| | | export interface AttendanceMyStatus { |
| | | /** åå·¥å§å */ |
| | | employeeName?: string; |
| | | userName?: string; |
| | | deptName?: string; |
| | | /** è夿¥æ */ |
| | | date?: string; |
| | | /** æå¡å¨æ¶é´ */ |
| | | serverTime?: string; |
| | | /** æ¯å¦å·¥ä½æ¥ */ |
| | | workDay?: boolean; |
| | | /** 伿¯æ¥åå ï¼å¦ï¼å½åºèãå¨å
*/ |
| | | restDayReason?: string; |
| | | /** æå¡å°ç¹åç§° */ |
| | | locationName?: string; |
| | | locationAddress?: string; |
| | | /** æ¯å¦å¯ç¨å®ä½èå´æ ¡éª */ |
| | | locationRequired?: boolean; |
| | | /** å
许æå¡åå¾ï¼åä½ç±³ */ |
| | | radiusMeters?: number; |
| | | /** 彿¥èå¤è®°å½ï¼æªæå¡æ¶ä¸º null */ |
| | | record?: AttendanceRecord; |
| | | /** 仿¥è®°å½ID */ |
| | | recordId?: number; |
| | | clockInTime?: string; |
| | | clockOutTime?: string; |
| | | clockInType?: number; |
| | | clockOutType?: number; |
| | | clockInDone?: boolean; |
| | | clockOutDone?: boolean; |
| | | workHours?: number; |
| | | overtimeHours?: number; |
| | | location?: string; |
| | | /** å½å¤©æ¯å¦è¯·å */ |
| | | onLeave?: boolean; |
| | | /** å½å¤©è¯·åå·¥æ¶ï¼å°æ¶ï¼ï¼æªè¯·å为 0 */ |
| | | leaveHours?: number; |
| | | /** å½å¤©è¯·åç±»ååç§°ï¼å¤æ®µç¨é¡¿å·åéï¼å¦ "äºåãç
å" */ |
| | | leaveTypeNames?: string; |
| | | /** å½å坿å¡ç¶æ */ |
| | | clockable?: { |
| | | clockIn?: boolean; |
| | | clockOut?: boolean; |
| | | /** ä¸ä¸æ¥å¨ä½ï¼in-ä¸çæå¡ï¼out-ä¸çæå¡ï¼none-æ éæå¡ */ |
| | | nextAction?: 'in' | 'none' | 'out' | string; |
| | | /** æç¤ºææ¡ */ |
| | | message?: string; |
| | | }; |
| | | } |
| | | |
| | | /** èå¤ç»è®¡ */ |
| | |
| | | } |
| | | } |
| | | |
| | | /** å®ä½æå¡åæ ï¼å端ä»
æ¥å WGS84 */ |
| | | export interface AttendanceClockLocation { |
| | | longitude: number; |
| | | latitude: number; |
| | | accuracy: number; |
| | | coordinateSystem: 'WGS84'; |
| | | } |
| | | |
| | | /** å®ä½æå¡ç»æï¼æªå¯ç¨å®ä½èå´æ ¡éªæ¶ï¼è·ç¦»ä¸åå¾ä¸ºç©º */ |
| | | export interface AttendanceClockResponse { |
| | | record?: HrmAttendanceRecordApi.AttendanceRecord; |
| | | locationName?: string; |
| | | distanceMeters?: number; |
| | | radiusMeters?: number; |
| | | withinRange?: boolean; |
| | | } |
| | | |
| | | /** æ¥è¯¢èå¤è®°å½å页å表 */ |
| | | export function getAttendanceRecordPage(params: PageParam) { |
| | | return requestClient.get<PageResult<HrmAttendanceRecordApi.AttendanceRecord>>( |
| | | '/hrm/attendance/record/page', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** |
| | | * æ¥è¯¢å½åç»å½äººçèå¤ç¶æ |
| | | * |
| | | * @param date è夿¥æï¼æ ¼å¼ yyyy-MM-ddï¼ä¸ä¼ åæå¡å¨å½å¤© |
| | | */ |
| | | export function getAttendanceMyStatus(date?: string) { |
| | | return requestClient.get<HrmAttendanceRecordApi.AttendanceMyStatus>( |
| | | '/hrm/attendance/record/my-status', |
| | | date ? { params: { date } } : undefined, |
| | | ); |
| | | } |
| | | |
| | |
| | | return requestClient.download('/hrm/attendance/record/export-excel', { params }); |
| | | } |
| | | |
| | | export interface AttendanceClockLocation { |
| | | longitude: number; |
| | | latitude: number; |
| | | accuracy: number; |
| | | coordinateSystem: 'WGS84'; |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | export function clockIn(data: AttendanceClockLocation) { |
| | | return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>( |
| | | /** |
| | | * ä¸çæå¡ |
| | | * |
| | | * åæ¶æäº¤å®ä½ï¼JSON bodyï¼ä¸æ§çå°ç¹ææ¬ï¼query åæ°ï¼ï¼ |
| | | * - æ°å端æå®ä½æ ¡éªæå¡èå´ï¼æªå¯ç¨å®ä½æ¶å¿½ç¥å®ä½ï¼ |
| | | * - æ§å端åªè¯»å location åæ°ï¼ä¿è¯å级ååé½è½æ£å¸¸æå¡ã |
| | | */ |
| | | export function clockIn( |
| | | data?: AttendanceClockLocation | null, |
| | | location?: string, |
| | | ) { |
| | | return requestClient.post<AttendanceClockResponse>( |
| | | '/hrm/attendance/record/clock-in', |
| | | data, |
| | | data ?? null, |
| | | location ? { params: { location } } : undefined, |
| | | ); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | export function clockOut(data: AttendanceClockLocation) { |
| | | return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>( |
| | | /** ä¸çæå¡ï¼åæ°è¯´æå clockIn */ |
| | | export function clockOut( |
| | | data?: AttendanceClockLocation | null, |
| | | location?: string, |
| | | ) { |
| | | return requestClient.post<AttendanceClockResponse>( |
| | | '/hrm/attendance/record/clock-out', |
| | | data, |
| | | data ?? null, |
| | | location ? { params: { location } } : undefined, |
| | | ); |
| | | } |
| | |
| | | const dashboardRef = ref<HTMLElement>(); |
| | | let resizeObserver: ResizeObserver | null = null; |
| | | |
| | | /** |
| | | * 精确计ç®å¾è¡¨åºå¯ç¨é«åº¦ï¼åå
¥ CSS åé --fill-h |
| | | * |
| | | * ä»
é flex + 1fr å¨é¨ååºæ¯ä¸çæï¼è¿éç´æ¥éæµå¤´é¨ãKPI è¡ã |
| | | * è¡¨æ ¼å¡çå ç¨é«åº¦ï¼æå©ä½é«åº¦ç®åºæ¥ç»å¾è¡¨åºã |
| | | */ |
| | | function layoutFill() { |
| | | const el = dashboardRef.value; |
| | | if (!el) return; |
| | | if (!isFullscreen.value) { |
| | | el.style.removeProperty('--fill-h'); |
| | | return; |
| | | } |
| | | const header = el.querySelector('.dash-header') as HTMLElement | null; |
| | | const kpi = el.querySelector('.kpi-row') as HTMLElement | null; |
| | | const tables = el.querySelectorAll<HTMLElement>('.table-card'); |
| | | const cs = getComputedStyle(el); |
| | | let used = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0); |
| | | if (header) used += header.offsetHeight + 24; |
| | | if (kpi) used += kpi.offsetHeight + 16; |
| | | tables.forEach((t) => { used += t.offsetHeight + 16; }); |
| | | const avail = Math.max(240, window.innerHeight - used); |
| | | el.style.setProperty('--fill-h', `${avail}px`); |
| | | } |
| | | |
| | | function resizeAllCharts() { |
| | | setTimeout(() => window.dispatchEvent(new Event('resize')), 100); |
| | | } |
| | |
| | | await document.exitFullscreen(); |
| | | isFullscreen.value = false; |
| | | } |
| | | setTimeout(resizeAllCharts, 400); |
| | | setTimeout(() => { layoutFill(); resizeAllCharts(); }, 400); |
| | | } |
| | | function onFullscreenChange() { |
| | | isFullscreen.value = !!document.fullscreenElement; |
| | | setTimeout(resizeAllCharts, 300); |
| | | setTimeout(() => { layoutFill(); resizeAllCharts(); }, 300); |
| | | } |
| | | |
| | | // ======== KPI æ°åå¨ç» ======== |
| | |
| | | |
| | | /* ======== å
¨å± ======== */ |
| | | .is-fullscreen { |
| | | max-height: none; min-height: 100vh; padding: 24px 28px; border-radius: 0; overflow-y: auto; |
| | | height: 100vh; |
| | | min-height: 100vh; |
| | | max-height: 100vh; |
| | | padding: 24px 28px; |
| | | border-radius: 0; |
| | | overflow-y: auto; |
| | | } |
| | | |
| | | /* 头é¨ä¸ KPI è¡ä¿æèªç¶é«åº¦ */ |
| | | .is-fullscreen > .dash-header, |
| | | .is-fullscreen > .kpi-row { |
| | | flex: 0 0 auto; |
| | | } |
| | | |
| | | /* å¾è¡¨åºåæå©ä½é«åº¦ï¼è¡é«ååï¼é¿å
åºé¨å¤§ççç½ */ |
| | | .is-fullscreen > .chart-row { |
| | | flex: 0 0 auto; |
| | | /* é«åº¦ç± layoutFill() éæµååå
¥ï¼ä¿è¯æ£å¥½å¡«æ»¡è§å£ */ |
| | | height: var(--fill-h, auto); |
| | | grid-auto-rows: minmax(200px, 1fr); |
| | | align-content: stretch; |
| | | } |
| | | |
| | | /* è¡¨æ ¼å¡ç䏿¢é«åº¦ï¼å
容è¶
åºæ¶å®¹å¨æ»å¨ */ |
| | | .is-fullscreen > .table-card { |
| | | flex: 0 1 auto; |
| | | } |
| | | .is-fullscreen::before, |
| | | .is-fullscreen::after { |
| | |
| | | import type { HrmAttendanceRecordApi } from '#/api/hrm/attendance/record'; |
| | | |
| | | import { DICT_TYPE } from '#/packages/constants/src'; |
| | | import { getDictOptions } from '#/packages/effects/hooks/src'; |
| | | import { getDictLabel, getDictOptions } from '#/packages/effects/hooks/src'; |
| | | |
| | | import { getDeptList } from '#/api/system/dept'; |
| | | import { handleTree } from '#/packages/utils/src'; |
| | | |
| | | /** 请åç±»åçå
åºåç§°ï¼ä¸å端 HrmLeaveTypeEnum ä¿æä¸è´ */ |
| | | const LEAVE_TYPE_FALLBACK_LABELS: Record<number, string> = { |
| | | 1: 'äºå', |
| | | 2: 'ç
å', |
| | | 3: 'å¹´å', |
| | | 4: 'å©å', |
| | | 5: '产å', |
| | | 6: '丧å', |
| | | }; |
| | | |
| | | /** |
| | | * 请åç±»åçåå
¸æ ç¾ |
| | | * |
| | | * åå
¸ hrm_leave_type æªåæ¥æ¶è¿å空串ï¼ç±è°ç¨æ¹å³å®å
åºï¼ |
| | | * é¿å
è¿ç±»ç¯å¢æè¯·åç±»åæ¾ç¤ºæ "-" è让人误以为没æè¯·åã |
| | | */ |
| | | export function getLeaveTypeLabel(type: number | string) { |
| | | const label = getDictLabel(DICT_TYPE.HRM_LEAVE_TYPE, String(type)); |
| | | return label || LEAVE_TYPE_FALLBACK_LABELS[Number(type)] || ''; |
| | | } |
| | | |
| | | /** æ leaveTypesï¼å¦ "1,2"ï¼ç¿»è¯æè¯·åç±»ååç§°ï¼å¤æ®µç¨é¡¿å·åé */ |
| | | export function formatLeaveTypeNames(leaveTypes?: string) { |
| | | if (!leaveTypes) { |
| | | return ''; |
| | | } |
| | | return leaveTypes |
| | | .split(',') |
| | | .map((type) => type.trim()) |
| | | .filter(Boolean) |
| | | .map((type) => getLeaveTypeLabel(type) || type) |
| | | .join('ã'); |
| | | } |
| | | |
| | | /** å表çæç´¢è¡¨å */ |
| | | export function useGridFormSchema(): VbenFormSchema[] { |
| | |
| | | placeholder: 'è¯·éæ©ä¸çæå¡ç±»å', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'leaveFlag', |
| | | label: 'æ¯å¦è¯·å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: [ |
| | | { label: '已请å', value: true }, |
| | | { label: 'æªè¯·å', value: false }, |
| | | ], |
| | | placeholder: 'è¯·éæ©æ¯å¦è¯·å', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | |
| | | props: { type: DICT_TYPE.HRM_CLOCK_TYPE }, |
| | | }, |
| | | }, |
| | | { |
| | | field: 'leaveFlag', |
| | | title: '请å', |
| | | width: 160, |
| | | slots: { |
| | | default: 'leave', |
| | | }, |
| | | }, |
| | | { field: 'workHours', title: '工使¶é¿(h)', width: 100 }, |
| | | { field: 'overtimeHours', title: 'å çæ¶é¿(h)', width: 100 }, |
| | | { field: 'location', title: 'æå¡å°ç¹', minWidth: 150 }, |
| | |
| | | import { Page, useVbenModal } from '#/packages/effects/common-ui/src'; |
| | | import { downloadFileFromBlobPart } from '#/packages/utils/src'; |
| | | |
| | | import { Button, message } from 'ant-design-vue'; |
| | | import { message, Tag } from 'ant-design-vue'; |
| | | |
| | | import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table'; |
| | | import { exportAttendanceRecord, getAttendanceRecordPage } from '#/api/hrm/attendance/record'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import { formatLeaveTypeNames, useGridColumns, useGridFormSchema } from './data'; |
| | | import ClockPanel from './modules/clock-panel.vue'; |
| | | import ImportForm from './modules/import-form.vue'; |
| | | |
| | |
| | | ]" |
| | | /> |
| | | </template> |
| | | <template #leave="{ row }"> |
| | | <Tag v-if="row.leaveFlag" color="blue"> |
| | | {{ formatLeaveTypeNames(row.leaveTypes) || '请å' }} |
| | | <span v-if="Number(row.leaveHours) > 0"> |
| | | {{ Number(row.leaveHours).toFixed(2) }}h |
| | | </span> |
| | | </Tag> |
| | | <span v-else>-</span> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| | |
| | | <script lang="ts" setup> |
| | | import { ref } from 'vue'; |
| | | import type { AttendanceClockLocation, AttendanceClockResponse, HrmAttendanceRecordApi } from '#/api/hrm/attendance/record'; |
| | | |
| | | import { computed, ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '#/packages/effects/common-ui/src'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | import type { AttendanceClockLocation } from '#/api/hrm/attendance/record'; |
| | | import { clockIn, clockOut } from '#/api/hrm/attendance/record'; |
| | | import { clockIn, clockOut, getAttendanceMyStatus } from '#/api/hrm/attendance/record'; |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const loading = ref(false); |
| | | const DEFAULT_LOCATION = 'å
¬å¸åå°'; |
| | | |
| | | function getCurrentLocation(): Promise<AttendanceClockLocation> { |
| | | if (!navigator.geolocation) { |
| | | return Promise.reject(new Error('å½åæµè§å¨ä¸æ¯æå®ä½ï¼è¯·ä½¿ç¨æ¯æ GPS çæµè§å¨')); |
| | | const location = ref(DEFAULT_LOCATION); |
| | | const loading = ref(false); |
| | | const locating = ref(false); |
| | | /** 仿¥èå¤ç¶æï¼å«è¯·åä¿¡æ¯ï¼ï¼å 载失败æ¶ä¸º undefined */ |
| | | const myStatus = ref<HrmAttendanceRecordApi.AttendanceMyStatus>(); |
| | | const statusLoading = ref(false); |
| | | /** å½åæå¡è¿åçå½å¤©è®°å½ï¼ç¨äºæå¡å峿¶å·æ°è¯·åä¿¡æ¯ */ |
| | | const lastClockRecord = ref<HrmAttendanceRecordApi.AttendanceRecord>(); |
| | | |
| | | /** å½å¤©æ¯å¦è¯·åï¼ä¼å
ç¨ my-status ç onLeaveï¼å
¶æ¬¡ç¨å½å¤©è®°å½ä¸ç leaveFlag */ |
| | | const onLeave = computed( |
| | | () => |
| | | myStatus.value?.onLeave === true || |
| | | myStatus.value?.record?.leaveFlag === true || |
| | | lastClockRecord.value?.leaveFlag === true, |
| | | ); |
| | | |
| | | /** 请åç±»ååç§°ï¼å¦ "äºåãç
å" */ |
| | | const leaveTypeNames = computed( |
| | | () => |
| | | myStatus.value?.leaveTypeNames || |
| | | formatLeaveTypes( |
| | | myStatus.value?.record?.leaveTypes || |
| | | lastClockRecord.value?.leaveTypes, |
| | | ), |
| | | ); |
| | | |
| | | /** 请åå·¥æ¶ï¼å°æ¶ï¼ */ |
| | | const leaveHours = computed( |
| | | () => |
| | | Number( |
| | | myStatus.value?.leaveHours ?? |
| | | myStatus.value?.record?.leaveHours ?? |
| | | lastClockRecord.value?.leaveHours ?? |
| | | 0, |
| | | ) || 0, |
| | | ); |
| | | |
| | | /** 请åç±»åå
åºåç§°ï¼ä¸å端 HrmLeaveTypeEnum ä¿æä¸è´ */ |
| | | const LEAVE_TYPE_NAMES: Record<number, string> = { |
| | | 1: 'äºå', |
| | | 2: 'ç
å', |
| | | 3: 'å¹´å', |
| | | 4: 'å©å', |
| | | 5: '产å', |
| | | 6: '丧å', |
| | | }; |
| | | |
| | | /** æ "1,2" ç¿»è¯æ "äºåãç
å" */ |
| | | function formatLeaveTypes(leaveTypes?: string) { |
| | | if (!leaveTypes) { |
| | | return ''; |
| | | } |
| | | return new Promise((resolve, reject) => { |
| | | return leaveTypes |
| | | .split(',') |
| | | .map((type) => type.trim()) |
| | | .filter(Boolean) |
| | | .map((type) => LEAVE_TYPE_NAMES[Number(type)] ?? '') |
| | | .filter(Boolean) |
| | | .join('ã'); |
| | | } |
| | | |
| | | /** å è½½å½åç»å½äººçèå¤ç¶æï¼å¤±è´¥æ¶ä¸é»ææå¡ */ |
| | | async function loadMyStatus() { |
| | | statusLoading.value = true; |
| | | try { |
| | | myStatus.value = await getAttendanceMyStatus(); |
| | | } catch { |
| | | // æ¥å£é误已ç±è¯·æ±æ¦æªå¨ç»ä¸æç¤ºï¼è¿ééé»é级ï¼ä¸å½±åæå¡ä¸»æµç¨ |
| | | myStatus.value = undefined; |
| | | } finally { |
| | | statusLoading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * è·åæµè§å¨å®ä½å¹¶ç»ä¸ä¸º WGS84 交ç»åç«¯æ ¡éªã |
| | | * å®ä½ä¸å¯ç¨ï¼ä¸æ¯æãæç»ææãé HTTPSï¼æ¶è¿å nullï¼ |
| | | * ç±å端æèå¤è§åå³å®æ¯å¦å
许æ å®ä½æå¡ã |
| | | */ |
| | | function getCurrentLocation(): Promise<AttendanceClockLocation | null> { |
| | | if (!navigator.geolocation) { |
| | | return Promise.resolve(null); |
| | | } |
| | | return new Promise((resolve) => { |
| | | navigator.geolocation.getCurrentPosition( |
| | | (position) => resolve({ |
| | | longitude: position.coords.longitude, |
| | | latitude: position.coords.latitude, |
| | | accuracy: position.coords.accuracy, |
| | | coordinateSystem: 'WGS84', |
| | | }), |
| | | () => reject(new Error('å®ä½å¤±è´¥ï¼è¯·å¼å¯å®ä½æéåéè¯ï¼é localhost éä½¿ç¨ HTTPSï¼')), |
| | | (position) => |
| | | resolve({ |
| | | longitude: Number(position.coords.longitude.toFixed(7)), |
| | | latitude: Number(position.coords.latitude.toFixed(7)), |
| | | accuracy: Number(position.coords.accuracy.toFixed(2)), |
| | | coordinateSystem: 'WGS84', |
| | | }), |
| | | () => resolve(null), |
| | | { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 }, |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | async function handleClockIn() { |
| | | /** æªå¯ç¨å®ä½èå´æ ¡éªæ¶å端ä¸è¿åè·ç¦»ï¼ç´æ¥æç¤ºæå */ |
| | | function buildSuccessMessage( |
| | | action: string, |
| | | result?: AttendanceClockResponse, |
| | | ) { |
| | | if (result?.distanceMeters === null || result?.distanceMeters === undefined) { |
| | | return `${action}æå`; |
| | | } |
| | | const place = result.locationName ? `è·${result.locationName}` : 'è·æå¡å°ç¹'; |
| | | return `${action}æåï¼${place} ${Number(result.distanceMeters).toFixed(1)} ç±³`; |
| | | } |
| | | |
| | | async function submitClock(action: 'in' | 'out') { |
| | | if (loading.value) { |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | await clockIn(await getCurrentLocation()); |
| | | message.success('ä¸çæå¡æå'); |
| | | locating.value = true; |
| | | const position = await getCurrentLocation(); |
| | | locating.value = false; |
| | | if (!position) { |
| | | message.warning( |
| | | 'æªè·åå°å®ä½ï¼å°ææå¡å°ç¹ææ¬æäº¤ï¼è¥èå¤è§åè¦æ±å®ä½ï¼è¯·å¼å¯å®ä½æéåéè¯ï¼é HTTPS ç¯å¢æµè§å¨ä¼ç¦ç¨å®ä½ï¼', |
| | | ); |
| | | } |
| | | const locationText = location.value.trim() || undefined; |
| | | const result = |
| | | action === 'in' |
| | | ? await clockIn(position, locationText) |
| | | : await clockOut(position, locationText); |
| | | lastClockRecord.value = result?.record; |
| | | message.success( |
| | | buildSuccessMessage(action === 'in' ? 'ä¸çæå¡' : 'ä¸çæå¡', result), |
| | | ); |
| | | // æå¡åå·æ°ä»æ¥ç¶æï¼å«è¯·åä¿¡æ¯ï¼ï¼ä¿è¯é¢æ¿ä¸çæç¤ºä¸å端ä¸è´ |
| | | await loadMyStatus(); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || 'æå¡å¤±è´¥'); |
| | | } catch (error: any) { |
| | | // æ¥å£é误已ç±è¯·æ±æ¦æªå¨ç»ä¸æç¤ºï¼è¿éåªå
åºæ¬å°å¼å¸¸ï¼é¿å
éå¤å¼¹åº |
| | | const handledByInterceptor = Boolean( |
| | | error?.response || error?.data || error?.isAxiosError || error?.code, |
| | | ); |
| | | if (!handledByInterceptor) { |
| | | message.error(error?.message || 'æå¡å¤±è´¥'); |
| | | } |
| | | } finally { |
| | | locating.value = false; |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | function handleClockIn() { |
| | | return submitClock('in'); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | async function handleClockOut() { |
| | | loading.value = true; |
| | | try { |
| | | await clockOut(await getCurrentLocation()); |
| | | message.success('ä¸çæå¡æå'); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || 'æå¡å¤±è´¥'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | function handleClockOut() { |
| | | return submitClock('out'); |
| | | } |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | onOpenChange() {}, |
| | | const [Modal] = useVbenModal({ |
| | | onOpenChange(isOpen: boolean) { |
| | | if (isOpen) { |
| | | // æ¯æ¬¡æå¼é½æä¸æ¬¡ä»æ¥ç¶æï¼é¿å
å±ç¤ºè¿æç请åä¿¡æ¯ |
| | | myStatus.value = undefined; |
| | | lastClockRecord.value = undefined; |
| | | void loadMyStatus(); |
| | | } else { |
| | | location.value = DEFAULT_LOCATION; |
| | | } |
| | | }, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="æå¡" class="w-[400px]"> |
| | | <Modal title="æå¡" class="w-[420px]"> |
| | | <div class="p-4"> |
| | | <div class="mb-4 text-sm text-gray-500"> |
| | | ç¹å»æå¡åå°è·åå½å GPS ä½ç½®ï¼å¹¶æèå¤è§åä¸çç»çº¬åº¦åæå¡å徿 ¡éªã |
| | | <a-alert |
| | | v-if="onLeave" |
| | | class="mb-4" |
| | | type="info" |
| | | show-icon |
| | | message="仿¥å·²è¯·åï¼å¦éä¸ç坿£å¸¸æå¡" |
| | | > |
| | | <template #description> |
| | | <div class="text-xs"> |
| | | <span v-if="leaveTypeNames">请åç±»åï¼{{ leaveTypeNames }}</span> |
| | | <span v-if="leaveHours > 0" class="ml-2"> |
| | | 请å {{ leaveHours.toFixed(2) }} å°æ¶ |
| | | </span> |
| | | <div class="mt-1 text-gray-500"> |
| | | 请åå½å¤©æå¡ä¸è®¡è¿å°/æ©éï¼ä¹ä¸è®¡ç¼ºå¡ã |
| | | </div> |
| | | </div> |
| | | </template> |
| | | </a-alert> |
| | | <div v-else-if="statusLoading" class="mb-4 text-xs text-gray-400"> |
| | | æ£å¨å è½½ä»æ¥èå¤ç¶æâ¦ |
| | | </div> |
| | | <div class="mb-4"> |
| | | <label class="mr-2">æå¡å°ç¹ï¼</label> |
| | | <a-input |
| | | v-model:value="location" |
| | | placeholder="请è¾å
¥æå¡å°ç¹" |
| | | style="width: 220px" |
| | | /> |
| | | </div> |
| | | <div class="mb-4 text-xs text-gray-500"> |
| | | ç¹å»æå¡åä¼èªå¨è·åå½å GPS ä½ç½®ï¼å¹¶æèå¤è§åä¸çå°ç¹ä¸å
许å徿 ¡éªï¼æµè§å¨å®ä½ä¸å¯ç¨æ¶ï¼å°æä¸æ¹å°ç¹ææ¬æäº¤ã |
| | | </div> |
| | | <div class="flex gap-4"> |
| | | <a-button |
| | | type="primary" |
| | | size="large" |
| | | :loading="loading" |
| | | :disabled="loading" |
| | | @click="handleClockIn" |
| | | > |
| | | ä¸çæå¡ |
| | |
| | | <a-button |
| | | size="large" |
| | | :loading="loading" |
| | | :disabled="loading" |
| | | @click="handleClockOut" |
| | | > |
| | | ä¸çæå¡ |
| | | </a-button> |
| | | </div> |
| | | <div v-if="locating" class="mt-2 text-xs text-gray-400"> |
| | | æ£å¨è·åå®ä½â¦ |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { HrmEmployeeApi } from '#/api/hrm/employee'; |
| | | |
| | | import { computed, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { Select } from 'ant-design-vue'; |
| | | |
| | | import { getEmployeeSimpleList } from '#/api/hrm/employee'; |
| | | |
| | | defineOptions({ name: 'EmployeeSelect', inheritAttrs: false }); |
| | | |
| | | const props = withDefaults( |
| | | defineProps<{ |
| | | allowClear?: boolean; |
| | | disabled?: boolean; |
| | | /** é¨é¨ IDï¼ä¼ å
¥ååªæ¾ç¤ºè¯¥é¨é¨çåå·¥ */ |
| | | deptId?: number; |
| | | /** å ä½ææ¡ */ |
| | | placeholder?: string; |
| | | modelValue?: number; |
| | | }>(), |
| | | { |
| | | allowClear: true, |
| | | disabled: false, |
| | | deptId: undefined, |
| | | placeholder: 'è¾å
¥å§åæå·¥å·æç´¢', |
| | | modelValue: undefined, |
| | | }, |
| | | ); |
| | | |
| | | const emit = defineEmits<{ |
| | | change: [item: HrmEmployeeApi.EmployeeSimple | undefined]; |
| | | 'update:modelValue': [value: number | undefined]; |
| | | }>(); |
| | | |
| | | const loading = ref(false); |
| | | const allOptions = ref<HrmEmployeeApi.EmployeeSimple[]>([]); |
| | | |
| | | /** 䏿é项ï¼å§åï¼é¨é¨ï¼ */ |
| | | const options = computed(() => |
| | | allOptions.value |
| | | .filter((e) => !props.deptId || (e as any).deptId === props.deptId) |
| | | .map((e) => ({ |
| | | label: e.deptName ? `${e.name}ï¼${e.deptName}ï¼` : e.name, |
| | | value: e.id, |
| | | raw: e, |
| | | })), |
| | | ); |
| | | |
| | | onMounted(async () => { |
| | | loading.value = true; |
| | | try { |
| | | allOptions.value = (await getEmployeeSimpleList()) || []; |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | }); |
| | | |
| | | /** 模ç³å¹é
ï¼å§å / å·¥å· / é¨é¨ */ |
| | | function filterOption(input: string, option: any) { |
| | | const kw = String(input ?? '').toLowerCase().trim(); |
| | | if (!kw) { |
| | | return true; |
| | | } |
| | | const raw = option?.raw || {}; |
| | | const hay = [raw.name, raw.employeeNo, raw.deptName, option?.label] |
| | | .filter(Boolean) |
| | | .join(' ') |
| | | .toLowerCase(); |
| | | return hay.includes(kw); |
| | | } |
| | | |
| | | function handleChange(val: any) { |
| | | const id = typeof val === 'number' ? val : undefined; |
| | | emit('update:modelValue', id); |
| | | emit('change', allOptions.value.find((e) => e.id === id)); |
| | | } |
| | | |
| | | watch( |
| | | () => props.modelValue, |
| | | () => { |
| | | // ä»
ç¨äºè§¦åè§å¾æ´æ° |
| | | }, |
| | | ); |
| | | </script> |
| | | |
| | | <template> |
| | | <Select |
| | | v-bind="$attrs" |
| | | :allow-clear="allowClear" |
| | | :disabled="disabled" |
| | | :filter-option="filterOption" |
| | | :loading="loading" |
| | | :options="options" |
| | | :placeholder="placeholder" |
| | | :value="modelValue" |
| | | class="w-full" |
| | | option-filter-prop="label" |
| | | show-search |
| | | @change="handleChange" |
| | | /> |
| | | </template> |
| | |
| | | label: '交æ¥äºº', |
| | | component: 'ApiSelect', |
| | | componentProps: { |
| | | showSearch: true, |
| | | optionFilterProp: 'label', |
| | | api: getEmployeeSimpleList, |
| | | labelField: 'name', |
| | | valueField: 'id', |
| | |
| | | label: 'åå·¥', |
| | | component: 'ApiSelect', |
| | | componentProps: { |
| | | showSearch: true, |
| | | optionFilterProp: 'label', |
| | | api: getEmployeeSimpleList, |
| | | labelField: 'name', |
| | | valueField: 'id', |