feat: 优化访客系统多模块功能,修复各类问题
- 新增访客待确认(PENDING_CONFIRM)状态展示
- 重构海康威视预约码校验逻辑,移除冗余字段别名,精简必填字段判断并新增预约时间校验
- 提取复用二维码解析调试弹窗函数,方便开发调试扫码功能
- 为人脸验证成功后添加1秒延迟,给用户明确的操作成功反馈
- 修复设备初始化页面的设备ID回填逻辑,使用百度SDK设备指纹替代不稳定的deviceId
- 优化管理员记录导出功能,适配app-plus环境的文件操作,修复导出失败问题
- 调整日期筛选默认值为今天,优化筛选体验
- 优化预约码已进场的提示文案,提升提示准确性
- 临时注释掉host-item的align-items样式用于调试
| | |
| | | activeOverlay.value = ''; |
| | | }; |
| | | |
| | | const getTodayDateString = () => { |
| | | const today = new Date(); |
| | | const year = today.getFullYear(); |
| | | const month = String(today.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(today.getDate()).padStart(2, '0'); |
| | | return `${year}-${month}-${day}`; |
| | | }; |
| | | |
| | | const openAreaPicker = () => { |
| | | tempArea.value = filters.area; |
| | | const [parent] = filters.area.split(' / '); |
| | |
| | | }; |
| | | |
| | | const openDatePicker = () => { |
| | | tempStartDate.value = filters.startDate; |
| | | tempEndDate.value = filters.endDate; |
| | | const defaultDate = getTodayDateString(); |
| | | tempStartDate.value = filters.startDate || defaultDate; |
| | | tempEndDate.value = filters.endDate || defaultDate; |
| | | activeDateField.value = 'start'; |
| | | syncDatePicker(tempStartDate.value); |
| | | activeOverlay.value = 'date'; |
| | |
| | | // #endif |
| | | |
| | | // #ifndef H5 |
| | | const fs = uni.getFileSystemManager(); |
| | | const filePath = `${uni.env.USER_DATA_PATH}/${fileName}`; |
| | | if (typeof plus === 'undefined' || !plus.io) { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '当前环境不支持导出', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | fs.writeFile({ |
| | | filePath, |
| | | data: csvString, |
| | | encoding: 'utf8', |
| | | success: () => { |
| | | uni.hideLoading(); |
| | | uni.openDocument({ |
| | | filePath, |
| | | showMenu: true, |
| | | success: () => { |
| | | console.log('打开文档成功'); |
| | | }, |
| | | fail: (err) => { |
| | | console.error('打开文件失败', err); |
| | | // 兜底策略:如果 openDocument 失败,尝试用 plus.runtime.openFile |
| | | if (typeof plus !== 'undefined' && plus.runtime) { |
| | | plus.io.resolveLocalFileSystemURL(filePath, (entry) => { |
| | | plus.runtime.openFile(entry.toLocalURL()); |
| | | }); |
| | | } else { |
| | | uni.showToast({ title: '打开文件失败', icon: 'none' }); |
| | | } |
| | | } |
| | | plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => { |
| | | docEntry.getFile(fileName, { create: true }, (fileEntry) => { |
| | | fileEntry.createWriter((writer) => { |
| | | writer.onwrite = () => { |
| | | uni.hideLoading(); |
| | | const filePath = fileEntry.toLocalURL(); |
| | | uni.openDocument({ |
| | | filePath, |
| | | showMenu: true, |
| | | success: () => { |
| | | console.log('打开文档成功'); |
| | | }, |
| | | fail: (err) => { |
| | | console.error('打开文件失败', err); |
| | | if (plus.runtime && typeof plus.runtime.openFile === 'function') { |
| | | plus.runtime.openFile(filePath); |
| | | } else { |
| | | uni.showToast({ title: '打开文件失败', icon: 'none' }); |
| | | } |
| | | } |
| | | }); |
| | | }; |
| | | |
| | | writer.onerror = (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '文件保存失败', icon: 'none' }); |
| | | console.error('文件保存失败:', err); |
| | | }; |
| | | |
| | | writer.seek(0); |
| | | writer.truncate(0); |
| | | writer.write(csvString); |
| | | }, (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '创建文件写入器失败', icon: 'none' }); |
| | | console.error('创建文件写入器失败:', err); |
| | | }); |
| | | }, |
| | | fail: (err) => { |
| | | }, (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '文件保存失败', icon: 'none' }); |
| | | console.error('文件保存失败:', err); |
| | | } |
| | | uni.showToast({ title: '创建导出文件失败', icon: 'none' }); |
| | | console.error('创建导出文件失败:', err); |
| | | }); |
| | | }, (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '访问本地目录失败', icon: 'none' }); |
| | | console.error('访问_doc目录失败:', err); |
| | | }); |
| | | // #endif |
| | | |
| | |
| | | return { text: '来访中', class: 'stay' }; |
| | | case 'SIGNED_OUT': |
| | | return { text: '已签离', class: 'done' }; |
| | | case 'PENDING_CONFIRM': |
| | | case 'OVERDUE': |
| | | return { text: '逾期滞留', class: 'warn' }; |
| | | case 'OVERDUE_AUTO_SIGNED_OUT': |
| | |
| | | .host-item, |
| | | .permission-title-row { |
| | | display: flex; |
| | | align-items: center; |
| | | /* align-items: center; */ |
| | | gap: 10px; |
| | | } |
| | | |
| | |
| | | // 手动录入身份证时没有证件照,跳过人证比对,仅保留现场人脸图 |
| | | if (isManualIdentityInput) { |
| | | faceVerifyStatusText.value = '人脸采集成功'; |
| | | await waitBeforeEnterHostStep(); |
| | | nextStep(); |
| | | return; |
| | | } |
| | |
| | | } |
| | | |
| | | faceVerifyStatusText.value = '人证比对成功'; |
| | | await waitBeforeEnterHostStep(); |
| | | nextStep(); |
| | | } catch (error) { |
| | | if (sessionId !== judgeFaceSessionId) { |
| | |
| | | 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); |
| | | if (validationResult.valid) { |
| | |
| | | if (validationResult.reason === 'out_of_time_range') { |
| | | openNoticeDialog( |
| | | '预约码不在进场时间内,请联系工作人员', |
| | | '请核对预约时段信息,或前往前台获取现场协助' |
| | | '请检查您的预约时间' |
| | | ); |
| | | return false; |
| | | } |
| | | |
| | | if (validationResult.reason === 'already_entered') { |
| | | openNoticeDialog( |
| | | '您已进场,请先签离', |
| | | '' |
| | | '该预约码已进场', |
| | | '您已进场,请签离' |
| | | ); |
| | | return false; |
| | | } |
| | |
| | | |
| | | try { |
| | | const decryptedResult = await parseHikvisionQrcodePayload(scanData); |
| | | await showQrcodeParseDebugModal(scanData, decryptedResult); |
| | | |
| | | // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用 |
| | | appointmentQrcodePayload.value = decryptedResult.payload; |
| | |
| | | const loadDeviceFingerprint = async () => { |
| | | try { |
| | | deviceFingerprint.value = await HardwareAPI.getDeviceFingerprint(); |
| | | formRows[0].value = deviceFingerprint.value; |
| | | } catch (error) { |
| | | deviceFingerprint.value = ''; |
| | | formRows[0].value = ''; |
| | | uni.showToast({ |
| | | title: '百度SDK指纹获取失败', |
| | | icon: 'none', |
| | |
| | | // }); |
| | | // 只有设备已经完整激活过,才跳过激活页直接进首页 |
| | | if (hasActivatedDevice()) { |
| | | formRows[0].value = deviceId || ''; |
| | | formRows[0].value = storedDeviceFingerprint || deviceId || ''; |
| | | deviceFingerprint.value = storedDeviceFingerprint || ''; |
| | | uni.redirectTo({ |
| | | url: '/pages/home/index', |
| | |
| | | }); |
| | | |
| | | onMounted(() => { |
| | | // 自动回填设备唯一标识 |
| | | // #ifdef APP-PLUS |
| | | const systemInfo = uni.getSystemInfoSync(); |
| | | const deviceId = systemInfo.deviceId; |
| | | console.log(deviceId, 'deviceId'); |
| | | formRows[0].value = deviceId; |
| | | // #endif |
| | | |
| | | // #ifndef APP-PLUS |
| | | formRows[0].value = `DEV-H5-TEST-${Math.random().toString(36).slice(2, 8).toUpperCase()}`; |
| | | // #endif |
| | | |
| | | // 设备指纹进入页面就自动获取,激活时直接复用 |
| | | // 使用百度 SDK 返回的设备指纹作为稳定设备唯一标识,避免 systemInfo.deviceId 在高版本安卓上漂移 |
| | | loadDeviceFingerprint(); |
| | | }); |
| | | |
| | |
| | | return; |
| | | } |
| | | |
| | | const machineId = formRows[0].value; |
| | | const machineId = deviceFingerprint.value.trim(); |
| | | const ip = formRows[1].value.trim(); |
| | | const port = formRows[2].value.trim(); |
| | | const previousServerConfig = getRequestServerConfig(); |
| | |
| | | noticeDialog.value.visible = false; |
| | | }; |
| | | |
| | | 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 initHomeScanner = () => { |
| | | homeScanSessionId += 1; |
| | | const currentSessionId = homeScanSessionId; |
| | |
| | | try { |
| | | const decryptedResult = await parseHikvisionQrcodePayload(scanData); |
| | | const payload = decryptedResult.payload; |
| | | |
| | | // await new Promise((resolve) => { |
| | | // uni.showModal({ |
| | | // title: '二维码解析结果', |
| | | // content: `原始内容:${scanData}\n\n解密结果:${decryptedResult.plainText || '无'}\n\n解析对象:${JSON.stringify(payload || {}, null, 2)}`, |
| | | // showCancel: false, |
| | | // success: () => resolve(), |
| | | // fail: () => resolve(), |
| | | // }); |
| | | // }); |
| | | await showQrcodeParseDebugModal(scanData, decryptedResult); |
| | | |
| | | const validation = validateAppointmentQrcodePayload(payload); |
| | | if (!validation.valid) { |
| | |
| | | }; |
| | | |
| | | 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', |
| | | ]); |
| | | const hasAppointmentTime = hasAnyNonEmptyField(payload, [ |
| | | 'startTime', |
| | | 'endTime', |
| | | ]); |
| | | |
| | | return hasVisitorName && hasHostName && hasVisitorIdentity; |
| | | return hasVisitorName && hasHostName && hasVisitorIdentity && hasAppointmentTime; |
| | | }; |
| | | |
| | | export const getAppointmentQrcodeTimeRange = (payload) => { |