All checks were successful
site-dist-deploy / build-and-deploy (push) Successful in 1m4s
292 lines
9.1 KiB
Vue
292 lines
9.1 KiB
Vue
<template>
|
||
<div class="flex min-h-screen flex-col bg-black text-black">
|
||
<!-- Full Width Header -->
|
||
<div class="h-[80px] md:h-[94px]">
|
||
<div class="fixed z-50 w-full bg-black pt-[20px] pb-[20px] md:pt-[34px]">
|
||
<div class="container">
|
||
<header
|
||
class="flex h-[40px] items-center justify-between rounded-full bg-[#ADFF5B] pr-[30px] pl-5 md:h-[60px] md:pr-[58px] md:pl-[41px]"
|
||
>
|
||
<router-link to="/" class="flex items-center gap-2">
|
||
<!-- Desktop Logo -->
|
||
<!-- <Logo :src="Logo" alt="Hi快VPN" class="hidden h-10 w-auto text-black md:block" />-->
|
||
<!-- Mobile Logo -->
|
||
<MobileLogo alt="Hi快VPN" class="block h-[22px] text-black md:h-[36px]" />
|
||
</router-link>
|
||
<div class="text-base font-[600] text-black md:text-2xl">个人账户</div>
|
||
</header>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
<div class="flex flex-1 flex-col">
|
||
<!-- Main Neon Green Card -->
|
||
<div class="container md:hidden">
|
||
<MobileLayout
|
||
:already-subscribed="alreadySubscribed"
|
||
:devices="devices"
|
||
:plans="plans"
|
||
:payments="payments"
|
||
:user-info="userSubInfo"
|
||
:selected-plan-id="selectedPlanId"
|
||
:selected-plan="activePlan"
|
||
:is-paying="isPaying"
|
||
:is-user-loading="isUserLoading"
|
||
:is-plans-loading="isPlansLoading"
|
||
:is-payments-loading="isPaymentsLoading"
|
||
@select-plan="handlePlanSelect"
|
||
@pay="handlePay"
|
||
@refresh="init"
|
||
/>
|
||
</div>
|
||
<div class="hidden flex-1 items-center justify-center md:flex md:pb-[50px]">
|
||
<div class="container mx-auto">
|
||
<DesktopLayout
|
||
:already-subscribed="alreadySubscribed"
|
||
:devices="devices"
|
||
:plans="plans"
|
||
:payments="payments"
|
||
:user-info="userSubInfo"
|
||
:selected-plan-id="selectedPlanId"
|
||
:selected-plan="activePlan"
|
||
:is-paying="isPaying"
|
||
:is-user-loading="isUserLoading"
|
||
:is-plans-loading="isPlansLoading"
|
||
:is-payments-loading="isPaymentsLoading"
|
||
@select-plan="handlePlanSelect"
|
||
@pay="handlePay"
|
||
@refresh="init"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<OrderStatusDialog ref="orderStatusDialogRef" @close="handleStatusClose" @refresh="init" />
|
||
</div>
|
||
</template>
|
||
|
||
<script setup lang="ts">
|
||
import { ref, computed, onMounted } from 'vue'
|
||
import { useRoute, useRouter } from 'vue-router'
|
||
import MobileLayout from './MobileLayout/index.vue'
|
||
import DesktopLayout from './DesktopLayout/index.vue'
|
||
import OrderStatusDialog from '@/components/user-center/OrderStatusDialog.vue'
|
||
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
|
||
import Logo from '@/pages/Home/logo.svg?component'
|
||
import MobileLogo from '@/pages/Home/mobile-logo.svg?component'
|
||
import request from '@/utils/request'
|
||
import { toast } from 'vue-sonner'
|
||
|
||
const route = useRoute()
|
||
const router = useRouter()
|
||
// --- State ---
|
||
const selectedPlanId = ref('p2')
|
||
const isPaying = ref(false)
|
||
const isUserLoading = ref(true)
|
||
const isPlansLoading = ref(true)
|
||
const isPaymentsLoading = ref(true)
|
||
const orderStatusDialogRef = ref<InstanceType<typeof OrderStatusDialog> | null>(null)
|
||
const selectedPayment = ref('alipay')
|
||
|
||
const devices = ref<any[]>([])
|
||
const plans = ref<any[]>([])
|
||
const payments = ref<any[]>([])
|
||
const alreadySubscribed = ref<any[]>([])
|
||
const userSubInfo = ref({
|
||
email: '',
|
||
expireDate: '',
|
||
})
|
||
|
||
// --- Data Mapping ---
|
||
const mapPlans = (apiList: any[]) => {
|
||
const flattened: any[] = []
|
||
let globalIndex = 0
|
||
apiList.forEach((item: any) => {
|
||
const unitDays = item.unit_time === 'Day' ? 1 : 365
|
||
if (!item.discount || item.discount.length === 0) {
|
||
flattened.push({
|
||
id: item.id.toString(),
|
||
planId: item.id,
|
||
quantity: 1,
|
||
days: unitDays,
|
||
price: (item.unit_price / 100).toFixed(2),
|
||
daily: (item.unit_price / 100 / unitDays).toFixed(2),
|
||
discount: '',
|
||
})
|
||
globalIndex++
|
||
} else {
|
||
item.discount.forEach((opt: any) => {
|
||
const totalDays = opt.quantity * unitDays
|
||
const ratioToPay = opt.discount === 0 || opt.discount === undefined ? 100 : opt.discount
|
||
const totalPrice = (item.unit_price / 100) * opt.quantity * (ratioToPay / 100)
|
||
|
||
flattened.push({
|
||
id: `${item.id}-${opt.quantity}`,
|
||
planId: item.id,
|
||
quantity: opt.quantity,
|
||
days: totalDays,
|
||
price: totalPrice.toFixed(2),
|
||
daily: (totalPrice / totalDays).toFixed(2),
|
||
discount:
|
||
globalIndex > 0 && ratioToPay < 100 ? `${Math.ceil(100 - ratioToPay)}% off` : '',
|
||
})
|
||
globalIndex++
|
||
})
|
||
}
|
||
})
|
||
return flattened
|
||
}
|
||
|
||
// --- Handlers ---
|
||
const activePlan = computed(() => {
|
||
return plans.value.find((p) => p.id === selectedPlanId.value)
|
||
})
|
||
|
||
const handlePlanSelect = (id: string) => {
|
||
selectedPlanId.value = id
|
||
}
|
||
|
||
const handlePay = async (methodId: number | string) => {
|
||
if (isPaying.value) return
|
||
|
||
// 1. 查找套餐并校验
|
||
const plan = plans.value.find((p) => p.id === selectedPlanId.value)
|
||
if (!plan) {
|
||
toast.error('请选择有效的订阅套餐')
|
||
return
|
||
}
|
||
|
||
isPaying.value = true
|
||
|
||
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 isRenewal = !!existingSub
|
||
const api = isRenewal ? '/api/v1/public/order/renewal' : '/api/v1/public/order/purchase'
|
||
|
||
const orderParams = {
|
||
subscribe_id: plan.planId,
|
||
quantity: plan.quantity,
|
||
payment: methodId,
|
||
...(isRenewal && { user_subscribe_id: existingSub.id }), // 仅续费时添加此字段
|
||
}
|
||
|
||
// 3. 创建订单
|
||
const orderRes: any = await request.post(api, orderParams)
|
||
if (!orderRes?.order_no) throw new Error('订单创建失败')
|
||
|
||
// 4. 获取支付收银台链接
|
||
const checkoutRes: any = await request.post('/api/v1/public/portal/order/checkout', {
|
||
orderNo: orderRes.order_no,
|
||
returnUrl: `${window.location.origin}/user-center?order_no=${orderRes.order_no}`,
|
||
})
|
||
|
||
// 5. 执行跳转
|
||
if (checkoutRes.type === 'url' && checkoutRes.checkout_url) {
|
||
localStorage.setItem('pending_order_no', orderRes.order_no)
|
||
window.location.href = checkoutRes.checkout_url
|
||
} else {
|
||
throw new Error('支付网关配置异常')
|
||
}
|
||
} catch (error: any) {
|
||
console.error('支付流程异常:', error)
|
||
// 使用你之前调整过字号的 Toaster 提示错误
|
||
toast.error(error.message || '发起支付失败,请稍后重试')
|
||
} finally {
|
||
isPaying.value = false
|
||
}
|
||
}
|
||
|
||
async function init() {
|
||
// 1. 用户信息 & 设备列表
|
||
isUserLoading.value = true
|
||
request
|
||
.get('/api/v1/public/user/info')
|
||
.then((res: any) => {
|
||
devices.value = res.user_devices
|
||
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email')
|
||
if (emailInfo) {
|
||
userSubInfo.value.email = emailInfo.auth_identifier
|
||
localStorage.setItem('UserEmail', emailInfo.auth_identifier)
|
||
}
|
||
})
|
||
.finally(() => {
|
||
isUserLoading.value = false
|
||
})
|
||
|
||
// 2. 已订阅列表 (非阻塞主要展示)
|
||
request.get('/api/v1/public/user/subscribe').then((res: any) => {
|
||
alreadySubscribed.value = res.list || []
|
||
})
|
||
|
||
// 3. 订阅套餐列表
|
||
isPlansLoading.value = true
|
||
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
|
||
}
|
||
})
|
||
.finally(() => {
|
||
isPlansLoading.value = false
|
||
})
|
||
|
||
// 4. 获取支付方式
|
||
isPaymentsLoading.value = true
|
||
request
|
||
.get('/api/v1/public/payment/methods')
|
||
.then((res: any) => {
|
||
payments.value =
|
||
res.list?.filter(
|
||
(p: any) =>
|
||
p.platform !== 'apple_iap' && p.platform !== 'Stripe' && p.platform !== 'balance',
|
||
) || []
|
||
})
|
||
.finally(() => {
|
||
isPaymentsLoading.value = false
|
||
})
|
||
}
|
||
|
||
onMounted(() => {
|
||
init()
|
||
const orderNo = (route.query.order_no as string) || localStorage.getItem('pending_order_no')
|
||
if (orderNo) {
|
||
request.get('/api/v1/public/order/detail', { order_no: orderNo }).then((data) => {
|
||
if (data.status === 1 || data.status === 5 || data.status === 2) {
|
||
orderStatusDialogRef.value?.show(orderNo)
|
||
}
|
||
})
|
||
}
|
||
})
|
||
|
||
function handleStatusClose() {
|
||
localStorage.removeItem('pending_order_no')
|
||
// 清除 URL 中的 order_no,防止刷新再次弹出
|
||
router.replace({ query: { ...route.query, order_no: undefined } })
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
.fade-enter-active,
|
||
.fade-leave-active {
|
||
transition: opacity 0.3s ease;
|
||
}
|
||
|
||
.fade-enter-from,
|
||
.fade-leave-to {
|
||
opacity: 0;
|
||
}
|
||
</style>
|
||
|
||
<style scoped>
|
||
/* Simplified layout font */
|
||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');
|
||
|
||
.tracking-tight {
|
||
font-family: 'Inter', 'PingFang SC', sans-serif;
|
||
}
|
||
</style>
|