From 611dd8268e40caa4d97e8954b85b5adaaef99afa Mon Sep 17 00:00:00 2001
From: liu <2021943741@qq.com>
Date: 星期一, 26 十月 2026 17:45:58 +0800
Subject: [PATCH] feat(hrm): 考勤规则支持地图定位打卡
---
src/api/hrm/attendance/record/index.ts | 71 ++++
src/views/hrm/attendance/rule/modules/form.vue | 81 +++++
src/views/hrm/attendance/rule/modules/location-map-picker.vue | 406 +++++++++++++++++++++++++++++
src/views/hrm/attendance/record/modules/clock-panel.vue | 116 ++++++-
src/views/hrm/attendance/rule/data.ts | 108 +++++++
src/api/hrm/attendance/rule/index.ts | 14 +
6 files changed, 758 insertions(+), 38 deletions(-)
diff --git a/src/api/hrm/attendance/record/index.ts b/src/api/hrm/attendance/record/index.ts
index f5a1144..4ca4bb5 100644
--- a/src/api/hrm/attendance/record/index.ts
+++ b/src/api/hrm/attendance/record/index.ts
@@ -21,6 +21,26 @@
workHours?: number;
overtimeHours?: number;
location?: string;
+ /** 涓婄彮鎵撳崱缁忓害锛圵GS84锛� */
+ clockInLongitude?: number;
+ /** 涓婄彮鎵撳崱绾害锛圵GS84锛� */
+ clockInLatitude?: number;
+ /** 涓婄彮瀹氫綅绮惧害锛岀背 */
+ clockInAccuracy?: number;
+ /** 涓婄彮璺濇墦鍗″湴鐐硅窛绂伙紝绫� */
+ clockInDistance?: number;
+ /** 涓婄彮鎵撳崱鍦扮偣鍦板潃蹇収 */
+ clockInAddress?: string;
+ /** 涓嬬彮鎵撳崱缁忓害锛圵GS84锛� */
+ clockOutLongitude?: number;
+ /** 涓嬬彮鎵撳崱绾害锛圵GS84锛� */
+ clockOutLatitude?: number;
+ /** 涓嬬彮瀹氫綅绮惧害锛岀背 */
+ clockOutAccuracy?: number;
+ /** 涓嬬彮璺濇墦鍗″湴鐐硅窛绂伙紝绫� */
+ clockOutDistance?: number;
+ /** 涓嬬彮鎵撳崱鍦扮偣鍦板潃蹇収 */
+ clockOutAddress?: string;
dataSource?: number;
dataSourceName?: string;
remark?: string;
@@ -40,6 +60,23 @@
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;
}
/** 鏌ヨ鑰冨嫟璁板綍鍒嗛〉鍒楄〃 */
@@ -80,20 +117,32 @@
return requestClient.download('/hrm/attendance/record/export-excel', { params });
}
-/** 涓婄彮鎵撳崱 */
-export function clockIn(params: { location: string }) {
- return requestClient.post<HrmAttendanceRecordApi.AttendanceRecord>(
+/**
+ * 涓婄彮鎵撳崱
+ *
+ * 鍚屾椂鎻愪氦瀹氫綅锛圝SON body锛変笌鏃х増鍦扮偣鏂囨湰锛坬uery 鍙傛暟锛夛細
+ * - 鏂板悗绔寜瀹氫綅鏍¢獙鎵撳崱鑼冨洿锛屾湭鍚敤瀹氫綅鏃跺拷鐣ュ畾浣嶏紱
+ * - 鏃у悗绔彧璇诲彇 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,
);
-}
\ No newline at end of file
+}
diff --git a/src/api/hrm/attendance/rule/index.ts b/src/api/hrm/attendance/rule/index.ts
index 4645d8f..ad16c01 100644
--- a/src/api/hrm/attendance/rule/index.ts
+++ b/src/api/hrm/attendance/rule/index.ts
@@ -16,6 +16,20 @@
overtimeThreshold?: number;
effectiveStartDate?: string;
effectiveEndDate?: string;
+ /** 鎵撳崱鍦扮偣鍚嶇О */
+ locationName?: string;
+ /** 鎵撳崱鍦扮偣鍦板潃 */
+ locationAddress?: string;
+ /** 鎵撳崱涓績缁忓害锛圵GS84锛� */
+ longitude?: number;
+ /** 鎵撳崱涓績绾害锛圵GS84锛� */
+ latitude?: number;
+ /** 鍏佽鎵撳崱鍗婂緞锛屽崟浣嶇背 */
+ radiusMeters?: number;
+ /** 鏄惁鍚敤瀹氫綅鑼冨洿鏍¢獙 */
+ locationRequired?: boolean;
+ /** 鍧愭爣绯伙紝浠呮敮鎸� WGS84 */
+ coordinateSystem?: string;
status?: number;
remark?: string;
createTime?: string;
diff --git a/src/views/hrm/attendance/record/modules/clock-panel.vue b/src/views/hrm/attendance/record/modules/clock-panel.vue
index 7f41eed..2190714 100644
--- a/src/views/hrm/attendance/record/modules/clock-panel.vue
+++ b/src/views/hrm/attendance/record/modules/clock-panel.vue
@@ -1,4 +1,6 @@
<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';
@@ -9,62 +11,124 @@
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"
>
涓婄彮鎵撳崱
@@ -72,11 +136,15 @@
<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">
+ 姝e湪鑾峰彇瀹氫綅鈥�
+ </div>
</div>
</Modal>
-</template>
\ No newline at end of file
+</template>
diff --git a/src/views/hrm/attendance/rule/data.ts b/src/views/hrm/attendance/rule/data.ts
index 718e3b8..6362242 100644
--- a/src/views/hrm/attendance/rule/data.ts
+++ b/src/views/hrm/attendance/rule/data.ts
@@ -128,6 +128,105 @@
},
},
{
+ 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',
@@ -215,6 +314,15 @@
`${row.effectiveStartDate || '涓嶉檺'} ~ ${row.effectiveEndDate || '涓嶉檺'}`,
},
{
+ field: 'locationName',
+ title: '鎵撳崱鑼冨洿',
+ minWidth: 180,
+ formatter: ({ row }) =>
+ row.locationRequired
+ ? `${row.locationName || '-'}锛�${row.radiusMeters ?? 0}绫筹級`
+ : '涓嶉檺鍒朵綅缃�',
+ },
+ {
field: 'status',
title: '鐘舵��',
width: 80,
diff --git a/src/views/hrm/attendance/rule/modules/form.vue b/src/views/hrm/attendance/rule/modules/form.vue
index 60bff96..a0a6eb8 100644
--- a/src/views/hrm/attendance/rule/modules/form.vue
+++ b/src/views/hrm/attendance/rule/modules/form.vue
@@ -13,11 +13,21 @@
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 '鏌ョ湅鑰冨嫟瑙勫垯';
@@ -27,6 +37,43 @@
}
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: {
@@ -40,6 +87,9 @@
layout: 'horizontal',
schema: [],
showDefaultActions: false,
+ handleValuesChange: (values) => {
+ syncLocationState(values);
+ },
});
const [Modal, modalApi] = useVbenModal({
@@ -54,6 +104,10 @@
}
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);
@@ -73,15 +127,26 @@
}
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();
}
@@ -90,7 +155,17 @@
</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>
\ No newline at end of file
+</template>
diff --git a/src/views/hrm/attendance/rule/modules/location-map-picker.vue b/src/views/hrm/attendance/rule/modules/location-map-picker.vue
new file mode 100644
index 0000000..d314e88
--- /dev/null
+++ b/src/views/hrm/attendance/rule/modules/location-map-picker.vue
@@ -0,0 +1,406 @@
+<!-- 鑰冨嫟鍦扮偣鍦板浘閫夋嫨鍣細鐧惧害鍦板浘灞曠ず锛屽澶栫粺涓�杈撳嚭 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';
+
+/** 瀵瑰杈撳嚭锛歐GS84 鍧愭爣 + 鍙嶆煡鍒扮殑鍦板潃/鍦扮偣鍚� */
+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锛岃凯浠i�艰繎锛岃宸彲鎺у埗鍦� 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,
+ });
+ },
+ );
+ });
+}
+
+/** 鍦板浘鍧愭爣锛圔D09锛夎惤鐐瑰悗锛屽洖浼� 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>
--
Gitblit v1.9.3