liu
9 小时以前 611dd8268e40caa4d97e8954b85b5adaaef99afa
feat(hrm): 考勤规则支持地图定位打卡

- 考勤规则新增打卡地点、地址、WGS84 经纬度、半径与坐标系配置
- 新增百度地图选点组件,支持搜索地址、定位当前位置、点击选点
- 打卡时采集 GPS 并提交,提示与打卡地点距离
- 未启用定位的规则保留原文本打卡;接口兼容旧版 location 参数

Co-Authored-By: Claude <noreply@anthropic.com>
(cherry picked from commit d514e9ea7a4804e6dbeae43ba0f1a81324c7a042)
已修改5个文件
已添加1个文件
790 ■■■■■ 文件已修改
src/api/hrm/attendance/record/index.ts 69 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/rule/index.ts 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/clock-panel.vue 114 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/data.ts 108 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/form.vue 79 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 406 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/record/index.ts
@@ -21,6 +21,26 @@
    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;
@@ -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>(
/**
 * ä¸Šç­æ‰“卡
 *
 * åŒæ—¶æäº¤å®šä½ï¼ˆ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,
  );
}
src/api/hrm/attendance/rule/index.ts
@@ -16,6 +16,20 @@
    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;
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">
        æ­£åœ¨èŽ·å–å®šä½â€¦
      </div>
    </div>
  </Modal>
</template>
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,
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>
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';
/** å¯¹å¤–输出: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>