pages/appointment-scan/n-index.nvue
@@ -1,5 +1,5 @@
<template>
  <view class="page-container" @touchstart="resetInactivityTimer" @click="resetInactivityTimer">
  <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,7 +116,8 @@
      <!-- 手动录入信息 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>
@@ -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>
@@ -646,16 +675,17 @@
<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 { 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,
@@ -681,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);
@@ -831,9 +862,9 @@
const handleInactivityAutoBack = () => {
  clearInactivityTimer();
  uni.reLaunch({
    url: '/pages/home/index',
  });
//   uni.reLaunch({
//     url: '/pages/home/index',
//   });
};
const resetInactivityTimer = () => {
@@ -1241,7 +1272,8 @@
const loadEmployeeVerifyList = async () => {
  try {
    employeeVerifyList.value = await getLocalEmployeeVerifyList();
    // 根据要求,此处只读取本地缓存的员工数据,不再每次都触发网络同步和图片下载
    employeeVerifyList.value = await getEmployeeVerifyListFromLocalCache();
    hostMatchedEmployee.value = findMatchedHostEmployee();
  } catch (error) {
    console.error('读取本地员工验证列表失败:', error);
@@ -1308,7 +1340,7 @@
});
const receiverVerifyMethodValue = computed(() => {
  return shouldShowHostStep.value ? '2' : '';
  return shouldShowHostStep.value ? '人脸验证' : '刷卡验证';
});
const stepProgressSteps = computed(() => {
@@ -1395,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;
});
// 手动录入状态
@@ -1932,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) {
@@ -2078,19 +2138,21 @@
const startHostVerify = async () => {
  hostMatchedEmployee.value = null;
  const candidateFaceEmployees = getHostFaceEmployees();
  if (candidateFaceEmployees.length === 0) {
    openNoticeDialog(
      '未获取到员工人脸照片',
      '当前员工列表中没有可用于比对的人脸照片,请先同步员工档案'
    );
    return;
  }
  resetFacePreviewState().then(async () => {
    try {
      hostVerifyStatusText.value = '正在开启摄像头...';
      await ensureHostCameraContext();
      const candidateFaceEmployees = getHostFaceEmployees();
      if (candidateFaceEmployees.length === 0) {
        openNoticeDialog(
          '未获取到员工人脸照片',
          '当前员工列表中没有可用于比对的人脸照片,请联系管理员核实'
        );
        return;
      }
      hostVerifyStatusText.value = '请将脸部置于框内';
      // 只有判到人脸后,才进入正式抓拍和人脸比对
      startJudgeFacePolling({
        stepName: 'host',
@@ -2409,7 +2471,7 @@
    try {
      const decryptedResult = await parseHikvisionQrcodePayload(scanData);
      await showQrcodeParseDebugModal(scanData, decryptedResult);
    //   await showQrcodeParseDebugModal(scanData, decryptedResult);
      // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用
      appointmentQrcodePayload.value = decryptedResult.payload;
@@ -2443,6 +2505,10 @@
  }
};
const handlePrinterOutOfPaper = () => {
  openNoticeDialog('提示', '打印机缺纸,请联系管理员');
};
// -------------------- 区域数据加载 --------------------
const loadVisitAreas = async () => {
  try {
@@ -2474,8 +2540,13 @@
};
// ----------------------------------------------------
onMounted(async () => {
  resetInactivityTimer();
resetInactivityTimer();
  // 必须在页面挂载的第一时间注册扫码和读卡的事件监听,
  // 绝对不能放在 await 数据加载之后,否则会导致进入页面前几十秒内刷卡/扫码无反应!
  registerAppointmentScanHandler();
  uni.$on('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  syncReadCardByStep(currentStepName.value);
  // 首页扫码成功后会直接跳到预约登记的身份证步骤,这里需要保留并恢复之前已缓存的二维码数据,
  // 否则后续身份证一致性校验会因为拿不到 visitorIdCard 而被误判为不一致。
@@ -2493,8 +2564,18 @@
  await loadVisitAreas();
  clearAppointmentIdCardPayload();
  // 首屏硬件动作不要依赖 init 回调,否则 init 卡住时扫码第一步不会触发
  triggerHardwareByStep(currentStepName.value);
  // 首屏硬件动作:这里只需触发人脸和打印等主动调用型动作,
  // 扫码和读卡的监听已提至 onMounted 顶部,避免被上述 await 阻塞
  if (currentStepName.value === 'face') {
    startFaceVerify();
  } else if (currentStepName.value === 'host') {
    startHostVerify();
  } else if (currentStepName.value === 'print') {
    setTimeout(() => {
      startPrintTicket();
    }, 500);
  }
  await syncScanLineAnimationByStep(currentStepName.value);
  // 初始化硬件插件
@@ -2506,8 +2587,9 @@
});
onUnmounted(() => {
  clearInactivityTimer();
clearInactivityTimer();
  unregisterAppointmentScanHandler();
  uni.$off('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  dismissNameInput();
  stopScanLineAnimation();
  stopNoticeDialogCountdown();
@@ -2520,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);
  }
@@ -2607,7 +2694,6 @@
  }
  console.log('读卡成功,拿到身份证信息:', cardData);
  if (mode.value === 'signout') {
    const hasEntry = await verifySignoutEntry(cardData?.licId);
    if (!hasEntry) return;
@@ -2836,6 +2922,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') {
@@ -2844,15 +2934,15 @@
      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
@@ -2865,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 // 增加签离时间字段供离线接口使用
      };
@@ -2889,6 +2983,7 @@
        visitReason,
        visitorName,
        registerType,
      receiverName,
        registerTime: nowFormat // 增加签到时间字段供离线接口使用
      };
    }
@@ -2902,25 +2997,54 @@
    };
    console.log('[CheckIn] 准备写入离线记录:', offlineRecord);
    showPageDebugModal('离线记录写入前', 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;
  }
};
@@ -3031,8 +3155,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
@@ -3077,6 +3228,14 @@
    visitArea: {
      type: 'text',
      value: areas || '无',
    },
    receiverName: {
      type: 'text',
      value: actualReceiverName,
    },
    registerTime: {
      type: 'text',
      value: getPrintRegisterTime(),
    },
  };
};
@@ -3144,6 +3303,9 @@
    goNext();
  } catch (error) {
    console.error('打印失败:', error);
    if (error?.code === 2) {
      return;
    }
    openNoticeDialog('打印失败', '请检查打印机状态或纸张是否充足');
  }
};
@@ -4525,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>