This commit is contained in:
@@ -5,36 +5,42 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<template v-for="(plan, index) in plans" :key="plan.id">
|
<template v-for="(plan, index) in plans" :key="plan.id">
|
||||||
<!-- Special Trial Card -->
|
<!-- Activity Price Card -->
|
||||||
<div
|
<div
|
||||||
v-if="index === 0 && trialPlan"
|
v-if="hasActivityPrice(plan, index)"
|
||||||
@click="$emit('select', trialPlan.id)"
|
@click="$emit('select', activityPlan(plan, index).id)"
|
||||||
class="relative h-[126px] cursor-pointer overflow-hidden rounded-[40px] border-4 border-black bg-[#A8FF53] transition-all"
|
class="relative h-[126px] cursor-pointer overflow-hidden rounded-[40px] border-4 border-black bg-[#A8FF53] transition-all"
|
||||||
>
|
>
|
||||||
<!-- Left content (Original Plan but dimmed/struck) -->
|
<!-- Left content (Original Plan but dimmed/struck) -->
|
||||||
<div class="py-4 pl-[45px] opacity-60">
|
<div class="py-4 pl-[25px] opacity-60">
|
||||||
<div class="text-2xl font-bold text-black">{{ plan.days }}天</div>
|
<div class="text-2xl font-bold text-black">{{ plan.days }}天</div>
|
||||||
<div class="relative w-fit text-[40px] leading-none font-semibold text-black">
|
<div class="relative w-fit text-[40px] leading-none font-semibold text-black">
|
||||||
${{ plan.price }}
|
${{ plan.price }}
|
||||||
<div class="absolute top-1/2 left-0 h-1 w-full -translate-y-1/2 bg-black/60"></div>
|
<div class="absolute top-1/2 left-0 h-1.5 w-full -translate-y-1/2 bg-black/60"></div>
|
||||||
</div>
|
</div>
|
||||||
<div class="text-sm text-black">约${{ plan.daily }}/天</div>
|
<div class="text-sm text-black">约${{ plan.daily }}/天</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Right overlay (Trial Info) -->
|
<!-- Right overlay (Activity Info) -->
|
||||||
<div
|
<div
|
||||||
class="absolute top-0 right-0 bottom-0 flex h-[120px] w-[126px] flex-col justify-center rounded-bl-[40px] bg-black/60 text-[#ADFF5B]"
|
class="absolute top-0 right-0 bottom-0 flex h-[120px] w-[126px] flex-col justify-center rounded-bl-[40px] bg-black/60 text-[#ADFF5B]"
|
||||||
>
|
>
|
||||||
<div class="ml-[4px] flex h-full w-full flex-col rounded-bl-[40px] bg-black">
|
<div class="ml-[4px] flex h-full w-full flex-col rounded-bl-[40px] bg-black">
|
||||||
<div class="pt-2 pl-1">
|
<div class="pt-1.5 pl-1 leading-[1.05]">
|
||||||
<div class="text-lg font-bold">新客尝鲜价</div>
|
<div class="text-[14px] font-bold">{{ activityTitle(plan, index) }}</div>
|
||||||
<div class="mb-1 text-xs">{{ trialCountdown }}</div>
|
<div class="mb-0.5 text-[10px]" v-if="activityCountdown(plan, index)">
|
||||||
|
{{ activityCountdown(plan, index) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div
|
<div
|
||||||
class="flex h-full flex-1 items-center justify-center rounded-bl-[40px] border-4 border-black bg-[#ADFF5B] text-4xl font-semibold"
|
class="flex h-full flex-1 items-center justify-center rounded-bl-[40px] border-4 border-black bg-[#ADFF5B] text-[32px] leading-none font-semibold"
|
||||||
:class="isTrialSelected ? 'bg-black text-white' : 'bg-[#ADFF5B] text-black'"
|
:class="
|
||||||
|
isActivitySelected(plan, index)
|
||||||
|
? 'bg-black text-white'
|
||||||
|
: 'bg-[#ADFF5B] text-black'
|
||||||
|
"
|
||||||
>
|
>
|
||||||
${{ trialPlan.price }}
|
${{ activityPrice(plan, index) }}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -51,7 +57,7 @@
|
|||||||
>
|
>
|
||||||
<div class="flex items-center gap-2 text-2xl font-bold">
|
<div class="flex items-center gap-2 text-2xl font-bold">
|
||||||
<Goods90Icon
|
<Goods90Icon
|
||||||
v-if="plan.days === 90"
|
v-if="plan.days === 90 && !plan.promoPrice"
|
||||||
:class="[currentPlanIndex === index ? 'text-[#ADFF5B]' : 'text-black']"
|
:class="[currentPlanIndex === index ? 'text-[#ADFF5B]' : 'text-black']"
|
||||||
/>
|
/>
|
||||||
{{ plan.days }}天
|
{{ plan.days }}天
|
||||||
@@ -79,7 +85,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
import Goods90Icon from './goods-90-icon.svg'
|
import Goods90Icon from './goods-90-icon.svg'
|
||||||
const props = defineProps({
|
const props = defineProps({
|
||||||
plans: Array,
|
plans: Array,
|
||||||
@@ -90,7 +96,70 @@ const props = defineProps({
|
|||||||
})
|
})
|
||||||
defineEmits(['select'])
|
defineEmits(['select'])
|
||||||
|
|
||||||
|
const now = ref(Date.now())
|
||||||
|
const countdownStart = Date.now()
|
||||||
|
let timer = null
|
||||||
|
|
||||||
const isTrialSelected = computed(() => {
|
const isTrialSelected = computed(() => {
|
||||||
return props.trialPlan && props.selectedPlanId === props.trialPlan.id
|
return props.trialPlan && props.selectedPlanId === props.trialPlan.id
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const hasActivityPrice = (plan, index) => {
|
||||||
|
return Boolean(plan?.promoPrice || (index === 0 && props.trialPlan))
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityPlan = (plan, index) => {
|
||||||
|
if (plan?.promoPrice) return plan
|
||||||
|
if (index === 0 && props.trialPlan) return props.trialPlan
|
||||||
|
return plan
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityTitle = (plan, index) => {
|
||||||
|
const promoPlan = activityPlan(plan, index)
|
||||||
|
return promoPlan?.promoTitle || promoPlan?.discount || '新客尝鲜价'
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityPrice = (plan, index) => {
|
||||||
|
const promoPlan = activityPlan(plan, index)
|
||||||
|
return promoPlan?.promoPrice || promoPlan?.price
|
||||||
|
}
|
||||||
|
|
||||||
|
const activityCountdown = (plan, index) => {
|
||||||
|
const promoPlan = activityPlan(plan, index)
|
||||||
|
if (promoPlan?.promoPrice) {
|
||||||
|
return formatPromoCountdown(promoPlan.promoRemainingSeconds)
|
||||||
|
}
|
||||||
|
return props.trialCountdown
|
||||||
|
}
|
||||||
|
|
||||||
|
const isActivitySelected = (plan, index) => {
|
||||||
|
const promoPlan = activityPlan(plan, index)
|
||||||
|
return props.selectedPlanId === promoPlan?.id || (index === 0 && isTrialSelected.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
const formatPromoCountdown = (remainingSeconds) => {
|
||||||
|
if (remainingSeconds === null || remainingSeconds === undefined) return ''
|
||||||
|
|
||||||
|
const elapsedSeconds = Math.floor((now.value - countdownStart) / 1000)
|
||||||
|
const totalSeconds = Math.max(0, Math.floor(remainingSeconds) - elapsedSeconds)
|
||||||
|
if (totalSeconds <= 0) return ''
|
||||||
|
|
||||||
|
const days = Math.floor(totalSeconds / 86400)
|
||||||
|
const hours = Math.floor((totalSeconds % 86400) / 3600)
|
||||||
|
const minutes = Math.floor((totalSeconds % 3600) / 60)
|
||||||
|
const seconds = totalSeconds % 60
|
||||||
|
|
||||||
|
if (days > 0) return `${days}d:${hours}h:${minutes}m后失效`
|
||||||
|
return `${hours}h:${minutes}m:${seconds}s后失效`
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(() => {
|
||||||
|
timer = setInterval(() => {
|
||||||
|
now.value = Date.now()
|
||||||
|
}, 1000)
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (timer) clearInterval(timer)
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|||||||
@@ -72,7 +72,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted } from 'vue'
|
import { ref, computed, onMounted, onUnmounted } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import MobileLayout from './MobileLayout/index.vue'
|
import MobileLayout from './MobileLayout/index.vue'
|
||||||
import DesktopLayout from './DesktopLayout/index.vue'
|
import DesktopLayout from './DesktopLayout/index.vue'
|
||||||
@@ -116,7 +116,11 @@ const updatePlans = () => {
|
|||||||
// First map all plans using the standard logic
|
// First map all plans using the standard logic
|
||||||
const allMappedPlans = mapPlans(rawPlansList.value)
|
const allMappedPlans = mapPlans(rawPlansList.value)
|
||||||
|
|
||||||
if (isTrial.value && allMappedPlans.length > 4) {
|
if (
|
||||||
|
isTrial.value &&
|
||||||
|
allMappedPlans.length > 4 &&
|
||||||
|
!allMappedPlans.some((plan) => plan.promoPrice)
|
||||||
|
) {
|
||||||
// Extract the last item as the trial plan (based on user requirement)
|
// Extract the last item as the trial plan (based on user requirement)
|
||||||
const trial = allMappedPlans.pop()
|
const trial = allMappedPlans.pop()
|
||||||
if (trial) {
|
if (trial) {
|
||||||
@@ -139,13 +143,12 @@ const updatePlans = () => {
|
|||||||
// The remaining plans are displayed in the normal list
|
// The remaining plans are displayed in the normal list
|
||||||
plans.value = allMappedPlans
|
plans.value = allMappedPlans
|
||||||
|
|
||||||
// Fallback selection if trial is not active and no plan selected
|
// Fallback selection if no valid plan is selected
|
||||||
if (
|
const hasSelectedPlan =
|
||||||
!isTrial.value &&
|
plans.value.some((p) => p.id === selectedPlanId.value) ||
|
||||||
plans.value.length > 0 &&
|
(trialPlan.value && trialPlan.value.id === selectedPlanId.value)
|
||||||
!plans.value.find((p) => p.id === selectedPlanId.value)
|
if (plans.value.length > 0 && !hasSelectedPlan) {
|
||||||
) {
|
selectedPlanId.value = trialPlan.value?.id || plans.value[0].id
|
||||||
selectedPlanId.value = plans.value[0].id
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -197,6 +200,10 @@ const mapPlans = (apiList: any[]) => {
|
|||||||
const totalDays = opt.quantity * unitDays
|
const totalDays = opt.quantity * unitDays
|
||||||
const ratioToPay = opt.discount === 0 || opt.discount === undefined ? 100 : opt.discount
|
const ratioToPay = opt.discount === 0 || opt.discount === undefined ? 100 : opt.discount
|
||||||
const totalPrice = (item.unit_price / 100) * opt.quantity * (ratioToPay / 100)
|
const totalPrice = (item.unit_price / 100) * opt.quantity * (ratioToPay / 100)
|
||||||
|
const promo = opt.promo || {}
|
||||||
|
const promoPriceCents = getNumber(promo.promo_price ?? opt.promo_price)
|
||||||
|
const promoRemainingSeconds = getPromoRemainingSeconds(promo, opt)
|
||||||
|
const hasPromoPrice = typeof promoPriceCents === 'number' && promoPriceCents > 0
|
||||||
|
|
||||||
flattened.push({
|
flattened.push({
|
||||||
id: `${item.id}-${opt.quantity}`,
|
id: `${item.id}-${opt.quantity}`,
|
||||||
@@ -204,6 +211,10 @@ const mapPlans = (apiList: any[]) => {
|
|||||||
quantity: opt.quantity,
|
quantity: opt.quantity,
|
||||||
days: totalDays,
|
days: totalDays,
|
||||||
price: totalPrice.toFixed(2),
|
price: totalPrice.toFixed(2),
|
||||||
|
promoPrice: hasPromoPrice ? (promoPriceCents / 100).toFixed(2) : '',
|
||||||
|
promoPriceCents: hasPromoPrice ? promoPriceCents : null,
|
||||||
|
promoTitle: hasPromoPrice ? promo.rule_name || opt.promo_title || '新客尝鲜价' : '',
|
||||||
|
promoRemainingSeconds,
|
||||||
daily: (totalPrice / totalDays).toFixed(2),
|
daily: (totalPrice / totalDays).toFixed(2),
|
||||||
discount:
|
discount:
|
||||||
globalIndex > 0 && ratioToPay < 100 ? `${Math.ceil(100 - ratioToPay)}% off` : '',
|
globalIndex > 0 && ratioToPay < 100 ? `${Math.ceil(100 - ratioToPay)}% off` : '',
|
||||||
@@ -215,14 +226,47 @@ const mapPlans = (apiList: any[]) => {
|
|||||||
return flattened
|
return flattened
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getNumber = (value: any) => {
|
||||||
|
if (value === null || value === undefined || value === '') return null
|
||||||
|
const numberValue = Number(value)
|
||||||
|
return Number.isFinite(numberValue) ? numberValue : null
|
||||||
|
}
|
||||||
|
|
||||||
|
const getPromoRemainingSeconds = (promo: any, opt: any) => {
|
||||||
|
const directSeconds = getNumber(
|
||||||
|
promo.remaining_seconds ??
|
||||||
|
promo.remaining_time ??
|
||||||
|
promo.remain_seconds ??
|
||||||
|
promo.remain_time ??
|
||||||
|
opt.remaining_seconds ??
|
||||||
|
opt.remaining_time,
|
||||||
|
)
|
||||||
|
if (typeof directSeconds === 'number') return Math.max(0, Math.floor(directSeconds))
|
||||||
|
|
||||||
|
const expiresAt = getNumber(promo.expires_at ?? opt.expires_at)
|
||||||
|
if (!expiresAt) return null
|
||||||
|
|
||||||
|
const expiresAtMs = expiresAt > 10000000000 ? expiresAt : expiresAt * 1000
|
||||||
|
return Math.max(0, Math.floor((expiresAtMs - Date.now()) / 1000))
|
||||||
|
}
|
||||||
|
|
||||||
// --- Handlers ---
|
// --- Handlers ---
|
||||||
const activePlan = computed(() => {
|
const activePlan = computed(() => {
|
||||||
if (trialPlan.value && selectedPlanId.value === trialPlan.value.id) {
|
if (trialPlan.value && selectedPlanId.value === trialPlan.value.id) {
|
||||||
return trialPlan.value
|
return getPayablePlan(trialPlan.value)
|
||||||
}
|
}
|
||||||
return plans.value.find((p) => p.id === selectedPlanId.value)
|
return getPayablePlan(plans.value.find((p) => p.id === selectedPlanId.value))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const getPayablePlan = (plan: any) => {
|
||||||
|
if (!plan) return plan
|
||||||
|
if (!plan.promoPrice) return plan
|
||||||
|
return {
|
||||||
|
...plan,
|
||||||
|
price: plan.promoPrice,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const handlePlanSelect = (id: string) => {
|
const handlePlanSelect = (id: string) => {
|
||||||
selectedPlanId.value = id
|
selectedPlanId.value = id
|
||||||
}
|
}
|
||||||
@@ -231,7 +275,7 @@ const handlePay = async (methodId: number | string) => {
|
|||||||
if (isPaying.value) return
|
if (isPaying.value) return
|
||||||
|
|
||||||
// 1. 查找套餐并校验
|
// 1. 查找套餐并校验
|
||||||
const plan = plans.value.find((p) => p.id === selectedPlanId.value)
|
const plan = activePlan.value
|
||||||
if (!plan) {
|
if (!plan) {
|
||||||
toast.error('请选择有效的订阅套餐')
|
toast.error('请选择有效的订阅套餐')
|
||||||
return
|
return
|
||||||
@@ -253,6 +297,7 @@ const handlePay = async (methodId: number | string) => {
|
|||||||
subscribe_id: plan.planId,
|
subscribe_id: plan.planId,
|
||||||
quantity: plan.quantity,
|
quantity: plan.quantity,
|
||||||
payment: methodId,
|
payment: methodId,
|
||||||
|
...(plan.promoPriceCents ? { promo_price: plan.promoPriceCents } : {}),
|
||||||
...(isRenewal && { user_subscribe_id: existingSub.id }), // 仅续费时添加此字段
|
...(isRenewal && { user_subscribe_id: existingSub.id }), // 仅续费时添加此字段
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -366,7 +411,7 @@ onMounted(() => {
|
|||||||
init()
|
init()
|
||||||
const orderNo = (route.query.order_no as string) || localStorage.getItem('pending_order_no')
|
const orderNo = (route.query.order_no as string) || localStorage.getItem('pending_order_no')
|
||||||
if (orderNo) {
|
if (orderNo) {
|
||||||
request.get('/api/v1/public/order/detail', { order_no: orderNo }).then((data) => {
|
request.get('/api/v1/public/order/detail', { order_no: orderNo }).then((data: any) => {
|
||||||
if (data.status === 1 || data.status === 5 || data.status === 2) {
|
if (data.status === 1 || data.status === 5 || data.status === 2) {
|
||||||
orderStatusDialogRef.value?.show(orderNo)
|
orderStatusDialogRef.value?.show(orderNo)
|
||||||
}
|
}
|
||||||
@@ -374,6 +419,10 @@ onMounted(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (countdownTimer) clearInterval(countdownTimer)
|
||||||
|
})
|
||||||
|
|
||||||
function handleStatusClose() {
|
function handleStatusClose() {
|
||||||
localStorage.removeItem('pending_order_no')
|
localStorage.removeItem('pending_order_no')
|
||||||
// 清除 URL 中的 order_no,防止刷新再次弹出
|
// 清除 URL 中的 order_no,防止刷新再次弹出
|
||||||
|
|||||||
Reference in New Issue
Block a user