liu
2026-10-26 ffc467e029453039d73f8177019d0d055dc5be2d
feat(hrm): 补充考勤定位打卡页面
已添加10个文件
已修改1个文件
1603 ■■■■■ 文件已修改
src/api/hrm/attendance/record/index.ts 122 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/rule/index.ts 83 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/packages/constants/src/dict-enum.ts 6 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/data.ts 116 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/index.vue 113 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/clock-panel.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/record/modules/import-form.vue 75 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/data.ts 320 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/index.vue 170 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/form.vue 162 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 335 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/api/hrm/attendance/record/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,122 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmAttendanceRecordApi {
  /** è€ƒå‹¤è®°å½• */
  export interface AttendanceRecord {
    id?: number;
    userId?: number;
    userName?: string;
    deptId?: number;
    deptName?: string;
    date?: string;
    clockInTime?: string;
    clockOutTime?: string;
    clockInType?: number;
    clockInTypeName?: string;
    clockOutType?: number;
    clockOutTypeName?: string;
    workHours?: number;
    overtimeHours?: number;
    location?: string;
    clockInLongitude?: number;
    clockInLatitude?: number;
    clockInAccuracy?: number;
    clockInDistance?: number;
    clockInAddress?: string;
    clockOutLongitude?: number;
    clockOutLatitude?: number;
    clockOutAccuracy?: number;
    clockOutDistance?: number;
    clockOutAddress?: string;
    dataSource?: number;
    dataSourceName?: string;
    remark?: string;
    createTime?: string;
  }
  /** è€ƒå‹¤ç»Ÿè®¡ */
  export interface AttendanceStatistics {
    userId?: number;
    userName?: string;
    deptName?: string;
    totalDays?: number;
    normalDays?: number;
    lateDays?: number;
    earlyDays?: number;
    absentDays?: number;
    overtimeHours?: number;
    totalWorkHours?: number;
  }
  /** å®šä½æ‰“卡结果 */
  export interface AttendanceClockResponse {
    record: AttendanceRecord;
    locationName?: string;
    distanceMeters?: number;
    radiusMeters?: number;
    withinRange?: boolean;
  }
}
/** æŸ¥è¯¢è€ƒå‹¤è®°å½•分页列表 */
export function getAttendanceRecordPage(params: PageParam) {
  return requestClient.get<PageResult<HrmAttendanceRecordApi.AttendanceRecord>>(
    '/hrm/attendance/record/page',
    { params },
  );
}
/** æŸ¥è¯¢è€ƒå‹¤ç»Ÿè®¡ï¼ˆæŒ‰æœˆï¼‰ */
export function getAttendanceStatistics(params: {
  userId?: number;
  deptId?: number;
  month: string;
}) {
  return requestClient.get<HrmAttendanceRecordApi.AttendanceStatistics[]>(
    '/hrm/attendance/record/statistics',
    { params },
  );
}
/** å¯¼å…¥è€ƒå‹¤è®°å½• */
export function importAttendanceRecord(file: File, updateSupport: boolean) {
  return requestClient.upload('/hrm/attendance/record/import', {
    file,
    updateSupport,
  });
}
/** ä¸‹è½½è€ƒå‹¤å¯¼å…¥æ¨¡æ¿ */
export function importAttendanceTemplate() {
  return requestClient.download('/hrm/attendance/record/get-import-template');
}
/** å¯¼å‡ºè€ƒå‹¤è®°å½• */
export function exportAttendanceRecord(params: any) {
  return requestClient.download('/hrm/attendance/record/export-excel', { params });
}
export interface AttendanceClockLocation {
  longitude: number;
  latitude: number;
  accuracy: number;
  coordinateSystem: 'WGS84';
}
/** ä¸Šç­æ‰“卡 */
export function clockIn(data: AttendanceClockLocation) {
  return requestClient.post<HrmAttendanceRecordApi.AttendanceClockResponse>(
    '/hrm/attendance/record/clock-in',
    data,
  );
}
/** ä¸‹ç­æ‰“卡 */
export function clockOut(data: AttendanceClockLocation) {
  return requestClient.post<HrmAttendanceRecordApi.AttendanceClockResponse>(
    '/hrm/attendance/record/clock-out',
    data,
  );
}
src/api/hrm/attendance/rule/index.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,83 @@
import type { PageParam, PageResult } from '#/packages/effects/request/src';
import { requestClient } from '#/api/request';
export namespace HrmAttendanceRuleApi {
  /** è€ƒå‹¤è§„则 */
  export interface AttendanceRule {
    id?: number;
    name?: string;
    deptId?: number;
    deptName?: string;
    workStartTime?: string;
    workEndTime?: string;
    lateAllowMinutes?: number;
    earlyAllowMinutes?: number;
    overtimeThreshold?: number;
    locationName?: string;
    locationAddress?: string;
    longitude?: number;
    latitude?: number;
    radiusMeters?: number;
    locationRequired?: boolean;
    coordinateSystem?: 'WGS84';
    status?: number;
    remark?: string;
    createTime?: string;
  }
}
/** æŸ¥è¯¢è€ƒå‹¤è§„则分页列表 */
export function getAttendanceRulePage(params: PageParam) {
  return requestClient.get<PageResult<HrmAttendanceRuleApi.AttendanceRule>>(
    '/hrm/attendance/rule/page',
    { params },
  );
}
/** æŸ¥è¯¢è€ƒå‹¤è§„则列表(启用状态) */
export function getAttendanceRuleList() {
  return requestClient.get<HrmAttendanceRuleApi.AttendanceRule[]>(
    '/hrm/attendance/rule/list',
  );
}
/** æŸ¥è¯¢è€ƒå‹¤è§„则详情 */
export function getAttendanceRule(id: number) {
  return requestClient.get<HrmAttendanceRuleApi.AttendanceRule>(
    `/hrm/attendance/rule/get?id=${id}`,
  );
}
/** æ–°å¢žè€ƒå‹¤è§„则 */
export function createAttendanceRule(data: HrmAttendanceRuleApi.AttendanceRule) {
  return requestClient.post('/hrm/attendance/rule/create', data);
}
/** æ›´æ–°è€ƒå‹¤è§„则 */
export function updateAttendanceRule(data: HrmAttendanceRuleApi.AttendanceRule) {
  return requestClient.put('/hrm/attendance/rule/update', data);
}
/** åˆ é™¤è€ƒå‹¤è§„则 */
export function deleteAttendanceRule(id: number) {
  return requestClient.delete(`/hrm/attendance/rule/delete?id=${id}`);
}
/** å¯¼å‡ºè€ƒå‹¤è§„则 Excel */
export function exportAttendanceRule(params: any) {
  return requestClient.download('/hrm/attendance/rule/export-excel', { params });
}
/** ä¸‹è½½è€ƒå‹¤è§„则导入模板 */
export function importAttendanceRuleTemplate() {
  return requestClient.download('/hrm/attendance/rule/get-import-template');
}
/** å¯¼å…¥è€ƒå‹¤è§„则 */
export function importAttendanceRule(file: File, updateSupport: boolean) {
  return requestClient.upload('/hrm/attendance/rule/import', {
    file,
    updateSupport,
  });
}
src/packages/constants/src/dict-enum.ts
@@ -279,12 +279,18 @@
  WMS_SHIPMENT_ORDER_TYPE: 'wms_shipment_order_type', // WMS å‡ºåº“单类型
} as const;
/** ========== HRM - äººåŠ›èµ„æºæ¨¡å— ========== */
const HRM_DICT = {
  HRM_CLOCK_TYPE: 'hrm_clock_type', // HRM æ‰“卡类型
} as const;
/** å­—典类型枚举 - ç»Ÿä¸€å¯¼å‡º */
const DICT_TYPE = {
  ...AI_DICT,
  ...BPM_DICT,
  ...CRM_DICT,
  ...ERP_DICT,
  ...HRM_DICT,
  ...INFRA_DICT,
  ...IM_DICT,
  ...IOT_DICT,
src/views/hrm/attendance/record/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,116 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmAttendanceRecordApi } from '#/api/hrm/attendance/record';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
import { getDeptList } from '#/api/system/dept';
import { handleTree } from '#/packages/utils/src';
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'userName',
      label: '员工姓名',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入员工姓名',
      },
    },
    {
      fieldName: 'date',
      label: '考勤日期',
      component: 'RangePicker',
      componentProps: {
        allowClear: true,
        valueFormat: 'YYYY-MM-DD',
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'deptId',
      label: '部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: async () => {
          const data = await getDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        allowClear: true,
        placeholder: '请选择部门',
        treeDefaultExpandAll: true,
      },
    },
    {
      fieldName: 'clockInType',
      label: '上班打卡类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_CLOCK_TYPE, 'number'),
        placeholder: '请选择上班打卡类型',
      },
    },
    {
      fieldName: 'clockOutType',
      label: '下班打卡类型',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.HRM_CLOCK_TYPE, 'number'),
        placeholder: '请选择下班打卡类型',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmAttendanceRecordApi.AttendanceRecord>['columns'] {
  return [
    { field: 'userName', title: '员工姓名', minWidth: 100 },
    { field: 'deptName', title: '部门', minWidth: 120 },
    { field: 'date', title: '考勤日期', width: 110 },
    { field: 'clockInTime', title: '上班打卡时间', width: 160 },
    {
      field: 'clockInType',
      title: '上班打卡类型',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.HRM_CLOCK_TYPE },
      },
    },
    { field: 'clockOutTime', title: '下班打卡时间', width: 160 },
    {
      field: 'clockOutType',
      title: '下班打卡类型',
      width: 100,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.HRM_CLOCK_TYPE },
      },
    },
    { field: 'workHours', title: '工作时长(h)', width: 100 },
    { field: 'overtimeHours', title: '加班时长(h)', width: 100 },
    { field: 'location', title: '打卡地点', minWidth: 150 },
    {
      field: 'dataSource',
      title: '数据来源',
      width: 100,
    },
    {
      title: '操作',
      width: 120,
      fixed: 'right',
      slots: {
        default: 'actions',
      },
    },
  ];
}
src/views/hrm/attendance/record/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,113 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmAttendanceRecordApi } from '#/api/hrm/attendance/record';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { exportAttendanceRecord, getAttendanceRecordPage } from '#/api/hrm/attendance/record';
import { $t } from '#/locales';
import { useGridColumns, useGridFormSchema } from './data';
import ClockPanel from './modules/clock-panel.vue';
import ImportForm from './modules/import-form.vue';
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportForm,
  destroyOnClose: true,
});
const [ClockModal, clockModalApi] = useVbenModal({
  connectedComponent: ClockPanel,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ‰“卡 */
function handleClock() {
  clockModalApi.open();
}
/** å¯¼å…¥è€ƒå‹¤è®°å½• */
function handleImport() {
  importModalApi.open();
}
/** å¯¼å‡ºè€ƒå‹¤è®°å½• */
async function handleExport() {
  const data = await exportAttendanceRecord(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '考勤记录.xls', source: data });
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getAttendanceRecordPage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmAttendanceRecordApi.AttendanceRecord>,
});
</script>
<template>
  <Page auto-content-height>
    <ImportModal @success="handleRefresh" />
    <ClockModal @success="handleRefresh" />
    <Grid table-title="考勤记录列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: '打卡',
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:attendance-record:clock'],
              onClick: handleClock,
            },
            {
              label: '导入',
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['hrm:attendance-record:import'],
              onClick: handleImport,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['hrm:attendance-record:export'],
              onClick: handleExport,
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/attendance/record/modules/clock-panel.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,101 @@
<script lang="ts" setup>
import { ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import type { AttendanceClockLocation } from '#/api/hrm/attendance/record';
import { clockIn, clockOut } from '#/api/hrm/attendance/record';
const emit = defineEmits(['success']);
const loading = ref(false);
function buildSuccessMessage(
  action: string,
  result: Awaited<ReturnType<typeof clockIn>>,
) {
  if (result.distanceMeters == null || !result.locationName) {
    return `${action}成功`;
  }
  return `${action}成功,距${result.locationName} ${Number(result.distanceMeters).toFixed(1)} ç±³`;
}
function getCurrentLocation(): Promise<AttendanceClockLocation> {
  if (!navigator.geolocation) {
    return Promise.reject(new Error('当前浏览器不支持定位,请使用支持 GPS çš„æµè§ˆå™¨'));
  }
  return new Promise((resolve, reject) => {
    navigator.geolocation.getCurrentPosition(
      (position) => resolve({
        longitude: position.coords.longitude,
        latitude: position.coords.latitude,
        accuracy: position.coords.accuracy,
        coordinateSystem: 'WGS84',
      }),
      () => reject(new Error('定位失败,请开启定位权限后重试(非 localhost éœ€ä½¿ç”¨ HTTPS)')),
      { enableHighAccuracy: true, maximumAge: 0, timeout: 15_000 },
    );
  });
}
/** ä¸Šç­æ‰“卡 */
async function handleClockIn() {
  loading.value = true;
  try {
    const result = await clockIn(await getCurrentLocation());
    message.success(buildSuccessMessage('上班打卡', result));
    emit('success');
  } catch (e: any) {
    message.error(e.message || '打卡失败');
  } finally {
    loading.value = false;
  }
}
/** ä¸‹ç­æ‰“卡 */
async function handleClockOut() {
  loading.value = true;
  try {
    const result = await clockOut(await getCurrentLocation());
    message.success(buildSuccessMessage('下班打卡', result));
    emit('success');
  } catch (e: any) {
    message.error(e.message || '打卡失败');
  } finally {
    loading.value = false;
  }
}
const [Modal, modalApi] = useVbenModal({
  onOpenChange() {},
});
</script>
<template>
  <Modal title="打卡" class="w-[400px]">
    <div class="p-4">
      <div class="mb-4 text-sm text-gray-500">
        ç‚¹å‡»æ‰“卡后将获取当前 GPS ä½ç½®ï¼Œå¹¶æŒ‰è€ƒå‹¤è§„则中的经纬度和打卡半径校验。
      </div>
      <div class="flex gap-4">
        <a-button
          type="primary"
          size="large"
          :loading="loading"
          @click="handleClockIn"
        >
          ä¸Šç­æ‰“卡
        </a-button>
        <a-button
          size="large"
          :loading="loading"
          @click="handleClockOut"
        >
          ä¸‹ç­æ‰“卡
        </a-button>
      </div>
    </div>
  </Modal>
</template>
src/views/hrm/attendance/record/modules/import-form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,75 @@
<script lang="ts" setup>
import type { FileType } from 'ant-design-vue/es/upload/interface';
import { ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { Button, message, Switch, Upload } from 'ant-design-vue';
import { importAttendanceRecord, importAttendanceTemplate } from '#/api/hrm/attendance/record';
const emit = defineEmits(['success']);
const fileList = ref<FileType[]>([]);
const updateSupport = ref(false);
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (fileList.value.length === 0) {
      message.warning('请选择要导入的文件');
      return;
    }
    modalApi.lock();
    try {
      await importAttendanceRecord(fileList.value[0] as File, updateSupport.value);
      await modalApi.close();
      emit('success');
      message.success('导入成功');
    } finally {
      modalApi.unlock();
    }
  },
});
function beforeUpload(file: FileType) {
  fileList.value = [file];
  return false;
}
function handleRemove() {
  fileList.value = [];
}
async function handleDownloadTemplate() {
  const data = await importAttendanceTemplate();
  downloadFileFromBlobPart({ fileName: '考勤导入模板.xls', source: data });
}
</script>
<template>
  <Modal title="导入考勤记录" class="w-[500px]">
    <div class="mx-4">
      <Upload
        :max-count="1"
        accept=".xls,.xlsx"
        :file-list="fileList"
        :before-upload="beforeUpload"
        @remove="handleRemove"
      >
        <Button type="primary">选择 Excel æ–‡ä»¶</Button>
      </Upload>
      <div class="mt-4">
        <span class="mr-2">是否覆盖已存在的数据:</span>
        <Switch v-model:checked="updateSupport" />
      </div>
      <div class="mt-2 text-gray-500">
        ä»…允许导入 xls、xlsx æ ¼å¼æ–‡ä»¶
      </div>
    </div>
    <template #prepend-footer>
      <Button @click="handleDownloadTemplate">下载导入模板</Button>
    </template>
  </Modal>
</template>
src/views/hrm/attendance/rule/data.ts
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,320 @@
import type { VbenFormSchema } from '#/adapter/form';
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule';
import { DICT_TYPE } from '#/packages/constants/src';
import { getDictOptions } from '#/packages/effects/hooks/src';
import { getDeptList } from '#/api/system/dept';
import { handleTree } from '#/packages/utils/src';
/** è¡¨å•类型 */
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,
  locationCallbacks: LocationFormCallbacks = {},
): VbenFormSchema[] {
  return [
    {
      fieldName: 'id',
      component: 'Input',
      dependencies: {
        triggerFields: [''],
        show: () => false,
      },
    },
    {
      fieldName: 'name',
      label: '规则名称',
      component: 'Input',
      componentProps: {
        placeholder: '请输入规则名称',
      },
      rules: 'required',
    },
    {
      fieldName: 'deptId',
      label: '适用部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: async () => {
          const data = await getDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        allowClear: true,
        placeholder: '请选择部门(不选表示全局)',
        treeDefaultExpandAll: true,
      },
    },
    {
      fieldName: 'workStartTime',
      label: '上班时间',
      component: 'TimePicker',
      componentProps: {
        placeholder: '选择上班时间',
        valueFormat: 'HH:mm:ss',
        format: 'HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'workEndTime',
      label: '下班时间',
      component: 'TimePicker',
      componentProps: {
        placeholder: '选择下班时间',
        valueFormat: 'HH:mm:ss',
        format: 'HH:mm:ss',
        style: { width: '100%' },
      },
      rules: 'required',
    },
    {
      fieldName: 'lateAllowMinutes',
      label: '迟到允许(分钟)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '分钟',
        min: 0,
        max: 60,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'earlyAllowMinutes',
      label: '早退允许(分钟)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '分钟',
        min: 0,
        max: 60,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'overtimeThreshold',
      label: '加班起算(小时)',
      component: 'InputNumber',
      componentProps: {
        placeholder: '小时',
        min: 0,
        precision: 1,
        style: { width: '100%' },
      },
    },
    {
      fieldName: 'locationRequired',
      label: '定位打卡',
      component: 'Switch',
      defaultValue: false,
      componentProps: {
        checkedChildren: '启用',
        onChange: locationCallbacks.onLocationRequiredChange,
        unCheckedChildren: '关闭',
      },
    },
    {
      fieldName: 'locationName',
      label: '打卡地点',
      component: 'Input',
      componentProps: { placeholder: '例如:新高电力厂区' },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'locationAddress',
      label: '地点地址',
      component: 'Input',
      componentProps: { placeholder: '请输入详细地址,便于员工识别' },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
    },
    {
      fieldName: 'longitude',
      label: '经度(WGS84)',
      component: 'InputNumber',
      componentProps: {
        min: -180,
        max: 180,
        onChange: locationCallbacks.onLongitudeChange,
        precision: 7,
        style: { width: '100%' },
      },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'latitude',
      label: '纬度(WGS84)',
      component: 'InputNumber',
      componentProps: {
        min: -90,
        max: 90,
        onChange: locationCallbacks.onLatitudeChange,
        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,
        onChange: locationCallbacks.onRadiusChange,
        precision: 0,
        style: { width: '100%' },
      },
      help: '最小可设 10 ç±³ï¼›å®žé™…精度取决于手机 GPS ä¸ŽçŽ¯å¢ƒ',
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
      rules: 'required',
    },
    {
      fieldName: 'coordinateSystem',
      label: '坐标系',
      component: 'Input',
      defaultValue: 'WGS84',
      componentProps: { disabled: true },
      dependencies: {
        triggerFields: ['locationRequired'],
        show: (values) => Boolean(values.locationRequired),
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'RadioGroup',
      componentProps: {
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
      },
    },
    {
      fieldName: 'remark',
      label: '备注',
      component: 'Textarea',
      formItemClass: 'col-span-2',
      componentProps: {
        placeholder: '请输入备注',
        rows: 2,
        maxlength: 500,
        showCount: true,
      },
    },
  ];
}
/** åˆ—表的搜索表单 */
export function useGridFormSchema(): VbenFormSchema[] {
  return [
    {
      fieldName: 'name',
      label: '规则名称',
      component: 'Input',
      componentProps: {
        allowClear: true,
        placeholder: '请输入规则名称',
      },
    },
    {
      fieldName: 'deptId',
      label: '适用部门',
      component: 'ApiTreeSelect',
      componentProps: {
        api: async () => {
          const data = await getDeptList();
          return handleTree(data);
        },
        labelField: 'name',
        valueField: 'id',
        childrenField: 'children',
        allowClear: true,
        placeholder: '请选择部门',
        treeDefaultExpandAll: true,
      },
    },
    {
      fieldName: 'status',
      label: '状态',
      component: 'Select',
      componentProps: {
        allowClear: true,
        options: getDictOptions(DICT_TYPE.COMMON_STATUS, 'number'),
        placeholder: '请选择状态',
      },
    },
  ];
}
/** åˆ—表的字段 */
export function useGridColumns(): VxeTableGridOptions<HrmAttendanceRuleApi.AttendanceRule>['columns'] {
  return [
    { field: 'name', title: '规则名称', minWidth: 150 },
    {
      field: 'deptName',
      title: '适用部门',
      minWidth: 120,
      formatter: ({ cellValue }) => cellValue || '全局',
    },
    { field: 'workStartTime', title: '上班时间', width: 100 },
    { field: 'workEndTime', title: '下班时间', width: 100 },
    { field: 'lateAllowMinutes', title: '迟到允许(分钟)', width: 120 },
    { field: 'earlyAllowMinutes', title: '早退允许(分钟)', width: 120 },
    { field: 'overtimeThreshold', title: '加班起算(小时)', width: 120 },
    {
      field: 'locationName',
      title: '打卡范围',
      minWidth: 180,
      formatter: ({ row }) => row.locationRequired
        ? `${row.locationName || '-'}(${row.radiusMeters || 0}米)`
        : '不限制位置',
    },
    {
      field: 'status',
      title: '状态',
      width: 80,
      cellRender: {
        name: 'CellDict',
        props: { type: DICT_TYPE.COMMON_STATUS },
      },
    },
    {
      title: '操作',
      width: 150,
      fixed: 'right',
      slots: {
        default: 'actions',
      },
    },
  ];
}
src/views/hrm/attendance/rule/index.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,170 @@
<script lang="ts" setup>
import type { VxeTableGridOptions } from '#/adapter/vxe-table';
import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule';
import { Page, useVbenModal } from '#/packages/effects/common-ui/src';
import { downloadFileFromBlobPart } from '#/packages/utils/src';
import { message, Modal } from 'ant-design-vue';
import { ACTION_ICON, TableAction, useVbenVxeGrid } from '#/adapter/vxe-table';
import { deleteAttendanceRule, exportAttendanceRule, getAttendanceRulePage, importAttendanceRule, importAttendanceRuleTemplate } from '#/api/hrm/attendance/rule';
import { $t } from '#/locales';
import ImportModalComponent from '#/components/import-modal';
import { useGridColumns, useGridFormSchema } from './data';
import Form from './modules/form.vue';
const [FormModal, formModalApi] = useVbenModal({
  connectedComponent: Form,
  destroyOnClose: true,
});
const [ImportModal, importModalApi] = useVbenModal({
  connectedComponent: ImportModalComponent,
  destroyOnClose: true,
});
/** åˆ·æ–°è¡¨æ ¼ */
function handleRefresh() {
  gridApi.query();
}
/** æ–°å¢žè€ƒå‹¤è§„则 */
function handleCreate() {
  formModalApi.setData({ formType: 'create' }).open();
}
/** ç¼–辑考勤规则 */
function handleUpdate(row: HrmAttendanceRuleApi.AttendanceRule) {
  formModalApi.setData({ id: row.id, formType: 'update' }).open();
}
/** æŸ¥çœ‹è€ƒå‹¤è§„则 */
function handleDetail(row: HrmAttendanceRuleApi.AttendanceRule) {
  formModalApi.setData({ id: row.id, formType: 'detail' }).open();
}
/** åˆ é™¤è€ƒå‹¤è§„则 */
async function handleDelete(id: number) {
  Modal.confirm({
    title: '确认删除',
    content: '确定要删除该考勤规则吗?',
    okText: '确定',
    cancelText: '取消',
    onOk: async () => {
      await deleteAttendanceRule(id);
      message.success('删除成功');
      handleRefresh();
    },
  });
}
/** å¯¼å‡ºè€ƒå‹¤è§„则 */
async function handleExport() {
  const data = await exportAttendanceRule(await gridApi.formApi.getValues());
  downloadFileFromBlobPart({ fileName: '考勤规则.xls', source: data });
}
/** å¯¼å…¥è€ƒå‹¤è§„则 */
function handleImport() {
  importModalApi
    .setData({
      title: $t('ui.actionTitle.import', ['考勤规则']),
      fileName: '考勤规则导入模板.xls',
      importFn: importAttendanceRule,
      templateFn: importAttendanceRuleTemplate,
    })
    .open();
}
const [Grid, gridApi] = useVbenVxeGrid({
  formOptions: {
    schema: useGridFormSchema(),
  },
  gridOptions: {
    columns: useGridColumns(),
    height: 'auto',
    keepSource: true,
    proxyConfig: {
      ajax: {
        query: async ({ page }, formValues) =>
          await getAttendanceRulePage({
            pageNo: page.currentPage,
            pageSize: page.pageSize,
            ...formValues,
          }),
      },
    },
    rowConfig: {
      keyField: 'id',
      isHover: true,
    },
    toolbarConfig: {
      refresh: true,
      search: true,
    },
  } as VxeTableGridOptions<HrmAttendanceRuleApi.AttendanceRule>,
});
</script>
<template>
  <Page auto-content-height>
    <FormModal @success="handleRefresh" />
    <ImportModal @success="handleRefresh" />
    <Grid table-title="考勤规则列表">
      <template #toolbar-tools>
        <TableAction
          :actions="[
            {
              label: $t('ui.actionTitle.create', ['考勤规则']),
              type: 'primary',
              icon: ACTION_ICON.ADD,
              auth: ['hrm:attendance-rule:create'],
              onClick: handleCreate,
            },
            {
              label: $t('ui.actionTitle.export'),
              type: 'primary',
              icon: ACTION_ICON.DOWNLOAD,
              auth: ['hrm:attendance-rule:export'],
              onClick: handleExport,
            },
            {
              label: $t('ui.actionTitle.import', ['考勤规则']),
              type: 'primary',
              icon: ACTION_ICON.UPLOAD,
              auth: ['hrm:attendance-rule:import'],
              onClick: handleImport,
            },
          ]"
        />
      </template>
      <template #actions="{ row }">
        <TableAction
          :actions="[
            {
              label: '查看',
              type: 'link',
              onClick: handleDetail.bind(null, row),
            },
            {
              label: $t('ui.actionTitle.edit'),
              type: 'link',
              auth: ['hrm:attendance-rule:update'],
              onClick: handleUpdate.bind(null, row),
            },
            {
              label: $t('ui.actionTitle.delete'),
              type: 'link',
              danger: true,
              auth: ['hrm:attendance-rule:delete'],
              onClick: handleDelete.bind(null, row.id!),
            },
          ]"
        />
      </template>
    </Grid>
  </Page>
</template>
src/views/hrm/attendance/rule/modules/form.vue
¶Ô±ÈÐÂÎļþ
@@ -0,0 +1,162 @@
<script lang="ts" setup>
import type { FormType } from '../data';
import type { HrmAttendanceRuleApi } from '#/api/hrm/attendance/rule';
import { computed, ref } from 'vue';
import { useVbenModal } from '#/packages/effects/common-ui/src';
import { message } from 'ant-design-vue';
import { useVbenForm } from '#/adapter/form';
import { createAttendanceRule, getAttendanceRule, updateAttendanceRule } from '#/api/hrm/attendance/rule';
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(() => {
  if (formType.value === 'detail') {
    return '查看考勤规则';
  }
  if (formType.value === 'update') {
    return '编辑考勤规则';
  }
  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: {
    componentProps: {
      class: 'w-full',
    },
    formItemClass: 'col-span-1',
    labelWidth: 120,
  },
  wrapperClass: 'grid-cols-2',
  layout: 'horizontal',
  schema: [],
  showDefaultActions: false,
});
const [Modal, modalApi] = useVbenModal({
  async onConfirm() {
    if (isDetail.value) {
      await modalApi.close();
      return;
    }
    const { valid } = await formApi.validate();
    if (!valid) {
      return;
    }
    modalApi.lock();
    const data = (await formApi.getValues()) as HrmAttendanceRuleApi.AttendanceRule;
    try {
      if (formType.value === 'update') {
        await updateAttendanceRule(data);
      } else {
        await createAttendanceRule(data);
      }
      await modalApi.close();
      emit('success');
      message.success($t('ui.actionMessage.operationSuccess'));
    } finally {
      modalApi.unlock();
    }
  },
  async onOpenChange(isOpen: boolean) {
    if (!isOpen) {
      return;
    }
    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: 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 {
      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();
    }
  },
});
</script>
<template>
  <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>
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>