feat: 优化多模块图片处理、离线同步与页面业务逻辑,修复已知问题
- 上传模块:新增URL归一化工具函数,清理上传返回的图片地址首尾空格与非法反引号
- 设备初始化页面:修复指纹获取失败的默认值,放开被注释的设备激活接口调用
- 预约扫描页面:优化人脸抓拍图清理逻辑,调整打印图片优先级,优化比对状态文案
- 员工接口模块:重构base64图片处理流程,新增脏数据清理逻辑,简化本地图片保存代码
- 首页:新增员工人脸同步调试弹窗,完善同步回调的错误处理与调试信息
- 同步引擎:移除冗余调试日志,修复同步payload处理逻辑,恢复图片上传同步功能
| | |
| | | }); |
| | | }; |
| | | |
| | | const sanitizeBase64String = (value = '') => { |
| | | return String(value || '') |
| | | // 清掉常见的零宽字符、BOM、方向控制字符等隐藏脏数据 |
| | | .replace(/[\u200B-\u200F\u202A-\u202E\u2060\uFEFF]/g, '') |
| | | .replace(/\s+/g, '') |
| | | // 仅保留 Base64 合法字符,避免把 dataURL 头或其他脏字符带入原生解码 |
| | | .replace(/[^A-Za-z0-9+/=]/g, ''); |
| | | }; |
| | | |
| | | const isBase64Image = (value = '') => { |
| | | return /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(String(value || '').trim()) |
| | | || /^[A-Za-z0-9+/=\r\n]+$/.test(String(value || '').trim()); |
| | | const normalizedValue = String(value || '').trim(); |
| | | if (!normalizedValue) { |
| | | return false; |
| | | } |
| | | |
| | | if (/data:image\/[a-zA-Z0-9+.-]+;base64,/i.test(normalizedValue)) { |
| | | return true; |
| | | } |
| | | |
| | | return sanitizeBase64String(normalizedValue).length > 0; |
| | | }; |
| | | |
| | | const getBase64Payload = (value = '') => { |
| | | const normalizedValue = String(value || '').trim(); |
| | | const dataUrlMatch = normalizedValue.match(/^data:image\/([a-zA-Z0-9+.-]+);base64,(.+)$/); |
| | | const dataUrlMatch = normalizedValue.match(/data:image\/([a-zA-Z0-9+.-]+);base64,/i); |
| | | if (dataUrlMatch) { |
| | | const base64Header = dataUrlMatch[0]; |
| | | const headerStartIndex = normalizedValue.indexOf(base64Header); |
| | | return { |
| | | extension: dataUrlMatch[1] === 'jpeg' ? 'jpg' : dataUrlMatch[1], |
| | | base64Data: dataUrlMatch[2], |
| | | // 兼容 dataURL 前面混入调试字符、零宽字符、换行等脏数据 |
| | | base64Data: sanitizeBase64String( |
| | | normalizedValue.slice(headerStartIndex + base64Header.length) |
| | | ), |
| | | }; |
| | | } |
| | | |
| | | return { |
| | | extension: 'png', |
| | | base64Data: normalizedValue.replace(/\s+/g, ''), |
| | | base64Data: sanitizeBase64String(normalizedValue), |
| | | }; |
| | | }; |
| | | |
| | |
| | | // #ifndef H5 |
| | | try { |
| | | const { extension, base64Data } = getBase64Payload(base64Value); |
| | | const fileName = `${fileNamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension}`; |
| | | |
| | | // 在 App 端使用原生的 plus.nativeObj.Bitmap 保存图片(最稳定,且能获取绝对路径供原生插件使用) |
| | | if (typeof plus !== 'undefined') { |
| | | const mimeType = extension === 'jpg' ? 'jpeg' : extension; |
| | | const fullBase64 = `data:image/${mimeType};base64,${base64Data}`; |
| | | const bitmap = new plus.nativeObj.Bitmap(`bmp_${Date.now()}`); |
| | | |
| | | bitmap.loadBase64Data(fullBase64, () => { |
| | | const filePath = `_doc/${EMPLOYEE_FACE_IMAGE_DIR}/${fileName}`; |
| | | bitmap.save(filePath, { overwrite: true }, (event) => { |
| | | bitmap.clear(); |
| | | // 转换为安卓绝对路径 (file:///storage/...),原生人脸比对插件必须用绝对路径 |
| | | const absolutePath = plus.io.convertLocalFileSystemURL(event.target); |
| | | resolve(absolutePath); |
| | | }, (error) => { |
| | | bitmap.clear(); |
| | | reject(error); |
| | | }); |
| | | }, (error) => { |
| | | reject(error); |
| | | }); |
| | | const normalizedBase64Data = sanitizeBase64String(base64Data); |
| | | if (!normalizedBase64Data) { |
| | | reject(new Error('Base64 数据为空')); |
| | | return; |
| | | } |
| | | |
| | | // 非 App 环境兜底走 FileSystemManager |
| | | const fs = uni.getFileSystemManager(); |
| | | const basePath = (uni.env && uni.env.USER_DATA_PATH) ? uni.env.USER_DATA_PATH : '_doc'; |
| | | const directoryPath = `${basePath}/${EMPLOYEE_FACE_IMAGE_DIR}`; |
| | | const filePath = `${directoryPath}/${fileName}`; |
| | | if (typeof plus === 'undefined' || !plus.nativeObj || !plus.nativeObj.Bitmap) { |
| | | reject(new Error('当前环境不支持 plus.nativeObj.Bitmap')); |
| | | return; |
| | | } |
| | | |
| | | const ensureDirAndWrite = () => { |
| | | fs.writeFile({ |
| | | filePath, |
| | | data: base64Data, |
| | | encoding: 'base64', |
| | | success: () => resolve(filePath), |
| | | fail: (error) => reject(error), |
| | | const fileName = `${fileNamePrefix}-${Date.now()}-${Math.random().toString(36).slice(2, 8)}.${extension || 'png'}`; |
| | | const filePath = `_doc/${EMPLOYEE_FACE_IMAGE_DIR}/${fileName}`; |
| | | const imageMimeType = extension === 'jpg' ? 'jpeg' : (extension || 'png'); |
| | | const imageStr = `data:image/${imageMimeType};base64,${normalizedBase64Data}`; |
| | | const bitmap = new plus.nativeObj.Bitmap(`employee_face_${Date.now()}`); |
| | | |
| | | bitmap.loadBase64Data(imageStr, () => { |
| | | bitmap.save(filePath, {}, (result) => { |
| | | bitmap.clear(); |
| | | const savedFilePath = plus.io.convertLocalFileSystemURL(result.target || filePath); |
| | | resolve(savedFilePath); |
| | | }, (error) => { |
| | | bitmap.clear(); |
| | | reject(error); |
| | | }); |
| | | }; |
| | | |
| | | fs.mkdir({ |
| | | dirPath: directoryPath, |
| | | recursive: true, |
| | | success: ensureDirAndWrite, |
| | | fail: () => ensureDirAndWrite(), |
| | | }, (error) => { |
| | | bitmap.clear(); |
| | | reject(error); |
| | | }); |
| | | } catch (error) { |
| | | reject(error); |
| | |
| | | import { buildBaseUrl, getRequestServerConfig } from '../utils/request'; |
| | | |
| | | const normalizeUploadUrl = (value = '') => { |
| | | return String(value || '') |
| | | .replace(/`/g, '') |
| | | .trim(); |
| | | }; |
| | | |
| | | export const uploadImage = (localFilePath) => { |
| | | return new Promise((resolve, reject) => { |
| | | const baseUrl = buildBaseUrl(getRequestServerConfig()); |
| | |
| | | success: (uploadFileRes) => { |
| | | try { |
| | | const data = JSON.parse(uploadFileRes.data); |
| | | // 适配常见的两种返回结构: data.url 或 data.fileName |
| | | const normalizedUrl = normalizeUploadUrl( |
| | | data.url || data.fileName || data.data?.url || '' |
| | | ); |
| | | |
| | | resolve({ |
| | | code: data.code, |
| | | url: data.url || data.fileName || data.data?.url |
| | | url: normalizedUrl |
| | | }); |
| | | } catch (e) { |
| | | reject(e); |
| | |
| | | }; |
| | | |
| | | // 清理访客现场抓拍图,避免本地临时图片堆积 |
| | | const cleanupLiveFacePhoto = async () => { |
| | | const cleanupLiveFacePhoto = async (options = {}) => { |
| | | const { preserveIdentityLivePhoto = false } = options; |
| | | if (!liveFacePhotoPath.value) { |
| | | return; |
| | | } |
| | | |
| | | const currentPath = liveFacePhotoPath.value; |
| | | liveFacePhotoPath.value = ''; |
| | | |
| | | // 人脸验证通过后,这张现场抓拍图还要继续用于后续打印, |
| | | // 步骤切换时只清理引用,不删除实体文件;真正离开页面时再统一删除。 |
| | | if (preserveIdentityLivePhoto && currentPath === appointmentIdCardPayload.value?.livePhotoAddress) { |
| | | return; |
| | | } |
| | | |
| | | try { |
| | | await deleteLocalImage(currentPath); |
| | |
| | | facePreviewReady.value = false; |
| | | // 中途只清理临时图片和上下文引用,真正的 stop 动作统一放到页面销毁时 |
| | | await Promise.allSettled([ |
| | | cleanupLiveFacePhoto(), |
| | | cleanupLiveFacePhoto({ preserveIdentityLivePhoto: true }), |
| | | cleanupHostLiveFacePhoto(), |
| | | ]); |
| | | faceCameraContext = null; |
| | |
| | | return; |
| | | } |
| | | |
| | | hostVerifyStatusText.value = '接待人人脸比对中...'; |
| | | hostVerifyStatusText.value = '人脸比对中...'; |
| | | let hostCompareResult = null; |
| | | if (isOnsiteHostVerify) { |
| | | hostCompareResult = await compareHostFaceWithAllEmployees(livePhotoAddress); |
| | |
| | | const getPrintFieldValueMap = () => { |
| | | const hostName = appointmentQrcodePayload.value?.beVisitorName || '无'; |
| | | const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无'); |
| | | const photoPath = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress || ''; |
| | | // 打印时优先使用已采集到的现场人脸图,只有没有抓拍图时才回退到身份证照片 |
| | | const photoPath = appointmentIdCardPayload.value?.livePhotoAddress |
| | | || liveFacePhotoPath.value |
| | | || appointmentIdCardPayload.value?.photoAddress |
| | | || ''; |
| | | |
| | | return { |
| | | visitorName: { |
| | |
| | | try { |
| | | deviceFingerprint.value = await HardwareAPI.getDeviceFingerprint(); |
| | | } catch (error) { |
| | | deviceFingerprint.value = '11111'; |
| | | deviceFingerprint.value = ''; |
| | | uni.showToast({ |
| | | title: '百度SDK指纹获取失败', |
| | | icon: 'none', |
| | |
| | | }); |
| | | |
| | | // 激活接口只关心业务参数,地址由公共请求层统一处理 |
| | | // await activateVisitMachine({ |
| | | // machineId, |
| | | // fingerprint: deviceFingerprint.value, |
| | | // }); |
| | | await activateVisitMachine({ |
| | | machineId, |
| | | fingerprint: deviceFingerprint.value, |
| | | }); |
| | | |
| | | // await HardwareAPI.offlineActivateDevice({ |
| | | // code: deviceFingerprint.value, |
| | | // }); |
| | | await HardwareAPI.offlineActivateDevice({ |
| | | code: deviceFingerprint.value, |
| | | }); |
| | | |
| | | uni.setStorageSync('device_id', machineId); |
| | | uni.setStorageSync('device_fingerprint', deviceFingerprint.value); |
| | |
| | | noticeDialog.value.visible = false; |
| | | }; |
| | | |
| | | const isBase64ImageValue = (value = '') => { |
| | | const normalizedValue = String(value || '').trim(); |
| | | if (!normalizedValue) { |
| | | return false; |
| | | } |
| | | |
| | | return /^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(normalizedValue) |
| | | || /^[A-Za-z0-9+/=\r\n]+$/.test(normalizedValue); |
| | | }; |
| | | |
| | | const isLocalEmployeeFacePath = (value = '') => { |
| | | const normalizedValue = String(value || '').trim(); |
| | | if (!normalizedValue) { |
| | | return false; |
| | | } |
| | | |
| | | return normalizedValue.includes('employee-face-images') |
| | | || normalizedValue.startsWith('_doc/') |
| | | || normalizedValue.startsWith('file:///') |
| | | || normalizedValue.startsWith('/storage/'); |
| | | }; |
| | | |
| | | const formatEmployeeSyncDebugMessage = (employeeList = []) => { |
| | | const list = Array.isArray(employeeList) ? employeeList : []; |
| | | const withFaceEmployees = list.filter((employee) => String(employee?.faceImage || '').trim() !== ''); |
| | | const localPathEmployees = withFaceEmployees.filter((employee) => isLocalEmployeeFacePath(employee?.faceImage)); |
| | | const base64Employees = withFaceEmployees.filter((employee) => isBase64ImageValue(employee?.faceImage)); |
| | | const successLines = localPathEmployees |
| | | .slice(0, 5) |
| | | .map((employee, index) => { |
| | | const faceImage = String(employee?.faceImage || '').trim(); |
| | | return `${index + 1}. ${employee?.employeeName || employee?.name || '未命名员工'}: ${faceImage}`; |
| | | }); |
| | | const sampleLines = withFaceEmployees |
| | | .filter((employee) => !isLocalEmployeeFacePath(employee?.faceImage)) |
| | | .slice(0, 5) |
| | | .map((employee, index) => { |
| | | const faceImage = String(employee?.faceImage || '').trim(); |
| | | const previewValue = faceImage ? faceImage.slice(0, 60) : '空'; |
| | | return `${index + 1}. ${employee?.employeeName || employee?.name || '未命名员工'}: ${previewValue}`; |
| | | }); |
| | | |
| | | return [ |
| | | `员工总数:${list.length}`, |
| | | `有人脸照片:${withFaceEmployees.length}`, |
| | | `已转本地路径:${localPathEmployees.length}`, |
| | | `仍是Base64:${base64Employees.length}`, |
| | | successLines.length > 0 ? `成功样本:\n${successLines.join('\n')}` : '成功样本:无', |
| | | sampleLines.length > 0 ? `异常样本:\n${sampleLines.join('\n')}` : '异常样本:无', |
| | | ].join('\n'); |
| | | }; |
| | | |
| | | const showEmployeeSyncDebugModal = (title, content) => { |
| | | uni.showModal({ |
| | | title, |
| | | content, |
| | | showCancel: false, |
| | | }); |
| | | }; |
| | | |
| | | const initHomeScanner = () => { |
| | | homeScanSessionId += 1; |
| | | const currentSessionId = homeScanSessionId; |
| | |
| | | |
| | | // 员工图片 base64 转本地文件比较重,放到首页后台静默处理,避免阻塞后续 live-pusher 拉起 |
| | | setTimeout(() => { |
| | | syncEmployeeVerifyList().catch((error) => { |
| | | console.error('首页后台同步员工验证列表失败:', error); |
| | | }); |
| | | syncEmployeeVerifyList() |
| | | .then((employeeList) => { |
| | | showEmployeeSyncDebugModal( |
| | | '员工人脸转换结果', |
| | | formatEmployeeSyncDebugMessage(employeeList) |
| | | ); |
| | | }) |
| | | .catch((error) => { |
| | | console.error('首页后台同步员工验证列表失败:', error); |
| | | showEmployeeSyncDebugModal( |
| | | '员工人脸转换失败', |
| | | String(error?.message || error || '未知错误') |
| | | ); |
| | | }); |
| | | }, 200); |
| | | }; |
| | | |
| | |
| | | */ |
| | | export const startSyncQueue = async () => { |
| | | if (isSyncing) { |
| | | console.log('[Sync] 当前正在同步中,跳过本次触发'); |
| | | // uni.showModal({ title: '同步诊断', content: '当前已有同步任务正在进行中,跳过本次触发', showCancel: false }); |
| | | return; |
| | | } |
| | | |
| | |
| | | }); |
| | | |
| | | if (networkType === 'none') { |
| | | console.log('[Sync] 当前无网络,取消同步'); |
| | | // uni.showModal({ title: '同步诊断', content: '检测到当前无网络 (networkType: none),取消同步', showCancel: false }); |
| | | return; |
| | | } |
| | | |
| | | isSyncing = true; |
| | | console.log('[Sync] 开始执行同步队列...'); |
| | | |
| | | try { |
| | | let pendingRecords = []; |
| | | try { |
| | | pendingRecords = await getPendingSyncRecords(); |
| | | } catch (dbErr) { |
| | | // uni.showModal({ title: '获取待同步数据失败', content: JSON.stringify(dbErr), showCancel: false }); |
| | | return; |
| | | } |
| | | |
| | | if (pendingRecords.length === 0) { |
| | | console.log('[Sync] 无待同步记录'); |
| | | // uni.showModal({ title: '同步诊断', content: '数据库中查询不到待同步记录', showCancel: false }); |
| | | isSyncing = false; |
| | | return; |
| | | } |
| | |
| | | const signoutDataToPull = []; |
| | | const recordsToSync = []; |
| | | |
| | | // uni.showModal({ |
| | | // title: '调试日志 (同步前)', |
| | | // content: `共查询到 ${pendingRecords.length} 条待同步记录`, |
| | | // showCancel: false |
| | | // }); |
| | | |
| | | for (let record of pendingRecords) { |
| | | // 如果创建时间距离当前超过 5 分钟,认为是离线堆积的旧数据,走批量 pull 接口 |
| | | const isOfflineOldData = Date.now() - record.create_time > 5 * 60 * 1000; |
| | | |
| | | try { |
| | | let payload = record.payload; |
| | | let payload = { |
| | | ...(record.payload || {}), |
| | | }; |
| | | |
| | | // [暂时跳过图片上传测试] |
| | | // 1. 如果存在本地图片,先上传图片 |
| | | /* |
| | | // 1. 如果存在本地图片,先上传图片,再把云端地址回填到业务 payload |
| | | if (record.local_img_path) { |
| | | const uploadRes = await uploadImage(record.local_img_path); |
| | | if (uploadRes && uploadRes.code === 200) { |
| | | if (uploadRes && uploadRes.code === 200 && uploadRes.url) { |
| | | // 根据记录类型,将云端 URL 赋值给不同的业务 payload 字段 |
| | | if (record.type === 'signout') { |
| | | payload.signImageUrl = uploadRes.url; |
| | |
| | | throw new Error('图片上传失败'); |
| | | } |
| | | } |
| | | */ |
| | | |
| | | if (isOfflineOldData) { |
| | | // 离线旧数据收集到数组中,准备批量提交 |
| | |
| | | } |
| | | } catch (innerErr) { |
| | | console.error(`[Sync] 处理记录 ${record.id} 的图片或数据时失败:`, innerErr); |
| | | // 通过弹窗暴露底层失败原因 |
| | | // uni.showModal({ |
| | | // title: '离线同步诊断 (图片/前置)', |
| | | // content: `同步记录被阻断:\n${innerErr.message || JSON.stringify(innerErr)}`, |
| | | // showCancel: false |
| | | // }); |
| | | // 如果处理单条前置(如传图)失败,跳过该条,等待下次轮询 |
| | | } |
| | | } |
| | | |
| | | // uni.showModal({ |
| | | // title: '调试日志 (分流结果)', |
| | | // content: `单条实时: ${recordsToSync.length}条\n批量签到: ${signinDataToPull.length}条\n批量签离: ${signoutDataToPull.length}条`, |
| | | // showCancel: false |
| | | // }); |
| | | |
| | | // 2. 提交实时单条业务数据 |
| | | for (let item of recordsToSync) { |
| | | try { |
| | | let submitRes; |
| | | if (item.type === 'signout') { |
| | | // uni.showModal({ |
| | | // title: '调试日志 (单条同步)', |
| | | // content: `准备调用签离接口: /api/visitorRecord/idCardSingOut\n参数: ${JSON.stringify(item.payload)}`, |
| | | // showCancel: false |
| | | // }); |
| | | submitRes = await idCardSingOut(item.payload); |
| | | } else { |
| | | // type 为 appointment, online, 或它们加上 _signedout 后缀时,都作为进场记录同步 |
| | | // uni.showModal({ |
| | | // title: '调试日志 (单条同步)', |
| | | // content: `准备调用签到接口: /api/visitorRecord/add\n参数: ${JSON.stringify(item.payload)}`, |
| | | // showCancel: false |
| | | // }); |
| | | submitRes = await addVisitorRecord(item.payload); |
| | | } |
| | | |
| | | if (submitRes && submitRes.code === 200) { |
| | | await markRecordSynced(item.recordId); |
| | | if (item.localImgPath) await deleteLocalImage(item.localImgPath); |
| | | console.log(`[Sync] 记录 ${item.recordId} 同步成功 (单条实时)`); |
| | | // uni.showModal({ title: '单条同步成功', content: `记录 ${item.recordId} 同步成功`, showCancel: false }); |
| | | } else { |
| | | // uni.showModal({ title: '单条同步失败', content: `接口返回: ${JSON.stringify(submitRes)}`, showCancel: false }); |
| | | } |
| | | } catch (err) { |
| | | console.error(`[Sync] 单条同步 ${item.recordId} 失败:`, err); |
| | | // uni.showModal({ title: '单条同步异常', content: `错误信息: ${err.message || JSON.stringify(err)}`, showCancel: false }); |
| | | } |
| | | } |
| | | |
| | |
| | | await markRecordSynced(item.recordId); |
| | | if (item.localImgPath) await deleteLocalImage(item.localImgPath); |
| | | } |
| | | console.log(`[Sync] 批量补发签到数据 ${signinDataToPull.length} 条成功`); |
| | | // uni.showModal({ title: '批量签到同步成功', content: `成功同步 ${signinDataToPull.length} 条`, showCancel: false }); |
| | | } else { |
| | | // uni.showModal({ title: '批量签到同步失败', content: `接口返回: ${JSON.stringify(pullRes)}`, showCancel: false }); |
| | | } |
| | | } catch (err) { |
| | | console.error('[Sync] 批量补发签到数据失败:', err); |
| | | // uni.showModal({ title: '批量签到同步异常', content: `错误信息: ${err.message || JSON.stringify(err)}`, showCancel: false }); |
| | | } |
| | | } |
| | | |
| | |
| | | await markRecordSynced(item.recordId); |
| | | if (item.localImgPath) await deleteLocalImage(item.localImgPath); |
| | | } |
| | | console.log(`[Sync] 批量补发签离数据 ${signoutDataToPull.length} 条成功`); |
| | | // uni.showModal({ title: '批量签离同步成功', content: `成功同步 ${signoutDataToPull.length} 条`, showCancel: false }); |
| | | } else { |
| | | // uni.showModal({ title: '批量签离同步失败', content: `接口返回: ${JSON.stringify(pullRes)}`, showCancel: false }); |
| | | } |
| | | } catch (err) { |
| | | console.error('[Sync] 批量补发签离数据失败:', err); |
| | | // uni.showModal({ title: '批量签离同步异常', content: `错误信息: ${err.message || JSON.stringify(err)}`, showCancel: false }); |
| | | } |
| | | } |
| | | |
| | | } catch (error) { |
| | | console.error('[Sync] 队列执行异常:', error); |
| | | // uni.showModal({ title: '队列执行异常', content: `错误信息: ${error.message || JSON.stringify(error)}`, showCancel: false }); |
| | | } finally { |
| | | isSyncing = false; |
| | | } |
| | |
| | | * 每日凌晨或空闲时清理超期废弃数据并同步最新配置字典 |
| | | */ |
| | | export const runDailyCleanup = async () => { |
| | | console.log('[Sync] 执行超期数据清理与基础数据同步...'); |
| | | |
| | | // 1. 清理过期业务记录 |
| | | cleanOldRecords().catch(err => { |
| | | console.error('[Sync] 清理过期记录失败:', err); |
| | |
| | | const res = await getVisitAreaTree(); |
| | | if (res && res.code === 200 && Array.isArray(res.data)) { |
| | | await saveOfflineConfig('visit_area_tree', res.data); |
| | | console.log('[Sync] 每日同步:访问区域数据已更新至本地缓存'); |
| | | } |
| | | } |
| | | } catch (err) { |