| | |
| | | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | <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> |
| | |
| | | </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> |
| | |
| | | </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'; |
| | |
| | | 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 { |
| | |
| | | const hostScanLineRef = ref(null); |
| | | const facePreviewReady = ref(false); |
| | | const liveFacePhotoPath = ref(''); |
| | | const hostLiveFacePhotoPath = ref(''); |
| | | let hasMountedHardwareFlow = false; |
| | | const scanLineOffset = 400; |
| | | let scanLineAnimationTicket = 0; |
| | |
| | | || '请按提示完成人脸认证'; |
| | | }); |
| | | 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')); |
| | |
| | | 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; |
| | |
| | | } |
| | | }; |
| | | |
| | | 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)'); |
| | | } |
| | |
| | | faceCameraContext.startPreview({ |
| | | success: () => { |
| | | facePreviewReady.value = true; |
| | | faceVerifyStatusText.value = '请保持面部位于取景框内'; |
| | | faceVerifyStatusText.value = '请将脸部置于框内'; |
| | | resolve(); |
| | | }, |
| | | fail: (err) => { |
| | |
| | | }); |
| | | }; |
| | | |
| | | // 接收访客摄像头组件初始化成功事件,同步页面状态 |
| | | const handleFaceCameraInit = () => { |
| | | facePreviewReady.value = true; |
| | | faceVerifyStatusText.value = '请保持面部位于取景框内'; |
| | | }; |
| | | |
| | | // 摄像头初始化失败时统一弹窗,方便真机联调定位问题 |
| | | const handleFaceCameraError = (error) => { |
| | | facePreviewReady.value = false; |
| | | faceVerifyStatusText.value = '摄像头预览初始化失败'; |
| | |
| | | } |
| | | }; |
| | | |
| | | 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 || {}), |
| | |
| | | saveAppointmentIdCardPayload(nextIdentityPayload); |
| | | }; |
| | | |
| | | // 身份核验通过后统一推进到人脸步骤,并顺手重置手动录入状态 |
| | | const advanceToFaceStep = () => { |
| | | dismissNameInput(); |
| | | faceVerifyStatusText.value = '等待开始人证比对'; |
| | |
| | | }; |
| | | |
| | | const handleFaceVerifyNextStep = () => { |
| | | if (faceCaptureTimer) { |
| | | clearTimeout(faceCaptureTimer); |
| | | faceCaptureTimer = null; |
| | | } |
| | | nextStep(); |
| | | clearFaceCaptureTimer(); |
| | | startFaceVerify(); |
| | | }; |
| | | |
| | | // 访客人脸验证:先判脸,再正式抓拍,最后根据是否有身份证照片决定是否做人证比对 |
| | | const startFaceVerify = () => { |
| | | const currentIdentityPayload = appointmentIdCardPayload.value || {}; |
| | | const isManualIdentityInput = currentIdentityPayload.inputType === 'manual'; |
| | |
| | | |
| | | 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) => { |
| | |
| | | |
| | | onMounted(async () => { |
| | | await loadSystemFlowConfig(); |
| | | await loadEmployeeVerifyList(); |
| | | await loadVisitAreas(); |
| | | clearAppointmentIdCardPayload(); |
| | | |
| | |
| | | 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; |
| | | } |
| | |
| | | onUnmounted(() => { |
| | | dismissNameInput(); |
| | | stopScanLineAnimation(); |
| | | // 离开页面时,确保关闭所有的扫描和读卡 |
| | | // 所有插件 stop 动作统一只放在页面销毁时调用,不在流程中途调用 |
| | | HardwareAPI.stopScan(); |
| | | HardwareAPI.stopReadCard(); |
| | | HardwareAPI.stopJudgeFace(); |
| | | HardwareAPI.stopFaceVerify(); |
| | | clearFaceCaptureTimer(); |
| | | cleanupLiveFacePhoto(); |
| | | stopLivePreview(faceCameraContext); |
| | | stopLivePreview(hostCameraContext); |
| | | resetFacePreviewState(); |
| | | }); |
| | | |
| | | onLoad((options) => { |
| | |
| | | } |
| | | }); |
| | | |
| | | // 监听步骤变化,动态启停硬件 |
| | | 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); |
| | | |
| | |
| | | } |
| | | |
| | | if (stepName === 'face') { |
| | | // startFaceVerify(); |
| | | // 访客进入人脸步骤后自动开始判脸和比对 |
| | | startFaceVerify(); |
| | | } |
| | | |
| | | if (stepName === 'host') { |
| | | // 接待人进入验证步骤后自动开始员工匹配和人脸比对 |
| | | startHostVerify(); |
| | | } |
| | | |
| | | if (stepName === 'print') { |
| | |
| | | return; |
| | | } |
| | | if (currentStep.value > 1) { |
| | | const prevStepName = currentStepName.value; |
| | | currentStep.value--; |
| | | |
| | | // 如果是从人脸页面退回,需要主动关闭人脸采集 |
| | | if (prevStepName === 'face') { |
| | | HardwareAPI.stopFaceVerify(); |
| | | } |
| | | // 页面内回退时不主动 stop 插件,只依赖步骤状态让旧流程失效 |
| | | } else { |
| | | uni.navigateBack({ |
| | | fail: () => { |
| | |
| | | .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; |
| | |
| | | flex-direction: row; |
| | | width: 1008px; |
| | | height: 88px; |
| | | cursor: pointer; |
| | | /* cursor: pointer; */ |
| | | } |
| | | |
| | | .back-btn { |
| | |
| | | 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; |
| | |
| | | .name-hint-row { |
| | | display: flex; |
| | | align-items: center; |
| | | gap: 8px; |
| | | flex-direction: row; |
| | | height: 26px; |
| | | } |
| | | |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | margin-right: 8px; |
| | | } |
| | | |
| | | .name-icon-text { |
| | |
| | | background: #F8FAFF; |
| | | border: 1px solid #D9E1F2; |
| | | padding: 26px; |
| | | box-sizing: border-box; |
| | | |
| | | display: flex; |
| | | flex-direction: column; |
| | | margin-top: 40px; |
| | |
| | | background: #FFFFFF; |
| | | border: 1px solid #D9E1F2; |
| | | padding: 0 28px; |
| | | box-sizing: border-box; |
| | | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | |
| | | 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; |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-sizing: border-box; |
| | | |
| | | margin: 0 18px 18px 0; |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | } |
| | | |
| | | .status-pill { |
| | | width: 236px; |
| | | /* width: 236px; */ |
| | | height: 52px; |
| | | border-radius: 26px; |
| | | background: #132852; |
| | |
| | | align-items: center; |
| | | justify-content: center; |
| | | flex-direction: row; |
| | | padding: 0 24px; |
| | | } |
| | | |
| | | .status-dot { |
| | |
| | | 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; |
| | |
| | | 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; |
| | |
| | | |
| | | .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 { |
| | |
| | | display: flex; |
| | | justify-content: center; |
| | | flex-direction: row; |
| | | gap: 18px; |
| | | margin-top: 26px; |
| | | } |
| | | |
| | |
| | | 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; |
| | | |
| | | } |
| | | |
| | | /* 打印凭条特有样式 */ |
| | |
| | | } |
| | | |
| | | .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; |
| | |
| | | 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; |
| | |
| | | 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%); |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | cursor: pointer; |
| | | /* cursor: pointer; */ |
| | | margin-top: 12px; |
| | | } |
| | | |
| | |
| | | /* ================= 完善登记信息样式 ================= */ |
| | | .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); |
| | |
| | | |
| | | .form-row { |
| | | width: 944px; |
| | | min-height: 56px; |
| | | height: 56px; |
| | | display: flex; |
| | | align-items: center; |
| | | flex-direction: row; |
| | |
| | | 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 { |
| | |
| | | .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 { |
| | |
| | | |
| | | .between-box { |
| | | justify-content: space-between; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .field-arrow-icon-img { |
| | | width: 20px; |
| | | height: 20px; |
| | | flex-shrink: 0; |
| | | /* flex-shrink: 0; */ |
| | | } |
| | | |
| | | .reverse-arrow { |
| | |
| | | } |
| | | |
| | | .plate-value { |
| | | letter-spacing: 1px; |
| | | /* letter-spacing: 1px; */ |
| | | } |
| | | |
| | | .selected-area-box { |
| | |
| | | border-radius: 16px; |
| | | background: #F3F6FC; |
| | | padding: 16px; |
| | | box-sizing: border-box; |
| | | |
| | | display: flex; |
| | | flex-direction: column; |
| | | gap: 10px; |
| | | } |
| | | |
| | | .tag-row { |
| | |
| | | font-size: 14px; |
| | | font-weight: 700; |
| | | color: #A0ACC4; |
| | | cursor: pointer; |
| | | /* cursor: pointer; */ |
| | | } |
| | | |
| | | .save-btn { |
| | |
| | | background: #FFFFFF; |
| | | border-radius: 28px 28px 0 0; |
| | | padding: 28px 28px 36px 28px; |
| | | box-sizing: border-box; |
| | | z-index: 21; |
| | | } |
| | | |
| | |
| | | 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; |
| | |
| | | background: #FFFFFF; |
| | | border: 1px solid #EEF2FA; |
| | | padding: 16px; |
| | | box-sizing: border-box; |
| | | |
| | | display: flex; |
| | | flex-direction: row; |
| | | margin-bottom: 430px; |
| | |
| | | height: 56px; |
| | | border-radius: 16px; |
| | | padding: 0 16px; |
| | | box-sizing: border-box; |
| | | |
| | | display: flex; |
| | | align-items: center; |
| | | margin-bottom: 12px; |
| | |
| | | display: flex; |
| | | align-items: center; |
| | | justify-content: center; |
| | | box-sizing: border-box; |
| | | |
| | | flex-direction: row; |
| | | margin-right: 12px; |
| | | } |
| | |
| | | |
| | | .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; |