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