Ver Fonte

完成人脸认证模块

yuanmingze há 2 meses atrás
pai
commit
444cdf7987

+ 1 - 0
components.d.ts

@@ -8,6 +8,7 @@ export {}
 declare module 'vue' {
   export interface GlobalComponents {
     AuthInputItem: typeof import('./src/components/auth/AuthInputItem.vue')['default']
+    BaseAuthDialog: typeof import('./src/components/BaseAuthDialog/index.vue')['default']
     WdActionSheet: typeof import('@wot-ui/ui/components/wd-action-sheet/wd-action-sheet.vue')['default']
     WdButton: typeof import('@wot-ui/ui/components/wd-button/wd-button.vue')['default']
     WdCheckbox: typeof import('@wot-ui/ui/components/wd-checkbox/wd-checkbox.vue')['default']

+ 105 - 0
src/components/BaseAuthDialog/index.vue

@@ -0,0 +1,105 @@
+<template>
+  <view v-if="modelValue" class="base-auth-dialog">
+    <view class="base-auth-dialog__mask" @touchmove.stop.prevent />
+    <view class="base-auth-dialog__panel">
+      <view class="base-auth-dialog__title">
+        {{ title }}
+      </view>
+
+      <view class="base-auth-dialog__content">
+        <slot />
+      </view>
+
+      <button class="base-auth-dialog__confirm" @click="handleConfirm">
+        {{ confirmText }}
+      </button>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+const modelValue = defineModel<boolean>({ default: false })
+
+withDefaults(
+  defineProps<{
+    title?: string
+    confirmText?: string
+  }>(),
+  {
+    title: '提示',
+    confirmText: '确定',
+  }
+)
+
+const emit = defineEmits<{
+  confirm: []
+}>()
+
+const handleConfirm = () => {
+  modelValue.value = false
+  emit('confirm')
+}
+</script>
+
+<style lang="scss" scoped>
+.base-auth-dialog {
+  position: fixed;
+  inset: 0;
+  z-index: 999;
+}
+
+.base-auth-dialog__mask {
+  position: absolute;
+  inset: 0;
+  background: rgba(15, 23, 42, 0.48);
+}
+
+.base-auth-dialog__panel {
+  position: absolute;
+  top: 50%;
+  left: 50%;
+  width: 610rpx;
+  overflow: hidden;
+  border-radius: 28rpx;
+  background: #ffffff;
+  transform: translate(-50%, -50%);
+  box-shadow: 0 28rpx 80rpx rgba(15, 23, 42, 0.18);
+}
+
+.base-auth-dialog__title {
+  padding: 32rpx 36rpx 24rpx;
+  color: #111827;
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+  text-align: center;
+}
+
+.base-auth-dialog__content {
+  min-height: 300rpx;
+  padding: 20rpx 36rpx 40rpx;
+  box-sizing: border-box;
+  color: #4b5563;
+  font-size: 29rpx;
+  line-height: 46rpx;
+}
+
+.base-auth-dialog__confirm {
+  height: 92rpx;
+  margin: 0;
+  border-radius: 0;
+  background: #1677ff;
+  color: #ffffff;
+  font-size: 32rpx;
+  font-weight: 700;
+  line-height: 92rpx;
+}
+
+.base-auth-dialog__confirm::after {
+  border: none;
+}
+
+.base-auth-dialog__confirm:active {
+  opacity: 0.9;
+}
+</style>

+ 1 - 1
src/pages-auth/certification-service/index.vue

@@ -77,7 +77,7 @@ import { useUserStore } from '@/stores/modules/user'
 
 const AUTH_SUCCESS_STATUS = 1
 
-const IDENTITY_AUTH_URL = '/pages-sub-verify/auth/identity'
+const IDENTITY_AUTH_URL = '/pages-auth/identity/index'
 const BANK_AUTH_URL = '/pages-sub-verify/auth/bank'
 
 const userStore = useUserStore()

+ 293 - 0
src/pages-auth/face/index.vue

@@ -0,0 +1,293 @@
+<template>
+  <view class="face-auth">
+    <web-view
+      v-if="faceUrl"
+      :src="faceUrl"
+      class="face-auth__webview"
+      @message="handleMessage"
+      @error="handleWebviewError"
+    />
+
+    <view v-if="loading" class="face-auth__loading">
+      <view class="loading-card">
+        <view class="loading-icon" />
+        <text class="loading-title">正在加载认证页面</text>
+        <text class="loading-desc">请勿关闭页面或重复操作</text>
+      </view>
+    </view>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { ref } from 'vue'
+
+import { onLoad, onUnload } from '@dcloudio/uni-app'
+
+import { bindBaseInfoApi, personCheckFaceApi } from '@/services/modules/auth'
+
+const API_SUCCESS_CODE = 0
+const API_PENDING_CODE = 1
+const FACE_AUTH_SUCCESS_CODE = 100000
+const FACE_CHECK_RETRY_COUNT = 3
+const FACE_CHECK_RETRY_INTERVAL = 3000
+const BACK_DELAY = 1200
+const PARAM_ERROR_BACK_DELAY = 1500
+
+const faceUrl = ref('')
+const serialNo = ref('')
+const realName = ref('')
+const cardId = ref('')
+
+const loading = ref(true)
+const checking = ref(false)
+
+let backTimer: ReturnType<typeof setTimeout> | null = null
+
+const safeDecode = (value?: string) => {
+  if (!value) return ''
+
+  try {
+    return decodeURIComponent(value)
+  } catch {
+    return value
+  }
+}
+
+const sleep = (ms: number) => {
+  return new Promise<void>((resolve) => {
+    setTimeout(resolve, ms)
+  })
+}
+
+const navigateBackLater = (delay = BACK_DELAY) => {
+  backTimer = setTimeout(() => {
+    uni.navigateBack({
+      delta: 1,
+    })
+  }, delay)
+}
+
+const backWithToast = (title: string) => {
+  uni.showToast({
+    title,
+    icon: 'none',
+  })
+
+  navigateBackLater(PARAM_ERROR_BACK_DELAY)
+}
+
+const checkFaceResult = async () => {
+  for (let index = 0; index < FACE_CHECK_RETRY_COUNT; index += 1) {
+    const res = await personCheckFaceApi({
+      serialNo: serialNo.value,
+      realName: realName.value,
+      cardId: cardId.value,
+    })
+
+    if (res.code === API_SUCCESS_CODE) {
+      return res
+    }
+
+    if (res.code === API_PENDING_CODE && index < FACE_CHECK_RETRY_COUNT - 1) {
+      await sleep(FACE_CHECK_RETRY_INTERVAL)
+      continue
+    }
+
+    return res
+  }
+
+  return null
+}
+
+const handleMessage = async (event: UniApp.WebViewMessageEvent) => {
+  const [message] = event.detail.data || []
+
+  if (!message) {
+    uni.showToast({
+      title: '认证结果异常',
+      icon: 'none',
+    })
+    return
+  }
+
+  if (checking.value) return
+
+  if (message.code !== FACE_AUTH_SUCCESS_CODE) {
+    uni.showModal({
+      title: '认证失败',
+      content: message.msg || message.message || '活体认证失败',
+      showCancel: false,
+    })
+    return
+  }
+
+  try {
+    checking.value = true
+
+    uni.showLoading({
+      title: '认证结果校验中...',
+      mask: true,
+    })
+
+    const res = await checkFaceResult()
+
+    uni.hideLoading()
+
+    if (res?.code === API_SUCCESS_CODE) {
+      await bindBaseInfoApi({
+        idCardNumber: cardId.value,
+      })
+
+      uni.showToast({
+        title: '认证成功',
+        icon: 'success',
+      })
+
+      navigateBackLater()
+      return
+    }
+
+    uni.showModal({
+      title: '认证失败',
+      content: res?.msg || '结果校验失败',
+      showCancel: false,
+      success: () => {
+        uni.navigateBack({
+          delta: 1,
+        })
+      },
+    })
+  } catch (error) {
+    console.error('活体结果校验异常:', error)
+
+    uni.hideLoading()
+
+    uni.showToast({
+      title: '结果验证失败',
+      icon: 'none',
+    })
+  } finally {
+    checking.value = false
+  }
+}
+
+const handleWebviewError = () => {
+  loading.value = false
+
+  uni.showModal({
+    title: '提示',
+    content: '认证页面加载失败,请检查网络后重试',
+    showCancel: false,
+    success: () => {
+      uni.navigateBack({
+        delta: 1,
+      })
+    },
+  })
+}
+
+onLoad((options) => {
+  const routeSerialNo = safeDecode(options?.serialNo)
+  const routeFaceUrl = safeDecode(options?.faceUrl)
+
+  if (!routeSerialNo) {
+    backWithToast('认证参数错误')
+    return
+  }
+
+  if (!routeFaceUrl) {
+    backWithToast('认证链接错误')
+    return
+  }
+
+  serialNo.value = routeSerialNo
+  faceUrl.value = routeFaceUrl
+  realName.value = safeDecode(options?.realName)
+  cardId.value = safeDecode(options?.cardId)
+  loading.value = false
+})
+
+onUnload(() => {
+  uni.hideLoading()
+
+  checking.value = false
+
+  if (backTimer) {
+    clearTimeout(backTimer)
+    backTimer = null
+  }
+})
+</script>
+
+<style lang="scss" scoped>
+.face-auth {
+  position: relative;
+  width: 100%;
+  height: 100vh;
+  overflow: hidden;
+  background: #f7f8fa;
+}
+
+.face-auth__webview {
+  width: 100%;
+  height: 100vh;
+}
+
+.face-auth__loading {
+  position: fixed;
+  inset: 0;
+  z-index: 999;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  padding: 48rpx;
+  background: linear-gradient(180deg, #eef6ff 0%, #ffffff 100%);
+  box-sizing: border-box;
+}
+
+.loading-card {
+  width: 100%;
+  padding: 56rpx 40rpx;
+  border-radius: 32rpx;
+  background: #ffffff;
+  box-shadow: 0 24rpx 70rpx rgba(15, 23, 42, 0.1);
+  box-sizing: border-box;
+  text-align: center;
+}
+
+.loading-icon {
+  width: 72rpx;
+  height: 72rpx;
+  margin: 0 auto 28rpx;
+  border: 6rpx solid #dbeafe;
+  border-top-color: #1677ff;
+  border-radius: 50%;
+  animation: loading-rotate 0.9s linear infinite;
+}
+
+.loading-title {
+  display: block;
+  color: #111827;
+  font-size: 34rpx;
+  font-weight: 700;
+  line-height: 48rpx;
+}
+
+.loading-desc {
+  display: block;
+  margin-top: 12rpx;
+  color: #6b7280;
+  font-size: 26rpx;
+  line-height: 38rpx;
+}
+
+@keyframes loading-rotate {
+  from {
+    transform: rotate(0deg);
+  }
+
+  to {
+    transform: rotate(360deg);
+  }
+}
+</style>

+ 869 - 0
src/pages-auth/identity/index.vue

@@ -0,0 +1,869 @@
+<template>
+  <view class="identity-auth">
+    <scroll-view scroll-y class="identity-auth__scroll">
+      <view class="identity-auth__hero">
+        <view class="identity-auth__hero-main">
+          <text class="identity-auth__hero-title">身份认证</text>
+          <text class="identity-auth__hero-desc">
+            请上传身份证人像面与国徽面,系统会自动识别证件信息
+          </text>
+        </view>
+
+        <view class="identity-auth__hero-status">
+          {{ ocrFinished ? '已识别' : '待识别' }}
+        </view>
+      </view>
+
+      <view class="section-card">
+        <view class="section-header">
+          <view>
+            <text class="section-title">身份证照片</text>
+            <text class="section-subtitle">支持相册选择或拍照上传</text>
+          </view>
+
+          <text v-if="ocrLoading" class="section-status">OCR识别中</text>
+        </view>
+
+        <view class="upload-grid">
+          <view
+            v-for="item in idCardUploadCards"
+            :key="item.side"
+            class="upload-card"
+            :class="{ 'has-image': item.url }"
+            @click="handleChooseImage(item.side)"
+          >
+            <image v-if="item.url" class="upload-card__image" :src="item.url" mode="aspectFill" />
+
+            <view v-else class="upload-card__empty">
+              <view class="upload-card__icon">+</view>
+              <text class="upload-card__title">点击上传</text>
+              <text class="upload-card__desc">{{ item.title }}</text>
+            </view>
+
+            <view
+              v-if="item.url && !item.loading"
+              class="upload-card__delete"
+              @click.stop="handleDeleteImage(item.side)"
+            >
+              ×
+            </view>
+
+            <view v-if="item.loading" class="upload-card__mask">
+              <text class="upload-card__mask-text">上传中...</text>
+            </view>
+          </view>
+        </view>
+
+        <view class="upload-tip">
+          <text class="upload-tip__dot" />
+          <text class="upload-tip__text">
+            请确保照片完整、清晰、无反光。两张照片上传成功后会自动进行 OCR 识别。
+          </text>
+        </view>
+      </view>
+
+      <view v-if="showOcrInfo" class="section-card">
+        <view class="section-header">
+          <view>
+            <text class="section-title">识别信息</text>
+            <text class="section-subtitle">请核对识别结果是否与身份证一致</text>
+          </view>
+
+          <button class="text-btn" :disabled="isPageBusy" @click="handleOcrRecognize">
+            重新识别
+          </button>
+        </view>
+
+        <view class="ocr-list">
+          <view v-for="item in ocrFieldList" :key="item.key" class="ocr-row">
+            <text class="ocr-row__label">{{ item.label }}</text>
+            <text class="ocr-row__value">{{ formData[item.key] || '-' }}</text>
+          </view>
+        </view>
+      </view>
+    </scroll-view>
+
+    <view class="bottom-bar">
+      <button class="bottom-btn bottom-btn--secondary" :disabled="isPageBusy" @click="handleBack">
+        返回
+      </button>
+
+      <button class="bottom-btn bottom-btn--primary" :disabled="!canSubmit" @click="handleSubmit">
+        {{ submitButtonText }}
+      </button>
+    </view>
+
+    <BaseAuthDialog
+      v-model="phoneAuthFailVisible"
+      title="手机三要素认证未通过"
+      confirm-text="我知道啦"
+      @confirm="handleAuthFailConfirm"
+    >
+      <view class="phone-auth-desc"> 抱歉,您的登录手机号三要素认证未通过! </view>
+
+      <view class="phone-auth-card-text">
+        请确保您的手机号持卡人身份证号与您提供的身份证号一致:
+      </view>
+
+      <view class="phone-auth-card-tip">
+        您可以前往相应手机号所属运营商营业厅办理
+        <text class="phone-auth-highlight">手机号过户</text>
+        业务。
+      </view>
+    </BaseAuthDialog>
+  </view>
+</template>
+
+<script setup lang="ts">
+import { computed, reactive, ref } from 'vue'
+
+import { onLoad } from '@dcloudio/uni-app'
+
+import {
+  gigCertOcrApi,
+  gigCertPersonVerifyFaceApi,
+  gigCertPersonVerifyMobile3Api,
+} from '@/services/modules/auth'
+import type { GigCertOcrResponse } from '@/services/modules/auth/type'
+
+import { useUserStore } from '@/stores/modules/user'
+
+import BaseAuthDialog from '@/components/BaseAuthDialog/index.vue'
+
+const API_SUCCESS_CODE = 0
+const FACE_AUTH_URL = '/pages-auth/face/index'
+const ID_CARD_UPLOAD_URL = `${String(import.meta.env.VITE_BASE_API ?? '').replace(/\/$/, '')}/gig/cert/id-card-image-upload`
+
+type IdCardSide = 'front' | 'back'
+
+interface OcrFormData {
+  realName: string
+  sex: string
+  nation: string
+  born: string
+  address: string
+  idCard: string
+  begin: string
+  end: string
+  department: string
+}
+
+type OcrFieldKey = keyof OcrFormData
+
+const userStore = useUserStore()
+
+const currentUserInfo = computed(() => userStore.currentUserInfo)
+
+const phoneAuthFailVisible = ref(false)
+const uploading = ref(false)
+const ocrLoading = ref(false)
+const ocrFinished = ref(false)
+const submitLoading = ref(false)
+
+const uploadStatus = reactive<Record<IdCardSide, boolean>>({
+  front: false,
+  back: false,
+})
+
+const idCardImages = reactive<Record<IdCardSide, string>>({
+  front: '',
+  back: '',
+})
+
+const formData = reactive<OcrFormData>({
+  realName: '',
+  sex: '',
+  nation: '',
+  born: '',
+  address: '',
+  idCard: '',
+  begin: '',
+  end: '',
+  department: '',
+})
+
+const ocrFieldList: Array<{ key: OcrFieldKey; label: string }> = [
+  { key: 'realName', label: '姓名' },
+  { key: 'sex', label: '性别' },
+  { key: 'nation', label: '民族' },
+  { key: 'born', label: '生日' },
+  { key: 'address', label: '籍贯' },
+  { key: 'idCard', label: '证件号' },
+  { key: 'begin', label: '生效日期' },
+  { key: 'end', label: '截止日期' },
+  { key: 'department', label: '发放机关' },
+]
+
+const mobile = computed(() => currentUserInfo.value?.phone ?? '')
+
+const hasBothImages = computed(() => Boolean(idCardImages.front && idCardImages.back))
+const showOcrInfo = computed(() => ocrFinished.value)
+const isPageBusy = computed(() => uploading.value || ocrLoading.value || submitLoading.value)
+
+const canSubmit = computed(() => {
+  return Boolean(
+    formData.realName &&
+    formData.idCard &&
+    !uploading.value &&
+    !ocrLoading.value &&
+    !submitLoading.value
+  )
+})
+
+const submitButtonText = computed(() => {
+  if (ocrLoading.value) return '识别中...'
+  if (uploading.value) return '上传中...'
+  if (submitLoading.value) return '认证中...'
+  return '去认证'
+})
+
+const idCardUploadCards = computed(() => [
+  {
+    side: 'front' as const,
+    title: '身份证人像面',
+    url: idCardImages.front,
+    loading: uploadStatus.front,
+  },
+  {
+    side: 'back' as const,
+    title: '身份证国徽面',
+    url: idCardImages.back,
+    loading: uploadStatus.back,
+  },
+])
+
+const clearPageData = () => {
+  idCardImages.front = ''
+  idCardImages.back = ''
+
+  uploadStatus.front = false
+  uploadStatus.back = false
+
+  uploading.value = false
+  ocrLoading.value = false
+  ocrFinished.value = false
+  submitLoading.value = false
+  phoneAuthFailVisible.value = false
+
+  clearOcrFormData()
+}
+
+const clearOcrFormData = () => {
+  Object.keys(formData).forEach((key) => {
+    formData[key as OcrFieldKey] = ''
+  })
+}
+
+const resetOcrResult = () => {
+  ocrFinished.value = false
+  clearOcrFormData()
+}
+
+const handleChooseImage = (side: IdCardSide) => {
+  if (isPageBusy.value) return
+
+  uni.chooseImage({
+    count: 1,
+    sizeType: ['compressed'],
+    sourceType: ['album', 'camera'],
+    success: async (res) => {
+      const tempFilePath = res.tempFilePaths?.[0]
+
+      if (!tempFilePath) return
+
+      resetOcrResult()
+      idCardImages[side] = tempFilePath
+
+      let shouldRecognize = false
+
+      try {
+        uploading.value = true
+        uploadStatus[side] = true
+
+        idCardImages[side] = await uploadIdCardImage(tempFilePath)
+        shouldRecognize = hasBothImages.value
+      } catch (error) {
+        idCardImages[side] = ''
+        showToast(getErrorMessage(error, '上传失败,请重试'))
+      } finally {
+        uploading.value = false
+        uploadStatus[side] = false
+      }
+
+      if (shouldRecognize) {
+        await handleOcrRecognize()
+      }
+    },
+  })
+}
+
+const handleDeleteImage = (side: IdCardSide) => {
+  if (isPageBusy.value) return
+
+  idCardImages[side] = ''
+  uploadStatus[side] = false
+
+  resetOcrResult()
+}
+
+const uploadIdCardImage = (filePath: string): Promise<string> => {
+  return new Promise((resolve, reject) => {
+    uni.uploadFile({
+      url: ID_CARD_UPLOAD_URL,
+      filePath,
+      name: 'file',
+      header: {
+        Authorization: getAuthorization(),
+      },
+      success: (res) => {
+        try {
+          const result = JSON.parse(String(res.data)) as {
+            code: number | string
+            msg?: string
+            message?: string
+            data?: string
+          }
+
+          if (Number(result.code) !== API_SUCCESS_CODE) {
+            reject(new Error(result.msg || result.message || '身份证图片上传失败'))
+            return
+          }
+
+          if (!result.data) {
+            reject(new Error('身份证图片上传接口未返回图片地址'))
+            return
+          }
+
+          resolve(result.data)
+        } catch {
+          reject(new Error('身份证图片上传响应解析失败'))
+        }
+      },
+      fail: () => {
+        reject(new Error('身份证图片上传网络异常'))
+      },
+    })
+  })
+}
+
+const handleOcrRecognize = async () => {
+  if (!hasBothImages.value || ocrLoading.value) return
+
+  try {
+    ocrLoading.value = true
+    ocrFinished.value = false
+
+    const res = await gigCertOcrApi({
+      frontUrl: idCardImages.front,
+      backUrl: idCardImages.back,
+    })
+
+    if (Number(res.code) !== API_SUCCESS_CODE) {
+      throw new Error(res.msg || 'OCR识别失败')
+    }
+
+    clearOcrFormData()
+    setOcrFormData(res.data)
+    ocrFinished.value = true
+  } catch (error) {
+    ocrFinished.value = false
+    clearOcrFormData()
+    showToast(getErrorMessage(error, 'OCR识别失败'))
+  } finally {
+    ocrLoading.value = false
+  }
+}
+
+const setOcrFormData = (data: Partial<GigCertOcrResponse> = {}) => {
+  formData.realName = formatOcrValue(data.realName)
+  formData.sex = formatOcrValue(data.sex)
+  formData.nation = formatOcrValue(data.nation)
+  formData.born = formatDate(data.born)
+  formData.address = formatOcrValue(data.address)
+  formData.idCard = formatOcrValue(data.idCard)
+  formData.begin = formatDate(data.begin)
+  formData.end = formatDate(data.end)
+  formData.department = formatOcrValue(data.department)
+}
+
+const formatOcrValue = (value: unknown) => {
+  return value === undefined || value === null ? '' : String(value)
+}
+
+const formatDate = (value: unknown) => {
+  if (!value) return ''
+  return String(value).split(' ')[0]
+}
+
+const handleSubmit = async () => {
+  if (!canSubmit.value) return
+
+  if (!mobile.value) {
+    showToast('未获取到登录手机号')
+    return
+  }
+
+  try {
+    submitLoading.value = true
+
+    const mobile3Res = await gigCertPersonVerifyMobile3Api({
+      mobile: mobile.value,
+      realName: formData.realName,
+      cardId: formData.idCard,
+    })
+
+    if (Number(mobile3Res.code) !== API_SUCCESS_CODE) {
+      phoneAuthFailVisible.value = true
+      return
+    }
+
+    const faceRes = await gigCertPersonVerifyFaceApi({
+      cardId: formData.idCard,
+      realName: formData.realName,
+    })
+
+    if (Number(faceRes.code) !== API_SUCCESS_CODE) {
+      throw new Error(faceRes.msg || '人脸认证初始化失败')
+    }
+
+    const faceData = faceRes.data
+
+    if (!faceData?.serialNo || !faceData.faceUrl) {
+      throw new Error('人脸认证接口返回数据不完整')
+    }
+
+    uni.navigateTo({
+      url: buildFaceAuthUrl({
+        serialNo: faceData.serialNo,
+        faceUrl: faceData.faceUrl,
+        realName: formData.realName,
+        cardId: formData.idCard,
+      }),
+    })
+  } catch (error) {
+    showToast(getErrorMessage(error, '认证失败,请重试'))
+  } finally {
+    submitLoading.value = false
+  }
+}
+
+const buildFaceAuthUrl = (params: {
+  serialNo: string
+  faceUrl: string
+  realName: string
+  cardId: string
+}) => {
+  const query = Object.entries(params)
+    .map(([key, value]) => `${key}=${encodeURIComponent(value)}`)
+    .join('&')
+
+  return `${FACE_AUTH_URL}?${query}`
+}
+
+const handleAuthFailConfirm = () => {
+  phoneAuthFailVisible.value = false
+}
+
+const handleBack = () => {
+  if (isPageBusy.value) return
+  uni.navigateBack()
+}
+
+const getAuthorization = () => {
+  const token = userStore.accessToken || ''
+
+  if (!token) return ''
+
+  return token.startsWith('Bearer ') ? token : `Bearer ${token}`
+}
+
+const getErrorMessage = (error: unknown, fallback: string) => {
+  if (error instanceof Error && error.message) {
+    return error.message
+  }
+
+  if (typeof error === 'object' && error !== null) {
+    const target = error as {
+      msg?: string
+      message?: string
+    }
+
+    return target.msg || target.message || fallback
+  }
+
+  return fallback
+}
+
+const showToast = (title: string) => {
+  uni.showToast({
+    title,
+    icon: 'none',
+  })
+}
+
+onLoad(() => {
+  clearPageData()
+})
+</script>
+
+<style lang="scss" scoped>
+.identity-auth {
+  min-height: 100vh;
+  background: linear-gradient(180deg, #eef6ff 0%, #f7f8fa 420rpx), #f7f8fa;
+}
+
+.identity-auth__scroll {
+  height: 100vh;
+  padding: 28rpx 28rpx 180rpx;
+  box-sizing: border-box;
+}
+
+.identity-auth__hero {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  margin-bottom: 24rpx;
+  padding: 34rpx 32rpx;
+  border-radius: 28rpx;
+  background: linear-gradient(135deg, #1677ff 0%, #69b1ff 100%);
+  box-shadow: 0 18rpx 40rpx rgba(22, 119, 255, 0.18);
+}
+
+.identity-auth__hero-main {
+  flex: 1;
+  padding-right: 24rpx;
+}
+
+.identity-auth__hero-title {
+  display: block;
+  color: #ffffff;
+  font-size: 40rpx;
+  font-weight: 700;
+  line-height: 56rpx;
+}
+
+.identity-auth__hero-desc {
+  display: block;
+  margin-top: 12rpx;
+  color: rgba(255, 255, 255, 0.88);
+  font-size: 26rpx;
+  line-height: 38rpx;
+}
+
+.identity-auth__hero-status {
+  flex-shrink: 0;
+  height: 48rpx;
+  padding: 0 18rpx;
+  border-radius: 999rpx;
+  background: rgba(255, 255, 255, 0.18);
+  color: #ffffff;
+  font-size: 24rpx;
+  line-height: 48rpx;
+}
+
+.section-card {
+  margin-bottom: 24rpx;
+  padding: 30rpx;
+  border-radius: 28rpx;
+  background: #ffffff;
+  box-shadow: 0 10rpx 32rpx rgba(15, 23, 42, 0.06);
+  box-sizing: border-box;
+}
+
+.section-header {
+  display: flex;
+  align-items: flex-start;
+  justify-content: space-between;
+  margin-bottom: 28rpx;
+}
+
+.section-title {
+  display: block;
+  color: #1f2937;
+  font-size: 32rpx;
+  font-weight: 700;
+  line-height: 44rpx;
+}
+
+.section-subtitle {
+  display: block;
+  margin-top: 6rpx;
+  color: #8a94a6;
+  font-size: 24rpx;
+  line-height: 34rpx;
+}
+
+.section-status {
+  flex-shrink: 0;
+  margin-left: 16rpx;
+  padding: 8rpx 16rpx;
+  border-radius: 999rpx;
+  background: #eef6ff;
+  color: #1677ff;
+  font-size: 24rpx;
+  line-height: 32rpx;
+}
+
+.text-btn {
+  height: 52rpx;
+  margin: 0;
+  padding: 0 20rpx;
+  border-radius: 999rpx;
+  background: #eef6ff;
+  color: #1677ff;
+  font-size: 24rpx;
+  line-height: 52rpx;
+}
+
+.text-btn::after {
+  border: none;
+}
+
+.text-btn[disabled] {
+  opacity: 0.45;
+}
+
+.upload-grid {
+  display: flex;
+}
+
+.upload-card {
+  position: relative;
+  flex: 1;
+  height: 238rpx;
+  overflow: hidden;
+  border: 2rpx dashed #cfd8e3;
+  border-radius: 22rpx;
+  background: #f8fafc;
+  box-sizing: border-box;
+}
+
+.upload-card + .upload-card {
+  margin-left: 22rpx;
+}
+
+.upload-card.has-image {
+  border-style: solid;
+  border-color: #e5e7eb;
+  background: #ffffff;
+}
+
+.upload-card__image {
+  width: 100%;
+  height: 100%;
+}
+
+.upload-card__empty {
+  display: flex;
+  height: 100%;
+  flex-direction: column;
+  align-items: center;
+  justify-content: center;
+}
+
+.upload-card__icon {
+  width: 54rpx;
+  height: 54rpx;
+  margin-bottom: 16rpx;
+  border-radius: 50%;
+  background: #e8f3ff;
+  color: #1677ff;
+  font-size: 40rpx;
+  font-weight: 300;
+  line-height: 50rpx;
+  text-align: center;
+}
+
+.upload-card__title {
+  color: #374151;
+  font-size: 28rpx;
+  font-weight: 600;
+  line-height: 40rpx;
+}
+
+.upload-card__desc {
+  margin-top: 4rpx;
+  color: #9ca3af;
+  font-size: 24rpx;
+  line-height: 34rpx;
+}
+
+.upload-card__delete {
+  position: absolute;
+  top: 14rpx;
+  right: 14rpx;
+  z-index: 3;
+  width: 46rpx;
+  height: 46rpx;
+  border-radius: 50%;
+  background: rgba(15, 23, 42, 0.62);
+  color: #ffffff;
+  font-size: 38rpx;
+  line-height: 42rpx;
+  text-align: center;
+}
+
+.upload-card__mask {
+  position: absolute;
+  inset: 0;
+  z-index: 4;
+  display: flex;
+  align-items: center;
+  justify-content: center;
+  background: rgba(15, 23, 42, 0.56);
+}
+
+.upload-card__mask-text {
+  color: #ffffff;
+  font-size: 28rpx;
+  line-height: 40rpx;
+}
+
+.upload-tip {
+  display: flex;
+  align-items: flex-start;
+  margin-top: 24rpx;
+  padding: 20rpx 22rpx;
+  border-radius: 18rpx;
+  background: #f8fafc;
+}
+
+.upload-tip__dot {
+  flex-shrink: 0;
+  width: 10rpx;
+  height: 10rpx;
+  margin-top: 12rpx;
+  margin-right: 12rpx;
+  border-radius: 50%;
+  background: #1677ff;
+}
+
+.upload-tip__text {
+  flex: 1;
+  color: #6b7280;
+  font-size: 24rpx;
+  line-height: 36rpx;
+}
+
+.ocr-list {
+  overflow: hidden;
+  border: 1rpx solid #eef0f3;
+  border-radius: 22rpx;
+}
+
+.ocr-row {
+  display: flex;
+  align-items: flex-start;
+  min-height: 88rpx;
+  padding: 24rpx 26rpx;
+  border-bottom: 1rpx solid #eef0f3;
+  background: #ffffff;
+  box-sizing: border-box;
+}
+
+.ocr-row:last-child {
+  border-bottom: none;
+}
+
+.ocr-row__label {
+  width: 150rpx;
+  flex-shrink: 0;
+  color: #6b7280;
+  font-size: 28rpx;
+  line-height: 40rpx;
+}
+
+.ocr-row__value {
+  flex: 1;
+  color: #111827;
+  font-size: 28rpx;
+  line-height: 40rpx;
+  text-align: right;
+  word-break: break-all;
+}
+
+.bottom-bar {
+  position: fixed;
+  right: 0;
+  bottom: 0;
+  left: 0;
+  z-index: 20;
+  display: flex;
+  gap: 20rpx;
+  padding: 20rpx 28rpx calc(20rpx + env(safe-area-inset-bottom));
+  border-top: 1rpx solid rgba(229, 231, 235, 0.8);
+  background: rgba(255, 255, 255, 0.96);
+  box-sizing: border-box;
+}
+
+.bottom-btn {
+  flex: 1;
+  height: 88rpx;
+  margin: 0;
+  border-radius: 999rpx;
+  font-size: 32rpx;
+  font-weight: 600;
+  line-height: 88rpx;
+}
+
+.bottom-btn::after {
+  border: none;
+}
+
+.bottom-btn--secondary {
+  background: #f3f4f6;
+  color: #4b5563;
+}
+
+.bottom-btn--primary {
+  background: linear-gradient(135deg, #1677ff 0%, #4096ff 100%);
+  color: #ffffff;
+  box-shadow: 0 12rpx 28rpx rgba(22, 119, 255, 0.22);
+}
+
+.bottom-btn[disabled] {
+  opacity: 1;
+}
+
+.bottom-btn--secondary[disabled] {
+  background: #f3f4f6 !important;
+  color: #a1a1aa !important;
+}
+
+.bottom-btn--primary[disabled] {
+  background: #d1d5db !important;
+  color: #ffffff !important;
+  box-shadow: none;
+}
+
+.phone-auth-desc {
+  color: #6b7280;
+  font-size: 29rpx;
+  line-height: 44rpx;
+  text-align: center;
+}
+
+.phone-auth-card-text {
+  margin-top: 28rpx;
+  padding: 28rpx 28rpx 0;
+  border-radius: 22rpx 22rpx 0 0;
+  background: #f8fafc;
+  color: #4b5563;
+  font-size: 29rpx;
+  line-height: 46rpx;
+}
+
+.phone-auth-card-tip {
+  padding: 20rpx 28rpx 30rpx;
+  border-radius: 0 0 22rpx 22rpx;
+  background: #f8fafc;
+  color: #6b7280;
+  font-size: 29rpx;
+  line-height: 46rpx;
+}
+
+.phone-auth-highlight {
+  color: #1677ff;
+  font-weight: 700;
+}
+</style>

+ 35 - 14
src/pages-mine/notification/detail.vue

@@ -12,7 +12,7 @@
     <view v-else class="detail-card">
       <view class="detail-header">
         <view class="icon-box">
-          <wd-icon name="notification" size="24px"></wd-icon>
+          <wd-icon name="notification" size="24px" />
         </view>
 
         <view class="header-content">
@@ -62,15 +62,43 @@ const formatRichTextContent = (html: string): string => {
     .replace(/<style[\s\S]*?<\/style>/gi, '')
     .replace(/<script[\s\S]*?<\/script>/gi, '')
     .replace(/<img\b([^>]*)>/gi, (_match: string, attrs: string) => {
-      const normalizedAttrs = attrs
-        .replace(/\swidth=(['"]).*?\1/gi, '')
-        .replace(/\sheight=(['"]).*?\1/gi, '')
-        .replace(/\sstyle=(['"]).*?\1/gi, '')
+      const normalizedAttrs = normalizeImageAttrs(attrs)
 
-      return `<img${normalizedAttrs} style="max-width:100%;height:auto;display:block;margin:16px auto;" />`
+      return `<img${normalizedAttrs} />`
     })
 }
 
+const normalizeImageAttrs = (attrs: string): string => {
+  const attrsWithoutSize = attrs
+    .replace(/\swidth=(['"]).*?\1/gi, '')
+    .replace(/\sheight=(['"]).*?\1/gi, '')
+    .replace(/\sstyle=(['"]).*?\1/gi, '')
+
+  const attrsWithClass = appendImageClass(attrsWithoutSize)
+
+  return `${attrsWithClass} style="max-width:100%;height:auto;display:block;margin:16px auto;"`
+}
+
+const appendImageClass = (attrs: string): string => {
+  const classMatch = attrs.match(/\sclass=(['"])(.*?)\1/i)
+
+  if (!classMatch) {
+    return `${attrs} class="rich-content__image"`
+  }
+
+  const quote = classMatch[1]
+  const className = classMatch[2]
+
+  if (className.split(/\s+/).includes('rich-content__image')) {
+    return attrs
+  }
+
+  return attrs.replace(
+    /\sclass=(['"])(.*?)\1/i,
+    ` class=${quote}${className} rich-content__image${quote}`
+  )
+}
+
 const getMsgDetail = async (id: number): Promise<void> => {
   loading.value = true
 
@@ -222,14 +250,7 @@ onLoad((options) => {
   font-size: 26rpx;
 }
 
-:deep(rich-text) {
-  max-width: 100%;
-  word-break: break-word;
-  white-space: normal;
-  overflow-wrap: break-word;
-}
-
-:deep(img) {
+:deep(.rich-content__image) {
   max-width: 100%;
   height: auto;
   display: block;

+ 1 - 1
src/pages-task/points-package-record/index.vue

@@ -893,7 +893,7 @@ onLoad((query) => {
     z-index: 2;
     position: absolute;
     right: 30rpx;
-    bottom: 25rpx;
+    top: 70rpx;
     width: 150rpx;
     height: 150rpx;
 

+ 2 - 2
src/pages-task/task-package/list.vue

@@ -334,12 +334,12 @@ const submitReceiveAfterTestPassed = async (item: TaskPackageItem) => {
   const res = await pickupPackageApi({
     pkgId: item.id,
   })
-  if (res.code === 0) {
+  if (res.code === 0 && res.data === 0) {
     toast.success('领取成功')
     removePackageFromList(item.id)
     return
   }
-  if (res.code === 1) {
+  if (res.data === 1) {
     uni.showModal({
       content: res.msg,
       confirmText: '去认证',

+ 14 - 0
src/pages.json

@@ -212,6 +212,20 @@
             "navigationBarTitleText": "认证服务",
             "navigationStyle": "default"
           }
+        },
+        {
+          "path": "identity/index",
+          "style": {
+            "navigationBarTitleText": "身份认证",
+            "navigationStyle": "default"
+          }
+        },
+        {
+          "path": "face/index",
+          "style": {
+            "navigationBarTitleText": "个人人人脸活体认证",
+            "navigationStyle": "default"
+          }
         }
       ]
     }

+ 2 - 2
src/pages/login/index.vue

@@ -103,8 +103,8 @@ const userStore = useUserStore()
 const { title } = projectConfig
 
 const form = reactive({
-  username: '15093284672',
-  password: '284672',
+  username: '17503049515',
+  password: '049515',
   code: '1001',
 })
 

+ 30 - 1
src/services/modules/auth/index.ts

@@ -1,6 +1,35 @@
 import http from '../../index'
-import type { GetAuthInfoResponse } from './type'
+import type {
+  GetAuthInfoResponse,
+  GigCertOcrRequest,
+  GigCertOcrResponse,
+  GigCertPersonVerifyFaceRequest,
+  GigCertPersonVerifyFaceResponse,
+  GigCertPersonVerifyMobile3Request,
+  GigCertPersonVerifyMobile3Response,
+  PersonCheckFaceRequest,
+} from './type'
 
 export const getAuthInfoApi = () => {
   return http.get<GetAuthInfoResponse>(`/admin/api/member/auth/get-auth-info`)
 }
+
+export const gigCertOcrApi = (data: GigCertOcrRequest) => {
+  return http.post<GigCertOcrResponse>(`/gig/cert/ocr`, data)
+}
+
+export const gigCertPersonVerifyMobile3Api = (data: GigCertPersonVerifyMobile3Request) => {
+  return http.post<GigCertPersonVerifyMobile3Response>(`/gig/cert/person-verify-mobile3`, data)
+}
+
+export const gigCertPersonVerifyFaceApi = (data: GigCertPersonVerifyFaceRequest) => {
+  return http.post<GigCertPersonVerifyFaceResponse>(`/gig/cert/person-verify-face`, data)
+}
+
+export const personCheckFaceApi = (data: PersonCheckFaceRequest) => {
+  return http.post(`/gig/cert/person-check-face`, data)
+}
+
+export const bindBaseInfoApi = (data: GigCertPersonVerifyFaceRequest) => {
+  return http.post(`/admin/user-sign-cert/bind-base-info`, data)
+}

+ 59 - 0
src/services/modules/auth/type.d.ts

@@ -40,3 +40,62 @@ export interface LoginAuthInfo {
   authTime: string
   phone: string
 }
+
+export interface GigCertOcrRequest {
+  frontUrl: string
+  backUrl: string
+}
+
+export interface GigCertOcrResponse {
+  address: string
+  back: string
+  backBase64: string | null
+  begin: string
+  born: string
+  department: string
+  end: string
+  front: string
+  frontBase64: string | null
+  idCard: string
+  nation: string
+  realName: string
+  sex: string
+}
+
+export interface GigCertPersonVerifyMobile3Request {
+  mobile: string
+  realName: string
+  cardId: string
+}
+
+export interface GigCertPersonVerifyMobile3Response {
+  faceUrl: string | null
+  identifyUrl: string | null
+  process: string | null
+  result: number
+  serialNo: string
+  type: string
+}
+
+export interface GigCertPersonVerifyFaceRequest {
+  cardId: string
+  realName: string
+}
+
+export interface GigCertPersonVerifyFaceResponse {
+  faceUrl: string | null
+  identifyUrl: string | null
+  process: string | null
+  result: number
+  serialNo: string
+  type: string
+}
+
+export interface PersonCheckFaceRequest {
+  serialNo: string
+  realName: string
+  cardId: string
+}
+export interface BindBaseInfoRequest {
+  idCardNumber: string
+}

+ 1 - 1
src/services/modules/task/package/index.ts

@@ -13,7 +13,7 @@ export const getUserScorePackageListApi = (params: getUserScorePackageListReques
 }
 
 export const pickupPackageApi = (data: pickupPackageRequest) => {
-  return http.post(`/admin/api/pkg/pickup`, data, {
+  return http.post<number>(`/admin/api/pkg/pickup`, data, {
     loading: true,
     loadingText: '提交中...',
   })