feat: 优化多模块图片处理、离线同步与页面业务逻辑,修复已知问题

- 上传模块:新增URL归一化工具函数,清理上传返回的图片地址首尾空格与非法反引号
- 设备初始化页面:修复指纹获取失败的默认值,放开被注释的设备激活接口调用
- 预约扫描页面:优化人脸抓拍图清理逻辑,调整打印图片优先级,优化比对状态文案
- 员工接口模块:重构base64图片处理流程,新增脏数据清理逻辑,简化本地图片保存代码
- 首页:新增员工人脸同步调试弹窗,完善同步回调的错误处理与调试信息
- 同步引擎:移除冗余调试日志,修复同步payload处理逻辑,恢复图片上传同步功能
6个文件已修改
288 ■■■■■ 已修改文件
api/employee.js 97 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/upload.js 13 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/n-index.nvue 19 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/device-init/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/home/index.vue 77 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/syncEngine.js 66 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/employee.js
@@ -10,24 +10,46 @@
  });
};
const sanitizeBase64String = (value = '') => {
  return String(value || '')
    // 清掉常见的零宽字符、BOM、方向控制字符等隐藏脏数据
    .replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, '')
    .replace(/\s+/g, '')
    // 仅保留 Base64 合法字符,避免把 dataURL 头或其他脏字符带入原生解码
    .replace(/[^A-Za-z0-9+/=]/g, '');
};
const isBase64Image = (value = '') => {
  return /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(String(value || '').trim())
    || /^[A-Za-z0-9+/=\r\n]+$/.test(String(value || '').trim());
  const normalizedValue = String(value || '').trim();
  if (!normalizedValue) {
    return false;
  }
  if (/data:image\/[a-zA-Z0-9+.-]+;base64,/i.test(normalizedValue)) {
    return true;
  }
  return sanitizeBase64String(normalizedValue).length > 0;
};
const getBase64Payload = (value = '') => {
  const normalizedValue = String(value || '').trim();
  const dataUrlMatch = normalizedValue.match(/^data:image\/([a-zA-Z0-9+.-]+);base64,(.+)$/);
  const dataUrlMatch = normalizedValue.match(/data:image\/([a-zA-Z0-9+.-]+);base64,/i);
  if (dataUrlMatch) {
    const base64Header = dataUrlMatch[0];
    const headerStartIndex = normalizedValue.indexOf(base64Header);
    return {
      extension: dataUrlMatch[1] === 'jpeg' ? 'jpg' : dataUrlMatch[1],
      base64Data: dataUrlMatch[2],
      // 兼容 dataURL 前面混入调试字符、零宽字符、换行等脏数据
      base64Data: sanitizeBase64String(
        normalizedValue.slice(headerStartIndex + base64Header.length)
      ),
    };
  }
  return {
    extension: 'png',
    base64Data: normalizedValue.replace(/\s+/g, ''),
    base64Data: sanitizeBase64String(normalizedValue),
  };
};
@@ -40,52 +62,35 @@
    // #ifndef H5
    try {
      const { extension, base64Data } = getBase64Payload(base64Value);
      const fileName = `${fileNamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`;
      // 在 App 端使用原生的 plus.nativeObj.Bitmap 保存图片(最稳定,且能获取绝对路径供原生插件使用)
      if (typeof plus !== 'undefined') {
        const mimeType = extension === 'jpg' ? 'jpeg' : extension;
        const fullBase64 = `data:image/${mimeType};base64,${base64Data}`;
        const bitmap = new plus.nativeObj.Bitmap(`bmp_${Date.now()}`);
        bitmap.loadBase64Data(fullBase64, () => {
          const filePath = `_doc/${EMPLOYEE_FACE_IMAGE_DIR}/${fileName}`;
          bitmap.save(filePath, { overwrite: true }, (event) => {
            bitmap.clear();
            // 转换为安卓绝对路径 (file:///storage/...),原生人脸比对插件必须用绝对路径
            const absolutePath = plus.io.convertLocalFileSystemURL(event.target);
            resolve(absolutePath);
          }, (error) => {
            bitmap.clear();
            reject(error);
          });
        }, (error) => {
          reject(error);
        });
      const normalizedBase64Data = sanitizeBase64String(base64Data);
      if (!normalizedBase64Data) {
        reject(new Error('Base64 数据为空'));
        return;
      }
      // 非 App 环境兜底走 FileSystemManager
      const fs = uni.getFileSystemManager();
      const basePath = (uni.env && uni.env.USER_DATA_PATH) ? uni.env.USER_DATA_PATH : '_doc';
      const directoryPath = `${basePath}/${EMPLOYEE_FACE_IMAGE_DIR}`;
      const filePath = `${directoryPath}/${fileName}`;
      if (typeof plus === 'undefined' || !plus.nativeObj || !plus.nativeObj.Bitmap) {
        reject(new Error('当前环境不支持 plus.nativeObj.Bitmap'));
        return;
      }
      const ensureDirAndWrite = () => {
        fs.writeFile({
          filePath,
          data: base64Data,
          encoding: 'base64',
          success: () => resolve(filePath),
          fail: (error) => reject(error),
      const fileName = `${fileNamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension || 'png'}`;
      const filePath = `_doc/${EMPLOYEE_FACE_IMAGE_DIR}/${fileName}`;
      const imageMimeType = extension === 'jpg' ? 'jpeg' : (extension || 'png');
      const imageStr = `data:image/${imageMimeType};base64,${normalizedBase64Data}`;
      const bitmap = new plus.nativeObj.Bitmap(`employee_face_${Date.now()}`);
      bitmap.loadBase64Data(imageStr, () => {
        bitmap.save(filePath, {}, (result) => {
          bitmap.clear();
          const savedFilePath = plus.io.convertLocalFileSystemURL(result.target || filePath);
          resolve(savedFilePath);
        }, (error) => {
          bitmap.clear();
          reject(error);
        });
      };
      fs.mkdir({
        dirPath: directoryPath,
        recursive: true,
        success: ensureDirAndWrite,
        fail: () => ensureDirAndWrite(),
      }, (error) => {
        bitmap.clear();
        reject(error);
      });
    } catch (error) {
      reject(error);
api/upload.js
@@ -1,5 +1,11 @@
import { buildBaseUrl, getRequestServerConfig } from '../utils/request';
const normalizeUploadUrl = (value = '') => {
  return String(value || '')
    .replace(/`/g, '')
    .trim();
};
export const uploadImage = (localFilePath) => {
  return new Promise((resolve, reject) => {
    const baseUrl = buildBaseUrl(getRequestServerConfig());
@@ -20,10 +26,13 @@
      success: (uploadFileRes) => {
        try {
          const data = JSON.parse(uploadFileRes.data);
          // 适配常见的两种返回结构: data.url 或 data.fileName
          const normalizedUrl = normalizeUploadUrl(
            data.url || data.fileName || data.data?.url || ''
          );
          resolve({
            code: data.code,
            url: data.url || data.fileName || data.data?.url
            url: normalizedUrl
          });
        } catch (e) {
          reject(e);
pages/appointment-scan/n-index.nvue
@@ -1688,13 +1688,20 @@
};
// 清理访客现场抓拍图,避免本地临时图片堆积
const cleanupLiveFacePhoto = async () => {
const cleanupLiveFacePhoto = async (options = {}) => {
  const { preserveIdentityLivePhoto = false } = options;
  if (!liveFacePhotoPath.value) {
    return;
  }
  const currentPath = liveFacePhotoPath.value;
  liveFacePhotoPath.value = '';
  // 人脸验证通过后,这张现场抓拍图还要继续用于后续打印,
  // 步骤切换时只清理引用,不删除实体文件;真正离开页面时再统一删除。
  if (preserveIdentityLivePhoto && currentPath === appointmentIdCardPayload.value?.livePhotoAddress) {
    return;
  }
  try {
    await deleteLocalImage(currentPath);
@@ -1730,7 +1737,7 @@
  facePreviewReady.value = false;
  // 中途只清理临时图片和上下文引用,真正的 stop 动作统一放到页面销毁时
  await Promise.allSettled([
    cleanupLiveFacePhoto(),
    cleanupLiveFacePhoto({ preserveIdentityLivePhoto: true }),
    cleanupHostLiveFacePhoto(),
  ]);
  faceCameraContext = null;
@@ -1974,7 +1981,7 @@
              return;
            }
            hostVerifyStatusText.value = '接待人人脸比对中...';
            hostVerifyStatusText.value = '人脸比对中...';
            let hostCompareResult = null;
            if (isOnsiteHostVerify) {
              hostCompareResult = await compareHostFaceWithAllEmployees(livePhotoAddress);
@@ -2850,7 +2857,11 @@
const getPrintFieldValueMap = () => {
  const hostName = appointmentQrcodePayload.value?.beVisitorName || '无';
  const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无');
  const photoPath = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress || '';
  // 打印时优先使用已采集到的现场人脸图,只有没有抓拍图时才回退到身份证照片
  const photoPath = appointmentIdCardPayload.value?.livePhotoAddress
    || liveFacePhotoPath.value
    || appointmentIdCardPayload.value?.photoAddress
    || '';
  return {
    visitorName: {
pages/device-init/index.vue
@@ -24,7 +24,7 @@
    try {
        deviceFingerprint.value = await HardwareAPI.getDeviceFingerprint();
    } catch (error) {
        deviceFingerprint.value = '11111';
        deviceFingerprint.value = '';
        uni.showToast({
            title: '百度SDK指纹获取失败',
            icon: 'none',
@@ -133,14 +133,14 @@
        });
        // 激活接口只关心业务参数,地址由公共请求层统一处理
        // await activateVisitMachine({
        //     machineId,
        //     fingerprint: deviceFingerprint.value,
        // });
        await activateVisitMachine({
            machineId,
            fingerprint: deviceFingerprint.value,
        });
        // await HardwareAPI.offlineActivateDevice({
        //     code: deviceFingerprint.value,
        // });
        await HardwareAPI.offlineActivateDevice({
            code: deviceFingerprint.value,
        });
        uni.setStorageSync('device_id', machineId);
        uni.setStorageSync('device_fingerprint', deviceFingerprint.value);
pages/home/index.vue
@@ -165,6 +165,66 @@
  noticeDialog.value.visible = false;
};
const isBase64ImageValue = (value = '') => {
  const normalizedValue = String(value || '').trim();
  if (!normalizedValue) {
    return false;
  }
  return /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(normalizedValue)
    || /^[A-Za-z0-9+/=\r\n]+$/.test(normalizedValue);
};
const isLocalEmployeeFacePath = (value = '') => {
  const normalizedValue = String(value || '').trim();
  if (!normalizedValue) {
    return false;
  }
  return normalizedValue.includes('employee-face-images')
    || normalizedValue.startsWith('_doc/')
    || normalizedValue.startsWith('file:///')
    || normalizedValue.startsWith('/storage/');
};
const formatEmployeeSyncDebugMessage = (employeeList = []) => {
  const list = Array.isArray(employeeList) ? employeeList : [];
  const withFaceEmployees = list.filter((employee) => String(employee?.faceImage || '').trim() !== '');
  const localPathEmployees = withFaceEmployees.filter((employee) => isLocalEmployeeFacePath(employee?.faceImage));
  const base64Employees = withFaceEmployees.filter((employee) => isBase64ImageValue(employee?.faceImage));
  const successLines = localPathEmployees
    .slice(0, 5)
    .map((employee, index) => {
      const faceImage = String(employee?.faceImage || '').trim();
      return `${index + 1}. ${employee?.employeeName || employee?.name || '未命名员工'}: ${faceImage}`;
    });
  const sampleLines = withFaceEmployees
    .filter((employee) => !isLocalEmployeeFacePath(employee?.faceImage))
    .slice(0, 5)
    .map((employee, index) => {
      const faceImage = String(employee?.faceImage || '').trim();
      const previewValue = faceImage ? faceImage.slice(0, 60) : '空';
      return `${index + 1}. ${employee?.employeeName || employee?.name || '未命名员工'}: ${previewValue}`;
    });
  return [
    `员工总数:${list.length}`,
    `有人脸照片:${withFaceEmployees.length}`,
    `已转本地路径:${localPathEmployees.length}`,
    `仍是Base64:${base64Employees.length}`,
    successLines.length > 0 ? `成功样本:\n${successLines.join('\n')}` : '成功样本:无',
    sampleLines.length > 0 ? `异常样本:\n${sampleLines.join('\n')}` : '异常样本:无',
  ].join('\n');
};
const showEmployeeSyncDebugModal = (title, content) => {
  uni.showModal({
    title,
    content,
    showCancel: false,
  });
};
const initHomeScanner = () => {
  homeScanSessionId += 1;
  const currentSessionId = homeScanSessionId;
@@ -246,9 +306,20 @@
  // 员工图片 base64 转本地文件比较重,放到首页后台静默处理,避免阻塞后续 live-pusher 拉起
  setTimeout(() => {
    syncEmployeeVerifyList().catch((error) => {
      console.error('首页后台同步员工验证列表失败:', error);
    });
    syncEmployeeVerifyList()
      .then((employeeList) => {
        showEmployeeSyncDebugModal(
          '员工人脸转换结果',
          formatEmployeeSyncDebugMessage(employeeList)
        );
      })
      .catch((error) => {
        console.error('首页后台同步员工验证列表失败:', error);
        showEmployeeSyncDebugModal(
          '员工人脸转换失败',
          String(error?.message || error || '未知错误')
        );
      });
  }, 200);
};
utils/syncEngine.js
@@ -16,8 +16,6 @@
 */
export const startSyncQueue = async () => {
  if (isSyncing) {
    console.log('[Sync] 当前正在同步中,跳过本次触发');
    // uni.showModal({ title: '同步诊断', content: '当前已有同步任务正在进行中,跳过本次触发', showCancel: false });
    return;
  }
@@ -30,26 +28,20 @@
  });
  if (networkType === 'none') {
    console.log('[Sync] 当前无网络,取消同步');
    // uni.showModal({ title: '同步诊断', content: '检测到当前无网络 (networkType: none),取消同步', showCancel: false });
    return;
  }
  isSyncing = true;
  console.log('[Sync] 开始执行同步队列...');
  try {
    let pendingRecords = [];
    try {
      pendingRecords = await getPendingSyncRecords();
    } catch (dbErr) {
    //   uni.showModal({ title: '获取待同步数据失败', content: JSON.stringify(dbErr), showCancel: false });
      return;
    }
    if (pendingRecords.length === 0) {
      console.log('[Sync] 无待同步记录');
    //   uni.showModal({ title: '同步诊断', content: '数据库中查询不到待同步记录', showCancel: false });
      isSyncing = false;
      return;
    }
@@ -59,25 +51,19 @@
    const signoutDataToPull = [];
    const recordsToSync = [];
    // uni.showModal({
    //   title: '调试日志 (同步前)',
    //   content: `共查询到 ${pendingRecords.length} 条待同步记录`,
    //   showCancel: false
    // });
    for (let record of pendingRecords) {
      // 如果创建时间距离当前超过 5 分钟,认为是离线堆积的旧数据,走批量 pull 接口
      const isOfflineOldData = Date.now() - record.create_time > 5 * 60 * 1000;
      try {
        let payload = record.payload;
        let payload = {
          ...(record.payload || {}),
        };
        // [暂时跳过图片上传测试]
        // 1. 如果存在本地图片,先上传图片
        /*
        // 1. 如果存在本地图片,先上传图片,再把云端地址回填到业务 payload
        if (record.local_img_path) {
          const uploadRes = await uploadImage(record.local_img_path);
          if (uploadRes && uploadRes.code === 200) {
          if (uploadRes && uploadRes.code === 200 && uploadRes.url) {
            // 根据记录类型,将云端 URL 赋值给不同的业务 payload 字段
            if (record.type === 'signout') {
              payload.signImageUrl = uploadRes.url;
@@ -88,7 +74,6 @@
            throw new Error('图片上传失败');
          }
        }
        */
        if (isOfflineOldData) {
          // 离线旧数据收集到数组中,准备批量提交
@@ -103,54 +88,27 @@
        }
      } catch (innerErr) {
        console.error(`[Sync] 处理记录 ${record.id} 的图片或数据时失败:`, innerErr);
        // 通过弹窗暴露底层失败原因
        // uni.showModal({
        //   title: '离线同步诊断 (图片/前置)',
        //   content: `同步记录被阻断:\n${innerErr.message || JSON.stringify(innerErr)}`,
        //   showCancel: false
        // });
        // 如果处理单条前置(如传图)失败,跳过该条,等待下次轮询
      }
    }
    // uni.showModal({
    //   title: '调试日志 (分流结果)',
    //   content: `单条实时: ${recordsToSync.length}条\n批量签到: ${signinDataToPull.length}条\n批量签离: ${signoutDataToPull.length}条`,
    //   showCancel: false
    // });
    // 2. 提交实时单条业务数据
    for (let item of recordsToSync) {
      try {
        let submitRes;
        if (item.type === 'signout') {
        //   uni.showModal({
        //     title: '调试日志 (单条同步)',
        //     content: `准备调用签离接口: /api/visitorRecord/idCardSingOut\n参数: ${JSON.stringify(item.payload)}`,
        //     showCancel: false
        //   });
          submitRes = await idCardSingOut(item.payload);
        } else {
          // type 为 appointment, online, 或它们加上 _signedout 后缀时,都作为进场记录同步
        //   uni.showModal({
        //     title: '调试日志 (单条同步)',
        //     content: `准备调用签到接口: /api/visitorRecord/add\n参数: ${JSON.stringify(item.payload)}`,
        //     showCancel: false
        //   });
          submitRes = await addVisitorRecord(item.payload);
        }
        if (submitRes && submitRes.code === 200) {
          await markRecordSynced(item.recordId);
          if (item.localImgPath) await deleteLocalImage(item.localImgPath);
          console.log(`[Sync] 记录 ${item.recordId} 同步成功 (单条实时)`);
        //   uni.showModal({ title: '单条同步成功', content: `记录 ${item.recordId} 同步成功`, showCancel: false });
        } else {
        //   uni.showModal({ title: '单条同步失败', content: `接口返回: ${JSON.stringify(submitRes)}`, showCancel: false });
        }
      } catch (err) {
        console.error(`[Sync] 单条同步 ${item.recordId} 失败:`, err);
        // uni.showModal({ title: '单条同步异常', content: `错误信息: ${err.message || JSON.stringify(err)}`, showCancel: false });
      }
    }
@@ -164,14 +122,9 @@
               await markRecordSynced(item.recordId);
               if (item.localImgPath) await deleteLocalImage(item.localImgPath);
            }
            console.log(`[Sync] 批量补发签到数据 ${signinDataToPull.length} 条成功`);
            // uni.showModal({ title: '批量签到同步成功', content: `成功同步 ${signinDataToPull.length} 条`, showCancel: false });
         } else {
            // uni.showModal({ title: '批量签到同步失败', content: `接口返回: ${JSON.stringify(pullRes)}`, showCancel: false });
         }
       } catch (err) {
         console.error('[Sync] 批量补发签到数据失败:', err);
        //  uni.showModal({ title: '批量签到同步异常', content: `错误信息: ${err.message || JSON.stringify(err)}`, showCancel: false });
       }
    }
@@ -185,20 +138,14 @@
               await markRecordSynced(item.recordId);
               if (item.localImgPath) await deleteLocalImage(item.localImgPath);
            }
            console.log(`[Sync] 批量补发签离数据 ${signoutDataToPull.length} 条成功`);
            // uni.showModal({ title: '批量签离同步成功', content: `成功同步 ${signoutDataToPull.length} 条`, showCancel: false });
         } else {
            // uni.showModal({ title: '批量签离同步失败', content: `接口返回: ${JSON.stringify(pullRes)}`, showCancel: false });
         }
       } catch (err) {
         console.error('[Sync] 批量补发签离数据失败:', err);
        //  uni.showModal({ title: '批量签离同步异常', content: `错误信息: ${err.message || JSON.stringify(err)}`, showCancel: false });
       }
    }
  } catch (error) {
    console.error('[Sync] 队列执行异常:', error);
    // uni.showModal({ title: '队列执行异常', content: `错误信息: ${error.message || JSON.stringify(error)}`, showCancel: false });
  } finally {
    isSyncing = false;
  }
@@ -208,8 +155,6 @@
 * 每日凌晨或空闲时清理超期废弃数据并同步最新配置字典
 */
export const runDailyCleanup = async () => {
  console.log('[Sync] 执行超期数据清理与基础数据同步...');
  // 1. 清理过期业务记录
  cleanOldRecords().catch(err => {
    console.error('[Sync] 清理过期记录失败:', err);
@@ -226,7 +171,6 @@
      const res = await getVisitAreaTree();
      if (res && res.code === 200 && Array.isArray(res.data)) {
        await saveOfflineConfig('visit_area_tree', res.data);
        console.log('[Sync] 每日同步:访问区域数据已更新至本地缓存');
      }
    }
  } catch (err) {