zjk
2026-07-23 14948868c3f63ed5243c20b7fbaa21282ff7bd25
feat: 优化访客系统多模块功能,修复各类问题

- 新增访客待确认(PENDING_CONFIRM)状态展示
- 重构海康威视预约码校验逻辑,移除冗余字段别名,精简必填字段判断并新增预约时间校验
- 提取复用二维码解析调试弹窗函数,方便开发调试扫码功能
- 为人脸验证成功后添加1秒延迟,给用户明确的操作成功反馈
- 修复设备初始化页面的设备ID回填逻辑,使用百度SDK设备指纹替代不稳定的deviceId
- 优化管理员记录导出功能,适配app-plus环境的文件操作,修复导出失败问题
- 调整日期筛选默认值为今天,优化筛选体验
- 优化预约码已进场的提示文案,提升提示准确性
- 临时注释掉host-item的align-items样式用于调试
6个文件已修改
189 ■■■■■ 已修改文件
components/admin/AdminRecordsPanel.vue 92 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-record-detail/index.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/n-index.nvue 32 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/device-init/index.vue 16 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/home/index.vue 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/hikvisionQrcode.js 23 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminRecordsPanel.vue
@@ -567,6 +567,14 @@
  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(' / ');
@@ -598,8 +606,9 @@
};
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';
@@ -713,39 +722,58 @@
    // #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
pages/admin-record-detail/index.vue
@@ -225,6 +225,7 @@
      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':
@@ -521,7 +522,7 @@
.host-item,
.permission-title-row {
  display: flex;
  align-items: center;
  /* align-items: center; */
  gap: 10px;
}
pages/appointment-scan/n-index.nvue
@@ -2171,6 +2171,7 @@
            // 手动录入身份证时没有证件照,跳过人证比对,仅保留现场人脸图
            if (isManualIdentityInput) {
              faceVerifyStatusText.value = '人脸采集成功';
              await waitBeforeEnterHostStep();
              nextStep();
              return;
            }
@@ -2206,6 +2207,7 @@
            }
            faceVerifyStatusText.value = '人证比对成功';
            await waitBeforeEnterHostStep();
            nextStep();
          } catch (error) {
            if (sessionId !== judgeFaceSessionId) {
@@ -2241,6 +2243,29 @@
  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) {
@@ -2250,15 +2275,15 @@
  if (validationResult.reason === 'out_of_time_range') {
    openNoticeDialog(
      '预约码不在进场时间内,请联系工作人员',
      '请核对预约时段信息,或前往前台获取现场协助'
      '请检查您的预约时间'
    );
    return false;
  }
  if (validationResult.reason === 'already_entered') {
    openNoticeDialog(
      '您已进场,请先签离',
      ''
      '该预约码已进场',
      '您已进场,请签离'
    );
    return false;
  }
@@ -2482,6 +2507,7 @@
      try {
        const decryptedResult = await parseHikvisionQrcodePayload(scanData);
        await showQrcodeParseDebugModal(scanData, decryptedResult);
        // 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用
        appointmentQrcodePayload.value = decryptedResult.payload;
pages/device-init/index.vue
@@ -23,8 +23,10 @@
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',
@@ -46,7 +48,7 @@
    // });
    // 只有设备已经完整激活过,才跳过激活页直接进首页
    if (hasActivatedDevice()) {
        formRows[0].value = deviceId || '';
        formRows[0].value = storedDeviceFingerprint || deviceId || '';
        deviceFingerprint.value = storedDeviceFingerprint || '';
        uni.redirectTo({
            url: '/pages/home/index',
@@ -55,19 +57,11 @@
});
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();
});
@@ -81,7 +75,7 @@
        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();
pages/home/index.vue
@@ -228,6 +228,18 @@
  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;
@@ -247,16 +259,7 @@
    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) {
utils/hikvisionQrcode.js
@@ -181,33 +181,22 @@
};
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) => {