liu
2026-10-26 910b2ead53df78a62a06d1bdfc3e05c8c5b5fc65
feat(hrm): 考勤规则支持百度地图选点
已添加1个文件
已修改2个文件
448 ■■■■■ 文件已修改
src/views/hrm/attendance/rule/data.ts 39 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/form.vue 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/data.ts
@@ -11,8 +11,18 @@
/** è¡¨å•类型 */
export type FormType = 'create' | 'update' | 'detail';
export interface LocationFormCallbacks {
  onLatitudeChange?: (value?: number) => void;
  onLocationRequiredChange?: (value: boolean) => void;
  onLongitudeChange?: (value?: number) => void;
  onRadiusChange?: (value?: number) => void;
}
/** æ–°å¢ž/编辑考勤规则的表单 */
export function useFormSchema(formType: FormType): VbenFormSchema[] {
export function useFormSchema(
  formType: FormType,
  locationCallbacks: LocationFormCallbacks = {},
): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
@@ -112,6 +122,7 @@
      defaultValue: false,
      componentProps: {
        checkedChildren: '启用',
        onChange: locationCallbacks.onLocationRequiredChange,
        unCheckedChildren: '关闭',
      },
    },
@@ -140,7 +151,13 @@
      fieldName: 'longitude',
      label: '经度(WGS84)',
      component: 'InputNumber',
      componentProps: { min: -180, max: 180, precision: 7, style: { width: '100%' } },
      componentProps: {
        min: -180,
        max: 180,
        onChange: locationCallbacks.onLongitudeChange,
        precision: 7,
        style: { width: '100%' },
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
@@ -151,7 +168,13 @@
      fieldName: 'latitude',
      label: '纬度(WGS84)',
      component: 'InputNumber',
      componentProps: { min: -90, max: 90, precision: 7, style: { width: '100%' } },
      componentProps: {
        min: -90,
        max: 90,
        onChange: locationCallbacks.onLatitudeChange,
        precision: 7,
        style: { width: '100%' },
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
@@ -162,8 +185,14 @@
      fieldName: 'radiusMeters',
      label: '打卡半径(ç±³)',
      component: 'InputNumber',
      defaultValue: 100,
      componentProps: { min: 10, max: 5000, precision: 0, style: { width: '100%' } },
      defaultValue: 10,
      componentProps: {
        min: 10,
        max: 5000,
        onChange: locationCallbacks.onRadiusChange,
        precision: 0,
        style: { width: '100%' },
      },
      help: '最小可设 10 ç±³ï¼›å®žé™…精度取决于手机 GPS ä¸ŽçŽ¯å¢ƒ',
      dependencies: {
        triggerFields: ['locationRequired'],
src/views/hrm/attendance/rule/modules/form.vue
@@ -13,9 +13,14 @@
import { $t } from '#/locales';
import { useFormSchema } from '../data';
import LocationMapPicker from './location-map-picker.vue';
const emit = defineEmits(['success']);
const formType = ref<FormType>('create');
const locationRequired = ref(false);
const longitude = ref<number>();
const latitude = ref<number>();
const radiusMeters = ref(10);
const isDetail = computed(() => formType.value === 'detail');
const getTitle = computed(() => {
@@ -27,6 +32,43 @@
  }
  return '新增考勤规则';
});
function getFormSchema(type: FormType) {
  return useFormSchema(type, {
    onLatitudeChange: (value) => {
      latitude.value = value == null ? undefined : Number(value);
    },
    onLocationRequiredChange: (value) => {
      locationRequired.value = value;
    },
    onLongitudeChange: (value) => {
      longitude.value = value == null ? undefined : Number(value);
    },
    onRadiusChange: (value) => {
      radiusMeters.value = value == null ? 10 : Number(value);
    },
  });
}
async function handleMapSelect(location: {
  address?: string;
  latitude: number;
  locationName?: string;
  longitude: number;
}) {
  const currentValues = await formApi.getValues();
  longitude.value = location.longitude;
  latitude.value = location.latitude;
  await formApi.setFieldValue('longitude', location.longitude);
  await formApi.setFieldValue('latitude', location.latitude);
  await formApi.setFieldValue('coordinateSystem', 'WGS84');
  if (location.address) {
    await formApi.setFieldValue('locationAddress', location.address);
  }
  if (!currentValues.locationName && location.locationName) {
    await formApi.setFieldValue('locationName', location.locationName);
  }
}
const [Form, formApi] = useVbenForm({
  commonConfig: {
@@ -73,15 +115,29 @@
    }
    const data = modalApi.getData<{ formType: FormType; id?: number }>();
    formType.value = data.formType;
    formApi.setState({ schema: useFormSchema(data.formType) });
    locationRequired.value = false;
    longitude.value = undefined;
    latitude.value = undefined;
    radiusMeters.value = 10;
    formApi.setState({ schema: getFormSchema(data.formType) });
    formApi.setDisabled(data.formType === 'detail');
    modalApi.setState({ showConfirmButton: data.formType !== 'detail' });
    if (!data?.id) {
      await formApi.setValues({
        coordinateSystem: 'WGS84',
        locationRequired: false,
        radiusMeters: 10,
      });
      return;
    }
    modalApi.lock();
    try {
      await formApi.setValues(await getAttendanceRule(data.id));
      const rule = await getAttendanceRule(data.id);
      await formApi.setValues(rule);
      locationRequired.value = Boolean(rule.locationRequired);
      longitude.value = rule.longitude == null ? undefined : Number(rule.longitude);
      latitude.value = rule.latitude == null ? undefined : Number(rule.latitude);
      radiusMeters.value = rule.radiusMeters ?? 10;
    } finally {
      modalApi.unlock();
    }
@@ -90,7 +146,17 @@
</script>
<template>
  <Modal :title="getTitle" class="w-[800px]">
  <Modal :title="getTitle" class="w-[1000px]">
    <Form class="mx-4" />
    <section v-if="locationRequired" class="mx-4 mt-2">
      <div class="mb-2 text-sm font-medium">地图选择打卡范围</div>
      <LocationMapPicker
        :disabled="isDetail"
        :latitude="latitude"
        :longitude="longitude"
        :radius-meters="radiusMeters"
        @select="handleMapSelect"
      />
    </section>
  </Modal>
</template>
</template>
src/views/hrm/attendance/rule/modules/location-map-picker.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,335 @@
<script lang="ts" setup>
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue';
import { message } from 'ant-design-vue';
interface LocationSelection {
  address?: string;
  latitude: number;
  locationName?: string;
  longitude: number;
}
interface Props {
  disabled?: boolean;
  latitude?: number;
  longitude?: number;
  radiusMeters?: number;
}
const props = withDefaults(defineProps<Props>(), {
  disabled: false,
  latitude: undefined,
  longitude: undefined,
  radiusMeters: 10,
});
const emit = defineEmits<{
  select: [location: LocationSelection];
}>();
const BAIDU_MAP_SCRIPT_ID = 'xgdl-baidu-map-jsapi';
const BAIDU_MAP_CALLBACK = '__xgdlBaiduMapReady';
const DEFAULT_BAIDU_CENTER: [number, number] = [87.625, 43.835];
const PI = Math.PI;
const X_PI = PI * 3000 / 180;
const EARTH_SEMI_MAJOR_AXIS = 6_378_245;
const EARTH_ECCENTRICITY_SQUARED = 0.006_693_421_622_965_943;
const mapContainer = ref<HTMLElement>();
const loading = ref(false);
const searchKeyword = ref('');
const selectedAddress = ref('');
let BMapGL: any;
let map: any;
let marker: any;
let rangeCircle: any;
let geocoder: any;
const env = (import.meta as any).env ?? {};
const baiduMapKey = String(env.VITE_BAIDU_MAP_KEY ?? '').trim();
const hasMapConfig = computed(() => Boolean(baiduMapKey));
function outOfChina(longitude: number, latitude: number) {
  return longitude < 72.004 || longitude > 137.8347 || latitude < 0.8293 || latitude > 55.8271;
}
function transformLatitude(longitude: number, latitude: number) {
  let result = -100 + 2 * longitude + 3 * latitude + 0.2 * latitude * latitude
    + 0.1 * longitude * latitude + 0.2 * Math.sqrt(Math.abs(longitude));
  result += (20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2 / 3;
  result += (20 * Math.sin(latitude * PI) + 40 * Math.sin(latitude / 3 * PI)) * 2 / 3;
  result += (160 * Math.sin(latitude / 12 * PI) + 320 * Math.sin(latitude * PI / 30)) * 2 / 3;
  return result;
}
function transformLongitude(longitude: number, latitude: number) {
  let result = 300 + longitude + 2 * latitude + 0.1 * longitude * longitude
    + 0.1 * longitude * latitude + 0.1 * Math.sqrt(Math.abs(longitude));
  result += (20 * Math.sin(6 * longitude * PI) + 20 * Math.sin(2 * longitude * PI)) * 2 / 3;
  result += (20 * Math.sin(longitude * PI) + 40 * Math.sin(longitude / 3 * PI)) * 2 / 3;
  result += (150 * Math.sin(longitude / 12 * PI) + 300 * Math.sin(longitude / 30 * PI)) * 2 / 3;
  return result;
}
function wgs84ToGcj02(longitude: number, latitude: number): [number, number] {
  if (outOfChina(longitude, latitude)) {
    return [longitude, latitude];
  }
  let deltaLatitude = transformLatitude(longitude - 105, latitude - 35);
  let deltaLongitude = transformLongitude(longitude - 105, latitude - 35);
  const radianLatitude = latitude / 180 * PI;
  let magic = Math.sin(radianLatitude);
  magic = 1 - EARTH_ECCENTRICITY_SQUARED * magic * magic;
  const sqrtMagic = Math.sqrt(magic);
  deltaLatitude = deltaLatitude * 180
    / ((EARTH_SEMI_MAJOR_AXIS * (1 - EARTH_ECCENTRICITY_SQUARED)) / (magic * sqrtMagic) * PI);
  deltaLongitude = deltaLongitude * 180
    / (EARTH_SEMI_MAJOR_AXIS / sqrtMagic * Math.cos(radianLatitude) * PI);
  return [longitude + deltaLongitude, latitude + deltaLatitude];
}
function gcj02ToWgs84(longitude: number, latitude: number): [number, number] {
  if (outOfChina(longitude, latitude)) {
    return [longitude, latitude];
  }
  const [gcjLongitude, gcjLatitude] = wgs84ToGcj02(longitude, latitude);
  return [longitude * 2 - gcjLongitude, latitude * 2 - gcjLatitude];
}
function gcj02ToBd09(longitude: number, latitude: number): [number, number] {
  const z = Math.sqrt(longitude * longitude + latitude * latitude) + 0.00002 * Math.sin(latitude * X_PI);
  const theta = Math.atan2(latitude, longitude) + 0.000003 * Math.cos(longitude * X_PI);
  return [z * Math.cos(theta) + 0.0065, z * Math.sin(theta) + 0.006];
}
function bd09ToGcj02(longitude: number, latitude: number): [number, number] {
  const x = longitude - 0.0065;
  const y = latitude - 0.006;
  const z = Math.sqrt(x * x + y * y) - 0.00002 * Math.sin(y * X_PI);
  const theta = Math.atan2(y, x) - 0.000003 * Math.cos(x * X_PI);
  return [z * Math.cos(theta), z * Math.sin(theta)];
}
function wgs84ToBd09(longitude: number, latitude: number): [number, number] {
  return gcj02ToBd09(...wgs84ToGcj02(longitude, latitude));
}
function bd09ToWgs84(longitude: number, latitude: number): [number, number] {
  return gcj02ToWgs84(...bd09ToGcj02(longitude, latitude));
}
function loadBaiduMap() {
  const mapWindow = window as any;
  if (mapWindow.BMapGL) {
    return Promise.resolve(mapWindow.BMapGL);
  }
  if (mapWindow.__xgdlBaiduMapPromise) {
    return mapWindow.__xgdlBaiduMapPromise;
  }
  mapWindow.__xgdlBaiduMapPromise = new Promise((resolve, reject) => {
    const timeout = window.setTimeout(() => reject(new Error('百度地图加载超时,请检查 AK、域名白名单和网络')), 15_000);
    mapWindow[BAIDU_MAP_CALLBACK] = () => {
      window.clearTimeout(timeout);
      resolve(mapWindow.BMapGL);
      delete mapWindow[BAIDU_MAP_CALLBACK];
    };
    if (document.querySelector(`#${BAIDU_MAP_SCRIPT_ID}`)) {
      return;
    }
    const script = document.createElement('script');
    script.id = BAIDU_MAP_SCRIPT_ID;
    script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${encodeURIComponent(baiduMapKey)}&callback=${BAIDU_MAP_CALLBACK}`;
    script.addEventListener('error', () => {
      window.clearTimeout(timeout);
      reject(new Error('百度地图加载失败,请检查 AK、域名白名单和网络'));
    }, { once: true });
    document.head.append(script);
  });
  return mapWindow.__xgdlBaiduMapPromise;
}
function updateOverlay(longitude: number, latitude: number, centerMap = false) {
  if (!map || !BMapGL) {
    return;
  }
  const point = new BMapGL.Point(longitude, latitude);
  if (!marker) {
    marker = new BMapGL.Marker(point);
    map.addOverlay(marker);
  } else {
    marker.setPosition(point);
  }
  if (!rangeCircle) {
    rangeCircle = new BMapGL.Circle(point, props.radiusMeters, {
      fillColor: '#1677ff',
      fillOpacity: 0.16,
      strokeColor: '#1677ff',
      strokeOpacity: 0.8,
      strokeWeight: 2,
    });
    map.addOverlay(rangeCircle);
  } else {
    rangeCircle.setCenter(point);
    rangeCircle.setRadius(props.radiusMeters);
  }
  if (centerMap) {
    map.centerAndZoom(point, 19);
  }
}
function reverseGeocode(longitude: number, latitude: number) {
  return new Promise<{ address?: string; name?: string }>((resolve) => {
    if (!geocoder) {
      resolve({});
      return;
    }
    geocoder.getLocation(new BMapGL.Point(longitude, latitude), (result: any) => {
      const components = result?.addressComponents ?? {};
      const nearestPoi = result?.surroundingPois?.[0]?.title;
      resolve({
        address: result?.address,
        name: nearestPoi || components.street || components.district || components.city,
      });
    });
  });
}
async function selectBaiduLocation(longitude: number, latitude: number, centerMap = true) {
  updateOverlay(longitude, latitude, centerMap);
  const [wgsLongitude, wgsLatitude] = bd09ToWgs84(longitude, latitude);
  const location = await reverseGeocode(longitude, latitude);
  selectedAddress.value = location.address ?? '';
  emit('select', {
    address: location.address,
    latitude: Number(wgsLatitude.toFixed(7)),
    locationName: location.name,
    longitude: Number(wgsLongitude.toFixed(7)),
  });
}
async function handleSearch() {
  const keyword = searchKeyword.value.trim();
  if (!keyword || !geocoder) {
    return;
  }
  loading.value = true;
  try {
    const point = await new Promise<any>((resolve, reject) => {
      geocoder.getPoint(keyword, (result: any) => {
        result ? resolve(result) : reject(new Error('没有搜索到该地址,请输入更完整的省市区和地点名称'));
      });
    });
    await selectBaiduLocation(point.lng, point.lat);
  } catch (error: any) {
    message.warning(error.message || '地址搜索失败');
  } finally {
    loading.value = false;
  }
}
function handleCurrentLocation() {
  if (!navigator.geolocation) {
    message.warning('当前浏览器不支持定位');
    return;
  }
  loading.value = true;
  navigator.geolocation.getCurrentPosition(
    async (position) => {
      const [longitude, latitude] = wgs84ToBd09(position.coords.longitude, position.coords.latitude);
      await selectBaiduLocation(longitude, latitude);
      loading.value = false;
    },
    () => {
      message.warning('定位失败,请开启浏览器定位权限(非 localhost çŽ¯å¢ƒéœ€ä½¿ç”¨ HTTPS)');
      loading.value = false;
    },
    { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 },
  );
}
async function initializeMap() {
  if (!hasMapConfig.value || !mapContainer.value) {
    return;
  }
  loading.value = true;
  try {
    BMapGL = await loadBaiduMap();
    await nextTick();
    const hasCoordinates = Number.isFinite(props.longitude) && Number.isFinite(props.latitude);
    const center = hasCoordinates
      ? wgs84ToBd09(Number(props.longitude), Number(props.latitude))
      : DEFAULT_BAIDU_CENTER;
    map = new BMapGL.Map(mapContainer.value);
    map.centerAndZoom(new BMapGL.Point(center[0], center[1]), hasCoordinates ? 19 : 12);
    map.enableScrollWheelZoom(true);
    map.addControl(new BMapGL.ZoomControl());
    map.addControl(new BMapGL.ScaleControl());
    geocoder = new BMapGL.Geocoder();
    map.addEventListener('click', (event: any) => {
      if (!props.disabled) {
        void selectBaiduLocation(event.latlng.lng, event.latlng.lat, false);
      }
    });
    if (hasCoordinates) {
      updateOverlay(center[0], center[1]);
      const location = await reverseGeocode(center[0], center[1]);
      selectedAddress.value = location.address ?? '';
    }
  } catch (error: any) {
    message.error(error.message || '地图初始化失败');
  } finally {
    loading.value = false;
  }
}
watch(
  () => [props.longitude, props.latitude] as const,
  ([longitude, latitude]) => {
    if (!map || !Number.isFinite(longitude) || !Number.isFinite(latitude)) {
      return;
    }
    const point = wgs84ToBd09(Number(longitude), Number(latitude));
    updateOverlay(point[0], point[1]);
  },
);
watch(() => props.radiusMeters, (radius) => rangeCircle?.setRadius(radius));
onMounted(initializeMap);
onBeforeUnmount(() => {
  map?.clearOverlays();
  map = undefined;
  marker = undefined;
  rangeCircle = undefined;
  geocoder = undefined;
});
</script>
<template>
  <a-alert
    v-if="!hasMapConfig"
    type="warning"
    show-icon
    message="百度地图尚未配置"
    description="请配置 VITE_BAIDU_MAP_KEY åŽé‡æ–°æž„建前端。"
  />
  <a-spin v-else :spinning="loading">
    <div v-if="!disabled" class="mb-2 flex gap-2">
      <a-input-search
        v-model:value="searchKeyword"
        enter-button="搜索地址"
        placeholder="输入省、市、区和具体地点,例如:乌鲁木齐市新高力厂区"
        @search="handleSearch"
      />
      <a-button @click="handleCurrentLocation">定位到当前位置</a-button>
    </div>
    <div ref="mapContainer" class="h-[360px] w-full overflow-hidden rounded border border-gray-200"></div>
    <div class="mt-2 flex flex-wrap justify-between gap-2 text-xs text-gray-500">
      <span>{{ disabled ? '打卡位置及允许范围' : '点击地图选择打卡中心点;蓝色圆圈表示允许打卡范围' }}</span>
      <span v-if="selectedAddress">{{ selectedAddress }}</span>
    </div>
  </a-spin>
</template>