zjk
2026-07-24 08995ec1791fb06b43b11da89a2675badd4533ba
pages/appointment-scan/n-index.nvue
@@ -651,11 +651,12 @@
import { HardwareAPI } from '../../utils/visitorDevice';
import { saveImageToLocal, deleteLocalImage } from '../../utils/fs';
import { getVisitAreaTree } from '../../api/visitArea';
import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, getOfflineConfig, saveOfflineConfig } from '../../utils/db';
import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, markRecordSynced, getOfflineConfig, saveOfflineConfig } from '../../utils/db';
import { buildBaseUrl, getRequestServerConfig } from '../../utils/request';
import { getLocalEmployeeVerifyList } from '../../api/employee';
import { getEmployeeVerifyListFromLocalCache, getLocalEmployeeVerifyList } from '../../api/employee';
import { getLocalSystemConfig } from '../../api/visitorConfig';
import { startSyncQueue } from '../../utils/syncEngine';
import { uploadImage } from '../../api/upload';
import { addVisitorRecord, idCardSingOut } from '../../api/visitorRecord';
import {
  parseHikvisionQrcodePayload,
  validateAppointmentQrcodePayload,
@@ -786,8 +787,41 @@
  countdown: 5,
});
let noticeDialogTimer = null;
let noticeDialogCloseHandler = null;
let appointmentScanResultHandler = null;
const INACTIVITY_AUTO_BACK_MS = 60 * 1000;
let inactivityAutoBackTimer = null;
let pageDebugModalQueue = Promise.resolve();
const formatPageDebugPayload = (payload) => {
  if (payload instanceof Error) {
    return payload.message;
  }
  if (typeof payload === 'string') {
    return payload;
  }
  try {
    const text = JSON.stringify(payload, null, 2);
    return text.length > 1600 ? `${text.slice(0, 1600)}\n...内容过长,已截断` : text;
  } catch (error) {
    return String(payload);
  }
};
const showPageDebugModal = (title, payload) => {
  const content = formatPageDebugPayload(payload);
  pageDebugModalQueue = pageDebugModalQueue.finally(() => new Promise((resolve) => {
    uni.showModal({
      title,
      content,
      showCancel: false,
      success: () => resolve(),
      fail: () => resolve(),
    });
  }));
};
const clearInactivityTimer = () => {
  if (inactivityAutoBackTimer) {
@@ -1208,7 +1242,8 @@
const loadEmployeeVerifyList = async () => {
  try {
    employeeVerifyList.value = await getLocalEmployeeVerifyList();
    // 根据要求,此处只读取本地缓存的员工数据,不再每次都触发网络同步和图片下载
    employeeVerifyList.value = await getEmployeeVerifyListFromLocalCache();
    hostMatchedEmployee.value = findMatchedHostEmployee();
  } catch (error) {
    console.error('读取本地员工验证列表失败:', error);
@@ -1275,7 +1310,7 @@
});
const receiverVerifyMethodValue = computed(() => {
  return shouldShowHostStep.value ? '2' : '';
  return shouldShowHostStep.value ? '人脸验证' : '刷卡验证';
});
const stepProgressSteps = computed(() => {
@@ -1362,21 +1397,49 @@
});
const configuredPrintFieldOptions = computed(() => {
  let options = [];
  if (Array.isArray(systemFlowConfig.value?.printFieldOptions) && systemFlowConfig.value.printFieldOptions.length > 0) {
    return systemFlowConfig.value.printFieldOptions;
    // 兼容后台配置可能使用 'beVisitor' 或 'interviewee' 作为被访者字段名
    options = systemFlowConfig.value.printFieldOptions.map(option => {
      if (option.fieldCode === 'beVisitor') {
        return { ...option, fieldCode: 'interviewee' };
      }
      return option;
    });
  } else {
    options = [
      { fieldCode: 'visitorName', fieldName: '访客姓名' },
      { fieldCode: 'idCard', fieldName: '身份证号' },
      { fieldCode: 'appointmentTimeRange', fieldName: '预约时间范围' },
      { fieldCode: 'interviewee', fieldName: '被访者' },
      { fieldCode: 'receiverName', fieldName: '接待人' },
      { fieldCode: 'visitorFaceImage', fieldName: '访客人脸图片' },
      { fieldCode: 'registerNo', fieldName: '登记编号' },
      { fieldCode: 'plateNo', fieldName: '车牌号' },
      { fieldCode: 'visitReason', fieldName: '来访事由' },
      { fieldCode: 'visitArea', fieldName: '访问区域' },
      { fieldCode: 'registerTime', fieldName: '登记时间' },
    ];
  }
  return [
    { fieldCode: 'visitorName', fieldName: '访客姓名' },
    { fieldCode: 'idCard', fieldName: '身份证号' },
    { fieldCode: 'appointmentTimeRange', fieldName: '预约时间范围' },
    { fieldCode: 'interviewee', fieldName: '被访者' },
    { fieldCode: 'visitorFaceImage', fieldName: '访客人脸图片' },
    { fieldCode: 'registerNo', fieldName: '登记编号' },
    { fieldCode: 'plateNo', fieldName: '车牌号' },
    { fieldCode: 'visitReason', fieldName: '来访事由' },
    { fieldCode: 'visitArea', fieldName: '访问区域' },
  ];
  // 如果有 printFields 数组,按照它的顺序对 options 进行重排
  if (Array.isArray(systemFlowConfig.value?.printFields) && systemFlowConfig.value.printFields.length > 0) {
    const printFieldsMap = systemFlowConfig.value.printFields.reduce((acc, fieldCode, index) => {
      // 兼容 beVisitor
      const normalizedCode = fieldCode === 'beVisitor' ? 'interviewee' : fieldCode;
      acc[normalizedCode] = index;
      return acc;
    }, {});
    // 将未在 printFields 中出现的字段排在最后
    options.sort((a, b) => {
      const orderA = printFieldsMap[a.fieldCode] !== undefined ? printFieldsMap[a.fieldCode] : 9999;
      const orderB = printFieldsMap[b.fieldCode] !== undefined ? printFieldsMap[b.fieldCode] : 9999;
      return orderA - orderB;
    });
  }
  return options;
});
// 手动录入状态
@@ -1604,9 +1667,32 @@
  selectedAreas.value = selectedAreas.value.filter((item) => item !== area);
};
const openNoticeDialog = (title, description = '') => {
const openVisitorFaceVerifyFailureDialog = (title, description = '') => {
  openNoticeDialog(title, description, {
    onClose: () => {
      if (currentStepName.value !== 'face') {
        return;
      }
      handleFaceVerifyNextStep();
    },
  });
};
const openHostFaceVerifyFailureDialog = (title, description = '') => {
  openNoticeDialog(title, description, {
    onClose: () => {
      if (currentStepName.value !== 'host') {
        return;
      }
      retryFaceVerify();
    },
  });
};
const openNoticeDialog = (title, description = '', options = {}) => {
  // 页面内直接维护弹窗状态与倒计时,避免额外组件通信带来的不确定性
  stopNoticeDialogCountdown();
  noticeDialogCloseHandler = typeof options?.onClose === 'function' ? options.onClose : null;
  noticeDialog.value = {
    visible: true,
    title,
@@ -1620,15 +1706,74 @@
const closeNoticeDialog = () => {
  stopNoticeDialogCountdown();
  const currentCloseHandler = noticeDialogCloseHandler;
  noticeDialogCloseHandler = null;
  noticeDialog.value = {
    ...noticeDialog.value,
    visible: false,
    countdown: noticeDialog.value.duration,
  };
  if (typeof currentCloseHandler === 'function') {
    currentCloseHandler();
  }
};
const normalizeIdentityCardNo = (value) => {
  return String(value || '').trim().toUpperCase();
};
const validateIdentityCardNo = (value) => {
  const normalizedValue = normalizeIdentityCardNo(value);
  // 手动录入场景统一只接受 18 位二代身份证,末位允许数字或 X。
  if (!/^\d{17}[\dX]$/.test(normalizedValue)) {
    return {
      valid: false,
      normalizedValue,
      message: '请输入正确的18位身份证号码',
    };
  }
  // 身份证中的出生日期必须是真实存在的日期,避免仅靠正则放过非法日期。
  const birthYear = Number(normalizedValue.slice(6, 10));
  const birthMonth = Number(normalizedValue.slice(10, 12));
  const birthDay = Number(normalizedValue.slice(12, 14));
  const birthDate = new Date(birthYear, birthMonth - 1, birthDay);
  const isValidBirthDate = birthDate.getFullYear() === birthYear
    && birthDate.getMonth() === birthMonth - 1
    && birthDate.getDate() === birthDay;
  if (!isValidBirthDate) {
    return {
      valid: false,
      normalizedValue,
      message: '身份证号码中的出生日期无效',
    };
  }
  // 校验最后一位校验码,避免录入了位数正确但内容错误的号码。
  const weightFactors = [7, 9, 10, 5, 8, 4, 2, 1, 6, 3, 7, 9, 10, 5, 8, 4, 2];
  const checksumMap = ['1', '0', 'X', '9', '8', '7', '6', '5', '4', '3', '2'];
  const checksum = normalizedValue
    .slice(0, 17)
    .split('')
    .reduce((sum, current, index) => sum + (Number(current) * weightFactors[index]), 0);
  const expectedCode = checksumMap[checksum % 11];
  if (normalizedValue.slice(-1) !== expectedCode) {
    return {
      valid: false,
      normalizedValue,
      message: '请输入正确的身份证号码',
    };
  }
  return {
    valid: true,
    normalizedValue,
    message: '',
  };
};
const getAppointmentQrIdentityCandidates = () => {
@@ -1817,11 +1962,11 @@
  const errorMsg = error && error.detail ? JSON.stringify(error.detail) : JSON.stringify(error);
  uni.showModal({
    title: '摄像头报错调试',
    content: errorMsg || '未知摄像头错误',
    showCancel: false
  });
//   uni.showModal({
//     title: '摄像头报错调试',
//     content: errorMsg || '未知摄像头错误',
//     showCancel: false
//   });
  console.error('摄像头预览初始化失败,详细错误信息:', JSON.stringify(error));
  if (error && error.detail) {
@@ -1958,48 +2103,26 @@
};
// 接待人人脸验证:
// - 预约/签离:按预约中的接待人信息精确匹配员工后做人脸比对
// - 现场登记:没有默认接待人,按所有带人脸照片的员工依次轮询比对,直到成功或全部失败
// - 统一按员工列表中所有带人脸照片的员工依次轮询比对
// - 预约场景仍保留刷卡时对指定接待人的精确校验,但纯人脸验证不再预先锁定某一个员工
const startHostVerify = async () => {
  // 接待人验证不再此处阻塞拉取员工列表,改为直接使用最新缓存
  // (在进入该页面前的 onLoad 或前置页面静默完成拉取)
  employeeVerifyList.value = await getLocalEmployeeVerifyList();
  hostMatchedEmployee.value = findMatchedHostEmployee();
  const isOnsiteHostVerify = mode.value === 'online';
  const matchedEmployee = isOnsiteHostVerify ? null : findMatchedHostEmployee();
  const candidateFaceEmployees = isOnsiteHostVerify ? getHostFaceEmployees() : [];
  hostMatchedEmployee.value = matchedEmployee;
  if (!isOnsiteHostVerify && !matchedEmployee) {
    openNoticeDialog(
      '未找到接待人员工信息',
      '请确认接待人信息与员工档案一致'
    );
    return;
  }
  const employeeFacePhotoAddress = isOnsiteHostVerify ? '' : resolveEmployeeFacePhotoAddress(matchedEmployee);
  if (!isOnsiteHostVerify && !employeeFacePhotoAddress) {
    openNoticeDialog(
      '未获取到接待人人脸照片',
      '当前接待人员工档案未配置人脸照片,请先完善员工资料'
    );
    return;
  }
  if (isOnsiteHostVerify && candidateFaceEmployees.length === 0) {
    openNoticeDialog(
      '未获取到员工人脸照片',
      '当前员工列表中没有可用于比对的人脸照片,请先同步员工档案'
    );
    return;
  }
  hostMatchedEmployee.value = null;
  resetFacePreviewState().then(async () => {
    try {
      hostVerifyStatusText.value = '正在开启摄像头...';
      await ensureHostCameraContext();
      const candidateFaceEmployees = getHostFaceEmployees();
      if (candidateFaceEmployees.length === 0) {
        openNoticeDialog(
          '未获取到员工人脸照片',
          '当前员工列表中没有可用于比对的人脸照片,请联系管理员核实'
        );
        return;
      }
      hostVerifyStatusText.value = '请将脸部置于框内';
      // 只有判到人脸后,才进入正式抓拍和人脸比对
      startJudgeFacePolling({
        stepName: 'host',
@@ -2013,12 +2136,7 @@
            }
            hostVerifyStatusText.value = '人脸比对中...';
            let hostCompareResult = null;
            if (isOnsiteHostVerify) {
              hostCompareResult = await compareHostFaceWithAllEmployees(livePhotoAddress);
            } else {
              hostCompareResult = await compareHostFaceWithEmployee(livePhotoAddress, matchedEmployee);
            }
            const hostCompareResult = await compareHostFaceWithAllEmployees(livePhotoAddress);
            if (sessionId !== judgeFaceSessionId) {
              return;
@@ -2026,11 +2144,9 @@
            if (!hostCompareResult?.passed) {
              hostVerifyStatusText.value = '接待人验证失败';
              openNoticeDialog(
              openHostFaceVerifyFailureDialog(
                '接待人验证失败,请重新验证',
                isOnsiteHostVerify
                  ? '未匹配到现场接待人员工,请重新验证'
                  : '请正视屏幕重新进行人脸认证'
                '请正视屏幕重新进行人脸认证'
              );
              return;
            }
@@ -2044,7 +2160,7 @@
            }
            hostVerifyStatusText.value = '接待人验证失败';
            openNoticeDialog(
            openHostFaceVerifyFailureDialog(
              resolveFaceVerifyErrorMessage(error),
              '请正视屏幕重新进行人脸认证'
            );
@@ -2171,6 +2287,7 @@
            // 手动录入身份证时没有证件照,跳过人证比对,仅保留现场人脸图
            if (isManualIdentityInput) {
              faceVerifyStatusText.value = '人脸采集成功';
              await waitBeforeEnterHostStep();
              nextStep();
              return;
            }
@@ -2198,7 +2315,7 @@
            if (!isFaceVerifyPassed(faceResult)) {
              faceVerifyStatusText.value = '人证比对失败';
              openNoticeDialog(
              openVisitorFaceVerifyFailureDialog(
                '人证照片比对失败,请重新采集',
                '请正视屏幕重新进行人脸认证'
              );
@@ -2206,6 +2323,7 @@
            }
            faceVerifyStatusText.value = '人证比对成功';
            await waitBeforeEnterHostStep();
            nextStep();
          } catch (error) {
            if (sessionId !== judgeFaceSessionId) {
@@ -2213,7 +2331,7 @@
            }
            faceVerifyStatusText.value = '人证比对失败';
            openNoticeDialog(
            openVisitorFaceVerifyFailureDialog(
              resolveFaceVerifyErrorMessage(error),
              '请正视屏幕重新进行人脸认证'
            );
@@ -2241,6 +2359,29 @@
  startHostVerify();
};
const waitBeforeEnterHostStep = async () => {
  // 访客人脸验证成功后,统一停留 1 秒再进入接待人验证,给现场一个明确的成功反馈。
  if (currentStepName.value !== 'face' || !shouldShowHostStep.value) {
    return;
  }
  await new Promise((resolve) => {
    setTimeout(resolve, 1000);
  });
};
// const showQrcodeParseDebugModal = (scanData, decryptedResult = {}) => {
//   return new Promise((resolve) => {
//     uni.showModal({
//       title: '二维码解析结果',
//       content: `原始内容:${scanData}\n\n解密结果:${decryptedResult.plainText || '无'}\n\n解析对象:${JSON.stringify(decryptedResult.payload || {}, null, 2)}`,
//       showCancel: false,
//       success: () => resolve(),
//       fail: () => resolve(),
//     });
//   });
// };
const handleAppointmentQrcodeValidation = (payload) => {
  const validationResult = validateAppointmentQrcodePayload(payload);
  if (validationResult.valid) {
@@ -2250,15 +2391,15 @@
  if (validationResult.reason === 'out_of_time_range') {
    openNoticeDialog(
      '预约码不在进场时间内,请联系工作人员',
      '请核对预约时段信息,或前往前台获取现场协助'
      '请检查您的预约时间'
    );
    return false;
  }
  if (validationResult.reason === 'already_entered') {
    openNoticeDialog(
      '您已进场,请先签离',
      ''
      '该预约码已进场',
      '您已进场,请签离'
    );
    return false;
  }
@@ -2268,6 +2409,74 @@
    ''
  );
  return false;
};
const registerAppointmentScanHandler = () => {
  appointmentScanResultHandler = async (scanData) => {
    console.log('[AppointmentScan] 收到扫码结果:', {
      scanData,
      mode: mode.value,
      currentStepName: currentStepName.value,
    });
    // 页面内扫码回调会一直存在,只有预约登记二维码步骤才消费扫码结果。
    if (currentStepName.value !== 'qr' || mode.value !== 'appointment') {
      console.warn('[AppointmentScan] 扫码结果已忽略,未处于预约二维码步骤:', {
        mode: mode.value,
        currentStepName: currentStepName.value,
      });
      return;
    }
    if (!scanData) {
      console.warn('[AppointmentScan] 扫码结果为空,按未识别二维码处理');
      openNoticeDialog(
        '未识别二维码,请联系工作人员',
        ''
      );
      return;
    }
    resetInactivityTimer();
    try {
      const decryptedResult = await parseHikvisionQrcodePayload(scanData);
    //   await showQrcodeParseDebugModal(scanData, decryptedResult);
      // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用
      appointmentQrcodePayload.value = decryptedResult.payload;
      saveAppointmentQrPayload(decryptedResult.payload);
      console.log('扫码成功,原始预约码:', scanData);
      console.log('预约码解密结果:', decryptedResult.plainText);
      console.log('预约码解析对象:', decryptedResult.payload);
      if (!handleAppointmentQrcodeValidation(decryptedResult.payload)) {
        return;
      }
      nextStep();
    } catch (error) {
      console.error('预约码解密失败:', error);
      openNoticeDialog(
        '未识别二维码,请联系工作人员',
        ''
      );
    }
  };
  uni.$on('Hardware-Scan', appointmentScanResultHandler);
};
const unregisterAppointmentScanHandler = () => {
  if (appointmentScanResultHandler) {
    uni.$off('Hardware-Scan', appointmentScanResultHandler);
    appointmentScanResultHandler = null;
  }
};
const handlePrinterOutOfPaper = () => {
  openNoticeDialog('提示', '打印机缺纸,请联系管理员');
};
// -------------------- 区域数据加载 --------------------
@@ -2302,10 +2511,12 @@
// ----------------------------------------------------
onMounted(async () => {
  resetInactivityTimer();
  await loadSystemFlowConfig();
  await loadEmployeeVerifyList();
  await loadVisitAreas();
  clearAppointmentIdCardPayload();
  // 必须在页面挂载的第一时间注册扫码和读卡的事件监听,
  // 绝对不能放在 await 数据加载之后,否则会导致进入页面前几十秒内刷卡/扫码无反应!
  registerAppointmentScanHandler();
  uni.$on('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  syncReadCardByStep(currentStepName.value);
  // 首页扫码成功后会直接跳到预约登记的身份证步骤,这里需要保留并恢复之前已缓存的二维码数据,
  // 否则后续身份证一致性校验会因为拿不到 visitorIdCard 而被误判为不一致。
@@ -2318,79 +2529,23 @@
  hasMountedHardwareFlow = true;
  // 页面加载后立即开启身份证读卡监听。
  // 只有当前处于身份证读卡步骤或接待人验证步骤时,才消费回调结果。
  HardwareAPI.startReadCard(async (cardData) => {
    resetInactivityTimer();
    if (currentStepName.value === 'host') {
      const hostCardVerifyResult = verifyHostEmployeeCard(cardData);
  await loadSystemFlowConfig();
  await loadEmployeeVerifyList();
  await loadVisitAreas();
  clearAppointmentIdCardPayload();
      if (hostCardVerifyResult.reason === 'card_no_missing') {
        return;
      }
  // 首屏硬件动作:这里只需触发人脸和打印等主动调用型动作,
  // 扫码和读卡的监听已提至 onMounted 顶部,避免被上述 await 阻塞
  if (currentStepName.value === 'face') {
    startFaceVerify();
  } else if (currentStepName.value === 'host') {
    startHostVerify();
  } else if (currentStepName.value === 'print') {
    setTimeout(() => {
      startPrintTicket();
    }, 500);
  }
      if (!hostCardVerifyResult.passed) {
        if (hostCardVerifyResult.reason === 'employee_not_found') {
          openNoticeDialog(
            '未找到接待人员工信息',
            '请确认首页已成功同步员工列表,且预约中的接待人信息与员工档案一致'
          );
          return;
        }
        if (hostCardVerifyResult.reason === 'employee_card_missing') {
          openNoticeDialog(
            '接待人员工未配置卡号',
            '当前员工档案缺少卡号,无法完成刷卡校验,请改用人脸验证'
          );
          return;
        }
        openNoticeDialog(
          '接待人卡号不匹配',
          '当前刷卡结果与接待人员工卡号不一致,请重新刷卡或改用人脸验证'
        );
        return;
      }
      await resetFacePreviewState();
      nextStep();
      return;
    }
    if (currentStepName.value !== 'idcard' || isManualInput.value) {
      return;
    }
    console.log('读卡成功,拿到身份证信息:', cardData);
    if (mode.value === 'signout') {
      const hasEntry = await verifySignoutEntry(cardData?.licId);
      if (!hasEntry) return;
    }
    if (mode.value === 'appointment' && !isAppointmentIdentityMatched(cardData)) {
      openNoticeDialog(
        '身份信息不一致',
        '请使用预约访客本人的身份证完成核验'
      );
      return;
    }
    if (mode.value === 'online' || mode.value === 'appointment') {
      const hasEntry = await verifyOnsiteEntry(cardData?.licId);
      if (hasEntry) return;
    }
    // 保存身份证识别结果,后续可用于身份校验和人脸照片比对
    appointmentIdCardPayload.value = cardData;
    saveAppointmentIdCardPayload(cardData);
    advanceToFaceStep();
  });
  // 首屏硬件动作不要依赖 init 回调,否则 init 卡住时扫码第一步不会触发
  triggerHardwareByStep(currentStepName.value);
  await syncScanLineAnimationByStep(currentStepName.value);
  // 初始化硬件插件
@@ -2403,11 +2558,12 @@
onUnmounted(() => {
  clearInactivityTimer();
  unregisterAppointmentScanHandler();
  uni.$off('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  dismissNameInput();
  stopScanLineAnimation();
  stopNoticeDialogCountdown();
  // 扫码停止统一收口到应用关闭时调用,页面销毁时不主动 stop
  HardwareAPI.stopReadCard();
  stopReadCardListener();
  HardwareAPI.stopJudgeFace();
  HardwareAPI.stopFaceVerify();
  stopLivePreview(faceCameraContext);
@@ -2470,52 +2626,7 @@
// 按步骤统一拉起对应的硬件动作,避免逻辑散落在多个 watch / onMounted 里
const triggerHardwareByStep = (stepName) => {
  // 只有预约登记第一步才需要扫码
  if (mode.value === 'appointment' && stepName === 'qr') {
    HardwareAPI.startScan(async (scanData) => {
      // 不再在步骤切换时主动 stopScan,所以这里只允许二维码步骤消费扫码结果
      if (currentStepName.value !== 'qr' || mode.value !== 'appointment') {
        return;
      }
      resetInactivityTimer();
      try {
        const decryptedResult = await parseHikvisionQrcodePayload(scanData);
        // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用
        appointmentQrcodePayload.value = decryptedResult.payload;
        saveAppointmentQrPayload(decryptedResult.payload);
        console.log('扫码成功,原始预约码:', scanData);
        console.log('预约码解密结果:', decryptedResult.plainText);
        console.log('预约码解析对象:', decryptedResult.payload);
        // await new Promise((resolve) => {
        //   uni.showModal({
        //     title: '二维码解析结果',
        //     content: `解析对象:${JSON.stringify(decryptedResult.payload || {}, null, 2)}`,
        //     showCancel: false,
        //     success: () => resolve(),
        //     fail: () => resolve(),
        //   });
        // });
        if (!handleAppointmentQrcodeValidation(decryptedResult.payload)) {
          return;
        }
        nextStep();
      } catch (error) {
        console.error('预约码解密失败:', error);
        openNoticeDialog(
          '未识别二维码,请联系工作人员',
          ''
        );
      }
    });
    return;
  }
  syncReadCardByStep(stepName);
  if (stepName === 'face') {
    // 访客进入人脸步骤后自动开始判脸和比对
@@ -2532,6 +2643,68 @@
    setTimeout(() => {
      startPrintTicket();
    }, 500);
  }
};
let isReadCardListening = false;
const shouldEnableReadCardByStep = (stepName) => {
  return stepName === 'idcard';
};
const handleReadCardResult = async (cardData) => {
  resetInactivityTimer();
  if (currentStepName.value !== 'idcard' || isManualInput.value) {
    return;
  }
  console.log('读卡成功,拿到身份证信息:', cardData);
  if (mode.value === 'signout') {
    const hasEntry = await verifySignoutEntry(cardData?.licId);
    if (!hasEntry) return;
  }
  if (mode.value === 'appointment' && !isAppointmentIdentityMatched(cardData)) {
    openNoticeDialog(
      '身份信息不一致',
      '请使用预约访客本人的身份证完成核验'
    );
    return;
  }
  if (mode.value === 'online' || mode.value === 'appointment') {
    const hasEntry = await verifyOnsiteEntry(cardData?.licId);
    if (hasEntry) return;
  }
  // 保存身份证识别结果,后续可用于身份校验和人脸照片比对
  appointmentIdCardPayload.value = cardData;
  saveAppointmentIdCardPayload(cardData);
  advanceToFaceStep();
};
const startReadCardListener = () => {
  if (isReadCardListening) {
    return;
  }
  uni.$on('Hardware-ReadCard', handleReadCardResult);
  isReadCardListening = true;
};
const stopReadCardListener = () => {
  if (!isReadCardListening) {
    return;
  }
  uni.$off('Hardware-ReadCard', handleReadCardResult);
  isReadCardListening = false;
};
const syncReadCardByStep = (stepName) => {
  if (shouldEnableReadCardByStep(stepName)) {
    startReadCardListener();
  }
};
@@ -2620,13 +2793,25 @@
const confirmManualInput = async () => {
  if (idNumber.value.length > 0) {
    showMissingEntryHint.value = false;
    if (mode.value === 'online' && !manualName.value.trim()) {
      uni.showToast({ title: '请输入姓名', icon: 'none' });
      return;
    }
    const identityValidationResult = validateIdentityCardNo(idNumber.value);
    if (!identityValidationResult.valid) {
      uni.showToast({ title: identityValidationResult.message, icon: 'none' });
      return;
    }
    // 统一把末位 x 转成大写 X,避免后续查库、比对和打印链路出现大小写不一致。
    const normalizedIdNumber = identityValidationResult.normalizedValue;
    idNumber.value = normalizedIdNumber;
    if (mode.value === 'signout') {
      const hasEntry = await verifySignoutEntry(idNumber.value);
      const hasEntry = await verifySignoutEntry(normalizedIdNumber);
      if (!hasEntry) {
        showMissingEntryHint.value = true;
        return;
@@ -2635,7 +2820,7 @@
    if (mode.value === 'online' || mode.value === 'appointment') {
      const manualIdCardPayload = {
        licId: idNumber.value,
        licId: normalizedIdNumber,
        name: mode.value === 'online' ? manualName.value.trim() : '',
        visitorName: mode.value === 'online' ? manualName.value.trim() : '', // 同时赋值给 visitorName 以防其他地方读取
        photoAddress: '',
@@ -2650,7 +2835,7 @@
        return;
      }
      const hasEntry = await verifyOnsiteEntry(idNumber.value);
      const hasEntry = await verifyOnsiteEntry(normalizedIdNumber);
      if (hasEntry) {
        return;
      }
@@ -2665,7 +2850,7 @@
    }
    const manualIdCardPayload = {
      licId: idNumber.value,
      licId: normalizedIdNumber,
      name: mode.value === 'online' ? manualName.value.trim() : '',
      visitorName: mode.value === 'online' ? manualName.value.trim() : '', // 同时赋值给 visitorName 以防其他地方读取
      photoAddress: '',
@@ -2702,6 +2887,10 @@
      : (appointmentQrcodePayload.value?.beVisitorName || '');
    const plateNo = plateNumber.value || '';
    const receiverVerifyMethod = receiverVerifyMethodValue.value;
    // 登记场景的接待人姓名统一优先取本次接待人验证实际命中的员工姓名
    const receiverName = matchedHostName || appointmentQrcodePayload.value?.beVisitorName || '';
    // 统一使用本地时间格式,避免 toISOString() 转成 UTC 后导致时区偏差
    const nowFormat = formatStandardDateTime(new Date());
    let validEndTime = '';
    if (mode.value === 'online') {
@@ -2710,15 +2899,20 @@
      validEndTime = appointmentQrcodePayload.value?.endTime || '';
    }
    const validStartTime = appointmentQrcodePayload.value?.startTime || '';
    const validStartTime = mode.value === 'online'
      ? nowFormat
      : (appointmentQrcodePayload.value?.startTime || '');
    const visitAreaIds = mode.value === 'online' ? selectedAreas.value.join(',') : '';
    const visitAreaNames = mode.value === 'appointment' ? (appointmentQrcodePayload.value?.areas || '') : '';
    const visitReason = appointmentQrcodePayload.value?.visitPurpose || '';
    const registerType = mode.value === 'appointment' ? 'APPOINTMENT' : 'ONSITE';
    let formData = {};
    // 统一使用本地时间格式,避免 toISOString() 转成 UTC 后导致时区偏差
    const nowFormat = formatStandardDateTime(new Date());
    const recordLocalImagePath = String(
      appointmentIdCardPayload.value?.livePhotoAddress
      || liveFacePhotoPath.value
      || ''
    ).trim();
    if (mode.value === 'signout') {
      formData = {
@@ -2726,8 +2920,12 @@
        intervieweeName,
        receiverVerifyMethod,
        signImageUrl: '', // 由 syncEngine 负责上传并回填
        signReceiverName: shouldShowHostStep.value ? (intervieweeName || appointmentHostName.value) : '',
        signVerifyMethod: receiverVerifyMethod,
        // 签离场景优先使用本次接待人验证实际命中的员工姓名,确保上传字段与验证结果一致
        signReceiverName: shouldShowHostStep.value
          ? (matchedHostName || intervieweeName || appointmentHostName.value)
          : '',
        // 签离核验方式固定按访客本人当前的人脸核验结果上报
        signVerifyMethod: '人脸验证',
        signTime: nowFormat // 增加签离时间字段供离线接口使用
      };
@@ -2750,6 +2948,7 @@
        visitReason,
        visitorName,
        registerType,
      receiverName,
        registerTime: nowFormat // 增加签到时间字段供离线接口使用
      };
    }
@@ -2758,28 +2957,59 @@
    const offlineRecord = {
      id: `record_${Date.now()}`,
      type: mode.value,
      localImgPath: liveFacePhotoPath.value || '',
      localImgPath: recordLocalImagePath,
      payload: formData,
    };
    console.log('[CheckIn] 准备写入离线记录:', offlineRecord);
    // showPageDebugModal('离线记录写入前', offlineRecord);
    await insertOfflineRecord(offlineRecord);
    console.log('离线记录保存成功');
    // uni.showModal({
    //   title: '离线保存诊断',
    //   content: `离线记录已存入本地数据库\nID: ${offlineRecord.id}\n类型: ${offlineRecord.type}`,
    //   showCancel: false
    // });
    // 2. 触发同步队列(联网时会自动上传图片并调用接口)
    startSyncQueue();
    // 2. 当前这单仍然要实时调用单次接口,但不能顺带触发历史离线数据的批量补发。
    //    这里将网络提交异步化,不阻塞主流程,保证页面秒进完成页。
    //    异步闭包内独立处理异常,即使进入成功页或退出当前页,接口依然在后台调用。
    (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);
          }
        }
      } catch (submitError) {
        console.error('当前登记/签离实时后台提交失败,保留离线记录等待后续同步:', submitError);
      }
    })();
    return true;
  } catch (error) {
    console.error('保存记录失败:', error);
    uni.showModal({
      title: '离线保存失败',
      content: `存入数据库报错: ${error.message || JSON.stringify(error)}`,
      showCancel: false
    });
    // uni.showModal({
    //   title: '离线保存失败',
    //   content: `存入数据库报错: ${error.message || JSON.stringify(error)}`,
    //   showCancel: false
    // });
    return false;
  }
};
@@ -2890,8 +3120,35 @@
  return '无';
};
const getPrintRegisterTime = () => {
  const now = new Date();
  const year = now.getFullYear();
  const month = String(now.getMonth() + 1).padStart(2, '0');
  const day = String(now.getDate()).padStart(2, '0');
  const hours = String(now.getHours()).padStart(2, '0');
  const minutes = String(now.getMinutes()).padStart(2, '0');
  const seconds = String(now.getSeconds()).padStart(2, '0');
  return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`;
};
const getPrintFieldValueMap = () => {
  const hostName = appointmentQrcodePayload.value?.beVisitorName || '无';
  // 综合现场登记和预约登记两种模式,获取接待人(被访人)姓名
  const matchedHostName = getFirstFilledValue(hostMatchedEmployee.value || {}, [
    'employeeName',
    'name',
    'userName',
    'staffName',
    'realName',
  ]);
  // 被访人(interviewee):现场登记优先用人脸匹配名,预约登记严格用二维码带的名
  const hostName = mode.value === 'online'
    ? (matchedHostName || '无')
    : (appointmentQrcodePayload.value?.beVisitorName || matchedHostName || '无');
  // 接待人(receiverName):无论什么模式,绝对优先取实际人脸匹配到的真实员工姓名
  const actualReceiverName = matchedHostName || appointmentQrcodePayload.value?.beVisitorName || '无';
  const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无');
  // 打印时优先使用已采集到的现场人脸图,只有没有抓拍图时才回退到身份证照片
  const photoPath = appointmentIdCardPayload.value?.livePhotoAddress
@@ -2936,6 +3193,14 @@
    visitArea: {
      type: 'text',
      value: areas || '无',
    },
    receiverName: {
      type: 'text',
      value: actualReceiverName,
    },
    registerTime: {
      type: 'text',
      value: getPrintRegisterTime(),
    },
  };
};
@@ -3003,6 +3268,9 @@
    goNext();
  } catch (error) {
    console.error('打印失败:', error);
    if (error?.code === 2) {
      return;
    }
    openNoticeDialog('打印失败', '请检查打印机状态或纸张是否充足');
  }
};