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,51 @@
    <!-- 步骤: 身份证验证 -->
    <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="ls-title-group">
                <text class="main-title ls-main-title">请将您的身份证放置读卡器位置</text>
                <text class="main-desc ls-main-desc">请将身份证贴近设备感应区,系统将自动读取身份信息</text>
              </view>
          <view class="name-hint-row" v-if="mode === 'appointment'">
            <view class="name-icon-box">
              <text class="name-icon-text">人</text>
              <view class="name-hint-row" v-if="mode === 'appointment'">
                <view class="name-icon-box">
                  <text class="name-icon-text">人</text>
                </view>
                <text class="name-text">姓名:{{ appointmentVisitorName }}</text>
              </view>
              <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 +117,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 +177,7 @@
          </view>
        </view>
        <!-- 自定义键盘 -->
        <view class="custom-keyboard" v-if="showCustomKeyboard">
          <view class="keyboard-grid">
@@ -190,15 +203,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 +223,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 +283,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 +317,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 +331,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 +389,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 +470,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,7 +676,7 @@
<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';
@@ -682,6 +712,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);
@@ -2510,7 +2541,7 @@
};
// ----------------------------------------------------
onMounted(async () => {
  resetInactivityTimer();
resetInactivityTimer();
  // 必须在页面挂载的第一时间注册扫码和读卡的事件监听,
  // 绝对不能放在 await 数据加载之后,否则会导致进入页面前几十秒内刷卡/扫码无反应!
@@ -2557,7 +2588,7 @@
});
onUnmounted(() => {
  clearInactivityTimer();
clearInactivityTimer();
  unregisterAppointmentScanHandler();
  uni.$off('Hardware-PrintOutOfPaper', handlePrinterOutOfPaper);
  dismissNameInput();
@@ -2572,6 +2603,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);
  }
@@ -2889,6 +2925,8 @@
    const receiverVerifyMethod = receiverVerifyMethodValue.value;
    // 登记场景的接待人姓名统一优先取本次接待人验证实际命中的员工姓名
    const receiverName = matchedHostName || appointmentQrcodePayload.value?.beVisitorName || '';
    // 统一使用本地时间格式,避免 toISOString() 转成 UTC 后导致时区偏差
    const nowFormat = formatStandardDateTime(new Date());
    let validEndTime = '';
    if (mode.value === 'online') {
@@ -2897,15 +2935,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
@@ -4650,4 +4688,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: 56px;
  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>