pages/appointment-scan/n-index.nvue
@@ -1,5 +1,5 @@
<template>
  <view class="page-container">
  <view class="page-container" :class="{ landscape: isLandscapeLayout }" @touchstart="resetInactivityTimer" @click="resetInactivityTimer">
    <!-- 左侧柔光氛围 -->
    <view class="bg-glow"></view>
@@ -18,38 +18,43 @@
    <!-- 步骤: 预约码验证 -->
    <template v-if="currentStepName === 'qr'">
      <view class="main-card">
        <text class="main-title">请将您的预约二维码放置到扫码位置</text>
        <text class="main-desc">保持二维码完整、清晰,距离扫码口约 10-20 厘米</text>
      <view class="main-card ls-main-card">
        <view class="ls-main-content">
          <view class="ls-text-panel">
            <view class="ls-title-group">
              <text class="main-title ls-main-title">请将您的预约二维码放置到扫码位置</text>
              <text class="main-desc ls-main-desc">保持二维码完整、清晰,距离扫码口约 10-20 厘米</text>
            </view>
            <text class="helper-text ls-helper-text">若识别失败,请调整手机亮度、避免反光,或联系工作人员协助</text>
          </view>
        <view class="device-area">
          <image :src="deviceScanImage" class="device-img" mode="aspectFit"></image>
          <view class="device-area ls-device-area">
            <image :src="deviceScanImage" class="device-img ls-device-img" mode="aspectFit"></image>
          </view>
        </view>
        <text class="helper-text">若识别失败,请调整手机亮度、避免反光,或联系工作人员协助</text>
      </view>
      <view class="bottom-area">
        <view class="help-card">
      <view class="bottom-area ls-bottom-area">
        <view class="help-card ls-help-card">
          <view class="highlight-bar"></view>
          <view class="card-title-row" style="margin-top: 16px;">
          <view class="card-title-row ls-card-title-row" style="margin-top: 16px;">
            <view class="icon-base">
              <image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">扫码小贴士</text>
          </view>
          <text class="help-content">请使用预约成功后收到的二维码,\n保持二维码完整显示后再靠近扫码区域</text>
          <text class="help-content ls-help-content">请使用预约成功后收到的二维码,\n保持二维码完整显示后再靠近扫码区域</text>
        </view>
        <view class="action-card">
        <view class="action-card ls-action-card">
          <view class="highlight-bar"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="returnIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">需要返回?</text>
          </view>
          <view class="main-btn" @click="goBack">
          <view class="main-btn ls-main-btn" @click="goBack">
            <text class="btn-text">返回上一步</text>
          </view>
        </view>
@@ -59,45 +64,50 @@
    <!-- 步骤: 身份证验证 -->
    <template v-else-if="currentStepName === 'idcard'">
      <template v-if="!isManualInput">
        <view class="main-card">
          <text class="main-title">请将您的身份证放置读卡器位置</text>
          <text class="main-desc">请将身份证贴近设备感应区,系统将自动读取身份信息</text>
        <view class="main-card ls-main-card">
          <view class="ls-main-content">
            <view class="ls-text-panel">
              <view class="title-group ls-title-group">
                <text class="main-title ls-main-title">请将您的身份证放置读卡器位置</text>
                <text class="main-desc ls-main-desc">请将身份证贴近设备感应区,系统将自动读取身份信息</text>
                <view class="name-pill" v-if="mode === 'appointment'">
                  <view class="name-icon">
                    <text class="name-icon-text">人</text>
                  </view>
                  <text class="name-text">姓名:{{ appointmentVisitorName }}</text>
                </view>
              </view>
          <view class="name-hint-row" v-if="mode === 'appointment'">
            <view class="name-icon-box">
              <text class="name-icon-text">人</text>
              <text class="helper-text ls-helper-text">若识别失败,请联系工作人员协助</text>
            </view>
            <text class="name-text">姓名:{{ appointmentVisitorName }}</text>
          </view>
          <view class="device-area">
            <image :src="deviceIdScanImage" class="device-img" mode="aspectFit"></image>
            <view class="device-area ls-device-area">
              <image :src="deviceIdScanImage" class="device-img ls-device-img" mode="aspectFit"></image>
            </view>
          </view>
          <text class="helper-text">若识别失败,请联系工作人员协助</text>
        </view>
        <view class="bottom-area">
          <view class="help-card">
        <view class="bottom-area ls-bottom-area">
          <view class="help-card ls-help-card">
            <view class="highlight-bar"></view>
            <view class="card-title-row">
            <view class="card-title-row ls-card-title-row">
              <view class="icon-base">
                <image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
              </view>
              <text class="card-title">读卡小贴士</text>
            </view>
            <text class="help-content">请使用二代身份证原件放至读卡区位置</text>
            <text class="help-content ls-help-content">请使用二代身份证原件放至读卡区位置</text>
          </view>
          <view class="action-card" v-if="allowManualIdCard">
          <view class="action-card ls-action-card" v-if="allowManualIdCard">
            <view class="highlight-bar"></view>
            <view class="card-title-row">
            <view class="card-title-row ls-card-title-row">
              <view class="icon-base">
                <image :src="keyboardIconImage" class="icon-base-img" mode="aspectFit"></image>
              </view>
              <text class="card-title">未携带身份证?</text>
            </view>
            <view class="main-btn" @click="openManualInput">
            <view class="main-btn ls-main-btn" @click="openManualInput">
              <text class="btn-text">手动录入信息</text>
            </view>
          </view>
@@ -106,13 +116,14 @@
      <!-- 手动录入信息 UI -->
      <template v-else>
        <view class="main-card manual-card" @click="dismissNameInput">
        <view class="manual-layout-wrapper" :class="{ landscape: isLandscapeLayout }">
          <view class="main-card manual-card" @click="dismissNameInput">
          <text class="main-title">手动录入身份证号</text>
          <text class="error-hint" v-if="mode === 'signout' && showMissingEntryHint">未检测到您的进场信息,请检查身份证号码是否正确</text>
          <view class="input-form-card" @click.stop>
            <template v-if="mode === 'online'">
              <view class="input-box" style="height: 118px;" @touchstart.stop="handleNameInputBoxTouchStart" @click.stop>
              <view class="manual-input-box" style="height: 118px;" @touchstart.stop="handleNameInputBoxTouchStart" @click.stop>
                <input
                  type="text"
                  class="input-text real-input"
@@ -127,7 +138,7 @@
              </view>
            </template>
            <view
              class="input-box"
              class="manual-input-box"
              style="height: 118px; position: relative;"
              @touchstart.stop="handleIdInputBoxClick"
            >
@@ -165,6 +176,7 @@
          </view>
        </view>
        <!-- 自定义键盘 -->
        <view class="custom-keyboard" v-if="showCustomKeyboard">
          <view class="keyboard-grid">
@@ -190,15 +202,18 @@
            </view>
          </view>
        </view>
      </view>
      </template>
    </template>
    <!-- 步骤: 人脸验证 -->
    <template v-else-if="currentStepName === 'face'">
      <view class="main-card">
        <view class="title-group">
          <text class="main-title">请面向屏幕完成人脸验证</text>
          <text class="sub-title">请摘下口罩和帽子,保持面部位于取景框内并正视摄像头</text>
      <view class="main-card ls-main-card">
        <view class="ls-main-content">
          <view class="ls-text-panel">
            <view class="title-group ls-title-group">
          <text class="main-title ls-main-title">请面向屏幕完成人脸验证</text>
          <text class="sub-title ls-main-desc">请摘下口罩和帽子,保持面部位于取景框内并正视摄像头</text>
          <view class="name-pill">
            <view class="name-icon">
              <text class="name-icon-text">人</text>
@@ -207,7 +222,9 @@
          </view>
        </view>
        <view class="face-capture-card">
        <text class="bottom-subtext ls-helper-text">系统将自动完成人脸采集与比对,请勿频繁移动或离开识别区域</text>
          </view>
          <view class="face-capture-card ls-device-area">
          <view class="status-pill">
            <view class="status-dot"></view>
            <text class="status-text">{{ faceVerifyStatusText }}</text>
@@ -265,30 +282,31 @@
          </view>
        </view>
        <text class="bottom-subtext">系统将自动完成人脸采集与比对,请勿频繁移动或离开识别区域</text>
        </view>
      </view>
      <view class="bottom-area">
        <view class="help-card">
      <view class="bottom-area ls-bottom-area">
        <view class="help-card ls-help-card">
          <view class="highlight-bar"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">识别提示</text>
          </view>
          <text class="help-content">{{ faceVerifyHelpText }}</text>
          <text class="help-content ls-help-content">{{ faceVerifyHelpText }}</text>
        </view>
        <view class="action-card">
        <view class="action-card ls-action-card">
          <view class="highlight-bar"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="retryIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">识别失败?</text>
          </view>
          <view class="main-btn" @click="handleFaceVerifyNextStep">
          <view class="main-btn ls-main-btn" @click="handleFaceVerifyNextStep">
            <text class="btn-text">重新采集人脸</text>
            <!-- <text class="btn-text">跳过人脸采集</text> -->
          </view>
@@ -298,10 +316,12 @@
    <!-- 步骤: 接待人验证 -->
    <template v-else-if="currentStepName === 'host'">
      <view class="main-card">
        <view class="title-group">
          <text class="main-title">请接待人面向屏幕</text>
          <text class="sub-title">请接待人站在设备前,正视屏幕完成身份确认</text>
      <view class="main-card ls-main-card">
        <view class="ls-main-content">
          <view class="ls-text-panel">
            <view class="title-group ls-title-group">
          <text class="main-title ls-main-title">请接待人面向屏幕</text>
          <text class="sub-title ls-main-desc">请接待人站在设备前,正视屏幕完成身份确认</text>
          <view class="name-pill" v-if="mode === 'appointment'">
            <view class="name-icon">
              <text class="name-icon-text">人</text>
@@ -310,7 +330,9 @@
          </view>
        </view>
        <view class="face-capture-card">
        <text class="bottom-subtext ls-helper-text">系统将自动完成人脸采集与比对,请勿频繁移动或离开识别区域</text>
          </view>
          <view class="face-capture-card ls-device-area">
          <view class="status-pill">
            <view class="status-dot"></view>
            <text class="status-text">{{ hostVerifyStatusText }}</text>
@@ -366,30 +388,31 @@
          </view>
        </view>
        <text class="bottom-subtext">系统将自动完成人脸采集与比对,请勿频繁移动或离开识别区域</text>
        </view>
      </view>
      <view class="bottom-area">
        <view class="help-card">
      <view class="bottom-area ls-bottom-area">
        <view class="help-card ls-help-card">
          <view class="highlight-bar"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">验证提示</text>
          </view>
          <text class="help-content">请由接待人本人操作;\n接待人可以直接使用门禁卡刷卡验证</text>
          <text class="help-content ls-help-content">请由接待人本人操作;\n接待人可以直接使用门禁卡刷卡验证</text>
        </view>
        <view class="action-card">
        <view class="action-card ls-action-card">
          <view class="highlight-bar" style="background: #6EA2FF;"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="retryIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">验证失败?</text>
          </view>
          <view class="main-btn" @click="retryFaceVerify">
          <view class="main-btn ls-main-btn" @click="retryFaceVerify">
            <text class="btn-text">重新验证接待人</text>
            <!-- <text class="btn-text">跳过人脸采集</text> -->
          </view>
@@ -446,35 +469,41 @@
    <!-- 步骤: 打印凭条 -->
    <template v-else-if="currentStepName === 'print'">
      <view class="main-card">
        <text class="main-title">请等待打印凭条</text>
      <view class="main-card ls-main-card">
        <view class="ls-main-content">
          <view class="ls-text-panel">
            <view class="ls-title-group">
              <text class="main-title ls-main-title">请等待打印凭条</text>
            </view>
          </view>
          <view class="print-demo-card ls-device-area" style="align-items: center; justify-content: center;">
          <image :src="printTicketDemoImage" class="print-demo-img ls-device-img" mode="aspectFit"></image>
        </view>
        <view class="print-demo-card">
          <image :src="printTicketDemoImage" class="print-demo-img" mode="aspectFit"></image>
        </view>
      </view>
      <view class="bottom-area">
        <view class="help-card">
      <view class="bottom-area ls-bottom-area">
        <view class="help-card ls-help-card">
          <view class="highlight-bar"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">取条提示</text>
          </view>
          <text class="help-content">请在凭条完全打印后再取走;\n如长时间未出条,请联系现场工作人员协助</text>
          <text class="help-content ls-help-content">请在凭条完全打印后再取走;\n如长时间未出条,请联系现场工作人员协助</text>
        </view>
        <view class="action-card">
        <view class="action-card ls-action-card">
          <view class="highlight-bar" style="background: #6EA2FF;"></view>
          <view class="card-title-row">
          <view class="card-title-row ls-card-title-row">
            <view class="icon-base">
              <image :src="retryIconImage" class="icon-base-img" mode="aspectFit"></image>
            </view>
            <text class="card-title">未打印成功?</text>
          </view>
          <view class="main-btn" @click="startPrintTicket">
          <view class="main-btn ls-main-btn" @click="startPrintTicket">
            <text class="btn-text">重新打印凭条</text>
          </view>
        </view>
@@ -628,27 +657,35 @@
        </view>
      </view>
    </view>
   <CommonNoticeDialog
      :visible="noticeDialog.visible"
      :title="noticeDialog.title"
      :description="noticeDialog.description"
      @close="closeNoticeDialog"
    />
    <view v-if="noticeDialog.visible" class="notice-dialog-wrapper">
      <view class="notice-dialog-mask"></view>
      <view class="notice-dialog-card">
        <view class="notice-dialog-copy">
          <text class="notice-dialog-title">{{ noticeDialog.title }}</text>
          <text v-if="noticeDialog.description" class="notice-dialog-desc">{{ noticeDialog.description }}</text>
        </view>
        <view class="notice-dialog-button-row">
          <view class="notice-dialog-close-btn" @click="closeNoticeDialog">
            <text class="notice-dialog-close-text">{{ noticeDialog.buttonText }}({{ noticeDialog.countdown }})</text>
          </view>
        </view>
      </view>
    </view>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { onLoad, onShow, onHide } from '@dcloudio/uni-app';
import StepProgress from '../../components/StepProgress.vue';
import CommonNoticeDialog from '../../components/CommonNoticeDialog.vue';
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,
@@ -674,6 +711,7 @@
const appointmentQrcodePayload = ref(null);
const appointmentIdCardPayload = ref(null);
const isLandscapeLayout = ref(false);
const faceScanLineRef = ref(null);
const hostScanLineRef = ref(null);
const facePreviewReady = ref(false);
@@ -774,7 +812,92 @@
  visible: false,
  title: '',
  description: '',
  buttonText: '关闭',
  duration: 5,
  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) {
    clearTimeout(inactivityAutoBackTimer);
    inactivityAutoBackTimer = null;
  }
};
const handleInactivityAutoBack = () => {
  clearInactivityTimer();
//   uni.reLaunch({
//     url: '/pages/home/index',
//   });
};
const resetInactivityTimer = () => {
  clearInactivityTimer();
  inactivityAutoBackTimer = setTimeout(() => {
    handleInactivityAutoBack();
  }, INACTIVITY_AUTO_BACK_MS);
};
const stopNoticeDialogCountdown = () => {
  if (noticeDialogTimer) {
    clearInterval(noticeDialogTimer);
    noticeDialogTimer = null;
  }
};
const startNoticeDialogCountdown = () => {
  stopNoticeDialogCountdown();
  noticeDialog.value.countdown = noticeDialog.value.duration;
  noticeDialogTimer = setInterval(() => {
    if (!noticeDialog.value.visible) {
      stopNoticeDialogCountdown();
      return;
    }
    noticeDialog.value.countdown -= 1;
    if (noticeDialog.value.countdown <= 0) {
      closeNoticeDialog();
    }
  }, 1000);
};
const appointmentVisitorName = computed(() => {
  if (mode.value === 'online') {
    return appointmentIdCardPayload.value?.name
@@ -940,6 +1063,11 @@
    return '';
  }
  // 兼容旧缓存里仍是 base64/dataURL 的员工脸照,避免被误判成“没有照片”
  if (/^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(normalizedPhotoAddress)) {
    return normalizedPhotoAddress;
  }
  if (/^(https?:\/\/|file:\/\/|content:|_doc\/|\/storage\/)/.test(normalizedPhotoAddress)) {
    return normalizedPhotoAddress;
  }
@@ -980,6 +1108,26 @@
  return null;
};
const getHostFaceEmployees = () => {
  if (!Array.isArray(employeeVerifyList.value) || employeeVerifyList.value.length === 0) {
    return [];
  }
  return employeeVerifyList.value.filter((employee) => Boolean(resolveEmployeeFacePhotoAddress(employee)));
};
const findEmployeeByCardNo = (cardNo = '') => {
  const normalizedCardNo = normalizeCardNoValue(cardNo);
  if (!normalizedCardNo || !Array.isArray(employeeVerifyList.value) || employeeVerifyList.value.length === 0) {
    return null;
  }
  return employeeVerifyList.value.find((employee) => {
    const employeeCardNo = normalizeCardNoValue(getMatchedHostEmployeeCardNo(employee));
    return Boolean(employeeCardNo) && employeeCardNo === normalizedCardNo;
  }) || null;
};
const getMatchedHostEmployeeCardNo = (employee = {}) => {
  return getFirstFilledValue(employee, [
    'cardNo',
@@ -1005,6 +1153,34 @@
};
const verifyHostEmployeeCard = (cardData = {}) => {
  const readCardNo = normalizeCardNoValue(getReaderCardNo(cardData));
  if (!readCardNo) {
    return {
      passed: false,
      reason: 'card_no_missing',
    };
  }
  if (mode.value === 'online') {
    const onsiteMatchedEmployee = findEmployeeByCardNo(readCardNo);
    hostMatchedEmployee.value = onsiteMatchedEmployee;
    if (!onsiteMatchedEmployee) {
      return {
        passed: false,
        reason: 'employee_not_found',
        readCardNo,
      };
    }
    return {
      passed: true,
      employee: onsiteMatchedEmployee,
      readCardNo,
    };
  }
  const matchedEmployee = findMatchedHostEmployee();
  hostMatchedEmployee.value = matchedEmployee;
@@ -1016,15 +1192,6 @@
  }
  const employeeCardNo = normalizeCardNoValue(getMatchedHostEmployeeCardNo(matchedEmployee));
  const readCardNo = normalizeCardNoValue(getReaderCardNo(cardData));
  if (!readCardNo) {
    return {
      passed: false,
      reason: 'card_no_missing',
    };
  }
  if (!employeeCardNo) {
    return {
      passed: false,
@@ -1050,9 +1217,63 @@
  };
};
const compareHostFaceWithEmployee = async (livePhotoAddress, employee = {}) => {
  const employeeFacePhotoAddress = resolveEmployeeFacePhotoAddress(employee);
  if (!employeeFacePhotoAddress) {
    return {
      passed: false,
      employee,
    };
  }
  try {
    const faceResult = await new Promise((resolve, reject) => {
      HardwareAPI.startFaceVerify(
        {
          face: livePhotoAddress,
          photo: employeeFacePhotoAddress,
          employeeFacePhotoAddress,
          photoAddress: employeeFacePhotoAddress,
          capturePhotoAddress: livePhotoAddress,
        },
        (result) => resolve(result),
        (error) => reject(error),
      );
    });
    return {
      passed: isFaceVerifyPassed(faceResult),
      employee,
      faceResult,
    };
  } catch (error) {
    return {
      passed: false,
      employee,
      error,
    };
  }
};
const compareHostFaceWithAllEmployees = async (livePhotoAddress) => {
  const candidateEmployees = getHostFaceEmployees();
  for (const employee of candidateEmployees) {
    const compareResult = await compareHostFaceWithEmployee(livePhotoAddress, employee);
    if (compareResult.passed) {
      return compareResult;
    }
  }
  return {
    passed: false,
    employee: null,
  };
};
const loadEmployeeVerifyList = async () => {
  try {
    employeeVerifyList.value = await getLocalEmployeeVerifyList();
    // 根据要求,此处只读取本地缓存的员工数据,不再每次都触发网络同步和图片下载
    employeeVerifyList.value = await getEmployeeVerifyListFromLocalCache();
    hostMatchedEmployee.value = findMatchedHostEmployee();
  } catch (error) {
    console.error('读取本地员工验证列表失败:', error);
@@ -1119,7 +1340,7 @@
});
const receiverVerifyMethodValue = computed(() => {
  return shouldShowHostStep.value ? '2' : '';
  return shouldShowHostStep.value ? '人脸验证' : '刷卡验证';
});
const stepProgressSteps = computed(() => {
@@ -1206,21 +1427,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;
});
// 手动录入状态
@@ -1283,24 +1532,28 @@
};
const inputKey = (key) => {
  resetInactivityTimer();
  if (idNumber.value.length < 18) {
    idNumber.value += key;
  }
};
const deleteKey = () => {
  resetInactivityTimer();
  if (idNumber.value.length > 0) {
    idNumber.value = idNumber.value.slice(0, -1);
  }
};
const openManualInput = () => {
  resetInactivityTimer();
  isManualInput.value = true;
  activeManualInputTarget.value = 'id';
  isNameInputFocused.value = false;
};
const clearInput = () => {
  resetInactivityTimer();
  idNumber.value = '';
  manualName.value = '';
  showMissingEntryHint.value = false;
@@ -1308,18 +1561,21 @@
};
const dismissNameInput = () => {
  resetInactivityTimer();
  activeManualInputTarget.value = 'id';
  isNameInputFocused.value = false;
  uni.hideKeyboard();
};
const handleNameInputBoxTouchStart = () => {
  resetInactivityTimer();
  // 提前切回姓名输入态,避免原生 focus 事件慢半拍导致自定义键盘残留
  activeManualInputTarget.value = 'name';
  isNameInputFocused.value = true;
};
const handleNameInputFocus = () => {
  resetInactivityTimer();
  activeManualInputTarget.value = 'name';
  isNameInputFocused.value = true;
};
@@ -1329,6 +1585,7 @@
};
const handleIdInputBoxClick = () => {
  resetInactivityTimer();
  dismissNameInput();
};
@@ -1440,23 +1697,113 @@
  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,
    description,
    buttonText: '关闭',
    duration: 5,
    countdown: 5,
  };
  startNoticeDialogCountdown();
};
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 = () => {
@@ -1547,13 +1894,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);
@@ -1589,7 +1943,7 @@
  facePreviewReady.value = false;
  // 中途只清理临时图片和上下文引用,真正的 stop 动作统一放到页面销毁时
  await Promise.allSettled([
    cleanupLiveFacePhoto(),
    cleanupLiveFacePhoto({ preserveIdentityLivePhoto: true }),
    cleanupHostLiveFacePhoto(),
  ]);
  faceCameraContext = null;
@@ -1638,11 +1992,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) {
@@ -1778,32 +2132,27 @@
  return currentSessionId;
};
// 接待人人脸验证:先匹配员工,再拉起摄像头判脸,最后拿员工人脸照做比对
const startHostVerify = () => {
  const matchedEmployee = findMatchedHostEmployee();
  hostMatchedEmployee.value = matchedEmployee;
  if (!matchedEmployee) {
    openNoticeDialog(
      '未找到接待人员工信息',
      '请确认接待人信息与员工档案一致'
    );
    return;
  }
  const employeeFacePhotoAddress = resolveEmployeeFacePhotoAddress(matchedEmployee);
  if (!employeeFacePhotoAddress) {
    openNoticeDialog(
      '未获取到接待人人脸照片',
      '当前接待人员工档案未配置人脸照片,请先完善员工资料'
    );
    return;
  }
// 接待人人脸验证:
// - 统一按员工列表中所有带人脸照片的员工依次轮询比对
// - 预约场景仍保留刷卡时对指定接待人的精确校验,但纯人脸验证不再预先锁定某一个员工
const startHostVerify = async () => {
  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',
@@ -1816,34 +2165,23 @@
              return;
            }
            hostVerifyStatusText.value = '接待人人脸比对中...';
            const faceResult = await new Promise((resolve, reject) => {
              HardwareAPI.startFaceVerify(
                {
                  face: livePhotoAddress,
                  photo: employeeFacePhotoAddress,
                  employeeFacePhotoAddress,
                  photoAddress: employeeFacePhotoAddress,
                  capturePhotoAddress: livePhotoAddress,
                },
                (result) => resolve(result),
                (error) => reject(error),
              );
            });
            hostVerifyStatusText.value = '人脸比对中...';
            const hostCompareResult = await compareHostFaceWithAllEmployees(livePhotoAddress);
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
            if (!isFaceVerifyPassed(faceResult)) {
            if (!hostCompareResult?.passed) {
              hostVerifyStatusText.value = '接待人验证失败';
              openNoticeDialog(
              openHostFaceVerifyFailureDialog(
                '接待人验证失败,请重新验证',
                '请正视屏幕重新进行人脸认证'
              );
              return;
            }
            hostMatchedEmployee.value = hostCompareResult.employee || null;
            hostVerifyStatusText.value = '接待人验证成功';
            nextStep();
          } catch (error) {
@@ -1852,7 +2190,7 @@
            }
            hostVerifyStatusText.value = '接待人验证失败';
            openNoticeDialog(
            openHostFaceVerifyFailureDialog(
              resolveFaceVerifyErrorMessage(error),
              '请正视屏幕重新进行人脸认证'
            );
@@ -1979,6 +2317,7 @@
            // 手动录入身份证时没有证件照,跳过人证比对,仅保留现场人脸图
            if (isManualIdentityInput) {
              faceVerifyStatusText.value = '人脸采集成功';
              await waitBeforeEnterHostStep();
              nextStep();
              return;
            }
@@ -2006,7 +2345,7 @@
            if (!isFaceVerifyPassed(faceResult)) {
              faceVerifyStatusText.value = '人证比对失败';
              openNoticeDialog(
              openVisitorFaceVerifyFailureDialog(
                '人证照片比对失败,请重新采集',
                '请正视屏幕重新进行人脸认证'
              );
@@ -2014,6 +2353,7 @@
            }
            faceVerifyStatusText.value = '人证比对成功';
            await waitBeforeEnterHostStep();
            nextStep();
          } catch (error) {
            if (sessionId !== judgeFaceSessionId) {
@@ -2021,7 +2361,7 @@
            }
            faceVerifyStatusText.value = '人证比对失败';
            openNoticeDialog(
            openVisitorFaceVerifyFailureDialog(
              resolveFaceVerifyErrorMessage(error),
              '请正视屏幕重新进行人脸认证'
            );
@@ -2046,12 +2386,31 @@
};
const retryFaceVerify = () => {
  if (currentStepName.value === 'host') {
    startHostVerify();
  } else {
    startFaceVerify();
  }
  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);
@@ -2062,15 +2421,15 @@
  if (validationResult.reason === 'out_of_time_range') {
    openNoticeDialog(
      '预约码不在进场时间内,请联系工作人员',
      '请核对预约时段信息,或前往前台获取现场协助'
      '请检查您的预约时间'
    );
    return false;
  }
  if (validationResult.reason === 'already_entered') {
    openNoticeDialog(
      '您已进场,请先签离',
      ''
      '该预约码已进场',
      '您已进场,请签离'
    );
    return false;
  }
@@ -2080,6 +2439,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('提示', '打印机缺纸,请联系管理员');
};
// -------------------- 区域数据加载 --------------------
@@ -2112,12 +2539,14 @@
  }
};
// ----------------------------------------------------
onMounted(async () => {
  await loadSystemFlowConfig();
  await loadEmployeeVerifyList();
  await loadVisitAreas();
  clearAppointmentIdCardPayload();
resetInactivityTimer();
  // 必须在页面挂载的第一时间注册扫码和读卡的事件监听,
  // 绝对不能放在 await 数据加载之后,否则会导致进入页面前几十秒内刷卡/扫码无反应!
  registerAppointmentScanHandler();
  uni.$on('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  syncReadCardByStep(currentStepName.value);
  // 首页扫码成功后会直接跳到预约登记的身份证步骤,这里需要保留并恢复之前已缓存的二维码数据,
  // 否则后续身份证一致性校验会因为拿不到 visitorIdCard 而被误判为不一致。
@@ -2130,78 +2559,23 @@
  hasMountedHardwareFlow = true;
  // 页面加载后立即开启身份证读卡监听。
  // 只有当前处于身份证读卡步骤或接待人验证步骤时,才消费回调结果。
  HardwareAPI.startReadCard(async (cardData) => {
    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);
  // 初始化硬件插件
@@ -2213,11 +2587,13 @@
});
onUnmounted(() => {
clearInactivityTimer();
  unregisterAppointmentScanHandler();
  uni.$off('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  dismissNameInput();
  stopScanLineAnimation();
  // 所有插件 stop 动作统一只放在页面销毁时调用,不在流程中途调用
  HardwareAPI.stopScan();
  HardwareAPI.stopReadCard();
  stopNoticeDialogCountdown();
  stopReadCardListener();
  HardwareAPI.stopJudgeFace();
  HardwareAPI.stopFaceVerify();
  stopLivePreview(faceCameraContext);
@@ -2226,6 +2602,11 @@
});
onLoad((options) => {
  const windowInfo = uni.getWindowInfo();
  isLandscapeLayout.value = windowInfo.windowWidth > windowInfo.windowHeight;
  uni.onWindowResize((res) => {
    isLandscapeLayout.value = res.size.windowWidth > res.size.windowHeight;
  });
  if (options.step) {
    currentStep.value = parseInt(options.step, 10);
  }
@@ -2248,6 +2629,10 @@
  if (currentStep.value > newFlowStepNames.length) {
    currentStep.value = newFlowStepNames.length || 1;
  }
});
watch(currentStepName, () => {
  resetInactivityTimer();
});
// 监听步骤变化,步骤切换时只做状态清理,不在中途调用插件 stop 方法
@@ -2276,50 +2661,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;
      }
      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') {
    // 访客进入人脸步骤后自动开始判脸和比对
@@ -2339,7 +2681,70 @@
  }
};
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();
  }
};
const goBack = () => {
  resetInactivityTimer();
  dismissNameInput();
  if (isManualInput.value) {
    isManualInput.value = false;
@@ -2361,6 +2766,7 @@
};
const nextStep = () => {
  resetInactivityTimer();
  if (currentStepName.value === 'form' && mode.value === 'online') {
    if (selectedAreas.value.length === 0) {
      uni.showToast({ title: '请选择访问区域', icon: 'none' });
@@ -2422,13 +2828,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;
@@ -2437,7 +2855,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: '',
@@ -2452,7 +2870,7 @@
        return;
      }
      const hasEntry = await verifyOnsiteEntry(idNumber.value);
      const hasEntry = await verifyOnsiteEntry(normalizedIdNumber);
      if (hasEntry) {
        return;
      }
@@ -2467,7 +2885,7 @@
    }
    const manualIdCardPayload = {
      licId: idNumber.value,
      licId: normalizedIdNumber,
      name: mode.value === 'online' ? manualName.value.trim() : '',
      visitorName: mode.value === 'online' ? manualName.value.trim() : '', // 同时赋值给 visitorName 以防其他地方读取
      photoAddress: '',
@@ -2487,14 +2905,27 @@
  try {
    const idCard = idNumber.value || appointmentIdCardPayload.value?.licId || '';
    let visitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '';
    const matchedHostName = getFirstFilledValue(hostMatchedEmployee.value || {}, [
      'employeeName',
      'name',
      'userName',
      'staffName',
      'realName',
    ]);
    // 如果是现场登记且有手动输入的姓名,优先使用
    if (mode.value === 'online' && manualName.value.trim()) {
      visitorName = manualName.value.trim();
    }
    const intervieweeName = appointmentQrcodePayload.value?.beVisitorName || '张三';
    const intervieweeName = mode.value === 'online'
      ? (matchedHostName || '')
      : (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') {
@@ -2503,15 +2934,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 = {
@@ -2519,8 +2955,12 @@
        intervieweeName,
        receiverVerifyMethod,
        signImageUrl: '', // 由 syncEngine 负责上传并回填
        signReceiverName: shouldShowHostStep.value ? (intervieweeName || appointmentHostName.value) : '',
        signVerifyMethod: receiverVerifyMethod,
        // 签离场景优先使用本次接待人验证实际命中的员工姓名,确保上传字段与验证结果一致
        signReceiverName: shouldShowHostStep.value
          ? (matchedHostName || intervieweeName || appointmentHostName.value)
          : '',
        // 签离核验方式固定按访客本人当前的人脸核验结果上报
        signVerifyMethod: '人脸验证',
        signTime: nowFormat // 增加签离时间字段供离线接口使用
      };
@@ -2543,6 +2983,7 @@
        visitReason,
        visitorName,
        registerType,
      receiverName,
        registerTime: nowFormat // 增加签到时间字段供离线接口使用
      };
    }
@@ -2551,28 +2992,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;
  }
};
@@ -2683,10 +3155,41 @@
  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 = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress || '';
  // 打印时优先使用已采集到的现场人脸图,只有没有抓拍图时才回退到身份证照片
  const photoPath = appointmentIdCardPayload.value?.livePhotoAddress
    || liveFacePhotoPath.value
    || appointmentIdCardPayload.value?.photoAddress
    || '';
  return {
    visitorName: {
@@ -2725,6 +3228,14 @@
    visitArea: {
      type: 'text',
      value: areas || '无',
    },
    receiverName: {
      type: 'text',
      value: actualReceiverName,
    },
    registerTime: {
      type: 'text',
      value: getPrintRegisterTime(),
    },
  };
};
@@ -2792,6 +3303,9 @@
    goNext();
  } catch (error) {
    console.error('打印失败:', error);
    if (error?.code === 2) {
      return;
    }
    openNoticeDialog('打印失败', '请检查打印机状态或纸张是否充足');
  }
};
@@ -2808,6 +3322,89 @@
  flex-direction: column;
  position: relative;
  overflow: hidden;
}
.notice-dialog-wrapper {
  position: fixed;
  left: 0;
  top: 0;
  width: 1080px;
  height: 1920px;
  z-index: 999;
}
.notice-dialog-mask {
  position: absolute;
  left: 0;
  top: 0;
  width: 1080px;
  height: 1920px;
  background: rgba(31, 42, 68, 0.6);
}
.notice-dialog-card {
  position: absolute;
  left: 165px;
  top: 808px;
  width: 750px;
  padding: 52px 40px;
  border-radius: 28px;
  background: #FFFFFF;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
}
.notice-dialog-copy {
  width: 670px;
  display: flex;
  flex-direction: column;
  align-items: center;
}
.notice-dialog-title {
  font-family: Inter, sans-serif;
  font-size: 34px;
  font-weight: 700;
  color: #1F2A44;
  text-align: center;
  margin-bottom: 16px;
}
.notice-dialog-desc {
  font-family: Inter, sans-serif;
  font-size: 20px;
  font-weight: 500;
  color: #7B88A8;
  text-align: center;
  line-height: 32px;
}
.notice-dialog-button-row {
  width: 670px;
  height: 72px;
  display: flex;
  align-items: center;
  justify-content: center;
  margin-top: 40px;
}
.notice-dialog-close-btn {
  width: 220px;
  height: 56px;
  display: flex;
  align-items: center;
  justify-content: center;
  border-radius: 20px;
  background: #F5F5F5;
}
.notice-dialog-close-text {
  font-family: Inter, sans-serif;
  font-size: 20px;
  font-weight: 700;
  color: #666666;
}
.bg-glow {
@@ -2991,14 +3588,13 @@
  margin-top: 40px;
}
.input-box {
.manual-input-box {
  width: 768px;
  height: 118px;
  border-radius: 22px;
  background: #FFFFFF;
  border: 1px solid #D9E1F2;
  padding: 0 28px;
  display: flex;
  align-items: center;
  justify-content: center;
@@ -3608,6 +4204,7 @@
  display: flex;
  align-items: center;
  flex-direction: row;
  margin-bottom: 24px;
}
.label {
@@ -3632,7 +4229,6 @@
  border: 1px solid #D9E1F2;
  background: #FFFFFF;
  padding: 0 16px;
  display: flex;
  align-items: center;
  flex-direction: row;
@@ -3649,6 +4245,7 @@
}
.time-box {
  width: 200px;
  flex: 1; /* 占据剩余空间 */
  justify-content: space-between;
}
@@ -3659,11 +4256,6 @@
  height: 20px;
  /* flex-shrink: 0; */
  opacity: 0.6; /* 让图标稍微弱化一点,更贴近设计图的灰色线条感 */
}
.time-box {
  width: 200px;
  justify-content: space-between;
}
.full-box {
@@ -3750,7 +4342,7 @@
.tag-x {
  font-family: Inter, sans-serif;
  font-size: 14px;
  font-size: 18px;
  font-weight: 700;
  color: #A0ACC4;
  /* cursor: pointer; */
@@ -3926,7 +4518,7 @@
.primary-area-row,
.secondary-area-row {
  width: 480px;
  height: 56px;
  height: 64px;
  border-radius: 16px;
  padding: 0 16px;
@@ -3991,15 +4583,14 @@
.secondary-area-text {
  font-family: Inter, sans-serif;
  font-size: 18px;
  font-weight: 600;
  color: #1F2A44;
}
.primary-area-text { font-weight: 600; color: #1F2A44; }
.secondary-area-row { height: 64px; }
.primary-area-text-active,
.secondary-area-text-active { font-weight: 700; color: #2F6BFF; }
.secondary-area-text { font-weight: 600; color: #1F2A44; }
.sheet-confirm-btn {
  width: 1024px;
@@ -4096,4 +4687,253 @@
.plate-confirm-btn { flex: 1; }
/* ==================== 横屏兼容 ==================== */
.page-container.landscape {
  width: 1920px;
  height: 1080px;
  padding-top: 30px;
  padding-right: 48px;
  padding-bottom: 40px;
  padding-left: 48px;
  align-items: center;
}
.page-container.landscape .header-area {
  width: 1824px;
}
.page-container.landscape .left-action {
  width: 1824px;
}
.page-container.landscape .steps-card {
  width: 1824px;
}
.page-container.landscape .steps-row {
  width: 1824px;
}
/* 主内容区 & 辅助区缩放 */
.page-container.landscape .main-card {
  width: 1824px;
  height: 540px;
  padding-top: 20px;
  padding-right: 80px;
  padding-bottom: 20px;
  padding-left: 80px;
}
.page-container.landscape .bottom-area {
  width: 1824px;
  height: 140px;
  flex-direction: row;
  justify-content: space-between;
}
.page-container.landscape .help-card,
.page-container.landscape .action-card {
  width: 880px;
  height: 140px;
}
.page-container.landscape .device-img {
  width: 420px;
  height: 320px;
}
.page-container.landscape .device-area {
  height: 320px;
}
.page-container.landscape .ls-main-card {
  height: 560px;
  padding-top: 40px;
  padding-right: 56px;
  padding-bottom: 34px;
  padding-left: 56px;
  align-items: stretch;
  flex-direction: row;
}
.page-container.landscape .ls-main-content {
  width: 100%;
  flex: 1;
  display: flex;
  flex-direction: row;
  align-items: stretch;
  justify-content: space-between;
}
.page-container.landscape .ls-text-panel {
  width: 760px;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  align-items: flex-start;
  padding-top: 28px;
  padding-bottom: 18px;
}
.page-container.landscape .ls-title-group {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
}
.page-container.landscape .ls-main-title,
.page-container.landscape .ls-main-desc,
.page-container.landscape .ls-helper-text {
  width: 100%;
  text-align: left;
}
.page-container.landscape .ls-main-title {
  font-size: 46px;
  line-height: 74px;
  height: auto;
}
.page-container.landscape .ls-main-desc {
  margin-top: 14px;
  margin-bottom: 0;
  font-size: 24px;
  line-height: 34px;
}
.page-container.landscape .ls-helper-text {
  font-size: 20px;
  line-height: 30px;
}
.page-container.landscape .ls-device-area {
  width: 620px;
  height: 100%;
  margin-bottom: 0;
}
.page-container.landscape .ls-device-img {
  width: 620px;
  height: 420px;
}
.page-container.landscape .ls-bottom-area {
  height: 176px;
  align-items: stretch;
}
.page-container.landscape .ls-help-card,
.page-container.landscape .ls-action-card {
  width: 900px;
  height: 176px;
  padding-top: 22px;
  padding-right: 28px;
  padding-bottom: 22px;
  padding-left: 28px;
}
.page-container.landscape .ls-card-title-row {
  margin-top: 14px;
}
.page-container.landscape .ls-help-content {
  margin-top: 14px;
  font-size: 17px;
  line-height: 26px;
}
.page-container.landscape .ls-action-card {
  justify-content: flex-start;
}
.page-container.landscape .ls-main-btn {
  width: 100%;
  height: 56px;
  margin-top: 18px;
}
/* 手动录入 */
.manual-layout-wrapper {
  display: flex;
  flex-direction: column;
}
.manual-layout-wrapper.landscape {
  flex-direction: row;
  justify-content: space-between;
  width: 1824px;
}
.manual-layout-wrapper.landscape .manual-card {
  width: 900px;
  height: 704px;
}
.manual-layout-wrapper.landscape .custom-keyboard {
  width: 900px;
  height: 704px;
}
.manual-layout-wrapper.landscape .input-form-card {
  width: 800px;
}
.manual-layout-wrapper.landscape .manual-input-box {
  width: 800px;
}
.manual-layout-wrapper.landscape .real-input {
  width: 800px;
}
/* 人脸验证 */
.page-container.landscape .face-capture-card {
  height: 360px;
}
.page-container.landscape .viewfinder-area {
  height: 240px;
}
.page-container.landscape .viewfinder-box {
  width: 240px;
  height: 240px;
}
.page-container.landscape .face-camera {
  width: 240px;
  height: 240px;
}
.page-container.landscape .scan-line {
  width: 240px;
}
.page-container.landscape .viewfinder-border-mask {
  width: 240px;
  height: 240px;
}
.page-container.landscape .corner-donut {
  width: 240px;
  height: 240px;
}
/* 完善信息 */
.page-container.landscape .form-card {
  width: 1824px;
  height: 704px;
  flex-direction: row;
  flex-wrap: wrap;
  align-content: flex-start;
  padding-top: 40px;
  padding-right: 40px;
  padding-bottom: 40px;
  padding-left: 40px;
}
.page-container.landscape .form-row {
  width: 800px;
  margin-right: 40px;
}
.page-container.landscape .form-action-row {
  width: 1744px;
  margin-top: auto;
  justify-content: center;
}
.page-container.landscape .form-save-btn {
  width: 600px;
}
/* 打印凭条 */
.page-container.landscape .print-ticket-demo {
  width: 240px;
  height: 320px;
}
.page-container.landscape .print-display-card {
  height: 360px;
}
</style>