| App.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/appointment-scan/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/appointment-scan/n-index.nvue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| pages/home/index.vue | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| utils/fs.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| utils/hikvisionQrcode.js | ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史 | |
| utils/visitorDevice.js | ●●●●● 补丁 | 查看 | 原始文档 | 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