zjk
2026-07-22 2587d39850fe973d907a255964f5166d229066b8
feat: 新增员工同步、激活恢复与人脸检测API,修复样式与版本

- 新增员工验证列表同步模块并在首页初始化时调用
- 添加App启动时的离线设备激活恢复逻辑
- 新增两个人脸检测硬件API
- 清理多余CSS样式,回退版本号至v1.0.0
- 优化设备激活检查逻辑与初始化页面流程
1个文件已添加
10个文件已修改
1241 ■■■■■ 已修改文件
App.vue 27 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
api/employee.js 57 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/CommonNoticeDialog.vue 5 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/StepProgress.vue 1 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
components/admin/AdminConfigPanel.vue 3 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/admin-home/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/appointment-scan/n-index.nvue 1018 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/device-init/index.vue 36 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
pages/home/index.vue 2 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/request.js 14 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
utils/visitorDevice.js 76 ●●●●● 补丁 | 查看 | 原始文档 | blame | 历史
App.vue
@@ -2,7 +2,7 @@
import { initDB } from './utils/db';
import { startSyncQueue, runDailyCleanup } from './utils/syncEngine';
import { HardwareAPI } from './utils/visitorDevice';
import { hasRequestServerConfig } from './utils/request';
import { getDeviceActivationStorage, hasActivatedDevice } from './utils/request';
export default {
    onLaunch() {
@@ -12,6 +12,7 @@
        HardwareAPI.init().then(() => {
            console.log('硬件插件全局初始化完成');
            this.restoreOfflineActivation();
        }).catch((err) => {
            console.error('硬件插件全局初始化失败:', err);
        });
@@ -44,6 +45,26 @@
        console.log('App Hide');
    },
    methods: {
        async restoreOfflineActivation() {
            // 已激活设备在每次 App 启动时都补调一次离线激活,确保原生插件状态恢复
            if (!hasActivatedDevice()) {
                return;
            }
            const { deviceFingerprint } = getDeviceActivationStorage();
            if (!deviceFingerprint) {
                return;
            }
            try {
                await HardwareAPI.offlineActivateDevice({
                    code: deviceFingerprint,
                });
                console.log('已激活设备启动时离线激活成功');
            } catch (error) {
                console.error('已激活设备启动时离线激活失败:', error);
            }
        },
        checkDeviceActivation() {
            const pages = getCurrentPages();
            if (pages.length > 0) {
@@ -53,8 +74,8 @@
                }
            }
            // 统一从公共请求层判断服务地址是否已配置
            if (!hasRequestServerConfig()) {
            // 只有“服务地址 + 设备ID + 设备指纹”都存在时,才认为设备已完成激活
            if (!hasActivatedDevice()) {
                console.log('未检测到设备激活信息,强制跳转激活页');
                setTimeout(() => {
                    uni.reLaunch({
api/employee.js
New file
@@ -0,0 +1,57 @@
import { request } from '../utils/request';
import { saveOfflineConfig, getOfflineConfig } from '../utils/db';
const EMPLOYEE_VERIFY_LIST_KEY = 'employee_verify_list';
const normalizeEmployeeListResponse = (responseData) => {
  if (Array.isArray(responseData)) {
    return responseData;
  }
  if (!responseData || typeof responseData !== 'object') {
    return [];
  }
  const candidateKeys = ['records', 'list', 'rows', 'items', 'data'];
  for (const key of candidateKeys) {
    if (Array.isArray(responseData[key])) {
      return responseData[key];
    }
  }
  return [];
};
export const getAllEmployeeVerifyAPI = (data = {}) => {
  return request({
    url: '/api/employee/verify/allEmployee',
    method: 'POST',
    data,
  });
};
export const syncEmployeeVerifyList = async () => {
  try {
    const res = await getAllEmployeeVerifyAPI();
    if (res && res.code === 200) {
      const employeeList = normalizeEmployeeListResponse(res.data);
      await saveOfflineConfig(EMPLOYEE_VERIFY_LIST_KEY, employeeList);
      uni.setStorageSync(EMPLOYEE_VERIFY_LIST_KEY, employeeList);
      return employeeList;
    }
  } catch (error) {
    console.error('同步员工验证列表失败:', error);
  }
  return [];
};
export const getLocalEmployeeVerifyList = async () => {
  const offlineEmployeeList = await getOfflineConfig(EMPLOYEE_VERIFY_LIST_KEY);
  if (Array.isArray(offlineEmployeeList)) {
    return offlineEmployeeList;
  }
  const storageEmployeeList = uni.getStorageSync(EMPLOYEE_VERIFY_LIST_KEY);
  return Array.isArray(storageEmployeeList) ? storageEmployeeList : [];
};
components/CommonNoticeDialog.vue
@@ -120,13 +120,11 @@
<style scoped>
.dialog-wrapper {
    position: fixed;
    inset: 0;
    z-index: 30;
}
.page-mask {
    position: absolute;
    inset: 0;
    background: rgba(31, 42, 68, 0.6);
}
@@ -136,7 +134,6 @@
    top: 808px;
    width: 750px;
    padding: 52px 40px;
    box-sizing: border-box;
    border-radius: 28px;
    background: #FFFFFF;
    display: flex;
@@ -171,7 +168,7 @@
.button-row {
    width: 670px;
    min-height: 72px;
    height: 72px;
    display: flex;
    align-items: center;
    justify-content: center;
components/StepProgress.vue
@@ -212,7 +212,6 @@
  font-family: Inter, sans-serif;
  font-size: 23px;
  text-align: center;
  white-space: nowrap;
}
.completed-text {
components/admin/AdminConfigPanel.vue
@@ -173,7 +173,7 @@
const showLogoutDialog = ref(false);
const isLoggingOut = ref(false);
const currentVersion = ref('v2.1.0');
const currentVersion = ref('v1.0.0');
const latestVersion = ref('');
const hasNewVersion = ref(false);
@@ -364,6 +364,7 @@
    // 3. 清理本地所有核心缓存
    uni.removeStorageSync('device_id');
    uni.removeStorageSync('device_fingerprint');
    clearRequestServerConfig(); // 清理 IP 和 端口
    // 如果有离线数据缓存等,也应一并清理
pages/admin-home/index.vue
@@ -586,7 +586,7 @@
    const supportIndex = deviceInfo.value.findIndex(item => item.id === 'support');
    if (versionIndex > -1) {
      deviceInfo.value[versionIndex].text = `当前版本号:${config.version || config.systemVersion || 'v2.1.0'}`;
      deviceInfo.value[versionIndex].text = `当前版本号:${config.version || config.systemVersion || 'v1.0.0'}`;
    }
    if (vendorIndex > -1) {
      deviceInfo.value[vendorIndex].text = `服务厂家:${config.serviceProvider || config.manufacturer || '未知'}`;
pages/appointment-scan/n-index.nvue
@@ -215,17 +215,35 @@
          <view class="viewfinder-area">
            <view class="viewfinder-box">
              <!-- <live-pusher
              <live-pusher
                id="faceCamera"
                class="face-camera"
                device-position="front"
                mode="FHD"
                orientation="vertical"
                device-position="back"
                :muted="true"
                local-mirror="disable"
                :enable-camera="true"
                :auto-focus="true"
                :beauty="1"
                :whiteness="2"
                @error="handleFaceCameraError"
              /> -->
              <view class="head-ellipse"></view>
              <view class="shoulder-ellipse"></view>
              />
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-tl">
                <view class="corner-donut corner-donut-tl"></view>
              </view>
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-tr">
                <view class="corner-donut corner-donut-tr"></view>
              </view>
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-bl">
                <view class="corner-donut corner-donut-bl"></view>
              </view>
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-br">
                <view class="corner-donut corner-donut-br"></view>
              </view>
              <view class="viewfinder-border-mask"></view>
              <!-- <view class="head-ellipse"></view>
              <view class="shoulder-ellipse"></view> -->
              <view ref="faceScanLineRef" class="scan-line"></view>
            </view>
            <text class="viewfinder-hint">请将脸部置于框内,保持静止</text>
@@ -271,8 +289,8 @@
            <text class="card-title">识别失败?</text>
          </view>
          <view class="main-btn" @click="handleFaceVerifyNextStep">
            <!-- <text class="btn-text">重新采集人脸</text> -->
            <text class="btn-text">跳过人脸采集</text>
            <text class="btn-text">重新采集人脸</text>
            <!-- <text class="btn-text">跳过人脸采集</text> -->
          </view>
        </view>
      </view>
@@ -295,13 +313,38 @@
        <view class="face-capture-card">
          <view class="status-pill">
            <view class="status-dot"></view>
            <text class="status-text">接待人核验中...</text>
            <text class="status-text">{{ hostVerifyStatusText }}</text>
          </view>
          <view class="viewfinder-area">
            <view class="viewfinder-box">
              <view class="head-ellipse"></view>
              <view class="shoulder-ellipse"></view>
              <live-pusher
                id="hostCamera"
                class="face-camera"
                mode="FHD"
                orientation="vertical"
                device-position="back"
                :muted="true"
                local-mirror="disable"
                :enable-camera="true"
                :auto-focus="true"
                :beauty="1"
                :whiteness="2"
                @error="handleFaceCameraError"
              />
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-tl">
                <view class="corner-donut corner-donut-tl"></view>
              </view>
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-tr">
                <view class="corner-donut corner-donut-tr"></view>
              </view>
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-bl">
                <view class="corner-donut corner-donut-bl"></view>
              </view>
              <view class="viewfinder-corner-wrap viewfinder-corner-wrap-br">
                <view class="corner-donut corner-donut-br"></view>
              </view>
              <view class="viewfinder-border-mask"></view>
              <view ref="hostScanLineRef" class="scan-line"></view>
            </view>
            <text class="viewfinder-hint">请将脸部置于框内,保持静止</text>
@@ -346,9 +389,9 @@
            </view>
            <text class="card-title">验证失败?</text>
          </view>
          <view class="main-btn" @click="nextStep">
            <!-- <text class="btn-text">重新验证接待人(测试下一步)</text> -->
            <text class="btn-text">跳过人脸采集</text>
          <view class="main-btn" @click="retryFaceVerify">
            <text class="btn-text">重新验证接待人</text>
            <!-- <text class="btn-text">跳过人脸采集</text> -->
          </view>
        </view>
      </view>
@@ -594,7 +637,7 @@
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted, watch, nextTick } from 'vue';
import { ref, computed, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue';
import { onLoad } from '@dcloudio/uni-app';
import StepProgress from '../../components/StepProgress.vue';
import CommonNoticeDialog from '../../components/CommonNoticeDialog.vue';
@@ -602,6 +645,8 @@
import { saveImageToLocal, deleteLocalImage } from '../../utils/fs';
import { getVisitAreaTree } from '../../api/visitArea';
import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, getOfflineConfig, saveOfflineConfig } from '../../utils/db';
import { buildBaseUrl, getRequestServerConfig } from '../../utils/request';
import { getLocalEmployeeVerifyList } from '../../api/employee';
import { getLocalSystemConfig } from '../../api/visitorConfig';
import { startSyncQueue } from '../../utils/syncEngine';
import {
@@ -633,6 +678,7 @@
const hostScanLineRef = ref(null);
const facePreviewReady = ref(false);
const liveFacePhotoPath = ref('');
const hostLiveFacePhotoPath = ref('');
let hasMountedHardwareFlow = false;
const scanLineOffset = 400;
let scanLineAnimationTicket = 0;
@@ -779,8 +825,241 @@
    || '请按提示完成人脸认证';
});
const faceVerifyStatusText = ref('等待开始人证比对');
const hostVerifyStatusText = ref('等待接待人开始验证');
let faceCameraContext = null;
let faceCaptureTimer = null;
let hostCameraContext = null;
let faceJudgePollTimer = null;
let faceCaptureDelayTimer = null;
let judgeFaceSessionId = 0;
let isJudgeFaceRunning = false;
const currentInstance = getCurrentInstance();
const employeeVerifyList = ref([]);
const hostMatchedEmployee = ref(null);
const FACE_JUDGE_POLL_INTERVAL = 1000;
const FACE_CAPTURE_DELAY = 2000;
const LIVE_PUSHER_READY_DELAY = 0;
const wait = (delay = 0) => {
  return new Promise((resolve) => {
    setTimeout(resolve, delay);
  });
};
const getFirstFilledValue = (source = {}, keys = []) => {
  for (const key of keys) {
    const value = source?.[key];
    if (value !== undefined && value !== null && String(value).trim() !== '') {
      return value;
    }
  }
  return '';
};
const normalizeLookupValue = (value = '') => {
  return String(value || '').trim().toLowerCase();
};
const normalizeCardNoValue = (value = '') => {
  return String(value || '').trim().toUpperCase();
};
const getHostEmployeeLookupCandidates = (payload = {}) => {
  const exactCandidates = [
    payload?.beVisitorId,
    payload?.beVisitorCode,
    payload?.beVisitorNo,
    payload?.beVisitorJobNo,
    payload?.beVisitorWorkNo,
    payload?.beVisitorCardNo,
    payload?.intervieweeId,
    payload?.intervieweeCode,
    payload?.intervieweeNo,
    payload?.receiverId,
    payload?.receiverCode,
    payload?.receiverCardNo,
  ].map(normalizeCardNoValue).filter(Boolean);
  const nameCandidates = [
    payload?.beVisitorName,
    payload?.intervieweeName,
    payload?.receiverName,
  ].map(normalizeLookupValue).filter(Boolean);
  return {
    exactCandidates: Array.from(new Set(exactCandidates)),
    nameCandidates: Array.from(new Set(nameCandidates)),
  };
};
const getEmployeeExactMatchCandidates = (employee = {}) => {
  return [
    employee?.employeeId,
    employee?.employeeCode,
    employee?.employeeNo,
    employee?.jobNo,
    employee?.workNo,
    employee?.staffNo,
    employee?.userId,
    employee?.userCode,
    employee?.cardNo,
    employee?.cardNum,
    employee?.cardId,
    employee?.icCardNo,
    employee?.accessCardNo,
    employee?.employeeCardNo,
  ].map(normalizeCardNoValue).filter(Boolean);
};
const getEmployeeNameCandidates = (employee = {}) => {
  return [
    employee?.employeeName,
    employee?.name,
    employee?.userName,
    employee?.staffName,
    employee?.realName,
  ].map(normalizeLookupValue).filter(Boolean);
};
const resolveEmployeeFacePhotoAddress = (employee = {}) => {
  const rawPhotoAddress = getFirstFilledValue(employee, [
    'faceImage',
    'faceImageUrl',
    'faceUrl',
    'facePhoto',
    'photoAddress',
    'photoUrl',
    'avatar',
    'avatarUrl',
    'headImg',
    'headImage',
    'imageUrl',
  ]);
  const normalizedPhotoAddress = String(rawPhotoAddress || '').trim();
  if (!normalizedPhotoAddress) {
    return '';
  }
  if (/^(https?:\/\/|file:\/\/|content:|_doc\/|\/storage\/)/.test(normalizedPhotoAddress)) {
    return normalizedPhotoAddress;
  }
  if (normalizedPhotoAddress.startsWith('/')) {
    const baseUrl = buildBaseUrl(getRequestServerConfig());
    return baseUrl ? `${baseUrl}${normalizedPhotoAddress}` : normalizedPhotoAddress;
  }
  return normalizedPhotoAddress;
};
const findMatchedHostEmployee = (payload = appointmentQrcodePayload.value || {}) => {
  if (!Array.isArray(employeeVerifyList.value) || employeeVerifyList.value.length === 0) {
    return null;
  }
  const { exactCandidates, nameCandidates } = getHostEmployeeLookupCandidates(payload);
  if (exactCandidates.length > 0) {
    const exactMatchedEmployee = employeeVerifyList.value.find((employee) => {
      const employeeCandidates = getEmployeeExactMatchCandidates(employee);
      return employeeCandidates.some((candidate) => exactCandidates.includes(candidate));
    });
    if (exactMatchedEmployee) {
      return exactMatchedEmployee;
    }
  }
  if (nameCandidates.length > 0) {
    return employeeVerifyList.value.find((employee) => {
      const employeeNameCandidates = getEmployeeNameCandidates(employee);
      return employeeNameCandidates.some((candidate) => nameCandidates.includes(candidate));
    }) || null;
  }
  return null;
};
const getMatchedHostEmployeeCardNo = (employee = {}) => {
  return getFirstFilledValue(employee, [
    'cardNo',
    'cardNum',
    'cardId',
    'icCardNo',
    'accessCardNo',
    'employeeCardNo',
  ]);
};
const getReaderCardNo = (cardData = {}) => {
  return getFirstFilledValue(cardData, [
    'cardNo',
    'cardNum',
    'cardId',
    'icCardNo',
    'accessCardNo',
    'serialNo',
    'uid',
    'cardSerialNo',
  ]);
};
const verifyHostEmployeeCard = (cardData = {}) => {
  const matchedEmployee = findMatchedHostEmployee();
  hostMatchedEmployee.value = matchedEmployee;
  if (!matchedEmployee) {
    return {
      passed: false,
      reason: 'employee_not_found',
    };
  }
  const employeeCardNo = normalizeCardNoValue(getMatchedHostEmployeeCardNo(matchedEmployee));
  const readCardNo = normalizeCardNoValue(getReaderCardNo(cardData));
  if (!readCardNo) {
    return {
      passed: false,
      reason: 'card_no_missing',
    };
  }
  if (!employeeCardNo) {
    return {
      passed: false,
      reason: 'employee_card_missing',
      employee: matchedEmployee,
    };
  }
  if (employeeCardNo !== readCardNo) {
    return {
      passed: false,
      reason: 'card_no_mismatch',
      employee: matchedEmployee,
      employeeCardNo,
      readCardNo,
    };
  }
  return {
    passed: true,
    employee: matchedEmployee,
    readCardNo,
  };
};
const loadEmployeeVerifyList = async () => {
  try {
    employeeVerifyList.value = await getLocalEmployeeVerifyList();
    hostMatchedEmployee.value = findMatchedHostEmployee();
  } catch (error) {
    console.error('读取本地员工验证列表失败:', error);
    employeeVerifyList.value = [];
    hostMatchedEmployee.value = null;
  }
};
const hours = Array.from({ length: 24 }, (_, index) => String(index).padStart(2, '0'));
const minutes = Array.from({ length: 60 }, (_, index) => String(index).padStart(2, '0'));
@@ -1225,13 +1504,49 @@
  return String(appointmentIdCardPayload.value?.photoAddress || '').trim();
};
const clearFaceCaptureTimer = () => {
  if (faceCaptureTimer) {
    clearTimeout(faceCaptureTimer);
    faceCaptureTimer = null;
// 统一更新当前步骤对应的状态文案,避免 face/host 两套状态分支重复判断
const updateVerifyStatusText = (stepName, text) => {
  if (stepName === 'host') {
    hostVerifyStatusText.value = text;
    return;
  }
  faceVerifyStatusText.value = text;
};
// 清理“每秒轮询判脸”的定时器
const clearJudgeFacePollTimer = () => {
  if (faceJudgePollTimer) {
    clearTimeout(faceJudgePollTimer);
    faceJudgePollTimer = null;
  }
};
// 清理人脸相关的所有延时任务,避免步骤切换后旧回调串到新步骤
const clearFaceCaptureTimer = () => {
  clearJudgeFacePollTimer();
  if (faceCaptureDelayTimer) {
    clearTimeout(faceCaptureDelayTimer);
    faceCaptureDelayTimer = null;
  }
};
// 安全停止 live-pusher 预览,失败也不阻塞后续流程
const stopLivePreview = (cameraContext) => {
  return new Promise((resolve) => {
    if (!cameraContext || typeof cameraContext.stopPreview !== 'function') {
      resolve();
      return;
    }
    cameraContext.stopPreview({
      success: () => resolve(),
      fail: () => resolve(),
    });
  });
};
// 清理访客现场抓拍图,避免本地临时图片堆积
const cleanupLiveFacePhoto = async () => {
  if (!liveFacePhotoPath.value) {
    return;
@@ -1247,16 +1562,51 @@
  }
};
const resetFacePreviewState = async () => {
  clearFaceCaptureTimer();
  facePreviewReady.value = false;
  await cleanupLiveFacePhoto();
// 清理接待人现场抓拍图,和访客抓拍图分开管理
const cleanupHostLiveFacePhoto = async () => {
  if (!hostLiveFacePhotoPath.value) {
    return;
  }
  const currentPath = hostLiveFacePhotoPath.value;
  hostLiveFacePhotoPath.value = '';
  try {
    await deleteLocalImage(currentPath);
  } catch (error) {
    console.error('清理接待人抓拍照片失败:', error);
  }
};
// 统一重置人脸预览相关状态。
// 这里只清理页面内的本地状态和定时器,不主动调用任何插件 stop 方法,
// 避免在步骤切换或流程中途把硬件能力提前停掉。
const resetFacePreviewState = async () => {
  // sessionId 自增后,旧的判脸轮询和延迟抓拍都会自动失效
  judgeFaceSessionId += 1;
  isJudgeFaceRunning = false;
  clearFaceCaptureTimer();
  facePreviewReady.value = false;
  // 中途只清理临时图片和上下文引用,真正的 stop 动作统一放到页面销毁时
  await Promise.allSettled([
    cleanupLiveFacePhoto(),
    cleanupHostLiveFacePhoto(),
  ]);
  faceCameraContext = null;
  hostCameraContext = null;
};
// 创建访客人脸步骤的 live-pusher 上下文,并在原生视图可用后开启预览
const ensureFaceCameraContext = async () => {
  await nextTick();
  // 打包环境下原生视图挂载通常慢于本地调试,这里预留一点缓冲时间
  await wait(LIVE_PUSHER_READY_DELAY);
  faceCameraContext = uni.createLivePusherContext('faceCamera', this);
  const currentPageContext = currentInstance?.proxy;
  faceCameraContext = currentPageContext
    ? uni.createLivePusherContext('faceCamera', currentPageContext)
    : uni.createLivePusherContext('faceCamera');
  if (!faceCameraContext) {
    throw new Error('当前环境不支持摄像头拍照(LivePusher)');
  }
@@ -1265,7 +1615,7 @@
    faceCameraContext.startPreview({
      success: () => {
        facePreviewReady.value = true;
        faceVerifyStatusText.value = '请保持面部位于取景框内';
        faceVerifyStatusText.value = '请将脸部置于框内';
        resolve();
      },
      fail: (err) => {
@@ -1275,11 +1625,13 @@
  });
};
// 接收访客摄像头组件初始化成功事件,同步页面状态
const handleFaceCameraInit = () => {
  facePreviewReady.value = true;
  faceVerifyStatusText.value = '请保持面部位于取景框内';
};
// 摄像头初始化失败时统一弹窗,方便真机联调定位问题
const handleFaceCameraError = (error) => {
  facePreviewReady.value = false;
  faceVerifyStatusText.value = '摄像头预览初始化失败';
@@ -1298,26 +1650,233 @@
  }
};
const captureLiveFacePhoto = async () => {
  if (!faceCameraContext) {
    throw new Error('摄像头尚未初始化完成');
// 创建接待人人脸步骤的 live-pusher 上下文,并开启预览
const ensureHostCameraContext = async () => {
  await nextTick();
  await wait(LIVE_PUSHER_READY_DELAY);
  const currentPageContext = currentInstance?.proxy;
  hostCameraContext = currentPageContext
    ? uni.createLivePusherContext('hostCamera', currentPageContext)
    : uni.createLivePusherContext('hostCamera');
  if (!hostCameraContext) {
    throw new Error('当前环境不支持摄像头拍照(LivePusher)');
  }
  return new Promise((resolve, reject) => {
    hostCameraContext.startPreview({
      success: () => {
        hostVerifyStatusText.value = '请将脸部置于框内';
        resolve();
      },
      fail: (err) => {
        reject(new Error('LivePusher 预览开启失败: ' + JSON.stringify(err)));
      }
    });
  });
};
// 对 live-pusher 的抓拍做统一封装,兼容不同环境下的返回结构
const takeCameraSnapshot = async (cameraContext, errorMessage) => {
  if (!cameraContext) {
    throw new Error(errorMessage);
  }
  const tempImagePath = await new Promise((resolve, reject) => {
    faceCameraContext.snapshot({
    cameraContext.snapshot({
      success: (result) => resolve(result?.message?.tempImagePath || result?.tempImagePath || ''),
      fail: (error) => reject(error),
    });
  });
  if (!tempImagePath) {
    throw new Error('未获取到现场人脸照片');
    throw new Error(errorMessage);
  }
  return tempImagePath;
};
// 判脸阶段只需要临时抓拍图,不需要落库
const captureJudgeFacePhoto = async (stepName) => {
  const cameraContext = stepName === 'host' ? hostCameraContext : faceCameraContext;
  return await takeCameraSnapshot(cameraContext, '判脸抓拍失败,请重试');
};
// 接待人正式比对前抓拍现场照片,并保存到本地临时路径
const captureHostLiveFacePhoto = async () => {
  const tempImagePath = await takeCameraSnapshot(hostCameraContext, '接待人抓拍失败,未获取到图片路径');
  hostLiveFacePhotoPath.value = await saveImageToLocal(tempImagePath);
  return hostLiveFacePhotoPath.value;
};
// 轮询判脸:每秒抓拍一张图,检测到人脸后再等待 2 秒进行正式抓拍
const startJudgeFacePolling = ({ stepName, onFaceDetected }) => {
  const currentSessionId = ++judgeFaceSessionId;
  clearFaceCaptureTimer();
  isJudgeFaceRunning = false;
  updateVerifyStatusText(stepName, '请将脸部置于框内');
  const pollJudgeFace = async () => {
    // sessionId 不一致说明步骤已切换,直接放弃旧轮询
    if (currentSessionId !== judgeFaceSessionId) {
      return;
    }
    // 上一轮判脸尚未结束时,不重复并发调用插件
    if (isJudgeFaceRunning) {
      faceJudgePollTimer = setTimeout(pollJudgeFace, FACE_JUDGE_POLL_INTERVAL);
      return;
    }
    isJudgeFaceRunning = true;
    try {
      const judgePhotoPath = await captureJudgeFacePhoto(stepName);
      // 插件只负责判断“图片里有没有人脸”,不做正式比对
      await new Promise((resolve, reject) => {
        HardwareAPI.startJudgeFace(
          { face: judgePhotoPath },
          (result) => resolve(result),
          (error) => reject(error),
        );
      });
      if (currentSessionId !== judgeFaceSessionId) {
        return;
      }
      // 判到人脸后不在流程中途调用 stopJudgeFace,只做稳定等待后正式抓拍
      updateVerifyStatusText(stepName, '已检测到人脸,请保持不动');
      faceCaptureDelayTimer = setTimeout(async () => {
        faceCaptureDelayTimer = null;
        if (currentSessionId !== judgeFaceSessionId) {
          return;
        }
        await onFaceDetected(currentSessionId);
      }, FACE_CAPTURE_DELAY);
      return;
    } catch (error) {
      if (currentSessionId !== judgeFaceSessionId) {
        return;
      }
      updateVerifyStatusText(stepName, '请将脸部置于框内');
    } finally {
      isJudgeFaceRunning = false;
    }
    if (currentSessionId !== judgeFaceSessionId) {
      return;
    }
    // 未检测到人脸时继续下一轮轮询
    faceJudgePollTimer = setTimeout(pollJudgeFace, FACE_JUDGE_POLL_INTERVAL);
  };
  faceJudgePollTimer = setTimeout(pollJudgeFace, 0);
  return currentSessionId;
};
// 接待人人脸验证:先匹配员工,再拉起摄像头判脸,最后拿员工人脸照做比对
const startHostVerify = () => {
  const matchedEmployee = findMatchedHostEmployee();
  hostMatchedEmployee.value = matchedEmployee;
  if (!matchedEmployee) {
    openNoticeDialog(
      '未找到接待人员工信息',
      '请确认接待人信息与员工档案一致'
    );
    return;
  }
  const employeeFacePhotoAddress = resolveEmployeeFacePhotoAddress(matchedEmployee);
  if (!employeeFacePhotoAddress) {
    openNoticeDialog(
      '未获取到接待人人脸照片',
      '当前接待人员工档案未配置人脸照片,请先完善员工资料'
    );
    return;
  }
  resetFacePreviewState().then(async () => {
    try {
      hostVerifyStatusText.value = '正在开启摄像头...';
      await ensureHostCameraContext();
      // 只有判到人脸后,才进入正式抓拍和人脸比对
      startJudgeFacePolling({
        stepName: 'host',
        onFaceDetected: async (sessionId) => {
          try {
            hostVerifyStatusText.value = '正在采集接待人人脸...';
            const livePhotoAddress = await captureHostLiveFacePhoto();
            // 步骤若已变化,直接丢弃这次异步结果
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
            hostVerifyStatusText.value = '接待人人脸比对中...';
            const faceResult = await new Promise((resolve, reject) => {
              HardwareAPI.startFaceVerify(
                {
                  face: livePhotoAddress,
                  photo: employeeFacePhotoAddress,
                  employeeFacePhotoAddress,
                  photoAddress: employeeFacePhotoAddress,
                  capturePhotoAddress: livePhotoAddress,
                },
                (result) => resolve(result),
                (error) => reject(error),
              );
            });
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
            if (!isFaceVerifyPassed(faceResult)) {
              hostVerifyStatusText.value = '接待人验证失败';
              openNoticeDialog(
                '接待人验证失败,请重新验证',
                '请正视屏幕重新进行人脸认证'
              );
              return;
            }
            hostVerifyStatusText.value = '接待人验证成功';
            nextStep();
          } catch (error) {
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
            hostVerifyStatusText.value = '接待人验证失败';
            openNoticeDialog(
              resolveFaceVerifyErrorMessage(error),
              '请正视屏幕重新进行人脸认证'
            );
          }
        },
      });
    } catch (err) {
      hostVerifyStatusText.value = '摄像头启动失败';
      openNoticeDialog('摄像头预览失败,请重新验证', err.message);
    }
  }).catch((err) => {
    hostVerifyStatusText.value = '摄像头初始化失败';
    openNoticeDialog('摄像头初始化失败,请重新验证', err.message);
  });
};
// 访客正式抓拍现场人脸图,并落到本地临时路径供后续比对/保存
const captureLiveFacePhoto = async () => {
  const tempImagePath = await takeCameraSnapshot(faceCameraContext, '未获取到现场人脸照片');
  liveFacePhotoPath.value = await saveImageToLocal(tempImagePath);
  return liveFacePhotoPath.value;
};
// 将现场抓拍图写回身份 payload,方便后续打印或接口复用
const saveLiveFacePhotoToIdentityPayload = (livePhotoAddress) => {
  const nextIdentityPayload = {
    ...(appointmentIdCardPayload.value || {}),
@@ -1328,6 +1887,7 @@
  saveAppointmentIdCardPayload(nextIdentityPayload);
};
// 身份核验通过后统一推进到人脸步骤,并顺手重置手动录入状态
const advanceToFaceStep = () => {
  dismissNameInput();
  faceVerifyStatusText.value = '等待开始人证比对';
@@ -1380,13 +1940,11 @@
};
const handleFaceVerifyNextStep = () => {
  if (faceCaptureTimer) {
    clearTimeout(faceCaptureTimer);
    faceCaptureTimer = null;
  }
  nextStep();
  clearFaceCaptureTimer();
  startFaceVerify();
};
// 访客人脸验证:先判脸,再正式抓拍,最后根据是否有身份证照片决定是否做人证比对
const startFaceVerify = () => {
  const currentIdentityPayload = appointmentIdCardPayload.value || {};
  const isManualIdentityInput = currentIdentityPayload.inputType === 'manual';
@@ -1403,82 +1961,96 @@
  resetFacePreviewState().then(async () => {
    try {
      faceVerifyStatusText.value = '摄像头启动中...';
      faceVerifyStatusText.value = '正在开启摄像头...';
      await ensureFaceCameraContext();
      // 访客和接待人共用一套“先判脸,再正式抓拍”的流程
      startJudgeFacePolling({
        stepName: 'face',
        onFaceDetected: async (sessionId) => {
          try {
            faceVerifyStatusText.value = '正在采集人脸...';
            const livePhotoAddress = await captureLiveFacePhoto();
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
      faceCaptureTimer = setTimeout(async () => {
        faceCaptureTimer = null;
        try {
          faceVerifyStatusText.value = '正在拍摄现场照片...';
          const livePhotoAddress = await captureLiveFacePhoto();
            saveLiveFacePhotoToIdentityPayload(livePhotoAddress);
          saveLiveFacePhotoToIdentityPayload(livePhotoAddress);
          if (isManualIdentityInput) {
            faceVerifyStatusText.value = '现场照片采集成功';
            nextStep();
            return;
          }
          faceVerifyStatusText.value = '人证比对中...';
          HardwareAPI.startFaceVerify(
            {
              face: livePhotoAddress,
              photo: idCardPhotoAddress,
              idCardPhotoAddress,
              livePhotoAddress,
              // 兼容现有原生参数命名
              photoAddress: idCardPhotoAddress,
              capturePhotoAddress: livePhotoAddress,
            },
            (faceResult) => {
              if (!isFaceVerifyPassed(faceResult)) {
                faceVerifyStatusText.value = '人证比对失败';
                openNoticeDialog(
                  '人证照片比对失败,请点击跳过或重新采集',
                  '请正视屏幕重新进行人脸认证'
                );
                return;
              }
              faceVerifyStatusText.value = '人证比对成功';
            // 手动录入身份证时没有证件照,跳过人证比对,仅保留现场人脸图
            if (isManualIdentityInput) {
              faceVerifyStatusText.value = '人脸采集成功';
              nextStep();
            },
            (error) => {
              return;
            }
            faceVerifyStatusText.value = '人证比对中...';
            const faceResult = await new Promise((resolve, reject) => {
              HardwareAPI.startFaceVerify(
                {
                  face: livePhotoAddress,
                  photo: idCardPhotoAddress,
                  idCardPhotoAddress,
                  livePhotoAddress,
                  // 兼容现有原生参数命名
                  photoAddress: idCardPhotoAddress,
                  capturePhotoAddress: livePhotoAddress,
                },
                (result) => resolve(result),
                (error) => reject(error),
              );
            });
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
            if (!isFaceVerifyPassed(faceResult)) {
              faceVerifyStatusText.value = '人证比对失败';
              openNoticeDialog(
                resolveFaceVerifyErrorMessage(error),
                '请正视屏幕重新进行人脸认证或跳过'
                '人证照片比对失败,请重新采集',
                '请正视屏幕重新进行人脸认证'
              );
              return;
            }
          );
        } catch (error) {
          faceVerifyStatusText.value = '现场拍照失败';
          openNoticeDialog(
            '摄像头拍照失败,可选择跳过',
            '请检查摄像头权限或直接点击跳过'
          );
        }
      }, 2000);
            faceVerifyStatusText.value = '人证比对成功';
            nextStep();
          } catch (error) {
            if (sessionId !== judgeFaceSessionId) {
              return;
            }
            faceVerifyStatusText.value = '人证比对失败';
            openNoticeDialog(
              resolveFaceVerifyErrorMessage(error),
              '请正视屏幕重新进行人脸认证'
            );
          }
        },
      });
    } 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(
        '摄像头启动失败',
        '请检查摄像头权限或直接点击跳过'
        '请检查摄像头权限'
      );
    }
  });
};
const retryFaceVerify = () => {
  startFaceVerify();
  if (currentStepName.value === 'host') {
    startHostVerify();
  } else {
    startFaceVerify();
  }
};
const handleAppointmentQrcodeValidation = (payload) => {
@@ -1543,6 +2115,7 @@
onMounted(async () => {
  await loadSystemFlowConfig();
  await loadEmployeeVerifyList();
  await loadVisitAreas();
  clearAppointmentIdCardPayload();
@@ -1558,8 +2131,44 @@
  hasMountedHardwareFlow = true;
  // 页面加载后立即开启身份证读卡监听。
  // 只有当前处于身份证读卡步骤且不是手动录入时,才消费回调结果。
  // 只有当前处于身份证读卡步骤或接待人验证步骤时,才消费回调结果。
  HardwareAPI.startReadCard(async (cardData) => {
    if (currentStepName.value === 'host') {
      const hostCardVerifyResult = verifyHostEmployeeCard(cardData);
      if (hostCardVerifyResult.reason === 'card_no_missing') {
        return;
      }
      if (!hostCardVerifyResult.passed) {
        if (hostCardVerifyResult.reason === 'employee_not_found') {
          openNoticeDialog(
            '未找到接待人员工信息',
            '请确认首页已成功同步员工列表,且预约中的接待人信息与员工档案一致'
          );
          return;
        }
        if (hostCardVerifyResult.reason === 'employee_card_missing') {
          openNoticeDialog(
            '接待人员工未配置卡号',
            '当前员工档案缺少卡号,无法完成刷卡校验,请改用人脸验证'
          );
          return;
        }
        openNoticeDialog(
          '接待人卡号不匹配',
          '当前刷卡结果与接待人员工卡号不一致,请重新刷卡或改用人脸验证'
        );
        return;
      }
      await resetFacePreviewState();
      nextStep();
      return;
    }
    if (currentStepName.value !== 'idcard' || isManualInput.value) {
      return;
    }
@@ -1606,12 +2215,14 @@
onUnmounted(() => {
  dismissNameInput();
  stopScanLineAnimation();
  // 离开页面时,确保关闭所有的扫描和读卡
  // 所有插件 stop 动作统一只放在页面销毁时调用,不在流程中途调用
  HardwareAPI.stopScan();
  HardwareAPI.stopReadCard();
  HardwareAPI.stopJudgeFace();
  HardwareAPI.stopFaceVerify();
  clearFaceCaptureTimer();
  cleanupLiveFacePhoto();
  stopLivePreview(faceCameraContext);
  stopLivePreview(hostCameraContext);
  resetFacePreviewState();
});
onLoad((options) => {
@@ -1639,33 +2250,40 @@
  }
});
// 监听步骤变化,动态启停硬件
watch(currentStepName, (newStep, oldStep) => {
// 监听步骤变化,步骤切换时只做状态清理,不在中途调用插件 stop 方法
watch(currentStepName, async (newStep, oldStep) => {
  // onLoad 修改 mode / step 时会先触发一次 watch,这里等页面挂载后再处理,
  // 避免现场登记和签离首屏读卡被 onLoad + onMounted 重复触发两次。
  if (!hasMountedHardwareFlow) {
    return;
  }
  dismissNameInput();
  // 先关闭上一步的硬件
  if (oldStep === 'qr') HardwareAPI.stopScan();
  if (oldStep === 'face') {
    HardwareAPI.stopFaceVerify();
  if (oldStep === 'face' || oldStep === 'host') {
    // 离开人脸相关步骤时,只让旧的异步和定时器失效,真正的 stop 留到页面销毁
    resetFacePreviewState();
  }
  await nextTick();
  await wait(50);
  // 启动新一步的硬件
  triggerHardwareByStep(newStep);
  syncScanLineAnimationByStep(newStep);
}, {
  flush: 'post',
});
// 动态触发硬件逻辑
// 按步骤统一拉起对应的硬件动作,避免逻辑散落在多个 watch / onMounted 里
const triggerHardwareByStep = (stepName) => {
  // 只有预约登记第一步才需要扫码
  if (mode.value === 'appointment' && stepName === 'qr') {
    HardwareAPI.startScan(async (scanData) => {
      // 不再在步骤切换时主动 stopScan,所以这里只允许二维码步骤消费扫码结果
      if (currentStepName.value !== 'qr' || mode.value !== 'appointment') {
        return;
      }
      try {
        const decryptedResult = await parseHikvisionQrcodePayload(scanData);
@@ -1704,7 +2322,13 @@
  }
  if (stepName === 'face') {
    // startFaceVerify();
    // 访客进入人脸步骤后自动开始判脸和比对
    startFaceVerify();
  }
  if (stepName === 'host') {
    // 接待人进入验证步骤后自动开始员工匹配和人脸比对
    startHostVerify();
  }
  if (stepName === 'print') {
@@ -1722,13 +2346,9 @@
    return;
  }
  if (currentStep.value > 1) {
    const prevStepName = currentStepName.value;
    currentStep.value--;
    // 如果是从人脸页面退回,需要主动关闭人脸采集
    if (prevStepName === 'face') {
      HardwareAPI.stopFaceVerify();
    }
    // 页面内回退时不主动 stop 插件,只依赖步骤状态让旧流程失效
  } else {
    uni.navigateBack({
      fail: () => {
@@ -2181,7 +2801,7 @@
.page-container {
  width: 1080px;
  height: 1920px;
  box-sizing: border-box;
  background: linear-gradient(180deg, #3B78FF 0%, #2860E8 50%, #163EBC 100%);
  padding: 30px 36px 40px 36px;
  display: flex;
@@ -2219,7 +2839,7 @@
  flex-direction: row;
  width: 1008px;
  height: 88px;
  cursor: pointer;
  /* cursor: pointer; */
}
.back-btn {
@@ -2258,7 +2878,7 @@
  border: 1px solid #DEE9FF;
  box-shadow: 0px 22px 52px rgba(13, 46, 134, 0.25);
  padding: 32px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
@@ -2323,7 +2943,7 @@
.name-hint-row {
  display: flex;
  align-items: center;
  gap: 8px;
  flex-direction: row;
  height: 26px;
}
@@ -2335,6 +2955,7 @@
  display: flex;
  align-items: center;
  justify-content: center;
  margin-right: 8px;
}
.name-icon-text {
@@ -2364,7 +2985,7 @@
  background: #F8FAFF;
  border: 1px solid #D9E1F2;
  padding: 26px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  margin-top: 40px;
@@ -2377,7 +2998,7 @@
  background: #FFFFFF;
  border: 1px solid #D9E1F2;
  padding: 0 28px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  justify-content: center;
@@ -2505,7 +3126,7 @@
  border: 1px solid #D9E6FF;
  box-shadow: 0px -8px 24px 0px rgba(13, 46, 134, 0.08);
  padding: 22px 16px 24px 16px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  z-index: 10;
@@ -2533,7 +3154,7 @@
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  margin: 0 18px 18px 0;
}
@@ -2635,7 +3256,6 @@
  border: 1px solid #243B72;
  box-shadow: 0px 14px 34px rgba(8, 17, 38, 0.23);
  padding: 30px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
@@ -2643,7 +3263,7 @@
}
.status-pill {
  width: 236px;
  /* width: 236px; */
  height: 52px;
  border-radius: 26px;
  background: #132852;
@@ -2653,6 +3273,7 @@
  align-items: center;
  justify-content: center;
  flex-direction: row;
  padding: 0 24px;
}
.status-dot {
@@ -2676,10 +3297,10 @@
  height: 652px;
  border-radius: 32px;
  background: linear-gradient(to bottom, #07111F , #050A15);
  /* background-color: #07111F; */
  border: 1px solid #223B71;
  box-shadow: 0px 10px 24px rgba(5, 10, 21, 0.2);
  padding: 24px 24px 28px 24px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  align-items: center;
@@ -2692,8 +3313,8 @@
  height: 528px;
  border-radius: 30px;
  background: #0A1427;
  border: 2px solid #3B66B8;
  box-shadow: 0px 10px 24px rgba(10, 20, 39, 0.27);
  /* border: 2px solid #3B66B8; */
  /* box-shadow: 0px 10px 24px rgba(10, 20, 39, 0.27); */
  display: flex;
  flex-direction: column;
  align-items: center;
@@ -2705,11 +3326,56 @@
.face-camera {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  left: 0;
  top: 0;
  width: 536px;
  height: 528px;
  z-index: 0;
  background: #0A1427;
  /* background: #fff; */
}
.viewfinder-corner-wrap {
  position: absolute;
  width: 30px;
  height: 30px;
  overflow: hidden;
  z-index: 1;
}
.viewfinder-corner-wrap-tl { left: 0; top: 0; }
.viewfinder-corner-wrap-tr { right: 0; top: 0; }
.viewfinder-corner-wrap-bl { left: 0; bottom: 0; }
.viewfinder-corner-wrap-br { right: 0; bottom: 0; }
.corner-donut {
  position: absolute;
  width: 122px;
  height: 122px;
  border-radius: 61px;
  border-width: 32px;
  border-style: solid;
  /* border-color: #0A1427; */
  border-color: #050A15;
  background-color: rgba(0, 0, 0, 0);
}
.corner-donut-tl { left: -31px; top: -31px; border-color: #07111F; }
.corner-donut-tr { left: -61px; top: -31px; border-color: #07111F;}
.corner-donut-bl { left: -31px; top: -61px; }
.corner-donut-br { left: -61px; top: -61px; }
.viewfinder-border-mask {
  position: absolute;
  left: 0;
  top: 0;
  width: 536px;
  height: 528px;
  border-radius: 30px;
  border: 2px solid #3B66B8;
  /* box-sizing: border-box; */
  background-color: rgba(0, 0, 0, 0);
  z-index: 2;
  pointer-events: none;
}
.head-ellipse {
@@ -2761,7 +3427,6 @@
  display: flex;
  justify-content: center;
  flex-direction: row;
  gap: 18px;
  margin-top: 26px;
}
@@ -2800,57 +3465,8 @@
  margin-top: 24px;
}
/* 完善信息特有样式 */
.form-container {
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 36px;
  margin-top: 20px;
  padding: 0 40px;
  box-sizing: border-box;
}
.form-item {
  display: flex;
  align-items: center;
}
.form-label {
  width: 220px;
  font-family: Inter, sans-serif;
  font-size: 26px;
  font-weight: 700;
  color: #1F2A44;
  flex-shrink: 0;
  text-align: right;
}
.required {
  color: #FF4D4F;
  margin-left: 4px;
}
.form-input {
  flex: 1;
  height: 88px;
  border-radius: 16px;
  border: 2px solid #DDE9FF;
  background: #FFFFFF;
  padding: 0 24px;
  display: flex;
  align-items: center;
  box-sizing: border-box;
}
.form-input.readonly {
  background: #F3F6FC;
  border-color: #E2EBFF;
}
.placeholder {
  color: #A0AFC0;
}
/* 打印凭条特有样式 */
@@ -2878,14 +3494,13 @@
}
.help-card {
  width: 360px;
  width: 388px;
  height: 176px;
  border-radius: 26px;
  background: linear-gradient(180deg, rgba(255, 255, 255, 0.11) 0%, rgba(234, 242, 255, 0.08) 100%);
  border: 2px solid rgba(255, 255, 255, 0.2);
  box-shadow: 0px 14px 30px rgba(10, 36, 107, 0.21);
  padding: 22px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  position: relative;
@@ -2900,7 +3515,6 @@
  border: 2px solid rgba(255, 255, 255, 0.2);
  box-shadow: 0px 14px 30px rgba(10, 36, 107, 0.21);
  padding: 20px 22px 22px 22px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  position: relative;
@@ -2951,12 +3565,12 @@
  font-size: 16px;
  font-weight: 500;
  color: #EAF1FF;
  white-space: pre-line;
  /* white-space: pre-line; */
  margin-top: 16px;
}
.main-btn {
  width: 586px;
  width: 556px;
  height: 56px;
  border-radius: 18px;
  background: linear-gradient(180deg, #FFFFFF 0%, #F4F8FF 100%);
@@ -2965,7 +3579,7 @@
  display: flex;
  align-items: center;
  justify-content: center;
  cursor: pointer;
  /* cursor: pointer; */
  margin-top: 12px;
}
@@ -2978,11 +3592,10 @@
/* ================= 完善登记信息样式 ================= */
.form-card {
  width: 1008px;
  min-height: 1200px;
  height: 1200px;
  border-radius: 32px;
  background: #FFFFFF;
  padding: 32px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  box-shadow: 0 22px 52px rgba(13, 46, 134, 0.25);
@@ -2991,7 +3604,7 @@
.form-row {
  width: 944px;
  min-height: 56px;
  height: 56px;
  display: flex;
  align-items: center;
  flex-direction: row;
@@ -3019,14 +3632,14 @@
  border: 1px solid #D9E1F2;
  background: #FFFFFF;
  padding: 0 16px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  flex-direction: row;
}
.pressable-box {
  cursor: pointer;
  /* cursor: pointer; */
}
.date-box {
@@ -3044,14 +3657,13 @@
.field-suffix-icon-img {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  /* flex-shrink: 0; */
  opacity: 0.6; /* 让图标稍微弱化一点,更贴近设计图的灰色线条感 */
}
.time-box {
  width: 200px;
  justify-content: space-between;
  gap: 10px;
}
.full-box {
@@ -3060,13 +3672,12 @@
.between-box {
  justify-content: space-between;
  gap: 10px;
}
.field-arrow-icon-img {
  width: 20px;
  height: 20px;
  flex-shrink: 0;
  /* flex-shrink: 0; */
}
.reverse-arrow {
@@ -3096,7 +3707,7 @@
}
.plate-value {
  letter-spacing: 1px;
  /* letter-spacing: 1px; */
}
.selected-area-box {
@@ -3104,10 +3715,9 @@
  border-radius: 16px;
  background: #F3F6FC;
  padding: 16px;
  box-sizing: border-box;
  display: flex;
  flex-direction: column;
  gap: 10px;
}
.tag-row {
@@ -3143,7 +3753,7 @@
  font-size: 14px;
  font-weight: 700;
  color: #A0ACC4;
  cursor: pointer;
  /* cursor: pointer; */
}
.save-btn {
@@ -3183,7 +3793,6 @@
  background: #FFFFFF;
  border-radius: 28px 28px 0 0;
  padding: 28px 28px 36px 28px;
  box-sizing: border-box;
  z-index: 21;
}
@@ -3251,14 +3860,13 @@
  background: #F7FAFF;
  border: 1px solid #EEF2FA;
  padding: 24px;
  box-sizing: border-box;
  margin-right: 24px;
}
.time-column:last-child { margin-right: 0; }
.time-column-title {
  display: block;
  /* display: block; */
  margin-bottom: 18px;
  font-family: Inter, sans-serif;
  font-size: 24px;
@@ -3299,7 +3907,7 @@
  background: #FFFFFF;
  border: 1px solid #EEF2FA;
  padding: 16px;
  box-sizing: border-box;
  display: flex;
  flex-direction: row;
  margin-bottom: 430px;
@@ -3321,7 +3929,7 @@
  height: 56px;
  border-radius: 16px;
  padding: 0 16px;
  box-sizing: border-box;
  display: flex;
  align-items: center;
  margin-bottom: 12px;
@@ -3356,7 +3964,7 @@
  display: flex;
  align-items: center;
  justify-content: center;
  box-sizing: border-box;
  flex-direction: row;
  margin-right: 12px;
}
@@ -3387,15 +3995,11 @@
.primary-area-text { font-weight: 600; color: #1F2A44; }
.secondary-area-row { gap: 12px; height: 64px; }
.secondary-area-row { height: 64px; }
.primary-area-text-active,
.secondary-area-text-active { font-weight: 700; color: #2F6BFF; }
.secondary-area-text { font-weight: 700; color: #2F6BFF; }
.secondary-area-row:not(.secondary-area-row-active) .secondary-area-text {
  color: #1F2A44;
  font-weight: 600;
}
.secondary-area-text { font-weight: 600; color: #1F2A44; }
.sheet-confirm-btn {
  width: 1024px;
pages/device-init/index.vue
@@ -5,7 +5,9 @@
import { HardwareAPI } from '../../utils/visitorDevice';
import {
    clearRequestServerConfig,
    getDeviceActivationStorage,
    getRequestServerConfig,
    hasActivatedDevice,
    setRequestServerConfig,
} from '../../utils/request';
@@ -32,14 +34,20 @@
onLoad(() => {
    const { ip, port } = getRequestServerConfig();
    const { deviceId, deviceFingerprint: storedDeviceFingerprint } = getDeviceActivationStorage();
    formRows[1].value = ip || '221.182.45.100';
    formRows[2].value = port || '9899';
    setRequestServerConfig({
        ip:  '221.182.45.100',
        port: '9899',
    });
    if (ip && port) {
    formRows[1].value = ip;
    // formRows[1].value = ip || '221.182.45.100';
    // formRows[2].value = port || '9899';
    formRows[2].value = port;
    // setRequestServerConfig({
    //     ip:  '221.182.45.100',
    //     port: '9899',
    // });
    // 只有设备已经完整激活过,才跳过激活页直接进首页
    if (hasActivatedDevice()) {
        formRows[0].value = deviceId || '';
        deviceFingerprint.value = storedDeviceFingerprint || '';
        uni.redirectTo({
            url: '/pages/home/index',
        });
@@ -131,15 +139,15 @@
        });
        // 激活接口只关心业务参数,地址由公共请求层统一处理
        await activateVisitMachine({
            machineId,
            fingerprint: deviceFingerprint.value,
        });
        // await HardwareAPI.offlineActivateDevice({
        //     code: deviceFingerprint.value,
        // await activateVisitMachine({
        //     machineId,
        //     fingerprint: deviceFingerprint.value,
        // });
        await HardwareAPI.offlineActivateDevice({
            code: deviceFingerprint.value,
        });
        uni.setStorageSync('device_id', machineId);
        uni.setStorageSync('device_fingerprint', deviceFingerprint.value);
        uni.hideLoading();
pages/home/index.vue
@@ -7,6 +7,7 @@
import { parseHikvisionQrcodePayload, validateAppointmentQrcodePayload } from '../../utils/hikvisionQrcode';
import { saveAppointmentQrPayload } from '../../utils/appointmentIdentityStore';
import { syncSystemConfig, syncCommitmentConfig } from '../../api/visitorConfig';
import { syncEmployeeVerifyList } from '../../api/employee';
import CommonNoticeDialog from '../../components/CommonNoticeDialog.vue';
import adminIcon from '../../static/home/avatar.png';
import operationImage from '../../static/home/operation.png';
@@ -238,6 +239,7 @@
  await Promise.all([
    syncSystemConfig(),
    syncCommitmentConfig(),
    syncEmployeeVerifyList(),
  ]);
  // 再次加载,如果接口配置有更新,则实时应用最新配置
utils/request.js
@@ -1,6 +1,8 @@
const DEFAULT_TIMEOUT = 30000;
const DEVICE_IP_KEY = 'device_ip';
const DEVICE_PORT_KEY = 'device_port';
const DEVICE_ID_KEY = 'device_id';
const DEVICE_FINGERPRINT_KEY = 'device_fingerprint';
const normalizeIp = (ip = '') => {
    return String(ip).trim().replace(/^https?:\/\//, '');
@@ -44,6 +46,18 @@
    return Boolean(ip && port);
};
export const getDeviceActivationStorage = () => {
    return {
        deviceId: String(uni.getStorageSync(DEVICE_ID_KEY) || '').trim(),
        deviceFingerprint: String(uni.getStorageSync(DEVICE_FINGERPRINT_KEY) || '').trim(),
    };
};
export const hasActivatedDevice = () => {
    const { deviceId, deviceFingerprint } = getDeviceActivationStorage();
    return Boolean(hasRequestServerConfig() && deviceId && deviceFingerprint);
};
const resolveRequestUrl = (url, baseURL) => {
    if (!url) {
        throw new Error('请求的 URL 不能为空');
utils/visitorDevice.js
@@ -219,7 +219,6 @@
   * 开始人证照片比对
   * @param {Object} options 比对参数,包含身份证照片和现场抓拍照片
   * @param {Function} callback 比对成功回调
   * @param {Function} failCallback 比对失败回调
   */
  startFaceVerify(options = {}, callback, failCallback) {
    const face = options?.face || '';
@@ -237,7 +236,7 @@
    const methodName = getPluginMethod(['startRecognition']);
    if (methodName) {
      HardwarePlugin[methodName]({ face, photo }, (res) => {
        showPluginDebugModal('人证比对回调', res);
        // showPluginDebugModal('人证比对回调', res);
        if (res && res.code === 0) {
          callback?.(res);
          return;
@@ -290,6 +289,77 @@
  },
  /**
   * 开始判断图片中是否有人脸
   * @param {Object} options
   * @param {Function} callback 判断到人脸成功回调
   * @param {Function} failCallback 判断失败回调
   */
  startJudgeFace(options = {}, callback, failCallback) {
    const face = options?.face || '';
    // #ifdef APP-PLUS
    const methodName = getPluginMethod(['startJudgeFace']);
    if (methodName) {
      HardwarePlugin[methodName]({ face }, (res) => {
        if (res && Number(res.code) === 0) {
          callback?.(res);
          return;
        }
        failCallback?.({
          ...res,
          message: res?.response || res?.msg || res?.message || '未检测到人脸',
        });
      });
      return;
    }
    failCallback?.({
      message: '当前插件未提供人脸检测能力',
    });
    // #endif
    // #ifndef APP-PLUS
    console.log('[Mock] 开始判断图片中是否有人脸...', options);
    setTimeout(() => {
      if (!face) {
        failCallback?.({
          message: '未获取到待检测图片',
        });
        return;
      }
      callback?.({
        code: 0,
        response: 'mock_has_face',
      });
    }, 300);
    // #endif
  },
  /**
   * 停止判断图片中是否有人脸
   */
  stopJudgeFace() {
    return new Promise((resolve) => {
      // #ifdef APP-PLUS
      const methodName = getPluginMethod(['stopJudgeFace']);
      if (methodName) {
        HardwarePlugin[methodName]({});
        resolve();
        return;
      }
      resolve();
      // #endif
      // #ifndef APP-PLUS
      console.log('[Mock] 停止判断图片中是否有人脸');
      resolve();
      // #endif
    });
  },
  /**
   * 停止人证照片比对
   */
  stopFaceVerify() {
@@ -330,7 +400,7 @@
      }
      HardwarePlugin[methodName](options, (res) => {
        showPluginDebugModal('离线激活设备回调', res);
        // showPluginDebugModal('离线激活设备回调', res);
        if (res && res.code === 0) {
          resolve(res);
          return;