| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '#/packages/effects/request/src'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace HrmAttendanceRecordApi { |
| | | /** èå¤è®°å½ */ |
| | | export interface AttendanceRecord { |
| | | id?: number; |
| | | userId?: number; |
| | | userName?: string; |
| | | deptId?: number; |
| | | deptName?: string; |
| | | date?: string; |
| | | clockInTime?: string; |
| | | clockOutTime?: string; |
| | | clockInType?: number; |
| | | clockInTypeName?: string; |
| | | clockOutType?: number; |
| | | clockOutTypeName?: string; |
| | | workHours?: number; |
| | | overtimeHours?: number; |
| | | location?: string; |
| | | clockInLongitude?: number; |
| | | clockInLatitude?: number; |
| | | clockInAccuracy?: number; |
| | | clockInDistance?: number; |
| | | clockInAddress?: string; |
| | | clockOutLongitude?: number; |
| | | clockOutLatitude?: number; |
| | | clockOutAccuracy?: number; |
| | | clockOutDistance?: number; |
| | | clockOutAddress?: string; |
| | | dataSource?: number; |
| | | dataSourceName?: string; |
| | | remark?: string; |
| | | createTime?: string; |
| | | } |
| | | |
| | | /** èå¤ç»è®¡ */ |
| | | export interface AttendanceStatistics { |
| | | userId?: number; |
| | | userName?: string; |
| | | deptName?: string; |
| | | totalDays?: number; |
| | | normalDays?: number; |
| | | lateDays?: number; |
| | | earlyDays?: number; |
| | | absentDays?: number; |
| | | overtimeHours?: number; |
| | | totalWorkHours?: number; |
| | | } |
| | | |
| | | /** å®ä½æå¡ç»æ */ |
| | | export interface AttendanceClockResponse { |
| | | record: 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 }, |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢èå¤ç»è®¡ï¼ææï¼ */ |
| | | export function getAttendanceStatistics(params: { |
| | | userId?: number; |
| | | deptId?: number; |
| | | month: string; |
| | | }) { |
| | | return requestClient.get<HrmAttendanceRecordApi.AttendanceStatistics[]>( |
| | | '/hrm/attendance/record/statistics', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** 导å
¥èå¤è®°å½ */ |
| | | export function importAttendanceRecord(file: File, updateSupport: boolean) { |
| | | return requestClient.upload('/hrm/attendance/record/import', { |
| | | file, |
| | | updateSupport, |
| | | }); |
| | | } |
| | | |
| | | /** ä¸è½½èå¤å¯¼å
¥æ¨¡æ¿ */ |
| | | export function importAttendanceTemplate() { |
| | | return requestClient.download('/hrm/attendance/record/get-import-template'); |
| | | } |
| | | |
| | | /** 导åºèå¤è®°å½ */ |
| | | export function exportAttendanceRecord(params: any) { |
| | | 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.AttendanceClockResponse>( |
| | | '/hrm/attendance/record/clock-in', |
| | | data, |
| | | ); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | export function clockOut(data: AttendanceClockLocation) { |
| | | return requestClient.post<HrmAttendanceRecordApi.AttendanceClockResponse>( |
| | | '/hrm/attendance/record/clock-out', |
| | | data, |
| | | ); |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { PageParam, PageResult } from '#/packages/effects/request/src'; |
| | | |
| | | import { requestClient } from '#/api/request'; |
| | | |
| | | export namespace HrmAttendanceRuleApi { |
| | | /** èå¤è§å */ |
| | | export interface AttendanceRule { |
| | | id?: number; |
| | | name?: string; |
| | | deptId?: number; |
| | | deptName?: string; |
| | | workStartTime?: string; |
| | | workEndTime?: string; |
| | | lateAllowMinutes?: number; |
| | | earlyAllowMinutes?: number; |
| | | overtimeThreshold?: number; |
| | | locationName?: string; |
| | | locationAddress?: string; |
| | | longitude?: number; |
| | | latitude?: number; |
| | | radiusMeters?: number; |
| | | locationRequired?: boolean; |
| | | coordinateSystem?: 'WGS84'; |
| | | status?: number; |
| | | remark?: string; |
| | | createTime?: string; |
| | | } |
| | | } |
| | | |
| | | /** æ¥è¯¢èå¤è§åå页å表 */ |
| | | export function getAttendanceRulePage(params: PageParam) { |
| | | return requestClient.get<PageResult<HrmAttendanceRuleApi.AttendanceRule>>( |
| | | '/hrm/attendance/rule/page', |
| | | { params }, |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢èå¤è§åå表ï¼å¯ç¨ç¶æï¼ */ |
| | | export function getAttendanceRuleList() { |
| | | return requestClient.get<HrmAttendanceRuleApi.AttendanceRule[]>( |
| | | '/hrm/attendance/rule/list', |
| | | ); |
| | | } |
| | | |
| | | /** æ¥è¯¢èå¤è§å详æ
*/ |
| | | export function getAttendanceRule(id: number) { |
| | | return requestClient.get<HrmAttendanceRuleApi.AttendanceRule>( |
| | | `/hrm/attendance/rule/get?id=${id}`, |
| | | ); |
| | | } |
| | | |
| | | /** æ°å¢èå¤è§å */ |
| | | export function createAttendanceRule(data: HrmAttendanceRuleApi.AttendanceRule) { |
| | | return requestClient.post('/hrm/attendance/rule/create', data); |
| | | } |
| | | |
| | | /** æ´æ°èå¤è§å */ |
| | | export function updateAttendanceRule(data: HrmAttendanceRuleApi.AttendanceRule) { |
| | | return requestClient.put('/hrm/attendance/rule/update', data); |
| | | } |
| | | |
| | | /** å é¤èå¤è§å */ |
| | | export function deleteAttendanceRule(id: number) { |
| | | return requestClient.delete(`/hrm/attendance/rule/delete?id=${id}`); |
| | | } |
| | | |
| | | /** 导åºèå¤è§å Excel */ |
| | | export function exportAttendanceRule(params: any) { |
| | | return requestClient.download('/hrm/attendance/rule/export-excel', { params }); |
| | | } |
| | | |
| | | /** ä¸è½½èå¤è§å导å
¥æ¨¡æ¿ */ |
| | | export function importAttendanceRuleTemplate() { |
| | | return requestClient.download('/hrm/attendance/rule/get-import-template'); |
| | | } |
| | | |
| | | /** 导å
¥èå¤è§å */ |
| | | export function importAttendanceRule(file: File, updateSupport: boolean) { |
| | | return requestClient.upload('/hrm/attendance/rule/import', { |
| | | file, |
| | | updateSupport, |
| | | }); |
| | | } |
| | |
| | | WMS_SHIPMENT_ORDER_TYPE: 'wms_shipment_order_type', // WMS åºåºåç±»å |
| | | } as const; |
| | | |
| | | /** ========== HRM - 人åèµæºæ¨¡å ========== */ |
| | | const HRM_DICT = { |
| | | HRM_CLOCK_TYPE: 'hrm_clock_type', // HRM æå¡ç±»å |
| | | } as const; |
| | | |
| | | /** åå
¸ç±»åæä¸¾ - ç»ä¸å¯¼åº */ |
| | | const DICT_TYPE = { |
| | | ...AI_DICT, |
| | | ...BPM_DICT, |
| | | ...CRM_DICT, |
| | | ...ERP_DICT, |
| | | ...HRM_DICT, |
| | | ...INFRA_DICT, |
| | | ...IM_DICT, |
| | | ...IOT_DICT, |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { HrmAttendanceRecordApi } from '#/api/hrm/attendance/record'; |
| | | |
| | | import { DICT_TYPE } from '#/packages/constants/src'; |
| | | import { getDictOptions } from '#/packages/effects/hooks/src'; |
| | | |
| | | import { getDeptList } from '#/api/system/dept'; |
| | | import { handleTree } from '#/packages/utils/src'; |
| | | |
| | | /** å表çæç´¢è¡¨å */ |
| | | export function useGridFormSchema(): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'userName', |
| | | label: 'åå·¥å§å', |
| | | component: 'Input', |
| | | componentProps: { |
| | | allowClear: true, |
| | | placeholder: '请è¾å
¥åå·¥å§å', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'date', |
| | | label: 'è夿¥æ', |
| | | component: 'RangePicker', |
| | | componentProps: { |
| | | allowClear: true, |
| | | valueFormat: 'YYYY-MM-DD', |
| | | style: { width: '100%' }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'deptId', |
| | | label: 'é¨é¨', |
| | | component: 'ApiTreeSelect', |
| | | componentProps: { |
| | | api: async () => { |
| | | const data = await getDeptList(); |
| | | return handleTree(data); |
| | | }, |
| | | labelField: 'name', |
| | | valueField: 'id', |
| | | childrenField: 'children', |
| | | allowClear: true, |
| | | placeholder: 'è¯·éæ©é¨é¨', |
| | | treeDefaultExpandAll: true, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clockInType', |
| | | label: 'ä¸çæå¡ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: getDictOptions(DICT_TYPE.HRM_CLOCK_TYPE, 'number'), |
| | | placeholder: 'è¯·éæ©ä¸çæå¡ç±»å', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'clockOutType', |
| | | label: 'ä¸çæå¡ç±»å', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: getDictOptions(DICT_TYPE.HRM_CLOCK_TYPE, 'number'), |
| | | placeholder: 'è¯·éæ©ä¸çæå¡ç±»å', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** å表çåæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions<HrmAttendanceRecordApi.AttendanceRecord>['columns'] { |
| | | return [ |
| | | { field: 'userName', title: 'åå·¥å§å', minWidth: 100 }, |
| | | { field: 'deptName', title: 'é¨é¨', minWidth: 120 }, |
| | | { field: 'date', title: 'è夿¥æ', width: 110 }, |
| | | { field: 'clockInTime', title: 'ä¸çæå¡æ¶é´', width: 160 }, |
| | | { |
| | | field: 'clockInType', |
| | | title: 'ä¸çæå¡ç±»å', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.HRM_CLOCK_TYPE }, |
| | | }, |
| | | }, |
| | | { field: 'clockOutTime', title: 'ä¸çæå¡æ¶é´', width: 160 }, |
| | | { |
| | | field: 'clockOutType', |
| | | title: 'ä¸çæå¡ç±»å', |
| | | width: 100, |
| | | cellRender: { |
| | | name: 'CellDict', |
| | | props: { type: DICT_TYPE.HRM_CLOCK_TYPE }, |
| | | }, |
| | | }, |
| | | { field: 'workHours', title: '工使¶é¿(h)', width: 100 }, |
| | | { field: 'overtimeHours', title: 'å çæ¶é¿(h)', width: 100 }, |
| | | { field: 'location', title: 'æå¡å°ç¹', minWidth: 150 }, |
| | | { |
| | | field: 'dataSource', |
| | | title: 'æ°æ®æ¥æº', |
| | | width: 100, |
| | | }, |
| | | { |
| | | title: 'æä½', |
| | | width: 120, |
| | | fixed: 'right', |
| | | slots: { |
| | | default: 'actions', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { HrmAttendanceRecordApi } from '#/api/hrm/attendance/record'; |
| | | |
| | | import { Page, useVbenModal } from '#/packages/effects/common-ui/src'; |
| | | import { downloadFileFromBlobPart } from '#/packages/utils/src'; |
| | | |
| | | import { Button, message } 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 ClockPanel from './modules/clock-panel.vue'; |
| | | import ImportForm from './modules/import-form.vue'; |
| | | |
| | | const [ImportModal, importModalApi] = useVbenModal({ |
| | | connectedComponent: ImportForm, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | const [ClockModal, clockModalApi] = useVbenModal({ |
| | | connectedComponent: ClockPanel, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | /** å·æ°è¡¨æ ¼ */ |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | } |
| | | |
| | | /** æå¡ */ |
| | | function handleClock() { |
| | | clockModalApi.open(); |
| | | } |
| | | |
| | | /** 导å
¥èå¤è®°å½ */ |
| | | function handleImport() { |
| | | importModalApi.open(); |
| | | } |
| | | |
| | | /** 导åºèå¤è®°å½ */ |
| | | async function handleExport() { |
| | | const data = await exportAttendanceRecord(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 getAttendanceRecordPage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }), |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<HrmAttendanceRecordApi.AttendanceRecord>, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <ImportModal @success="handleRefresh" /> |
| | | <ClockModal @success="handleRefresh" /> |
| | | <Grid table-title="èå¤è®°å½å表"> |
| | | <template #toolbar-tools> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æå¡', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['hrm:attendance-record:clock'], |
| | | onClick: handleClock, |
| | | }, |
| | | { |
| | | label: '导å
¥', |
| | | type: 'primary', |
| | | icon: ACTION_ICON.UPLOAD, |
| | | auth: ['hrm:attendance-record:import'], |
| | | onClick: handleImport, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.export'), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.DOWNLOAD, |
| | | auth: ['hrm:attendance-record:export'], |
| | | onClick: handleExport, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { 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'; |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const loading = ref(false); |
| | | |
| | | function buildSuccessMessage( |
| | | action: string, |
| | | result: Awaited<ReturnType<typeof clockIn>>, |
| | | ) { |
| | | if (result.distanceMeters == null || !result.locationName) { |
| | | return `${action}æå`; |
| | | } |
| | | return `${action}æåï¼è·${result.locationName} ${Number(result.distanceMeters).toFixed(1)} ç±³`; |
| | | } |
| | | |
| | | function getCurrentLocation(): Promise<AttendanceClockLocation> { |
| | | if (!navigator.geolocation) { |
| | | return Promise.reject(new Error('å½åæµè§å¨ä¸æ¯æå®ä½ï¼è¯·ä½¿ç¨æ¯æ GPS çæµè§å¨')); |
| | | } |
| | | return new Promise((resolve, reject) => { |
| | | navigator.geolocation.getCurrentPosition( |
| | | (position) => resolve({ |
| | | longitude: position.coords.longitude, |
| | | latitude: position.coords.latitude, |
| | | accuracy: position.coords.accuracy, |
| | | coordinateSystem: 'WGS84', |
| | | }), |
| | | () => reject(new Error('å®ä½å¤±è´¥ï¼è¯·å¼å¯å®ä½æéåéè¯ï¼é localhost éä½¿ç¨ HTTPSï¼')), |
| | | { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 }, |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | async function handleClockIn() { |
| | | loading.value = true; |
| | | try { |
| | | const result = await clockIn(await getCurrentLocation()); |
| | | message.success(buildSuccessMessage('ä¸çæå¡', result)); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || 'æå¡å¤±è´¥'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | async function handleClockOut() { |
| | | loading.value = true; |
| | | try { |
| | | const result = await clockOut(await getCurrentLocation()); |
| | | message.success(buildSuccessMessage('ä¸çæå¡', result)); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || 'æå¡å¤±è´¥'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | onOpenChange() {}, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="æå¡" class="w-[400px]"> |
| | | <div class="p-4"> |
| | | <div class="mb-4 text-sm text-gray-500"> |
| | | ç¹å»æå¡åå°è·åå½å GPS ä½ç½®ï¼å¹¶æèå¤è§åä¸çç»çº¬åº¦åæå¡å徿 ¡éªã |
| | | </div> |
| | | <div class="flex gap-4"> |
| | | <a-button |
| | | type="primary" |
| | | size="large" |
| | | :loading="loading" |
| | | @click="handleClockIn" |
| | | > |
| | | ä¸çæå¡ |
| | | </a-button> |
| | | <a-button |
| | | size="large" |
| | | :loading="loading" |
| | | @click="handleClockOut" |
| | | > |
| | | ä¸çæå¡ |
| | | </a-button> |
| | | </div> |
| | | </div> |
| | | </Modal> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FileType } from 'ant-design-vue/es/upload/interface'; |
| | | |
| | | import { ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '#/packages/effects/common-ui/src'; |
| | | import { downloadFileFromBlobPart } from '#/packages/utils/src'; |
| | | |
| | | import { Button, message, Switch, Upload } from 'ant-design-vue'; |
| | | |
| | | import { importAttendanceRecord, importAttendanceTemplate } from '#/api/hrm/attendance/record'; |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const fileList = ref<FileType[]>([]); |
| | | const updateSupport = ref(false); |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | async onConfirm() { |
| | | if (fileList.value.length === 0) { |
| | | message.warning('è¯·éæ©è¦å¯¼å
¥çæä»¶'); |
| | | return; |
| | | } |
| | | modalApi.lock(); |
| | | try { |
| | | await importAttendanceRecord(fileList.value[0] as File, updateSupport.value); |
| | | await modalApi.close(); |
| | | emit('success'); |
| | | message.success('导å
¥æå'); |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | | }, |
| | | }); |
| | | |
| | | function beforeUpload(file: FileType) { |
| | | fileList.value = [file]; |
| | | return false; |
| | | } |
| | | |
| | | function handleRemove() { |
| | | fileList.value = []; |
| | | } |
| | | |
| | | async function handleDownloadTemplate() { |
| | | const data = await importAttendanceTemplate(); |
| | | downloadFileFromBlobPart({ fileName: 'èå¤å¯¼å
¥æ¨¡æ¿.xls', source: data }); |
| | | } |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="导å
¥èå¤è®°å½" class="w-[500px]"> |
| | | <div class="mx-4"> |
| | | <Upload |
| | | :max-count="1" |
| | | accept=".xls,.xlsx" |
| | | :file-list="fileList" |
| | | :before-upload="beforeUpload" |
| | | @remove="handleRemove" |
| | | > |
| | | <Button type="primary">éæ© Excel æä»¶</Button> |
| | | </Upload> |
| | | <div class="mt-4"> |
| | | <span class="mr-2">æ¯å¦è¦çå·²åå¨çæ°æ®ï¼</span> |
| | | <Switch v-model:checked="updateSupport" /> |
| | | </div> |
| | | <div class="mt-2 text-gray-500"> |
| | | ä»
å
许导å
¥ xlsãxlsx æ ¼å¼æä»¶ |
| | | </div> |
| | | </div> |
| | | <template #prepend-footer> |
| | | <Button @click="handleDownloadTemplate">ä¸è½½å¯¼å
¥æ¨¡æ¿</Button> |
| | | </template> |
| | | </Modal> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | import type { VbenFormSchema } from '#/adapter/form'; |
| | | import type { VxeTableGridOptions } from '#/adapter/vxe-table'; |
| | | import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule'; |
| | | |
| | | import { DICT_TYPE } from '#/packages/constants/src'; |
| | | import { getDictOptions } from '#/packages/effects/hooks/src'; |
| | | |
| | | import { getDeptList } from '#/api/system/dept'; |
| | | import { handleTree } from '#/packages/utils/src'; |
| | | |
| | | /** 表åç±»å */ |
| | | export type FormType = 'create' | 'update' | 'detail'; |
| | | |
| | | export interface LocationFormCallbacks { |
| | | onLatitudeChange?: (value?: number) => void; |
| | | onLocationRequiredChange?: (value: boolean) => void; |
| | | onLongitudeChange?: (value?: number) => void; |
| | | onRadiusChange?: (value?: number) => void; |
| | | } |
| | | |
| | | /** æ°å¢/ç¼è¾èå¤è§åç表å */ |
| | | export function useFormSchema( |
| | | formType: FormType, |
| | | locationCallbacks: LocationFormCallbacks = {}, |
| | | ): VbenFormSchema[] { |
| | | return [ |
| | | { |
| | | fieldName: 'id', |
| | | component: 'Input', |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | show: () => false, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'name', |
| | | label: 'è§ååç§°', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥è§ååç§°', |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'deptId', |
| | | label: 'éç¨é¨é¨', |
| | | component: 'ApiTreeSelect', |
| | | componentProps: { |
| | | api: async () => { |
| | | const data = await getDeptList(); |
| | | return handleTree(data); |
| | | }, |
| | | labelField: 'name', |
| | | valueField: 'id', |
| | | childrenField: 'children', |
| | | allowClear: true, |
| | | placeholder: 'è¯·éæ©é¨é¨ï¼ä¸é表示å
¨å±ï¼', |
| | | treeDefaultExpandAll: true, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'workStartTime', |
| | | label: 'ä¸çæ¶é´', |
| | | component: 'TimePicker', |
| | | componentProps: { |
| | | placeholder: 'éæ©ä¸çæ¶é´', |
| | | valueFormat: 'HH:mm:ss', |
| | | format: 'HH:mm:ss', |
| | | style: { width: '100%' }, |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'workEndTime', |
| | | label: 'ä¸çæ¶é´', |
| | | component: 'TimePicker', |
| | | componentProps: { |
| | | placeholder: 'éæ©ä¸çæ¶é´', |
| | | valueFormat: 'HH:mm:ss', |
| | | format: 'HH:mm:ss', |
| | | style: { width: '100%' }, |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'lateAllowMinutes', |
| | | label: 'è¿å°å
许(åé)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'åé', |
| | | min: 0, |
| | | max: 60, |
| | | style: { width: '100%' }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'earlyAllowMinutes', |
| | | label: 'æ©éå
许(åé)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'åé', |
| | | min: 0, |
| | | max: 60, |
| | | style: { width: '100%' }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'overtimeThreshold', |
| | | label: 'å çèµ·ç®(å°æ¶)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'å°æ¶', |
| | | min: 0, |
| | | precision: 1, |
| | | style: { width: '100%' }, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'locationRequired', |
| | | label: 'å®ä½æå¡', |
| | | component: 'Switch', |
| | | defaultValue: false, |
| | | componentProps: { |
| | | checkedChildren: 'å¯ç¨', |
| | | onChange: locationCallbacks.onLocationRequiredChange, |
| | | unCheckedChildren: 'å
³é', |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'locationName', |
| | | label: 'æå¡å°ç¹', |
| | | component: 'Input', |
| | | componentProps: { placeholder: 'ä¾å¦ï¼æ°é«çµåååº' }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'locationAddress', |
| | | label: 'å°ç¹å°å', |
| | | component: 'Input', |
| | | componentProps: { placeholder: '请è¾å
¥è¯¦ç»å°åï¼ä¾¿äºåå·¥è¯å«' }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'longitude', |
| | | label: 'ç»åº¦(WGS84)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | min: -180, |
| | | max: 180, |
| | | onChange: locationCallbacks.onLongitudeChange, |
| | | precision: 7, |
| | | style: { width: '100%' }, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'latitude', |
| | | label: '纬度(WGS84)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | min: -90, |
| | | max: 90, |
| | | onChange: locationCallbacks.onLatitudeChange, |
| | | precision: 7, |
| | | style: { width: '100%' }, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'radiusMeters', |
| | | label: 'æå¡åå¾(ç±³)', |
| | | component: 'InputNumber', |
| | | defaultValue: 10, |
| | | componentProps: { |
| | | min: 10, |
| | | max: 5000, |
| | | onChange: locationCallbacks.onRadiusChange, |
| | | precision: 0, |
| | | style: { width: '100%' }, |
| | | }, |
| | | help: 'æå°å¯è®¾ 10 ç±³ï¼å®é
精度åå³äºææº GPS ä¸ç¯å¢', |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'coordinateSystem', |
| | | label: 'åæ ç³»', |
| | | component: 'Input', |
| | | defaultValue: 'WGS84', |
| | | componentProps: { disabled: true }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | }, |
| | | { |
| | | 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: 'deptId', |
| | | label: 'éç¨é¨é¨', |
| | | component: 'ApiTreeSelect', |
| | | componentProps: { |
| | | api: async () => { |
| | | const data = await getDeptList(); |
| | | return handleTree(data); |
| | | }, |
| | | labelField: 'name', |
| | | valueField: 'id', |
| | | childrenField: 'children', |
| | | allowClear: true, |
| | | placeholder: 'è¯·éæ©é¨é¨', |
| | | treeDefaultExpandAll: true, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'status', |
| | | label: 'ç¶æ', |
| | | component: 'Select', |
| | | componentProps: { |
| | | allowClear: true, |
| | | options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'), |
| | | placeholder: 'è¯·éæ©ç¶æ', |
| | | }, |
| | | }, |
| | | ]; |
| | | } |
| | | |
| | | /** å表çåæ®µ */ |
| | | export function useGridColumns(): VxeTableGridOptions<HrmAttendanceRuleApi.AttendanceRule>['columns'] { |
| | | return [ |
| | | { field: 'name', title: 'è§ååç§°', minWidth: 150 }, |
| | | { |
| | | field: 'deptName', |
| | | title: 'éç¨é¨é¨', |
| | | minWidth: 120, |
| | | formatter: ({ cellValue }) => cellValue || 'å
¨å±', |
| | | }, |
| | | { field: 'workStartTime', title: 'ä¸çæ¶é´', width: 100 }, |
| | | { field: 'workEndTime', title: 'ä¸çæ¶é´', width: 100 }, |
| | | { field: 'lateAllowMinutes', title: 'è¿å°å
许(åé)', width: 120 }, |
| | | { field: 'earlyAllowMinutes', title: 'æ©éå
许(åé)', width: 120 }, |
| | | { field: 'overtimeThreshold', title: 'å çèµ·ç®(å°æ¶)', width: 120 }, |
| | | { |
| | | field: 'locationName', |
| | | title: 'æå¡èå´', |
| | | minWidth: 180, |
| | | formatter: ({ row }) => row.locationRequired |
| | | ? `${row.locationName || '-'}ï¼${row.radiusMeters || 0}ç±³ï¼` |
| | | : 'ä¸éå¶ä½ç½®', |
| | | }, |
| | | { |
| | | field: 'status', |
| | | title: 'ç¶æ', |
| | | width: 80, |
| | | 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 { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule'; |
| | | |
| | | 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 { deleteAttendanceRule, exportAttendanceRule, getAttendanceRulePage, importAttendanceRule, importAttendanceRuleTemplate } from '#/api/hrm/attendance/rule'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import ImportModalComponent from '#/components/import-modal'; |
| | | |
| | | import { useGridColumns, useGridFormSchema } from './data'; |
| | | import Form from './modules/form.vue'; |
| | | |
| | | const [FormModal, formModalApi] = useVbenModal({ |
| | | connectedComponent: Form, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | const [ImportModal, importModalApi] = useVbenModal({ |
| | | connectedComponent: ImportModalComponent, |
| | | destroyOnClose: true, |
| | | }); |
| | | |
| | | /** å·æ°è¡¨æ ¼ */ |
| | | function handleRefresh() { |
| | | gridApi.query(); |
| | | } |
| | | |
| | | /** æ°å¢èå¤è§å */ |
| | | function handleCreate() { |
| | | formModalApi.setData({ formType: 'create' }).open(); |
| | | } |
| | | |
| | | /** ç¼è¾èå¤è§å */ |
| | | function handleUpdate(row: HrmAttendanceRuleApi.AttendanceRule) { |
| | | formModalApi.setData({ id: row.id, formType: 'update' }).open(); |
| | | } |
| | | |
| | | /** æ¥çèå¤è§å */ |
| | | function handleDetail(row: HrmAttendanceRuleApi.AttendanceRule) { |
| | | formModalApi.setData({ id: row.id, formType: 'detail' }).open(); |
| | | } |
| | | |
| | | /** å é¤èå¤è§å */ |
| | | async function handleDelete(id: number) { |
| | | Modal.confirm({ |
| | | title: '确认å é¤', |
| | | content: 'ç¡®å®è¦å é¤è¯¥èå¤è§ååï¼', |
| | | okText: 'ç¡®å®', |
| | | cancelText: 'åæ¶', |
| | | onOk: async () => { |
| | | await deleteAttendanceRule(id); |
| | | message.success('å 餿å'); |
| | | handleRefresh(); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | /** 导åºèå¤è§å */ |
| | | async function handleExport() { |
| | | const data = await exportAttendanceRule(await gridApi.formApi.getValues()); |
| | | downloadFileFromBlobPart({ fileName: 'èå¤è§å.xls', source: data }); |
| | | } |
| | | |
| | | /** 导å
¥èå¤è§å */ |
| | | function handleImport() { |
| | | importModalApi |
| | | .setData({ |
| | | title: $t('ui.actionTitle.import', ['èå¤è§å']), |
| | | fileName: 'èå¤è§å导å
¥æ¨¡æ¿.xls', |
| | | importFn: importAttendanceRule, |
| | | templateFn: importAttendanceRuleTemplate, |
| | | }) |
| | | .open(); |
| | | } |
| | | |
| | | const [Grid, gridApi] = useVbenVxeGrid({ |
| | | formOptions: { |
| | | schema: useGridFormSchema(), |
| | | }, |
| | | gridOptions: { |
| | | columns: useGridColumns(), |
| | | height: 'auto', |
| | | keepSource: true, |
| | | proxyConfig: { |
| | | ajax: { |
| | | query: async ({ page }, formValues) => |
| | | await getAttendanceRulePage({ |
| | | pageNo: page.currentPage, |
| | | pageSize: page.pageSize, |
| | | ...formValues, |
| | | }), |
| | | }, |
| | | }, |
| | | rowConfig: { |
| | | keyField: 'id', |
| | | isHover: true, |
| | | }, |
| | | toolbarConfig: { |
| | | refresh: true, |
| | | search: true, |
| | | }, |
| | | } as VxeTableGridOptions<HrmAttendanceRuleApi.AttendanceRule>, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Page auto-content-height> |
| | | <FormModal @success="handleRefresh" /> |
| | | <ImportModal @success="handleRefresh" /> |
| | | <Grid table-title="èå¤è§åå表"> |
| | | <template #toolbar-tools> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: $t('ui.actionTitle.create', ['èå¤è§å']), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.ADD, |
| | | auth: ['hrm:attendance-rule:create'], |
| | | onClick: handleCreate, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.export'), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.DOWNLOAD, |
| | | auth: ['hrm:attendance-rule:export'], |
| | | onClick: handleExport, |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.import', ['èå¤è§å']), |
| | | type: 'primary', |
| | | icon: ACTION_ICON.UPLOAD, |
| | | auth: ['hrm:attendance-rule:import'], |
| | | onClick: handleImport, |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | <template #actions="{ row }"> |
| | | <TableAction |
| | | :actions="[ |
| | | { |
| | | label: 'æ¥ç', |
| | | type: 'link', |
| | | onClick: handleDetail.bind(null, row), |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.edit'), |
| | | type: 'link', |
| | | auth: ['hrm:attendance-rule:update'], |
| | | onClick: handleUpdate.bind(null, row), |
| | | }, |
| | | { |
| | | label: $t('ui.actionTitle.delete'), |
| | | type: 'link', |
| | | danger: true, |
| | | auth: ['hrm:attendance-rule:delete'], |
| | | onClick: handleDelete.bind(null, row.id!), |
| | | }, |
| | | ]" |
| | | /> |
| | | </template> |
| | | </Grid> |
| | | </Page> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import type { FormType } from '../data'; |
| | | import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule'; |
| | | |
| | | 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 { createAttendanceRule, getAttendanceRule, updateAttendanceRule } from '#/api/hrm/attendance/rule'; |
| | | import { $t } from '#/locales'; |
| | | |
| | | import { useFormSchema } from '../data'; |
| | | import LocationMapPicker from './location-map-picker.vue'; |
| | | |
| | | const emit = defineEmits(['success']); |
| | | const formType = ref<FormType>('create'); |
| | | const locationRequired = ref(false); |
| | | const longitude = ref<number>(); |
| | | const latitude = ref<number>(); |
| | | const radiusMeters = ref(10); |
| | | |
| | | const isDetail = computed(() => formType.value === 'detail'); |
| | | const getTitle = computed(() => { |
| | | if (formType.value === 'detail') { |
| | | return 'æ¥çèå¤è§å'; |
| | | } |
| | | if (formType.value === 'update') { |
| | | return 'ç¼è¾èå¤è§å'; |
| | | } |
| | | return 'æ°å¢èå¤è§å'; |
| | | }); |
| | | |
| | | function getFormSchema(type: FormType) { |
| | | return useFormSchema(type, { |
| | | onLatitudeChange: (value) => { |
| | | latitude.value = value == null ? undefined : Number(value); |
| | | }, |
| | | onLocationRequiredChange: (value) => { |
| | | locationRequired.value = value; |
| | | }, |
| | | onLongitudeChange: (value) => { |
| | | longitude.value = value == null ? undefined : Number(value); |
| | | }, |
| | | onRadiusChange: (value) => { |
| | | radiusMeters.value = value == null ? 10 : Number(value); |
| | | }, |
| | | }); |
| | | } |
| | | |
| | | async function handleMapSelect(location: { |
| | | address?: string; |
| | | latitude: number; |
| | | locationName?: string; |
| | | longitude: number; |
| | | }) { |
| | | const currentValues = await formApi.getValues(); |
| | | longitude.value = location.longitude; |
| | | latitude.value = location.latitude; |
| | | await formApi.setFieldValue('longitude', location.longitude); |
| | | await formApi.setFieldValue('latitude', location.latitude); |
| | | await formApi.setFieldValue('coordinateSystem', 'WGS84'); |
| | | if (location.address) { |
| | | await formApi.setFieldValue('locationAddress', location.address); |
| | | } |
| | | if (!currentValues.locationName && location.locationName) { |
| | | await formApi.setFieldValue('locationName', location.locationName); |
| | | } |
| | | } |
| | | |
| | | 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 HrmAttendanceRuleApi.AttendanceRule; |
| | | try { |
| | | if (formType.value === 'update') { |
| | | await updateAttendanceRule(data); |
| | | } else { |
| | | await createAttendanceRule(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; |
| | | locationRequired.value = false; |
| | | longitude.value = undefined; |
| | | latitude.value = undefined; |
| | | radiusMeters.value = 10; |
| | | formApi.setState({ schema: getFormSchema(data.formType) }); |
| | | formApi.setDisabled(data.formType === 'detail'); |
| | | modalApi.setState({ showConfirmButton: data.formType !== 'detail' }); |
| | | if (!data?.id) { |
| | | await formApi.setValues({ |
| | | coordinateSystem: 'WGS84', |
| | | locationRequired: false, |
| | | radiusMeters: 10, |
| | | }); |
| | | return; |
| | | } |
| | | modalApi.lock(); |
| | | try { |
| | | const rule = await getAttendanceRule(data.id); |
| | | await formApi.setValues(rule); |
| | | locationRequired.value = Boolean(rule.locationRequired); |
| | | longitude.value = rule.longitude == null ? undefined : Number(rule.longitude); |
| | | latitude.value = rule.latitude == null ? undefined : Number(rule.latitude); |
| | | radiusMeters.value = rule.radiusMeters ?? 10; |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | | }, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal :title="getTitle" class="w-[1000px]"> |
| | | <Form class="mx-4" /> |
| | | <section v-if="locationRequired" class="mx-4 mt-2"> |
| | | <div class="mb-2 text-sm font-medium">å°å¾éæ©æå¡èå´</div> |
| | | <LocationMapPicker |
| | | :disabled="isDetail" |
| | | :latitude="latitude" |
| | | :longitude="longitude" |
| | | :radius-meters="radiusMeters" |
| | | @select="handleMapSelect" |
| | | /> |
| | | </section> |
| | | </Modal> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <script lang="ts" setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { message } from 'ant-design-vue'; |
| | | |
| | | interface LocationSelection { |
| | | address?: string; |
| | | latitude: number; |
| | | locationName?: string; |
| | | longitude: number; |
| | | } |
| | | |
| | | interface Props { |
| | | disabled?: boolean; |
| | | latitude?: number; |
| | | longitude?: number; |
| | | radiusMeters?: number; |
| | | } |
| | | |
| | | const props = withDefaults(defineProps<Props>(), { |
| | | disabled: false, |
| | | latitude: undefined, |
| | | longitude: undefined, |
| | | radiusMeters: 10, |
| | | }); |
| | | |
| | | const emit = defineEmits<{ |
| | | select: [location: LocationSelection]; |
| | | }>(); |
| | | |
| | | const BAIDU_MAP_SCRIPT_ID = 'xgdl-baidu-map-jsapi'; |
| | | const BAIDU_MAP_CALLBACK = '__xgdlBaiduMapReady'; |
| | | const DEFAULT_BAIDU_CENTER: [number, number] = [87.625, 43.835]; |
| | | const PI = Math.PI; |
| | | const X_PI = PI * 3000 / 180; |
| | | const EARTH_SEMI_MAJOR_AXIS = 6_378_245; |
| | | const EARTH_ECCENTRICITY_SQUARED = 0.006_693_421_622_965_943; |
| | | |
| | | const mapContainer = ref<HTMLElement>(); |
| | | const loading = ref(false); |
| | | const searchKeyword = ref(''); |
| | | const selectedAddress = ref(''); |
| | | |
| | | let BMapGL: any; |
| | | let map: any; |
| | | let marker: any; |
| | | let rangeCircle: any; |
| | | let geocoder: any; |
| | | |
| | | const env = (import.meta as any).env ?? {}; |
| | | const baiduMapKey = String(env.VITE_BAIDU_MAP_KEY ?? '').trim(); |
| | | const hasMapConfig = computed(() => Boolean(baiduMapKey)); |
| | | |
| | | function outOfChina(longitude: number, latitude: number) { |
| | | return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271; |
| | | } |
| | | |
| | | function transformLatitude(longitude: number, latitude: number) { |
| | | let result = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude |
| | | + 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude)); |
| | | result += (20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2 / 3; |
| | | result += (20 * Math.sin(latitude * PI) + 40 * Math.sin(latitude / 3 * PI)) * 2 / 3; |
| | | result += (160 * Math.sin(latitude / 12 * PI) + 320 * Math.sin(latitude * PI / 30)) * 2 / 3; |
| | | return result; |
| | | } |
| | | |
| | | function transformLongitude(longitude: number, latitude: number) { |
| | | let result = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude |
| | | + 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude)); |
| | | result += (20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2 / 3; |
| | | result += (20 * Math.sin(longitude * PI) + 40 * Math.sin(longitude / 3 * PI)) * 2 / 3; |
| | | result += (150 * Math.sin(longitude / 12 * PI) + 300 * Math.sin(longitude / 30 * PI)) * 2 / 3; |
| | | return result; |
| | | } |
| | | |
| | | function wgs84ToGcj02(longitude: number, latitude: number): [number, number] { |
| | | if (outOfChina(longitude, latitude)) { |
| | | return [longitude, latitude]; |
| | | } |
| | | let deltaLatitude = transformLatitude(longitude - 105, latitude - 35); |
| | | let deltaLongitude = transformLongitude(longitude - 105, latitude - 35); |
| | | const radianLatitude = latitude / 180 * PI; |
| | | let magic = Math.sin(radianLatitude); |
| | | magic = 1 - EARTH_ECCENTRICITY_SQUARED * magic * magic; |
| | | const sqrtMagic = Math.sqrt(magic); |
| | | deltaLatitude = deltaLatitude * 180 |
| | | / ((EARTH_SEMI_MAJOR_AXIS * (1 - EARTH_ECCENTRICITY_SQUARED)) / (magic * sqrtMagic) * PI); |
| | | deltaLongitude = deltaLongitude * 180 |
| | | / (EARTH_SEMI_MAJOR_AXIS / sqrtMagic * Math.cos(radianLatitude) * PI); |
| | | return [longitude + deltaLongitude, latitude + deltaLatitude]; |
| | | } |
| | | |
| | | function gcj02ToWgs84(longitude: number, latitude: number): [number, number] { |
| | | if (outOfChina(longitude, latitude)) { |
| | | return [longitude, latitude]; |
| | | } |
| | | const [gcjLongitude, gcjLatitude] = wgs84ToGcj02(longitude, latitude); |
| | | return [longitude * 2 - gcjLongitude, latitude * 2 - gcjLatitude]; |
| | | } |
| | | |
| | | function gcj02ToBd09(longitude: number, latitude: number): [number, number] { |
| | | const z = Math.sqrt(longitude * longitude + latitude * latitude) + 0.00002 * Math.sin(latitude * X_PI); |
| | | const theta = Math.atan2(latitude, longitude) + 0.000003 * Math.cos(longitude * X_PI); |
| | | return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006]; |
| | | } |
| | | |
| | | function bd09ToGcj02(longitude: number, latitude: number): [number, number] { |
| | | const x = longitude - 0.0065; |
| | | const y = latitude - 0.006; |
| | | const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI); |
| | | const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI); |
| | | return [z * Math.cos(theta), z * Math.sin(theta)]; |
| | | } |
| | | |
| | | function wgs84ToBd09(longitude: number, latitude: number): [number, number] { |
| | | return gcj02ToBd09(...wgs84ToGcj02(longitude, latitude)); |
| | | } |
| | | |
| | | function bd09ToWgs84(longitude: number, latitude: number): [number, number] { |
| | | return gcj02ToWgs84(...bd09ToGcj02(longitude, latitude)); |
| | | } |
| | | |
| | | function loadBaiduMap() { |
| | | const mapWindow = window as any; |
| | | if (mapWindow.BMapGL) { |
| | | return Promise.resolve(mapWindow.BMapGL); |
| | | } |
| | | if (mapWindow.__xgdlBaiduMapPromise) { |
| | | return mapWindow.__xgdlBaiduMapPromise; |
| | | } |
| | | mapWindow.__xgdlBaiduMapPromise = new Promise((resolve, reject) => { |
| | | const timeout = window.setTimeout(() => reject(new Error('ç¾åº¦å°å¾å è½½è¶
æ¶ï¼è¯·æ£æ¥ AKãååç½åååç½ç»')), 15_000); |
| | | mapWindow[BAIDU_MAP_CALLBACK] = () => { |
| | | window.clearTimeout(timeout); |
| | | resolve(mapWindow.BMapGL); |
| | | delete mapWindow[BAIDU_MAP_CALLBACK]; |
| | | }; |
| | | if (document.querySelector(`#${BAIDU_MAP_SCRIPT_ID}`)) { |
| | | return; |
| | | } |
| | | const script = document.createElement('script'); |
| | | script.id = BAIDU_MAP_SCRIPT_ID; |
| | | script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${encodeURIComponent(baiduMapKey)}&callback=${BAIDU_MAP_CALLBACK}`; |
| | | script.addEventListener('error', () => { |
| | | window.clearTimeout(timeout); |
| | | reject(new Error('ç¾åº¦å°å¾å 载失败ï¼è¯·æ£æ¥ AKãååç½åååç½ç»')); |
| | | }, { once: true }); |
| | | document.head.append(script); |
| | | }); |
| | | return mapWindow.__xgdlBaiduMapPromise; |
| | | } |
| | | |
| | | function updateOverlay(longitude: number, latitude: number, centerMap = false) { |
| | | if (!map || !BMapGL) { |
| | | return; |
| | | } |
| | | const point = new BMapGL.Point(longitude, latitude); |
| | | if (!marker) { |
| | | marker = new BMapGL.Marker(point); |
| | | map.addOverlay(marker); |
| | | } else { |
| | | marker.setPosition(point); |
| | | } |
| | | if (!rangeCircle) { |
| | | rangeCircle = new BMapGL.Circle(point, props.radiusMeters, { |
| | | fillColor: '#1677ff', |
| | | fillOpacity: 0.16, |
| | | strokeColor: '#1677ff', |
| | | strokeOpacity: 0.8, |
| | | strokeWeight: 2, |
| | | }); |
| | | map.addOverlay(rangeCircle); |
| | | } else { |
| | | rangeCircle.setCenter(point); |
| | | rangeCircle.setRadius(props.radiusMeters); |
| | | } |
| | | if (centerMap) { |
| | | map.centerAndZoom(point, 19); |
| | | } |
| | | } |
| | | |
| | | function reverseGeocode(longitude: number, latitude: number) { |
| | | return new Promise<{ address?: string; name?: string }>((resolve) => { |
| | | if (!geocoder) { |
| | | resolve({}); |
| | | return; |
| | | } |
| | | geocoder.getLocation(new BMapGL.Point(longitude, latitude), (result: any) => { |
| | | const components = result?.addressComponents ?? {}; |
| | | const nearestPoi = result?.surroundingPois?.[0]?.title; |
| | | resolve({ |
| | | address: result?.address, |
| | | name: nearestPoi || components.street || components.district || components.city, |
| | | }); |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | async function selectBaiduLocation(longitude: number, latitude: number, centerMap = true) { |
| | | updateOverlay(longitude, latitude, centerMap); |
| | | const [wgsLongitude, wgsLatitude] = bd09ToWgs84(longitude, latitude); |
| | | const location = await reverseGeocode(longitude, latitude); |
| | | selectedAddress.value = location.address ?? ''; |
| | | emit('select', { |
| | | address: location.address, |
| | | latitude: Number(wgsLatitude.toFixed(7)), |
| | | locationName: location.name, |
| | | longitude: Number(wgsLongitude.toFixed(7)), |
| | | }); |
| | | } |
| | | |
| | | async function handleSearch() { |
| | | const keyword = searchKeyword.value.trim(); |
| | | if (!keyword || !geocoder) { |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | const point = await new Promise<any>((resolve, reject) => { |
| | | geocoder.getPoint(keyword, (result: any) => { |
| | | result ? resolve(result) : reject(new Error('没ææç´¢å°è¯¥å°åï¼è¯·è¾å
¥æ´å®æ´ççå¸åºåå°ç¹åç§°')); |
| | | }); |
| | | }); |
| | | await selectBaiduLocation(point.lng, point.lat); |
| | | } catch (error: any) { |
| | | message.warning(error.message || 'å°åæç´¢å¤±è´¥'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | function handleCurrentLocation() { |
| | | if (!navigator.geolocation) { |
| | | message.warning('å½åæµè§å¨ä¸æ¯æå®ä½'); |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | navigator.geolocation.getCurrentPosition( |
| | | async (position) => { |
| | | const [longitude, latitude] = wgs84ToBd09(position.coords.longitude, position.coords.latitude); |
| | | await selectBaiduLocation(longitude, latitude); |
| | | loading.value = false; |
| | | }, |
| | | () => { |
| | | message.warning('å®ä½å¤±è´¥ï¼è¯·å¼å¯æµè§å¨å®ä½æéï¼é localhost ç¯å¢éä½¿ç¨ HTTPSï¼'); |
| | | loading.value = false; |
| | | }, |
| | | { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 }, |
| | | ); |
| | | } |
| | | |
| | | async function initializeMap() { |
| | | if (!hasMapConfig.value || !mapContainer.value) { |
| | | return; |
| | | } |
| | | loading.value = true; |
| | | try { |
| | | BMapGL = await loadBaiduMap(); |
| | | await nextTick(); |
| | | const hasCoordinates = Number.isFinite(props.longitude) && Number.isFinite(props.latitude); |
| | | const center = hasCoordinates |
| | | ? wgs84ToBd09(Number(props.longitude), Number(props.latitude)) |
| | | : DEFAULT_BAIDU_CENTER; |
| | | map = new BMapGL.Map(mapContainer.value); |
| | | map.centerAndZoom(new BMapGL.Point(center[0], center[1]), hasCoordinates ? 19 : 12); |
| | | map.enableScrollWheelZoom(true); |
| | | map.addControl(new BMapGL.ZoomControl()); |
| | | map.addControl(new BMapGL.ScaleControl()); |
| | | geocoder = new BMapGL.Geocoder(); |
| | | map.addEventListener('click', (event: any) => { |
| | | if (!props.disabled) { |
| | | void selectBaiduLocation(event.latlng.lng, event.latlng.lat, false); |
| | | } |
| | | }); |
| | | if (hasCoordinates) { |
| | | updateOverlay(center[0], center[1]); |
| | | const location = await reverseGeocode(center[0], center[1]); |
| | | selectedAddress.value = location.address ?? ''; |
| | | } |
| | | } catch (error: any) { |
| | | message.error(error.message || 'å°å¾åå§å失败'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | } |
| | | |
| | | watch( |
| | | () => [props.longitude, props.latitude] as const, |
| | | ([longitude, latitude]) => { |
| | | if (!map || !Number.isFinite(longitude) || !Number.isFinite(latitude)) { |
| | | return; |
| | | } |
| | | const point = wgs84ToBd09(Number(longitude), Number(latitude)); |
| | | updateOverlay(point[0], point[1]); |
| | | }, |
| | | ); |
| | | |
| | | watch(() => props.radiusMeters, (radius) => rangeCircle?.setRadius(radius)); |
| | | |
| | | onMounted(initializeMap); |
| | | |
| | | onBeforeUnmount(() => { |
| | | map?.clearOverlays(); |
| | | map = undefined; |
| | | marker = undefined; |
| | | rangeCircle = undefined; |
| | | geocoder = undefined; |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <a-alert |
| | | v-if="!hasMapConfig" |
| | | type="warning" |
| | | show-icon |
| | | message="ç¾åº¦å°å¾å°æªé
ç½®" |
| | | description="请é
ç½® VITE_BAIDU_MAP_KEY åéæ°æå»ºå端ã" |
| | | /> |
| | | <a-spin v-else :spinning="loading"> |
| | | <div v-if="!disabled" class="mb-2 flex gap-2"> |
| | | <a-input-search |
| | | v-model:value="searchKeyword" |
| | | enter-button="æç´¢å°å" |
| | | placeholder="è¾å
¥çãå¸ãåºåå
·ä½å°ç¹ï¼ä¾å¦ï¼ä¹é²æ¨é½å¸æ°é«åååº" |
| | | @search="handleSearch" |
| | | /> |
| | | <a-button @click="handleCurrentLocation">å®ä½å°å½åä½ç½®</a-button> |
| | | </div> |
| | | <div ref="mapContainer" class="h-[360px] w-full overflow-hidden rounded border border-gray-200"></div> |
| | | <div class="mt-2 flex flex-wrap justify-between gap-2 text-xs text-gray-500"> |
| | | <span>{{ disabled ? 'æå¡ä½ç½®åå
许èå´' : 'ç¹å»å°å¾éæ©æå¡ä¸å¿ç¹ï¼èè²åå表示å
许æå¡èå´' }}</span> |
| | | <span v-if="selectedAddress">{{ selectedAddress }}</span> |
| | | </div> |
| | | </a-spin> |
| | | </template> |