Parcourir la source

完成认证验证提醒功能

yuanmingze il y a 4 mois
Parent
commit
b14cce0c40

+ 310 - 0
src/pages/index/components/LivenessReminderDialog.vue

@@ -0,0 +1,310 @@
+<template>
+  <wd-popup
+    v-model="show"
+    position="center"
+    :close-on-click-modal="false"
+    :safe-area-inset-bottom="false"
+  >
+    <view class="liveness-dialog">
+      <view class="dialog-hero">
+        <view class="hero-icon">
+          <text class="hero-icon-text">!</text>
+        </view>
+
+        <view class="hero-title">活体验证提醒</view>
+
+        <view class="hero-subtitle">
+          {{ contentTitle }}
+        </view>
+      </view>
+
+      <view class="dialog-body">
+        <view class="notice-card">
+          <view class="notice-label">
+            {{ noticeLabel }}
+          </view>
+
+          <view class="notice-desc">
+            <block v-if="type === 'expiring'">
+              请于<text class="strong">{{ expireDateText }}</text>
+              前重新完成活体验证。超期后未重新完成活体验证,将无法正常结算。
+            </block>
+
+            <block v-else-if="type === 'expired'">
+              请立即重新完成活体验证。重新完成活体验证前,将无法正常结算。
+            </block>
+          </view>
+        </view>
+
+        <view class="auth-entry" @click="handleGoAuth">
+          <view class="auth-entry-main">
+            <view class="auth-entry-title">去进行人脸识别活体验证</view>
+            <view class="auth-entry-desc">完成后可恢复正常结算能力</view>
+          </view>
+
+          <view class="auth-entry-arrow">›</view>
+        </view>
+      </view>
+
+      <view class="dialog-footer">
+        <view class="confirm-btn" @click="handleConfirm"> 我知道啦 </view>
+      </view>
+    </view>
+  </wd-popup>
+</template>
+
+<script setup lang="ts">
+import { computed } from 'vue'
+
+// import { personVerifyFaceApi } from '@/services/modules/auth'
+import { useUserStore } from '@/stores/modules/user'
+
+type LivenessReminderType = 'expiring' | 'expired' | ''
+
+const props = withDefaults(
+  defineProps<{
+    modelValue: boolean
+    type: LivenessReminderType
+    expireDateText: string
+  }>(),
+  {
+    modelValue: false,
+    type: '',
+    expireDateText: '',
+  }
+)
+
+const emit = defineEmits<{
+  'update:modelValue': [value: boolean]
+}>()
+
+const userStore = useUserStore()
+
+const show = computed({
+  get: () => props.modelValue,
+  set: (value: boolean) => {
+    emit('update:modelValue', value)
+  },
+})
+
+const contentTitle = computed(() => {
+  if (props.type === 'expired') {
+    return '您的活体验证已超过税局要求的6个月限制'
+  }
+
+  return '您的活体验证即将超过税局要求的6个月限制'
+})
+
+const noticeLabel = computed(() => {
+  if (props.type === 'expired') {
+    return '当前状态:已超期'
+  }
+
+  return '当前状态:即将超期'
+})
+
+const handleConfirm = () => {
+  show.value = false
+}
+
+const handleGoAuth = async () => {
+  const userInfo = userStore.currentUserInfo
+  const idCardNumber = userInfo?.idCardNumber
+  const realname = userInfo?.realname
+
+  if (!idCardNumber || !realname) {
+    uni.showToast({
+      title: '用户实名信息缺失',
+      icon: 'none',
+    })
+    return
+  }
+
+  // const res = await personVerifyFaceApi({
+  //   cardId: idCardNumber,
+  //   realName: realname,
+  // })
+
+  // if (res.code !== 0 || !res.data?.serialNo || !res.data?.faceUrl) return
+
+  // show.value = false
+
+  // uni.navigateTo({
+  //   url:
+  //     `/pages-sub-verify/auth/face-auth` +
+  //     `?serialNo=${res.data.serialNo}` +
+  //     `&faceUrl=${encodeURIComponent(res.data.faceUrl)}` +
+  //     `&realName=${encodeURIComponent(realname)}` +
+  //     `&cardId=${encodeURIComponent(idCardNumber)}`,
+  // })
+}
+</script>
+
+<style lang="scss" scoped>
+.liveness-dialog {
+  width: 620rpx;
+  overflow: hidden;
+  border-radius: 28rpx;
+  background: #ffffff;
+  box-shadow: 0 24rpx 64rpx rgba(21, 34, 56, 0.18);
+}
+
+.dialog-hero {
+  position: relative;
+  padding: 44rpx 40rpx 38rpx;
+  text-align: center;
+  background: linear-gradient(180deg, #f2f6ff 0%, #ffffff 100%);
+}
+
+.dialog-hero::before {
+  content: '';
+  position: absolute;
+  top: -120rpx;
+  left: 50%;
+  width: 360rpx;
+  height: 360rpx;
+  border-radius: 50%;
+  background: rgba(63, 115, 223, 0.08);
+  transform: translateX(-50%);
+}
+
+.hero-icon {
+  position: relative;
+  z-index: 1;
+  width: 88rpx;
+  height: 88rpx;
+  margin: 0 auto 24rpx;
+  border-radius: 50%;
+  background: linear-gradient(180deg, #4d7ff0 0%, #2f64d8 100%);
+  box-shadow: 0 12rpx 28rpx rgba(63, 115, 223, 0.28);
+  display: flex;
+  align-items: center;
+  justify-content: center;
+}
+
+.hero-icon-text {
+  color: #ffffff;
+  font-size: 52rpx;
+  line-height: 1;
+  font-weight: 700;
+}
+
+.hero-title {
+  position: relative;
+  z-index: 1;
+  font-size: 34rpx;
+  line-height: 48rpx;
+  font-weight: 700;
+  color: #1f2937;
+}
+
+.hero-subtitle {
+  position: relative;
+  z-index: 1;
+  margin-top: 14rpx;
+  font-size: 28rpx;
+  line-height: 40rpx;
+  color: #4b5563;
+}
+
+.dialog-body {
+  padding: 0 36rpx 36rpx;
+}
+
+.notice-card {
+  padding: 28rpx 28rpx 30rpx;
+  border-radius: 20rpx;
+  background: #f8faff;
+  border: 1rpx solid #e6edff;
+}
+
+.notice-label {
+  display: inline-flex;
+  align-items: center;
+  height: 44rpx;
+  padding: 0 18rpx;
+  border-radius: 999rpx;
+  background: #eaf1ff;
+  color: #2f64d8;
+  font-size: 24rpx;
+  line-height: 44rpx;
+  font-weight: 600;
+}
+
+.notice-desc {
+  margin-top: 22rpx;
+  font-size: 28rpx;
+  line-height: 44rpx;
+  color: #374151;
+}
+
+.strong {
+  margin: 0 4rpx;
+  color: #d93025;
+  font-weight: 700;
+}
+
+.auth-entry {
+  margin-top: 24rpx;
+  padding: 26rpx 28rpx;
+  border-radius: 20rpx;
+  background: #ffffff;
+  border: 1rpx solid #e5e7eb;
+  display: flex;
+  align-items: center;
+  justify-content: space-between;
+}
+
+.auth-entry:active {
+  background: #f9fafb;
+}
+
+.auth-entry-main {
+  flex: 1;
+  min-width: 0;
+}
+
+.auth-entry-title {
+  font-size: 29rpx;
+  line-height: 42rpx;
+  color: #2563eb;
+  font-weight: 700;
+}
+
+.auth-entry-desc {
+  margin-top: 6rpx;
+  font-size: 24rpx;
+  line-height: 34rpx;
+  color: #8a94a6;
+}
+
+.auth-entry-arrow {
+  flex-shrink: 0;
+  margin-left: 20rpx;
+  color: #2563eb;
+  font-size: 48rpx;
+  line-height: 48rpx;
+  font-weight: 300;
+}
+
+.dialog-footer {
+  padding: 0 36rpx 36rpx;
+}
+
+.confirm-btn {
+  height: 88rpx;
+  border-radius: 999rpx;
+  background: linear-gradient(90deg, #3f73df 0%, #2563eb 100%);
+  color: #ffffff;
+  font-size: 31rpx;
+  line-height: 88rpx;
+  font-weight: 700;
+  text-align: center;
+  box-shadow: 0 12rpx 28rpx rgba(63, 115, 223, 0.26);
+}
+
+.confirm-btn:active {
+  opacity: 0.9;
+  transform: scale(0.99);
+}
+</style>

+ 72 - 3
src/pages/index/index.vue

@@ -27,6 +27,12 @@
     <WorkbenchTaskSection @select-task="handleSelectTask" />
     <!--廉洁协议 -->
     <HonestAgreementGate v-model="isHonestAgreementVisible" />
+
+    <LivenessReminderDialog
+      v-model="isLivenessReminderVisible"
+      :type="livenessReminderType"
+      :expire-date-text="livenessExpireDateText"
+    />
   </view>
 </template>
 
@@ -34,6 +40,7 @@
 import { computed, ref } from 'vue'
 
 import { onShow } from '@dcloudio/uni-app'
+import dayjs from 'dayjs'
 
 import {
   getQuizPltTestResultApi,
@@ -44,6 +51,7 @@ import {
 import { useUserStore } from '@/stores/modules/user'
 
 import HonestAgreementGate from './components/HonestAgreementGate.vue'
+import LivenessReminderDialog from './components/LivenessReminderDialog.vue'
 import WorkbenchCard from './components/WorkbenchCard.vue'
 import WorkbenchTaskSection from './components/WorkbenchTaskSection.vue'
 import type { TASK_TYPE } from './workbench/workbench.config'
@@ -58,11 +66,14 @@ const onTheWay = ref('0')
 onShow(async () => {
   // 每次进入页面时刷新用户状态
   if (!userStore.isLoggedIn) return
+
   const userInfo = await userStore.getUserInfoByCode()
 
   if (!userInfo) return
+
   waitApprove.value = userInfo.waitApprove ?? '0'
   onTheWay.value = userInfo.onTheWay ?? '0'
+
   // 未签署廉洁协议
   if (!userInfo.signedAgreement?.includes('HONEST_AGREEMENT_V2')) {
     uni.hideTabBar()
@@ -73,20 +84,31 @@ onShow(async () => {
   // 超龄退出
   if (userInfo.ageLimit) {
     isAgeOverLimit()
+    return
   }
+
   // 超龄提醒
   if (userInfo.ageReminder) {
     notifyUpcomingAgeLimit()
   }
+
   // 平台合规测试
-  const res = await getPltQuizResult()
-  if (!res) return
+  const quizPassed = await getPltQuizResult()
+  if (!quizPassed) return
+
+  // 活体验证提醒:本次登录只弹一次
+  const hasOpenedLivenessReminder = checkLivenessReminder(userInfo)
+  if (hasOpenedLivenessReminder) {
+    uni.showTabBar()
+    return
+  }
+
   // 密码修改提醒
   checkLastChangePassword()
+
   // 兜底处理
   uni.showTabBar()
 })
-
 const ensureCanOperate = (): boolean => {
   if (!userStore.isLoggedIn) {
     uni.showToast({
@@ -247,6 +269,53 @@ const checkLastChangePassword = async () => {
     },
   })
 }
+
+type LivenessReminderType = 'expiring' | 'expired' | ''
+
+const LIVENESS_VALID_DAYS = 182
+const LIVENESS_REMIND_BEFORE_DAYS = 7
+
+const isLivenessReminderVisible = ref(false)
+const livenessReminderType = ref<LivenessReminderType>('')
+const livenessExpireDateText = ref('')
+
+const checkLivenessReminder = (userInfo: Record<string, any>): boolean => {
+  if (userStore.hasShownLivenessReminder) return false
+
+  const { faceAuthTime, faceExpireTime } = userInfo || {}
+
+  if (!faceAuthTime && !faceExpireTime) return false
+
+  const expireTime = faceExpireTime
+    ? dayjs(faceExpireTime)
+    : dayjs(faceAuthTime).add(LIVENESS_VALID_DAYS, 'day')
+
+  if (!expireTime.isValid()) return false
+
+  const now = dayjs()
+  const remindStartTime = expireTime.subtract(LIVENESS_REMIND_BEFORE_DAYS, 'day')
+
+  if (now.isSame(expireTime) || now.isAfter(expireTime)) {
+    openLivenessReminder('expired', expireTime)
+    return true
+  }
+
+  if (now.isSame(remindStartTime) || now.isAfter(remindStartTime)) {
+    openLivenessReminder('expiring', expireTime)
+    return true
+  }
+
+  return false
+}
+
+const openLivenessReminder = (type: Exclude<LivenessReminderType, ''>, expireTime: dayjs.Dayjs) => {
+  livenessReminderType.value = type
+  livenessExpireDateText.value = expireTime.format('YYYY年MM月DD日')
+  isLivenessReminderVisible.value = true
+
+  // 关键:弹出即标记,避免关闭后 onShow 再次弹
+  userStore.markLivenessReminderShown()
+}
 </script>
 
 <style lang="scss" scoped>

+ 38 - 5
src/stores/modules/user.ts

@@ -20,6 +20,12 @@ interface UserState {
   currentUserInfoIndex?: number
   userInfoList: UserInfoItem[]
   isBlacklisted?: boolean
+
+  /**
+   * 活体验证提醒:本次登录是否已经展示过
+   * 不持久化,避免变成永久只展示一次
+   */
+  hasShownLivenessReminder: boolean
 }
 
 export const useUserStore = defineStore('user', {
@@ -28,40 +34,51 @@ export const useUserStore = defineStore('user', {
     currentUserInfoIndex: 0,
     userInfoList: [],
     isBlacklisted: false,
+    hasShownLivenessReminder: false,
   }),
+
   getters: {
     isLoggedIn(state): boolean {
       return Boolean(state.access_token)
     },
+
     hasBlacklistRisk(state): boolean {
       return Boolean(state.isBlacklisted)
     },
+
     currentUserInfo(state): UserInfoItem | undefined {
       const index = state.currentUserInfoIndex
       if (index == null) return undefined
       return state.userInfoList[index]
     },
   },
+
   actions: {
     // 登录
     async login(payload: LoginRequest): Promise<LoginActionResult> {
       const res = await getTokenBySmsApi(payload)
-      // 登录失败(业务失败)
+
       if (!res.access_token) {
         return {
           success: false,
           message: res.msg || '登录失败',
         }
       }
-      // 登录成功
+
       this.access_token = res.access_token
+
+      // 登录成功后立即重置,保证本次登录重新具备弹窗资格
+      this.resetLivenessReminderShown()
+
       resetUnauthorizedHandled()
-      // 拉取用户信息
+
       await this.getUserInfoByCode()
+
       return {
         success: true,
       }
     },
+
     async getUserInfoByCode(): Promise<UserInfoItem | undefined> {
       const wxRes = await getWXLoginCode()
       if (!wxRes?.code) return undefined
@@ -81,8 +98,8 @@ export const useUserStore = defineStore('user', {
         return undefined
       }
 
-      // 黑名单校验(不阻塞主流程)
       const { phone, idCardNumber } = currentUserInfo
+
       if (phone && idCardNumber) {
         this.fetchUserBlacklistStatus({
           phoneNumber: phone,
@@ -95,19 +112,35 @@ export const useUserStore = defineStore('user', {
 
     async fetchUserBlacklistStatus(payload: CheckBlacklistRequest) {
       const res = await checkBlacklistApi(payload)
-      if (res.code == 0) {
+
+      if (res.code === 0) {
         const { idCardNumberStatus, phoneNumberStatus } = res.data
         this.isBlacklisted = idCardNumberStatus || phoneNumberStatus
       }
     },
+
     setCurrentUserInfoIndex(index: number) {
+      if (this.currentUserInfoIndex === index) return
+
       this.currentUserInfoIndex = index
+
+      // 切换身份后,活体状态需要重新判断
+      this.resetLivenessReminderShown()
+    },
+
+    markLivenessReminderShown() {
+      this.hasShownLivenessReminder = true
+    },
+
+    resetLivenessReminderShown() {
+      this.hasShownLivenessReminder = false
     },
 
     logout() {
       this.$reset()
     },
   },
+
   persist: {
     key: 'user-store',
     storage: uniStorage,