<template>
|
<view class="page-container" @touchstart="resetInactivityTimer" @click="resetInactivityTimer">
|
<!-- 左侧柔光氛围 -->
|
<view class="bg-glow"></view>
|
|
<!-- 扫码页头部 -->
|
<view class="header-area">
|
<view class="left-action" @click="goBack">
|
<view class="back-btn">
|
<image :src="backIconImage" class="back-icon-img" mode="aspectFit"></image>
|
</view>
|
<text class="page-title">{{ mode === 'online' ? '现场登记' : (mode === 'signout' ? '签离' : '预约登记') }}</text>
|
</view>
|
</view>
|
|
<!-- 步骤卡片组件 -->
|
<StepProgress :currentStep="currentStep" :mode="mode" :steps="stepProgressSteps" />
|
|
<!-- 步骤: 预约码验证 -->
|
<template v-if="currentStepName === 'qr'">
|
<view class="main-card">
|
<text class="main-title">请将您的预约二维码放置到扫码位置</text>
|
<text class="main-desc">保持二维码完整、清晰,距离扫码口约 10-20 厘米</text>
|
|
<view class="device-area">
|
<image :src="deviceScanImage" class="device-img" mode="aspectFit"></image>
|
</view>
|
|
<text class="helper-text">若识别失败,请调整手机亮度、避免反光,或联系工作人员协助</text>
|
</view>
|
|
<view class="bottom-area">
|
<view class="help-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row" style="margin-top: 16px;">
|
<view class="icon-base">
|
<image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">扫码小贴士</text>
|
</view>
|
<text class="help-content">请使用预约成功后收到的二维码,\n保持二维码完整显示后再靠近扫码区域</text>
|
</view>
|
|
<view class="action-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="returnIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">需要返回?</text>
|
</view>
|
<view class="main-btn" @click="goBack">
|
<text class="btn-text">返回上一步</text>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<!-- 步骤: 身份证验证 -->
|
<template v-else-if="currentStepName === 'idcard'">
|
<template v-if="!isManualInput">
|
<view class="main-card">
|
<text class="main-title">请将您的身份证放置读卡器位置</text>
|
<text class="main-desc">请将身份证贴近设备感应区,系统将自动读取身份信息</text>
|
|
<view class="name-hint-row" v-if="mode === 'appointment'">
|
<view class="name-icon-box">
|
<text class="name-icon-text">人</text>
|
</view>
|
<text class="name-text">姓名:{{ appointmentVisitorName }}</text>
|
</view>
|
|
<view class="device-area">
|
<image :src="deviceIdScanImage" class="device-img" mode="aspectFit"></image>
|
</view>
|
|
<text class="helper-text">若识别失败,请联系工作人员协助</text>
|
</view>
|
|
<view class="bottom-area">
|
<view class="help-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">读卡小贴士</text>
|
</view>
|
<text class="help-content">请使用二代身份证原件放至读卡区位置</text>
|
</view>
|
|
<view class="action-card" v-if="allowManualIdCard">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="keyboardIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">未携带身份证?</text>
|
</view>
|
<view class="main-btn" @click="openManualInput">
|
<text class="btn-text">手动录入信息</text>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<!-- 手动录入信息 UI -->
|
<template v-else>
|
<view class="main-card manual-card" @click="dismissNameInput">
|
<text class="main-title">手动录入身份证号</text>
|
<text class="error-hint" v-if="mode === 'signout' && showMissingEntryHint">未检测到您的进场信息,请检查身份证号码是否正确</text>
|
|
<view class="input-form-card" @click.stop>
|
<template v-if="mode === 'online'">
|
<view class="manual-input-box" style="height: 118px;" @touchstart.stop="handleNameInputBoxTouchStart" @click.stop>
|
<input
|
type="text"
|
class="input-text real-input"
|
v-model="manualName"
|
:focus="activeManualInputTarget === 'name'"
|
placeholder="请输入您的姓名"
|
placeholder-style="font-size: 24px; font-weight: 500; color: #A0ACC4; letter-spacing: normal;"
|
style="width: 768px; height: 118px; border: none; outline: none; background: transparent; text-align: center;"
|
@focus="handleNameInputFocus"
|
@blur="handleNameInputBlur"
|
/>
|
</view>
|
</template>
|
<view
|
class="manual-input-box"
|
style="height: 118px; position: relative;"
|
@touchstart.stop="handleIdInputBoxClick"
|
>
|
<input
|
type="text"
|
class="input-text real-input"
|
:value="idNumber"
|
:disabled="true"
|
placeholder="请输入您的身份证号码"
|
placeholder-style="font-size: 24px; font-weight: 500; color: #A0ACC4; letter-spacing: normal;"
|
style="width: 768px; height: 118px; border: none; outline: none; background: transparent; text-align: center;"
|
/>
|
<view style="position: absolute; left: 0; top: 0; right: 0; bottom: 0; background-color: rgba(0,0,0,0);"></view>
|
</view>
|
<view class="input-helper-row">
|
<text class="input-helper-text">请输入 18 位身份证号码,X 需大写</text>
|
</view>
|
</view>
|
|
<view class="form-action-row" v-if="idNumber.length > 0">
|
<view class="clear-btn" @click="clearInput">
|
<text class="clear-btn-text">清空</text>
|
</view>
|
<view class="confirm-btn" @click="confirmManualInput">
|
<text class="confirm-btn-text">确认</text>
|
</view>
|
</view>
|
<view class="form-action-row" v-else>
|
<view class="clear-btn-disabled">
|
<text class="clear-btn-text-disabled">清空</text>
|
</view>
|
<view class="confirm-btn-disabled">
|
<text class="confirm-btn-text-disabled">确认</text>
|
</view>
|
</view>
|
</view>
|
|
<!-- 自定义键盘 -->
|
<view class="custom-keyboard" v-if="showCustomKeyboard">
|
<view class="keyboard-grid">
|
<view class="keyboard-row">
|
<view class="key-btn" @click="inputKey('1')"><text class="key-text">1</text></view>
|
<view class="key-btn" @click="inputKey('2')"><text class="key-text">2</text></view>
|
<view class="key-btn" @click="inputKey('3')"><text class="key-text">3</text></view>
|
</view>
|
<view class="keyboard-row">
|
<view class="key-btn" @click="inputKey('4')"><text class="key-text">4</text></view>
|
<view class="key-btn" @click="inputKey('5')"><text class="key-text">5</text></view>
|
<view class="key-btn" @click="inputKey('6')"><text class="key-text">6</text></view>
|
</view>
|
<view class="keyboard-row">
|
<view class="key-btn" @click="inputKey('7')"><text class="key-text">7</text></view>
|
<view class="key-btn" @click="inputKey('8')"><text class="key-text">8</text></view>
|
<view class="key-btn" @click="inputKey('9')"><text class="key-text">9</text></view>
|
</view>
|
<view class="keyboard-row">
|
<view class="key-btn key-x" @click="inputKey('X')"><text class="key-text-x">X</text></view>
|
<view class="key-btn" @click="inputKey('0')"><text class="key-text">0</text></view>
|
<view class="key-btn key-delete" @click="deleteKey"><text class="key-text-delete">删除</text></view>
|
</view>
|
</view>
|
</view>
|
</template>
|
</template>
|
|
<!-- 步骤: 人脸验证 -->
|
<template v-else-if="currentStepName === 'face'">
|
<view class="main-card">
|
<view class="title-group">
|
<text class="main-title">请面向屏幕完成人脸验证</text>
|
<text class="sub-title">请摘下口罩和帽子,保持面部位于取景框内并正视摄像头</text>
|
<view class="name-pill">
|
<view class="name-icon">
|
<text class="name-icon-text">人</text>
|
</view>
|
<text class="name-text">姓名:{{ identityDisplayName }}</text>
|
</view>
|
</view>
|
|
<view class="face-capture-card">
|
<view class="status-pill">
|
<view class="status-dot"></view>
|
<text class="status-text">{{ faceVerifyStatusText }}</text>
|
</view>
|
|
<view class="viewfinder-area">
|
<view class="viewfinder-box">
|
<live-pusher
|
id="faceCamera"
|
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 class="head-ellipse"></view>
|
<view class="shoulder-ellipse"></view> -->
|
<view ref="faceScanLineRef" class="scan-line"></view>
|
</view>
|
<text class="viewfinder-hint">请将脸部置于框内,保持静止</text>
|
</view>
|
|
<view class="hints-row">
|
<view class="hint-item">
|
<view class="hint-dot" style="background: #6EA2FF;"></view>
|
<text class="hint-text">请正视屏幕</text>
|
</view>
|
<view class="hint-item">
|
<view class="hint-dot" style="background: #86C7FF;"></view>
|
<text class="hint-text">保持静止 2 秒</text>
|
</view>
|
<view class="hint-item">
|
<view class="hint-dot" style="background: #FFC36B;"></view>
|
<text class="hint-text">避免逆光遮挡</text>
|
</view>
|
</view>
|
</view>
|
|
<text class="bottom-subtext">系统将自动完成人脸采集与比对,请勿频繁移动或离开识别区域</text>
|
</view>
|
|
<view class="bottom-area">
|
<view class="help-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">识别提示</text>
|
</view>
|
<text class="help-content">{{ faceVerifyHelpText }}</text>
|
</view>
|
|
<view class="action-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="retryIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">识别失败?</text>
|
</view>
|
<view class="main-btn" @click="handleFaceVerifyNextStep">
|
<text class="btn-text">重新采集人脸</text>
|
<!-- <text class="btn-text">跳过人脸采集</text> -->
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<!-- 步骤: 接待人验证 -->
|
<template v-else-if="currentStepName === 'host'">
|
<view class="main-card">
|
<view class="title-group">
|
<text class="main-title">请接待人面向屏幕</text>
|
<text class="sub-title">请接待人站在设备前,正视屏幕完成身份确认</text>
|
<view class="name-pill" v-if="mode === 'appointment'">
|
<view class="name-icon">
|
<text class="name-icon-text">人</text>
|
</view>
|
<text class="name-text">姓名:{{ appointmentHostName }}</text>
|
</view>
|
</view>
|
|
<view class="face-capture-card">
|
<view class="status-pill">
|
<view class="status-dot"></view>
|
<text class="status-text">{{ hostVerifyStatusText }}</text>
|
</view>
|
|
<view class="viewfinder-area">
|
<view class="viewfinder-box">
|
<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>
|
|
<view class="hints-row">
|
<view class="hint-item">
|
<view class="hint-dot" style="background: #6EA2FF;"></view>
|
<text class="hint-text">请正视屏幕</text>
|
</view>
|
<view class="hint-item">
|
<view class="hint-dot" style="background: #86C7FF;"></view>
|
<text class="hint-text">保持静止 2 秒</text>
|
</view>
|
<view class="hint-item">
|
<view class="hint-dot" style="background: #FFC36B;"></view>
|
<text class="hint-text">避免逆光遮挡</text>
|
</view>
|
</view>
|
</view>
|
|
<text class="bottom-subtext">系统将自动完成人脸采集与比对,请勿频繁移动或离开识别区域</text>
|
</view>
|
|
<view class="bottom-area">
|
<view class="help-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">验证提示</text>
|
</view>
|
<text class="help-content">请由接待人本人操作;\n接待人可以直接使用门禁卡刷卡验证</text>
|
</view>
|
|
<view class="action-card">
|
<view class="highlight-bar" style="background: #6EA2FF;"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="retryIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">验证失败?</text>
|
</view>
|
<view class="main-btn" @click="retryFaceVerify">
|
<text class="btn-text">重新验证接待人</text>
|
<!-- <text class="btn-text">跳过人脸采集</text> -->
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<!-- 步骤: 完善登记信息 (仅现场登记有此步骤) -->
|
<template v-else-if="currentStepName === 'form'">
|
<view class="form-card">
|
<view class="form-row">
|
<text class="label">访问结束时间:</text>
|
<view class="datetime-group">
|
<view class="input-box date-box">
|
<image :src="calendarIconImage" class="field-prefix-icon-img" mode="aspectFit"></image>
|
<text class="input-value">{{ visitEndDateText }}</text>
|
</view>
|
<view class="input-box time-box pressable-box" @click="openTimePicker">
|
<text class="time-value">{{ selectedTime || '18:00' }}</text>
|
<image :src="clockIconImage" class="field-suffix-icon-img" mode="aspectFit"></image>
|
</view>
|
</view>
|
</view>
|
|
<view class="form-row">
|
<text class="label">车辆进园(选填):</text>
|
<view class="input-box full-box pressable-box" @click="openPlateKeyboard">
|
<text :class="plateNumber ? 'input-value plate-value' : 'placeholder'">{{ plateNumber || '请输入车牌号' }}</text>
|
</view>
|
</view>
|
|
<view class="form-row">
|
<text class="label">访问区域:</text>
|
<view class="input-box full-box between-box pressable-box" @click="openAreaPicker">
|
<text :class="selectedAreas.length ? 'input-value' : 'placeholder'">{{ selectedAreas.length ? `已选择 ${selectedAreas.length} 项` : '请选择' }}</text>
|
<image :src="returnIconImage" class="field-arrow-icon-img reverse-arrow" mode="aspectFit"></image>
|
</view>
|
</view>
|
|
<view class="selected-area-box" v-if="selectedAreas.length">
|
<view class="tag-row">
|
<view class="tag-item" v-for="areaId in selectedAreas" :key="areaId">
|
<text class="tag-text">{{ getAreaNameById(areaId) }}</text>
|
<text class="tag-x" @click.stop="removeArea(areaId)">×</text>
|
</view>
|
</view>
|
</view>
|
|
<view class="save-btn" @click="nextStep">
|
<text class="save-btn-text">保存</text>
|
</view>
|
</view>
|
</template>
|
|
<!-- 步骤: 打印凭条 -->
|
<template v-else-if="currentStepName === 'print'">
|
<view class="main-card">
|
<text class="main-title">请等待打印凭条</text>
|
|
<view class="print-demo-card">
|
<image :src="printTicketDemoImage" class="print-demo-img" mode="aspectFit"></image>
|
</view>
|
</view>
|
|
<view class="bottom-area">
|
<view class="help-card">
|
<view class="highlight-bar"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="hintIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">取条提示</text>
|
</view>
|
<text class="help-content">请在凭条完全打印后再取走;\n如长时间未出条,请联系现场工作人员协助</text>
|
</view>
|
|
<view class="action-card">
|
<view class="highlight-bar" style="background: #6EA2FF;"></view>
|
<view class="card-title-row">
|
<view class="icon-base">
|
<image :src="retryIconImage" class="icon-base-img" mode="aspectFit"></image>
|
</view>
|
<text class="card-title">未打印成功?</text>
|
</view>
|
<view class="main-btn" @click="startPrintTicket">
|
<text class="btn-text">重新打印凭条</text>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
</view>
|
<view class="mask-layer" v-if="showTimePicker || showAreaPicker" @click="closeAllPopups"></view>
|
<view class="bottom-sheet time-sheet" v-if="showTimePicker" @click.stop>
|
<view class="sheet-head">
|
<text class="sheet-title">选择访问结束时间</text>
|
<text class="sheet-close" @click="showTimePicker = false">关闭</text>
|
</view>
|
<view class="time-picker-body">
|
<view class="time-column">
|
<text class="time-column-title">时</text>
|
<scroll-view scroll-y class="time-scroll">
|
<view
|
v-for="hour in hours"
|
:key="hour"
|
class="time-item"
|
:class="{ 'time-item-active': tempHour === hour }"
|
@click="tempHour = hour"
|
>
|
<text class="time-item-text">{{ hour }}</text>
|
</view>
|
</scroll-view>
|
</view>
|
<view class="time-column">
|
<text class="time-column-title">分</text>
|
<scroll-view scroll-y class="time-scroll">
|
<view
|
v-for="minute in minutes"
|
:key="minute"
|
class="time-item"
|
:class="{ 'time-item-active': tempMinute === minute }"
|
@click="tempMinute = minute"
|
>
|
<text class="time-item-text">{{ minute }}</text>
|
</view>
|
</scroll-view>
|
</view>
|
</view>
|
<view class="sheet-confirm-btn" @click="confirmTimePicker">
|
<text class="sheet-confirm-text">确定</text>
|
</view>
|
</view>
|
|
<view class="bottom-sheet area-sheet" v-if="showAreaPicker" @click.stop>
|
<view class="sheet-head area-sheet-head">
|
<view class="sheet-head-placeholder"></view>
|
<text class="sheet-title area-title">选择访问区域</text>
|
<view class="area-close-btn" @click="showAreaPicker = false">
|
x
|
</view>
|
</view>
|
<view class="area-list-card">
|
<view class="area-column area-primary-column">
|
<view
|
v-for="area in primaryAreaOptions"
|
:key="area"
|
class="primary-area-row"
|
:class="{ 'primary-area-row-active': tempPrimaryArea === area }"
|
@click="selectPrimaryArea(area)"
|
>
|
<view class="primary-area-left">
|
<view
|
class="primary-check"
|
:class="{
|
'primary-check-active': getPrimarySelectionState(area) !== 'none',
|
'primary-check-partial': getPrimarySelectionState(area) === 'partial'
|
}"
|
@click.stop="togglePrimaryCheckbox(area)"
|
>
|
<image v-if="getPrimarySelectionState(area) === 'full'" :src="successIconImage" class="check-icon-img" mode="aspectFit"></image>
|
<view v-else-if="getPrimarySelectionState(area) === 'partial'" class="partial-line"></view>
|
</view>
|
<text class="primary-area-text" :class="{ 'primary-area-text-active': tempPrimaryArea === area }">{{ area }}</text>
|
</view>
|
<image :src="returnIconImage" class="field-arrow-icon-img reverse-arrow" mode="aspectFit"></image>
|
</view>
|
</view>
|
<view class="area-column area-secondary-column">
|
<view
|
v-for="area in currentSecondaryOptions"
|
:key="area.id"
|
class="secondary-area-row"
|
:class="{ 'secondary-area-row-active': tempSelectedAreas.includes(area.id) }"
|
@click="toggleTempArea(area.id)"
|
>
|
<view class="secondary-check" :class="{ 'secondary-check-active': tempSelectedAreas.includes(area.id) }">
|
<image v-if="tempSelectedAreas.includes(area.id)" :src="successIconImage" class="check-icon-img" mode="aspectFit"></image>
|
</view>
|
<text class="secondary-area-text" :class="{ 'secondary-area-text-active': tempSelectedAreas.includes(area.id) }">{{ area.name }}</text>
|
</view>
|
</view>
|
</view>
|
<view class="sheet-confirm-btn" @click="confirmAreaPicker">
|
<text class="sheet-confirm-text">确定</text>
|
</view>
|
</view>
|
|
<view class="bottom-sheet plate-sheet" v-if="showPlateKeyboard" @click.stop>
|
<view class="sheet-head compact-head">
|
<text class="sheet-title">录入车牌号</text>
|
<text class="sheet-close" @click="showPlateKeyboard = false">关闭</text>
|
</view>
|
<view class="plate-step-tip">
|
<text class="plate-step-tip-text">{{ plateInputStep === 'province' ? '第一步:请选择省份简称' : '第二步:请输入数字字母' }}</text>
|
</view>
|
<view class="plate-section" v-if="plateInputStep === 'province'">
|
<view class="province-grid">
|
<view
|
v-for="(province, index) in provinceKeys"
|
:key="province"
|
class="keyboard-key province-key"
|
:class="{ 'keyboard-key-active': plateNumber.startsWith(province) }"
|
:style="{ marginRight: (index + 1) % 9 === 0 ? '0' : '11px' }"
|
@click="inputProvince(province)"
|
>
|
<text class="keyboard-key-text">{{ province }}</text>
|
</view>
|
</view>
|
</view>
|
<view class="plate-section" v-else>
|
<view class="plate-grid-row" v-for="(row, rowIndex) in plateKeys" :key="rowIndex">
|
<view
|
v-for="(key, keyIndex) in row"
|
:key="key"
|
class="keyboard-key"
|
:class="{
|
'keyboard-key-disabled': !canInputPlateKey(key),
|
'keyboard-key-special': ['学', '警', '港', '澳'].includes(key)
|
}"
|
:style="{ marginRight: (keyIndex === row.length - 1 && rowIndex !== 3) ? '0' : '12px' }"
|
@click="inputPlateKey(key)"
|
>
|
<text class="keyboard-key-text">{{ key }}</text>
|
</view>
|
<view v-if="rowIndex === 3" class="keyboard-key delete-key" style="margin-right: 0;" @click="deletePlateKey">
|
<image :src="keyboardIconImage" class="delete-key-icon-img" mode="aspectFit"></image>
|
</view>
|
</view>
|
</view>
|
<view class="plate-action-row">
|
<view class="plate-secondary-btn" @click="clearPlateNumber">
|
<text class="plate-secondary-text">清空</text>
|
</view>
|
<view class="sheet-confirm-btn plate-confirm-btn" @click="showPlateKeyboard = false">
|
<text class="sheet-confirm-text">确定</text>
|
</view>
|
</view>
|
</view>
|
<view v-if="noticeDialog.visible" class="notice-dialog-wrapper">
|
<view class="notice-dialog-mask"></view>
|
<view class="notice-dialog-card">
|
<view class="notice-dialog-copy">
|
<text class="notice-dialog-title">{{ noticeDialog.title }}</text>
|
<text v-if="noticeDialog.description" class="notice-dialog-desc">{{ noticeDialog.description }}</text>
|
</view>
|
<view class="notice-dialog-button-row">
|
<view class="notice-dialog-close-btn" @click="closeNoticeDialog">
|
<text class="notice-dialog-close-text">{{ noticeDialog.buttonText }}({{ noticeDialog.countdown }})</text>
|
</view>
|
</view>
|
</view>
|
</view>
|
</template>
|
|
<script setup>
|
import { ref, computed, onMounted, onUnmounted, watch, nextTick, getCurrentInstance } from 'vue';
|
import { onLoad } from '@dcloudio/uni-app';
|
import StepProgress from '../../components/StepProgress.vue';
|
import { HardwareAPI } from '../../utils/visitorDevice';
|
import { saveImageToLocal, deleteLocalImage } from '../../utils/fs';
|
import { getVisitAreaTree } from '../../api/visitArea';
|
import { insertOfflineRecord, findEntryRecordByIdCard, markEntryRecordSignedOut, getOfflineConfig, saveOfflineConfig } from '../../utils/db';
|
import { buildBaseUrl, getRequestServerConfig } from '../../utils/request';
|
import { getLocalEmployeeVerifyList } from '../../api/employee';
|
import { getLocalSystemConfig } from '../../api/visitorConfig';
|
import { startSyncQueue } from '../../utils/syncEngine';
|
import {
|
parseHikvisionQrcodePayload,
|
validateAppointmentQrcodePayload,
|
} from '../../utils/hikvisionQrcode';
|
import {
|
clearAppointmentIdCardPayload,
|
clearAppointmentQrPayload,
|
getAppointmentQrPayload,
|
saveAppointmentIdCardPayload,
|
saveAppointmentQrPayload,
|
} from '../../utils/appointmentIdentityStore';
|
import backIconImage from '../../static/appointment-scan/back.png';
|
import hintIconImage from '../../static/appointment-scan/hint-icon.png';
|
import returnIconImage from '../../static/appointment-scan/return-icon.png';
|
import retryIconImage from '../../static/appointment-scan/retry-icon.png';
|
import keyboardIconImage from '../../static/appointment-scan/keyboard-icon.png';
|
import successIconImage from '../../static/appointment-scan/success.png';
|
import calendarIconImage from '../../static/appointment-scan/calendar-icon.png';
|
import clockIconImage from '../../static/appointment-scan/clock-icon.png';
|
import printTicketDemoImage from '../../static/appointment-scan/print-ticket-demo.png';
|
import deviceScanImage from '../../static/appointment-scan/device-scan.png';
|
import deviceIdScanImage from '../../static/appointment-scan/device-id-scan.png';
|
|
const appointmentQrcodePayload = ref(null);
|
const appointmentIdCardPayload = ref(null);
|
const faceScanLineRef = ref(null);
|
const hostScanLineRef = ref(null);
|
const facePreviewReady = ref(false);
|
const liveFacePhotoPath = ref('');
|
const hostLiveFacePhotoPath = ref('');
|
let hasMountedHardwareFlow = false;
|
const scanLineOffset = 400;
|
let scanLineAnimationTicket = 0;
|
|
const getNvueAnimationModule = () => {
|
if (typeof weex === 'undefined' || !weex?.requireModule) {
|
return null;
|
}
|
|
return weex.requireModule('animation');
|
};
|
|
const getScanLineRefByStep = (stepName) => {
|
if (stepName === 'face') {
|
return faceScanLineRef.value;
|
}
|
if (stepName === 'host') {
|
return hostScanLineRef.value;
|
}
|
return null;
|
};
|
|
const resetScanLinePosition = (stepName) => {
|
const animationModule = getNvueAnimationModule();
|
const targetRef = getScanLineRefByStep(stepName);
|
if (!animationModule || !targetRef) {
|
return;
|
}
|
|
animationModule.transition(targetRef, {
|
styles: {
|
transform: 'translateY(0px)',
|
},
|
duration: 0,
|
delay: 0,
|
timingFunction: 'linear',
|
});
|
};
|
|
const runScanLineAnimation = (stepName, ticket, direction) => {
|
const animationModule = getNvueAnimationModule();
|
const targetRef = getScanLineRefByStep(stepName);
|
if (!animationModule || !targetRef || ticket !== scanLineAnimationTicket) {
|
return;
|
}
|
|
const targetOffset = direction > 0 ? scanLineOffset : 0;
|
animationModule.transition(targetRef, {
|
styles: {
|
transform: `translateY(${targetOffset}px)`,
|
},
|
duration: 1800,
|
delay: 0,
|
timingFunction: 'ease-in-out',
|
}, () => {
|
if (ticket !== scanLineAnimationTicket) {
|
return;
|
}
|
|
runScanLineAnimation(stepName, ticket, direction * -1);
|
});
|
};
|
|
const stopScanLineAnimation = () => {
|
scanLineAnimationTicket += 1;
|
resetScanLinePosition('face');
|
resetScanLinePosition('host');
|
};
|
|
const startScanLineAnimation = async (stepName) => {
|
scanLineAnimationTicket += 1;
|
const currentTicket = scanLineAnimationTicket;
|
resetScanLinePosition(stepName);
|
await nextTick();
|
|
const targetRef = getScanLineRefByStep(stepName);
|
if (!targetRef || currentTicket !== scanLineAnimationTicket) {
|
return;
|
}
|
|
runScanLineAnimation(stepName, currentTicket, 1);
|
};
|
|
const syncScanLineAnimationByStep = async (stepName) => {
|
if (stepName === 'face' || stepName === 'host') {
|
await startScanLineAnimation(stepName);
|
return;
|
}
|
|
stopScanLineAnimation();
|
};
|
const noticeDialog = ref({
|
visible: false,
|
title: '',
|
description: '',
|
buttonText: '关闭',
|
duration: 5,
|
countdown: 5,
|
});
|
let noticeDialogTimer = null;
|
const INACTIVITY_AUTO_BACK_MS = 60 * 1000;
|
let inactivityAutoBackTimer = null;
|
|
const clearInactivityTimer = () => {
|
if (inactivityAutoBackTimer) {
|
clearTimeout(inactivityAutoBackTimer);
|
inactivityAutoBackTimer = null;
|
}
|
};
|
|
const handleInactivityAutoBack = () => {
|
clearInactivityTimer();
|
uni.reLaunch({
|
url: '/pages/home/index',
|
});
|
};
|
|
const resetInactivityTimer = () => {
|
clearInactivityTimer();
|
inactivityAutoBackTimer = setTimeout(() => {
|
handleInactivityAutoBack();
|
}, INACTIVITY_AUTO_BACK_MS);
|
};
|
|
const stopNoticeDialogCountdown = () => {
|
if (noticeDialogTimer) {
|
clearInterval(noticeDialogTimer);
|
noticeDialogTimer = null;
|
}
|
};
|
|
const startNoticeDialogCountdown = () => {
|
stopNoticeDialogCountdown();
|
noticeDialog.value.countdown = noticeDialog.value.duration;
|
|
noticeDialogTimer = setInterval(() => {
|
if (!noticeDialog.value.visible) {
|
stopNoticeDialogCountdown();
|
return;
|
}
|
|
noticeDialog.value.countdown -= 1;
|
if (noticeDialog.value.countdown <= 0) {
|
closeNoticeDialog();
|
}
|
}, 1000);
|
};
|
|
const appointmentVisitorName = computed(() => {
|
if (mode.value === 'online') {
|
return appointmentIdCardPayload.value?.name
|
|| appointmentIdCardPayload.value?.visitorName
|
|| '请读取身份证';
|
}
|
|
return appointmentQrcodePayload.value?.visitorName || '请使用预约访客本人的身份证';
|
});
|
const appointmentHostName = computed(() => {
|
if (mode.value === 'online') {
|
return '';
|
}
|
|
return appointmentQrcodePayload.value?.beVisitorName || '请等待接待人完成验证';
|
});
|
const formatStandardDateTime = (dateValue) => {
|
const date = dateValue instanceof Date ? dateValue : new Date(dateValue);
|
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
return '';
|
}
|
|
const year = date.getFullYear();
|
const month = String(date.getMonth() + 1).padStart(2, '0');
|
const day = String(date.getDate()).padStart(2, '0');
|
const hour = String(date.getHours()).padStart(2, '0');
|
const minute = String(date.getMinutes()).padStart(2, '0');
|
const second = String(date.getSeconds()).padStart(2, '0');
|
return `${year}-${month}-${day} ${hour}:${minute}:${second}`;
|
};
|
const visitEndDateText = computed(() => {
|
const today = new Date();
|
const year = today.getFullYear();
|
const month = String(today.getMonth() + 1).padStart(2, '0');
|
const day = String(today.getDate()).padStart(2, '0');
|
return `${year}-${month}-${day}`;
|
});
|
const identityDisplayName = computed(() => {
|
if (mode.value === 'online') {
|
return appointmentIdCardPayload.value?.name
|
|| appointmentIdCardPayload.value?.visitorName
|
|| '请按提示完成人脸认证';
|
}
|
|
return appointmentQrcodePayload.value?.visitorName
|
|| appointmentIdCardPayload.value?.name
|
|| appointmentIdCardPayload.value?.visitorName
|
|| '请按提示完成人脸认证';
|
});
|
const faceVerifyStatusText = ref('等待开始人证比对');
|
const hostVerifyStatusText = ref('等待接待人开始验证');
|
let faceCameraContext = 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 '';
|
}
|
|
// 兼容旧缓存里仍是 base64/dataURL 的员工脸照,避免被误判成“没有照片”
|
if (/^data:image\/[a-zA-Z0-9+.-]+;base64,/.test(normalizedPhotoAddress)) {
|
return normalizedPhotoAddress;
|
}
|
|
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 getHostFaceEmployees = () => {
|
if (!Array.isArray(employeeVerifyList.value) || employeeVerifyList.value.length === 0) {
|
return [];
|
}
|
|
return employeeVerifyList.value.filter((employee) => Boolean(resolveEmployeeFacePhotoAddress(employee)));
|
};
|
|
const findEmployeeByCardNo = (cardNo = '') => {
|
const normalizedCardNo = normalizeCardNoValue(cardNo);
|
if (!normalizedCardNo || !Array.isArray(employeeVerifyList.value) || employeeVerifyList.value.length === 0) {
|
return null;
|
}
|
|
return employeeVerifyList.value.find((employee) => {
|
const employeeCardNo = normalizeCardNoValue(getMatchedHostEmployeeCardNo(employee));
|
return Boolean(employeeCardNo) && employeeCardNo === normalizedCardNo;
|
}) || 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 readCardNo = normalizeCardNoValue(getReaderCardNo(cardData));
|
|
if (!readCardNo) {
|
return {
|
passed: false,
|
reason: 'card_no_missing',
|
};
|
}
|
|
if (mode.value === 'online') {
|
const onsiteMatchedEmployee = findEmployeeByCardNo(readCardNo);
|
hostMatchedEmployee.value = onsiteMatchedEmployee;
|
|
if (!onsiteMatchedEmployee) {
|
return {
|
passed: false,
|
reason: 'employee_not_found',
|
readCardNo,
|
};
|
}
|
|
return {
|
passed: true,
|
employee: onsiteMatchedEmployee,
|
readCardNo,
|
};
|
}
|
|
const matchedEmployee = findMatchedHostEmployee();
|
hostMatchedEmployee.value = matchedEmployee;
|
|
if (!matchedEmployee) {
|
return {
|
passed: false,
|
reason: 'employee_not_found',
|
};
|
}
|
|
const employeeCardNo = normalizeCardNoValue(getMatchedHostEmployeeCardNo(matchedEmployee));
|
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 compareHostFaceWithEmployee = async (livePhotoAddress, employee = {}) => {
|
const employeeFacePhotoAddress = resolveEmployeeFacePhotoAddress(employee);
|
if (!employeeFacePhotoAddress) {
|
return {
|
passed: false,
|
employee,
|
};
|
}
|
|
try {
|
const faceResult = await new Promise((resolve, reject) => {
|
HardwareAPI.startFaceVerify(
|
{
|
face: livePhotoAddress,
|
photo: employeeFacePhotoAddress,
|
employeeFacePhotoAddress,
|
photoAddress: employeeFacePhotoAddress,
|
capturePhotoAddress: livePhotoAddress,
|
},
|
(result) => resolve(result),
|
(error) => reject(error),
|
);
|
});
|
|
return {
|
passed: isFaceVerifyPassed(faceResult),
|
employee,
|
faceResult,
|
};
|
} catch (error) {
|
return {
|
passed: false,
|
employee,
|
error,
|
};
|
}
|
};
|
|
const compareHostFaceWithAllEmployees = async (livePhotoAddress) => {
|
const candidateEmployees = getHostFaceEmployees();
|
for (const employee of candidateEmployees) {
|
const compareResult = await compareHostFaceWithEmployee(livePhotoAddress, employee);
|
if (compareResult.passed) {
|
return compareResult;
|
}
|
}
|
|
return {
|
passed: false,
|
employee: null,
|
};
|
};
|
|
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'));
|
const provinceKeys = ['京', '津', '晋', '冀', '蒙', '辽', '吉', '黑', '沪', '苏', '浙', '皖', '闽', '赣', '鲁', '豫', '鄂', '湘', '粤', '桂', '琼', '渝', '川', '贵', '云', '藏', '陕', '甘', '青', '宁', '新', '无'];
|
const plateKeys = [
|
['1', '2', '3', '4', '5', '6', '7', '8', '9', '0'],
|
['Q', 'W', 'E', 'R', 'T', 'Y', 'U', 'I', 'O', 'P', '军'],
|
['A', 'S', 'D', 'F', 'G', 'H', 'J', 'K', 'L', '警'],
|
['Z', 'X', 'C', 'V', 'B', 'N', 'M', '港', '澳']
|
];
|
|
// 访问区域树数据
|
const visitAreaTree = ref([]);
|
|
const primaryAreaOptions = computed(() => {
|
return visitAreaTree.value.map(item => item.name || item.areaName || item.label || item.areaId || item.id);
|
});
|
|
const secondaryAreaMap = computed(() => {
|
const map = {};
|
visitAreaTree.value.forEach(item => {
|
const key = item.name || item.areaName || item.label || item.areaId || item.id;
|
|
// 如果没有子节点,则将自身作为可选项,确保能被选中
|
const children = (item.children && item.children.length > 0) ? item.children : [item];
|
|
// 提取子节点的 name 和 id
|
map[key] = children.map(child => ({
|
id: child.areaId || child.id,
|
name: child.name || child.areaName || child.label || child.areaId || child.id
|
}));
|
});
|
return map;
|
});
|
|
const currentStep = ref(1);
|
const mode = ref('appointment');
|
const systemFlowConfig = ref({});
|
|
const allowManualIdCard = computed(() => {
|
return systemFlowConfig.value.manualIdCardEnabled !== false;
|
});
|
|
const registerReceiverConfirmEnabled = computed(() => {
|
return systemFlowConfig.value.registerReceiverConfirmEnabled !== false;
|
});
|
|
const signReceiverConfirmEnabled = computed(() => {
|
return systemFlowConfig.value.signReceiverConfirmEnabled !== false;
|
});
|
|
const shouldShowHostStep = computed(() => {
|
if (mode.value === 'signout') {
|
return signReceiverConfirmEnabled.value;
|
}
|
return registerReceiverConfirmEnabled.value;
|
});
|
|
const receiverVerifyMethodValue = computed(() => {
|
return shouldShowHostStep.value ? '2' : '';
|
});
|
|
const stepProgressSteps = computed(() => {
|
if (mode.value === 'appointment') {
|
const steps = [
|
{ text: '预约码验证', icon: 'qr' },
|
{ text: '身份证验证', icon: 'idcard' },
|
{ text: '人脸验证', icon: 'face' },
|
];
|
if (shouldShowHostStep.value) {
|
steps.push({ text: '接待人验证', icon: 'user' });
|
}
|
steps.push({ text: '打印凭条', icon: 'print' });
|
return steps;
|
}
|
|
if (mode.value === 'online') {
|
const steps = [
|
{ text: '身份证验证', icon: 'idcard' },
|
{ text: '人脸验证', icon: 'face' },
|
];
|
if (shouldShowHostStep.value) {
|
steps.push({ text: '接待人验证', icon: 'user' });
|
}
|
// steps.push({ text: '完善登记信息', icon: 'form' });
|
steps.push({ text: '完善信息', icon: 'form' });
|
steps.push({ text: '打印凭条', icon: 'print' });
|
return steps;
|
}
|
|
const steps = [
|
{ text: '身份证验证', icon: 'idcard' },
|
{ text: '人脸验证', icon: 'face' },
|
];
|
if (shouldShowHostStep.value) {
|
steps.push({ text: '接待人验证', icon: 'user' });
|
}
|
return steps;
|
});
|
|
const flowStepNames = computed(() => {
|
if (mode.value === 'appointment') {
|
const steps = ['qr', 'idcard', 'face'];
|
if (shouldShowHostStep.value) {
|
steps.push('host');
|
}
|
steps.push('print');
|
return steps;
|
}
|
|
if (mode.value === 'online') {
|
const steps = ['idcard', 'face'];
|
if (shouldShowHostStep.value) {
|
steps.push('host');
|
}
|
steps.push('form');
|
steps.push('print');
|
return steps;
|
}
|
|
const steps = ['idcard', 'face'];
|
if (shouldShowHostStep.value) {
|
steps.push('host');
|
}
|
return steps;
|
});
|
|
const faceVerifyHelpText = computed(() => {
|
if (mode.value === 'signout') {
|
return shouldShowHostStep.value
|
? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证'
|
: '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动完成签离流程';
|
}
|
|
if (mode.value === 'online') {
|
return shouldShowHostStep.value
|
? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证'
|
: '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入完善登记信息';
|
}
|
|
return shouldShowHostStep.value
|
? '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入下一步接待人验证'
|
: '请确保现场光线适中,面部无遮挡;\n识别成功后系统将自动进入打印凭条';
|
});
|
|
const configuredPrintFieldOptions = computed(() => {
|
if (Array.isArray(systemFlowConfig.value?.printFieldOptions) && systemFlowConfig.value.printFieldOptions.length > 0) {
|
return systemFlowConfig.value.printFieldOptions;
|
}
|
|
return [
|
{ fieldCode: 'visitorName', fieldName: '访客姓名' },
|
{ fieldCode: 'idCard', fieldName: '身份证号' },
|
{ fieldCode: 'appointmentTimeRange', fieldName: '预约时间范围' },
|
{ fieldCode: 'interviewee', fieldName: '被访者' },
|
{ fieldCode: 'visitorFaceImage', fieldName: '访客人脸图片' },
|
{ fieldCode: 'registerNo', fieldName: '登记编号' },
|
{ fieldCode: 'plateNo', fieldName: '车牌号' },
|
{ fieldCode: 'visitReason', fieldName: '来访事由' },
|
{ fieldCode: 'visitArea', fieldName: '访问区域' },
|
];
|
});
|
|
// 手动录入状态
|
const isManualInput = ref(false);
|
const idNumber = ref('');
|
const manualName = ref('');
|
const showMissingEntryHint = ref(false);
|
const isNameInputFocused = ref(false); // 记录姓名输入框是否处于聚焦状态
|
const activeManualInputTarget = ref('id');
|
|
const loadSystemFlowConfig = async () => {
|
try {
|
const config = await getLocalSystemConfig();
|
systemFlowConfig.value = config || {};
|
if (!allowManualIdCard.value) {
|
isManualInput.value = false;
|
}
|
} catch (error) {
|
console.error('读取登记流程系统配置失败:', error);
|
systemFlowConfig.value = {};
|
}
|
};
|
|
const showCustomKeyboard = computed(() => {
|
if (mode.value === 'online') {
|
// 现场登记:只在身份证输入态显示自定义键盘
|
return activeManualInputTarget.value === 'id';
|
}
|
// 预约登记/签离等其他模式:长显
|
return true;
|
});
|
|
// 完善登记信息表单状态
|
const showTimePicker = ref(false);
|
const showPlateKeyboard = ref(false);
|
const showAreaPicker = ref(false);
|
const plateInputStep = ref('province');
|
const selectedTime = ref('18:00');
|
const tempHour = ref('18');
|
const tempMinute = ref('00');
|
const plateNumber = ref('');
|
const selectedAreas = ref([]); // 存储选中的区域 ID
|
const tempPrimaryArea = ref('');
|
const tempSelectedAreas = ref([]); // 存储临时选中的区域 ID
|
const currentSecondaryOptions = computed(() => secondaryAreaMap.value[tempPrimaryArea.value] || []);
|
const normalizedPlateLength = computed(() => plateNumber.value.length);
|
|
// 获取选中区域的名称用于展示
|
const getAreaNameById = (id) => {
|
for (const key in secondaryAreaMap.value) {
|
const found = secondaryAreaMap.value[key].find(child => String(child.id) === String(id));
|
if (found) return found.name;
|
}
|
return id;
|
};
|
|
// 获取区域完整数据,用于后续提交和打印
|
const getSelectedAreaNames = () => {
|
return selectedAreas.value.map(id => getAreaNameById(id)).join(',');
|
};
|
|
const inputKey = (key) => {
|
resetInactivityTimer();
|
if (idNumber.value.length < 18) {
|
idNumber.value += key;
|
}
|
};
|
|
const deleteKey = () => {
|
resetInactivityTimer();
|
if (idNumber.value.length > 0) {
|
idNumber.value = idNumber.value.slice(0, -1);
|
}
|
};
|
|
const openManualInput = () => {
|
resetInactivityTimer();
|
isManualInput.value = true;
|
activeManualInputTarget.value = 'id';
|
isNameInputFocused.value = false;
|
};
|
|
const clearInput = () => {
|
resetInactivityTimer();
|
idNumber.value = '';
|
manualName.value = '';
|
showMissingEntryHint.value = false;
|
activeManualInputTarget.value = 'id';
|
};
|
|
const dismissNameInput = () => {
|
resetInactivityTimer();
|
activeManualInputTarget.value = 'id';
|
isNameInputFocused.value = false;
|
uni.hideKeyboard();
|
};
|
|
const handleNameInputBoxTouchStart = () => {
|
resetInactivityTimer();
|
// 提前切回姓名输入态,避免原生 focus 事件慢半拍导致自定义键盘残留
|
activeManualInputTarget.value = 'name';
|
isNameInputFocused.value = true;
|
};
|
|
const handleNameInputFocus = () => {
|
resetInactivityTimer();
|
activeManualInputTarget.value = 'name';
|
isNameInputFocused.value = true;
|
};
|
|
const handleNameInputBlur = () => {
|
isNameInputFocused.value = false;
|
};
|
|
const handleIdInputBoxClick = () => {
|
resetInactivityTimer();
|
dismissNameInput();
|
};
|
|
// 完善登记信息相关方法
|
const closeAllPopups = () => {
|
showTimePicker.value = false;
|
showPlateKeyboard.value = false;
|
showAreaPicker.value = false;
|
};
|
|
const openTimePicker = () => {
|
tempHour.value = selectedTime.value.split(':')[0];
|
tempMinute.value = selectedTime.value.split(':')[1];
|
showTimePicker.value = true;
|
showPlateKeyboard.value = false;
|
showAreaPicker.value = false;
|
};
|
|
const confirmTimePicker = () => {
|
selectedTime.value = `${tempHour.value}:${tempMinute.value}`;
|
showTimePicker.value = false;
|
};
|
|
const openPlateKeyboard = () => {
|
showPlateKeyboard.value = true;
|
plateInputStep.value = plateNumber.value ? 'alnum' : 'province';
|
showTimePicker.value = false;
|
showAreaPicker.value = false;
|
};
|
|
const inputProvince = (province) => {
|
plateNumber.value = province;
|
plateInputStep.value = 'alnum';
|
};
|
|
const canInputPlateKey = (key) => {
|
if (normalizedPlateLength.value === 0) return false;
|
if (normalizedPlateLength.value >= 8) return false;
|
return true;
|
};
|
|
const inputPlateKey = (key) => {
|
if (!canInputPlateKey(key)) return;
|
plateNumber.value += key;
|
};
|
|
const deletePlateKey = () => {
|
if (!plateNumber.value) return;
|
plateNumber.value = plateNumber.value.slice(0, -1);
|
if (!plateNumber.value) {
|
plateInputStep.value = 'province';
|
}
|
};
|
|
const clearPlateNumber = () => {
|
plateNumber.value = '';
|
plateInputStep.value = 'province';
|
};
|
|
const openAreaPicker = () => {
|
tempPrimaryArea.value = primaryAreaOptions.value.length ? primaryAreaOptions.value[0] : '';
|
tempSelectedAreas.value = [...selectedAreas.value];
|
showAreaPicker.value = true;
|
showTimePicker.value = false;
|
showPlateKeyboard.value = false;
|
};
|
|
const selectPrimaryArea = (area) => {
|
tempPrimaryArea.value = area;
|
};
|
|
const getPrimarySelectionState = (area) => {
|
const childAreas = secondaryAreaMap.value[area] || [];
|
const childIds = childAreas.map(c => c.id);
|
if (childIds.length === 0) return 'none';
|
const selectedCount = childIds.filter((id) => tempSelectedAreas.value.includes(id)).length;
|
if (selectedCount === 0) return 'none';
|
if (selectedCount === childIds.length) return 'full';
|
return 'partial';
|
};
|
|
const togglePrimaryCheckbox = (area) => {
|
tempPrimaryArea.value = area;
|
const childAreas = secondaryAreaMap.value[area] || [];
|
const childIds = childAreas.map(c => c.id);
|
const currentStatus = getPrimarySelectionState(area);
|
|
if (currentStatus === 'full') {
|
tempSelectedAreas.value = tempSelectedAreas.value.filter((id) => !childIds.includes(id));
|
return;
|
}
|
tempSelectedAreas.value = Array.from(new Set([...tempSelectedAreas.value, ...childIds]));
|
};
|
|
const toggleTempArea = (area) => {
|
if (tempSelectedAreas.value.includes(area)) {
|
tempSelectedAreas.value = tempSelectedAreas.value.filter((item) => item !== area);
|
return;
|
}
|
tempSelectedAreas.value = [...tempSelectedAreas.value, area];
|
};
|
|
const confirmAreaPicker = () => {
|
selectedAreas.value = [...tempSelectedAreas.value];
|
showAreaPicker.value = false;
|
};
|
|
const removeArea = (area) => {
|
selectedAreas.value = selectedAreas.value.filter((item) => item !== area);
|
};
|
|
const openNoticeDialog = (title, description = '') => {
|
// 页面内直接维护弹窗状态与倒计时,避免额外组件通信带来的不确定性
|
stopNoticeDialogCountdown();
|
noticeDialog.value = {
|
visible: true,
|
title,
|
description,
|
buttonText: '关闭',
|
duration: 5,
|
countdown: 5,
|
};
|
startNoticeDialogCountdown();
|
};
|
|
const closeNoticeDialog = () => {
|
stopNoticeDialogCountdown();
|
noticeDialog.value = {
|
...noticeDialog.value,
|
visible: false,
|
countdown: noticeDialog.value.duration,
|
};
|
};
|
|
const normalizeIdentityCardNo = (value) => {
|
return String(value || '').trim().toUpperCase();
|
};
|
|
const getAppointmentQrIdentityCandidates = () => {
|
const payload = appointmentQrcodePayload.value || {};
|
const candidateKeys = [
|
'visitorIdCard',
|
'idCard',
|
'licId',
|
'visitorIdNo',
|
'visitorIdentityNo',
|
'identityNo',
|
'certificateNo',
|
'certNo',
|
'idCardMask',
|
];
|
|
return candidateKeys
|
.map((key) => normalizeIdentityCardNo(payload?.[key]))
|
.filter(Boolean);
|
};
|
|
const isSameIdentityCard = (sourceIdentityNo, targetIdentityNo) => {
|
const source = normalizeIdentityCardNo(sourceIdentityNo);
|
const target = normalizeIdentityCardNo(targetIdentityNo);
|
|
if (!source || !target) {
|
return false;
|
}
|
|
if (source === target) {
|
return true;
|
}
|
|
// 二维码里如果返回了脱敏身份证号,则兜底按后 4 位比对
|
const sourceLast4 = source.slice(-4);
|
const targetLast4 = target.slice(-4);
|
if (sourceLast4 && targetLast4 && sourceLast4 === targetLast4) {
|
return true;
|
}
|
|
return false;
|
};
|
|
const getIdentityPhotoAddress = () => {
|
return String(appointmentIdCardPayload.value?.photoAddress || '').trim();
|
};
|
|
// 统一更新当前步骤对应的状态文案,避免 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 (options = {}) => {
|
const { preserveIdentityLivePhoto = false } = options;
|
if (!liveFacePhotoPath.value) {
|
return;
|
}
|
|
const currentPath = liveFacePhotoPath.value;
|
liveFacePhotoPath.value = '';
|
|
// 人脸验证通过后,这张现场抓拍图还要继续用于后续打印,
|
// 步骤切换时只清理引用,不删除实体文件;真正离开页面时再统一删除。
|
if (preserveIdentityLivePhoto && currentPath === appointmentIdCardPayload.value?.livePhotoAddress) {
|
return;
|
}
|
|
try {
|
await deleteLocalImage(currentPath);
|
} catch (error) {
|
console.error('清理现场抓拍照片失败:', error);
|
}
|
};
|
|
// 清理接待人现场抓拍图,和访客抓拍图分开管理
|
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({ preserveIdentityLivePhoto: true }),
|
cleanupHostLiveFacePhoto(),
|
]);
|
faceCameraContext = null;
|
hostCameraContext = null;
|
};
|
|
// 创建访客人脸步骤的 live-pusher 上下文,并在原生视图可用后开启预览
|
const ensureFaceCameraContext = async () => {
|
await nextTick();
|
// 打包环境下原生视图挂载通常慢于本地调试,这里预留一点缓冲时间
|
await wait(LIVE_PUSHER_READY_DELAY);
|
|
const currentPageContext = currentInstance?.proxy;
|
faceCameraContext = currentPageContext
|
? uni.createLivePusherContext('faceCamera', currentPageContext)
|
: uni.createLivePusherContext('faceCamera');
|
|
if (!faceCameraContext) {
|
throw new Error('当前环境不支持摄像头拍照(LivePusher)');
|
}
|
|
return new Promise((resolve, reject) => {
|
faceCameraContext.startPreview({
|
success: () => {
|
facePreviewReady.value = true;
|
faceVerifyStatusText.value = '请将脸部置于框内';
|
resolve();
|
},
|
fail: (err) => {
|
reject(new Error('LivePusher 预览开启失败: ' + JSON.stringify(err)));
|
}
|
});
|
});
|
};
|
|
// 接收访客摄像头组件初始化成功事件,同步页面状态
|
const handleFaceCameraInit = () => {
|
facePreviewReady.value = true;
|
faceVerifyStatusText.value = '请保持面部位于取景框内';
|
};
|
|
// 摄像头初始化失败时统一弹窗,方便真机联调定位问题
|
const handleFaceCameraError = (error) => {
|
facePreviewReady.value = false;
|
faceVerifyStatusText.value = '摄像头预览初始化失败';
|
|
const errorMsg = error && error.detail ? JSON.stringify(error.detail) : JSON.stringify(error);
|
|
uni.showModal({
|
title: '摄像头报错调试',
|
content: errorMsg || '未知摄像头错误',
|
showCancel: false
|
});
|
|
console.error('摄像头预览初始化失败,详细错误信息:', JSON.stringify(error));
|
if (error && error.detail) {
|
console.error('摄像头错误 detail:', error.detail);
|
}
|
};
|
|
// 创建接待人人脸步骤的 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) => {
|
cameraContext.snapshot({
|
success: (result) => resolve(result?.message?.tempImagePath || result?.tempImagePath || ''),
|
fail: (error) => reject(error),
|
});
|
});
|
|
if (!tempImagePath) {
|
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 = async () => {
|
// 接待人验证不再此处阻塞拉取员工列表,改为直接使用最新缓存
|
// (在进入该页面前的 onLoad 或前置页面静默完成拉取)
|
employeeVerifyList.value = await getLocalEmployeeVerifyList();
|
hostMatchedEmployee.value = findMatchedHostEmployee();
|
|
const isOnsiteHostVerify = mode.value === 'online';
|
const matchedEmployee = isOnsiteHostVerify ? null : findMatchedHostEmployee();
|
const candidateFaceEmployees = isOnsiteHostVerify ? getHostFaceEmployees() : [];
|
hostMatchedEmployee.value = matchedEmployee;
|
|
if (!isOnsiteHostVerify && !matchedEmployee) {
|
openNoticeDialog(
|
'未找到接待人员工信息',
|
'请确认接待人信息与员工档案一致'
|
);
|
return;
|
}
|
|
const employeeFacePhotoAddress = isOnsiteHostVerify ? '' : resolveEmployeeFacePhotoAddress(matchedEmployee);
|
if (!isOnsiteHostVerify && !employeeFacePhotoAddress) {
|
openNoticeDialog(
|
'未获取到接待人人脸照片',
|
'当前接待人员工档案未配置人脸照片,请先完善员工资料'
|
);
|
return;
|
}
|
|
if (isOnsiteHostVerify && candidateFaceEmployees.length === 0) {
|
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 = '人脸比对中...';
|
let hostCompareResult = null;
|
if (isOnsiteHostVerify) {
|
hostCompareResult = await compareHostFaceWithAllEmployees(livePhotoAddress);
|
} else {
|
hostCompareResult = await compareHostFaceWithEmployee(livePhotoAddress, matchedEmployee);
|
}
|
|
if (sessionId !== judgeFaceSessionId) {
|
return;
|
}
|
|
if (!hostCompareResult?.passed) {
|
hostVerifyStatusText.value = '接待人验证失败';
|
openNoticeDialog(
|
'接待人验证失败,请重新验证',
|
isOnsiteHostVerify
|
? '未匹配到现场接待人员工,请重新验证'
|
: '请正视屏幕重新进行人脸认证'
|
);
|
return;
|
}
|
|
hostMatchedEmployee.value = hostCompareResult.employee || null;
|
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 || {}),
|
livePhotoAddress,
|
};
|
|
appointmentIdCardPayload.value = nextIdentityPayload;
|
saveAppointmentIdCardPayload(nextIdentityPayload);
|
};
|
|
// 身份核验通过后统一推进到人脸步骤,并顺手重置手动录入状态
|
const advanceToFaceStep = () => {
|
dismissNameInput();
|
faceVerifyStatusText.value = '等待开始人证比对';
|
showMissingEntryHint.value = false;
|
isManualInput.value = false;
|
|
nextStep();
|
};
|
|
const isAppointmentIdentityMatched = (cardData) => {
|
if (mode.value !== 'appointment') {
|
return true;
|
}
|
|
const currentIdentityNo = normalizeIdentityCardNo(cardData?.licId);
|
const qrIdentityCandidates = getAppointmentQrIdentityCandidates();
|
|
if (!currentIdentityNo || qrIdentityCandidates.length === 0) {
|
return false;
|
}
|
|
return qrIdentityCandidates.some((candidate) => isSameIdentityCard(candidate, currentIdentityNo));
|
};
|
|
const resolveFaceVerifyErrorMessage = (error) => {
|
return error?.msg || error?.message || '人证照片比对失败,请联系工作人员';
|
};
|
|
const isFaceVerifyPassed = (faceResult) => {
|
if (!faceResult || typeof faceResult !== 'object') {
|
return true;
|
}
|
|
const failKeys = ['matched', 'pass', 'success', 'verified', 'compareResult'];
|
for (const key of failKeys) {
|
if (Object.prototype.hasOwnProperty.call(faceResult, key) && faceResult[key] === false) {
|
return false;
|
}
|
}
|
|
if (Object.prototype.hasOwnProperty.call(faceResult, 'score')) {
|
const score = Number(faceResult.score);
|
const threshold = Number(faceResult.threshold ?? faceResult.passScore ?? 0);
|
if (!Number.isNaN(score) && !Number.isNaN(threshold) && threshold > 0) {
|
return score >= threshold;
|
}
|
}
|
|
return true;
|
};
|
|
const handleFaceVerifyNextStep = () => {
|
clearFaceCaptureTimer();
|
startFaceVerify();
|
};
|
|
// 访客人脸验证:先判脸,再正式抓拍,最后根据是否有身份证照片决定是否做人证比对
|
const startFaceVerify = () => {
|
const currentIdentityPayload = appointmentIdCardPayload.value || {};
|
const isManualIdentityInput = currentIdentityPayload.inputType === 'manual';
|
const idCardPhotoAddress = getIdentityPhotoAddress();
|
|
if (!isManualIdentityInput && !idCardPhotoAddress) {
|
faceVerifyStatusText.value = '未获取到身份证照片';
|
openNoticeDialog(
|
'未获取到身份证照片,请重新读取身份证',
|
'手动输入身份证后无法进行人证照片比对'
|
);
|
return;
|
}
|
|
resetFacePreviewState().then(async () => {
|
try {
|
faceVerifyStatusText.value = '正在开启摄像头...';
|
await ensureFaceCameraContext();
|
// 访客和接待人共用一套“先判脸,再正式抓拍”的流程
|
startJudgeFacePolling({
|
stepName: 'face',
|
onFaceDetected: async (sessionId) => {
|
try {
|
faceVerifyStatusText.value = '正在采集人脸...';
|
const livePhotoAddress = await captureLiveFacePhoto();
|
if (sessionId !== judgeFaceSessionId) {
|
return;
|
}
|
|
saveLiveFacePhotoToIdentityPayload(livePhotoAddress);
|
|
// 手动录入身份证时没有证件照,跳过人证比对,仅保留现场人脸图
|
if (isManualIdentityInput) {
|
faceVerifyStatusText.value = '人脸采集成功';
|
await waitBeforeEnterHostStep();
|
nextStep();
|
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(
|
'人证照片比对失败,请重新采集',
|
'请正视屏幕重新进行人脸认证'
|
);
|
return;
|
}
|
|
faceVerifyStatusText.value = '人证比对成功';
|
await waitBeforeEnterHostStep();
|
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
|
// });
|
|
openNoticeDialog(
|
'摄像头启动失败',
|
'请检查摄像头权限'
|
);
|
}
|
});
|
};
|
|
const retryFaceVerify = () => {
|
startHostVerify();
|
};
|
|
const waitBeforeEnterHostStep = async () => {
|
// 访客人脸验证成功后,统一停留 1 秒再进入接待人验证,给现场一个明确的成功反馈。
|
if (currentStepName.value !== 'face' || !shouldShowHostStep.value) {
|
return;
|
}
|
|
await new Promise((resolve) => {
|
setTimeout(resolve, 1000);
|
});
|
};
|
|
const showQrcodeParseDebugModal = (scanData, decryptedResult = {}) => {
|
return new Promise((resolve) => {
|
uni.showModal({
|
title: '二维码解析结果',
|
content: `原始内容:${scanData}\n\n解密结果:${decryptedResult.plainText || '无'}\n\n解析对象:${JSON.stringify(decryptedResult.payload || {}, null, 2)}`,
|
showCancel: false,
|
success: () => resolve(),
|
fail: () => resolve(),
|
});
|
});
|
};
|
|
const handleAppointmentQrcodeValidation = (payload) => {
|
const validationResult = validateAppointmentQrcodePayload(payload);
|
if (validationResult.valid) {
|
return true;
|
}
|
|
if (validationResult.reason === 'out_of_time_range') {
|
openNoticeDialog(
|
'预约码不在进场时间内,请联系工作人员',
|
'请检查您的预约时间'
|
);
|
return false;
|
}
|
|
if (validationResult.reason === 'already_entered') {
|
openNoticeDialog(
|
'该预约码已进场',
|
'您已进场,请签离'
|
);
|
return false;
|
}
|
|
openNoticeDialog(
|
'未识别二维码,请联系工作人员',
|
''
|
);
|
return false;
|
};
|
|
// -------------------- 区域数据加载 --------------------
|
const loadVisitAreas = async () => {
|
try {
|
// 简单判断网络
|
const networkType = await new Promise(resolve => {
|
uni.getNetworkType({
|
success: (res) => resolve(res.networkType)
|
});
|
});
|
|
if (networkType !== 'none') {
|
const res = await getVisitAreaTree();
|
if (res && res.code === 200 && Array.isArray(res.data)) {
|
visitAreaTree.value = res.data;
|
// 在线获取成功后,同步到本地配置表
|
await saveOfflineConfig('visit_area_tree', res.data);
|
return;
|
}
|
}
|
} catch (err) {
|
console.error('获取在线区域树失败:', err);
|
}
|
|
// 离线或获取失败时,尝试读取本地缓存
|
const cachedData = await getOfflineConfig('visit_area_tree');
|
if (cachedData && Array.isArray(cachedData)) {
|
visitAreaTree.value = cachedData;
|
}
|
};
|
// ----------------------------------------------------
|
onMounted(async () => {
|
resetInactivityTimer();
|
await loadSystemFlowConfig();
|
await loadEmployeeVerifyList();
|
await loadVisitAreas();
|
clearAppointmentIdCardPayload();
|
|
// 首页扫码成功后会直接跳到预约登记的身份证步骤,这里需要保留并恢复之前已缓存的二维码数据,
|
// 否则后续身份证一致性校验会因为拿不到 visitorIdCard 而被误判为不一致。
|
if (mode.value === 'appointment' && currentStep.value > 1) {
|
appointmentQrcodePayload.value = getAppointmentQrPayload();
|
} else {
|
clearAppointmentQrPayload();
|
appointmentQrcodePayload.value = null;
|
}
|
|
hasMountedHardwareFlow = true;
|
|
// 页面加载后立即开启身份证读卡监听。
|
// 只有当前处于身份证读卡步骤或接待人验证步骤时,才消费回调结果。
|
HardwareAPI.startReadCard(async (cardData) => {
|
resetInactivityTimer();
|
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;
|
}
|
|
console.log('读卡成功,拿到身份证信息:', cardData);
|
|
if (mode.value === 'signout') {
|
const hasEntry = await verifySignoutEntry(cardData?.licId);
|
if (!hasEntry) return;
|
}
|
|
if (mode.value === 'appointment' && !isAppointmentIdentityMatched(cardData)) {
|
openNoticeDialog(
|
'身份信息不一致',
|
'请使用预约访客本人的身份证完成核验'
|
);
|
return;
|
}
|
|
if (mode.value === 'online' || mode.value === 'appointment') {
|
const hasEntry = await verifyOnsiteEntry(cardData?.licId);
|
if (hasEntry) return;
|
}
|
|
// 保存身份证识别结果,后续可用于身份校验和人脸照片比对
|
appointmentIdCardPayload.value = cardData;
|
saveAppointmentIdCardPayload(cardData);
|
|
advanceToFaceStep();
|
});
|
|
// 首屏硬件动作不要依赖 init 回调,否则 init 卡住时扫码第一步不会触发
|
triggerHardwareByStep(currentStepName.value);
|
await syncScanLineAnimationByStep(currentStepName.value);
|
|
// 初始化硬件插件
|
HardwareAPI.init().then(() => {
|
console.log('硬件插件初始化完成');
|
}).catch(err => {
|
console.error('硬件插件初始化失败:', err);
|
});
|
});
|
|
onUnmounted(() => {
|
clearInactivityTimer();
|
dismissNameInput();
|
stopScanLineAnimation();
|
stopNoticeDialogCountdown();
|
// 扫码停止统一收口到应用关闭时调用,页面销毁时不主动 stop
|
HardwareAPI.stopReadCard();
|
HardwareAPI.stopJudgeFace();
|
HardwareAPI.stopFaceVerify();
|
stopLivePreview(faceCameraContext);
|
stopLivePreview(hostCameraContext);
|
resetFacePreviewState();
|
});
|
|
onLoad((options) => {
|
if (options.step) {
|
currentStep.value = parseInt(options.step, 10);
|
}
|
if (options.mode) {
|
mode.value = options.mode;
|
}
|
if (options.scanCase === 'no-entry') {
|
openNoticeDialog(
|
'未检测到您的进场信息',
|
'请核对证件信息,或联系工作人员协助处理'
|
);
|
}
|
});
|
|
const currentStepName = computed(() => {
|
return flowStepNames.value[currentStep.value - 1] || '';
|
});
|
|
watch(flowStepNames, (newFlowStepNames) => {
|
if (currentStep.value > newFlowStepNames.length) {
|
currentStep.value = newFlowStepNames.length || 1;
|
}
|
});
|
|
watch(currentStepName, () => {
|
resetInactivityTimer();
|
});
|
|
// 监听步骤变化,步骤切换时只做状态清理,不在中途调用插件 stop 方法
|
watch(currentStepName, async (newStep, oldStep) => {
|
// onLoad 修改 mode / step 时会先触发一次 watch,这里等页面挂载后再处理,
|
// 避免现场登记和签离首屏读卡被 onLoad + onMounted 重复触发两次。
|
if (!hasMountedHardwareFlow) {
|
return;
|
}
|
dismissNameInput();
|
|
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;
|
}
|
|
resetInactivityTimer();
|
|
try {
|
const decryptedResult = await parseHikvisionQrcodePayload(scanData);
|
await showQrcodeParseDebugModal(scanData, decryptedResult);
|
|
// 保存解密后的预约码数据,后续身份证读卡和接口校验都直接复用
|
appointmentQrcodePayload.value = decryptedResult.payload;
|
saveAppointmentQrPayload(decryptedResult.payload);
|
|
console.log('扫码成功,原始预约码:', scanData);
|
console.log('预约码解密结果:', decryptedResult.plainText);
|
console.log('预约码解析对象:', decryptedResult.payload);
|
|
// await new Promise((resolve) => {
|
// uni.showModal({
|
// title: '二维码解析结果',
|
// content: `解析对象:${JSON.stringify(decryptedResult.payload || {}, null, 2)}`,
|
// showCancel: false,
|
// success: () => resolve(),
|
// fail: () => resolve(),
|
// });
|
// });
|
|
if (!handleAppointmentQrcodeValidation(decryptedResult.payload)) {
|
return;
|
}
|
|
nextStep();
|
} catch (error) {
|
console.error('预约码解密失败:', error);
|
openNoticeDialog(
|
'未识别二维码,请联系工作人员',
|
''
|
);
|
}
|
});
|
return;
|
}
|
|
if (stepName === 'face') {
|
// 访客进入人脸步骤后自动开始判脸和比对
|
startFaceVerify();
|
}
|
|
if (stepName === 'host') {
|
// 接待人进入验证步骤后自动开始员工匹配和人脸比对
|
startHostVerify();
|
}
|
|
if (stepName === 'print') {
|
// 延迟 500ms 调用打印机,确保 UI 已经切换到打印步骤
|
setTimeout(() => {
|
startPrintTicket();
|
}, 500);
|
}
|
};
|
|
const goBack = () => {
|
resetInactivityTimer();
|
dismissNameInput();
|
if (isManualInput.value) {
|
isManualInput.value = false;
|
return;
|
}
|
if (currentStep.value > 1) {
|
currentStep.value--;
|
|
// 页面内回退时不主动 stop 插件,只依赖步骤状态让旧流程失效
|
} else {
|
uni.navigateBack({
|
fail: () => {
|
uni.redirectTo({
|
url: '/pages/home/index'
|
});
|
}
|
});
|
}
|
};
|
|
const nextStep = () => {
|
resetInactivityTimer();
|
if (currentStepName.value === 'form' && mode.value === 'online') {
|
if (selectedAreas.value.length === 0) {
|
uni.showToast({ title: '请选择访问区域', icon: 'none' });
|
return;
|
}
|
}
|
|
const maxStep = flowStepNames.value.length;
|
if (currentStep.value < maxStep) {
|
currentStep.value++;
|
} else if (currentStepName.value !== 'print') {
|
// 只有非打印步骤到达最大步数时才直接跳转
|
// 打印步骤到达最大步数时,由 startPrintTicket 的成功回调来触发 goNext
|
goNext();
|
}
|
};
|
|
const verifySignoutEntry = async (idCard) => {
|
if (mode.value !== 'signout') return true;
|
|
if (!idCard) {
|
if (!isManualInput.value) {
|
openNoticeDialog('未检测到您的进场信息', '');
|
}
|
return false;
|
}
|
|
// 直接查询本地数据库中的进场记录
|
const hasEntry = await findEntryRecordByIdCard(idCard);
|
|
if (!hasEntry) {
|
// 只有在非手动输入(即硬件读卡)时,才弹出全局对话框
|
// 手动输入时直接在输入框上方展示红色提示字,不弹窗
|
if (!isManualInput.value) {
|
openNoticeDialog('未检测到您的进场信息', '');
|
}
|
return false;
|
}
|
|
return true;
|
};
|
|
const verifyOnsiteEntry = async (idCard) => {
|
if (mode.value !== 'online' && mode.value !== 'appointment') return false;
|
|
if (!idCard) {
|
return false;
|
}
|
|
const hasEntry = await findEntryRecordByIdCard(idCard);
|
|
if (hasEntry) {
|
openNoticeDialog('该身份已进场', '您已进场,请签离');
|
return true;
|
}
|
|
return false;
|
};
|
|
const confirmManualInput = async () => {
|
if (idNumber.value.length > 0) {
|
if (mode.value === 'online' && !manualName.value.trim()) {
|
uni.showToast({ title: '请输入姓名', icon: 'none' });
|
return;
|
}
|
|
if (mode.value === 'signout') {
|
const hasEntry = await verifySignoutEntry(idNumber.value);
|
if (!hasEntry) {
|
showMissingEntryHint.value = true;
|
return;
|
}
|
}
|
|
if (mode.value === 'online' || mode.value === 'appointment') {
|
const manualIdCardPayload = {
|
licId: idNumber.value,
|
name: mode.value === 'online' ? manualName.value.trim() : '',
|
visitorName: mode.value === 'online' ? manualName.value.trim() : '', // 同时赋值给 visitorName 以防其他地方读取
|
photoAddress: '',
|
inputType: 'manual',
|
};
|
|
if (mode.value === 'appointment' && !isAppointmentIdentityMatched(manualIdCardPayload)) {
|
openNoticeDialog(
|
'身份信息不一致',
|
'请使用预约访客本人的身份证完成核验'
|
);
|
return;
|
}
|
|
const hasEntry = await verifyOnsiteEntry(idNumber.value);
|
if (hasEntry) {
|
return;
|
}
|
|
// 保存手动录入的身份证信息,后续流程统一从公共存储读取
|
appointmentIdCardPayload.value = manualIdCardPayload;
|
saveAppointmentIdCardPayload(manualIdCardPayload);
|
|
// 身份比对通过后,自动进入下一步人脸认证
|
advanceToFaceStep();
|
return;
|
}
|
|
const manualIdCardPayload = {
|
licId: idNumber.value,
|
name: mode.value === 'online' ? manualName.value.trim() : '',
|
visitorName: mode.value === 'online' ? manualName.value.trim() : '', // 同时赋值给 visitorName 以防其他地方读取
|
photoAddress: '',
|
inputType: 'manual',
|
};
|
|
// 保存手动录入的身份证信息,后续流程统一从公共存储读取
|
appointmentIdCardPayload.value = manualIdCardPayload;
|
saveAppointmentIdCardPayload(manualIdCardPayload);
|
|
// 身份比对通过后,自动进入下一步人脸认证
|
advanceToFaceStep();
|
}
|
};
|
|
const saveCheckInRecord = async () => {
|
try {
|
const idCard = idNumber.value || appointmentIdCardPayload.value?.licId || '';
|
let visitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '';
|
const matchedHostName = getFirstFilledValue(hostMatchedEmployee.value || {}, [
|
'employeeName',
|
'name',
|
'userName',
|
'staffName',
|
'realName',
|
]);
|
|
// 如果是现场登记且有手动输入的姓名,优先使用
|
if (mode.value === 'online' && manualName.value.trim()) {
|
visitorName = manualName.value.trim();
|
}
|
const intervieweeName = mode.value === 'online'
|
? (matchedHostName || '')
|
: (appointmentQrcodePayload.value?.beVisitorName || '');
|
const plateNo = plateNumber.value || '';
|
const receiverVerifyMethod = receiverVerifyMethodValue.value;
|
|
let validEndTime = '';
|
if (mode.value === 'online') {
|
validEndTime = `${visitEndDateText.value} ${selectedTime.value}:00`;
|
} else {
|
validEndTime = appointmentQrcodePayload.value?.endTime || '';
|
}
|
|
const validStartTime = appointmentQrcodePayload.value?.startTime || '';
|
const visitAreaIds = mode.value === 'online' ? selectedAreas.value.join(',') : '';
|
const visitAreaNames = mode.value === 'appointment' ? (appointmentQrcodePayload.value?.areas || '') : '';
|
const visitReason = appointmentQrcodePayload.value?.visitPurpose || '';
|
const registerType = mode.value === 'appointment' ? 'APPOINTMENT' : 'ONSITE';
|
|
let formData = {};
|
// 统一使用本地时间格式,避免 toISOString() 转成 UTC 后导致时区偏差
|
const nowFormat = formatStandardDateTime(new Date());
|
|
if (mode.value === 'signout') {
|
formData = {
|
idCard,
|
intervieweeName,
|
receiverVerifyMethod,
|
signImageUrl: '', // 由 syncEngine 负责上传并回填
|
signReceiverName: shouldShowHostStep.value ? (intervieweeName || appointmentHostName.value) : '',
|
signVerifyMethod: receiverVerifyMethod,
|
signTime: nowFormat // 增加签离时间字段供离线接口使用
|
};
|
|
// 标记本地进场记录为已签离,避免下次重复签离
|
if (idCard) {
|
await markEntryRecordSignedOut(idCard);
|
}
|
} else {
|
formData = {
|
// deviceId: visitorDevice.getDeviceInfo().deviceSn || 'unknown',
|
faceImageUrl: '', // 由 syncEngine 负责上传并回填
|
idCard,
|
intervieweeName,
|
plateNo,
|
receiverVerifyMethod,
|
validEndTime,
|
validStartTime,
|
visitAreaIds,
|
visitAreaNames,
|
visitReason,
|
visitorName,
|
registerType,
|
registerTime: nowFormat // 增加签到时间字段供离线接口使用
|
};
|
}
|
|
// 1. 存入本地离线数据库
|
const offlineRecord = {
|
id: `record_${Date.now()}`,
|
type: mode.value,
|
localImgPath: liveFacePhotoPath.value || '',
|
payload: formData,
|
};
|
|
await insertOfflineRecord(offlineRecord);
|
console.log('离线记录保存成功');
|
// uni.showModal({
|
// title: '离线保存诊断',
|
// content: `离线记录已存入本地数据库\nID: ${offlineRecord.id}\n类型: ${offlineRecord.type}`,
|
// showCancel: false
|
// });
|
|
// 2. 触发同步队列(联网时会自动上传图片并调用接口)
|
startSyncQueue();
|
return true;
|
} catch (error) {
|
console.error('保存记录失败:', error);
|
uni.showModal({
|
title: '离线保存失败',
|
content: `存入数据库报错: ${error.message || JSON.stringify(error)}`,
|
showCancel: false
|
});
|
return false;
|
}
|
};
|
|
const goNext = async () => {
|
const saveSuccess = await saveCheckInRecord();
|
if (!saveSuccess) {
|
return;
|
}
|
|
if (mode.value === 'signout') {
|
uni.navigateTo({
|
url: '/pages/signout-complete/index'
|
});
|
} else {
|
uni.navigateTo({
|
url: `/pages/online-complete/index?mode=${mode.value}`
|
});
|
}
|
};
|
|
// 打印凭条辅助函数
|
const getPrintPair = (key, value) => {
|
return {
|
textLineHeight: 40,
|
alignment: 0,
|
textUnderline: 0,
|
textBold: 1,
|
textWidthScale: 0,
|
textHeightScale: 0,
|
text: key,
|
lineStroke: 0,
|
lineStartPosition: 0,
|
lineEndPosition: 0,
|
feedLine: 1,
|
qrcode: null,
|
barcodeHeight: 60,
|
barcodeUnitWidth: 3,
|
barcode: null,
|
imageWidth: 384,
|
url: null,
|
path: null,
|
sub: {
|
textLineHeight: 40,
|
alignment: 2,
|
textUnderline: 0,
|
textBold: 0,
|
textWidthScale: 0,
|
textHeightScale: 0,
|
text: value || '',
|
lineStroke: 0,
|
lineStartPosition: 0,
|
lineEndPosition: 0,
|
feedLine: 0,
|
qrcode: null,
|
barcodeHeight: 60,
|
barcodeUnitWidth: 3,
|
barcode: null,
|
imageWidth: 384,
|
url: null,
|
path: null,
|
sub: null
|
}
|
};
|
};
|
|
const createEmptyPrintLine = () => {
|
return {
|
textLineHeight: 40,
|
alignment: 1,
|
textUnderline: 0,
|
textBold: 0,
|
textWidthScale: 0,
|
textHeightScale: 0,
|
text: null,
|
lineStroke: 0,
|
lineStartPosition: 0,
|
lineEndPosition: 0,
|
feedLine: 1,
|
qrcode: null,
|
barcodeHeight: 60,
|
barcodeUnitWidth: 3,
|
barcode: null,
|
imageWidth: 384,
|
url: null,
|
path: null,
|
sub: null
|
};
|
};
|
|
const getPrintVisitorName = () => {
|
let printVisitorName = appointmentIdCardPayload.value?.name || appointmentIdCardPayload.value?.visitorName || appointmentQrcodePayload.value?.visitorName || '无';
|
if (mode.value === 'online' && manualName.value.trim()) {
|
printVisitorName = manualName.value.trim();
|
}
|
return printVisitorName || '无';
|
};
|
|
const getPrintAppointmentTimeRange = () => {
|
if (mode.value === 'online') {
|
return `${visitEndDateText.value} ${selectedTime.value}`;
|
}
|
|
if (appointmentQrcodePayload.value) {
|
return `${appointmentQrcodePayload.value.startTime || ''} 至 ${appointmentQrcodePayload.value.endTime || ''}` || '无';
|
}
|
|
return '无';
|
};
|
|
const getPrintFieldValueMap = () => {
|
const hostName = appointmentQrcodePayload.value?.beVisitorName || '无';
|
const areas = mode.value === 'online' ? getSelectedAreaNames() : (appointmentQrcodePayload.value?.areas || '无');
|
// 打印时优先使用已采集到的现场人脸图,只有没有抓拍图时才回退到身份证照片
|
const photoPath = appointmentIdCardPayload.value?.livePhotoAddress
|
|| liveFacePhotoPath.value
|
|| appointmentIdCardPayload.value?.photoAddress
|
|| '';
|
|
return {
|
visitorName: {
|
type: 'text',
|
value: getPrintVisitorName(),
|
},
|
idCard: {
|
type: 'text',
|
value: idNumber.value || appointmentIdCardPayload.value?.licId || '无',
|
},
|
appointmentTimeRange: {
|
type: 'text',
|
value: getPrintAppointmentTimeRange(),
|
},
|
interviewee: {
|
type: 'text',
|
value: hostName,
|
},
|
visitorFaceImage: {
|
type: 'image',
|
value: photoPath,
|
emptyText: '无',
|
},
|
registerNo: {
|
type: 'text',
|
value: appointmentQrcodePayload.value?.registerNo || appointmentQrcodePayload.value?.appointmentNo || appointmentQrcodePayload.value?.reservationNo || '无',
|
},
|
plateNo: {
|
type: 'text',
|
value: plateNumber.value || '无',
|
},
|
visitReason: {
|
type: 'text',
|
value: appointmentQrcodePayload.value?.visitPurpose || '无',
|
},
|
visitArea: {
|
type: 'text',
|
value: areas || '无',
|
},
|
};
|
};
|
|
const pushPrintFieldByOption = (list, fieldOption, fieldValueMap) => {
|
const fieldCode = fieldOption?.fieldCode;
|
const fieldName = fieldOption?.fieldName || fieldCode || '未知字段';
|
const fieldConfig = fieldValueMap[fieldCode];
|
|
if (!fieldConfig) {
|
return;
|
}
|
|
if (fieldConfig.type === 'image') {
|
list.push(getPrintPair(fieldName, fieldConfig.value ? '已采集' : (fieldConfig.emptyText || '无')));
|
if (fieldConfig.value) {
|
const imageLine = createEmptyPrintLine();
|
imageLine.path = fieldConfig.value;
|
imageLine.alignment = 2;
|
imageLine.imageWidth = 240;
|
imageLine.feedLine = 1;
|
list.push(imageLine);
|
}
|
return;
|
}
|
|
list.push(getPrintPair(fieldName, fieldConfig.value || '无'));
|
};
|
|
const startPrintTicket = async () => {
|
try {
|
const list = [];
|
|
// 空行
|
list.push(createEmptyPrintLine());
|
|
const titleLine = createEmptyPrintLine();
|
titleLine.text = "登记凭证";
|
titleLine.alignment = 1;
|
titleLine.textWidthScale = 1;
|
titleLine.textHeightScale = 1;
|
titleLine.textBold = 1;
|
list.push(titleLine);
|
|
const subTitleLine = createEmptyPrintLine();
|
subTitleLine.text = "*****";
|
subTitleLine.alignment = 1;
|
subTitleLine.textBold = 1;
|
list.push(subTitleLine);
|
|
// 空行
|
list.push(createEmptyPrintLine());
|
|
const fieldValueMap = getPrintFieldValueMap();
|
configuredPrintFieldOptions.value.forEach((fieldOption) => {
|
pushPrintFieldByOption(list, fieldOption, fieldValueMap);
|
});
|
|
console.log('开始打印凭条,参数:', list);
|
const data = JSON.stringify(list);
|
const printRes = await HardwareAPI.startPrint(data);
|
console.log('打印成功:', printRes);
|
|
// 打印成功后自动跳转到完成页面
|
goNext();
|
} catch (error) {
|
console.error('打印失败:', error);
|
openNoticeDialog('打印失败', '请检查打印机状态或纸张是否充足');
|
}
|
};
|
</script>
|
|
<style scoped>
|
.page-container {
|
width: 1080px;
|
height: 1920px;
|
|
background: linear-gradient(180deg, #3B78FF 0%, #2860E8 50%, #163EBC 100%);
|
padding: 30px 36px 40px 36px;
|
display: flex;
|
flex-direction: column;
|
position: relative;
|
overflow: hidden;
|
}
|
|
.notice-dialog-wrapper {
|
position: fixed;
|
left: 0;
|
top: 0;
|
width: 1080px;
|
height: 1920px;
|
z-index: 999;
|
}
|
|
.notice-dialog-mask {
|
position: absolute;
|
left: 0;
|
top: 0;
|
width: 1080px;
|
height: 1920px;
|
background: rgba(31, 42, 68, 0.6);
|
}
|
|
.notice-dialog-card {
|
position: absolute;
|
left: 165px;
|
top: 808px;
|
width: 750px;
|
padding: 52px 40px;
|
border-radius: 28px;
|
background: #FFFFFF;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.notice-dialog-copy {
|
width: 670px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
}
|
|
.notice-dialog-title {
|
font-family: Inter, sans-serif;
|
font-size: 34px;
|
font-weight: 700;
|
color: #1F2A44;
|
text-align: center;
|
margin-bottom: 16px;
|
}
|
|
.notice-dialog-desc {
|
font-family: Inter, sans-serif;
|
font-size: 20px;
|
font-weight: 500;
|
color: #7B88A8;
|
text-align: center;
|
line-height: 32px;
|
}
|
|
.notice-dialog-button-row {
|
width: 670px;
|
height: 72px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-top: 40px;
|
}
|
|
.notice-dialog-close-btn {
|
width: 220px;
|
height: 56px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
border-radius: 20px;
|
background: #F5F5F5;
|
}
|
|
.notice-dialog-close-text {
|
font-family: Inter, sans-serif;
|
font-size: 20px;
|
font-weight: 700;
|
color: #666666;
|
}
|
|
.bg-glow {
|
position: absolute;
|
width: 300px;
|
height: 300px;
|
border-radius: 300px;
|
background: rgba(127, 177, 255, 0.1);
|
left: -128px;
|
top: 412px;
|
filter: blur(50px);
|
pointer-events: none;
|
}
|
|
/* 头部 */
|
.header-area {
|
width: 1008px;
|
height: 88px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
margin-bottom: 24px;
|
z-index: 1;
|
}
|
|
.left-action {
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
width: 1008px;
|
height: 88px;
|
/* cursor: pointer; */
|
}
|
|
.back-btn {
|
width: 44px;
|
height: 44px;
|
border-radius: 22px;
|
background: rgba(255, 255, 255, 0.14);
|
border: 1px solid rgba(255, 255, 255, 0.26);
|
box-shadow: 0px 10px 22px rgba(15, 46, 133, 0.18);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.back-icon-img {
|
width: 20px;
|
height: 20px;
|
}
|
|
.page-title {
|
font-family: Inter, sans-serif;
|
font-size: 30px;
|
font-weight: 700;
|
color: #FFFFFF;
|
margin-left: 16px;
|
line-height: 44px;
|
height: 44px;
|
}
|
|
/* 主体区 */
|
.main-card {
|
width: 1008px;
|
height: 1200px;
|
border-radius: 34px;
|
background: linear-gradient(180deg, #FFFFFF 0%, #F7FAFF 100%);
|
border: 1px solid #DEE9FF;
|
box-shadow: 0px 22px 52px rgba(13, 46, 134, 0.25);
|
padding: 32px;
|
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
margin-bottom: 24px;
|
z-index: 1;
|
}
|
|
.main-title {
|
font-family: Inter, sans-serif;
|
font-size: 42px;
|
font-weight: 800;
|
color: #1F2A44;
|
text-align: center;
|
height: 50px;
|
}
|
|
.error-hint {
|
margin-top: -20px;
|
font-family: Inter, sans-serif;
|
font-size: 22px;
|
font-weight: 700;
|
color: #F05A67;
|
text-align: center;
|
height: 32px;
|
margin-bottom: 24px;
|
}
|
|
.main-desc {
|
font-family: Inter, sans-serif;
|
font-size: 22px;
|
font-weight: 500;
|
color: #4A5672;
|
text-align: center;
|
margin-bottom: 24px;
|
margin-top: 24px;
|
}
|
|
/* 设备展示区 */
|
.device-area {
|
width: 942px;
|
height: 820px;
|
display: flex;
|
justify-content: center;
|
align-items: center;
|
margin-bottom: 24px;
|
}
|
|
.device-img {
|
width: 942px;
|
height: 820px;
|
}
|
|
.helper-text {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 500;
|
color: #7B88A8;
|
text-align: center;
|
}
|
|
/* 身份证信息特有样式 */
|
.name-hint-row {
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
height: 26px;
|
}
|
|
.name-icon-box {
|
width: 18px;
|
height: 18px;
|
border-radius: 9px;
|
background: #F3F6FC;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 8px;
|
}
|
|
.name-icon-text {
|
font-family: Inter, sans-serif;
|
font-size: 10px;
|
font-weight: 700;
|
color: #7B88A8;
|
}
|
|
.name-text {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 500;
|
color: #7B88A8;
|
}
|
|
/* 手动录入信息特有样式 */
|
.manual-card {
|
height: 720px;
|
padding: 38px 32px 28px 32px;
|
}
|
|
.input-form-card {
|
width: 820px;
|
/* height: 230px; */
|
border-radius: 28px;
|
background: #F8FAFF;
|
border: 1px solid #D9E1F2;
|
padding: 26px;
|
|
display: flex;
|
flex-direction: column;
|
margin-top: 40px;
|
}
|
|
.manual-input-box {
|
width: 768px;
|
height: 118px;
|
border-radius: 22px;
|
background: #FFFFFF;
|
border: 1px solid #D9E1F2;
|
padding: 0 28px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-bottom: 18px;
|
}
|
|
.input-text {
|
font-family: Inter, sans-serif;
|
font-size: 40px;
|
font-weight: 700;
|
color: #1F2A44;
|
letter-spacing: 2px;
|
}
|
|
.input-text.placeholder {
|
font-size: 24px;
|
font-weight: 500;
|
color: #A0ACC4;
|
letter-spacing: normal;
|
}
|
|
.input-helper-row {
|
height: 30px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.input-helper-text {
|
font-family: Inter, sans-serif;
|
font-size: 17px;
|
font-weight: 500;
|
color: #7B88A8;
|
}
|
|
.form-action-row {
|
width: 820px;
|
height: 112px;
|
display: flex;
|
flex-direction: row;
|
margin-top: 40px;
|
}
|
|
.clear-btn {
|
width: 232px;
|
height: 112px;
|
border-radius: 24px;
|
background: #EEF4FF;
|
border: 1px solid #D6E3FF;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 16px;
|
}
|
|
.clear-btn-text {
|
font-family: Inter, sans-serif;
|
font-size: 24px;
|
font-weight: 700;
|
color: #2F6BFF;
|
}
|
|
.confirm-btn {
|
flex: 1;
|
height: 112px;
|
border-radius: 24px;
|
background: linear-gradient(to bottom, #5A8FFF, #2F6BFF);
|
border: 1px solid rgba(255, 255, 255, 0.33);
|
box-shadow: 0px 10px 22px rgba(47, 107, 255, 0.18);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.confirm-btn-text {
|
font-family: Inter, sans-serif;
|
font-size: 24px;
|
font-weight: 700;
|
color: #FFFFFF;
|
}
|
|
.clear-btn-disabled {
|
width: 232px;
|
height: 112px;
|
border-radius: 24px;
|
background: #F3F6FB;
|
border: 1px solid #E1E7F0;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 16px;
|
}
|
|
.clear-btn-text-disabled {
|
font-family: Inter, sans-serif;
|
font-size: 24px;
|
font-weight: 700;
|
color: #A8B3C4;
|
}
|
|
.confirm-btn-disabled {
|
flex: 1;
|
height: 112px;
|
border-radius: 24px;
|
background: #E5EAF2;
|
border: 1px solid #D8DEE8;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.confirm-btn-text-disabled {
|
font-family: Inter, sans-serif;
|
font-size: 24px;
|
font-weight: 700;
|
color: #9AA6B8;
|
}
|
|
/* 自定义键盘 */
|
.custom-keyboard {
|
width: 1008px;
|
height: 796px;
|
background: #FFFFFF;
|
border-radius: 30px;
|
border: 1px solid #D9E6FF;
|
box-shadow: 0px -8px 24px 0px rgba(13, 46, 134, 0.08);
|
padding: 22px 16px 24px 16px;
|
|
display: flex;
|
flex-direction: column;
|
z-index: 10;
|
}
|
|
.keyboard-grid {
|
display: flex;
|
flex-direction: column;
|
flex: 1;
|
width: 976px;
|
}
|
|
.keyboard-row {
|
display: flex;
|
flex-direction: row;
|
flex: 1;
|
width: 976px;
|
}
|
|
.key-btn {
|
flex: 1;
|
background: #FFFFFF;
|
border-radius: 26px;
|
border: 1px solid #D9E1F2;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
|
margin: 0 18px 18px 0;
|
}
|
|
.key-btn:last-child {
|
margin-right: 0;
|
}
|
|
.keyboard-row:last-child .key-btn{
|
margin-bottom: 0;
|
}
|
|
.key-btn:active {
|
background: #E5EAF2;
|
}
|
|
.key-text {
|
font-family: Inter, sans-serif;
|
font-size: 38px;
|
font-weight: 700;
|
color: #1F2A44;
|
}
|
|
.key-x {
|
background: #EEF4FF;
|
border: 1px solid #D6E3FF;
|
}
|
|
.key-text-x {
|
font-family: Inter, sans-serif;
|
font-size: 38px;
|
font-weight: 700;
|
color: #2F6BFF;
|
}
|
|
.key-delete {
|
background: #FFF5E4;
|
border: 1px solid #FFDDA8;
|
}
|
|
.key-text-delete {
|
font-family: Inter, sans-serif;
|
font-size: 34px;
|
font-weight: 700;
|
color: #C47A00;
|
}
|
|
.keyboard-close {
|
position: absolute;
|
top: -60px;
|
right: 20px;
|
width: 80px;
|
height: 60px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
background: rgba(255, 255, 255, 0.9);
|
border-radius: 16px 16px 0 0;
|
box-shadow: 0 -4px 10px rgba(0,0,0,0.05);
|
}
|
|
/* 人脸验证特有样式 */
|
.title-group {
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
}
|
|
.sub-title {
|
font-family: Inter, sans-serif;
|
font-size: 22px;
|
font-weight: 500;
|
color: #4A5672;
|
margin-top: 12px;
|
}
|
|
.name-pill {
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
margin-top: 16px;
|
}
|
|
.name-icon {
|
width: 18px;
|
height: 18px;
|
border-radius: 9px;
|
background: #F3F6FC;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 8px;
|
}
|
|
.face-capture-card {
|
width: 880px;
|
height: 876px;
|
border-radius: 36px;
|
background: linear-gradient(to bottom, #101C3D, #0A132B);
|
border: 1px solid #243B72;
|
box-shadow: 0px 14px 34px rgba(8, 17, 38, 0.23);
|
padding: 30px;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
margin-top: 24px;
|
}
|
|
.status-pill {
|
/* width: 236px; */
|
height: 52px;
|
border-radius: 26px;
|
background: #132852;
|
border: 1px solid #2D4F8D;
|
box-shadow: 0px 6px 16px rgba(11, 23, 48, 0.2);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex-direction: row;
|
padding: 0 24px;
|
}
|
|
.status-dot {
|
width: 12px;
|
height: 12px;
|
border-radius: 12px;
|
background: #55D6FF;
|
box-shadow: 0px 0px 10px rgba(85, 214, 255, 0.4);
|
margin-right: 10px;
|
}
|
|
.status-text {
|
font-family: Inter, sans-serif;
|
font-size: 20px;
|
font-weight: 700;
|
color: #F4F8FF;
|
}
|
|
.viewfinder-area {
|
width: 760px;
|
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;
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
margin-top: 26px;
|
}
|
|
.viewfinder-box {
|
width: 536px;
|
height: 528px;
|
border-radius: 30px;
|
background: #0A1427;
|
/* border: 2px solid #3B66B8; */
|
/* box-shadow: 0px 10px 24px rgba(10, 20, 39, 0.27); */
|
display: flex;
|
flex-direction: column;
|
align-items: center;
|
justify-content: center;
|
position: relative;
|
overflow: hidden;
|
margin-bottom: 28px;
|
}
|
|
.face-camera {
|
position: absolute;
|
left: 0;
|
top: 0;
|
width: 536px;
|
height: 528px;
|
z-index: 0;
|
/* 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 {
|
width: 128px;
|
height: 150px;
|
border-radius: 50%;
|
background: #F7FAFF;
|
position: absolute;
|
top: 100px;
|
opacity: 0.15;
|
z-index: 1;
|
pointer-events: none;
|
}
|
|
.shoulder-ellipse {
|
width: 300px;
|
height: 176px;
|
border-radius: 50%;
|
background: #F7FAFF;
|
position: absolute;
|
bottom: 0px;
|
opacity: 0.15;
|
z-index: 1;
|
pointer-events: none;
|
}
|
|
.scan-line {
|
width: 372px;
|
height: 5px;
|
border-radius: 3px;
|
background: #55D6FF;
|
box-shadow: 0px 0px 12px rgba(85, 214, 255, 0.33);
|
position: absolute;
|
top: 50px;
|
z-index: 2;
|
pointer-events: none;
|
}
|
|
.viewfinder-hint {
|
font-family: Inter, sans-serif;
|
font-size: 17px;
|
font-weight: 600;
|
color: #D8E7FF;
|
}
|
|
.hints-row {
|
width: 818px;
|
height: 34px;
|
display: flex;
|
justify-content: center;
|
flex-direction: row;
|
margin-top: 26px;
|
}
|
|
.hint-item {
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
flex-direction: row;
|
margin-right: 18px;
|
}
|
|
.hint-item:last-child {
|
margin-right: 0;
|
}
|
|
.hint-dot {
|
width: 8px;
|
height: 8px;
|
border-radius: 8px;
|
margin-right: 8px;
|
}
|
|
.hint-text {
|
font-family: Inter, sans-serif;
|
font-size: 16px;
|
font-weight: 700;
|
color: #DCE6FF;
|
}
|
|
.bottom-subtext {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 500;
|
color: #7B88A8;
|
text-align: center;
|
margin-top: 24px;
|
}
|
|
.placeholder {
|
color: #A0AFC0;
|
}
|
|
/* 打印凭条特有样式 */
|
.print-demo-card {
|
width: 880px;
|
height: 860px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-top: 20px;
|
}
|
|
.print-demo-img {
|
width: 880px;
|
height: 860px;
|
}
|
|
/* 底部区域 */
|
.bottom-area {
|
width: 1008px;
|
height: 176px;
|
display: flex;
|
flex-direction: row;
|
z-index: 1;
|
}
|
|
.help-card {
|
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;
|
display: flex;
|
flex-direction: column;
|
position: relative;
|
margin-right: 18px;
|
}
|
|
.action-card {
|
flex: 1;
|
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: 20px 22px 22px 22px;
|
display: flex;
|
flex-direction: column;
|
position: relative;
|
}
|
|
.highlight-bar {
|
/* position: absolute;
|
top: 20px;
|
left: 22px; */
|
width: 108px;
|
height: 6px;
|
border-radius: 2px;
|
background: #A8CBFF;
|
}
|
|
.card-title-row {
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
margin-top: 12px;
|
}
|
|
.icon-base {
|
width: 38px;
|
height: 38px;
|
border-radius: 19px;
|
background: rgba(255, 255, 255, 0.15);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 10px;
|
}
|
|
.icon-base-img {
|
width: 20px;
|
height: 20px;
|
}
|
|
.card-title {
|
font-family: Inter, sans-serif;
|
font-size: 21px;
|
font-weight: 800;
|
color: #FFFFFF;
|
}
|
|
.help-content {
|
font-family: Inter, sans-serif;
|
font-size: 16px;
|
font-weight: 500;
|
color: #EAF1FF;
|
/* white-space: pre-line; */
|
margin-top: 16px;
|
}
|
|
.main-btn {
|
width: 556px;
|
height: 56px;
|
border-radius: 18px;
|
background: linear-gradient(180deg, #FFFFFF 0%, #F4F8FF 100%);
|
border: 1px solid rgba(255, 255, 255, 0.5);
|
box-shadow: 0px 8px 18px rgba(7, 29, 90, 0.1);
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
/* cursor: pointer; */
|
margin-top: 12px;
|
}
|
|
.btn-text {
|
font-family: Inter, sans-serif;
|
font-size: 19px;
|
font-weight: 800;
|
color: #1F49D8;
|
}
|
/* ================= 完善登记信息样式 ================= */
|
.form-card {
|
width: 1008px;
|
height: 1200px;
|
border-radius: 32px;
|
background: #FFFFFF;
|
padding: 32px;
|
display: flex;
|
flex-direction: column;
|
box-shadow: 0 22px 52px rgba(13, 46, 134, 0.25);
|
z-index: 1;
|
}
|
|
.form-row {
|
width: 944px;
|
height: 56px;
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
margin-bottom: 24px;
|
}
|
|
.label {
|
width: 220px;
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 600;
|
color: #1F2A44;
|
margin-right: 16px;
|
}
|
|
.datetime-group {
|
flex: 1;
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
}
|
|
.input-box {
|
height: 56px;
|
border-radius: 14px;
|
border: 1px solid #D9E1F2;
|
background: #FFFFFF;
|
padding: 0 16px;
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
}
|
|
.pressable-box {
|
/* cursor: pointer; */
|
}
|
|
.date-box {
|
width: 440px; /* 变长 */
|
justify-content: flex-start;
|
margin-right: 14px;
|
}
|
|
.time-box {
|
width: 200px;
|
flex: 1; /* 占据剩余空间 */
|
justify-content: space-between;
|
}
|
|
.field-prefix-icon-img,
|
.field-suffix-icon-img {
|
width: 20px;
|
height: 20px;
|
/* flex-shrink: 0; */
|
opacity: 0.6; /* 让图标稍微弱化一点,更贴近设计图的灰色线条感 */
|
}
|
|
.full-box {
|
flex: 1;
|
}
|
|
.between-box {
|
justify-content: space-between;
|
}
|
|
.field-arrow-icon-img {
|
width: 20px;
|
height: 20px;
|
/* flex-shrink: 0; */
|
}
|
|
.reverse-arrow {
|
transform: rotate(180deg);
|
}
|
|
.input-value {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 600;
|
color: #1F2A44;
|
margin-left: 12px;
|
}
|
|
.time-value {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 600;
|
color: #7B88A8;
|
}
|
|
.muted-value {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 600;
|
color: #7B88A8;
|
}
|
|
.plate-value {
|
/* letter-spacing: 1px; */
|
}
|
|
.selected-area-box {
|
width: 944px;
|
border-radius: 16px;
|
background: #F3F6FC;
|
padding: 16px;
|
|
display: flex;
|
flex-direction: column;
|
}
|
|
.tag-row {
|
width: 912px;
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
flex-wrap: wrap;
|
}
|
|
.tag-item {
|
height: 36px;
|
border-radius: 18px;
|
border: 1px solid #D9E1F2;
|
background: #FFFFFF;
|
padding: 0 12px;
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
margin-right: 10px;
|
}
|
|
.tag-text {
|
font-family: Inter, sans-serif;
|
font-size: 14px;
|
font-weight: 600;
|
color: #1F2A44;
|
margin-right: 8px;
|
}
|
|
.tag-x {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 700;
|
color: #A0ACC4;
|
/* cursor: pointer; */
|
}
|
|
.save-btn {
|
width: 944px;
|
height: 78px;
|
border-radius: 18px;
|
background: #2F6BFF;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-top: 24px;
|
}
|
|
.save-btn-text {
|
font-family: Inter, sans-serif;
|
font-size: 22px;
|
font-weight: 700;
|
color: #FFFFFF;
|
}
|
|
/* 底部弹窗相关 */
|
.mask-layer {
|
position: fixed;
|
left: 0;
|
top: 0;
|
width: 1080px;
|
height: 1920px;
|
background: rgba(11, 18, 32, 0.5);
|
z-index: 20;
|
}
|
|
.bottom-sheet {
|
position: fixed;
|
left: 0;
|
bottom: 0;
|
width: 1080px;
|
background: #FFFFFF;
|
border-radius: 28px 28px 0 0;
|
padding: 28px 28px 36px 28px;
|
z-index: 21;
|
}
|
|
.time-sheet { height: 800px; }
|
.area-sheet { height: 1200px; }
|
.plate-sheet { height: 960px; }
|
|
.sheet-head {
|
width: 1024px;
|
height: 48px;
|
display: flex;
|
align-items: center;
|
justify-content: space-between;
|
flex-direction: row;
|
margin-bottom: 24px;
|
}
|
|
.compact-head { margin-bottom: 18px; }
|
|
.sheet-title {
|
font-family: Inter, sans-serif;
|
font-size: 30px;
|
font-weight: 700;
|
color: #1F2A44;
|
}
|
|
.area-sheet-head { justify-content: space-between; }
|
.sheet-head-placeholder { width: 44px; height: 44px; }
|
.area-title { font-size: 22px; text-align: center; }
|
|
.area-close-btn {
|
width: 44px;
|
height: 44px;
|
border-radius: 22px;
|
border: 1px solid #EEF2FA;
|
background: #FFFFFF;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
font-size: 24px;
|
}
|
|
.rotate-45 {
|
transform: rotate(45deg);
|
}
|
|
.sheet-close {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 600;
|
color: #1F2A44;
|
}
|
|
.time-picker-body {
|
width: 1024px;
|
display: flex;
|
flex-direction: row;
|
margin-bottom: 30px;
|
}
|
|
.time-column {
|
flex: 1;
|
height: 560px;
|
border-radius: 24px;
|
background: #F7FAFF;
|
border: 1px solid #EEF2FA;
|
padding: 24px;
|
margin-right: 24px;
|
}
|
|
.time-column:last-child { margin-right: 0; }
|
|
.time-column-title {
|
/* display: block; */
|
margin-bottom: 18px;
|
font-family: Inter, sans-serif;
|
font-size: 24px;
|
font-weight: 700;
|
color: #1F2A44;
|
}
|
|
.time-scroll { height: 460px; }
|
|
.time-item {
|
width: 438px;
|
height: 68px;
|
border-radius: 18px;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-bottom: 12px;
|
background: #FFFFFF;
|
border: 1px solid #E5ECF8;
|
}
|
|
.time-item-active {
|
background: #EAF1FF;
|
border-color: #BFD4FF;
|
}
|
|
.time-item-text {
|
font-family: Inter, sans-serif;
|
font-size: 24px;
|
font-weight: 700;
|
color: #1F2A44;
|
}
|
|
.area-list-card {
|
width: 1024px;
|
height: 520px;
|
border-radius: 24px;
|
background: #FFFFFF;
|
border: 1px solid #EEF2FA;
|
padding: 16px;
|
|
display: flex;
|
flex-direction: row;
|
margin-bottom: 430px;
|
}
|
|
.area-column {
|
height: 520px;
|
display: flex;
|
flex-direction: column;
|
margin-right: 16px;
|
}
|
|
.area-primary-column { width: 470px; }
|
.area-secondary-column { flex: 1; }
|
|
.primary-area-row,
|
.secondary-area-row {
|
width: 480px;
|
height: 64px;
|
border-radius: 16px;
|
padding: 0 16px;
|
|
display: flex;
|
align-items: center;
|
margin-bottom: 12px;
|
flex-direction: row;
|
}
|
|
.primary-area-row {
|
width: 470px;
|
justify-content: space-between;
|
height: 64px;
|
background: #FFFFFF;
|
}
|
|
.primary-area-row-active,
|
.secondary-area-row-active {
|
background: #EAF1FF;
|
}
|
|
.primary-area-left {
|
display: flex;
|
align-items: center;
|
flex-direction: row;
|
}
|
|
.primary-check,
|
.secondary-check {
|
width: 28px;
|
height: 28px;
|
border-radius: 8px;
|
border: 1px solid #D9E1F2;
|
background: #FFFFFF;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
|
flex-direction: row;
|
margin-right: 12px;
|
}
|
|
.primary-check-active,
|
.secondary-check-active {
|
background: #2F6BFF;
|
border-color: #2F6BFF;
|
}
|
|
.partial-line {
|
width: 14px;
|
height: 3px;
|
border-radius: 999px;
|
background: #FFFFFF;
|
}
|
|
.check-icon-img {
|
width: 16px;
|
height: 16px;
|
}
|
|
.primary-area-text,
|
.secondary-area-text {
|
font-family: Inter, sans-serif;
|
font-size: 18px;
|
font-weight: 600;
|
color: #1F2A44;
|
}
|
|
.primary-area-text { font-weight: 600; color: #1F2A44; }
|
|
.primary-area-text-active,
|
.secondary-area-text-active { font-weight: 700; color: #2F6BFF; }
|
|
.sheet-confirm-btn {
|
width: 1024px;
|
height: 78px;
|
border-radius: 18px;
|
background: #2F6BFF;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
}
|
|
.sheet-confirm-text {
|
font-family: Inter, sans-serif;
|
font-size: 22px;
|
font-weight: 700;
|
color: #FFFFFF;
|
}
|
|
.plate-section { width: 1024px; margin-bottom: 16px; }
|
.plate-step-tip { width: 1024px; margin-bottom: 14px; }
|
.plate-step-tip-text { font-family: Inter, sans-serif; font-size: 18px; font-weight: 600; color: #7B88A8; }
|
|
.province-grid {
|
display: flex;
|
flex-direction: row;
|
flex-wrap: wrap;
|
}
|
|
.plate-grid-row {
|
display: flex;
|
flex-direction: row;
|
margin-bottom: 12px;
|
}
|
|
.keyboard-key {
|
width: 82px;
|
height: 58px;
|
border-radius: 10px;
|
background: #F5F5F5;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-bottom: 12px;
|
}
|
|
|
.province-key {
|
width: 103px;
|
height: 68px;
|
}
|
.keyboard-key-active { background: #EAF1FF; }
|
.keyboard-key-disabled { opacity: 0.45; }
|
.keyboard-key-special { background: #ECECEC; }
|
.delete-key {
|
width: 68px;
|
background: #F0F0F0;
|
}
|
|
.delete-key-icon-img {
|
width: 22px;
|
height: 22px;
|
}
|
|
.keyboard-key-text {
|
font-family: Inter, sans-serif;
|
font-size: 20px;
|
font-weight: 700;
|
color: #4D4D4D;
|
}
|
|
.plate-action-row {
|
display: flex;
|
flex-direction: row;
|
margin-top: 10px;
|
}
|
|
.plate-secondary-btn {
|
width: 180px;
|
height: 78px;
|
border-radius: 18px;
|
background: #EEF4FF;
|
display: flex;
|
align-items: center;
|
justify-content: center;
|
margin-right: 16px;
|
}
|
|
.plate-secondary-text {
|
font-family: Inter, sans-serif;
|
font-size: 22px;
|
font-weight: 700;
|
color: #2F6BFF;
|
}
|
|
.plate-confirm-btn { flex: 1; }
|
|
</style>
|