zjk
2026-07-24 12d08e1f7c859d18c1aa6fd3121f4dff32224b54
feat(visitor,admin,home): 优化多模块功能并重构访客导出流程

- 新增访客记录后端导出接口,重构管理后台导出功能适配多端环境
- 暴露员工本地缓存读取函数为公共API,优化预约页面员工验证加载逻辑
- 升级request工具类,新增responseType参数支持二进制请求
- 完善首页初始化流程,添加全局加载遮罩并调整员工同步时机
- 注释多处调试用弹窗,关闭默认5分钟定时同步队列,优化离线同步逻辑并添加调试弹窗
- 完善版本更新功能,实现版本检查、下载APK包和自动安装的完整流程
- 优化预约打印字段配置,适配后台配置的字段顺序并兼容旧版字段名
11个文件已修改
676 ■■■■■ 已修改文件
App.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/employee.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/visitorRecord.js 9 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminConfigPanel.vue 99 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminRecordsPanel.vue 185 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/index.vue 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/n-index.nvue 196 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/home/index.vue 131 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/request.js 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/syncEngine.js 20 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/visitorDevice.js 4 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
App.vue
@@ -11,7 +11,7 @@
        HardwareAPI.init().then(async () => {
            console.log('硬件插件全局初始化完成');
            await this.restoreOfflineActivation();
            // 全局开启扫码和读卡监听,避免多线程干扰
            HardwareAPI.startScan((res) => {
                uni.$emit('Hardware-Scan', res);
@@ -39,9 +39,9 @@
        runDailyCleanup();
        setInterval(() => {
            startSyncQueue();
        }, 5 * 60 * 1000);
        // setInterval(() => {
        //     startSyncQueue();
        // }, 5 * 60 * 1000);
    },
    onShow() {
        console.log('App Show');
api/employee.js
@@ -294,7 +294,7 @@
  }
};
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)
api/visitorRecord.js
@@ -47,6 +47,15 @@
  });
};
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',
components/admin/AdminConfigPanel.vue
@@ -57,9 +57,9 @@
            <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>
@@ -191,6 +191,7 @@
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('');
@@ -200,11 +201,17 @@
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?:\/\//, '');
@@ -356,6 +363,72 @@
    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 () => {
@@ -514,24 +587,20 @@
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;
  }
};
components/admin/AdminRecordsPanel.vue
@@ -268,7 +268,7 @@
<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';
@@ -678,125 +678,110 @@
  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, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;')
        .replace(/"/g, '&quot;')
        .replace(/'/g, '&#39;');
    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();
pages/appointment-scan/index.vue
@@ -1154,11 +1154,11 @@
  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) {
@@ -1330,11 +1330,11 @@
    } 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(
        '摄像头启动失败',
pages/appointment-scan/n-index.nvue
@@ -651,11 +651,12 @@
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,
@@ -1241,12 +1242,24 @@
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);
  }
};
@@ -1395,21 +1408,49 @@
});
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;
});
// 手动录入状态
@@ -1932,11 +1973,11 @@
  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) {
@@ -2078,7 +2119,12 @@
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(
      '未获取到员工人脸照片',
@@ -2409,7 +2455,7 @@
    try {
      const decryptedResult = await parseHikvisionQrcodePayload(scanData);
      await showQrcodeParseDebugModal(scanData, decryptedResult);
    //   await showQrcodeParseDebugModal(scanData, decryptedResult);
      // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用
      appointmentQrcodePayload.value = decryptedResult.payload;
@@ -2475,7 +2521,11 @@
// ----------------------------------------------------
onMounted(async () => {
  resetInactivityTimer();
  // 必须在页面挂载的第一时间注册扫码和读卡的事件监听,
  // 绝对不能放在 await 数据加载之后,否则会导致进入页面前几十秒内刷卡/扫码无反应!
  registerAppointmentScanHandler();
  syncReadCardByStep(currentStepName.value);
  // 首页扫码成功后会直接跳到预约登记的身份证步骤,这里需要保留并恢复之前已缓存的二维码数据,
  // 否则后续身份证一致性校验会因为拿不到 visitorIdCard 而被误判为不一致。
@@ -2493,8 +2543,18 @@
  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);
  // 初始化硬件插件
@@ -2607,7 +2667,6 @@
  }
  console.log('读卡成功,拿到身份证信息:', cardData);
  if (mode.value === 'signout') {
    const hasEntry = await verifySignoutEntry(cardData?.licId);
    if (!hasEntry) return;
@@ -2902,7 +2961,7 @@
    };
    console.log('[CheckIn] 准备写入离线记录:', offlineRecord);
    showPageDebugModal('离线记录写入前', offlineRecord);
    // showPageDebugModal('离线记录写入前', offlineRecord);
    await insertOfflineRecord(offlineRecord);
    console.log('离线记录保存成功');
    // uni.showModal({
@@ -2911,16 +2970,48 @@
    //   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;
  }
};
@@ -3031,8 +3122,35 @@
  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
@@ -3078,6 +3196,14 @@
      type: 'text',
      value: areas || '无',
    },
    receiverName: {
      type: 'text',
      value: actualReceiverName,
    },
    registerTime: {
      type: 'text',
      value: getPrintRegisterTime(),
    },
  };
};
pages/home/index.vue
@@ -3,7 +3,6 @@
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';
@@ -26,6 +25,8 @@
const showAppointmentEntry = ref(true);
const companyName = ref('华南光电');
const logoImage = ref(defaultLogoImage);
const isHomeDataLoading = ref(false);
const homeLoadingText = ref('系统配置加载中,请稍后...');
const screenStyle = {
  backgroundImage: `url(${homeBgImage})`,
@@ -306,39 +307,45 @@
};
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(() => {
@@ -363,6 +370,12 @@
          :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
@@ -431,6 +444,60 @@
    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;
utils/request.js
@@ -95,6 +95,7 @@
    header = {},
    timeout = DEFAULT_TIMEOUT,
    baseURL = '',
    responseType = 'text',
}) => {
    return new Promise((resolve, reject) => {
        let resolvedUrl = '';
@@ -114,6 +115,7 @@
            data,
            header,
            timeout,
            responseType,
            success: (response) => {
                const { statusCode, data: responseData } = response;
                console.log('请求成功:', response);
utils/syncEngine.js
@@ -133,6 +133,16 @@
            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] 开始调用登记接口:', {
@@ -141,6 +151,16 @@
            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] 单条业务接口返回:', {
utils/visitorDevice.js
@@ -157,7 +157,7 @@
    // #ifdef APP-PLUS
    if (HardwarePlugin) {
      HardwarePlugin.startReadCard({}, (res) => {
        showPluginDebugModal('读卡回调', res);
        // showPluginDebugModal('读卡回调', res);
        // 如果插件回调中包含 licId (身份证号),说明读卡成功
        const resData = JSON.parse(res || '{}');
        if (resData && resData.licId) {
@@ -211,7 +211,7 @@
    // #ifdef APP-PLUS
    if (HardwarePlugin) {
      HardwarePlugin.startScan({ duration: 2000 }, (res) => {
        showPluginDebugModal('扫码回调', res);
        // showPluginDebugModal('扫码回调', res);
        if (res) {
          callback(res);
        } else {