reserveCard.vue 8.13 KB
<!--
 * @Date: 2024-01-24 16:38:13
 * @LastEditors: hookehuyr hookehuyr@gmail.com
 * @LastEditTime: 2026-01-16 19:54:03
 * @FilePath: /xyxBooking-weapp/src/components/reserveCard.vue
 * @Description: 预约记录卡组件
-->
<template>
  <view class="booking-list-item" @tap="goToDetail(reserve_info)">
    <view class="booking-list-item-header">
      <view>{{ reserve_info.booking_time }}</view>
      <view v-if="is_offline" class="status offline">离线</view>
      <view v-else :class="[status_info.key, 'status']">{{ status_info.value }}</view>
    </view>
    <view class="booking-list-item-body">
      <view class="booking-num">
        <view class="num-body van-ellipsis">预约人数:<text>{{ reserve_info.total_qty }} 人</text>&nbsp;<text>({{ reserve_info.person_name }})</text></view>
        <view v-if="(reserve_info.status === CodeStatus.SUCCESS || reserve_info.status === CodeStatus.USED || reserve_info.status === CodeStatus.CANCEL)">
          <IconFont name="rect-right" />
        </view>
      </view>
      <view class="booking-price">支付金额:<text>¥ {{ reserve_info.total_amt }}</text></view>
      <view class="booking-time">下单时间:<text>{{ reserve_info.order_time }}</text></view>
    </view>
    <view v-if="is_pay_pending" class="booking-list-item-footer" @tap.stop>
      <view v-if="countdown_seconds > 0" class="countdown">剩余支付时间:{{ countdown_text }}</view>
      <view v-else class="countdown timeout">支付已超时</view>
      <view v-if="countdown_seconds > 0" class="repay-btn" @tap.stop="onRepay">重新支付</view>
    </view>
  </view>
</template>

<script setup>
import { computed, ref, watch, onUnmounted } from 'vue'
import Taro from '@tarojs/taro'
import { IconFont } from '@nutui/icons-vue-taro'
import { useGo } from '@/hooks/useGo'
import { wechat_pay } from '@/utils/wechatPay'

const go = useGo();

const props = defineProps({
  data: {
    type: Object,
    default: () => ({}),
  },
  detail_path: {
    type: String,
    default: '/bookingDetail',
  },
  is_offline: {
    type: Boolean,
    default: false,
  },
});

const reserve_info = computed(() => props.data);

const is_offline = computed(() => props.is_offline);

const CodeStatus = {
  APPLY: '1',
  PAYING: '2',
  SUCCESS: '3',
  CANCEL: '5',
  CANCELED: '7',
  USED: '9',
  REFUNDING: '11'
}

/**
 * 是否支付待处理状态
 */

const is_pay_pending = computed(() => {
  if (is_offline.value) return false
  return reserve_info.value?.status === CodeStatus.APPLY && !!reserve_info.value?.pay_id
})

const countdown_seconds = ref(0)

const format_two_digits = (n) => {
  const num = Number(n) || 0
  return num < 10 ? `0${num}` : String(num)
}

const countdown_text = computed(() => {
  const seconds = Number(countdown_seconds.value) || 0
  const minutes = Math.floor(seconds / 60)
  const remain = seconds % 60
  return `${format_two_digits(minutes)}:${format_two_digits(remain)}`
})

const parse_created_time_ms = (created_time) => {
  const raw = String(created_time || '')
  if (!raw) return 0
  const fixed = raw.replace(/-/g, '/')
  const date = new Date(fixed)
  const time = date.getTime()
  return Number.isFinite(time) ? time : 0
}

let countdown_timer = null
/**
 * 停止倒计时
 */

const stop_countdown = () => {
  if (countdown_timer) {
    clearInterval(countdown_timer)
    countdown_timer = null
  }
}

const update_countdown = () => {
  const start_ms = parse_created_time_ms(reserve_info.value?.created_time)
  if (!start_ms) {
    countdown_seconds.value = 0
    stop_countdown()
    return
  }

  const end_ms = start_ms + 10 * 60 * 1000
  const diff_ms = end_ms - Date.now()
  const seconds = Math.max(0, Math.floor(diff_ms / 1000))
  countdown_seconds.value = seconds

  if (seconds <= 0) {
    stop_countdown()
  }
}

const start_countdown = () => {
  stop_countdown()
  update_countdown()
  if (countdown_seconds.value <= 0) return
  countdown_timer = setInterval(update_countdown, 1000)
}

let is_showing_pay_modal = false
const show_pay_modal = async (content) => {
  if (is_showing_pay_modal) return false
  is_showing_pay_modal = true
  try {
    const res = await Taro.showModal({
      title: '提示',
      content: content || '支付未完成',
      showCancel: true,
      cancelText: '取消',
      confirmText: '继续支付',
    })
    return !!res?.confirm
  } finally {
    is_showing_pay_modal = false
  }
}

/**
 * 重新支付
 */

const onRepay = async () => {
  if (!is_pay_pending.value) return
  if (countdown_seconds.value <= 0) {
    Taro.showToast({ title: '支付已超时', icon: 'none' })
    return
  }

  let should_continue = true
  while (should_continue) {
    const pay_id = reserve_info.value?.pay_id
    const pay_res = await wechat_pay({ pay_id })
    if (pay_res && pay_res.code == 1) {
      go('/success', { pay_id })
      return
    }
    should_continue = await show_pay_modal(pay_res?.msg || '支付未完成')
  }
}

const formatStatus = (status) => {
  switch (status) {
    case CodeStatus.APPLY:
      return {
        key: 'cancel',
        value: '待支付'
      }
    case CodeStatus.PAYING:
      return {
        key: 'success',
        value: '支付中'
      }
    case CodeStatus.SUCCESS:
      return {
        key: 'success',
        value: '预约成功'
      }
    case CodeStatus.CANCEL:
      return {
        key: 'cancel',
        value: '已取消'
      }
    case CodeStatus.CANCELED:
      return {
        key: 'cancel',
        value: '已取消'
      }
    case CodeStatus.USED:
      return {
        key: 'used',
        value: '已使用'
      }
    case CodeStatus.REFUNDING:
      return {
        key: 'cancel',
        value: '退款中'
      }
    default:
        return { key: '', value: '' }
  }
}

const status_info = computed(() => {
    return formatStatus(reserve_info.value?.status) || { key: '', value: '' }
})

const goToDetail = (item) => {
    // 只有成功、已使用、已取消(退款成功)才跳转详情
  if (item.status === CodeStatus.SUCCESS || item.status === CodeStatus.USED || item.status === CodeStatus.CANCEL) {
    go(props.detail_path, { pay_id: item.pay_id });
  }
}

/**
 * 监听支付待处理状态变化
 */

watch(is_pay_pending, (val) => {
  if (val) {
    start_countdown()
  } else {
    countdown_seconds.value = 0
    stop_countdown()
  }
}, { immediate: true })

onUnmounted(() => {
  stop_countdown()
})
</script>

<style lang="less">
.booking-list-item {
  background-color: #FFF;
  border-radius: 16rpx;
  padding: 32rpx;
  margin-bottom: 32rpx;
  box-shadow: 0 0 30rpx 0 rgba(106,106,106,0.1);

  .booking-list-item-header {
    display: flex;
    justify-content: space-between;
    align-items: center;
    padding-bottom: 16rpx;
    border-bottom: 2rpx dashed #E6E6E6;
    margin-bottom: 16rpx;
    font-size: 32rpx;
    font-weight: bold;
    color: #333;

    .status {
        font-size: 27rpx;
        font-weight: normal;
        padding: 4rpx 12rpx;
        border-radius: 8rpx;

        &.offline {
            color: #999;
            background-color: #EEE;
        }

        &.success {
            color: #A67939;
            background-color: #FBEEDC;
        }
        &.cancel {
            color: #999;
            background-color: #EEE;
        }
        &.used {
            color: #477F3D;
            background-color: #E5EFE3;
        }
    }
  }

  .booking-list-item-body {
    .booking-num {
        display: flex;
        justify-content: space-between;
        align-items: center;
        margin-bottom: 16rpx;
        color: #666;

        .num-body {
            span, text {
                color: #A67939;
                font-weight: bold;
            }
        }
    }
    .booking-price, .booking-time {
        color: #999;
        font-size: 29rpx;
        margin-bottom: 10rpx;
        text {
            color: #333;
        }
    }
  }

  .booking-list-item-footer {
    display: flex;
    justify-content: space-between;
    align-items: center;
    margin-top: 16rpx;

    .countdown {
      color: #A67939;
      font-size: 28rpx;

      &.timeout {
        color: #999;
      }
    }

    .repay-btn {
      padding: 8rpx 20rpx;
      border-radius: 12rpx;
      background-color: #A67939;
      color: #FFF;
      font-size: 28rpx;
    }
  }
}
</style>