zjk
2026-07-24 f167fd2b0484bea86cc7fddf22169c0a9ce117df
feat(employee, admin): 新增强制员工同步,重构同步缓存与业务逻辑

- 新增syncEmployeeVerifyList的force参数,支持强制全量同步员工档案
- 添加缓存过期机制,默认2小时内不重复拉取员工数据
- 优化人脸图片本地缓存复用,避免重复转换base64文件
- 注释所有调试弹窗,清理正式环境干扰代码
- 调整管理员首页签离状态文案为“已签离”,修正显示错误
- 优化预约扫描页员工验证与提交逻辑,简化代码并提升用户体验
- 简化本地图片存在性检查代码,适配更多安卓机型
5个文件已修改
229 ■■■■■ 已修改文件
api/employee.js 84 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminConfigPanel.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-home/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/n-index.nvue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/syncEngine.js 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/employee.js
@@ -11,9 +11,13 @@
  });
};
const saveEmployeeVerifyListCache = async (employeeList = []) => {
const EMPLOYEE_VERIFY_LIST_LAST_SYNC_KEY = 'employee_verify_list_last_sync';
const SYNC_CACHE_TTL_MS = 2 * 60 * 60 * 1000; // 2 小时内不重复强拉接口
export const saveEmployeeVerifyListCache = async (employeeList = []) => {
  await saveOfflineConfig(EMPLOYEE_VERIFY_LIST_KEY, employeeList);
  uni.setStorageSync(EMPLOYEE_VERIFY_LIST_KEY, employeeList);
  uni.setStorageSync(EMPLOYEE_VERIFY_LIST_LAST_SYNC_KEY, Date.now());
};
const getCurrentNetworkType = async () => {
@@ -38,22 +42,9 @@
      return;
    }
    // #ifdef APP-PLUS
    if (typeof plus === 'undefined' || !plus.io || !plus.io.resolveLocalFileSystemURL) {
      resolve(false);
      return;
    }
    plus.io.resolveLocalFileSystemURL(normalizedFilePath, () => {
      resolve(true);
    }, () => {
      resolve(false);
    });
    // #endif
    // #ifndef APP-PLUS
    // 考虑到不同安卓机型对 plus.io.resolveLocalFileSystemURL 处理绝对路径(/storage/emulated/0/...)的支持度不一致,
    // 直接返回 true,将真实性校验交给实际图片加载或人脸比对环节。
    resolve(true);
    // #endif
  });
};
@@ -146,7 +137,7 @@
  });
};
const normalizeEmployeeFaceImage = async (employee = {}, index = 0) => {
const normalizeEmployeeFaceImage = async (employee = {}, index = 0, localFaceImageMap = new Map()) => {
  if (!employee || typeof employee !== 'object') {
    return employee;
  }
@@ -158,9 +149,25 @@
    return normalizedEmployee;
  }
  // 1. 计算当前 Base64 的伪哈希特征值(长度 + 头部 + 尾部),以极低性能开销判断图片是否变化
  // 结合姓名一起作为特征,只要姓名和图片不变,就不重复转存
  const len = rawFaceImage.length;
  const imageHash = len < 100
    ? `${employee.name}_${rawFaceImage}`
    : `${employee.name}_${len}_${rawFaceImage.substring(0, 50)}_${rawFaceImage.substring(len - 50)}`;
  // 2. 比对缓存,如果存在且未变化,直接复用本地路径,跳过耗时的文件写入操作
  if (localFaceImageMap.has(imageHash)) {
    const cachedLocalPath = localFaceImageMap.get(imageHash);
    normalizedEmployee.faceImage = cachedLocalPath;
    normalizedEmployee._faceImageHash = imageHash;
    return normalizedEmployee;
  }
  try {
    const localFaceImagePath = await writeBase64ImageToLocal(rawFaceImage, `employee-${index}`);
    normalizedEmployee.faceImage = localFaceImagePath;
    normalizedEmployee._faceImageHash = imageHash;
    return normalizedEmployee;
  } catch (error) {
    console.error('员工人脸图片 base64 转本地文件失败:', error);
@@ -168,13 +175,13 @@
  }
};
const normalizeEmployeeListFaceImages = async (employeeList = []) => {
const normalizeEmployeeListFaceImages = async (employeeList = [], localFaceImageMap = new Map()) => {
  const normalizedEmployeeList = [];
  for (let index = 0; index < employeeList.length; index += 1) {
    const employee = employeeList[index];
    // 串行处理 base64 转本地文件,避免 Promise.all 并发解码大量图片时卡住 UI 线程
    const normalizedEmployee = await normalizeEmployeeFaceImage(employee, index);
    const normalizedEmployee = await normalizeEmployeeFaceImage(employee, index, localFaceImageMap);
    normalizedEmployeeList.push(normalizedEmployee);
    // 每处理一张图就主动让出一次线程,降低首页进入登记页时对 live-pusher 的影响
@@ -256,7 +263,24 @@
  const res = await getAllEmployeeVerifyAPI();
  if (res && res.code === 200) {
    const employeeList = normalizeEmployeeListResponse(res.data);
    const normalizedEmployeeList = await normalizeEmployeeListFaceImages(employeeList);
    // 获取当前本地缓存,用于与接口返回的最新数据进行比对
    const offlineEmployeeList = await getOfflineConfig(EMPLOYEE_VERIFY_LIST_KEY) || [];
    const storageEmployeeList = uni.getStorageSync(EMPLOYEE_VERIFY_LIST_KEY) || [];
    const localEmployeeList = Array.isArray(offlineEmployeeList) && offlineEmployeeList.length > 0
      ? offlineEmployeeList
      : (Array.isArray(storageEmployeeList) ? storageEmployeeList : []);
    // 构建人脸哈希映射表(包含已处理过的本地路径)
    const localFaceImageMap = new Map();
    localEmployeeList.forEach((emp) => {
      // 只有当本地路径是 _doc 开头的有效本地文件时,才放入复用池
      if (emp._faceImageHash && emp.faceImage && String(emp.faceImage).includes('_doc/')) {
        localFaceImageMap.set(emp._faceImageHash, emp.faceImage);
      }
    });
    const normalizedEmployeeList = await normalizeEmployeeListFaceImages(employeeList, localFaceImageMap);
    await saveEmployeeVerifyListCache(normalizedEmployeeList);
    return normalizedEmployeeList;
  }
@@ -285,8 +309,26 @@
  });
};
export const syncEmployeeVerifyList = async () => {
export const syncEmployeeVerifyList = async (force = false) => {
  try {
    // 检查缓存是否过期
    const lastSyncTime = uni.getStorageSync(EMPLOYEE_VERIFY_LIST_LAST_SYNC_KEY) || 0;
    const isCacheExpired = (Date.now() - lastSyncTime) > SYNC_CACHE_TTL_MS;
    if (!force && !isCacheExpired) {
      // 缓存未过期,检查本地是否有数据
      const offlineEmployeeList = await getOfflineConfig(EMPLOYEE_VERIFY_LIST_KEY) || [];
      const storageEmployeeList = uni.getStorageSync(EMPLOYEE_VERIFY_LIST_KEY) || [];
      const localEmployeeList = Array.isArray(offlineEmployeeList) && offlineEmployeeList.length > 0
        ? offlineEmployeeList
        : (Array.isArray(storageEmployeeList) ? storageEmployeeList : []);
      if (localEmployeeList.length > 0) {
        console.log('员工缓存未过期,跳过全量接口同步');
        return localEmployeeList;
      }
    }
    return await runEmployeeVerifyListSync();
  } catch (error) {
    console.error('同步员工验证列表失败:', error);
components/admin/AdminConfigPanel.vue
@@ -187,6 +187,7 @@
import saveIcon from '../../static/admin-pages/ySqSZ.png';
import closeIcon from '../../static/admin-pages/pDMhJ.png';
import logoutIcon from '../../static/admin-pages/uZNeV.png';
import { syncEmployeeVerifyList } from '../../api/employee';
import { deactivateVisitMachine } from '../../api/device';
import { getLatestVersion } from '../../api/visitorRecord';
import { clearRequestServerConfig, getRequestServerConfig, setRequestServerConfig } from '../../utils/request';
@@ -438,6 +439,13 @@
  try {
    isManualSyncing.value = true;
    // 强制触发员工档案全量同步,绕过缓存有效期
    try {
      await syncEmployeeVerifyList(true);
    } catch (e) {
      console.error('强制同步员工数据异常:', e);
    }
    const syncResult = await syncAllPendingRecords();
    if (syncResult.reason === 'offline') {
pages/admin-home/index.vue
@@ -152,7 +152,7 @@
        <view class="summary-top">
          <view class="summary-title-row">
            <view class="summary-dot checkin-dot"></view>
            <text class="summary-title checkin-title">已签到</text>
            <text class="summary-title checkin-title">已签离</text>
          </view>
          <view class="summary-accent checkin-accent"></view>
        </view>
pages/appointment-scan/n-index.nvue
@@ -1252,17 +1252,6 @@
  }
};
const refreshEmployeeVerifyListForHostVerify = async () => {
  try {
    // 仅在接待人验证发现本地列表为空时兜底刷新一次:
    // 联网时会自动拉最新员工并补齐本地图片缓存,离线时仍然回退本地缓存。
    employeeVerifyList.value = await getLocalEmployeeVerifyList();
    hostMatchedEmployee.value = findMatchedHostEmployee();
  } catch (error) {
    console.error('接待人验证前刷新员工验证列表失败:', error);
  }
};
const hours = Array.from({ length: 24 }, (_, index) => String(index).padStart(2, '0'));
const minutes = Array.from({ length: 60 }, (_, index) => String(index).padStart(2, '0'));
const provinceKeys = ['京', '津', '晋', '冀', '蒙', '辽', '吉', '黑', '沪', '苏', '浙', '皖', '闽', '赣', '鲁', '豫', '鄂', '湘', '粤', '桂', '琼', '渝', '川', '贵', '云', '藏', '陕', '甘', '青', '宁', '新', '无'];
@@ -2119,24 +2108,21 @@
const startHostVerify = async () => {
  hostMatchedEmployee.value = null;
  let candidateFaceEmployees = getHostFaceEmployees();
  if (candidateFaceEmployees.length === 0) {
    await refreshEmployeeVerifyListForHostVerify();
    candidateFaceEmployees = getHostFaceEmployees();
  }
  if (candidateFaceEmployees.length === 0) {
    openNoticeDialog(
      '未获取到员工人脸照片',
      '当前员工列表中没有可用于比对的人脸照片,请先同步员工档案'
    );
    return;
  }
  resetFacePreviewState().then(async () => {
    try {
      hostVerifyStatusText.value = '正在开启摄像头...';
      await ensureHostCameraContext();
      const candidateFaceEmployees = getHostFaceEmployees();
      if (candidateFaceEmployees.length === 0) {
        openNoticeDialog(
          '未获取到员工人脸照片',
          '当前员工列表中没有可用于比对的人脸照片,请联系管理员核实'
        );
        return;
      }
      hostVerifyStatusText.value = '请将脸部置于框内';
      // 只有判到人脸后,才进入正式抓拍和人脸比对
      startJudgeFacePolling({
        stepName: 'host',
@@ -2964,45 +2950,42 @@
    // showPageDebugModal('离线记录写入前', offlineRecord);
    await insertOfflineRecord(offlineRecord);
    console.log('离线记录保存成功');
    // uni.showModal({
    //   title: '离线保存诊断',
    //   content: `离线记录已存入本地数据库\nID: ${offlineRecord.id}\n类型: ${offlineRecord.type}`,
    //   showCancel: false
    // });
    // 2. 当前这单仍然要实时调用单次接口,但不能顺带触发历史离线数据的批量补发。
    //    因此这里只处理当前记录本身;若实时提交失败,保留离线记录等待后续统一同步。
    try {
      const submitPayload = {
        ...(offlineRecord.payload || {}),
      };
    //    这里将网络提交异步化,不阻塞主流程,保证页面秒进完成页。
    //    异步闭包内独立处理异常,即使进入成功页或退出当前页,接口依然在后台调用。
    (async () => {
      try {
        const submitPayload = {
          ...(offlineRecord.payload || {}),
        };
      if (offlineRecord.localImgPath) {
        const uploadRes = await uploadImage(offlineRecord.localImgPath);
        if (uploadRes && uploadRes.code === 200 && uploadRes.url) {
          if (offlineRecord.type === 'signout') {
            submitPayload.signImageUrl = uploadRes.url;
          } else {
            submitPayload.faceImageUrl = uploadRes.url;
          }
        } else {
          throw new Error('当前记录图片上传失败');
        }
      }
      const submitRes = offlineRecord.type === 'signout'
        ? await idCardSingOut(submitPayload)
        : await addVisitorRecord(submitPayload);
      if (submitRes && submitRes.code === 200) {
        await markRecordSynced(offlineRecord.id);
        if (offlineRecord.localImgPath) {
          await deleteLocalImage(offlineRecord.localImgPath);
          const uploadRes = await uploadImage(offlineRecord.localImgPath);
          if (uploadRes && uploadRes.code === 200 && uploadRes.url) {
            if (offlineRecord.type === 'signout') {
              submitPayload.signImageUrl = uploadRes.url;
            } else {
              submitPayload.faceImageUrl = uploadRes.url;
            }
          } else {
            throw new Error('当前记录图片上传失败');
          }
        }
        const submitRes = offlineRecord.type === 'signout'
          ? await idCardSingOut(submitPayload)
          : await addVisitorRecord(submitPayload);
        if (submitRes && submitRes.code === 200) {
          await markRecordSynced(offlineRecord.id);
          if (offlineRecord.localImgPath) {
            await deleteLocalImage(offlineRecord.localImgPath);
          }
        }
      } catch (submitError) {
        console.error('当前登记/签离实时后台提交失败,保留离线记录等待后续同步:', submitError);
      }
    } catch (submitError) {
      console.error('当前登记/签离实时提交失败,保留离线记录等待后续同步:', submitError);
    }
    })();
    return true;
  } catch (error) {
utils/syncEngine.js
@@ -134,15 +134,15 @@
          });
          submitRes = await idCardSingOut(item.payload);
          await new Promise((resolve) => {
            uni.showModal({
              title: '调试: 签离接口返回',
              content: `记录ID: ${item.recordId}\n结果: ${JSON.stringify(submitRes).substring(0, 500)}`,
              showCancel: false,
              success: resolve,
              fail: resolve
            });
          });
        //   await new Promise((resolve) => {
        //     uni.showModal({
        //       title: '调试: 签离接口返回',
        //       content: `记录ID: ${item.recordId}\n结果: ${JSON.stringify(submitRes).substring(0, 500)}`,
        //       showCancel: false,
        //       success: resolve,
        //       fail: resolve
        //     });
        //   });
        } else {
          // type 为 appointment, online, 或它们加上 _signedout 后缀时,都作为进场记录同步
          console.log('[Sync] 开始调用登记接口:', {
@@ -152,15 +152,15 @@
          });
          submitRes = await addVisitorRecord(item.payload);
          await new Promise((resolve) => {
            uni.showModal({
              title: '调试: 登记接口返回',
              content: `类型: ${item.type}\n记录ID: ${item.recordId}\n结果: ${JSON.stringify(submitRes).substring(0, 500)}`,
              showCancel: false,
              success: resolve,
              fail: resolve
            });
          });
        //   await new Promise((resolve) => {
        //     uni.showModal({
        //       title: '调试: 登记接口返回',
        //       content: `类型: ${item.type}\n记录ID: ${item.recordId}\n结果: ${JSON.stringify(submitRes).substring(0, 500)}`,
        //       showCancel: false,
        //       success: resolve,
        //       fail: resolve
        //     });
        //   });
        }
        console.log('[Sync] 单条业务接口返回:', {