zjk
2026-07-23 3108b92691f639d988e7b506b2d05c4fe316a0a1
feat: 优化扫码硬件管理,新增二维码校验与本地缓存功能

统一调整扫码硬件启停逻辑,将stopScan移至App的onHide中,仅在应用退后台/关闭时停止扫码,避免页面重复启停硬件。
新增预约二维码合法性校验,过滤缺少访客姓名、接待人、证件号的无效二维码。
重构文件工具类,新增远程文件下载缓存能力,优化本地图片保存逻辑。
为预约扫描页面添加无操作60秒自动返回首页的交互逻辑。
新增首页logo本地缓存功能,优化logo展示样式与旧缓存清理逻辑。
修复首页公司名称取值错误问题,移除多处调试用的弹窗日志。
优化扫码会话管理,通过递增sessionId拦截旧回调而非主动停止硬件。
7个文件已修改
288 ■■■■■ 已修改文件
App.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/n-index.nvue 47 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/home/index.vue 101 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/fs.js 74 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/hikvisionQrcode.js 44 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/visitorDevice.js 18 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
App.vue
@@ -43,6 +43,8 @@
    },
    onHide() {
        console.log('App Hide');
        // 扫码硬件只在应用退到后台/关闭时统一停止,页面切换过程中不主动 stop
        HardwareAPI.stopScan();
    },
    methods: {
        async restoreOfflineActivation() {
pages/appointment-scan/index.vue
@@ -1471,7 +1471,6 @@
onUnmounted(() => {
  // 离开页面时,确保关闭所有的扫描和读卡
  HardwareAPI.stopScan();
  HardwareAPI.stopReadCard();
  HardwareAPI.stopFaceVerify();
  clearFaceCaptureTimer();
@@ -1512,7 +1511,6 @@
  }
  // 先关闭上一步的硬件
  if (oldStep === 'qr') HardwareAPI.stopScan();
  if (oldStep === 'face') {
    HardwareAPI.stopFaceVerify();
    resetFacePreviewState();
pages/appointment-scan/n-index.nvue
@@ -1,5 +1,5 @@
<template>
  <view class="page-container">
  <view class="page-container" @touchstart="resetInactivityTimer" @click="resetInactivityTimer">
    <!-- 左侧柔光氛围 -->
    <view class="bg-glow"></view>
@@ -786,6 +786,29 @@
  countdown: 5,
});
let noticeDialogTimer = null;
const INACTIVITY_AUTO_BACK_MS = 60 * 1000;
let inactivityAutoBackTimer = null;
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) {
@@ -1416,24 +1439,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;
@@ -1441,18 +1468,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;
};
@@ -1462,6 +1492,7 @@
};
const handleIdInputBoxClick = () => {
  resetInactivityTimer();
  dismissNameInput();
};
@@ -2270,6 +2301,7 @@
};
// ----------------------------------------------------
onMounted(async () => {
  resetInactivityTimer();
  await loadSystemFlowConfig();
  await loadEmployeeVerifyList();
  await loadVisitAreas();
@@ -2289,6 +2321,7 @@
  // 页面加载后立即开启身份证读卡监听。
  // 只有当前处于身份证读卡步骤或接待人验证步骤时,才消费回调结果。
  HardwareAPI.startReadCard(async (cardData) => {
    resetInactivityTimer();
    if (currentStepName.value === 'host') {
      const hostCardVerifyResult = verifyHostEmployeeCard(cardData);
@@ -2369,11 +2402,11 @@
});
onUnmounted(() => {
  clearInactivityTimer();
  dismissNameInput();
  stopScanLineAnimation();
  stopNoticeDialogCountdown();
  // 所有插件 stop 动作统一只放在页面销毁时调用,不在流程中途调用
  HardwareAPI.stopScan();
  // 扫码停止统一收口到应用关闭时调用,页面销毁时不主动 stop
  HardwareAPI.stopReadCard();
  HardwareAPI.stopJudgeFace();
  HardwareAPI.stopFaceVerify();
@@ -2405,6 +2438,10 @@
  if (currentStep.value > newFlowStepNames.length) {
    currentStep.value = newFlowStepNames.length || 1;
  }
});
watch(currentStepName, () => {
  resetInactivityTimer();
});
// 监听步骤变化,步骤切换时只做状态清理,不在中途调用插件 stop 方法
@@ -2440,6 +2477,8 @@
      if (currentStepName.value !== 'qr' || mode.value !== 'appointment') {
        return;
      }
      resetInactivityTimer();
      try {
        const decryptedResult = await parseHikvisionQrcodePayload(scanData);
@@ -2497,6 +2536,7 @@
};
const goBack = () => {
  resetInactivityTimer();
  dismissNameInput();
  if (isManualInput.value) {
    isManualInput.value = false;
@@ -2518,6 +2558,7 @@
};
const nextStep = () => {
  resetInactivityTimer();
  if (currentStepName.value === 'form' && mode.value === 'online') {
    if (selectedAreas.value.length === 0) {
      uni.showToast({ title: '请选择访问区域', icon: 'none' });
pages/home/index.vue
@@ -1,13 +1,14 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onShow, onHide } from '@dcloudio/uni-app';
import { getOfflineConfig } from '../../utils/db';
import { getOfflineConfig, saveOfflineConfig } from '../../utils/db';
import { buildBaseUrl, getRequestServerConfig } from '../../utils/request';
import { HardwareAPI } from '../../utils/visitorDevice';
import { parseHikvisionQrcodePayload, validateAppointmentQrcodePayload } from '../../utils/hikvisionQrcode';
import { saveAppointmentQrPayload } from '../../utils/appointmentIdentityStore';
import { syncSystemConfig, syncCommitmentConfig } from '../../api/visitorConfig';
import { syncEmployeeVerifyList } from '../../api/employee';
import { downloadRemoteFileToLocal, deleteLocalImage } from '../../utils/fs';
import CommonNoticeDialog from '../../components/CommonNoticeDialog.vue';
import adminIcon from '../../static/home/avatar.png';
import operationImage from '../../static/home/operation.png';
@@ -37,6 +38,8 @@
const welcomeBadgeStyle = {
  backgroundImage: `url(${welcomeBgImage})`,
};
const HOME_LOGO_CACHE_KEY = 'home_logo_cache';
const normalizeConfigLogoUrl = (logoValue = '') => {
  const normalizedLogo = String(logoValue || '').trim();
@@ -70,6 +73,60 @@
  );
};
const getLocalLogoCache = async () => {
  const offlineLogoCache = await getOfflineConfig(HOME_LOGO_CACHE_KEY);
  if (offlineLogoCache && typeof offlineLogoCache === 'object') {
    return offlineLogoCache;
  }
  const storageLogoCache = uni.getStorageSync(HOME_LOGO_CACHE_KEY);
  return storageLogoCache && typeof storageLogoCache === 'object' ? storageLogoCache : {};
};
const saveLocalLogoCache = async (cacheData = {}) => {
  await saveOfflineConfig(HOME_LOGO_CACHE_KEY, cacheData);
  uni.setStorageSync(HOME_LOGO_CACHE_KEY, cacheData);
};
const cacheHomeLogoToLocal = async (remoteLogoUrl = '') => {
  const normalizedRemoteLogoUrl = String(remoteLogoUrl || '').trim();
  if (!/^https?:\/\//.test(normalizedRemoteLogoUrl)) {
    return '';
  }
  const currentLogoCache = await getLocalLogoCache();
  if (
    currentLogoCache.sourceUrl === normalizedRemoteLogoUrl
    && String(currentLogoCache.localPath || '').trim()
  ) {
    return currentLogoCache.localPath;
  }
  const downloadedLogoLocalPath = await downloadRemoteFileToLocal(normalizedRemoteLogoUrl, {
    directoryName: 'logo-cache',
    fileNamePrefix: 'home-logo',
    fallbackExtension: 'png',
  });
  await saveLocalLogoCache({
    sourceUrl: normalizedRemoteLogoUrl,
    localPath: downloadedLogoLocalPath,
  });
  if (
    currentLogoCache.localPath
    && currentLogoCache.localPath !== downloadedLogoLocalPath
  ) {
    try {
      await deleteLocalImage(currentLogoCache.localPath);
    } catch (error) {
      console.error('清理旧首页Logo缓存失败:', error);
    }
  }
  return downloadedLogoLocalPath;
};
const serviceProviderName = ref('');
const loadHomeLogo = async () => {
@@ -82,8 +139,14 @@
    const mergedConfig = Object.assign({}, storageHomeConfig, offlineSystemConfig, storageSystemConfig);
    const configuredLogo = resolveConfigLogoUrl(mergedConfig);
    // logoImage.value = configuredLogo || defaultLogoImage;
    logoImage.value =  defaultLogoImage;
    const cachedLogo = await getLocalLogoCache();
    const matchedCachedLogoPath = (
      configuredLogo
      && cachedLogo.sourceUrl === configuredLogo
      && String(cachedLogo.localPath || '').trim()
    ) ? cachedLogo.localPath : '';
    logoImage.value = matchedCachedLogoPath || configuredLogo || defaultLogoImage;
    // 解析新的开关和文案配置
    if (mergedConfig.hasOwnProperty('visitorMachineEntryEnabled')) {
@@ -96,7 +159,7 @@
      showAppointmentEntry.value = Boolean(mergedConfig.appointmentRegistrationEnabled);
    }
    if (mergedConfig.systemName) {
      companyName.value = mergedConfig.serviceProvider;
      companyName.value = mergedConfig.systemName;
    }
  } catch (error) {
    console.error('加载首页Logo和配置失败:', error);
@@ -170,9 +233,6 @@
  const currentSessionId = homeScanSessionId;
  isHandlingHomeScan = false;
  // 先主动停止上一次扫码监听,避免旧回调残留导致误跳转
  HardwareAPI.stopScan();
  HardwareAPI.startScan(async (scanData) => {
    if (currentSessionId !== homeScanSessionId || isHandlingHomeScan) {
      return;
@@ -205,15 +265,14 @@
        } else if (validation.reason === 'already_entered') {
          openNoticeDialog('该预约码已进场', '您已进场,请签离');
        } else {
          openNoticeDialog('无效的预约二维码', '请联系工作人员');
          openNoticeDialog('未识别二维码,请联系工作人员', '');
        }
        return;
      }
      saveAppointmentQrPayload(payload);
      // 跳转前主动关闭首页扫码,避免页面切换时旧回调继续触发
      await HardwareAPI.stopScan();
      // 通过失效当前 session 的方式拦截旧扫码回调,不在页面跳转时主动 stop 扫码
      homeScanSessionId += 1;
      // 扫码成功,直接进入预约登记的第二步(身份证阅读)
      uni.navigateTo({
@@ -244,6 +303,20 @@
  // 再次加载,如果接口配置有更新,则实时应用最新配置
  await loadHomeLogo();
  // 在线配置里的 logo 若是远程地址,则后台下载到本地缓存,供离线时优先展示
  try {
    const latestSystemConfig = uni.getStorageSync('system_config') || {};
    const latestLogoUrl = resolveConfigLogoUrl(latestSystemConfig);
    if (latestLogoUrl) {
      const localLogoPath = await cacheHomeLogoToLocal(latestLogoUrl);
      if (localLogoPath) {
        logoImage.value = localLogoPath;
      }
    }
  } catch (error) {
    console.error('缓存首页Logo到本地失败:', error);
  }
  // 员工图片 base64 转本地文件比较重,放到首页后台静默处理,避免阻塞后续 live-pusher 拉起
  setTimeout(() => {
    syncEmployeeVerifyList()
@@ -264,7 +337,6 @@
onHide(() => {
  homeScanSessionId += 1;
  isHandlingHomeScan = false;
  HardwareAPI.stopScan();
});
</script>
@@ -277,7 +349,7 @@
          @close="closeNoticeDialog"
        />
        <view class="top-bar">
            <image class="logo" :src="logoImage" mode="widthFix" />
            <image class="logo" :src="logoImage" mode="heightFix" />
            <view
                class="admin-btn"
                :style="showAdminEntry ? adminBtnStyle : null"
@@ -354,7 +426,8 @@
}
.logo {
    width: 800rpx;
    /* width: 800rpx; */
    height: 136px;
}
.admin-btn {
utils/fs.js
@@ -3,17 +3,18 @@
 * 用于将临时抓拍图片持久化到设备存储中,以及同步后的清理
 */
// 将临时文件保存到本地 _doc/images/ 目录下
export const saveImageToLocal = (tempFilePath) => {
const getFileExtensionFromPath = (filePath = '', fallbackExtension = 'jpg') => {
  const normalizedPath = String(filePath || '').split('?')[0];
  const match = normalizedPath.match(/\.([a-zA-Z0-9]+)$/);
  return match ? match[1].toLowerCase() : fallbackExtension;
};
const moveFileToDocDirectory = (tempFilePath, directoryName = 'images', fileName = '') => {
  return new Promise((resolve, reject) => {
    // 只有 App-Plus 环境支持 plus.io
    // #ifdef APP-PLUS
    const fileName = `face_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
    plus.io.resolveLocalFileSystemURL(tempFilePath, (entry) => {
      plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => {
        // 创建或打开 images 目录
        docEntry.getDirectory('images', { create: true }, (dirEntry) => {
        docEntry.getDirectory(directoryName, { create: true }, (dirEntry) => {
          entry.moveTo(dirEntry, fileName, (newFile) => {
            resolve(newFile.fullPath);
          }, (err) => {
@@ -32,6 +33,21 @@
      console.error('[FS] 访问临时文件失败:', err);
      reject(err);
    });
    // #endif
    // #ifndef APP-PLUS
    resolve(tempFilePath);
    // #endif
  });
};
// 将临时文件保存到本地 _doc/images/ 目录下
export const saveImageToLocal = (tempFilePath) => {
  return new Promise((resolve, reject) => {
    // 只有 App-Plus 环境支持 plus.io
    // #ifdef APP-PLUS
    const fileName = `face_${Date.now()}_${Math.floor(Math.random() * 1000)}.jpg`;
    moveFileToDocDirectory(tempFilePath, 'images', fileName).then(resolve).catch(reject);
    // #endif
    
    // #ifndef APP-PLUS
@@ -68,3 +84,47 @@
    // #endif
  });
};
// 将远程文件下载到本地 _doc 指定目录,常用于离线缓存图片资源
export const downloadRemoteFileToLocal = (remoteUrl, options = {}) => {
  const {
    directoryName = 'downloads',
    fileNamePrefix = 'file',
    fallbackExtension = 'jpg',
  } = options;
  return new Promise((resolve, reject) => {
    const normalizedRemoteUrl = String(remoteUrl || '').trim();
    if (!normalizedRemoteUrl) {
      reject(new Error('远程文件地址为空'));
      return;
    }
    // #ifdef APP-PLUS
    uni.downloadFile({
      url: normalizedRemoteUrl,
      success: (downloadResult) => {
        if (downloadResult.statusCode !== 200 || !downloadResult.tempFilePath) {
          reject(new Error(`远程文件下载失败,状态码:${downloadResult.statusCode || 'unknown'}`));
          return;
        }
        const fileExtension = getFileExtensionFromPath(normalizedRemoteUrl, fallbackExtension);
        const fileName = `${fileNamePrefix}_${Date.now()}_${Math.floor(Math.random() * 1000)}.${fileExtension}`;
        moveFileToDocDirectory(downloadResult.tempFilePath, directoryName, fileName)
          .then(resolve)
          .catch(reject);
      },
      fail: (error) => {
        console.error('[FS] 下载远程文件失败:', error);
        reject(error);
      },
    });
    // #endif
    // #ifndef APP-PLUS
    resolve(normalizedRemoteUrl);
    // #endif
  });
};
utils/hikvisionQrcode.js
@@ -173,6 +173,43 @@
    return normalizedPayload;
};
const hasAnyNonEmptyField = (payload, fieldNames = []) => {
    return fieldNames.some((fieldName) => {
        const fieldValue = payload?.[fieldName];
        return String(fieldValue || '').trim() !== '';
    });
};
const hasRequiredAppointmentQrcodeFields = (payload = {}) => {
    // 预约码后续流程至少依赖:访客姓名、接待人姓名、访客证件号三组信息。
    // 缺少这些核心字段时,即便二维码能解密成功,也不应继续按合法预约码放行。
    const hasVisitorName = hasAnyNonEmptyField(payload, [
        'visitorName',
        'visitor',
        'guestName',
        'name',
    ]);
    const hasHostName = hasAnyNonEmptyField(payload, [
        'beVisitorName',
        'intervieweeName',
        'receiverName',
        'hostName',
    ]);
    const hasVisitorIdentity = hasAnyNonEmptyField(payload, [
        'visitorIdCard',
        'idCard',
        'licId',
        'visitorIdNo',
        'visitorIdentityNo',
        'identityNo',
        'certificateNo',
        'certNo',
        'idCardMask',
    ]);
    return hasVisitorName && hasHostName && hasVisitorIdentity;
};
export const getAppointmentQrcodeTimeRange = (payload) => {
    return {
        startTime: parseAnyDateTime(payload?.startTime),
@@ -188,6 +225,13 @@
        };
    }
    if (!hasRequiredAppointmentQrcodeFields(payload)) {
        return {
            valid: false,
            reason: 'invalid_qrcode',
        };
    }
    const { startTime, endTime } = getAppointmentQrcodeTimeRange(payload);
    const now = new Date();
utils/visitorDevice.js
@@ -257,13 +257,13 @@
    const face = options?.face || '';
    const photo = options?.photo || '';
    showPluginDebugModal('开始人证比对调用', {
      method: 'startRecognition',
      options: {
        face,
        photo,
      },
    });
    // showPluginDebugModal('开始人证比对调用', {
    //   method: 'startRecognition',
    //   options: {
    //     face,
    //     photo,
    //   },
    // });
    // #ifdef APP-PLUS
    const methodName = getPluginMethod(['startRecognition']);
@@ -469,7 +469,7 @@
      }
      HardwarePlugin[methodName](options, (res) => {
        showPluginDebugModal('在线激活设备回调', res);
        // showPluginDebugModal('在线激活设备回调', res);
        if (res && res.code === 0) {
          resolve(res.data || res);
          return;
@@ -548,7 +548,7 @@
        });
        return;
      }
      showPluginDebugModal('打印凭证错误', '未找到打印方法 startPrint');
    //   showPluginDebugModal('打印凭证错误', '未找到打印方法 startPrint');
      reject(new Error('未找到打印方法 startPrint'));
      // #endif