fix(hrm): 考勤规则打开定位后点确认报 Cannot read properties of null (reading Map)

两个叠加的 bug:

一、百度地图 SDK 根本没加载上(主因)
loadBaiduMapSdk 用的是 api?v=1.0&type=webgl&ak=xxx&callback=xxx 这个地址,
它返回的是一段 document.write 包装的 getscript 引用。
而脚本是通过 document.body.append(script) 动态插入的,
浏览器会拦截动态脚本里的 document.write(Chrome 会提示
A parser-blocking cross site script invoked via document.write was NOT executed),
于是真正的 SDK 从未执行、window.BMapGL 一直是空。
但 api 的 callback 依然会被调用,调用方误判为加载成功,接着 new BMapGL.Map 就抛
Cannot read properties of null (reading Map)。
修复:直连 api.map.baidu.com/getscript 地址,用 script 的 load 事件判断就绪,
并额外校验 window.BMapGL 是否真的挂载。

二、异步初始化与组件卸载竞态
用户打开定位开关后立刻点确认,弹窗关闭导致 LocationMapPicker 卸载、地图容器从 DOM 移除,
但 await loadBaiduMapSdk 之后仍会执行 new BMapGL.Map(mapContainer.value),
此时容器已是 null,同样报该错误。
修复:新增 disposed 标志,每次 await 后检查组件是否已卸载、容器是否存在;
卸载时先置标志再销毁实例,并把 destroy 包在 try/catch 里。

顺带修正:script 的事件名写成 onerror 永远不会触发,改为 error。

实测:getscript 直连返回 HTTP 200、875KB 真实 SDK 且含 BMapGL 定义;
构建产物已无 api?v=1.0 地址,地图 chunk 仅含 getscript。
已修改2个文件
97 ■■■■■ 文件已修改
src/components/map/src/utils.ts 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/views/hrm/attendance/rule/modules/location-map-picker.vue 40 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
src/components/map/src/utils.ts
@@ -1,6 +1,17 @@
/* eslint-disable @typescript-eslint/no-dynamic-delete */
/**
 * 百度地图 SDK 加载工具
 *
 * 重要:这里必须直连 getscript,不能用 api?... 地址。
 * api?v=1.0&type=webgl&ak=xxx 返回的是一段
 *   document.write('<script src="https://api.map.baidu.com/getscript?..."></script>')
 * 而通过 document.body.append(script) 动态插入的脚本里,document.write 会被浏览器拦截
 * (Chrome 直接提示 "A parser-blocking, cross site script ... invoked via document.write was NOT executed"),
 * 导致真正的 SDK 从未加载、window.BMapGL 一直是 undefined/null。
 * 但 api 里的 callback 仍会被调用,调用方误以为加载成功,接着执行 new BMapGL.Map()
 * 就抛出「Cannot read properties of null (reading 'Map')」。
 *
 * getscript 返回的是 875KB 的真实 SDK(已实测 HTTP 200 且含 BMapGL 定义),
 * 直接用 script.src 加载即可,脚本执行完 window.BMapGL 就已就绪。
 */
// 扩展 Window 接口以包含百度地图 GL API
@@ -9,9 +20,6 @@
    BMapGL: any;
  }
}
// 全局回调名称
const CALLBACK_NAME = '__BAIDU_MAP_LOAD_CALLBACK__';
// SDK 加载状态
let loadPromise: null | Promise<void> = null;
@@ -32,30 +40,41 @@
    return loadPromise;
  }
  const accessKey = String(import.meta.env.VITE_BAIDU_MAP_KEY ?? '').trim();
  if (!accessKey) {
    return Promise.reject(new Error('未配置 VITE_BAIDU_MAP_KEY'));
  }
  loadPromise = new Promise((resolve, reject) => {
    const timeoutId = setTimeout(() => {
      loadPromise = null;
      reject(new Error('百度地图 SDK 加载超时'));
      reject(new Error('百度地图 SDK 加载超时,请检查网络'));
    }, timeout);
    // 全局回调
    (window as any)[CALLBACK_NAME] = () => {
      clearTimeout(timeoutId);
      delete (window as any)[CALLBACK_NAME];
      resolve();
    };
    // 创建 script 标签
    const script = document.createElement('script');
    script.src = `https://api.map.baidu.com/api?v=1.0&type=webgl&ak=${
      import.meta.env.VITE_BAIDU_MAP_KEY
    }&callback=${CALLBACK_NAME}`;
    script.addEventListener('onerror', () => {
    script.src = `https://api.map.baidu.com/getscript?type=webgl&v=1.0&ak=${accessKey}&services=&t=${Date.now()}`;
    script.async = true;
    script.addEventListener('load', () => {
      clearTimeout(timeoutId);
      if (!window.BMapGL) {
        // ak 无效 / 域名未授权 / 被拦截时,脚本能加载但不会挂载 BMapGL
        loadPromise = null;
        reject(
          new Error('百度地图加载异常:未获取到地图对象,请检查 Key 或域名授权'),
        );
        return;
      }
      resolve();
    });
    // 注意:事件名是 error,不是 onerror(写 onerror 永远不会触发)
    script.addEventListener('error', () => {
      clearTimeout(timeoutId);
      loadPromise = null;
      delete (window as any)[CALLBACK_NAME];
      reject(new Error('百度地图 SDK 加载失败'));
      reject(new Error('百度地图 SDK 加载失败,请检查网络'));
    });
    document.body.append(script);
  });
src/views/hrm/attendance/rule/modules/location-map-picker.vue
@@ -51,6 +51,17 @@
let marker: AnyMap;
let rangeCircle: AnyMap;
let geocoder: AnyMap;
/**
 * 组件是否已卸载。
 *
 * 百度地图 SDK 是异步加载的(loadBaiduMapSdk 内部创建 script 标签)。
 * 用户打开定位后如果很快点「确认」或「取消」,弹窗关闭 → 本组件卸载 → 地图容器从 DOM 移除,
 * 但加载流程仍在 await 中继续,等 SDK 就绪后会执行 new BMapGL.Map(mapContainer.value),
 * 此时 mapContainer 已是 null,百度地图内部会抛
 * "Cannot read properties of null (reading 'Map')"。
 * 用这个标志位让异步流程在卸载后立即退出。
 */
let disposed = false;
const baiduMapKey = String(import.meta.env.VITE_BAIDU_MAP_KEY ?? '').trim();
const hasMapConfig = computed(() => Boolean(baiduMapKey));
@@ -274,13 +285,31 @@
  loading.value = true;
  try {
    await loadBaiduMapSdk(15_000);
    // 卸载后立即退出:此时地图容器已从 DOM 移除,继续初始化会抛 null.Map
    if (disposed || !mapContainer.value) {
      return;
    }
    await nextTick();
    if (disposed || !mapContainer.value) {
      return;
    }
    // SDK 回调触发但 BMapGL 未挂载(常见于 ak 无效 / 域名未授权 / 被网络拦截)
    if (!window.BMapGL) {
      message.error('百度地图加载失败,请检查地图 Key 配置或网络');
      return;
    }
    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);
    if (disposed) {
      // 初始化过程中被卸载,直接销毁,避免残留实例
      map?.destroy?.();
      map = undefined;
      return;
    }
    map.centerAndZoom(
      new window.BMapGL.Point(center[0], center[1]),
      hasCoordinates ? 19 : 12,
@@ -333,8 +362,15 @@
onMounted(initializeMap);
onBeforeUnmount(() => {
  map?.clearOverlays?.();
  map?.destroy?.();
  // 先置标志位,让还在 await 中的 initializeMap 立即退出
  disposed = true;
  try {
    map?.clearOverlays?.();
    map?.destroy?.();
  } catch (error) {
    // 地图实例若处于半初始化状态,destroy 内部可能报错,这里吞掉避免影响弹窗关闭
    console.warn('[LocationMapPicker] 销毁地图实例失败', error);
  }
  map = undefined;
  marker = undefined;
  rangeCircle = undefined;