提交尝鲜价套餐
All checks were successful
site-dist-deploy / build-and-deploy (push) Successful in 1m44s

This commit is contained in:
speakeloudest 2026-03-20 14:30:23 +02:00
parent a350863a3e
commit fb7e5031c8
7 changed files with 211 additions and 26 deletions

6
env.d.ts vendored
View File

@ -5,3 +5,9 @@ declare module '*.vue' {
const component: DefineComponent<{}, {}, any>
export default component
}
declare module '*.svg?component' {
import type { DefineComponent } from 'vue'
const component: DefineComponent<{}, {}, any>
export default component
}

View File

@ -4,34 +4,93 @@
<p class="mb-4 text-center text-sm font-[100] text-gray-600">*所有套餐均不限流量不限速度</p>
<div class="space-y-4">
<template v-for="(plan, index) in plans" :key="plan.id">
<!-- Special Trial Card -->
<div
v-for="(plan, index) in plans"
:key="plan.id"
v-if="index === 0 && trialPlan"
@click="$emit('select', trialPlan.id)"
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) -->
<div class="py-4 pl-[45px] opacity-60">
<div class="text-2xl font-bold text-black">{{ plan.days }}</div>
<div class="relative w-fit text-[40px] leading-none font-semibold text-black">
${{ plan.price }}
<div class="absolute top-1/2 left-0 h-1 w-full -translate-y-1/2 bg-black/60"></div>
</div>
<div class="text-sm text-black">约${{ plan.daily }}/</div>
</div>
<!-- Right overlay (Trial Info) -->
<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]"
>
<div class="ml-[4px] flex h-full w-full flex-col rounded-bl-[40px] bg-black">
<div class="pt-2 pl-1">
<div class="text-lg font-bold">新客尝鲜价</div>
<div class="mb-1 text-xs">{{ trialCountdown }}</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="isTrialSelected ? 'bg-black text-white' : 'bg-[#ADFF5B] text-black'"
>
${{ trialPlan.price }}
</div>
</div>
</div>
</div>
<!-- Normal Card -->
<div
v-else
@click="$emit('select', plan.id)"
:class="[
'relative h-[126px] cursor-pointer overflow-hidden rounded-[40px] border-4 border-black py-4 pl-[45px] transition-all',
currentPlanIndex === index ? 'bg-black text-white' : 'bg-[#A8FF53] text-black',
]"
>
<div class="text-2xl font-bold">{{ plan.days }}</div>
<div class="flex items-center gap-2 text-2xl font-bold">
<Goods90Icon
v-if="plan.days === 90"
:class="[currentPlanIndex === index ? 'text-[#ADFF5B]' : 'text-black']"
/>
{{ plan.days }}
</div>
<div class="text-[40px] leading-none font-semibold">${{ plan.price }}</div>
<div :class="'text-sm'">约${{ plan.daily }}/</div>
<div
v-if="plan.discount"
:class="[currentPlanIndex === index ? 'bg-[#A8FF53]! text-black' : ' ']"
class="absolute top-[20px] -right-[40px] h-[16px] w-[126px] origin-center rotate-45 bg-black text-center text-[14px] leading-[16px] font-[200] font-semibold text-[#ADFF5B]"
:class="[
currentPlanIndex === index ? 'bg-[#A8FF53]! text-black' : ' ',
index > 1 ? 'top-[15px] -right-[30px] h-[35px]' : 'top-[20px] -right-[40px] h-[16px]',
]"
class="absolute h-[16px] w-[126px] origin-center rotate-45 bg-black text-center text-[14px] leading-[16px] font-[200] font-semibold text-[#ADFF5B]"
>
{{ plan.discount }}
<span
v-html="
plan.discount + (index === 2 ? '<br>年销百万' : index === 3 ? '<br>最划算' : '')
"
></span>
</div>
</div>
</template>
</div>
</div>
</template>
<script setup>
defineProps({
import { computed } from 'vue'
import Goods90Icon from './goods-90-icon.svg'
const props = defineProps({
plans: Array,
currentPlanIndex: Number,
trialPlan: Object,
trialCountdown: String,
selectedPlanId: [String, Number],
})
defineEmits(['select'])
const isTrialSelected = computed(() => {
return props.trialPlan && props.selectedPlanId === props.trialPlan.id
})
</script>

View File

@ -0,0 +1,3 @@
<svg width="22" height="21" viewBox="0 0 22 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<path fill-rule="evenodd" clip-rule="evenodd" d="M11.9698 0.620247L14.559 6.21814C14.5851 6.27797 14.6271 6.32974 14.6804 6.36799C14.7338 6.40624 14.7967 6.42956 14.8623 6.43549L21.0502 7.139C21.254 7.16112 21.4469 7.24088 21.6059 7.3687C21.7649 7.49652 21.8832 7.66698 21.9466 7.85961C22.01 8.05223 22.0158 8.25884 21.9632 8.45462C21.9107 8.65041 21.8021 8.82705 21.6505 8.96334L17.063 13.126C17.0131 13.1689 16.9759 13.2244 16.9554 13.2866C16.9349 13.3488 16.9317 13.4153 16.9464 13.479L18.1814 19.5116C18.2232 19.7098 18.2063 19.9157 18.1325 20.1046C18.0587 20.2935 17.9312 20.4572 17.7654 20.5762C17.5996 20.6952 17.4026 20.7642 17.198 20.7751C16.9934 20.786 16.79 20.7382 16.6123 20.6375L11.1877 17.6127C11.1306 17.5799 11.0657 17.5627 10.9997 17.5627C10.9337 17.5627 10.8689 17.5799 10.8118 17.6127L5.38595 20.6375C5.20818 20.7379 5.00485 20.7855 4.80041 20.7745C4.59596 20.7634 4.39908 20.6943 4.23341 20.5754C4.06774 20.4565 3.94033 20.2929 3.86648 20.1042C3.79263 19.9155 3.77549 19.7097 3.8171 19.5116L5.05209 13.479C5.06671 13.4153 5.0636 13.3488 5.04307 13.2866C5.02255 13.2244 4.98538 13.1689 4.93551 13.126L0.348975 8.96286C0.197134 8.82666 0.0883434 8.65001 0.0356868 8.45417C-0.0169699 8.25832 -0.0112508 8.05162 0.0521563 7.85891C0.115563 7.6662 0.233959 7.49569 0.393104 7.36789C0.552249 7.24009 0.745368 7.16044 0.949237 7.13852L7.1381 6.43501C7.20376 6.42908 7.26659 6.40576 7.31997 6.36751C7.37335 6.32926 7.4153 6.27749 7.4414 6.21765L10.0297 0.620247C10.1137 0.435474 10.2499 0.278626 10.422 0.168585C10.5941 0.0585442 10.7947 0 10.9997 0C11.2047 0 11.4053 0.0585442 11.5774 0.168585C11.7495 0.278626 11.8858 0.435474 11.9698 0.620247Z" fill="currentColor"/>
</svg>

After

Width:  |  Height:  |  Size: 1.7 KiB

View File

@ -62,7 +62,10 @@
<PlanCard
v-else
:plans="plans"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
:currentPlanIndex="currentPlanIndex"
:selectedPlanId="selectedPlanId"
@select="handlePlanSelect"
/>
</Transition>
@ -111,6 +114,8 @@ const props = defineProps<{
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
trialPlan: any
trialCountdown: string
}>()
const emit = defineEmits(['select-plan', 'pay', 'refresh', 'show-order-details'])

View File

@ -34,7 +34,10 @@
<PlanCard
v-else
:plans="plans"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
:currentPlanIndex="currentPlanIndex"
:selectedPlanId="selectedPlanId"
@select="handlePlanSelect"
/>
</Transition>
@ -98,6 +101,8 @@ const props = defineProps<{
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
trialPlan: any
trialCountdown: string
}>()
const emit = defineEmits(['select-plan', 'pay', 'refresh', 'show-order-details'])

View File

@ -33,6 +33,8 @@
:is-user-loading="isUserLoading"
:is-plans-loading="isPlansLoading"
:is-payments-loading="isPaymentsLoading"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
@select-plan="handlePlanSelect"
@pay="handlePay"
@refresh="init"
@ -53,6 +55,8 @@
:is-user-loading="isUserLoading"
:is-plans-loading="isPlansLoading"
:is-payments-loading="isPaymentsLoading"
:trial-plan="trialPlan"
:trial-countdown="trialCountdown"
@select-plan="handlePlanSelect"
@pay="handlePay"
@refresh="init"
@ -101,7 +105,77 @@ const userSubInfo = ref({
expireDate: '',
})
const rawPlansList = ref<any[]>([])
const isTrial = ref(false)
const trialPlan = ref<any>(null)
const trialCountdown = ref('')
let countdownTimer: any = null
// --- Data Mapping ---
const updatePlans = () => {
// First map all plans using the standard logic
const allMappedPlans = mapPlans(rawPlansList.value)
if (isTrial.value && allMappedPlans.length > 0) {
// Extract the last item as the trial plan (based on user requirement)
const trial = allMappedPlans.pop()
if (trial) {
trial.discount = '新客尝鲜价' // Override discount text
trialPlan.value = trial
// If we haven't selected a plan yet, or if the current selection is invalid, select the trial plan
if (
!selectedPlanId.value ||
(allMappedPlans.length > 0 &&
!allMappedPlans.find((p: any) => p.id === selectedPlanId.value))
) {
selectedPlanId.value = trial.id
}
}
} else {
trialPlan.value = null
}
// The remaining plans are displayed in the normal list
plans.value = allMappedPlans
// Fallback selection if trial is not active and no plan selected
if (
!isTrial.value &&
plans.value.length > 0 &&
!plans.value.find((p) => p.id === selectedPlanId.value)
) {
selectedPlanId.value = plans.value[0].id
}
}
const startCountdown = (createdAtStr: string) => {
const createdAt = new Date(createdAtStr).getTime()
const deadline = createdAt + 24 * 60 * 60 * 1000
const update = () => {
const now = new Date().getTime()
const diff = deadline - now
if (diff <= 0) {
isTrial.value = false
trialPlan.value = null
updatePlans()
if (countdownTimer) clearInterval(countdownTimer)
return
}
const h = Math.floor(diff / (1000 * 60 * 60))
const m = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60))
const s = Math.floor((diff % (1000 * 60)) / 1000)
trialCountdown.value = `${h}h:${m}m:${s}s后失效`
}
update()
countdownTimer = setInterval(update, 1000)
}
const mapPlans = (apiList: any[]) => {
const flattened: any[] = []
let globalIndex = 0
@ -143,6 +217,9 @@ const mapPlans = (apiList: any[]) => {
// --- Handlers ---
const activePlan = computed(() => {
if (trialPlan.value && selectedPlanId.value === trialPlan.value.id) {
return trialPlan.value
}
return plans.value.find((p) => p.id === selectedPlanId.value)
})
@ -164,8 +241,10 @@ const handlePay = async (methodId: number | string) => {
try {
// 2.
const { list } = await request.get('/api/v1/public/user/subscribe', { includeExpired: 'all' })
const existingSub = list.find((s: any) => s.subscribe_id === plan.planId)
const { list } = (await request.get('/api/v1/public/user/subscribe', {
includeExpired: 'all',
})) as any
const existingSub = list?.find((s: any) => s.subscribe_id === plan.planId)
const isRenewal = !!existingSub
const api = isRenewal ? '/api/v1/public/order/renewal' : '/api/v1/public/order/purchase'
@ -215,6 +294,36 @@ async function init() {
userSubInfo.value.email = emailInfo.auth_identifier
localStorage.setItem('UserEmail', emailInfo.auth_identifier)
}
/*
*
* */
// mock 12:00
// const todayStart = new Date()
// todayStart.setHours(0, 0, 0, 0)
//
// res.created_at = todayStart.getTime()
// Check trial eligibility
if (res.created_at) {
const createdAt = new Date(res.created_at).getTime()
const now = new Date().getTime()
if (now - createdAt < 24 * 60 * 60 * 1000) {
request
.get('/api/v1/public/order/list', {
page: 1,
size: 1,
status: 5,
})
.then((orderRes: any) => {
if (orderRes.list && orderRes.list.length === 0) {
isTrial.value = true
updatePlans()
startCountdown(res.created_at)
}
})
}
}
})
.finally(() => {
isUserLoading.value = false
@ -230,10 +339,8 @@ async function init() {
request
.get('/api/v1/public/subscribe/list')
.then((res: any) => {
plans.value = mapPlans(res.list || [])
if (plans.value.length > 0) {
selectedPlanId.value = plans.value[0].id
}
rawPlansList.value = res.list || []
updatePlans()
})
.finally(() => {
isPlansLoading.value = false

View File

@ -19,9 +19,9 @@ export default defineConfig({
// 将所有以 /api 开头的请求转发到目标服务器
// 1. 匹配所有以 /public 开头的请求
'/api/v1': {
target: 'https://hifastvpn.com',
target: 'https://tapi.hifast.biz',
changeOrigin: true,
// rewrite: (path) => path.replace(/^\/api/, ''),
rewrite: (path) => path.replace(/^\/api/, ''),
autoRewrite: true,
// 3. 关键:将路径重写,在前面补上 /api/v1
},