骨架屏细节

This commit is contained in:
speakeloudest 2025-12-31 07:12:53 -08:00
parent 4d153b4149
commit 107d950771
4 changed files with 287 additions and 101 deletions

View File

@ -0,0 +1,64 @@
<template>
<div class="user-center-skeleton custom-pulse" :class="[layout === 'desktop' ? 'w-full' : '']">
<!-- --- Header Part --- -->
<template v-if="type === 'header'">
<div v-if="layout === 'mobile'" class="mb-3 ml-[31px] flex h-[60px] items-center gap-3">
<div class="size-[60px] rounded-full bg-[#A8FF53]/60"></div>
<div class="flex flex-col gap-2">
<div class="h-5 w-40 rounded bg-[#A8FF53]/60"></div>
<div class="h-3 w-24 rounded bg-[#A8FF53]/40"></div>
</div>
</div>
<div v-else class="mb-3 flex flex-col items-center">
<div class="size-[60px] rounded-full bg-[#A8FF53]/60"></div>
<div class="mt-2 h-6 w-32 rounded bg-[#A8FF53]/60"></div>
</div>
</template>
<!-- --- Devices Part --- -->
<template v-if="type === 'devices'">
<div v-if="layout === 'mobile'" class="h-[76px] w-full rounded-2xl bg-[#A8FF53]/15"></div>
<div v-else class="h-[160px] w-full rounded-2xl bg-[#A8FF53]/15"></div>
</template>
<!-- --- Plans Part --- -->
<template v-if="type === 'plans'">
<div class="space-y-4">
<div class="h-[120px] w-full rounded-2xl bg-black/15"></div>
<div class="h-[120px] w-full rounded-2xl bg-black/15"></div>
</div>
</template>
<!-- --- Payments Part --- -->
<template v-if="type === 'payments'">
<div class="h-[300px] w-full rounded-2xl bg-black/15"></div>
</template>
<!-- --- Full Layouts (Backward Compatibility) --- -->
<template v-if="!type">
<!-- Old Full Layout implementation if needed, but we'll use type-based now -->
</template>
</div>
</template>
<script setup lang="ts">
defineProps<{
type?: 'header' | 'devices' | 'plans' | 'payments'
layout?: 'mobile' | 'desktop'
}>()
</script>
<style scoped>
.custom-pulse {
animation: fast-pulse 1s cubic-bezier(0.4, 0, 0.6, 1) infinite;
}
@keyframes fast-pulse {
0%, 100% {
opacity: 1;
}
50% {
opacity: 0.7;
}
}
</style>

View File

@ -1,18 +1,26 @@
<template> <template>
<!-- Main Neon Green Card --> <!-- Main Neon Green Card -->
<div class="flex h-[678px]"> <div class="flex h-[678px] justify-center">
<div <div
class="flex w-[345px] flex-col justify-between rounded-4xl border-5 border-white py-[32px] pb-[22px]" class="flex w-[345px] flex-col justify-between rounded-4xl border-5 border-white py-[32px] pb-[22px]"
> >
<div> <div>
<div class="mb-3 flex flex-col items-center"> <div class="mb-3 flex min-h-[88px] flex-col items-center">
<img src="../avatar.png" class="size-[60px]" alt="" /> <Transition name="fade" mode="out-in">
<div class="flex flex-col justify-center text-white"> <UserCenterSkeleton v-if="isUserLoading" type="header" layout="desktop" />
<div class="text-xl font-semibold">{{ userInfo.email }}</div> <div v-else class="flex flex-col items-center">
</div> <img src="../avatar.png" class="size-[60px]" alt="" />
<div class="flex flex-col justify-center text-white">
<div class="text-xl font-semibold">{{ userInfo.email }}</div>
</div>
</div>
</Transition>
</div> </div>
<div class="mb-5 px-[20px] text-white"> <div class="mb-5 px-[20px] text-white">
<DeviceList :devices="devices" @refresh="emit('refresh')" /> <Transition name="fade" mode="out-in">
<UserCenterSkeleton v-if="isUserLoading" type="devices" layout="desktop" />
<DeviceList v-else :devices="devices" @refresh="emit('refresh')" />
</Transition>
</div> </div>
</div> </div>
<div> <div>
@ -43,18 +51,34 @@
class="ml-2.5 flex items-center overflow-hidden rounded-4xl bg-[#A8FF53] pt-[32px] pb-[22px]" class="ml-2.5 flex items-center overflow-hidden rounded-4xl bg-[#A8FF53] pt-[32px] pb-[22px]"
> >
<div class="h-full w-[345px]"> <div class="h-full w-[345px]">
<PlanCard :plans="plans" :currentPlanIndex="currentPlanIndex" @select="handlePlanSelect" /> <Transition name="fade" mode="out-in">
<div v-if="isPlansLoading" class="p-8">
<UserCenterSkeleton type="plans" layout="desktop" />
</div>
<PlanCard
v-else
:plans="plans"
:currentPlanIndex="currentPlanIndex"
@select="handlePlanSelect"
/>
</Transition>
</div> </div>
<div <div
class="mx-[5px] h-[624px] w-[1px] bg-[url(@/pages/userCenter/DesktopLayout/Line-8.png)]" class="mx-[5px] h-[624px] w-[1px] bg-[url(@/pages/userCenter/DesktopLayout/Line-8.png)]"
></div> ></div>
<div class="h-full w-[345px]"> <div class="h-full w-[345px]">
<PaymentMethod <Transition name="fade" mode="out-in">
:methods="payments" <div v-if="isPaymentsLoading" class="p-8">
:selectedPlan="selectedPlan" <UserCenterSkeleton type="payments" layout="desktop" />
:is-paying="isPaying" </div>
@pay="(id: number | string) => $emit('pay', id)" <PaymentMethod
/> v-else
:methods="payments"
:selectedPlan="selectedPlan"
:is-paying="isPaying"
@pay="(id: number | string) => $emit('pay', id)"
/>
</Transition>
</div> </div>
</div> </div>
</div> </div>
@ -65,6 +89,7 @@ import { computed } from 'vue'
import PlanCard from '@/components/user-center/PlanCard.vue' import PlanCard from '@/components/user-center/PlanCard.vue'
import DeviceList from '@/components/user-center/DeviceList.vue' import DeviceList from '@/components/user-center/DeviceList.vue'
import PaymentMethod from '@/components/user-center/PaymentMethod.vue' import PaymentMethod from '@/components/user-center/PaymentMethod.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -78,6 +103,9 @@ const props = defineProps<{
selectedPlanId: string selectedPlanId: string
selectedPlan: any selectedPlan: any
isPaying: boolean isPaying: boolean
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
}>() }>()
const emit = defineEmits(['select-plan', 'pay', 'refresh']) const emit = defineEmits(['select-plan', 'pay', 'refresh'])
@ -139,4 +167,14 @@ function logout() {
.tracking-tight { .tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif; font-family: 'Inter', 'PingFang SC', sans-serif;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@ -2,29 +2,53 @@
<!-- Main Neon Green Card --> <!-- Main Neon Green Card -->
<div class="pt-[35px]"> <div class="pt-[35px]">
<div class="mb-3 ml-[31px] flex h-[60px] items-center gap-3"> <div class="mb-3 ml-[31px] flex h-[60px] items-center gap-3">
<img src="../avatar.png" class="size-[60px]" alt="" /> <Transition name="fade" mode="out-in">
<div class="flex h-full flex-col justify-center text-white"> <UserCenterSkeleton v-if="isUserLoading" type="header" layout="mobile" />
<div class="text-xl font-semibold">{{ userInfo.email }}</div> <div v-else class="flex items-center gap-3 text-white">
<div class="text-xs" :class="{ 'text-[#FF00B7]': expireDateInfo.highlight }"> <img src="../avatar.png" class="size-[60px]" alt="" />
{{ expireDateInfo.text }} <div class="flex h-full flex-col justify-center text-white">
<div class="text-xl font-semibold">{{ userInfo.email }}</div>
<div class="text-xs" :class="{ 'text-[#FF00B7]': expireDateInfo.highlight }">
{{ expireDateInfo.text }}
</div>
</div>
</div> </div>
</div> </Transition>
</div> </div>
<div class="mb-5 pr-[25px] pl-[21px] text-white"> <div class="mb-5 pr-[25px] pl-[21px] text-white">
<DeviceList :devices="devices" @refresh="emit('refresh')" /> <Transition name="fade" mode="out-in">
<UserCenterSkeleton v-if="isUserLoading" type="devices" layout="mobile" />
<DeviceList v-else :devices="devices" @refresh="emit('refresh')" />
</Transition>
</div> </div>
<div class="overflow-hidden rounded-4xl bg-[#A8FF53]"> <div class="overflow-hidden rounded-4xl bg-[#A8FF53]">
<div class="pt-7"> <div class="pt-7">
<PlanCard :plans="plans" :currentPlanIndex="currentPlanIndex" @select="handlePlanSelect" /> <Transition name="fade" mode="out-in">
<div v-if="isPlansLoading" class="px-6">
<UserCenterSkeleton type="plans" layout="mobile" />
</div>
<PlanCard
v-else
:plans="plans"
:currentPlanIndex="currentPlanIndex"
@select="handlePlanSelect"
/>
</Transition>
</div> </div>
<div class="pt-7"> <div class="pt-7">
<PaymentMethod <Transition name="fade" mode="out-in">
:methods="payments" <div v-if="isPaymentsLoading" class="px-6">
:selectedPlan="selectedPlan" <UserCenterSkeleton type="payments" layout="mobile" />
:is-paying="isPaying" </div>
@pay="(id: number | string) => $emit('pay', id)" <PaymentMethod
/> v-else
:methods="payments"
:selectedPlan="selectedPlan"
:is-paying="isPaying"
@pay="(id: number | string) => $emit('pay', id)"
/>
</Transition>
</div> </div>
</div> </div>
@ -52,6 +76,7 @@ import { computed } from 'vue'
import PlanCard from '@/components/user-center/PlanCard.vue' import PlanCard from '@/components/user-center/PlanCard.vue'
import DeviceList from '@/components/user-center/DeviceList.vue' import DeviceList from '@/components/user-center/DeviceList.vue'
import PaymentMethod from '@/components/user-center/PaymentMethod.vue' import PaymentMethod from '@/components/user-center/PaymentMethod.vue'
import UserCenterSkeleton from '@/components/user-center/UserCenterSkeleton.vue'
import { Button } from '@/components/ui/button' import { Button } from '@/components/ui/button'
import { toast } from 'vue-sonner' import { toast } from 'vue-sonner'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
@ -65,6 +90,9 @@ const props = defineProps<{
selectedPlanId: string selectedPlanId: string
selectedPlan: any selectedPlan: any
isPaying: boolean isPaying: boolean
isUserLoading: boolean
isPlansLoading: boolean
isPaymentsLoading: boolean
}>() }>()
const emit = defineEmits(['select-plan', 'pay', 'refresh']) const emit = defineEmits(['select-plan', 'pay', 'refresh'])
@ -126,4 +154,14 @@ function logout() {
.tracking-tight { .tracking-tight {
font-family: 'Inter', 'PingFang SC', sans-serif; font-family: 'Inter', 'PingFang SC', sans-serif;
} }
.fade-enter-active,
.fade-leave-active {
transition: opacity 0.3s ease;
}
.fade-enter-from,
.fade-leave-to {
opacity: 0;
}
</style> </style>

View File

@ -22,36 +22,46 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Main Neon Green Card --> <div class="flex flex-1 flex-col">
<div class="container md:hidden"> <!-- Main Neon Green Card -->
<MobileLayout <div class="container md:hidden">
:already-subscribed="alreadySubscribed" <MobileLayout
:devices="devices" :already-subscribed="alreadySubscribed"
:plans="plans" :devices="devices"
:payments="payments" :plans="plans"
:user-info="userSubInfo" :payments="payments"
:selected-plan-id="selectedPlanId" :user-info="userSubInfo"
:selected-plan="activePlan" :selected-plan-id="selectedPlanId"
:is-paying="isPaying" :selected-plan="activePlan"
@select-plan="handlePlanSelect" :is-paying="isPaying"
@pay="handlePay" :is-user-loading="isUserLoading"
@refresh="init" :is-plans-loading="isPlansLoading"
/> :is-payments-loading="isPaymentsLoading"
</div> @select-plan="handlePlanSelect"
<div class="container mx-auto hidden flex-1 items-center justify-center md:flex"> @pay="handlePay"
<DesktopLayout @refresh="init"
:already-subscribed="alreadySubscribed" />
:devices="devices" </div>
:plans="plans" <div class="hidden flex-1 items-center justify-center md:flex md:pb-[50px]">
:payments="payments" <div class="container mx-auto">
:user-info="userSubInfo" <DesktopLayout
:selected-plan-id="selectedPlanId" :already-subscribed="alreadySubscribed"
:selected-plan="activePlan" :devices="devices"
:is-paying="isPaying" :plans="plans"
@select-plan="handlePlanSelect" :payments="payments"
@pay="handlePay" :user-info="userSubInfo"
@refresh="init" :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> </div>
<OrderStatusDialog ref="orderStatusDialogRef" @close="handleStatusClose" @refresh="init" /> <OrderStatusDialog ref="orderStatusDialogRef" @close="handleStatusClose" @refresh="init" />
@ -64,6 +74,7 @@ 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'
import OrderStatusDialog from '@/components/user-center/OrderStatusDialog.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 Logo from '@/pages/Home/logo.svg?component'
import MobileLogo from '@/pages/Home/mobile-logo.svg?component' import MobileLogo from '@/pages/Home/mobile-logo.svg?component'
import request from '@/utils/request' import request from '@/utils/request'
@ -73,6 +84,9 @@ const router = useRouter()
// --- State --- // --- State ---
const selectedPlanId = ref('p2') const selectedPlanId = ref('p2')
const isPaying = ref(false) 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 orderStatusDialogRef = ref<InstanceType<typeof OrderStatusDialog> | null>(null)
const selectedPayment = ref('alipay') const selectedPayment = ref('alipay')
@ -152,57 +166,77 @@ const handlePay = (methodId: number | string) => {
params.user_subscribe_id = already.id params.user_subscribe_id = already.id
} }
isPaying.value = true isPaying.value = true
request.post(api, params).then((res: any) => { request
request .post(api, params)
.post('/api/v1/public/portal/order/checkout', { .then((res: any) => {
orderNo: res.order_no, request
returnUrl: `${window.location.origin}/user-center?order_no=${res.order_no}`, .post('/api/v1/public/portal/order/checkout', {
}) orderNo: res.order_no,
.then((checkoutRes: any) => { returnUrl: `${window.location.origin}/user-center?order_no=${res.order_no}`,
if (checkoutRes.type === 'url' && checkoutRes.checkout_url) { })
localStorage.setItem('pending_order_no', res.order_no) .then((checkoutRes: any) => {
console.log('pending_order_no', res.order_no) if (checkoutRes.type === 'url' && checkoutRes.checkout_url) {
setTimeout(() => { localStorage.setItem('pending_order_no', res.order_no)
window.location.href = checkoutRes.checkout_url console.log('pending_order_no', res.order_no)
}) setTimeout(() => {
} window.location.href = checkoutRes.checkout_url
}) })
.finally(() => { }
isPaying.value = false })
}) .finally(() => {
}).catch(() => { isPaying.value = false
isPaying.value = false })
}) })
.catch(() => {
isPaying.value = false
})
} }
function init() { async function init() {
// & // 1. &
request.get('/api/v1/public/user/info').then((res: any) => { isUserLoading.value = true
devices.value = res.user_devices request
const emailInfo = res.auth_methods?.find((item: any) => item.auth_type === 'email') .get('/api/v1/public/user/info')
if (emailInfo) { .then((res: any) => {
userSubInfo.value.email = emailInfo.auth_identifier 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
}
})
.finally(() => {
isUserLoading.value = false
})
// // 2. ()
request.get('/api/v1/public/user/subscribe').then((res: any) => { request.get('/api/v1/public/user/subscribe').then((res: any) => {
alreadySubscribed.value = res.list || [] alreadySubscribed.value = res.list || []
}) })
// // 3.
request.get('/api/v1/public/subscribe/list').then((res: any) => { isPlansLoading.value = true
plans.value = mapPlans(res.list || []) request
if (plans.value.length > 0) { .get('/api/v1/public/subscribe/list')
selectedPlanId.value = plans.value[0].id .then((res: any) => {
} plans.value = mapPlans(res.list || [])
}) if (plans.value.length > 0) {
selectedPlanId.value = plans.value[0].id
}
})
.finally(() => {
isPlansLoading.value = false
})
// // 4.
request.get('/api/v1/public/payment/methods').then((res: any) => { isPaymentsLoading.value = true
console.log(res) request
payments.value = res.list?.filter((p: any) => p.platform !== 'apple_iap') || [] .get('/api/v1/public/payment/methods')
}) .then((res: any) => {
payments.value = res.list?.filter((p: any) => p.platform !== 'apple_iap') || []
})
.finally(() => {
isPaymentsLoading.value = false
})
} }
onMounted(() => { onMounted(() => {
@ -220,6 +254,18 @@ function handleStatusClose() {
} }
</script> </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> <style scoped>
/* Simplified layout font */ /* Simplified layout font */
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap'); @import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;700;900&display=swap');