feat(visitor,admin,home): 优化多模块功能并重构访客导出流程
- 新增访客记录后端导出接口,重构管理后台导出功能适配多端环境
- 暴露员工本地缓存读取函数为公共API,优化预约页面员工验证加载逻辑
- 升级request工具类,新增responseType参数支持二进制请求
- 完善首页初始化流程,添加全局加载遮罩并调整员工同步时机
- 注释多处调试用弹窗,关闭默认5分钟定时同步队列,优化离线同步逻辑并添加调试弹窗
- 完善版本更新功能,实现版本检查、下载APK包和自动安装的完整流程
- 优化预约打印字段配置,适配后台配置的字段顺序并兼容旧版字段名
| | |
| | | HardwareAPI.init().then(async () => { |
| | | console.log('硬件插件全局初始化完成'); |
| | | await this.restoreOfflineActivation(); |
| | | |
| | | |
| | | // 全局开启扫码和读卡监听,避免多线程干扰 |
| | | HardwareAPI.startScan((res) => { |
| | | uni.$emit('Hardware-Scan', res); |
| | |
| | | |
| | | runDailyCleanup(); |
| | | |
| | | setInterval(() => { |
| | | startSyncQueue(); |
| | | }, 5 * 60 * 1000); |
| | | // setInterval(() => { |
| | | // startSyncQueue(); |
| | | // }, 5 * 60 * 1000); |
| | | }, |
| | | onShow() { |
| | | console.log('App Show'); |
| | |
| | | } |
| | | }; |
| | | |
| | | const getEmployeeVerifyListFromLocalCache = async () => { |
| | | export const getEmployeeVerifyListFromLocalCache = async () => { |
| | | const offlineEmployeeList = await getOfflineConfig(EMPLOYEE_VERIFY_LIST_KEY); |
| | | const storageEmployeeList = uni.getStorageSync(EMPLOYEE_VERIFY_LIST_KEY); |
| | | const localEmployeeList = Array.isArray(offlineEmployeeList) |
| | |
| | | }); |
| | | }; |
| | | |
| | | export const exportVisitorRecord = (data) => { |
| | | return request({ |
| | | url: '/api/visitorRecord/export', |
| | | method: 'POST', |
| | | data, |
| | | responseType: 'arraybuffer', // 添加 responseType 支持二进制流,如果没有影响 JSON 解析 |
| | | }); |
| | | }; |
| | | |
| | | export const getVisitorRecordDetail = (data) => { |
| | | return request({ |
| | | url: '/api/visitorRecord/detail', |
| | |
| | | <image :src="updateHintIcon" class="inline-icon" mode="aspectFit"></image> |
| | | <text class="update-text">检测到新版本 {{ latestVersion }} 可用</text> |
| | | </view> |
| | | <view class="update-btn"> |
| | | <view class="update-btn" @click="handleVersionUpdate"> |
| | | <image :src="updateBtnIcon" class="banner-btn-icon" mode="aspectFit"></image> |
| | | <text class="update-btn-text">立即更新</text> |
| | | <text class="update-btn-text">{{ isUpdatingVersion ? '下载中...' : '立即更新' }}</text> |
| | | </view> |
| | | </view> |
| | | <view class="update-banner" v-else> |
| | |
| | | import { getLatestVersion } from '../../api/visitorRecord'; |
| | | import { clearRequestServerConfig, getRequestServerConfig, setRequestServerConfig } from '../../utils/request'; |
| | | import { syncAllPendingRecords } from '../../utils/syncEngine'; |
| | | import { downloadRemoteFileToLocal } from '../../utils/fs'; |
| | | |
| | | const VISITOR_MACHINE_IP_KEY = 'visitor_machine_ip'; |
| | | const visitorIp = ref(''); |
| | |
| | | const showLogoutHoldDialog = ref(false); |
| | | const isLoggingOut = ref(false); |
| | | const isManualSyncing = ref(false); |
| | | const isUpdatingVersion = ref(false); |
| | | const logoutHoldTimer = ref(null); |
| | | |
| | | const currentVersion = ref('v1.0.0'); |
| | | const latestVersion = ref(''); |
| | | const hasNewVersion = ref(false); |
| | | const latestVersionInfo = ref(null); |
| | | |
| | | const normalizeVersionNumber = (version = '') => { |
| | | return String(version || '').trim().replace(/^[vV]/, ''); |
| | | }; |
| | | |
| | | const normalizeIpValue = (value = '') => { |
| | | return String(value).trim().replace(/^https?:\/\//, ''); |
| | |
| | | title: '保存成功', |
| | | icon: 'success', |
| | | }); |
| | | }; |
| | | |
| | | const handleVersionUpdate = async () => { |
| | | if (isUpdatingVersion.value) { |
| | | return; |
| | | } |
| | | |
| | | const packageUrl = latestVersionInfo.value?.packageUrl || ''; |
| | | const packageName = latestVersionInfo.value?.packageName || 'visitor-update.apk'; |
| | | |
| | | if (!hasNewVersion.value || !packageUrl) { |
| | | uni.showToast({ |
| | | title: '未获取到更新包地址', |
| | | icon: 'none', |
| | | }); |
| | | return; |
| | | } |
| | | |
| | | // #ifndef APP-PLUS |
| | | uni.showToast({ |
| | | title: '当前环境不支持安装更新包', |
| | | icon: 'none', |
| | | }); |
| | | return; |
| | | // #endif |
| | | |
| | | try { |
| | | isUpdatingVersion.value = true; |
| | | uni.showLoading({ |
| | | title: '更新包下载中...', |
| | | mask: true, |
| | | }); |
| | | |
| | | const fileNamePrefix = String(packageName || 'visitor-update') |
| | | .replace(/\.[^.]+$/, '') |
| | | .replace(/[^a-zA-Z0-9_-]/g, '-') || 'visitor-update'; |
| | | const localPackagePath = await downloadRemoteFileToLocal(packageUrl, { |
| | | directoryName: 'apk-updates', |
| | | fileNamePrefix, |
| | | fallbackExtension: 'apk', |
| | | }); |
| | | |
| | | uni.hideLoading(); |
| | | |
| | | plus.runtime.install(localPackagePath, {}, () => { |
| | | uni.showToast({ |
| | | title: '安装包已打开,请按提示完成更新', |
| | | icon: 'none', |
| | | }); |
| | | }, (error) => { |
| | | console.error('安装更新包失败:', error); |
| | | uni.showToast({ |
| | | title: error?.message || '安装更新包失败', |
| | | icon: 'none', |
| | | }); |
| | | }); |
| | | } catch (error) { |
| | | console.error('下载更新包失败:', error); |
| | | uni.showToast({ |
| | | title: error?.message || '下载更新包失败', |
| | | icon: 'none', |
| | | }); |
| | | } finally { |
| | | uni.hideLoading(); |
| | | isUpdatingVersion.value = false; |
| | | } |
| | | }; |
| | | |
| | | const handleManualSync = async () => { |
| | |
| | | |
| | | const checkLatestVersion = async () => { |
| | | try { |
| | | const appWgtVersion = uni.getAppBaseInfo().appWgtVersion || ''; |
| | | currentVersion.value = appWgtVersion; |
| | | const appWgtVersion = uni.getAppBaseInfo().appWgtVersion || ''; |
| | | currentVersion.value = normalizeVersionNumber(appWgtVersion) || 'v1.0.0'; |
| | | const res = await getLatestVersion(); |
| | | if (res && res.code === 200 && res.data) { |
| | | // 假设后端返回的数据为版本号字符串或者包含 version 字段的对象 |
| | | const versionStr = typeof res.data === 'object' ? (res.data.version || res.data.versionName || '') : res.data; |
| | | if (versionStr) { |
| | | latestVersion.value = versionStr; |
| | | // 简单比对:如果获取到的版本和当前不一致,则认为是新版本(实际业务中可换成语义化版本对比库) |
| | | if (latestVersion.value !== currentVersion.value) { |
| | | hasNewVersion.value = true; |
| | | } else { |
| | | hasNewVersion.value = false; |
| | | } |
| | | } |
| | | const versionData = typeof res.data === 'object' ? res.data : {}; |
| | | const versionStr = normalizeVersionNumber(versionData.versionNo || ''); |
| | | |
| | | latestVersionInfo.value = versionData; |
| | | latestVersion.value = versionStr; |
| | | hasNewVersion.value = Boolean(versionStr && versionStr !== normalizeVersionNumber(currentVersion.value)); |
| | | } |
| | | } catch (error) { |
| | | console.error('获取最新版本失败:', error); |
| | | hasNewVersion.value = false; |
| | | } |
| | | }; |
| | | |
| | |
| | | |
| | | <script setup> |
| | | import { computed, reactive, ref, onMounted } from 'vue'; |
| | | import { getVisitorRecordPage } from '../../api/visitorRecord'; |
| | | import { getVisitorRecordPage, exportVisitorRecord } from '../../api/visitorRecord'; |
| | | import { getVisitAreaTree } from '../../api/visitArea'; |
| | | import { saveHistoryRecords, getCombinedHistoryRecords, getOfflineConfig } from '../../utils/db'; |
| | | import resetIcon from '../../static/admin-pages/slDEC.png'; |
| | |
| | | closeOverlay(); |
| | | }; |
| | | |
| | | const handleExport = () => { |
| | | const handleExport = async () => { |
| | | if (totalRecords.value === 0) { |
| | | uni.showToast({ title: '暂无数据可导出', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | uni.showLoading({ title: '正在生成导出文件...' }); |
| | | uni.showLoading({ title: '正在请求导出...' }); |
| | | try { |
| | | const headers = ['访客姓名', '身份证号', '被访人', '部门', '登记方式', '访问区域', '来访事由', '状态', '有效期']; |
| | | const escapeHtml = (value) => { |
| | | if (value === null || value === undefined) { |
| | | return ''; |
| | | } |
| | | |
| | | return String(value) |
| | | .replace(/&/g, '&') |
| | | .replace(/</g, '<') |
| | | .replace(/>/g, '>') |
| | | .replace(/"/g, '"') |
| | | .replace(/'/g, '''); |
| | | const payload = { |
| | | pageNum: 1, |
| | | pageSize: 9999, |
| | | intervieweeKeyword: appliedFilters.host, |
| | | intervieweeDept: appliedFilters.department, |
| | | visitorKeyword: appliedFilters.guest, |
| | | visitAreaKeyword: getSelectedAreaLeafName(appliedFilters.area), |
| | | visitReason: appliedFilters.reason, |
| | | validStartTime: formatFilterDateTime(appliedFilters.startDate), |
| | | validEndTime: formatFilterDateTime(appliedFilters.endDate, true), |
| | | }; |
| | | |
| | | let tableRows = '<tr>'; |
| | | headers.forEach((header) => { |
| | | tableRows += `<th>${escapeHtml(header)}</th>`; |
| | | }); |
| | | tableRows += '</tr>'; |
| | | const res = await exportVisitorRecord(payload); |
| | | |
| | | records.value.forEach(item => { |
| | | const hostName = (item.host || '').split('(')[0] || item.host; |
| | | const deptName = item.department; |
| | | // 如果返回的是二进制流 (ArrayBuffer) |
| | | if (res instanceof ArrayBuffer) { |
| | | const fileName = `访客记录_${Date.now()}.xls`; |
| | | |
| | | tableRows += '<tr>'; |
| | | tableRows += `<td>${escapeHtml(item.name)}</td>`; |
| | | // 用文本格式输出身份证,避免表格软件自动转成科学计数法或丢失末尾 X。 |
| | | tableRows += `<td style="mso-number-format:'\\@';">${escapeHtml(item.idCard)}</td>`; |
| | | tableRows += `<td>${escapeHtml(hostName)}</td>`; |
| | | tableRows += `<td>${escapeHtml(deptName)}</td>`; |
| | | tableRows += `<td>${escapeHtml(item.type)}</td>`; |
| | | tableRows += `<td>${escapeHtml(item.area)}</td>`; |
| | | tableRows += `<td>${escapeHtml(item.reason)}</td>`; |
| | | tableRows += `<td>${escapeHtml(item.status)}</td>`; |
| | | tableRows += `<td>${escapeHtml(item.time)}</td>`; |
| | | tableRows += '</tr>'; |
| | | }); |
| | | |
| | | const excelString = `\uFEFF |
| | | <html xmlns:o="urn:schemas-microsoft-com:office:office" |
| | | xmlns:x="urn:schemas-microsoft-com:office:excel" |
| | | xmlns="http://www.w3.org/TR/REC-html40"> |
| | | <head> |
| | | <meta charset="UTF-8"> |
| | | <!-- 让 Excel/WPS 按工作表渲染,避免安卓端把文件识别成普通文本 --> |
| | | </head> |
| | | <body> |
| | | <table border="1"> |
| | | ${tableRows} |
| | | </table> |
| | | </body> |
| | | </html>`; |
| | | |
| | | const fileName = `访客记录_${Date.now()}.xls`; |
| | | |
| | | // #ifdef H5 |
| | | const blob = new Blob([excelString], { type: 'application/vnd.ms-excel;charset=utf-8' }); |
| | | const url = URL.createObjectURL(blob); |
| | | const a = document.createElement('a'); |
| | | a.href = url; |
| | | a.download = fileName; |
| | | document.body.appendChild(a); |
| | | a.click(); |
| | | document.body.removeChild(a); |
| | | URL.revokeObjectURL(url); |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '导出成功', icon: 'success' }); |
| | | // #endif |
| | | |
| | | // #ifndef H5 |
| | | if (typeof plus === 'undefined' || !plus.io) { |
| | | // #ifdef H5 |
| | | const blob = new Blob([res], { type: 'application/vnd.ms-excel' }); |
| | | const url = URL.createObjectURL(blob); |
| | | const a = document.createElement('a'); |
| | | a.href = url; |
| | | a.download = fileName; |
| | | document.body.appendChild(a); |
| | | a.click(); |
| | | document.body.removeChild(a); |
| | | URL.revokeObjectURL(url); |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '当前环境不支持导出', icon: 'none' }); |
| | | return; |
| | | } |
| | | uni.showToast({ title: '导出成功', icon: 'none' }); |
| | | // #endif |
| | | |
| | | plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => { |
| | | docEntry.getFile(fileName, { create: true }, (fileEntry) => { |
| | | fileEntry.createWriter((writer) => { |
| | | writer.onwrite = () => { |
| | | // #ifndef H5 |
| | | if (typeof plus === 'undefined' || !plus.io) { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '当前环境不支持导出', icon: 'none' }); |
| | | return; |
| | | } |
| | | |
| | | // 保存到本地 |
| | | plus.io.resolveLocalFileSystemURL('_doc/', (docEntry) => { |
| | | docEntry.getFile(fileName, { create: true }, (fileEntry) => { |
| | | fileEntry.createWriter((writer) => { |
| | | writer.onwrite = () => { |
| | | uni.hideLoading(); |
| | | const filePath = fileEntry.toLocalURL(); |
| | | console.log('访客记录导出成功,文件路径:', filePath); |
| | | uni.showToast({ title: '导出成功,文件地址:' + filePath, icon: 'none' }); |
| | | }; |
| | | writer.onerror = (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '文件保存失败', icon: 'none' }); |
| | | console.error('文件保存失败:', err); |
| | | }; |
| | | writer.seek(0); |
| | | writer.truncate(0); |
| | | writer.writeAsBinary(res); // 使用 writeAsBinary 写入 ArrayBuffer |
| | | }, (err) => { |
| | | uni.hideLoading(); |
| | | const filePath = fileEntry.toLocalURL(); |
| | | console.log('访客记录导出成功,文件路径:', filePath); |
| | | uni.showToast({ title: '导出成功,文件地址:' + filePath, icon: 'none' }); |
| | | }; |
| | | |
| | | writer.onerror = (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '文件保存失败', icon: 'none' }); |
| | | console.error('文件保存失败:', err); |
| | | }; |
| | | |
| | | writer.seek(0); |
| | | writer.truncate(0); |
| | | writer.write(excelString); |
| | | console.error('创建文件写入器失败:', err); |
| | | }); |
| | | }, (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '创建文件写入器失败', icon: 'none' }); |
| | | console.error('创建文件写入器失败:', err); |
| | | console.error('创建导出文件失败:', err); |
| | | }); |
| | | }, (err) => { |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '创建导出文件失败', icon: 'none' }); |
| | | console.error('创建导出文件失败:', err); |
| | | console.error('访问_doc目录失败:', err); |
| | | }); |
| | | }, (err) => { |
| | | // #endif |
| | | return; |
| | | } |
| | | |
| | | // 如果接口返回的是 JSON 包裹的文件地址 |
| | | if (res && typeof res === 'string' && res.startsWith('http')) { |
| | | // #ifdef H5 |
| | | window.open(res); |
| | | // #endif |
| | | // #ifndef H5 |
| | | uni.downloadFile({ |
| | | url: res, |
| | | success: (downloadRes) => { |
| | | if (downloadRes.statusCode === 200) { |
| | | console.log('访客记录导出成功,文件路径:', downloadRes.tempFilePath); |
| | | uni.showToast({ title: '导出成功,文件地址:' + downloadRes.tempFilePath, icon: 'none' }); |
| | | } |
| | | } |
| | | }); |
| | | // #endif |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '访问本地目录失败', icon: 'none' }); |
| | | console.error('访问_doc目录失败:', err); |
| | | }); |
| | | // #endif |
| | | return; |
| | | } |
| | | |
| | | // 如果返回的是业务 JSON (如后端生成文件失败等) |
| | | uni.hideLoading(); |
| | | uni.showToast({ title: '导出成功', icon: 'success' }); |
| | | |
| | | } catch (error) { |
| | | uni.hideLoading(); |
| | |
| | | |
| | | const errorMsg = error && error.detail ? JSON.stringify(error.detail) : JSON.stringify(error); |
| | | |
| | | uni.showModal({ |
| | | title: '摄像头报错调试', |
| | | content: errorMsg || '未知摄像头错误', |
| | | showCancel: false |
| | | }); |
| | | // uni.showModal({ |
| | | // title: '摄像头报错调试', |
| | | // content: errorMsg || '未知摄像头错误', |
| | | // showCancel: false |
| | | // }); |
| | | |
| | | console.error('摄像头预览初始化失败,详细错误信息:', JSON.stringify(error)); |
| | | if (error && error.detail) { |
| | |
| | | } catch (error) { |
| | | faceVerifyStatusText.value = '摄像头启动失败'; |
| | | |
| | | uni.showModal({ |
| | | title: '启动捕获异常调试', |
| | | content: error?.message || JSON.stringify(error) || '未知异常', |
| | | showCancel: false |
| | | }); |
| | | // uni.showModal({ |
| | | // title: '启动捕获异常调试', |
| | | // content: error?.message || JSON.stringify(error) || '未知异常', |
| | | // showCancel: false |
| | | // }); |
| | | |
| | | openNoticeDialog( |
| | | '摄像头启动失败', |
| | |
| | | import { HardwareAPI } from '../../utils/visitorDevice'; |
| | | import { saveImageToLocal, deleteLocalImage } from '../../utils/fs'; |
| | | import { getVisitAreaTree } from '../../api/visitArea'; |
| | | import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, getOfflineConfig, saveOfflineConfig } from '../../utils/db'; |
| | | import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, markRecordSynced, getOfflineConfig, saveOfflineConfig } from '../../utils/db'; |
| | | import { buildBaseUrl, getRequestServerConfig } from '../../utils/request'; |
| | | import { getLocalEmployeeVerifyList } from '../../api/employee'; |
| | | import { getEmployeeVerifyListFromLocalCache, getLocalEmployeeVerifyList } from '../../api/employee'; |
| | | import { getLocalSystemConfig } from '../../api/visitorConfig'; |
| | | import { startSyncQueue } from '../../utils/syncEngine'; |
| | | import { uploadImage } from '../../api/upload'; |
| | | import { addVisitorRecord, idCardSingOut } from '../../api/visitorRecord'; |
| | | import { |
| | | parseHikvisionQrcodePayload, |
| | | validateAppointmentQrcodePayload, |
| | |
| | | |
| | | const loadEmployeeVerifyList = async () => { |
| | | try { |
| | | employeeVerifyList.value = await getLocalEmployeeVerifyList(); |
| | | // 根据要求,此处只读取本地缓存的员工数据,不再每次都触发网络同步和图片下载 |
| | | employeeVerifyList.value = await getEmployeeVerifyListFromLocalCache(); |
| | | hostMatchedEmployee.value = findMatchedHostEmployee(); |
| | | } catch (error) { |
| | | console.error('读取本地员工验证列表失败:', error); |
| | | employeeVerifyList.value = []; |
| | | hostMatchedEmployee.value = null; |
| | | } |
| | | }; |
| | | |
| | | const refreshEmployeeVerifyListForHostVerify = async () => { |
| | | try { |
| | | // 仅在接待人验证发现本地列表为空时兜底刷新一次: |
| | | // 联网时会自动拉最新员工并补齐本地图片缓存,离线时仍然回退本地缓存。 |
| | | employeeVerifyList.value = await getLocalEmployeeVerifyList(); |
| | | hostMatchedEmployee.value = findMatchedHostEmployee(); |
| | | } catch (error) { |
| | | console.error('接待人验证前刷新员工验证列表失败:', error); |
| | | } |
| | | }; |
| | | |
| | |
| | | }); |
| | | |
| | | const configuredPrintFieldOptions = computed(() => { |
| | | let options = []; |
| | | if (Array.isArray(systemFlowConfig.value?.printFieldOptions) && systemFlowConfig.value.printFieldOptions.length > 0) { |
| | | return systemFlowConfig.value.printFieldOptions; |
| | | // 兼容后台配置可能使用 'beVisitor' 或 'interviewee' 作为被访者字段名 |
| | | options = systemFlowConfig.value.printFieldOptions.map(option => { |
| | | if (option.fieldCode === 'beVisitor') { |
| | | return { ...option, fieldCode: 'interviewee' }; |
| | | } |
| | | return option; |
| | | }); |
| | | } else { |
| | | options = [ |
| | | { fieldCode: 'visitorName', fieldName: '访客姓名' }, |
| | | { fieldCode: 'idCard', fieldName: '身份证号' }, |
| | | { fieldCode: 'appointmentTimeRange', fieldName: '预约时间范围' }, |
| | | { fieldCode: 'interviewee', fieldName: '被访者' }, |
| | | { fieldCode: 'receiverName', fieldName: '接待人' }, |
| | | { fieldCode: 'visitorFaceImage', fieldName: '访客人脸图片' }, |
| | | { fieldCode: 'registerNo', fieldName: '登记编号' }, |
| | | { fieldCode: 'plateNo', fieldName: '车牌号' }, |
| | | { fieldCode: 'visitReason', fieldName: '来访事由' }, |
| | | { fieldCode: 'visitArea', fieldName: '访问区域' }, |
| | | { fieldCode: 'registerTime', fieldName: '登记时间' }, |
| | | ]; |
| | | } |
| | | |
| | | return [ |
| | | { fieldCode: 'visitorName', fieldName: '访客姓名' }, |
| | | { fieldCode: 'idCard', fieldName: '身份证号' }, |
| | | { fieldCode: 'appointmentTimeRange', fieldName: '预约时间范围' }, |
| | | { fieldCode: 'interviewee', fieldName: '被访者' }, |
| | | { fieldCode: 'visitorFaceImage', fieldName: '访客人脸图片' }, |
| | | { fieldCode: 'registerNo', fieldName: '登记编号' }, |
| | | { fieldCode: 'plateNo', fieldName: '车牌号' }, |
| | | { fieldCode: 'visitReason', fieldName: '来访事由' }, |
| | | { fieldCode: 'visitArea', fieldName: '访问区域' }, |
| | | ]; |
| | | // 如果有 printFields 数组,按照它的顺序对 options 进行重排 |
| | | if (Array.isArray(systemFlowConfig.value?.printFields) && systemFlowConfig.value.printFields.length > 0) { |
| | | const printFieldsMap = systemFlowConfig.value.printFields.reduce((acc, fieldCode, index) => { |
| | | // 兼容 beVisitor |
| | | const normalizedCode = fieldCode === 'beVisitor' ? 'interviewee' : fieldCode; |
| | | acc[normalizedCode] = index; |
| | | return acc; |
| | | }, {}); |
| | | |
| | | // 将未在 printFields 中出现的字段排在最后 |
| | | options.sort((a, b) => { |
| | | const orderA = printFieldsMap[a.fieldCode] !== undefined ? printFieldsMap[a.fieldCode] : 9999; |
| | | const orderB = printFieldsMap[b.fieldCode] !== undefined ? printFieldsMap[b.fieldCode] : 9999; |
| | | return orderA - orderB; |
| | | }); |
| | | } |
| | | |
| | | return options; |
| | | }); |
| | | |
| | | // 手动录入状态 |
| | |
| | | |
| | | const errorMsg = error && error.detail ? JSON.stringify(error.detail) : JSON.stringify(error); |
| | | |
| | | uni.showModal({ |
| | | title: '摄像头报错调试', |
| | | content: errorMsg || '未知摄像头错误', |
| | | showCancel: false |
| | | }); |
| | | // uni.showModal({ |
| | | // title: '摄像头报错调试', |
| | | // content: errorMsg || '未知摄像头错误', |
| | | // showCancel: false |
| | | // }); |
| | | |
| | | console.error('摄像头预览初始化失败,详细错误信息:', JSON.stringify(error)); |
| | | if (error && error.detail) { |
| | |
| | | const startHostVerify = async () => { |
| | | hostMatchedEmployee.value = null; |
| | | |
| | | const candidateFaceEmployees = getHostFaceEmployees(); |
| | | let candidateFaceEmployees = getHostFaceEmployees(); |
| | | if (candidateFaceEmployees.length === 0) { |
| | | await refreshEmployeeVerifyListForHostVerify(); |
| | | candidateFaceEmployees = getHostFaceEmployees(); |
| | | } |
| | | |
| | | if (candidateFaceEmployees.length === 0) { |
| | | openNoticeDialog( |
| | | '未获取到员工人脸照片', |
| | |
| | | |
| | | try { |
| | | const decryptedResult = await parseHikvisionQrcodePayload(scanData); |
| | | await showQrcodeParseDebugModal(scanData, decryptedResult); |
| | | // await showQrcodeParseDebugModal(scanData, decryptedResult); |
| | | |
| | | // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用 |
| | | appointmentQrcodePayload.value = decryptedResult.payload; |
| | |
| | | // ---------------------------------------------------- |
| | | onMounted(async () => { |
| | | resetInactivityTimer(); |
| | | |
| | | // 必须在页面挂载的第一时间注册扫码和读卡的事件监听, |
| | | // 绝对不能放在 await 数据加载之后,否则会导致进入页面前几十秒内刷卡/扫码无反应! |
| | | registerAppointmentScanHandler(); |
| | | syncReadCardByStep(currentStepName.value); |
| | | |
| | | // 首页扫码成功后会直接跳到预约登记的身份证步骤,这里需要保留并恢复之前已缓存的二维码数据, |
| | | // 否则后续身份证一致性校验会因为拿不到 visitorIdCard 而被误判为不一致。 |
| | |
| | | await loadVisitAreas(); |
| | | clearAppointmentIdCardPayload(); |
| | | |
| | | // 首屏硬件动作不要依赖 init 回调,否则 init 卡住时扫码第一步不会触发 |
| | | triggerHardwareByStep(currentStepName.value); |
| | | // 首屏硬件动作:这里只需触发人脸和打印等主动调用型动作, |
| | | // 扫码和读卡的监听已提至 onMounted 顶部,避免被上述 await 阻塞 |
| | | if (currentStepName.value === 'face') { |
| | | startFaceVerify(); |
| | | } else if (currentStepName.value === 'host') { |
| | | startHostVerify(); |
| | | } else if (currentStepName.value === 'print') { |
| | | setTimeout(() => { |
| | | startPrintTicket(); |
| | | }, 500); |
| | | } |
| | | |
| | | await syncScanLineAnimationByStep(currentStepName.value); |
| | | |
| | | // 初始化硬件插件 |
| | |
| | | } |
| | | |
| | | console.log('读卡成功,拿到身份证信息:', cardData); |
| | | |
| | | if (mode.value === 'signout') { |
| | | const hasEntry = await verifySignoutEntry(cardData?.licId); |
| | | if (!hasEntry) return; |
| | |
| | | }; |
| | | |
| | | console.log('[CheckIn] 准备写入离线记录:', offlineRecord); |
| | | showPageDebugModal('离线记录写入前', offlineRecord); |
| | | // showPageDebugModal('离线记录写入前', offlineRecord); |
| | | await insertOfflineRecord(offlineRecord); |
| | | console.log('离线记录保存成功'); |
| | | // uni.showModal({ |
| | |
| | | // showCancel: false |
| | | // }); |
| | | |
| | | // 2. 触发同步队列(联网时会自动上传图片并调用接口) |
| | | startSyncQueue(); |
| | | // 2. 当前这单仍然要实时调用单次接口,但不能顺带触发历史离线数据的批量补发。 |
| | | // 因此这里只处理当前记录本身;若实时提交失败,保留离线记录等待后续统一同步。 |
| | | try { |
| | | const submitPayload = { |
| | | ...(offlineRecord.payload || {}), |
| | | }; |
| | | |
| | | if (offlineRecord.localImgPath) { |
| | | const uploadRes = await uploadImage(offlineRecord.localImgPath); |
| | | if (uploadRes && uploadRes.code === 200 && uploadRes.url) { |
| | | if (offlineRecord.type === 'signout') { |
| | | submitPayload.signImageUrl = uploadRes.url; |
| | | } else { |
| | | submitPayload.faceImageUrl = uploadRes.url; |
| | | } |
| | | } else { |
| | | throw new Error('当前记录图片上传失败'); |
| | | } |
| | | } |
| | | |
| | | const submitRes = offlineRecord.type === 'signout' |
| | | ? await idCardSingOut(submitPayload) |
| | | : await addVisitorRecord(submitPayload); |
| | | |
| | | if (submitRes && submitRes.code === 200) { |
| | | await markRecordSynced(offlineRecord.id); |
| | | if (offlineRecord.localImgPath) { |
| | | await deleteLocalImage(offlineRecord.localImgPath); |
| | | } |
| | | } |
| | | } catch (submitError) { |
| | | console.error('当前登记/签离实时提交失败,保留离线记录等待后续同步:', submitError); |
| | | } |
| | | |
| | | return true; |
| | | } catch (error) { |
| | | console.error('保存记录失败:', error); |
| | | uni.showModal({ |
| | | title: '离线保存失败', |
| | | content: `存入数据库报错: ${error.message || JSON.stringify(error)}`, |
| | | showCancel: false |
| | | }); |
| | | // uni.showModal({ |
| | | // title: '离线保存失败', |
| | | // content: `存入数据库报错: ${error.message || JSON.stringify(error)}`, |
| | | // showCancel: false |
| | | // }); |
| | | return false; |
| | | } |
| | | }; |
| | |
| | | return '无'; |
| | | }; |
| | | |
| | | const getPrintRegisterTime = () => { |
| | | const now = new Date(); |
| | | const year = now.getFullYear(); |
| | | const month = String(now.getMonth() + 1).padStart(2, '0'); |
| | | const day = String(now.getDate()).padStart(2, '0'); |
| | | const hours = String(now.getHours()).padStart(2, '0'); |
| | | const minutes = String(now.getMinutes()).padStart(2, '0'); |
| | | const seconds = String(now.getSeconds()).padStart(2, '0'); |
| | | return `${year}-${month}-${day} ${hours}:${minutes}:${seconds}`; |
| | | }; |
| | | |
| | | const getPrintFieldValueMap = () => { |
| | | const hostName = appointmentQrcodePayload.value?.beVisitorName || '无'; |
| | | // 综合现场登记和预约登记两种模式,获取接待人(被访人)姓名 |
| | | const matchedHostName = getFirstFilledValue(hostMatchedEmployee.value || {}, [ |
| | | 'employeeName', |
| | | 'name', |
| | | 'userName', |
| | | 'staffName', |
| | | 'realName', |
| | | ]); |
| | | |
| | | // 被访人(interviewee):现场登记优先用人脸匹配名,预约登记严格用二维码带的名 |
| | | const hostName = mode.value === 'online' |
| | | ? (matchedHostName || '无') |
| | | : (appointmentQrcodePayload.value?.beVisitorName || matchedHostName || '无'); |
| | | |
| | | // 接待人(receiverName):无论什么模式,绝对优先取实际人脸匹配到的真实员工姓名 |
| | | const actualReceiverName = matchedHostName || appointmentQrcodePayload.value?.beVisitorName || '无'; |
| | | |
| | | const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无'); |
| | | // 打印时优先使用已采集到的现场人脸图,只有没有抓拍图时才回退到身份证照片 |
| | | const photoPath = appointmentIdCardPayload.value?.livePhotoAddress |
| | |
| | | type: 'text', |
| | | value: areas || '无', |
| | | }, |
| | | receiverName: { |
| | | type: 'text', |
| | | value: actualReceiverName, |
| | | }, |
| | | registerTime: { |
| | | type: 'text', |
| | | value: getPrintRegisterTime(), |
| | | }, |
| | | }; |
| | | }; |
| | | |
| | |
| | | import { onShow, onHide } from '@dcloudio/uni-app'; |
| | | 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'; |
| | |
| | | const showAppointmentEntry = ref(true); |
| | | const companyName = ref('华南光电'); |
| | | const logoImage = ref(defaultLogoImage); |
| | | const isHomeDataLoading = ref(false); |
| | | const homeLoadingText = ref('系统配置加载中,请稍后...'); |
| | | |
| | | const screenStyle = { |
| | | backgroundImage: `url(${homeBgImage})`, |
| | |
| | | }; |
| | | |
| | | const initHomeData = async () => { |
| | | // 先加载本地缓存的 Logo 以求快速显示 |
| | | await loadHomeLogo(); |
| | | |
| | | // 先同步影响首页展示的轻量配置,保证首屏和页面跳转尽快可用 |
| | | await Promise.all([ |
| | | syncSystemConfig(), |
| | | syncCommitmentConfig(), |
| | | ]); |
| | | |
| | | // 再次加载,如果接口配置有更新,则实时应用最新配置 |
| | | 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); |
| | | } |
| | | isHomeDataLoading.value = true; |
| | | |
| | | // 员工图片 base64 转本地文件比较重,放到首页后台静默处理,避免阻塞后续 live-pusher 拉起 |
| | | setTimeout(() => { |
| | | syncEmployeeVerifyList() |
| | | .catch((error) => { |
| | | console.error('首页后台同步员工验证列表失败:', error); |
| | | }); |
| | | }, 200); |
| | | // 先加载本地缓存的 Logo 以求快速显示 |
| | | await loadHomeLogo(); |
| | | |
| | | // 先同步影响首页展示的轻量配置,保证首屏和页面跳转尽快可用 |
| | | await Promise.all([ |
| | | syncSystemConfig(), |
| | | syncCommitmentConfig(), |
| | | ]); |
| | | |
| | | // 再次加载,如果接口配置有更新,则实时应用最新配置 |
| | | 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); |
| | | } |
| | | |
| | | // 员工人脸缓存未完成前,不允许用户提前进入后续流程,避免接待人验证时拿到空员工列表。 |
| | | await new Promise((resolve) => { |
| | | setTimeout(resolve, 200); |
| | | }); |
| | | await syncEmployeeVerifyList(); |
| | | } catch (error) { |
| | | console.error('首页初始化失败:', error); |
| | | } finally { |
| | | isHomeDataLoading.value = false; |
| | | } |
| | | }; |
| | | |
| | | onMounted(() => { |
| | |
| | | :description="noticeDialog.description" |
| | | @close="closeNoticeDialog" |
| | | /> |
| | | <view v-if="isHomeDataLoading" class="home-loading-mask"> |
| | | <view class="home-loading-dialog"> |
| | | <view class="home-loading-spinner"></view> |
| | | <text class="home-loading-text">{{ homeLoadingText }}</text> |
| | | </view> |
| | | </view> |
| | | <view class="top-bar"> |
| | | <image class="logo" :src="logoImage" mode="heightFix" /> |
| | | <view |
| | |
| | | padding-top: 140rpx; |
| | | } |
| | | |
| | | .home-loading-mask { |
| | | position: absolute; |
| | | left: 0; |
| | | top: 0; |
| | | right: 0; |
| | | bottom: 0; |
| | | z-index: 99; |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | background: rgba(9, 20, 46, 0.52); |
| | | } |
| | | |
| | | .home-loading-dialog { |
| | | width: 620rpx; |
| | | min-height: 260rpx; |
| | | padding: 56rpx 48rpx; |
| | | border-radius: 36rpx; |
| | | background: rgba(255, 255, 255, 0.96); |
| | | box-shadow: 0 20rpx 60rpx rgba(15, 35, 84, 0.18); |
| | | display: flex; |
| | | flex-direction: column; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-sizing: border-box; |
| | | } |
| | | |
| | | .home-loading-spinner { |
| | | width: 72rpx; |
| | | height: 72rpx; |
| | | border-radius: 50%; |
| | | border: 8rpx solid rgba(47, 107, 255, 0.18); |
| | | border-top-color: #2F6BFF; |
| | | animation: home-loading-spin 0.9s linear infinite; |
| | | } |
| | | |
| | | .home-loading-text { |
| | | margin-top: 28rpx; |
| | | color: #1F2A44; |
| | | font-size: 34rpx; |
| | | font-weight: 600; |
| | | line-height: 48rpx; |
| | | text-align: center; |
| | | } |
| | | |
| | | @keyframes home-loading-spin { |
| | | from { |
| | | transform: rotate(0deg); |
| | | } |
| | | to { |
| | | transform: rotate(360deg); |
| | | } |
| | | } |
| | | |
| | | .top-bar { |
| | | width: 100%; |
| | | display: flex; |
| | |
| | | header = {}, |
| | | timeout = DEFAULT_TIMEOUT, |
| | | baseURL = '', |
| | | responseType = 'text', |
| | | }) => { |
| | | return new Promise((resolve, reject) => { |
| | | let resolvedUrl = ''; |
| | |
| | | data, |
| | | header, |
| | | timeout, |
| | | responseType, |
| | | success: (response) => { |
| | | const { statusCode, data: responseData } = response; |
| | | console.log('请求成功:', response); |
| | |
| | | payload: item.payload, |
| | | }); |
| | | submitRes = await idCardSingOut(item.payload); |
| | | |
| | | await new Promise((resolve) => { |
| | | uni.showModal({ |
| | | title: '调试: 签离接口返回', |
| | | content: `记录ID: ${item.recordId}\n结果: ${JSON.stringify(submitRes).substring(0, 500)}`, |
| | | showCancel: false, |
| | | success: resolve, |
| | | fail: resolve |
| | | }); |
| | | }); |
| | | } else { |
| | | // type 为 appointment, online, 或它们加上 _signedout 后缀时,都作为进场记录同步 |
| | | console.log('[Sync] 开始调用登记接口:', { |
| | |
| | | payload: item.payload, |
| | | }); |
| | | submitRes = await addVisitorRecord(item.payload); |
| | | |
| | | await new Promise((resolve) => { |
| | | uni.showModal({ |
| | | title: '调试: 登记接口返回', |
| | | content: `类型: ${item.type}\n记录ID: ${item.recordId}\n结果: ${JSON.stringify(submitRes).substring(0, 500)}`, |
| | | showCancel: false, |
| | | success: resolve, |
| | | fail: resolve |
| | | }); |
| | | }); |
| | | } |
| | | |
| | | console.log('[Sync] 单条业务接口返回:', { |
| | |
| | | // #ifdef APP-PLUS |
| | | if (HardwarePlugin) { |
| | | HardwarePlugin.startReadCard({}, (res) => { |
| | | showPluginDebugModal('读卡回调', res); |
| | | // showPluginDebugModal('读卡回调', res); |
| | | // 如果插件回调中包含 licId (身份证号),说明读卡成功 |
| | | const resData = JSON.parse(res || '{}'); |
| | | if (resData && resData.licId) { |
| | |
| | | // #ifdef APP-PLUS |
| | | if (HardwarePlugin) { |
| | | HardwarePlugin.startScan({ duration: 2000 }, (res) => { |
| | | showPluginDebugModal('扫码回调', res); |
| | | // showPluginDebugModal('扫码回调', res); |
| | | if (res) { |
| | | callback(res); |
| | | } else { |