zjk
2026-07-17 507a84d8a725e31cfa4944f19c031faf24feb3f9
feat(visitor-kiosk): 新增入园承诺页面并优化系统配置流程

- 新增入园承诺页面pages/commitment/index.vue并配置路由
- 新增visitorConfig API模块统一管理系统配置的同步与本地缓存
- 重构StepProgress组件支持自定义步骤列表
- 替换海康威视二维码解密算法从WebCrypto为CryptoJS实现
- 优化首页、预约扫描页、管理后台等页面的业务逻辑与配置加载
- 更新项目依赖配置,添加node_modules到.gitignore并删除旧logo资源
- 完善网络异常处理与离线缓存逻辑
1个文件已删除
4个文件已添加
10个文件已修改
1166 ■■■■■ 已修改文件
.gitignore 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/visitorConfig.js 85 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/StepProgress.vue 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminRecordsPanel.vue 100 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package-lock.json 28 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
package.json 22 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages.json 8 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-home/index.vue 72 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-record-detail/index.vue 60 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/index.vue 340 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/commitment/index.vue 222 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/home/index.vue 137 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
static/logo.png 补丁 | 查看 | 原始文档 | blame | 历史
utils/hikvisionQrcode.js 82 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/request.js 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
.gitignore
@@ -1,3 +1,4 @@
unpackage/
.DS_Store
.hbuilderx/
node_modules/
api/visitorConfig.js
New file
@@ -0,0 +1,85 @@
import { request } from '../utils/request';
import { saveOfflineConfig, getOfflineConfig } from '../utils/db';
/**
 * 获取全局系统配置
 * @param {Object} data
 * @returns {Promise}
 */
export const getSystemConfigAPI = (data = {}) => {
  return request({
    url: '/api/visitorconfig/detail',
    method: 'POST',
    data,
  });
};
/**
 * 获取入园承诺配置
 * @param {Object} data
 * @returns {Promise}
 */
export const getCommitmentConfigAPI = (data = {}) => {
  return request({
    // TODO: 这里暂用 /api/admission/detail,如果真实接口不同请替换
    url: '/api/admission/detail',
    method: 'POST',
    data,
  });
};
/**
 * 同步全局系统配置到本地数据库
 */
export const syncSystemConfig = async () => {
  try {
    const res = await getSystemConfigAPI();
    if (res && res.code === 200 && res.data) {
      await saveOfflineConfig('system_config', res.data);
      uni.setStorageSync('system_config', res.data);
      return res.data;
    }
  } catch (error) {
    console.error('同步全局系统配置失败:', error);
  }
  return null;
};
/**
 * 获取本地缓存的全局系统配置
 */
export const getLocalSystemConfig = async () => {
  const offlineConfig = await getOfflineConfig('system_config');
  if (offlineConfig) {
    return offlineConfig;
  }
  return uni.getStorageSync('system_config') || {};
};
/**
 * 同步入园承诺配置到本地数据库
 */
export const syncCommitmentConfig = async () => {
  try {
    const res = await getCommitmentConfigAPI();
    if (res && res.code === 200 && res.data) {
      await saveOfflineConfig('commitment_config', res.data);
      uni.setStorageSync('commitment_config', res.data);
      return res.data;
    }
  } catch (error) {
    console.error('同步入园承诺配置失败:', error);
  }
  return null;
};
/**
 * 获取本地缓存的入园承诺配置
 */
export const getLocalCommitmentConfig = async () => {
  const offlineConfig = await getOfflineConfig('commitment_config');
  if (offlineConfig) {
    return offlineConfig;
  }
  return uni.getStorageSync('commitment_config') || {};
};
components/StepProgress.vue
@@ -45,10 +45,18 @@
  mode: {
    type: String,
    default: 'appointment' // 'appointment' 或 'online'
  },
  steps: {
    type: Array,
    default: () => []
  }
});
const stepsList = computed(() => {
  if (Array.isArray(props.steps) && props.steps.length > 0) {
    return props.steps;
  }
  if (props.mode === 'online') {
    return [
      { text: '身份证验证', icon: 'idcard' },
components/admin/AdminRecordsPanel.vue
@@ -466,48 +466,19 @@
      validEndTime: formatFilterDateTime(appliedFilters.endDate, true),
    };
    let isOnline = true;
    uni.showLoading({ title: '加载中...' });
    const res = await getVisitorRecordPage(payload);
    let onlineList = [];
    try {
      const res = await getVisitorRecordPage(payload);
      if (res && res.code === 200 && res.data) {
        // 兼容不同的后端返回结构:有的后端返回 list,有的返回 records
        onlineList = res.data.records || res.data.list || [];
        // 只有无筛选的全量查询结果才回写本地缓存,避免把筛选结果覆盖全量历史
        if (!hasActiveFilters.value) {
          await saveHistoryRecords(onlineList);
        }
      }
    } catch (networkError) {
      console.log('无法获取线上历史记录,降级使用本地缓存', networkError);
      isOnline = false;
    }
    // 无论线上成功与否,都读取一次本地数据;有筛选时优先使用接口结果,并补充本地待同步记录
    const combinedLocalList = await getCombinedHistoryRecords();
    let filteredList = [];
    if (isOnline && hasActiveFilters.value) {
      const localPendingList = combinedLocalList.filter(item => item._source === 'local_pending');
      filteredList = [...applyClientFilters(localPendingList), ...onlineList];
    if (res && res.code === 200 && res.data) {
      onlineList = res.data.records || res.data.list || [];
    } else {
      let combinedList = combinedLocalList;
      // 如果是在 H5/浏览器环境下预览,本地数据库会被 mock 返回空数组
      // 因此如果 combinedList 为空且在线请求成功,直接使用在线数据进行渲染兜底
      // #ifndef APP-PLUS
      if (isOnline && combinedList.length === 0) {
        combinedList = onlineList;
      }
      // #endif
      filteredList = applyClientFilters(combinedList);
      throw new Error(res?.msg || '获取线上记录失败');
    }
    totalRecords.value = filteredList.length;
    totalRecords.value = onlineList.length;
    currentPage.value = 1;
    records.value = filteredList.map((item) => {
    records.value = onlineList.map((item) => {
      const statusInfo = getStatusInfo(item.status);
      const name = item.visitorName || '未知';
      const detailSnapshot = buildDetailSnapshot(item);
@@ -529,54 +500,34 @@
        detailSnapshot,
      };
    });
    if (!isOnline) {
      uni.showToast({ title: '已加载离线记录', icon: 'none' });
    }
  } catch (error) {
    console.error('加载访客记录失败', error);
    uni.showToast({ title: '加载失败', icon: 'none' });
    records.value = [];
    totalRecords.value = 0;
    uni.showToast({ title: '网络异常,获取失败', icon: 'none' });
  } finally {
    isLoading.value = false;
    uni.hideLoading();
  }
};
const loadVisitAreas = async () => {
  let rawData = [];
  try {
    const networkType = await new Promise(resolve => {
      uni.getNetworkType({
        success: (res) => resolve(res.networkType)
    const res = await getVisitAreaTree();
    if (res && res.code === 200 && Array.isArray(res.data)) {
      const rawData = res.data;
      areaTree.value = rawData.map(item => {
        const name = item.name || item.areaName || item.label || item.areaId || item.id;
        const rawChildren = (item.children && item.children.length > 0) ? item.children : [item];
        const children = rawChildren.map(child => child.name || child.areaName || child.label || child.areaId || child.id);
        return { name, children };
      });
    });
    if (networkType !== 'none') {
      const res = await getVisitAreaTree();
      if (res && res.code === 200 && Array.isArray(res.data)) {
        rawData = res.data;
      if (!tempAreaParent.value) {
        tempAreaParent.value = areaTree.value[0]?.name || '';
      }
    }
  } catch (err) {
    console.error('获取在线区域树失败:', err);
  }
  if (!rawData.length) {
    const cachedData = await getOfflineConfig('visit_area_tree');
    if (cachedData && Array.isArray(cachedData)) {
      rawData = cachedData;
    }
  }
  if (rawData.length) {
    areaTree.value = rawData.map(item => {
      const name = item.name || item.areaName || item.label || item.areaId || item.id;
      const rawChildren = (item.children && item.children.length > 0) ? item.children : [item];
      const children = rawChildren.map(child => child.name || child.areaName || child.label || child.areaId || child.id);
      return { name, children };
    });
    if (!tempAreaParent.value) {
      tempAreaParent.value = areaTree.value[0]?.name || '';
    }
    console.error('获取区域树失败:', err);
  }
};
@@ -808,13 +759,6 @@
const openDetail = (record) => {
  const recordId = record?.id || '';
  if (recordId && record?.detailSnapshot) {
    uni.setStorageSync(`${DETAIL_SNAPSHOT_STORAGE_PREFIX}${recordId}`, {
      value: record.detailSnapshot,
      updateTime: Date.now(),
    });
  }
  uni.navigateTo({
    url: `/pages/admin-record-detail/index?id=${recordId}`,
  });
package-lock.json
New file
@@ -0,0 +1,28 @@
{
  "name": "visitor-kiosk-uniapp",
  "version": "1.0.0",
  "lockfileVersion": 2,
  "requires": true,
  "packages": {
    "": {
      "name": "visitor-kiosk-uniapp",
      "version": "1.0.0",
      "license": "ISC",
      "dependencies": {
        "crypto-js": "^4.2.0"
      }
    },
    "node_modules/crypto-js": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
    }
  },
  "dependencies": {
    "crypto-js": {
      "version": "4.2.0",
      "resolved": "https://registry.npmjs.org/crypto-js/-/crypto-js-4.2.0.tgz",
      "integrity": "sha512-KALDyEYgpY+Rlob/iriUtjV6d5Eq+Y191A5g4UqLAi8CyGP9N1+FdVbkc1SxKc2r4YAYqG8JzO2KGL+AizD70Q=="
    }
  }
}
package.json
New file
@@ -0,0 +1,22 @@
{
  "name": "visitor-kiosk-uniapp",
  "version": "1.0.0",
  "description": "## 项目简介",
  "main": "main.js",
  "directories": {
    "doc": "docs"
  },
  "scripts": {
    "test": "echo \"Error: no test specified\" && exit 1"
  },
  "repository": {
    "type": "git",
    "url": "http://pyt@120.76.84.145:10101/gitblit/r/H5/fangkeji.git"
  },
  "keywords": [],
  "author": "",
  "license": "ISC",
  "dependencies": {
    "crypto-js": "^4.2.0"
  }
}
pages.json
@@ -87,6 +87,14 @@
        "disableScroll": true,
        "backgroundColor": "#2550D9"
      }
    },
    {
      "path": "pages/commitment/index",
      "style": {
        "navigationStyle": "custom",
        "disableScroll": true,
        "backgroundColor": "#FFFFFF"
      }
    }
  ],
  "globalStyle": {
pages/admin-home/index.vue
@@ -308,7 +308,7 @@
import { computed, ref } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import { getDashboardOverview } from '../../api/dashboard';
import { getOfflineConfigWithMeta, saveOfflineConfig } from '../../utils/db';
import { getLocalSystemConfig } from '../../api/visitorConfig';
import AdminShell from '../../components/admin/AdminShell.vue';
import AdminTabBar from '../../components/admin/AdminTabBar.vue';
import AdminRecordsPanel from '../../components/admin/AdminRecordsPanel.vue';
@@ -530,7 +530,6 @@
};
const fetchDashboardData = async () => {
  const cacheKey = buildDashboardCacheKey();
  try {
    const payload = {};
    if (appliedPeriod.value === 'custom') {
@@ -550,51 +549,71 @@
    if (res && res.code === 200 && res.data) {
      applyDashboardResponseData(res.data);
      await saveOfflineConfig(cacheKey, res.data);
      return;
    }
    throw new Error(res?.msg || '获取看板数据失败');
  } catch (err) {
    console.error('获取看板数据失败', err);
    const cachedData = await getOfflineConfigWithMeta(cacheKey);
    if (cachedData?.value) {
      applyDashboardResponseData(cachedData.value);
      uni.showToast({ title: '已加载离线统计数据', icon: 'none' });
    } else {
      dashboardStats.value = {
        onlineAppointments: 0,
        onlineCars: 0,
        onsiteAppointments: 0,
        onsiteCars: 0,
        stayingVisitors: 0,
        checkedInVisitors: 0,
        overdueVisitors: 0,
      };
      trendChartData.value = [];
      uni.showToast({ title: '数据加载失败', icon: 'none' });
    }
    dashboardStats.value = {
      onlineAppointments: 0,
      onlineCars: 0,
      onsiteAppointments: 0,
      onsiteCars: 0,
      stayingVisitors: 0,
      checkedInVisitors: 0,
      overdueVisitors: 0,
    };
    trendChartData.value = [];
    uni.showToast({ title: '网络异常,获取失败', icon: 'none' });
  } finally {
    uni.hideLoading();
  }
};
const deviceInfo = ref([
  { icon: versionIcon, text: '当前版本号:v1.0.0' },
  { icon: vendorIcon, text: '服务厂家:华南光电' },
  { icon: supportIcon, text: '技术支持:400-882-6688' },
  { icon: terminalIcon, text: '终端编号:K1024-8823' },
  { id: 'version', icon: versionIcon, text: '当前版本号:获取中...' },
  { id: 'vendor', icon: vendorIcon, text: '服务厂家:获取中...' },
  { id: 'support', icon: supportIcon, text: '技术支持:获取中...' },
  { id: 'terminal', icon: terminalIcon, text: '终端编号:获取中...' },
]);
const fetchSystemFooterConfig = async () => {
  try {
    const config = await getLocalSystemConfig();
    const versionIndex = deviceInfo.value.findIndex(item => item.id === 'version');
    const vendorIndex = deviceInfo.value.findIndex(item => item.id === 'vendor');
    const supportIndex = deviceInfo.value.findIndex(item => item.id === 'support');
    if (versionIndex > -1) {
      deviceInfo.value[versionIndex].text = `当前版本号:${config.version || config.systemVersion || 'v2.1.0'}`;
    }
    if (vendorIndex > -1) {
      deviceInfo.value[vendorIndex].text = `服务厂家:${config.serviceProvider || config.manufacturer || '未知'}`;
    }
    if (supportIndex > -1) {
      deviceInfo.value[supportIndex].text = `技术支持:${config.technicalSupport || config.supportPhone || '未知'}`;
    }
  } catch (error) {
    console.error('获取系统底部配置失败:', error);
  }
};
const fetchDeviceFingerprint = async () => {
  try {
    const sysInfo = uni.getSystemInfoSync();
    // 优先尝试获取 deviceId,其次用 model 作为兜底
    const deviceId = sysInfo.deviceId || sysInfo.model || '未知设备';
    deviceInfo.value[3].text = `终端编号:${deviceId}`;
    const terminalIndex = deviceInfo.value.findIndex(item => item.id === 'terminal');
    if (terminalIndex > -1) {
      deviceInfo.value[terminalIndex].text = `终端编号:${deviceId}`;
    }
  } catch (err) {
    console.error('获取设备编号失败', err);
    deviceInfo.value[3].text = '终端编号:获取失败';
    const terminalIndex = deviceInfo.value.findIndex(item => item.id === 'terminal');
    if (terminalIndex > -1) {
      deviceInfo.value[terminalIndex].text = '终端编号:获取失败';
    }
  }
};
@@ -652,6 +671,7 @@
  fetchDashboardData();
  fetchDeviceFingerprint();
  fetchSystemFooterConfig();
});
</script>
pages/admin-record-detail/index.vue
@@ -243,8 +243,6 @@
  return typeStr || '未知';
};
const getDetailSnapshotStorageKey = (recordId) => `${DETAIL_SNAPSHOT_STORAGE_PREFIX}${recordId}`;
const normalizeDetailData = (data = {}) => {
  const statusInfo = getStatusInfo(data.status);
  const areaTags = Array.isArray(data.visitAreaNameList)
@@ -275,57 +273,18 @@
  };
};
const applyDetailSnapshot = (snapshot) => {
  if (!snapshot) {
    return false;
  }
  detail.value = {
    ...createDefaultDetail(),
    ...snapshot,
  };
  return true;
};
const loadLocalDetail = async (recordId) => {
  const snapshotStorage = uni.getStorageSync(getDetailSnapshotStorageKey(recordId));
  const snapshotValue = snapshotStorage?.value || snapshotStorage;
  if (snapshotValue) {
    return applyDetailSnapshot(snapshotValue);
  }
  const cachedRecord = await getHistoryRecordByRecordId(recordId);
  if (!cachedRecord) {
    return false;
  }
  const normalizedSnapshot = normalizeDetailData(cachedRecord);
  uni.setStorageSync(getDetailSnapshotStorageKey(recordId), {
    value: normalizedSnapshot,
    updateTime: Number(cachedRecord?._cacheTime || 0) || Date.now(),
  });
  return applyDetailSnapshot(normalizedSnapshot);
};
const fetchDetail = async (recordId) => {
const loadDetail = async (recordId) => {
  uni.showLoading({ title: '加载中...' });
  try {
    uni.showLoading({ title: '加载中...' });
    const res = await getVisitorRecordDetail({ recordId });
    if (res && res.code === 200 && res.data) {
      const normalizedSnapshot = normalizeDetailData(res.data);
      detail.value = normalizedSnapshot;
      uni.setStorageSync(getDetailSnapshotStorageKey(recordId), {
        value: normalizedSnapshot,
        updateTime: Date.now(),
      });
      return;
    } else if (res && res.msg) {
      throw new Error(res.msg);
      detail.value = normalizeDetailData(res.data);
    } else {
      throw new Error(res?.msg || '获取详情失败');
    }
  } catch (error) {
    console.error('获取详情失败', error);
    const hasLocalDetail = await loadLocalDetail(recordId);
    uni.showToast({ title: hasLocalDetail ? '已加载离线详情' : '获取详情失败', icon: 'none' });
  } catch (err) {
    console.error('获取在线详情失败:', err);
    uni.showToast({ title: '网络异常,获取详情失败', icon: 'none' });
  } finally {
    uni.hideLoading();
  }
@@ -334,8 +293,7 @@
onLoad((options) => {
  id.value = options?.id || '';
  if (id.value) {
    loadLocalDetail(id.value);
    fetchDetail(id.value);
    loadDetail(id.value);
  }
});
pages/appointment-scan/index.vue
@@ -14,7 +14,7 @@
    </view>
    <!-- 步骤卡片组件 -->
    <StepProgress :currentStep="currentStep" :mode="mode" />
    <StepProgress :currentStep="currentStep" :mode="mode" :steps="stepProgressSteps" />
    <!-- 步骤: 预约码验证 -->
    <template v-if="currentStepName === 'qr'">
@@ -89,7 +89,7 @@
            <text class="help-content">请使用二代身份证原件放至读卡区位置</text>
          </view>
          <view class="action-card">
          <view class="action-card" v-if="allowManualIdCard">
            <view class="highlight-bar"></view>
            <view class="card-title-row">
              <view class="icon-base">
@@ -253,7 +253,7 @@
            </view>
            <text class="card-title">识别提示</text>
          </view>
          <text class="help-content">请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证</text>
          <text class="help-content">{{ faceVerifyHelpText }}</text>
        </view>
        <view class="action-card">
@@ -595,6 +595,7 @@
import { saveImageToLocal, deleteLocalImage } from '../../utils/fs';
import { getVisitAreaTree } from '../../api/visitArea';
import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, getOfflineConfig, saveOfflineConfig } from '../../utils/db';
import { getLocalSystemConfig } from '../../api/visitorConfig';
import { startSyncQueue } from '../../utils/syncEngine';
import {
  parseHikvisionQrcodePayload,
@@ -703,6 +704,130 @@
const currentStep = ref(1);
const mode = ref('appointment');
const systemFlowConfig = ref({});
const allowManualIdCard = computed(() => {
  return systemFlowConfig.value.manualIdCardEnabled !== false;
});
const registerReceiverConfirmEnabled = computed(() => {
  return systemFlowConfig.value.registerReceiverConfirmEnabled !== false;
});
const signReceiverConfirmEnabled = computed(() => {
  return systemFlowConfig.value.signReceiverConfirmEnabled !== false;
});
const shouldShowHostStep = computed(() => {
  if (mode.value === 'signout') {
    return signReceiverConfirmEnabled.value;
  }
  return registerReceiverConfirmEnabled.value;
});
const receiverVerifyMethodValue = computed(() => {
  return shouldShowHostStep.value ? '2' : '';
});
const stepProgressSteps = computed(() => {
  if (mode.value === 'appointment') {
    const steps = [
      { text: '预约码验证', icon: 'qr' },
      { text: '身份证验证', icon: 'idcard' },
      { text: '人脸验证', icon: 'face' },
    ];
    if (shouldShowHostStep.value) {
      steps.push({ text: '接待人验证', icon: 'user' });
    }
    steps.push({ text: '打印凭条', icon: 'print' });
    return steps;
  }
  if (mode.value === 'online') {
    const steps = [
      { text: '身份证验证', icon: 'idcard' },
      { text: '人脸验证', icon: 'face' },
    ];
    if (shouldShowHostStep.value) {
      steps.push({ text: '接待人验证', icon: 'user' });
    }
    steps.push({ text: '完善登记信息', icon: 'form' });
    steps.push({ text: '打印凭条', icon: 'print' });
    return steps;
  }
  const steps = [
    { text: '身份证验证', icon: 'idcard' },
    { text: '人脸验证', icon: 'face' },
  ];
  if (shouldShowHostStep.value) {
    steps.push({ text: '接待人验证', icon: 'user' });
  }
  return steps;
});
const flowStepNames = computed(() => {
  if (mode.value === 'appointment') {
    const steps = ['qr', 'idcard', 'face'];
    if (shouldShowHostStep.value) {
      steps.push('host');
    }
    steps.push('print');
    return steps;
  }
  if (mode.value === 'online') {
    const steps = ['idcard', 'face'];
    if (shouldShowHostStep.value) {
      steps.push('host');
    }
    steps.push('form');
    steps.push('print');
    return steps;
  }
  const steps = ['idcard', 'face'];
  if (shouldShowHostStep.value) {
    steps.push('host');
  }
  return steps;
});
const faceVerifyHelpText = computed(() => {
  if (mode.value === 'signout') {
    return shouldShowHostStep.value
      ? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证'
      : '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动完成签离流程';
  }
  if (mode.value === 'online') {
    return shouldShowHostStep.value
      ? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证'
      : '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入完善登记信息';
  }
  return shouldShowHostStep.value
    ? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证'
    : '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入打印凭条';
});
const configuredPrintFieldOptions = computed(() => {
  if (Array.isArray(systemFlowConfig.value?.printFieldOptions) && systemFlowConfig.value.printFieldOptions.length > 0) {
    return systemFlowConfig.value.printFieldOptions;
  }
  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: '访问区域' },
  ];
});
// 手动录入状态
const isManualInput = ref(false);
@@ -710,6 +835,19 @@
const manualName = ref('');
const showMissingEntryHint = ref(false);
const isNameInputFocused = ref(false); // 记录姓名输入框是否处于聚焦状态
const loadSystemFlowConfig = async () => {
  try {
    const config = await getLocalSystemConfig();
    systemFlowConfig.value = config || {};
    if (!allowManualIdCard.value) {
      isManualInput.value = false;
    }
  } catch (error) {
    console.error('读取登记流程系统配置失败:', error);
    systemFlowConfig.value = {};
  }
};
const showCustomKeyboard = computed(() => {
  if (mode.value === 'online') {
@@ -1214,8 +1352,9 @@
};
// ----------------------------------------------------
onMounted(() => {
  loadVisitAreas();
onMounted(async () => {
  await loadSystemFlowConfig();
  await loadVisitAreas();
  clearAppointmentQrPayload();
  clearAppointmentIdCardPayload();
@@ -1257,14 +1396,13 @@
});
const currentStepName = computed(() => {
  if (mode.value === 'appointment') {
    return ['qr', 'idcard', 'face', 'host', 'print'][currentStep.value - 1];
  } else if (mode.value === 'online') {
    return ['idcard', 'face', 'host', 'form', 'print'][currentStep.value - 1];
  } else if (mode.value === 'signout') {
    return ['idcard', 'face', 'host'][currentStep.value - 1];
  return flowStepNames.value[currentStep.value - 1] || '';
});
watch(flowStepNames, (newFlowStepNames) => {
  if (currentStep.value > newFlowStepNames.length) {
    currentStep.value = newFlowStepNames.length || 1;
  }
  return '';
});
// 监听步骤变化,动态启停硬件
@@ -1329,6 +1467,11 @@
        if (!hasEntry) return;
      }
      if (mode.value === 'online') {
        const hasEntry = await verifyOnsiteEntry(cardData?.licId);
        if (hasEntry) return;
      }
      // 保存身份证识别结果,后续可用于身份校验和人脸照片比对
      appointmentIdCardPayload.value = cardData;
      saveAppointmentIdCardPayload(cardData);
@@ -1391,7 +1534,7 @@
    }
  }
  let maxStep = mode.value === 'signout' ? 3 : 5;
  const maxStep = flowStepNames.value.length;
  if (currentStep.value < maxStep) {
    currentStep.value++;
  } else if (currentStepName.value !== 'print') {
@@ -1426,6 +1569,23 @@
  return true;
};
const verifyOnsiteEntry = async (idCard) => {
  if (mode.value !== 'online') return false;
  if (!idCard) {
    return false;
  }
  const hasEntry = await findEntryRecordByIdCard(idCard);
  if (hasEntry) {
    openNoticeDialog('该身份已进场', '您已进场,请签离');
    return true;
  }
  return false;
};
const confirmManualInput = async () => {
  if (idNumber.value.length > 0) {
    if (mode.value === 'online' && !manualName.value.trim()) {
@@ -1437,6 +1597,13 @@
      const hasEntry = await verifySignoutEntry(idNumber.value);
      if (!hasEntry) {
        showMissingEntryHint.value = true;
        return;
      }
    }
    if (mode.value === 'online') {
      const hasEntry = await verifyOnsiteEntry(idNumber.value);
      if (hasEntry) {
        return;
      }
    }
@@ -1477,7 +1644,7 @@
    }
    const intervieweeName = appointmentQrcodePayload.value?.beVisitorName || '';
    const plateNo = plateNumber.value || '';
    const receiverVerifyMethod = '2'; // 接待人验证方式:默认人脸
    const receiverVerifyMethod = receiverVerifyMethodValue.value;
    let validEndTime = '';
    if (mode.value === 'online') {
@@ -1500,7 +1667,7 @@
        intervieweeName,
        receiverVerifyMethod,
        signImageUrl: '', // 由 syncEngine 负责上传并回填
        signReceiverName: intervieweeName || appointmentHostName.value,
        signReceiverName: shouldShowHostStep.value ? (intervieweeName || appointmentHostName.value) : '',
        signVerifyMethod: receiverVerifyMethod,
        signTime: nowFormat // 增加签离时间字段供离线接口使用
      };
@@ -1643,6 +1810,97 @@
  };
};
const getPrintVisitorName = () => {
  let printVisitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '无';
  if (mode.value === 'online' && manualName.value.trim()) {
    printVisitorName = manualName.value.trim();
  }
  return printVisitorName || '无';
};
const getPrintAppointmentTimeRange = () => {
  if (mode.value === 'online') {
    return `${visitEndDateText.value} ${selectedTime.value}`;
  }
  if (appointmentQrcodePayload.value) {
    return `${appointmentQrcodePayload.value.startTime || ''} 至 ${appointmentQrcodePayload.value.endTime || ''}` || '无';
  }
  return '无';
};
const getPrintFieldValueMap = () => {
  const hostName = appointmentQrcodePayload.value?.beVisitorName || '无';
  const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无');
  const photoPath = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress || '';
  return {
    visitorName: {
      type: 'text',
      value: getPrintVisitorName(),
    },
    idCard: {
      type: 'text',
      value: idNumber.value || appointmentIdCardPayload.value?.licId || '无',
    },
    appointmentTimeRange: {
      type: 'text',
      value: getPrintAppointmentTimeRange(),
    },
    interviewee: {
      type: 'text',
      value: hostName,
    },
    visitorFaceImage: {
      type: 'image',
      value: photoPath,
      emptyText: '无',
    },
    registerNo: {
      type: 'text',
      value: appointmentQrcodePayload.value?.registerNo || appointmentQrcodePayload.value?.appointmentNo || appointmentQrcodePayload.value?.reservationNo || '无',
    },
    plateNo: {
      type: 'text',
      value: plateNumber.value || '无',
    },
    visitReason: {
      type: 'text',
      value: appointmentQrcodePayload.value?.visitPurpose || '无',
    },
    visitArea: {
      type: 'text',
      value: areas || '无',
    },
  };
};
const pushPrintFieldByOption = (list, fieldOption, fieldValueMap) => {
  const fieldCode = fieldOption?.fieldCode;
  const fieldName = fieldOption?.fieldName || fieldCode || '未知字段';
  const fieldConfig = fieldValueMap[fieldCode];
  if (!fieldConfig) {
    return;
  }
  if (fieldConfig.type === 'image') {
    list.push(getPrintPair(fieldName, fieldConfig.value ? '已采集' : (fieldConfig.emptyText || '无')));
    if (fieldConfig.value) {
      const imageLine = createEmptyPrintLine();
      imageLine.path = fieldConfig.value;
      imageLine.alignment = 2;
      imageLine.imageWidth = 240;
      imageLine.feedLine = 1;
      list.push(imageLine);
    }
    return;
  }
  list.push(getPrintPair(fieldName, fieldConfig.value || '无'));
};
const startPrintTicket = async () => {
  try {
    const list = [];
@@ -1667,54 +1925,10 @@
    // 空行
    list.push(createEmptyPrintLine());
    // 登记时间
    const now = new Date();
    const registerTime = `${now.getFullYear()}-${String(now.getMonth() + 1).padStart(2, '0')}-${String(now.getDate()).padStart(2, '0')} ${String(now.getHours()).padStart(2, '0')}:${String(now.getMinutes()).padStart(2, '0')}`;
    list.push(getPrintPair("登记时间", registerTime));
    let printVisitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '未知';
    if (mode.value === 'online' && manualName.value.trim()) {
      printVisitorName = manualName.value.trim();
    }
    list.push(getPrintPair("访客", printVisitorName));
    list.push(getPrintPair("车牌号", plateNumber.value || '无'));
    const hostName = appointmentQrcodePayload.value?.beVisitorName || '无';
    list.push(getPrintPair("被访人", hostName));
    list.push(getPrintPair("接待人", hostName));
    const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无');
    list.push(getPrintPair("访问区域", areas));
    let visitTimeStr = '';
    if (mode.value === 'online') {
      visitTimeStr = `${visitEndDateText.value} ${selectedTime.value}`;
    } else {
      visitTimeStr = appointmentQrcodePayload.value ? `${appointmentQrcodePayload.value.startTime || ''} 至 ${appointmentQrcodePayload.value.endTime || ''}` : '无';
    }
    list.push(getPrintPair("访问时间", visitTimeStr));
    const idCardNumber = idNumber.value || appointmentIdCardPayload.value?.licId || '';
    list.push(getPrintPair("身份证号码", idCardNumber));
    // 人像标题
    const headerLabel = createEmptyPrintLine();
    headerLabel.text = "人像";
    headerLabel.alignment = 0;
    headerLabel.textBold = 1;
    headerLabel.feedLine = 1;
    list.push(headerLabel);
    // 人像图片
    const photoPath = liveFacePhotoPath.value || appointmentIdCardPayload.value?.photoAddress;
    if (photoPath) {
      const headerImage = createEmptyPrintLine();
      headerImage.path = photoPath;
      headerImage.alignment = 2;
      headerImage.imageWidth = 240;
      headerImage.feedLine = 1;
      list.push(headerImage);
    }
    const fieldValueMap = getPrintFieldValueMap();
    configuredPrintFieldOptions.value.forEach((fieldOption) => {
      pushPrintFieldByOption(list, fieldOption, fieldValueMap);
    });
    console.log('开始打印凭条,参数:', list);
    const data = JSON.stringify(list);
pages/commitment/index.vue
New file
@@ -0,0 +1,222 @@
<template>
    <view class="page-container" @touchstart="resetIdleTimer">
        <view class="header-area">
            <view class="left-action" @click="goBack">
                <view class="back-btn">
                    <image src="../../static/admin-detail/Ay837.png" class="back-icon" mode="aspectFit"></image>
                </view>
            </view>
        </view>
        <view class="content-wrapper">
            <text class="page-title">{{ title }}</text>
            <scroll-view class="scroll-area" scroll-y @scroll="resetIdleTimer">
                <view class="paragraphs">
                    <!-- 这里使用 text 组件包裹文本,并保留换行符支持 -->
                    <text class="commitment-text">{{ commitmentText }}</text>
                </view>
            </scroll-view>
            <view class="footer-area">
                <view class="confirm-btn" :class="{ disabled: countdown > 0 }" @click="handleConfirm">
                    <text class="btn-text">我已知晓{{ countdown > 0 ? `(${countdown}s)` : '' }}</text>
                </view>
            </view>
        </view>
    </view>
</template>
<script setup>
import { ref, onMounted, onUnmounted } from 'vue';
import { getLocalCommitmentConfig, syncCommitmentConfig } from '../../api/visitorConfig';
const countdown = ref(5);
const idleTime = ref(60);
const commitmentText = ref('');
const title = ref('');
let countdownTimer = null;
let idleTimer = null;
const loadCommitmentData = async () => {
    try {
        uni.showLoading({ title: '加载中...' });
        // 先尝试从在线接口拉取并同步到本地
        await syncCommitmentConfig();
        // 读取本地的入园承诺配置
        const config = await getLocalCommitmentConfig();
        if (config && config.protocolContent) {
            title.value = config.protocolName || '入园承诺';
            commitmentText.value = config.protocolContent;
        }
    } catch (err) {
        console.error('获取入园承诺配置失败:', err);
    } finally {
        uni.hideLoading();
    }
};
const goBack = () => {
    uni.navigateBack({
        fail: () => uni.reLaunch({ url: '/pages/home/index' })
    });
};
const handleConfirm = () => {
    if (countdown.value > 0) return;
    uni.redirectTo({
        url: '/pages/appointment-scan/index?mode=online&step=1',
    });
};
const resetIdleTimer = () => {
    idleTime.value = 60;
};
const startTimers = () => {
    countdownTimer = setInterval(() => {
        if (countdown.value > 0) {
            countdown.value--;
        } else {
            clearInterval(countdownTimer);
        }
    }, 1000);
    idleTimer = setInterval(() => {
        idleTime.value--;
        if (idleTime.value <= 0) {
            uni.reLaunch({ url: '/pages/home/index' });
        }
    }, 1000);
};
const clearTimers = () => {
    if (countdownTimer) clearInterval(countdownTimer);
    if (idleTimer) clearInterval(idleTimer);
};
onMounted(() => {
    loadCommitmentData();
    startTimers();
});
onUnmounted(() => {
    clearTimers();
});
</script>
<style scoped>
.page-container {
    width: 100vw;
    height: 100vh;
    background-color: #FFFFFF;
    display: flex;
    flex-direction: column;
    box-sizing: border-box;
}
.header-area {
    padding: 60px 40px 0;
    display: flex;
    align-items: center;
}
.left-action {
    display: flex;
    align-items: center;
    gap: 16px;
    cursor: pointer;
}
.back-btn {
    width: 44px;
    height: 44px;
    border-radius: 22px;
    border: 1px solid #E1EAF8;
    background: #F5F8FD;
    box-shadow: 0 6px 14px rgba(11, 23, 48, 0.03);
    display: flex;
    align-items: center;
    justify-content: center;
}
.back-icon {
    width: 20px;
    height: 20px;
}
.content-wrapper {
    flex: 1;
    display: flex;
    flex-direction: column;
    align-items: center;
    padding: 20px 80px 0;
    box-sizing: border-box;
    overflow: hidden;
}
.page-title {
    font-family: Inter, sans-serif;
    font-size: 36px;
    font-weight: 700;
    color: #1F2A44;
    margin-bottom: 50px;
    flex-shrink: 0;
}
.scroll-area {
    flex: 1;
    height: 0;
    width: 100%;
    box-sizing: border-box;
    margin-bottom: 40px;
}
.paragraphs {
    display: flex;
    flex-direction: column;
    padding-bottom: 20px;
}
.commitment-text {
    font-family: Inter, sans-serif;
    font-size: 16px;
    font-weight: 500;
    line-height: 1.6;
    color: #1F2A44;
    white-space: pre-wrap;
    word-break: break-all;
}
.footer-area {
    flex-shrink: 0;
    width: 100%;
    display: flex;
    justify-content: center;
    padding-bottom: 60px;
}
.confirm-btn {
    width: 600px;
    height: 88px;
    border-radius: 44px;
    background: #2F6BFF;
    display: flex;
    align-items: center;
    justify-content: center;
    cursor: pointer;
    transition: all 0.3s;
}
.confirm-btn.disabled {
    background: #A0ACC4;
    cursor: not-allowed;
}
.btn-text {
    font-size: 30px;
    font-weight: 700;
    color: #FFFFFF;
}
</style>
pages/home/index.vue
@@ -1,7 +1,13 @@
<script setup>
import { onMounted, ref } from 'vue';
import { onShow, onHide } from '@dcloudio/uni-app';
import { getOfflineConfig } 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 } from '../../api/visitorConfig';
import CommonNoticeDialog from '../../components/CommonNoticeDialog.vue';
import adminIcon from '../../static/home/avatar.png';
import operationImage from '../../static/home/operation.png';
import appointmentIcon from '../../static/home/appointment.png';
@@ -14,6 +20,9 @@
// 模拟后台配置:是否显示管理员入口,默认为 true
const showAdminEntry = ref(true);
const showOnsiteEntry = ref(true);
const showAppointmentEntry = ref(true);
const companyName = ref('华南光电');
const logoImage = ref(defaultLogoImage);
const screenStyle = {
@@ -60,18 +69,36 @@
  );
};
const serviceProviderName = ref('');
const loadHomeLogo = async () => {
  try {
    const offlineSystemConfig = await getOfflineConfig('system_config');
    const storageSystemConfig = uni.getStorageSync('system_config');
    const storageHomeConfig = uni.getStorageSync('home_config');
    const configuredLogo = resolveConfigLogoUrl(offlineSystemConfig)
      || resolveConfigLogoUrl(storageSystemConfig)
      || resolveConfigLogoUrl(storageHomeConfig);
    logoImage.value = configuredLogo || defaultLogoImage;
    // 合并配置,优先使用在线接口最新拉取的 system_config
    const mergedConfig = Object.assign({}, storageHomeConfig, offlineSystemConfig, storageSystemConfig);
    const configuredLogo = resolveConfigLogoUrl(mergedConfig);
    // logoImage.value = configuredLogo || defaultLogoImage;
    logoImage.value =  defaultLogoImage;
    // 解析新的开关和文案配置
    if (mergedConfig.hasOwnProperty('visitorMachineEntryEnabled')) {
      showAdminEntry.value =  Boolean(mergedConfig.visitorMachineEntryEnabled);
    }
    if (mergedConfig.hasOwnProperty('onsiteRegistrationEnabled')) {
      showOnsiteEntry.value = Boolean(mergedConfig.onsiteRegistrationEnabled);
    }
    if (mergedConfig.hasOwnProperty('appointmentRegistrationEnabled')) {
      showAppointmentEntry.value = Boolean(mergedConfig.appointmentRegistrationEnabled);
    }
    if (mergedConfig.systemName) {
      companyName.value = mergedConfig.serviceProvider;
    }
  } catch (error) {
    console.error('加载首页Logo失败:', error);
    console.error('加载首页Logo和配置失败:', error);
    logoImage.value = defaultLogoImage;
  }
};
@@ -105,7 +132,7 @@
const goOnline = () => {
  uni.navigateTo({
    url: '/pages/appointment-scan/index?mode=online&step=1',
    url: '/pages/commitment/index',
  })
}
@@ -115,13 +142,87 @@
  })
}
const noticeDialog = ref({
  visible: false,
  title: '',
  description: '',
});
const openNoticeDialog = (title, description) => {
  noticeDialog.value = {
    visible: true,
    title,
    description,
  };
};
const closeNoticeDialog = () => {
  noticeDialog.value.visible = false;
};
const initHomeScanner = () => {
  HardwareAPI.startScan(async (scanData) => {
    try {
      const decryptedResult = await parseHikvisionQrcodePayload(scanData);
      const payload = decryptedResult.payload;
      const validation = validateAppointmentQrcodePayload(payload);
      if (!validation.valid) {
        if (validation.reason === 'out_of_time_range') {
          openNoticeDialog('预约码不在进场时间内,请联系工作人员', '请检查您的预约时间');
        } else if (validation.reason === 'already_entered') {
          openNoticeDialog('该预约码已进场', '您已进场,请签离');
        } else {
          openNoticeDialog('无效的预约二维码', '请联系工作人员');
        }
        return;
      }
      saveAppointmentQrPayload(payload);
      // 扫码成功,直接进入预约登记的第二步(身份证阅读)
      uni.navigateTo({
        url: '/pages/appointment-scan/index?mode=appointment&step=2'
      });
    } catch (error) {
      console.error('首页扫码解析失败:', error);
      openNoticeDialog('未识别二维码,请联系工作人员', '');
    }
  });
};
const initHomeData = async () => {
  // 先加载本地缓存的 Logo 以求快速显示
  await loadHomeLogo();
  // 异步拉取并同步全局系统配置到本地
  await syncSystemConfig();
  // 再次加载,如果接口配置有更新,则实时应用最新配置
  await loadHomeLogo();
};
onMounted(() => {
  loadHomeLogo();
  initHomeData();
});
onShow(() => {
  initHomeScanner();
});
onHide(() => {
  HardwareAPI.stopScan();
});
</script>
<template>
    <view class="screen" :style="screenStyle">
        <CommonNoticeDialog
          v-if="noticeDialog.visible"
          :title="noticeDialog.title"
          :description="noticeDialog.description"
          @close="closeNoticeDialog"
        />
        <view class="top-bar">
            <image class="logo" :src="logoImage" mode="widthFix" />
            <view
@@ -144,7 +245,7 @@
                <view class="welcome-badge" :style="welcomeBadgeStyle">
                    <view class="welcome-text">欢迎到访</view>
                </view>
                <view class="company-name">华南光电</view>
                <view class="company-name">{{ companyName }}</view>
            </view>
            <view class="hero-figure">
@@ -153,12 +254,12 @@
        </view>
        <view class="entry-panel">
            <view class="entry-card blue" @click="goAppointment">
            <view class="entry-card blue" v-if="showAppointmentEntry" @click="goAppointment">
                <text class="entry-title">预约登记</text>
                <image class="entry-icon" :src="appointmentIcon" mode="aspectFit" />
            </view>
            <view class="entry-card purple" @click="goOnline">
            <view class="entry-card purple" v-if="showOnsiteEntry" @click="goOnline">
                <text class="entry-title">现场登记</text>
               <image class="entry-icon" :src="registerIcon" mode="aspectFit" />
            </view>
@@ -167,6 +268,10 @@
                <text class="entry-title">签离</text>
                <image class="entry-icon" :src="signOutIcon" mode="aspectFit" />
            </view>
        </view>
        <view class="provider-footer" v-if="serviceProviderName">
            <text class="provider-text">{{ serviceProviderName }}</text>
        </view>
    </view>
</template>
@@ -342,4 +447,16 @@
    width: 320rpx;
    height: 320rpx;
}
.provider-footer {
    position: absolute;
    bottom: 40rpx;
    width: 100%;
    text-align: center;
}
.provider-text {
    font-size: 28rpx;
    color: rgba(255, 255, 255, 0.6);
}
</style>
static/logo.png
Binary files differ
utils/hikvisionQrcode.js
@@ -1,53 +1,9 @@
import CryptoJS from 'crypto-js';
const DEFAULT_HIKVISION_QRCODE_CONFIG = {
    encryptEnabled: true,
    aesKey: 'WwXxYyZz1234!@#$',
    aesIv: 'AaBbCcDd1234!@#$',
};
const textEncoder = typeof TextEncoder !== 'undefined' ? new TextEncoder() : null;
const textDecoder = typeof TextDecoder !== 'undefined' ? new TextDecoder() : null;
const getCryptoInstance = () => {
    if (typeof globalThis !== 'undefined' && globalThis.crypto?.subtle) {
        return globalThis.crypto;
    }
    throw new Error('当前环境不支持 Web Crypto,无法解密预约二维码');
};
const stringToUint8Array = (value) => {
    if (!textEncoder) {
        throw new Error('当前环境不支持 TextEncoder');
    }
    return textEncoder.encode(String(value));
};
const decodeBase64ToUint8Array = (base64Text) => {
    if (typeof uni !== 'undefined' && typeof uni.base64ToArrayBuffer === 'function') {
        return new Uint8Array(uni.base64ToArrayBuffer(base64Text));
    }
    if (typeof atob === 'function') {
        const binary = atob(base64Text);
        const bytes = new Uint8Array(binary.length);
        for (let index = 0; index < binary.length; index += 1) {
            bytes[index] = binary.charCodeAt(index);
        }
        return bytes;
    }
    throw new Error('当前环境不支持 Base64 解码');
};
const uint8ArrayToString = (bytes) => {
    if (!textDecoder) {
        throw new Error('当前环境不支持 TextDecoder');
    }
    return textDecoder.decode(bytes);
};
const tryParseJson = (text) => {
@@ -97,24 +53,24 @@
        return rawText;
    }
    const crypto = getCryptoInstance();
    const secretKey = await crypto.subtle.importKey(
        'raw',
        stringToUint8Array(config.aesKey),
        { name: 'AES-CBC' },
        false,
        ['decrypt']
    );
    const decryptedBuffer = await crypto.subtle.decrypt(
        {
            name: 'AES-CBC',
            iv: stringToUint8Array(config.aesIv),
        },
        secretKey,
        decodeBase64ToUint8Array(rawText)
    );
    const key = CryptoJS.enc.Utf8.parse(config.aesKey);
    const iv = CryptoJS.enc.Utf8.parse(config.aesIv);
    return uint8ArrayToString(new Uint8Array(decryptedBuffer));
    try {
        const decrypted = CryptoJS.AES.decrypt(rawText, key, {
            iv: iv,
            mode: CryptoJS.mode.CBC,
            padding: CryptoJS.pad.Pkcs7
        });
        const plainText = decrypted.toString(CryptoJS.enc.Utf8);
        if (!plainText) {
            throw new Error('解密结果为空,可能密钥不匹配');
        }
        return plainText;
    } catch (e) {
        throw new Error('二维码解密失败,请检查加密配置或数据格式: ' + e.message);
    }
};
export const parseHikvisionQrcodePayload = async (scanData, customConfig = {}) => {
utils/request.js
@@ -11,6 +11,7 @@
};
export const buildBaseUrl = ({ ip, port }) => {
    return 'http://e9925a9c.natappfree.cc'
    const normalizedIp = normalizeIp(ip);
    const normalizedPort = normalizePort(port);