压缩历史至 95081aff

This commit is contained in:
ryan
2026-06-08 20:34:27 +08:00
commit 8a782525de
435 changed files with 71146 additions and 0 deletions
@@ -0,0 +1,179 @@
"use client"
import * as React from "react"
import { Skeleton } from "@/components/ui/skeleton"
import { ShoppingBag } from "lucide-react"
import type { GetMerchantOrderResponse } from "@/lib/services"
/**
* 加载中骨架组件
* 显示活动信息加载中的骨架
*/
function LoadingSkeleton() {
return (
<div className="w-full">
<div className="mb-6 text-center md:text-left">
<Skeleton className="h-4 w-32 mb-2 bg-white/10 mx-auto md:mx-0" />
<Skeleton className="h-8 w-40 bg-white/10 mx-auto md:mx-0" />
</div>
<div className="space-y-4">
<div className="rounded-xl bg-white/5 p-4 space-y-4">
<div className="flex justify-between items-start pb-4">
<div className="flex flex-col items-start space-y-1.5">
<Skeleton className="h-4 w-24 bg-white/10" />
<Skeleton className="h-3 w-16 bg-white/10" />
</div>
<div className="flex flex-col items-end space-y-1.5">
<Skeleton className="h-4 w-20 bg-white/10" />
<Skeleton className="h-3 w-24 bg-white/10" />
</div>
</div>
<div className="flex justify-between items-center pt-2">
<Skeleton className="h-6 w-20 bg-white/10" />
<Skeleton className="h-6 w-24 bg-white/10" />
</div>
</div>
<div className="space-y-1.5 px-1">
<Skeleton className="h-3 w-full bg-white/10" />
<Skeleton className="h-3 w-3/4 bg-white/10" />
</div>
</div>
</div>
)
}
/**
* 活动信息组件
* 显示活动信息
*/
function OrderContent({ orderInfo, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, forceMobile?: boolean }) {
const amount = parseFloat(orderInfo.order.amount).toFixed(2)
const feeRateValue = orderInfo.user_pay_config?.fee_rate
const feeRate = feeRateValue ? (parseFloat(feeRateValue) * 100).toFixed(2) : '0.00'
return (
<div className="w-full">
<div className={`mb-4 text-center ${ forceMobile ? '' : 'md:mb-6 md:text-left' }`}>
<div className={`inline-flex items-center justify-center gap-1.5 mb-1 text-white/50 text-[10px] font-medium uppercase tracking-wider ${ forceMobile ? '' : 'md:justify-start md:gap-2 md:mb-1.5 md:text-xs' }`}>
<span>服务方</span>
<span className="w-1 h-1 rounded-full bg-white/30" />
<span className="text-white hover:text-blue-400 transition-colors cursor-pointer font-bold">{orderInfo.merchant.app_name}</span>
</div>
<h1 className={`text-2xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1 ${ forceMobile ? '' : 'md:text-4xl md:justify-start' }`}>
<span className={`text-lg text-white/50 font-normal ${ forceMobile ? '' : 'md:text-2xl' }`}>LDC</span>
{amount}
</h1>
</div>
<div className={`space-y-3 ${ forceMobile ? '' : 'md:space-y-4' }`}>
<div className={`rounded-2xl bg-white/5 p-3 backdrop-blur-sm transition-colors ${ forceMobile ? '' : 'md:p-4' }`}>
<div className={`flex justify-between items-start pb-3 mb-3 ${ forceMobile ? '' : 'md:pb-4 md:mb-4' }`}>
<div className={`flex flex-col items-start pr-3 ${ forceMobile ? '' : 'md:pr-4' }`}>
<p className={`text-xs font-medium text-white mb-0.5 line-clamp-2 ${ forceMobile ? '' : 'md:text-sm' }`}>{orderInfo.order.order_name}</p>
<p className="text-white/40 text-[10px]">数量: 1</p>
</div>
<div className="flex flex-col items-end text-right whitespace-nowrap">
<p className={`text-xs font-medium text-white mb-0.5 ${ forceMobile ? '' : 'md:text-sm' }`}>LDC {amount}</p>
<p className="text-white/40 text-[10px]">LDC {amount} / 个</p>
</div>
</div>
<button className="w-full text-xs text-left mb-3 text-white/40 hover:text-white transition-colors duration-200 flex items-center gap-2 group">
<span>添加优惠码</span>
<span className="px-1.5 py-0.5 rounded text-[10px] bg-white/5 text-white/30">暂未开放</span>
</button>
<div className="flex justify-between items-center pt-2">
<p className="text-base font-bold text-white">积分合计</p>
<p className="text-lg font-bold text-white tracking-tight">LDC {amount}</p>
</div>
</div>
<div className="px-2">
<p className="text-[10px] text-white/30 space-y-0.5 leading-relaxed">
* 包含 {feeRate}% 的服务手续费 (由服务方承担)。
<br />
服务手续费率根据服务方等级和平台活动动态调整,不会消耗您(消费者)的积分。
</p>
</div>
</div>
</div>
)
}
/**
* 活动信息组件
* 显示活动信息
*/
export function PayingInfo({ orderInfo, loading = false, forceMobile = false }: { orderInfo: GetMerchantOrderResponse, loading: boolean, forceMobile?: boolean }) {
return (
<div className={`flex flex-col w-full p-4 justify-center bg-gradient-to-br from-neutral-900/95 to-black dark:from-neutral-900 dark:to-black relative text-white rounded-t-3xl ${ forceMobile ? '' : 'md:w-[45%] md:p-8 md:rounded-l-3xl md:rounded-tr-none' }`}>
<div className="absolute top-0 left-0 w-full h-full overflow-hidden pointer-events-none opacity-30">
<div className="absolute -top-[20%] -left-[10%] w-[70%] h-[70%] bg-blue-900/20 rounded-full blur-3xl" />
<div className="absolute top-[40%] -right-[10%] w-[60%] h-[60%] bg-purple-900/20 rounded-full blur-3xl" />
</div>
<div className={`w-full max-w-xs mx-auto relative z-10 flex flex-col justify-center ${ forceMobile ? '' : 'md:h-full md:min-h-[360px]' }`}>
<div className={`text-center mb-2 ${ forceMobile ? '' : 'md:hidden' }`}>
{loading ? (
<div className="flex flex-col items-center gap-2">
<Skeleton className="h-3 w-32 bg-white/10" />
<div className="flex items-baseline justify-center gap-1">
<Skeleton className="h-8 w-24 bg-white/10" />
</div>
</div>
) : (
<>
<div className="text-white/50 text-[10px] uppercase tracking-wider mb-1">服务方 {orderInfo.merchant.app_name}</div>
<div className="text-3xl font-bold text-white tracking-tight flex items-baseline justify-center gap-1">
<span className="text-lg text-white/50 font-normal">LDC</span>
{(parseFloat(orderInfo.order.amount)).toFixed(2)}
</div>
</>
)}
</div>
<div className={`mb-10 flex justify-center hidden ${ forceMobile ? '' : 'md:block md:justify-start' }`}>
<div className="flex items-center gap-2">
<div className="w-8 h-8 flex items-center justify-center shadow-sm backdrop-blur-sm">
{loading ? (
<Skeleton className="w-5 h-5 rounded-full bg-white/20" />
) : (
<ShoppingBag className="size-4 text-white/80" />
)}
</div>
{loading ? (
<Skeleton className="h-5 w-24 bg-white/10" />
) : (
<span className="text-xs font-bold text-white/60">积分流转信息</span>
)}
</div>
</div>
<div className={`hidden ${ forceMobile ? '' : 'md:block' }`}>
{loading ? <LoadingSkeleton /> : <OrderContent orderInfo={orderInfo} forceMobile={forceMobile} />}
</div>
{!loading && (
<div className={`text-center ${ forceMobile ? '' : 'md:hidden' }`}>
<div className="inline-flex items-center gap-1 text-[10px] text-white/40 bg-white/5 px-2 py-1 rounded-full">
<span>{orderInfo.order.order_name}</span>
</div>
</div>
)}
<div className={`hidden mt-auto pt-8 ${ forceMobile ? '' : 'md:block' }`}>
<div className="text-[10px] text-white/20 font-mono">
ORDER: {orderInfo?.order?.order_no || 'LOADING...'}
</div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,240 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useSearchParams, useRouter } from "next/navigation"
import { toast } from "sonner"
import { motion } from "motion/react"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import services from "@/lib/services"
import type { GetMerchantOrderResponse } from "@/lib/services"
/**
* 积分认证主页面组件
* 通过 order_no 查询订单信息并完成积分认证
*/
export function PayingMain() {
/** 获取URL参数中的认证编号 */
const searchParams = useSearchParams()
const router = useRouter()
const encryptedOrderNo = searchParams.get('order_no')
/** 组件状态管理 */
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [paying, setPaying] = useState(false)
const [payKey, setPayKey] = useState("")
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
const [selectedMethod, setSelectedMethod] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
/** 统一的服务错误处理函数 */
const handleServiceError = (error: unknown, operation: string) => {
console.error(`${ operation }失败:`, error)
let errorMessage = `${ operation }失败`
if (error && typeof error === 'object' && 'message' in error) {
errorMessage = String(error.message)
}
const errorMap: Record<string, string> = {
'订单不存在': "此积分流转服务不存在或已过期",
'未登录': "请先登录后再进行认证",
'余额不足': "您的积分余额不足",
'安全密码': "安全密码错误",
'不能认证自己的订单': "不能认证自己创建的积分流转服务",
'每日限额': "已达到每日认证限额",
'已完成': "认证完成"
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key)
)?.[1] || errorMessage
toast.error(userMessage, { id: 'payment-error' })
}
/** 组件初始化时查询订单信息 */
useEffect(() => {
if (encryptedOrderNo) {
setError(false)
handleQueryOrder(encryptedOrderNo)
} else {
setLoading(false)
setError(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [encryptedOrderNo])
/** 查询订单信息 */
const handleQueryOrder = async (queryOrderNo?: string) => {
const targetOrderNo = queryOrderNo || encryptedOrderNo
if (!targetOrderNo) {
toast.error("缺少认证编号", { id: 'missing-order-no' })
return
}
setLoading(true)
try {
const data = await services.merchant.getMerchantOrder({ order_no: targetOrderNo })
setOrderInfo(data)
setError(false)
} catch (error: unknown) {
handleServiceError(error, "查询认证信息")
setOrderInfo(null)
setError(true)
} finally {
setLoading(false)
}
}
/** 执行积分认证操作 */
const handlePayOrder = async () => {
if (!orderInfo) return
if (!payKey.trim()) {
toast.error("请输入安全密码")
return
}
if (payKey.length !== 6) {
toast.error("安全密码长度为6位,请重新输入")
return
}
setPaying(true)
try {
const freshOrderInfo = await services.merchant.getMerchantOrder({
order_no: encryptedOrderNo!
})
if (freshOrderInfo.order.status !== 'pending') {
toast.error("此积分流转服务状态已变更,无法认证")
setOrderInfo(freshOrderInfo)
return
}
await services.merchant.payMerchantOrder({
order_no: encryptedOrderNo!,
pay_key: payKey
})
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
/** 支付成功后立即更新订单状态为成功 */
if (orderInfo) {
setOrderInfo({
...orderInfo,
order: {
...orderInfo.order,
status: 'success'
}
})
}
/** 5秒后跳转到redirect_uri或刷新页面 */
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
const redirectUri = orderInfo?.merchant?.redirect_uri
if (redirectUri && redirectUri.trim()) {
window.location.href = redirectUri
return
}
router.push('/home')
}, 5000)
} catch (error: unknown) {
handleServiceError(error, "认证")
if (
error &&
typeof error === 'object' &&
'message' in error &&
String(error.message).includes('已完成')
) {
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
window.location.reload()
}, 500)
}
} finally {
setPaying(false)
}
}
if (error) {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
出了点问题
</h1>
<p className="text-muted-foreground text-sm">
您访问的认证编号已经完成认证或不存在。请检查您的积分流转服务链接是否正确,对此如有疑问请联系社区技术支持。
</p>
</div>
</motion.div>
</div>
</div>
)
}
return (
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
style={{ minHeight: 'auto' }}
>
<PayingInfo orderInfo={orderInfo!} loading={loading} />
<PayingNow
orderInfo={orderInfo}
paying={paying}
payKey={payKey}
currentStep={currentStep}
selectedMethod={selectedMethod}
isOpen={isOpen}
loading={loading}
onPayKeyChange={setPayKey}
onCurrentStepChange={setCurrentStep}
onSelectedMethodChange={setSelectedMethod}
onIsOpenChange={setIsOpen}
onPayOrder={handlePayOrder}
/>
</motion.div>
</div>
</div>
</div>
)
}
@@ -0,0 +1,445 @@
import * as React from "react"
import { motion, AnimatePresence } from "motion/react"
import { Button } from "@/components/ui/button"
import { Skeleton } from "@/components/ui/skeleton"
import { Send } from "@/components/animate-ui/icons/send"
import { Check } from "@/components/animate-ui/icons/check"
import { InputOTP, InputOTPGroup, InputOTPSlot } from "@/components/ui/input-otp"
import { Collapsible, CollapsibleContent, CollapsibleTrigger } from "@/components/animate-ui/primitives/radix/collapsible"
import { AlertCircle, CreditCard, ArrowLeft, ShieldCheck, Lock } from "lucide-react"
import type { GetMerchantOrderResponse } from "@/lib/services"
import { useUser } from "@/contexts/user-context"
type PaymentStep = typeof PAYMENT_STEPS[keyof typeof PAYMENT_STEPS]
const PAYMENT_METHODS = {
LINUX_DO_PAY: 'linux-do-pay'
} as const
const PAYMENT_STEPS = {
METHOD: 'method' as const,
Credit: 'pay' as const
} as const
const ORDER_STATUSES = {
SUCCESS: 'success',
PENDING: 'pending'
} as const
/**
* 加载中骨架组件
* 显示支付方式加载中的骨架
*/
function LoadingSkeleton() {
return (
<div className="space-y-6 w-full max-w-xs mx-auto">
<div className="space-y-3">
<Skeleton className="h-6 w-32 bg-muted/50" />
<Skeleton className="h-4 w-48 bg-muted/50" />
</div>
<div className="space-y-3">
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
<div className="flex items-center space-x-3">
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
<Skeleton className="h-3.5 w-24 bg-muted/50" />
</div>
</div>
<div className="rounded-xl border border-border/50 p-3 bg-card/50">
<div className="flex items-center space-x-3">
<Skeleton className="w-9 h-9 rounded-lg bg-muted/50" />
<Skeleton className="h-3.5 w-20 bg-muted/50" />
</div>
</div>
</div>
<Skeleton className="h-10 w-full rounded-full bg-muted/50" />
</div>
)
}
/**
* 成功状态组件
* 显示认证成功状态
*/
function SuccessState() {
const [countdown, setCountdown] = React.useState(5)
React.useEffect(() => {
const timer = setInterval(() => {
setCountdown((prev) => {
if (prev <= 1) {
clearInterval(timer)
return 0
}
return prev - 1
})
}, 1000)
return () => clearInterval(timer)
}, [])
return (
<motion.div
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
transition={{
duration: 0.6,
ease: "easeOut",
type: "spring",
stiffness: 100
}}
className="text-center py-10"
>
<div className="relative inline-block mb-6">
<div className="absolute inset-0 bg-green-500/20 blur-xl rounded-full" />
<motion.div
initial={{ scale: 0 }}
animate={{ scale: 1 }}
transition={{
delay: 0.2,
duration: 0.5,
type: "spring",
stiffness: 200
}}
className="relative w-10 h-10 bg-green-500 rounded-full flex items-center justify-center "
>
<Check
className="size-6 text-white"
animate={true}
loop={false}
/>
</motion.div>
</div>
<motion.h3
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.4, duration: 0.4 }}
className="text-xl font-bold text-foreground mb-2"
>
认证成功
</motion.h3>
<motion.p
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.6, duration: 0.4 }}
className="text-muted-foreground text-sm"
>
认证已安全完成,{countdown > 0 ? `${ countdown }秒后自动跳转...` : '正在跳转...'}
</motion.p>
</motion.div>
)
}
/**
* 认证方式选择步骤组件
* 显示认证方式选择步骤
*/
function MethodSelectionStep({
orderInfo,
selectedMethod,
isOpen,
onSelectedMethodChange,
onIsOpenChange,
onCurrentStepChange,
forceMobile = false
}: {
orderInfo: GetMerchantOrderResponse
selectedMethod: string
isOpen: boolean
onSelectedMethodChange: (method: string) => void
onIsOpenChange: (isOpen: boolean) => void
onCurrentStepChange: (step: PaymentStep) => void
forceMobile?: boolean
}) {
const { user } = useUser()
return (
<motion.div
key="method-step"
initial={{ opacity: 0, x: -20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -20 }}
transition={{ duration: 0.3 }}
className="space-y-6 w-full max-w-xs mx-auto"
>
<div className={`space-y-2 text-center ${ forceMobile ? '' : 'md:text-left' }`}>
<h2 className="text-xl font-bold text-foreground tracking-tight">选择认证平台</h2>
<p className="text-xs text-muted-foreground">请选择您想要使用的服务认证平台</p>
</div>
<div className="space-y-3">
<Collapsible open={isOpen} onOpenChange={onIsOpenChange}>
<CollapsibleTrigger asChild>
<div
className={`group cursor-pointer rounded-xl p-2.5 transition-all duration-300 border backdrop-blur-sm ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'border-primary/50 bg-primary/5'
: 'border-border/50 bg-card hover:bg-muted/50 hover:border-border'
}`}
onClick={() => {
if (selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY) {
onSelectedMethodChange('')
onIsOpenChange(false)
} else {
onSelectedMethodChange(PAYMENT_METHODS.LINUX_DO_PAY)
onIsOpenChange(true)
}
}}
>
<div className="flex items-center gap-2.5">
<div className={`w-8 h-8 rounded-lg flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground group-hover:bg-muted/80'
}`}>
<CreditCard className="size-4" />
</div>
<div className="flex-1 text-left">
<div className="font-bold text-xs text-foreground">LINUX DO Credit</div>
<div className="text-[9px] text-muted-foreground mt-0.5">即时安全认证</div>
</div>
<div className={`w-5 h-5 rounded-full border flex items-center justify-center transition-all duration-300 ${ selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY
? 'border-primary bg-primary text-primary-foreground'
: 'border-border'
}`}>
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && <Check className="size-3" />}
</div>
</div>
</div>
</CollapsibleTrigger>
{selectedMethod === PAYMENT_METHODS.LINUX_DO_PAY && (
<CollapsibleContent>
<motion.div
initial={{ opacity: 0, height: 0 }}
animate={{ opacity: 1, height: 'auto' }}
exit={{ opacity: 0, height: 0 }}
className="pt-3 px-1"
>
<div className="flex items-center gap-2.5 text-xs text-muted-foreground mb-4 bg-muted/50 p-2.5 rounded-lg border">
<div className="p-1 rounded">
<Lock className="size-3 text-primary" />
</div>
<div className="flex flex-col">
<span className="text-[10px] opacity-70">消费方账户</span>
<span className="font-medium text-foreground">{orderInfo.order.payer_username || user?.username}</span>
</div>
</div>
<Button
onClick={() => onCurrentStepChange(PAYMENT_STEPS.Credit)}
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
>
确认并继续
</Button>
</motion.div>
</CollapsibleContent>
)}
</Collapsible>
</div>
</motion.div>
)
}
/**
* 认证步骤组件
* 显示认证步骤
*/
function PaymentStep({
orderInfo,
payKey,
paying,
onPayKeyChange,
onCurrentStepChange,
onPayOrder
}: {
orderInfo: GetMerchantOrderResponse
payKey: string
paying: boolean
onPayKeyChange: (value: string) => void
onCurrentStepChange: (step: PaymentStep) => void
onPayOrder: () => void
}) {
return (
<motion.div
key="pay-step"
initial={{ opacity: 0, x: 20 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: 20 }}
transition={{ duration: 0.3 }}
className="space-y-6 w-full max-w-xs mx-auto"
>
<div className="space-y-1.5">
<Button
variant="ghost"
size="sm"
className="-ml-3 h-8 text-muted-foreground hover:text-foreground mb-1"
onClick={() => onCurrentStepChange(PAYMENT_STEPS.METHOD)}
>
<ArrowLeft className="size-3.5 mr-1" /> 返回
</Button>
<h2 className="text-xl font-bold text-foreground tracking-tight">身份验证</h2>
<p className="text-xs text-muted-foreground">请输入您的6位安全密码</p>
</div>
<div className="flex justify-center py-4">
<InputOTP
maxLength={6}
value={payKey}
onChange={onPayKeyChange}
onKeyDown={(e) => {
if (e.key === 'Enter' && payKey.length === 6 && !paying) {
onPayOrder()
}
}}
>
<InputOTPGroup className="gap-2">
{[0, 1, 2, 3, 4, 5].map((index) => (
<InputOTPSlot
key={index}
index={index}
className="size-11 bg-background border-border text-foreground text-lg font-bold transition-all focus:border-primary focus:ring-4 focus:ring-primary/10 ring-offset-0"
/>
))}
</InputOTPGroup>
</InputOTP>
</div>
<Button
onClick={onPayOrder}
disabled={paying || payKey.length !== 6}
className="w-full rounded-full font-bold text-sm shadow-md transition-all hover:scale-[1.02] active:scale-[0.98]"
>
{paying ? (
<span className="flex items-center gap-2">
<Send className="size-4" animate={true} loop={true} loopDelay={2000} /> 处理中...
</span>
) : (
`消耗 LDC ${ (parseFloat(orderInfo.order.amount)).toFixed(2) }`
)}
</Button>
</motion.div>
)
}
/**
* 错误状态组件
* 显示错误状态
*/
function ErrorState({ status }: { status: string }) {
return (
<div className="text-center py-10">
<div className="w-14 h-14 bg-red-500/10 rounded-full flex items-center justify-center mx-auto mb-4 border border-red-500/20">
<AlertCircle className="size-6 text-red-500" />
</div>
<h3 className="text-lg font-bold text-foreground mb-1">认证出现问题</h3>
<p className="text-muted-foreground text-sm">状态:<span className="font-medium text-foreground">{status}</span></p>
</div>
)
}
/**
* 底部组件
* 显示底部
*/
function Footer({ loading }: { loading?: boolean }) {
return loading ? (
<div className="flex flex-col items-center justify-center space-y-2 mt-8 opacity-50">
<Skeleton className="h-3 w-32" />
</div>
) : (
<div className="flex items-center justify-center gap-2 text-[10px] uppercase font-bold text-muted-foreground/40 mt-10">
<ShieldCheck className="size-3 text-green-500" />
<span>由 LINUX DO Credit 提供安全认证</span>
</div>
)
}
interface PayingNowProps {
orderInfo: GetMerchantOrderResponse | null
paying: boolean
payKey: string
currentStep: PaymentStep
selectedMethod: string
isOpen: boolean
loading?: boolean
onPayKeyChange: (value: string) => void
onCurrentStepChange: (step: PaymentStep) => void
onSelectedMethodChange: (method: string) => void
onIsOpenChange: (isOpen: boolean) => void
onPayOrder: () => void
forceMobile?: boolean
}
/**
* 支付现在组件
* 显示支付现在组件
*/
export function PayingNow({
orderInfo,
paying,
payKey,
currentStep,
selectedMethod,
isOpen,
loading = false,
onPayKeyChange,
onCurrentStepChange,
onSelectedMethodChange,
onIsOpenChange,
onPayOrder,
forceMobile = false
}: PayingNowProps) {
const renderContent = () => {
if (loading) return <LoadingSkeleton />
if (!orderInfo) return null
switch (orderInfo.order.status) {
case ORDER_STATUSES.SUCCESS:
return <SuccessState />
case ORDER_STATUSES.PENDING:
return (
<AnimatePresence mode="wait">
{currentStep === PAYMENT_STEPS.METHOD ? (
<MethodSelectionStep
orderInfo={orderInfo}
selectedMethod={selectedMethod}
isOpen={isOpen}
onSelectedMethodChange={onSelectedMethodChange}
onIsOpenChange={onIsOpenChange}
onCurrentStepChange={onCurrentStepChange}
forceMobile={forceMobile}
/>
) : (
<PaymentStep
orderInfo={orderInfo}
payKey={payKey}
paying={paying}
onPayKeyChange={onPayKeyChange}
onCurrentStepChange={onCurrentStepChange}
onPayOrder={onPayOrder}
/>
)}
</AnimatePresence>
)
default:
return <ErrorState status={orderInfo.order.status} />
}
}
return (
<div className={`flex-1 flex flex-col items-center justify-center p-6 relative overflow-hidden bg-white dark:bg-neutral-900 transition-colors duration-500 ${ forceMobile ? '' : 'md:p-8' }`}>
<div className="w-full relative z-10 flex flex-col h-full justify-center">
<div className="flex-1 flex flex-col justify-center">
{renderContent()}
</div>
<Footer loading={loading} />
</div>
</div>
)
}
@@ -0,0 +1,282 @@
"use client"
import { useState, useEffect, useRef } from "react"
import { useSearchParams } from "next/navigation"
import { toast } from "sonner"
import { motion } from "motion/react"
import { PayingNow } from "@/components/common/pay/paying/paying-now"
import { PayingInfo } from "@/components/common/pay/paying/paying-info"
import { useUser } from "@/contexts/user-context"
import services from "@/lib/services"
import { ConfigService } from "@/lib/services"
import type { PaymentLink, GetMerchantOrderResponse, UserPayConfig } from "@/lib/services"
/**
* 支付链接支付页面组件
* 通过 token 查询支付链接信息并完成支付
*/
export function PayingOnline() {
/* 获取URL参数中的Token */
const searchParams = useSearchParams()
const token = searchParams.get('token')
const { user } = useUser()
/* 组件状态管理 */
const [paymentLink, setPaymentLink] = useState<PaymentLink | null>(null)
const [orderInfo, setOrderInfo] = useState<GetMerchantOrderResponse | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState(false)
const [paying, setPaying] = useState(false)
const [payKey, setPayKey] = useState("")
const [currentStep, setCurrentStep] = useState<'method' | 'pay'>('method')
const [selectedMethod, setSelectedMethod] = useState<string>('')
const [isOpen, setIsOpen] = useState(false)
const timeoutRef = useRef<NodeJS.Timeout | null>(null)
const isMountedRef = useRef(true)
useEffect(() => {
isMountedRef.current = true
return () => {
isMountedRef.current = false
if (timeoutRef.current) {
clearTimeout(timeoutRef.current)
timeoutRef.current = null
}
}
}, [])
/* 获取用户支付配置 */
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])
useEffect(() => {
const loadConfigs = async () => {
try {
const configs = await ConfigService.getUserPayConfigs()
setUserPayConfigs(configs)
} catch (error) {
console.error("Failed to load user pay configs:", error)
}
}
loadConfigs()
}, [])
/* 统一的服务错误处理函数 */
const handleServiceError = (error: unknown, operation: string) => {
console.error(`${ operation }失败:`, error)
let errorMessage = `${ operation }失败`
if (error && typeof error === 'object' && 'message' in error) {
errorMessage = String(error.message)
}
const errorMap: Record<string, string> = {
'不存在': "此积分流转服务不存在或已失效",
'未登录': "请先登录后再进行认证",
'余额不足': "账户积分不足",
'支付密码': "安全密码错误",
'不能支付': "不能支付自己创建的积分流转服务",
'每日限额': "已达到每日认证限额",
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key)
)?.[1] || errorMessage
toast.error(userMessage, { id: 'payment-error' })
}
/* 组件初始化时查询支付链接信息 */
useEffect(() => {
if (token) {
setError(false)
handleQueryPaymentLink(token)
} else {
setLoading(false)
setError(true)
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [token, userPayConfigs.length, user?.pay_level])
/* 查询支付链接信息 */
const handleQueryPaymentLink = async (queryToken?: string) => {
const targetToken = queryToken || token
if (!targetToken) {
toast.error("缺少支付链接", { id: 'missing-token' })
return
}
setLoading(true)
setLoading(true)
try {
const data = await services.merchant.getPaymentLinkByToken(targetToken)
setPaymentLink(data)
/* 将支付链接信息转换为订单信息格式,以复用PayingInfo和PayingNow组件 */
const mockOrderInfo: GetMerchantOrderResponse = {
merchant: {
app_name: data.app_name || "商户",
redirect_uri: data.redirect_uri || "",
},
order: {
id: '0',
order_no: `LINK-${ data.id }`,
order_name: data.product_name,
payer_username: user?.username || "",
payee_username: "",
amount: data.amount,
status: "pending",
type: "payment",
payment_type: "link",
remark: data.remark || "",
client_id: "",
return_url: "",
notify_url: "",
trade_time: null,
created_at: data.created_at,
updated_at: data.updated_at,
},
user_pay_config: (() => {
const currentUserConfig = userPayConfigs.find(c => user?.pay_level !== undefined && c.level === user.pay_level)
return currentUserConfig ? {
...currentUserConfig,
fee_rate: String(currentUserConfig.fee_rate)
} : {
id: '0',
level: user?.pay_level ?? 0,
min_score: 0,
max_score: null,
daily_limit: null,
fee_rate: "0.00",
created_at: "",
updated_at: ""
}
})(),
}
setOrderInfo(mockOrderInfo)
setError(false)
} catch (error: unknown) {
handleServiceError(error, "查询积分流转服务")
setPaymentLink(null)
setOrderInfo(null)
setError(true)
} finally {
setLoading(false)
}
}
/* 执行支付操作 */
const handlePayOrder = async () => {
if (!paymentLink || !token) return
if (!payKey.trim()) {
toast.error("请输入安全密码")
return
}
if (payKey.length < 6 || payKey.length > 10) {
toast.error("安全密码长度必须为6位")
return
}
setPaying(true)
try {
await services.merchant.payByLink({
token: token,
pay_key: payKey,
remark: paymentLink.remark || undefined
})
toast.success("积分流转服务认证成功!", { id: 'payment-success' })
/* 支付成功后更新订单状态 */
if (orderInfo) {
setOrderInfo({
...orderInfo,
order: {
...orderInfo.order,
status: 'success'
}
})
}
/* 5秒后跳转到首页或回调地址 */
timeoutRef.current = setTimeout(() => {
if (!isMountedRef.current) return
const redirectUri = orderInfo?.merchant?.redirect_uri
if (redirectUri && redirectUri.trim()) {
window.location.href = redirectUri
return
}
window.location.href = '/home'
}, 5000)
} catch (error: unknown) {
handleServiceError(error, "认证")
} finally {
setPaying(false)
}
}
if (error) {
return (
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
className="relative z-10 flex flex-col items-center justify-center min-h-screen text-center px-4"
initial={{ opacity: 0, scale: 0.95, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
>
<div className="w-full rounded-3xl p-8 max-w-lg">
<div className="w-12 h-12 rounded-full flex items-center justify-center mx-auto mb-6 bg-red-500/10">
<div className="size-8 text-red-500 font-bold text-4xl flex items-center justify-center">!</div>
</div>
<h1 className="text-2xl font-bold text-foreground mb-4">
出了点问题
</h1>
<p className="text-muted-foreground text-sm">
您访问的积分流转服务不存在或已失效。请检查链接地址是否正确,对此如有疑问请联系社区技术支持。
</p>
</div>
</motion.div>
</div>
</div>
)
}
return (
<div className="relative min-h-screen w-full font-sans text-foreground overflow-hidden bg-background selection:bg-primary/30">
<div className="relative min-h-screen w-full flex flex-col items-center justify-center bg-background">
<div className="relative z-10 w-full h-full flex items-center justify-center p-4">
<motion.div
initial={{ opacity: 0, y: 30, scale: 0.95 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
transition={{ duration: 0.6, ease: "easeOut" }}
className="flex flex-col md:flex-row w-full max-w-4xl bg-card/70 backdrop-blur-2xl border border-border/50 rounded-3xl overflow-hidden shadow-2xl"
style={{ minHeight: 'auto' }}
>
<PayingInfo orderInfo={orderInfo!} loading={loading} />
<PayingNow
orderInfo={orderInfo}
paying={paying}
payKey={payKey}
currentStep={currentStep}
selectedMethod={selectedMethod}
isOpen={isOpen}
loading={loading}
onPayKeyChange={setPayKey}
onCurrentStepChange={setCurrentStep}
onSelectedMethodChange={setSelectedMethod}
onIsOpenChange={setIsOpen}
onPayOrder={handlePayOrder}
/>
</motion.div>
</div>
</div>
</div>
)
}