index.vue 8.84 KB
<template>
  <view class="scan-checkin-detail-page">
    <view class="scan-checkin-detail-cover">
      <nut-swiper
        class="scan-checkin-detail-cover-swiper"
        :init-page="0"
        :pagination-visible="detail.banners.length > 1"
        pagination-color="#ffffff"
        pagination-unselected-color="rgba(255, 255, 255, 0.5)"
        :loop="detail.banners.length > 1"
        :auto-play="detail.banners.length > 1 ? 3000 : 0"
      >
        <nut-swiper-item v-for="(banner, index) in detail.banners" :key="`${banner}-${index}`">
          <image class="scan-checkin-detail-cover-image" :src="banner" mode="aspectFill" />
        </nut-swiper-item>
      </nut-swiper>
    </view>

    <view class="scan-checkin-detail-card">
      <view class="scan-checkin-detail-heading">
        <text class="scan-checkin-detail-title">{{ detail.title }}</text>
        <view class="scan-checkin-detail-status" :class="detail.isChecked ? 'done' : 'pending'">
          {{ detail.isChecked ? '已打卡' : '未打卡' }}
        </view>
      </view>

      <text class="scan-checkin-detail-subtitle">{{ detail.guideText }}</text>

      <view class="scan-checkin-detail-section">
        <view class="scan-checkin-detail-section-header">
          <text class="scan-checkin-detail-section-title">{{ detail.discountTitle }}</text>
        </view>
        <view class="scan-checkin-detail-content">
          <RichTextRenderer :content="normalizedRichTextContent" />
        </view>
      </view>
    </view>

    <view class="scan-checkin-detail-button-wrap">
      <nut-button
        type="primary"
        class="scan-checkin-detail-button"
        color="#DF7750"
        :loading="scanSubmitting"
        @click="handleScanCheckin"
      >
        扫码打卡
      </nut-button>
    </view>
  </view>
</template>

<script setup>
import { reactive, computed } from 'vue'
import Taro, { useLoad } from '@tarojs/taro'
import './index.less'
import RichTextRenderer from '@/components/RichTextRenderer.vue'
import { getCurrentPageFullPath } from '@/utils/authRedirect'
import { getMyFamiliesAPI } from '@/api/family'
import { getScanStageDetailAPI, submitScanCheckinAPI } from '@/api/map'
import { verifyCheckinRangeWithCurrentLocation } from '@/utils/checkinLocation'
import { parseScanCheckinParams } from '@/utils/scanCheckin'

const defaultCover =
  'https://cdn.ipadbiz.cn/lls_prog/images/check_detail_img.png?imageMogr2/strip/quality/60'

const detail = reactive({
  activityId: '',
  id: '',
  regSource: '',
  regStageId: '',
  returnUrl: '',
  banners: [defaultCover],
  title: '',
  guideText: '',
  discountTitle: '打卡点专属优惠',
  discountContentRaw: '',
  geoEnabled: false,
  centerLng: null,
  centerLat: null,
  radiusMeters: null,
  isChecked: false,
  lastScanCode: '',
  scanSubmitting: false,
})

const scanSubmitting = computed(() => detail.scanSubmitting === true)

const normalizedRichTextContent = computed(() => {
  const content = detail.discountContentRaw

  if (!content) {
    return ''
  }

  if (Array.isArray(content)) {
    return content
      .map(
        item =>
          `<p style="margin: 0 0 20rpx; line-height: 1.8; color: #4b5563; font-size: 30rpx;">${item}</p>`
      )
      .join('')
  }

  let formattedContent = content

  formattedContent = formattedContent.replace(/\n/g, '<br>')
  formattedContent = formattedContent.replace(
    /<img/g,
    '<img style="max-width:100%;height:auto;display:block;border-radius:16rpx;margin:24rpx 0;"'
  )

  return formattedContent
})

const navigateToWelcome = () => {
  // 扫码入口沿用首页广告位的判断路线:
  // 先判断是否有家庭,没有则先去 Welcome,再由 Welcome 决定补资料/创建家庭/加入家庭。
  const params = new URLSearchParams({
    reg_source: detail.regSource,
    reg_stage_id: detail.regStageId,
    return_url: detail.returnUrl,
  })

  Taro.redirectTo({
    url: `/pages/Welcome/index?${params.toString()}`,
  })
}

const promptNavigateToWelcome = async () => {
  const modalResult = await Taro.showModal({
    title: '温馨提示',
    content: '扫码打卡需要先完善个人信息并加入家庭,完成后才能继续参与。',
    confirmText: '去完善',
    cancelText: '取消',
  })

  if (modalResult.confirm) {
    navigateToWelcome()
  }
}

const handleScanCheckin = async () => {
  detail.scanSubmitting = true

  try {
    const familyResult = await getMyFamiliesAPI()
    const hasFamily = familyResult?.code && familyResult?.data?.families?.length > 0

    if (!hasFamily) {
      detail.scanSubmitting = false
      await promptNavigateToWelcome()
      return
    }

    // 点击扫码时重新静默拉一次当前位置,避免依赖进入页面时的旧定位缓存。
    const rangeCheck = await verifyCheckinRangeWithCurrentLocation({
      geoEnabled: detail.geoEnabled,
      centerLng: detail.centerLng,
      centerLat: detail.centerLat,
      radiusMeters: detail.radiusMeters,
    })

    if (!rangeCheck.allowed) {
      detail.scanSubmitting = false

      const outOfRangeTitle =
        rangeCheck.reason === 'out_of_range'
          ? '未在打卡范围内,请前往指定位置后再扫码打卡'
          : '获取当前位置失败,请确认定位权限后重试'

      Taro.showToast({
        title: outOfRangeTitle,
        icon: 'none',
        duration: 3000,
      })
      return
    }

    const scanResult = await Taro.scanCode({
      onlyFromCamera: false,
      scanType: ['qrCode', 'barCode'],
    })

    const scannedCode = scanResult.result || ''

    if (!scannedCode) {
      Taro.showToast({
        title: '未识别到扫码结果',
        icon: 'none',
      })
      return
    }

    // 打卡提交参数以二维码里携带的内容为准,当前页面参数只用于展示和后续列表跳转。
    const parsedScanParams = parseScanCheckinParams(scannedCode)
    const submitActivityId = parsedScanParams.activityId
    const submitDetailId = parsedScanParams.detailId

    if (!submitActivityId || !submitDetailId) {
      Taro.showToast({
        title: '二维码缺少打卡参数',
        icon: 'none',
      })
      return
    }

    const submitResult = await submitScanCheckinAPI({
      activity_id: submitActivityId,
      detail_id: submitDetailId,
    })

    if (submitResult.code === 1) {
      detail.isChecked = true
      detail.lastScanCode = scannedCode

      const modalResult = await Taro.showModal({
        title: '打卡成功',
        content: '您已完成当前扫码打卡,可前往列表查看状态。',
        showCancel: false,
        confirmText: '查看列表',
      })

      if (modalResult.confirm) {
        const params = new URLSearchParams({
          activityId: detail.activityId,
        })

        // 用户确认“查看列表”后回到列表页,方便继续处理同一活动下的其他关卡。
        Taro.redirectTo({
          url: `/pages/ScanCheckinList/index?${params.toString()}`,
        })
      }
      return
    }

    Taro.showToast({
      title: submitResult.msg || '提交失败',
      icon: 'none',
    })
  } catch (error) {
    if (error?.errMsg && error.errMsg.includes('cancel')) {
      return
    }

    console.error('扫码打卡失败:', error)
    Taro.showToast({
      title: '扫码失败,请重试',
      icon: 'none',
    })
  } finally {
    detail.scanSubmitting = false
  }
}

const applyStageDetail = stageDetail => {
  detail.scanSubmitting = false

  // 这里把接口字段统一映射成页面内部字段,避免模板层直接耦合后端命名。
  Object.assign(detail, {
    id: stageDetail.id || '',
    banners:
      Array.isArray(stageDetail.banner) && stageDetail.banner.length > 0
        ? stageDetail.banner
        : [defaultCover],
    title: stageDetail.title || '',
    guideText: stageDetail.note || '',
    discountTitle: stageDetail.discount_title || '打卡点专属优惠',
    discountContentRaw: stageDetail.introduction || '',
    geoEnabled: stageDetail.geo_enabled === true,
    centerLng: stageDetail.center_lng,
    centerLat: stageDetail.center_lat,
    radiusMeters: stageDetail.radius_meters,
    isChecked: stageDetail.is_checked === true,
  })
}

useLoad(options => {
  detail.activityId = options.activityId || options.activity_id || ''
  const detailId = options.detailId || options.id || ''
  detail.regSource = options.reg_source || ''
  detail.regStageId = options.reg_stage_id || ''
  // 当前页路径会透传给补资料页,提交成功后用于回跳续扫。
  detail.returnUrl = `/${getCurrentPageFullPath()}`
  detail.id = detailId

  loadStageDetail()
})

const loadStageDetail = async () => {
  const result = await getScanStageDetailAPI({
    id: detail.id,
  })

  if (result?.code !== 1 || !result?.data) {
    Taro.showToast({
      title: result?.msg || '获取关卡详情失败',
      icon: 'none',
    })
    return
  }

  applyStageDetail(result.data)
}
</script>

<script>
export default {
  name: 'ScanCheckinDetail',
}
</script>