speakeloudest 0aee63988c
All checks were successful
site-dist-deploy / build-and-deploy (push) Successful in 1m4s
订单和订阅修改
2026-01-06 23:13:39 -08:00

292 lines
9.1 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<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>