feat(hrm): 考勤规则支持地图定位打卡
- 考勤规则新增打卡地点、地址、WGS84 经纬度、半径与坐标系配置
- 新增百度地图选点组件,支持搜索地址、定位当前位置、点击选点
- 打卡时采集 GPS 并提交,提示与打卡地点距离
- 未启用定位的规则保留原文本打卡;接口兼容旧版 location 参数
Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit d514e9ea7a4804e6dbeae43ba0f1a81324c7a042)
| | |
| | | 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; |
| | | remark?: string; |
| | |
| | | overtimeHours?: number; |
| | | totalWorkHours?: number; |
| | | } |
| | | } |
| | | |
| | | /** å®ä½æå¡åæ ï¼å端ä»
æ¥å 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; |
| | | } |
| | | |
| | | /** æ¥è¯¢èå¤è®°å½å页å表 */ |
| | |
| | | return requestClient.download('/hrm/attendance/record/export-excel', { params }); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | export function clockIn(params: { location: string }) { |
| | | 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', |
| | | null, |
| | | { params }, |
| | | data ?? null, |
| | | location ? { params: { location } } : undefined, |
| | | ); |
| | | } |
| | | |
| | | /** ä¸çæå¡ */ |
| | | export function clockOut(params: { location: string }) { |
| | | return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>( |
| | | /** ä¸çæå¡ï¼åæ°è¯´æå clockIn */ |
| | | export function clockOut( |
| | | data?: AttendanceClockLocation | null, |
| | | location?: string, |
| | | ) { |
| | | return requestClient.post<AttendanceClockResponse>( |
| | | '/hrm/attendance/record/clock-out', |
| | | null, |
| | | { params }, |
| | | data ?? null, |
| | | location ? { params: { location } } : undefined, |
| | | ); |
| | | } |
| | | } |
| | |
| | | overtimeThreshold?: number; |
| | | effectiveStartDate?: string; |
| | | effectiveEndDate?: string; |
| | | /** æå¡å°ç¹åç§° */ |
| | | locationName?: string; |
| | | /** æå¡å°ç¹å°å */ |
| | | locationAddress?: string; |
| | | /** æå¡ä¸å¿ç»åº¦ï¼WGS84ï¼ */ |
| | | longitude?: number; |
| | | /** æå¡ä¸å¿çº¬åº¦ï¼WGS84ï¼ */ |
| | | latitude?: number; |
| | | /** å
许æå¡åå¾ï¼åä½ç±³ */ |
| | | radiusMeters?: number; |
| | | /** æ¯å¦å¯ç¨å®ä½èå´æ ¡éª */ |
| | | locationRequired?: boolean; |
| | | /** åæ ç³»ï¼ä»
æ¯æ WGS84 */ |
| | | coordinateSystem?: string; |
| | | status?: number; |
| | | remark?: string; |
| | | createTime?: string; |
| | |
| | | <script lang="ts" setup> |
| | | import type { AttendanceClockLocation, AttendanceClockResponse } from '#/api/hrm/attendance/record'; |
| | | |
| | | import { ref } from 'vue'; |
| | | |
| | | import { useVbenModal } from '#/packages/effects/common-ui/src'; |
| | |
| | | |
| | | const emit = defineEmits(['success']); |
| | | |
| | | const location = ref('å
¬å¸åå°'); |
| | | const loading = ref(false); |
| | | const DEFAULT_LOCATION = 'å
¬å¸åå°'; |
| | | |
| | | /** ä¸çæå¡ */ |
| | | async function handleClockIn() { |
| | | const location = ref(DEFAULT_LOCATION); |
| | | const loading = ref(false); |
| | | const locating = ref(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: 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 }, |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | /** æªå¯ç¨å®ä½èå´æ ¡éªæ¶å端ä¸è¿åè·ç¦»ï¼ç´æ¥æç¤ºæå */ |
| | | 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({ location: location.value }); |
| | | 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); |
| | | message.success( |
| | | buildSuccessMessage(action === 'in' ? 'ä¸çæå¡' : 'ä¸çæå¡', result), |
| | | ); |
| | | 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({ location: location.value }); |
| | | message.success('ä¸çæå¡æå'); |
| | | emit('success'); |
| | | } catch (e: any) { |
| | | message.error(e.message || 'æå¡å¤±è´¥'); |
| | | } finally { |
| | | loading.value = false; |
| | | } |
| | | function handleClockOut() { |
| | | return submitClock('out'); |
| | | } |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | | const [Modal] = useVbenModal({ |
| | | onOpenChange(isOpen: boolean) { |
| | | if (!isOpen) { |
| | | location.value = 'å
¬å¸åå°'; |
| | | location.value = DEFAULT_LOCATION; |
| | | } |
| | | }, |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal title="æå¡" class="w-[400px]"> |
| | | <Modal title="æå¡" class="w-[420px]"> |
| | | <div class="p-4"> |
| | | <div class="mb-4"> |
| | | <label class="mr-2">æå¡å°ç¹ï¼</label> |
| | | <a-input |
| | | v-model:value="location" |
| | | placeholder="请è¾å
¥æå¡å°ç¹" |
| | | style="width: 200px" |
| | | 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> |
| | | </template> |
| | |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'locationRequired', |
| | | label: 'å®ä½æå¡', |
| | | component: 'Switch', |
| | | defaultValue: false, |
| | | componentProps: { |
| | | checkedChildren: 'å¯ç¨', |
| | | unCheckedChildren: 'å
³é', |
| | | }, |
| | | help: 'å¯ç¨åï¼åå·¥æå¡å¿
é¡»å¨ä¸æ¹è®¾å®å°ç¹çå
许åå¾å
', |
| | | }, |
| | | { |
| | | fieldName: 'locationName', |
| | | label: 'æå¡å°ç¹', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: 'ä¾å¦ï¼æ°é«çµåååº', |
| | | maxlength: 100, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'locationAddress', |
| | | label: 'å°ç¹å°å', |
| | | component: 'Input', |
| | | componentProps: { |
| | | placeholder: '请è¾å
¥è¯¦ç»å°åï¼ä¾¿äºåå·¥è¯å«', |
| | | maxlength: 255, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'longitude', |
| | | label: 'ç»åº¦(WGS84)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'å°å¾éç¹åèªå¨å¡«å
', |
| | | min: -180, |
| | | max: 180, |
| | | precision: 7, |
| | | style: { width: '100%' }, |
| | | }, |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'latitude', |
| | | label: '纬度(WGS84)', |
| | | component: 'InputNumber', |
| | | componentProps: { |
| | | placeholder: 'å°å¾éç¹åèªå¨å¡«å
', |
| | | min: -90, |
| | | max: 90, |
| | | 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, |
| | | precision: 0, |
| | | style: { width: '100%' }, |
| | | }, |
| | | help: 'å
许èå´ 10~5000 ç±³ï¼å®é
精度åå³äºææº GPS ä¸ç¯å¢', |
| | | dependencies: { |
| | | triggerFields: ['locationRequired'], |
| | | show: (values) => Boolean(values.locationRequired), |
| | | }, |
| | | rules: 'required', |
| | | }, |
| | | { |
| | | fieldName: 'coordinateSystem', |
| | | component: 'Input', |
| | | defaultValue: 'WGS84', |
| | | dependencies: { |
| | | triggerFields: [''], |
| | | show: () => false, |
| | | }, |
| | | }, |
| | | { |
| | | fieldName: 'status', |
| | | label: 'ç¶æ', |
| | | component: 'RadioGroup', |
| | |
| | | `${row.effectiveStartDate || 'ä¸é'} ~ ${row.effectiveEndDate || 'ä¸é'}`, |
| | | }, |
| | | { |
| | | field: 'locationName', |
| | | title: 'æå¡èå´', |
| | | minWidth: 180, |
| | | formatter: ({ row }) => |
| | | row.locationRequired |
| | | ? `${row.locationName || '-'}ï¼${row.radiusMeters ?? 0}ç±³ï¼` |
| | | : 'ä¸éå¶ä½ç½®', |
| | | }, |
| | | { |
| | | field: 'status', |
| | | title: 'ç¶æ', |
| | | width: 80, |
| | |
| | | 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 modalWidth = computed(() => |
| | | locationRequired.value ? 'w-[1000px]' : 'w-[800px]', |
| | | ); |
| | | const getTitle = computed(() => { |
| | | if (formType.value === 'detail') { |
| | | return 'æ¥çèå¤è§å'; |
| | |
| | | } |
| | | return 'æ°å¢èå¤è§å'; |
| | | }); |
| | | |
| | | function toNumber(value: unknown): number | undefined { |
| | | return value === null || value === undefined || value === '' |
| | | ? undefined |
| | | : Number(value); |
| | | } |
| | | |
| | | /** 表åå¼å忶忥å®ä½ç¶æï¼ç¨äºæ§å¶å°å¾åºåä¸å¼¹çªå®½åº¦ */ |
| | | function syncLocationState( |
| | | values: Partial<HrmAttendanceRuleApi.AttendanceRule>, |
| | | ) { |
| | | locationRequired.value = Boolean(values.locationRequired); |
| | | longitude.value = toNumber(values.longitude); |
| | | latitude.value = toNumber(values.latitude); |
| | | radiusMeters.value = toNumber(values.radiusMeters) ?? 10; |
| | | } |
| | | |
| | | /** å°å¾éç¹ååå¡«ç»çº¬åº¦ï¼å¹¶å°½éè¡¥å
¨å°ç¹åç§°ä¸å°å */ |
| | | 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: { |
| | |
| | | layout: 'horizontal', |
| | | schema: [], |
| | | showDefaultActions: false, |
| | | handleValuesChange: (values) => { |
| | | syncLocationState(values); |
| | | }, |
| | | }); |
| | | |
| | | const [Modal, modalApi] = useVbenModal({ |
| | |
| | | } |
| | | modalApi.lock(); |
| | | const data = (await formApi.getValues()) as HrmAttendanceRuleApi.AttendanceRule; |
| | | // å端ä»
æ¥å WGS84ï¼ä¸å¯ç¨å®ä½æ¶è¦æ±åæ ç³»å¿
å¡« |
| | | if (data.locationRequired) { |
| | | data.coordinateSystem = 'WGS84'; |
| | | } |
| | | try { |
| | | if (formType.value === 'update') { |
| | | await updateAttendanceRule(data); |
| | |
| | | } |
| | | 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: useFormSchema(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 { |
| | | await formApi.setValues(await getAttendanceRule(data.id)); |
| | | const rule = await getAttendanceRule(data.id); |
| | | await formApi.setValues(rule); |
| | | syncLocationState(rule); |
| | | } finally { |
| | | modalApi.unlock(); |
| | | } |
| | |
| | | </script> |
| | | |
| | | <template> |
| | | <Modal :title="getTitle" class="w-[800px]"> |
| | | <Modal :title="getTitle" :class="modalWidth"> |
| | | <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> |
| | | </template> |
| ¶Ô±ÈÐÂÎļþ |
| | |
| | | <!-- èå¤å°ç¹å°å¾éæ©å¨ï¼ç¾åº¦å°å¾å±ç¤ºï¼å¯¹å¤ç»ä¸è¾åº WGS84 åæ --> |
| | | <script lang="ts" setup> |
| | | import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'; |
| | | |
| | | import { Alert, Button, Input, Spin, message } from 'ant-design-vue'; |
| | | |
| | | import { loadBaiduMapSdk } from '#/components/map'; |
| | | |
| | | /** 对å¤è¾åºï¼WGS84 åæ + 忥å°çå°å/å°ç¹å */ |
| | | 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 DEFAULT_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; |
| | | |
| | | /* eslint-disable @typescript-eslint/no-explicit-any */ |
| | | type AnyMap = any; |
| | | |
| | | const mapContainer = ref<HTMLElement>(); |
| | | const loading = ref(false); |
| | | const searchKeyword = ref(''); |
| | | const selectedAddress = ref(''); |
| | | |
| | | let map: AnyMap; |
| | | let marker: AnyMap; |
| | | let rangeCircle: AnyMap; |
| | | let geocoder: AnyMap; |
| | | |
| | | const baiduMapKey = String(import.meta.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; |
| | | } |
| | | |
| | | /** WGS84 â GCJ02 */ |
| | | 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]; |
| | | } |
| | | |
| | | /** GCJ02 â WGS84ï¼è¿ä»£é¼è¿ï¼è¯¯å·®å¯æ§å¶å¨ 0.5 ç±³å
*/ |
| | | function gcj02ToWgs84(longitude: number, latitude: number): [number, number] { |
| | | if (outOfChina(longitude, latitude)) { |
| | | return [longitude, latitude]; |
| | | } |
| | | let wgsLongitude = longitude; |
| | | let wgsLatitude = latitude; |
| | | for (let index = 0; index < 5; index++) { |
| | | const [gcjLongitude, gcjLatitude] = wgs84ToGcj02(wgsLongitude, wgsLatitude); |
| | | wgsLongitude += longitude - gcjLongitude; |
| | | wgsLatitude += latitude - gcjLatitude; |
| | | } |
| | | return [wgsLongitude, wgsLatitude]; |
| | | } |
| | | |
| | | /** GCJ02 â BD09 */ |
| | | function gcj02ToBd09(longitude: number, latitude: number): [number, number] { |
| | | const z = |
| | | Math.sqrt(longitude * longitude + latitude * latitude) + |
| | | 0.000_02 * Math.sin(latitude * X_PI); |
| | | const theta = |
| | | Math.atan2(latitude, longitude) + 0.000_003 * Math.cos(longitude * X_PI); |
| | | return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006]; |
| | | } |
| | | |
| | | /** BD09 â GCJ02 */ |
| | | 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.000_02 * Math.sin(y * X_PI); |
| | | const theta = Math.atan2(y, x) - 0.000_003 * 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 updateOverlay( |
| | | longitude: number, |
| | | latitude: number, |
| | | centerMap = false, |
| | | ) { |
| | | if (!map || !window.BMapGL) { |
| | | return; |
| | | } |
| | | const point = new window.BMapGL.Point(longitude, latitude); |
| | | if (!marker) { |
| | | marker = new window.BMapGL.Marker(point); |
| | | map.addOverlay(marker); |
| | | } else { |
| | | marker.setPosition(point); |
| | | } |
| | | if (!rangeCircle) { |
| | | rangeCircle = new window.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 window.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, |
| | | }); |
| | | }, |
| | | ); |
| | | }); |
| | | } |
| | | |
| | | /** å°å¾åæ ï¼BD09ï¼è½ç¹åï¼åä¼ WGS84 ç»è¡¨å */ |
| | | 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 { |
| | | await loadBaiduMapSdk(15_000); |
| | | await nextTick(); |
| | | const hasCoordinates = |
| | | Number.isFinite(props.longitude) && Number.isFinite(props.latitude); |
| | | const center = hasCoordinates |
| | | ? wgs84ToBd09(Number(props.longitude), Number(props.latitude)) |
| | | : DEFAULT_CENTER; |
| | | map = new window.BMapGL.Map(mapContainer.value); |
| | | map.centerAndZoom( |
| | | new window.BMapGL.Point(center[0], center[1]), |
| | | hasCoordinates ? 19 : 12, |
| | | ); |
| | | map.enableScrollWheelZoom(true); |
| | | map.addControl(new window.BMapGL.ZoomControl()); |
| | | map.addControl(new window.BMapGL.ScaleControl()); |
| | | geocoder = new window.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) => { |
| | | if (Number.isFinite(radius)) { |
| | | rangeCircle?.setRadius(Number(radius)); |
| | | } |
| | | }, |
| | | ); |
| | | |
| | | onMounted(initializeMap); |
| | | |
| | | onBeforeUnmount(() => { |
| | | map?.clearOverlays?.(); |
| | | map?.destroy?.(); |
| | | map = undefined; |
| | | marker = undefined; |
| | | rangeCircle = undefined; |
| | | geocoder = undefined; |
| | | }); |
| | | </script> |
| | | |
| | | <template> |
| | | <Alert |
| | | v-if="!hasMapConfig" |
| | | type="warning" |
| | | show-icon |
| | | message="ç¾åº¦å°å¾å°æªé
ç½®" |
| | | description="请é
ç½® VITE_BAIDU_MAP_KEY åéæ°æå»ºåç«¯ï¼æªé
ç½®æ¶å¯æå·¥å¡«åç»çº¬åº¦ã" |
| | | /> |
| | | <Spin v-else :spinning="loading"> |
| | | <div v-if="!disabled" class="mb-2 flex gap-2"> |
| | | <Input |
| | | v-model:value="searchKeyword" |
| | | allow-clear |
| | | placeholder="è¾å
¥çãå¸ãåºåå
·ä½å°ç¹ï¼ä¾å¦ï¼ä¹é²æ¨é½å¸ååº" |
| | | @press-enter="handleSearch" |
| | | /> |
| | | <Button :loading="loading" @click="handleSearch">æç´¢å°å</Button> |
| | | <Button @click="handleCurrentLocation">å®ä½å°å½åä½ç½®</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> |
| | | </Spin> |
| | | </template> |