Compare commits
7 Commits
v1.6.0
...
27e694ee0d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
27e694ee0d | ||
|
|
74d53c619d | ||
| 8f8def3a87 | |||
|
|
ed73d8579f | ||
|
|
e3c61a77e6 | ||
| 5f7756199a | |||
| 6aa108e8d3 |
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "lanhu-web",
|
||||
"version": "1.6.0",
|
||||
"version": "1.8.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev -H 0.0.0.0 --turbopack",
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
import {API_BASE_URL, ApiResponse, CLIENT_ID, CLIENT_SECRET} from '@/lib/api'
|
||||
import {add, isBefore} from 'date-fns'
|
||||
import {cookies, headers} from 'next/headers'
|
||||
import {redirect} from 'next/navigation'
|
||||
import {cache} from 'react'
|
||||
|
||||
export type TokenResp = {
|
||||
@@ -106,7 +107,6 @@ const _callByUser = cache(async <R = undefined>(
|
||||
// ======================
|
||||
|
||||
async function call<R = undefined>(url: string, body: RequestInit['body'], auth?: string): Promise<ApiResponse<R>> {
|
||||
let response: Response
|
||||
try {
|
||||
const reqHeaders = await headers()
|
||||
const reqIP = reqHeaders.get('x-forwarded-for')
|
||||
@@ -118,55 +118,59 @@ async function call<R = undefined>(url: string, body: RequestInit['body'], auth?
|
||||
if (reqIP) callHeaders['X-Forwarded-For'] = reqIP
|
||||
if (reqUA) callHeaders['User-Agent'] = reqUA
|
||||
|
||||
response = await fetch(url, {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: callHeaders,
|
||||
body,
|
||||
})
|
||||
|
||||
if (response.status === 401) {
|
||||
return redirect('/login?redirect=' + encodeURIComponent(url.replace(API_BASE_URL, '')))
|
||||
}
|
||||
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('后端请求失败', url, `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
if (!!text?.trim()?.length) {
|
||||
console.log('未处理的响应成功', `type=text`, `text=${text}`)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as R, // 强转类型,考虑优化
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('后端请求失败', url, `status=${response.status}`, json)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || json.error_description || '请求失败', // 业务错误(message)或者 oauth 错误(error_description)
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
catch (e) {
|
||||
console.error('后端请求失败', url, (e as Error).message)
|
||||
throw new Error(`请求失败,网络错误`)
|
||||
}
|
||||
|
||||
const type = response.headers.get('Content-Type') ?? 'text/plain'
|
||||
if (type.indexOf('text/plain') !== -1) {
|
||||
const text = await response.text()
|
||||
if (!response.ok) {
|
||||
console.log('后端请求失败', url, `status=${response.status}`, text)
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: text || '请求失败',
|
||||
}
|
||||
}
|
||||
|
||||
if (!!text?.trim()?.length) {
|
||||
console.log('未处理的响应成功', `type=text`, `text=${text}`)
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: undefined as R, // 强转类型,考虑优化
|
||||
}
|
||||
}
|
||||
else if (type.indexOf('application/json') !== -1) {
|
||||
const json = await response.json()
|
||||
if (!response.ok) {
|
||||
console.log('后端请求失败', url, `status=${response.status}`, json)
|
||||
|
||||
return {
|
||||
success: false,
|
||||
status: response.status,
|
||||
message: json.message || json.error_description || '请求失败', // 业务错误(message)或者 oauth 错误(error_description)
|
||||
}
|
||||
}
|
||||
return {
|
||||
success: true,
|
||||
data: json,
|
||||
}
|
||||
}
|
||||
|
||||
throw new Error(`无法解析响应数据,未处理的 Content-Type: ${type}`)
|
||||
}
|
||||
|
||||
// 导出
|
||||
|
||||
@@ -10,13 +10,13 @@ export default function Addr({channel}: {
|
||||
const expired = isBefore(channel.expired_at, new Date())
|
||||
|
||||
return (
|
||||
<div className={`${expired ? 'text-weak' : ''}`}>
|
||||
<>
|
||||
<span>{ip}:{port}</span>
|
||||
{expired && (
|
||||
<Badge variant="secondary">
|
||||
<Badge className="ml-2 bg-orange-100 text-orange-700 hover:bg-orange-100 dark:bg-orange-900/30 dark:text-orange-400">
|
||||
已过期
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -192,7 +192,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
|
||||
<span >白名单</span>
|
||||
<div className="flex flex-wrap gap-1 max-w-[200px]">
|
||||
{channel.whitelists.split(',').map((ip, index) => (
|
||||
<Badge key={index} variant="secondary">
|
||||
<Badge key={index} className="bg-green-100 text-green-700 hover:bg-green-100 dark:bg-green-900/30 dark:text-green-400">
|
||||
{ip.trim()}
|
||||
</Badge >
|
||||
))}
|
||||
@@ -201,7 +201,7 @@ export default function ChannelsPage(props: ChannelsPageProps) {
|
||||
) : hasAuth ? (
|
||||
<div className="flex flex-col">
|
||||
<span>账号密码</span>
|
||||
<Badge variant="secondary">
|
||||
<Badge className="bg-blue-100 text-blue-700 hover:bg-blue-100 dark:bg-blue-900/30 dark:text-blue-400">
|
||||
{channel.username}:{channel.password}
|
||||
</Badge >
|
||||
</div>
|
||||
|
||||
@@ -105,23 +105,21 @@ export function Header() {
|
||||
theme="ghost"
|
||||
className="w-9 h-9 ml-4 md:ml-0"
|
||||
onClick={toggleNavbar}>
|
||||
{navbar
|
||||
? <PanelLeftCloseIcon/>
|
||||
: <PanelLeftOpenIcon/>
|
||||
}
|
||||
{navbar ? <PanelLeftCloseIcon/> : <PanelLeftOpenIcon/>}
|
||||
</Button>
|
||||
<span className="max-md:hidden">
|
||||
欢迎来到,蓝狐代理
|
||||
</span>
|
||||
<span className="max-md:hidden">欢迎来到,蓝狐代理</span>
|
||||
<div className="max-md:hidden h-5 w-px bg-gray-300 mx-2"/>
|
||||
<Link
|
||||
href="/admin/identify"
|
||||
className="max-md:hidden flex items-center gap-1.5 text-sm text-blue-600 hover:text-blue-800 transition-colors"
|
||||
>
|
||||
<IdCard size={16}/>
|
||||
<span>实名认证</span>
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* right */}
|
||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-2">
|
||||
<Link
|
||||
href="/"
|
||||
className={merge(
|
||||
`flex-none h-16 flex items-center justify-center`,
|
||||
)}>
|
||||
<div className="flex-none flex items-center justify-end pr-4 max-md:hidden gap-3">
|
||||
<Link href="/" className="flex-none h-16 flex items-center justify-center text-sm">
|
||||
返回首页
|
||||
</Link>
|
||||
<Suspense>
|
||||
@@ -176,7 +174,7 @@ export function Navbar() {
|
||||
<TooltipProvider>
|
||||
<NavItem href="/admin" icon={<UserRound size={20}/>} label="账户总览" expand={navbar}/>
|
||||
<NavTitle label="快速开始"/>
|
||||
<NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/>
|
||||
{/* <NavItem href="/admin/identify" icon={<IdCard size={20}/>} label="实名认证" expand={navbar}/> */}
|
||||
<NavItem href="/admin/whitelist" icon={<LockKeyhole size={20}/>} label="白名单" expand={navbar}/>
|
||||
<NavItem href="/admin/purchase" icon={<ShoppingCart size={20}/>} label="购买套餐" expand={navbar}/>
|
||||
<NavItem href="/admin/extract" icon={<HardDriveUpload size={20}/>} label="提取 IP" expand={navbar}/>
|
||||
|
||||
@@ -9,7 +9,7 @@ import {Button} from '@/components/ui/button'
|
||||
import {useForm, useFormContext, useWatch} from 'react-hook-form'
|
||||
import {Alert, AlertTitle} from '@/components/ui/alert'
|
||||
import {ArrowRight, Box, CircleAlert, CopyIcon, ExternalLinkIcon, LinkIcon, Loader, Plus, Timer} from 'lucide-react'
|
||||
import {memo, ReactNode, useEffect, useRef, useState} from 'react'
|
||||
import {memo, ReactNode, Suspense, use, useEffect, useRef, useState} from 'react'
|
||||
import {useStatus} from '@/lib/states'
|
||||
import {allResource} from '@/actions/resource'
|
||||
import {Resource} from '@/lib/models'
|
||||
@@ -113,7 +113,9 @@ const FormFields = memo(() => {
|
||||
return (
|
||||
<div className="flex flex-col gap-6 items-stretch max-w-[calc(160px*4+1rem*3)]">
|
||||
{/* 选择套餐 */}
|
||||
<SelectResource/>
|
||||
<Suspense>
|
||||
<SelectResource/>
|
||||
</Suspense>
|
||||
|
||||
{/* 地区筛选 */}
|
||||
<SelectRegion/>
|
||||
@@ -332,8 +334,13 @@ FormFields.displayName = 'FormFields'
|
||||
function SelectResource() {
|
||||
const [resources, setResources] = useState<Resource[]>([])
|
||||
const [status, setStatus] = useStatus()
|
||||
const profile = useProfileStore(state => state.profile)
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
const getResources = async () => {
|
||||
if (!profile) {
|
||||
setStatus('done')
|
||||
setResources([])
|
||||
return
|
||||
}
|
||||
setStatus('load')
|
||||
try {
|
||||
const resp = await allResource()
|
||||
@@ -352,7 +359,7 @@ function SelectResource() {
|
||||
useEffect(() => {
|
||||
getResources().then()
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [])
|
||||
}, [profile])
|
||||
|
||||
return (
|
||||
<FormField name="resource" className="md:max-w-[calc(160px*2+1rem)]" label="选择套餐" classNames={{label: 'max-md:text-sm'}}>
|
||||
@@ -372,8 +379,8 @@ function SelectResource() {
|
||||
</div>
|
||||
) : !profile ? (
|
||||
<div className="p-4 flex gap-1 items-center">
|
||||
<Loader className="animate-spin" size={20}/>
|
||||
<span>请先登录账号,<Link href="/login" className="text-blue-600 hover:underline">去登录</Link></span>
|
||||
{/* <Loader className="animate-spin" size={20}/> */}
|
||||
<span className="text-gray-600">请先登录账号,<Link href="/login" className="text-blue-600 hover:text-blue-700 font-medium">去登录</Link></span>
|
||||
</div>
|
||||
) : resources.length === 0 ? (
|
||||
<div className="p-4 flex gap-1 items-center">
|
||||
|
||||
@@ -17,21 +17,21 @@ export type PaymentModalProps = {
|
||||
export function PaymentModal(props: PaymentModalProps) {
|
||||
// 手动关闭时的处理
|
||||
const handleClose = async () => {
|
||||
try {
|
||||
const res = await payClose({
|
||||
trade_no: props.inner_no,
|
||||
method: props.method,
|
||||
})
|
||||
if (!res.success) {
|
||||
throw new Error(res.message)
|
||||
}
|
||||
}
|
||||
catch (error) {
|
||||
console.error('关闭订单失败:', error)
|
||||
}
|
||||
finally {
|
||||
props.onClose?.()
|
||||
}
|
||||
// try {
|
||||
// const res = await payClose({
|
||||
// trade_no: props.inner_no,
|
||||
// method: props.method,
|
||||
// })
|
||||
// if (!res.success) {
|
||||
// throw new Error(res.message)
|
||||
// }
|
||||
// }
|
||||
// catch (error) {
|
||||
// console.error('关闭订单失败:', error)
|
||||
// }
|
||||
// finally {
|
||||
props.onClose?.()
|
||||
// }
|
||||
}
|
||||
|
||||
// SSE处理方式检查支付状态
|
||||
|
||||
@@ -1,244 +0,0 @@
|
||||
'use client'
|
||||
import {Form, FormField} from '@/components/ui/form'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import {z} from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import Image from 'next/image'
|
||||
import check from '@/assets/check-accent.svg'
|
||||
import banner from '../_assets/Mask-group.webp'
|
||||
import group from '../_assets/Group.webp'
|
||||
import {merge} from '@/lib/utils'
|
||||
import FreeTrial from '@/components/free-trial'
|
||||
|
||||
const formSchema = z.object({
|
||||
companyName: z.string().min(2, '企业名称至少2个字符'),
|
||||
contactName: z.string().min(2, '联系人姓名至少2个字符'),
|
||||
phone: z.string().min(11, '请输入11位手机号码').max(11, '手机号码长度不正确'),
|
||||
monthlyUsage: z.string().min(1, '请选择您需要的用量'),
|
||||
purpose: z.string().min(1, '输入用途'),
|
||||
})
|
||||
|
||||
type FormValues = z.infer<typeof formSchema>
|
||||
|
||||
export default function CollectPage() {
|
||||
const form = useForm<FormValues>({
|
||||
resolver: zodResolver(formSchema),
|
||||
defaultValues: {
|
||||
companyName: '',
|
||||
contactName: '',
|
||||
phone: '',
|
||||
monthlyUsage: '',
|
||||
purpose: '',
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="bg-white rounded-lg shadow-md overflow-hidden p-6">
|
||||
<div className="text-center mb-4">
|
||||
<h1 className="text-2xl font-bold">优质代理IP服务商</h1>
|
||||
<p className="text-gray-600 font-medium mt-2">
|
||||
以技术升级为核心,提供优质的IP代理使用体验
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col md:flex-row md:gap-4">
|
||||
<div className="w-full md:w-1/3 mb-6 md:mb-0">
|
||||
<div className="relative h-full w-full min-h-[200px] md:min-h-[300px] rounded-xl overflow-hidden">
|
||||
<Image
|
||||
src={banner}
|
||||
alt="宣传图"
|
||||
fill
|
||||
className="object-cover"
|
||||
priority
|
||||
sizes="(max-width: 768px) 100vw, 33vw"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="w-full md:w-2/3 flex flex-col gap-4">
|
||||
<p className="text-sm md:text-base text-gray-600 leading-relaxed">
|
||||
华连科技公司专注代理IP领域,多年来凭借专业技术与不懈努力,在行业内树立起良好口碑,为众多客户解决网络访问难题。公司拥有海量优质IP资源,涵盖全球多地,能精准匹配不同客户需求,无论是数据采集、网络营销还是突破地域限制,都能提供合适方案。凭借智能分配系统与严密安全防护,确保代理IP稳定、高效、安全运行,让用户使用过程顺畅无忧,数据安全有保障。秉持以客户为中心理念,配备专业客服与技术团队,提供7×24小时服务,助力企业与个人在网络世界畅行无阻,不断开拓业务新边界。
|
||||
</p>
|
||||
|
||||
<div className="mt-2 md:mt-4">
|
||||
<Button className="w-full md:w-auto bg-blue-600 hover:bg-blue-700 text-white px-4 md:px-6 py-2 md:py-3 rounded-md">
|
||||
立即咨询
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 gap-3 md:gap-4 mt-2 md:mt-6">
|
||||
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
|
||||
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
|
||||
<span>IP时效3-30分钟(可定制)</span>
|
||||
</div>
|
||||
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
|
||||
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
|
||||
<span>IP时效3-30分钟(可定制)</span>
|
||||
</div>
|
||||
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
|
||||
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
|
||||
<span>IP时效3-30分钟(可定制)</span>
|
||||
</div>
|
||||
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
|
||||
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
|
||||
<span>支持高并发提取</span>
|
||||
</div>
|
||||
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
|
||||
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
|
||||
<span>支持高并发提取</span>
|
||||
</div>
|
||||
<div className="flex gap-1 md:gap-2 items-center text-xs md:text-sm">
|
||||
<Image src={check} alt="特性" width={16} height={16} className="w-4 h-4 md:w-5 md:h-5"/>
|
||||
<span>支持高并发提取</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
<h2 className="text-2xl font-semibold mb-6 mt-6">企业基本信息</h2>
|
||||
</div>
|
||||
|
||||
<div className="bg-white rounded-lg shadow-md p-6">
|
||||
<Form form={form}>
|
||||
<div className="mx-auto max-w-xl space-y-6">
|
||||
{/* 企业名称 */}
|
||||
<FormField name="companyName">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
|
||||
<span className="text-red-500">*</span>
|
||||
<span>企业名称</span>
|
||||
</label>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
placeholder="请输入企业名称"
|
||||
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 联系人姓名 */}
|
||||
<FormField name="contactName">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
|
||||
<span className="text-red-500">*</span>
|
||||
<span>联系人姓名</span>
|
||||
</label>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
placeholder="请输入联系人姓名"
|
||||
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 联系人手机号码 */}
|
||||
<FormField name="phone">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
|
||||
<span className="text-red-500">*</span>
|
||||
<span>联系人手机号码</span>
|
||||
</label>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
placeholder="请输入手机号码"
|
||||
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 每月需求用量 */}
|
||||
<FormField name="monthlyUsage">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
|
||||
<span className="text-red-500">*</span>
|
||||
<span>每月需求用量</span>
|
||||
</label>
|
||||
<Select onValueChange={field.onChange} value={field.value}>
|
||||
<SelectTrigger
|
||||
id={id}
|
||||
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs">
|
||||
<SelectValue placeholder="请选择您需要的用量"/>
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="less20">小于20万</SelectItem>
|
||||
<SelectItem value="20-100">20万~100万</SelectItem>
|
||||
<SelectItem value="100-500">100万~500万</SelectItem>
|
||||
<SelectItem value="more500">大于500万</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 用途 */}
|
||||
<FormField name="purpose">
|
||||
{({id, field}) => (
|
||||
<div className="flex flex-col md:flex-row items-start md:items-center justify-start md:justify-between">
|
||||
<label
|
||||
htmlFor={id}
|
||||
className="text-sm flex items-center gap-1 mb-2 md:mb-0 md:w-1/3 md:text-right">
|
||||
<span className="text-red-500">*</span>
|
||||
<span>用途</span>
|
||||
</label>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
placeholder="请输入用途,例如:爬虫"
|
||||
className="flex-1 w-full md:w-2/3 md:ml-4 md:max-w-xs"/>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
<div className="pt-4 flex justify-center">
|
||||
<Button type="submit" className="bg-blue-600 hover:bg-blue-700 px-8">
|
||||
提交
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Form>
|
||||
</div>
|
||||
|
||||
<div className="relative mt-8 rounded-lg overflow-hidden">
|
||||
<div className="h-40 md:h-48 relative">
|
||||
<div
|
||||
className="absolute inset-0 bg-no-repeat"
|
||||
style={{
|
||||
backgroundImage: `url(${group.src})`,
|
||||
backgroundPosition: 'center',
|
||||
backgroundSize: 'cover',
|
||||
}}
|
||||
/>
|
||||
<div className="absolute inset-0 flex items-center justify-center">
|
||||
<div className="w-full max-w-4xl px-6 flex flex-col md:flex-row items-center gap-4 justify-between md:gap-10">
|
||||
<div className="text-blue-600 font-bold text-2xl md:text-2xl text-center md:text-left">
|
||||
现在注册,免费领取5000IP
|
||||
</div>
|
||||
<FreeTrial className={merge('bg-blue-600 hover:bg-blue-700 text-white px-6 py-2 rounded-md whitespace-nowrap cursor-pointer')}/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -30,6 +30,7 @@ export default function Purchase() {
|
||||
const res = profile
|
||||
? await listProduct({})
|
||||
: await listProductHome({})
|
||||
console.log(res, 'res')
|
||||
|
||||
if (res.success) {
|
||||
setProductList(res.data)
|
||||
@@ -41,7 +42,6 @@ export default function Purchase() {
|
||||
const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = {
|
||||
short: ShortForm,
|
||||
long: LongForm,
|
||||
// static: StaticForm
|
||||
}
|
||||
return (
|
||||
<div className="flex flex-col gap-4">
|
||||
|
||||
@@ -1,292 +1,185 @@
|
||||
'use client'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Minus, Plus} from 'lucide-react'
|
||||
import FormOption from '@/components/composites/purchase/option'
|
||||
import Image from 'next/image'
|
||||
import check from '../_assets/check.svg'
|
||||
import {Schema} from '@/components/composites/purchase/long/form'
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {useFormContext, useWatch} from 'react-hook-form'
|
||||
import {Card} from '@/components/ui/card'
|
||||
import {BillingMethodField} from '../shared/billing-method-field'
|
||||
import {FeatureList} from '../shared/feature-list'
|
||||
import {NumberStepperField} from '../shared/number-stepper-field'
|
||||
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
|
||||
|
||||
export default function Center({map, expireList, liveList}: {
|
||||
map: Map<string, string>
|
||||
liveList: string[]
|
||||
expireList: string[]
|
||||
export default function Center({skuData}: {
|
||||
skuData: PurchaseSkuData
|
||||
}) {
|
||||
const form = useFormContext<Schema>()
|
||||
const type = useWatch({name: 'type'})
|
||||
const {setValue, getValues} = useFormContext<Schema>()
|
||||
const type = useWatch<Schema>({name: 'type'}) as Schema['type']
|
||||
const live = useWatch<Schema>({name: 'live'}) as Schema['live']
|
||||
const expire = useWatch<Schema>({name: 'expire'}) as Schema['expire']
|
||||
const {modeList, priceMap} = skuData
|
||||
const liveList = type === '1'
|
||||
? getAvailablePurchaseLives(skuData, {mode: type, expire})
|
||||
: getAvailablePurchaseLives(skuData, {mode: type})
|
||||
const expireList = type === '1'
|
||||
? getAvailablePurchaseExpires(skuData, {mode: type, live})
|
||||
: []
|
||||
|
||||
const currentCountMin = useMemo(() => {
|
||||
if (!type || !live) return 0
|
||||
const expireValue = type === '1' ? expire : '0'
|
||||
return getPurchaseSkuCountMin(skuData, {mode: type, live, expire: expireValue})
|
||||
}, [type, live, expire, skuData])
|
||||
|
||||
useEffect(() => {
|
||||
if (type === '1') {
|
||||
const current = getValues('daily_limit')
|
||||
if (current < currentCountMin) {
|
||||
setValue('daily_limit', currentCountMin)
|
||||
}
|
||||
}
|
||||
else {
|
||||
const current = getValues('quota')
|
||||
if (current < currentCountMin) {
|
||||
setValue('quota', currentCountMin)
|
||||
}
|
||||
}
|
||||
}, [currentCountMin, type, setValue, getValues])
|
||||
|
||||
useEffect(() => {
|
||||
const nextType = modeList.includes(type) ? type : modeList[0]
|
||||
|
||||
if (!nextType) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextType !== type) {
|
||||
setValue('type', nextType)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLiveList = nextType === '1'
|
||||
? getAvailablePurchaseLives(skuData, {mode: nextType, expire})
|
||||
: getAvailablePurchaseLives(skuData, {mode: nextType})
|
||||
const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0]
|
||||
|
||||
if (nextLive && nextLive !== live) {
|
||||
setValue('live', nextLive)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextType === '2') {
|
||||
if (expire !== '0') {
|
||||
setValue('expire', '0')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive})
|
||||
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
|
||||
setValue('expire', nextExpireList[0])
|
||||
}
|
||||
}, [expire, live, modeList, setValue, skuData, type])
|
||||
|
||||
return (
|
||||
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
||||
<BillingMethodField modeList={modeList} timeDailyLimit={100}/>
|
||||
|
||||
{/* 计费方式 */}
|
||||
<BillingMethod expireList={expireList}/>
|
||||
{/* IP 时效 */}
|
||||
<IpTime {...{map, liveList}}/>
|
||||
<FormField<Schema, 'live'>
|
||||
className="space-y-4"
|
||||
name="live"
|
||||
label="IP 时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
|
||||
{/* 根据套餐类型显示不同表单项 */}
|
||||
{type === '2' ? (
|
||||
/* 包量:IP 购买数量 */
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="quota"
|
||||
label="IP 购买数量">
|
||||
{({id, field}) => {
|
||||
const value = Number(field.value) || 500
|
||||
const minValue = 500
|
||||
const step = 100
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('quota', Math.max(minValue, value - step))}
|
||||
disabled={value === minValue}>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={minValue}
|
||||
step={step}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (value < 500) {
|
||||
form.setValue('quota', 500)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('quota', value + step)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</FormField>
|
||||
) : (
|
||||
<>
|
||||
{/* 包时:套餐时效 */}
|
||||
<ComboValidity expireList={expireList}/>
|
||||
if (type !== '1') {
|
||||
return
|
||||
}
|
||||
|
||||
{/* 包时:每日提取上限 */}
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="daily_limit"
|
||||
label="每日提取上限">
|
||||
{({id, field}) => {
|
||||
const value = Number(field.value) || 100
|
||||
const minValue = 100
|
||||
const step = 100
|
||||
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg ${
|
||||
value === minValue ? 'opacity-50 cursor-not-allowed' : ''
|
||||
}`}
|
||||
onClick={() => form.setValue('daily_limit', Math.max(minValue, value - step))}
|
||||
disabled={value === minValue}>
|
||||
<Minus/>
|
||||
</Button>
|
||||
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={100}
|
||||
step={100}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (value < 100) {
|
||||
form.setValue('daily_limit', 100)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('daily_limit', value + step)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value})
|
||||
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
|
||||
setValue('expire', nextExpireList[0])
|
||||
}
|
||||
}}
|
||||
</FormField>
|
||||
</>
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||
{liveList.map((live) => {
|
||||
const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire})
|
||||
? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0'
|
||||
: String(expire)
|
||||
const price = getPurchaseSkuPrice(priceMap, {
|
||||
mode: type,
|
||||
live,
|
||||
expire: priceExpire,
|
||||
})
|
||||
return (
|
||||
<FormOption
|
||||
key={live}
|
||||
id={`${id}-${live}`}
|
||||
value={live}
|
||||
label={`${Number(live) / 60} 小时`}
|
||||
description={price && `¥${price}/IP`}
|
||||
compare={field.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 套餐时效 */}
|
||||
{type === '1' && (
|
||||
<FormField className="space-y-4" name="expire" label="套餐时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
|
||||
const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
|
||||
if (!nextLiveList.includes(live) && nextLiveList[0]) {
|
||||
setValue('live', nextLiveList[0])
|
||||
}
|
||||
}}
|
||||
className="flex gap-4 flex-wrap">
|
||||
{expireList.map(day => (
|
||||
<FormOption
|
||||
key={day}
|
||||
id={`${id}-${day}`}
|
||||
value={day}
|
||||
label={`${day} 天`}
|
||||
compare={field.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
{/* 产品特性 */}
|
||||
<div className="space-y-6">
|
||||
<h3>产品特性</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 gap-y-6">
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">支持高并发提取</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">指定省份、城市或混播</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">账密+白名单验证</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">IP时效3-30分钟(可定制)</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">IP资源定期筛选</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">包量/包时计费方式</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">每日去重量:500万</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 每日提取上限/购买数量 */}
|
||||
{type === '1' ? (
|
||||
<NumberStepperField
|
||||
name="daily_limit"
|
||||
label="每日提取上限"
|
||||
min={currentCountMin || 100}
|
||||
step={100}
|
||||
/>
|
||||
) : (
|
||||
<NumberStepperField
|
||||
name="quota"
|
||||
label="IP 购买数量"
|
||||
min={currentCountMin || 500}
|
||||
step={100}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FeatureList/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BillingMethod(props: {
|
||||
expireList: string[]
|
||||
}) {
|
||||
const {setValue} = useFormContext<Schema>()
|
||||
return (
|
||||
<FormField
|
||||
className="flex flex-col gap-4"
|
||||
name="type"
|
||||
label="计费方式">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
if (v === '2') {
|
||||
setValue('expire', '0')
|
||||
}
|
||||
else if (props.expireList.length > 0) {
|
||||
setValue('expire', props.expireList[0])
|
||||
}
|
||||
}}
|
||||
className="flex gap-4 max-md:flex-col">
|
||||
|
||||
<FormOption
|
||||
id={`${id}-2`}
|
||||
value="2"
|
||||
label="包量套餐"
|
||||
description="适用于短期或不定期高提取业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
<FormOption
|
||||
id={`${id}-1`}
|
||||
value="1"
|
||||
label="包时套餐"
|
||||
description="适用于每日提取量稳定的业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
function IpTime({map, liveList}: {
|
||||
map: Map<string, string>
|
||||
liveList: string[]}) {
|
||||
const {control, getValues} = useFormContext<Schema>()
|
||||
const values = useWatch({control})
|
||||
|
||||
return (
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="live"
|
||||
label="IP 时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup id={id} value={field.value} onValueChange={field.onChange} className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||
{liveList.map((live) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('mode', {
|
||||
1: 'time',
|
||||
2: 'quota',
|
||||
}[values.type || '2'])
|
||||
params.set('live', live || '0')
|
||||
params.set('expire', values.expire || '0')
|
||||
const price = map.get(params.toString())
|
||||
return (
|
||||
<FormOption
|
||||
key={live}
|
||||
id={live}
|
||||
value={live}
|
||||
label={`${Number(live) / 60} 小时`}
|
||||
description={price && `¥${price}/IP`}
|
||||
compare={field.value}
|
||||
/>
|
||||
)
|
||||
})}
|
||||
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
function ComboValidity({expireList}: {expireList: string[]}) {
|
||||
return (
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="expire"
|
||||
label="套餐时效"
|
||||
>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(val) => {
|
||||
field.onChange(val)
|
||||
}}
|
||||
className="flex gap-4 flex-wrap"
|
||||
>
|
||||
{expireList.map(item => (
|
||||
<FormOption
|
||||
key={item}
|
||||
id={`${id}-${item}`}
|
||||
value={item}
|
||||
label={`${item} 天`}
|
||||
compare={field.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import Center from '@/components/composites/purchase/long/center'
|
||||
import Right from '@/components/composites/purchase/long/right'
|
||||
import {Form} from '@/components/ui/form'
|
||||
import * as z from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {ProductItem} from '@/actions/product'
|
||||
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, parsePurchaseSkuList} from '../shared/sku'
|
||||
import {PurchaseSidePanel} from '../shared/side-panel'
|
||||
|
||||
// 定义表单验证架构
|
||||
const schema = z.object({
|
||||
type: z.enum(['1', '2']).default('2'),
|
||||
live: z.string(),
|
||||
@@ -16,44 +16,37 @@ const schema = z.object({
|
||||
daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
|
||||
pay_type: z.enum(['wechat', 'alipay', 'balance']),
|
||||
})
|
||||
|
||||
// 从架构中推断类型
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
export default function LongForm({skuList}: {skuList: ProductItem['skus']}) {
|
||||
if (!skuList) throw new Error('没有套餐数据')
|
||||
|
||||
const map = new Map<string, string>()
|
||||
// const _modeList = new Set<string>()
|
||||
const _liveList = new Set<number>()
|
||||
const _expireList = new Set<number>()
|
||||
for (const sku of skuList) {
|
||||
const params = new URLSearchParams(sku.code)
|
||||
// _modeList.add(params.get('mode') || '')
|
||||
_liveList.add(Number(params.get('live')))
|
||||
_expireList.add(Number(params.get('expire')))
|
||||
map.set(sku.code, sku.price)
|
||||
}
|
||||
// const modeList = Array.from(_modeList).filter(Boolean)
|
||||
const liveList = Array.from(_liveList).filter(Boolean).map(String)
|
||||
const expireList = Array.from(_expireList).filter(Boolean).map(String)
|
||||
const skuData = parsePurchaseSkuList('long', skuList)
|
||||
const defaultMode = skuData.modeList.includes('2') ? '2' : '1'
|
||||
const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || ''
|
||||
const defaultExpire = defaultMode === '1'
|
||||
? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0'
|
||||
: '0'
|
||||
const defaultCountMin = getPurchaseSkuCountMin(skuData, {
|
||||
mode: defaultMode,
|
||||
live: defaultLive,
|
||||
expire: defaultExpire,
|
||||
})
|
||||
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: '2', // 默认为包量套餐
|
||||
live: liveList[0], // 分钟
|
||||
expire: '0', // 天
|
||||
quota: 500,
|
||||
daily_limit: 100,
|
||||
type: defaultMode,
|
||||
live: defaultLive,
|
||||
expire: defaultExpire,
|
||||
quota: defaultMode === '2' ? Math.max(defaultCountMin, 500) : 500,
|
||||
daily_limit: defaultMode === '1' ? Math.max(defaultCountMin, 100) : 100,
|
||||
pay_type: 'balance', // 余额支付
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
||||
<Center {...{liveList, map, expireList}}/>
|
||||
<Right/>
|
||||
<Center skuData={skuData}/>
|
||||
<PurchaseSidePanel kind="long"/>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,209 +0,0 @@
|
||||
'use client'
|
||||
import {Suspense, use, useEffect, useState} from 'react'
|
||||
import {useProfileStore} from '@/components/stores/profile'
|
||||
import Pay from '@/components/composites/purchase/pay'
|
||||
import {buttonVariants} from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import {merge} from '@/lib/utils'
|
||||
import {useFormContext, useWatch} from 'react-hook-form'
|
||||
import {Schema} from '@/components/composites/purchase/long/form'
|
||||
import {Card} from '@/components/ui/card'
|
||||
import {getPrice, getPriceHome} from '@/actions/resource'
|
||||
import {ExtraResp} from '@/lib/api'
|
||||
import {FieldPayment} from '../shared/field-payment'
|
||||
|
||||
export default function Right() {
|
||||
const {control} = useFormContext<Schema>()
|
||||
const method = useWatch({control, name: 'pay_type'})
|
||||
const mode = useWatch({control, name: 'type'})
|
||||
const live = useWatch({control, name: 'live'})
|
||||
const quota = useWatch({control, name: 'quota'})
|
||||
const expire = useWatch({control, name: 'expire'})
|
||||
const dailyLimit = useWatch({control, name: 'daily_limit'})
|
||||
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
|
||||
price: '0.00',
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
useEffect(() => {
|
||||
const price = async () => {
|
||||
try {
|
||||
const resp = profile
|
||||
? await getPrice({
|
||||
type: 2,
|
||||
long: {
|
||||
live: Number(live),
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
}) : await getPriceHome({
|
||||
type: 1,
|
||||
short: {
|
||||
live: Number(live),
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
})
|
||||
if (!resp.success) {
|
||||
throw new Error('获取价格失败')
|
||||
}
|
||||
setPriceData({
|
||||
price: resp.data.price,
|
||||
actual: resp.data.actual ?? resp.data.price ?? '',
|
||||
discounted: resp.data.discounted,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取价格失败:', error)
|
||||
setPriceData({
|
||||
price: '0.00',
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
}
|
||||
}
|
||||
price()
|
||||
}, [dailyLimit, expire, live, quota, mode, profile])
|
||||
|
||||
const {price, actual: discountedPrice = ''} = priceData
|
||||
// 计算总折扣价(原价 - 实付价格)
|
||||
const calculateTotalDiscount = () => {
|
||||
const originalPrice = parseFloat(price)
|
||||
const actualPrice = parseFloat(discountedPrice)
|
||||
if (isNaN(originalPrice) || isNaN(actualPrice)) {
|
||||
return '0.00'
|
||||
}
|
||||
const discount = originalPrice - actualPrice
|
||||
return discount.toFixed(2)
|
||||
}
|
||||
|
||||
const totalDiscount = calculateTotalDiscount()
|
||||
const hasDiscount = parseFloat(totalDiscount) > 0
|
||||
return (
|
||||
<Card className={merge(
|
||||
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
|
||||
)}>
|
||||
<h3>订单详情</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐名称</span>
|
||||
<span className="text-sm">
|
||||
{mode === '2' ? `包量套餐` : `包时套餐`}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">IP 时效</span>
|
||||
<span className="text-sm">
|
||||
{live}
|
||||
{' '}
|
||||
分钟
|
||||
</span>
|
||||
</li>
|
||||
{mode === '2' ? (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">购买 IP 量</span>
|
||||
<span className="text-sm">
|
||||
{quota}
|
||||
个
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐时长</span>
|
||||
<span className="text-sm">
|
||||
{expire}
|
||||
天
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">每日限额</span>
|
||||
<span className="text-sm">
|
||||
{dailyLimit}
|
||||
个
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<div className="border-b border-gray-200"></div>
|
||||
<p className="flex justify-between items-center">
|
||||
<span>实付价格</span>
|
||||
<span className="text-xl text-orange-500">
|
||||
¥{discountedPrice}
|
||||
</span>
|
||||
</p>
|
||||
<Suspense>
|
||||
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
|
||||
</Suspense>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BalanceOrLogin(props: {
|
||||
method: 'wechat' | 'alipay' | 'balance'
|
||||
discountedPrice: string
|
||||
mode: string
|
||||
live: string
|
||||
quota: number
|
||||
expire: string
|
||||
dailyLimit: number
|
||||
}) {
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
return profile ? (
|
||||
<>
|
||||
<FieldPayment/>
|
||||
<Pay
|
||||
method={props.method}
|
||||
balance={profile.balance}
|
||||
amount={props.discountedPrice}
|
||||
resource={{
|
||||
type: 2,
|
||||
long: {
|
||||
mode: Number(props.mode),
|
||||
live: Number(props.live),
|
||||
expire: props.mode === '1' ? Number(props.expire) : undefined,
|
||||
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
|
||||
},
|
||||
}}/>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import {
|
||||
} from '@/lib/models/trade'
|
||||
import {PaymentModal} from '@/components/composites/payment/payment-modal'
|
||||
import {PaymentProps} from '@/components/composites/payment/type'
|
||||
import {usePlatformType} from '@/lib/hooks'
|
||||
|
||||
export type PayProps = {
|
||||
amount: string
|
||||
@@ -32,36 +31,35 @@ export default function Pay(props: PayProps) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const [trade, setTrade] = useState<PaymentProps | null>(null)
|
||||
const router = useRouter()
|
||||
// const platform = usePlatformType()
|
||||
|
||||
const onOpen = async () => {
|
||||
setOpen(true)
|
||||
|
||||
if (props.method === 'balance') return
|
||||
if (props.method === 'balance') {
|
||||
return
|
||||
}
|
||||
|
||||
const method = props.method === 'alipay'
|
||||
? TradeMethod.SftAlipay
|
||||
: TradeMethod.SftWechat
|
||||
const req = {
|
||||
const response = await prepareResource({
|
||||
...props.resource,
|
||||
payment_method: method,
|
||||
payment_platform: TradePlatform.Desktop,
|
||||
}
|
||||
})
|
||||
|
||||
const resp = await prepareResource(req)
|
||||
|
||||
if (!resp.success) {
|
||||
toast.error(`创建订单失败: ${resp.message}`)
|
||||
if (!response.success) {
|
||||
toast.error(`创建订单失败: ${response.message}`)
|
||||
setOpen(false)
|
||||
return
|
||||
}
|
||||
|
||||
setTrade({
|
||||
inner_no: resp.data.trade_no,
|
||||
pay_url: resp.data.pay_url,
|
||||
inner_no: response.data.trade_no,
|
||||
pay_url: response.data.pay_url,
|
||||
amount: Number(props.amount),
|
||||
platform: TradePlatform.Desktop,
|
||||
method: method,
|
||||
method,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -112,7 +110,6 @@ export default function Pay(props: PayProps) {
|
||||
立即支付
|
||||
</Button>
|
||||
|
||||
{/* 余额支付对话框 */}
|
||||
{props.method === 'balance' && (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogContent>
|
||||
@@ -178,7 +175,6 @@ export default function Pay(props: PayProps) {
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* 支付宝/微信支付 */}
|
||||
{props.method !== 'balance' && trade && (
|
||||
<PaymentModal
|
||||
{...trade}
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
'use client'
|
||||
|
||||
import {useFormContext} from 'react-hook-form'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import FormOption from '../option'
|
||||
import {PurchaseMode} from './resource'
|
||||
import {PurchaseFormValues} from './form-values'
|
||||
|
||||
export function BillingMethodField(props: {
|
||||
modeList: PurchaseMode[]
|
||||
timeDailyLimit: number
|
||||
}) {
|
||||
const {setValue} = useFormContext<PurchaseFormValues>()
|
||||
|
||||
return (
|
||||
<FormField<PurchaseFormValues, 'type'>
|
||||
className="flex flex-col gap-4"
|
||||
name="type"
|
||||
label="计费方式"
|
||||
>
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
|
||||
if (value === '2') {
|
||||
setValue('expire', '0')
|
||||
return
|
||||
}
|
||||
|
||||
setValue('daily_limit', props.timeDailyLimit)
|
||||
}}
|
||||
className="flex gap-4 max-md:flex-col"
|
||||
>
|
||||
{props.modeList.includes('2') && (
|
||||
<FormOption
|
||||
id={`${id}-2`}
|
||||
value="2"
|
||||
label="包量套餐"
|
||||
description="适用于短期或不定期高提取业务场景"
|
||||
compare={field.value}
|
||||
/>
|
||||
)}
|
||||
|
||||
{props.modeList.includes('1') && (
|
||||
<FormOption
|
||||
id={`${id}-1`}
|
||||
value="1"
|
||||
label="包时套餐"
|
||||
description="适用于每日提取量稳定的业务场景"
|
||||
compare={field.value}
|
||||
/>
|
||||
)}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
35
src/components/composites/purchase/shared/feature-list.tsx
Normal file
35
src/components/composites/purchase/shared/feature-list.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
'use client'
|
||||
|
||||
import Image from 'next/image'
|
||||
import check from '../_assets/check.svg'
|
||||
|
||||
const defaultFeatures = [
|
||||
'支持高并发提取',
|
||||
'指定省份、城市或混播',
|
||||
'账密+白名单验证',
|
||||
'完备的API接口',
|
||||
'IP时效3-30分钟(可定制)',
|
||||
'IP资源定期筛选',
|
||||
'包量/包时计费方式',
|
||||
'每日去重量:500万',
|
||||
]
|
||||
|
||||
export function FeatureList(props: {
|
||||
items?: string[]
|
||||
}) {
|
||||
const items = props.items || defaultFeatures
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<h3>产品特性</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
|
||||
{items.map(item => (
|
||||
<p key={item} className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">{item}</span>
|
||||
</p>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -6,20 +6,16 @@ import alipay from '../_assets/alipay.svg'
|
||||
import wechat from '../_assets/wechat.svg'
|
||||
import balance from '../_assets/balance.svg'
|
||||
import RechargeModal from '@/components/composites/recharge'
|
||||
import {useProfileStore} from '@/components/stores/profile'
|
||||
import {use} from 'react'
|
||||
import Link from 'next/link'
|
||||
import {buttonVariants} from '@/components/ui/button'
|
||||
|
||||
export function FieldPayment() {
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
return profile ? (
|
||||
export function FieldPayment(props: {
|
||||
balance: number
|
||||
}) {
|
||||
return (
|
||||
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
defaultValue={field.value}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
className="flex flex-col gap-3">
|
||||
|
||||
@@ -29,7 +25,7 @@ export function FieldPayment() {
|
||||
<span className="text-sm text-gray-500">账户余额</span>
|
||||
</p>
|
||||
<p className="flex justify-between items-center">
|
||||
<span className="text-xl">{profile.balance}</span>
|
||||
<span className="text-xl">{props.balance}</span>
|
||||
<RechargeModal/>
|
||||
</p>
|
||||
</div>
|
||||
@@ -60,9 +56,5 @@ export function FieldPayment() {
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
|
||||
8
src/components/composites/purchase/shared/form-values.ts
Normal file
8
src/components/composites/purchase/shared/form-values.ts
Normal file
@@ -0,0 +1,8 @@
|
||||
export type PurchaseFormValues = {
|
||||
type: '1' | '2'
|
||||
live: string
|
||||
quota: number
|
||||
expire: string
|
||||
daily_limit: number
|
||||
pay_type: 'wechat' | 'alipay' | 'balance'
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
'use client'
|
||||
|
||||
import {useFormContext} from 'react-hook-form'
|
||||
import {Minus, Plus} from 'lucide-react'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {PurchaseFormValues} from './form-values'
|
||||
|
||||
type PurchaseStepperFieldName = 'quota' | 'daily_limit'
|
||||
|
||||
type NumberStepperFieldProps = {
|
||||
name: PurchaseStepperFieldName
|
||||
label: string
|
||||
min: number
|
||||
step: number
|
||||
}
|
||||
|
||||
export function NumberStepperField(props: NumberStepperFieldProps) {
|
||||
const form = useFormContext<PurchaseFormValues>()
|
||||
|
||||
const setValue = (value: number) => {
|
||||
form.setValue(props.name, value)
|
||||
}
|
||||
|
||||
return (
|
||||
<FormField<PurchaseFormValues, PurchaseStepperFieldName>
|
||||
className="space-y-4"
|
||||
name={props.name}
|
||||
label={props.label}
|
||||
>
|
||||
{({id, field}) => {
|
||||
const value = Number(field.value) || props.min
|
||||
return (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => setValue(Math.max(props.min, value - props.step))}
|
||||
disabled={value === props.min}
|
||||
>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={props.min}
|
||||
step={props.step}
|
||||
onInvalid={(e) => {
|
||||
e.preventDefault()
|
||||
}}
|
||||
onBlur={(event) => {
|
||||
field.onBlur()
|
||||
const nextValue = Number(event.target.value)
|
||||
if (nextValue < props.min) {
|
||||
setValue(props.min)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => setValue(value + props.step)}
|
||||
>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}}
|
||||
</FormField>
|
||||
)
|
||||
}
|
||||
38
src/components/composites/purchase/shared/resource.ts
Normal file
38
src/components/composites/purchase/shared/resource.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import {CreateResourceReq} from '@/actions/resource'
|
||||
|
||||
export type PurchaseKind = 'short' | 'long'
|
||||
export type PurchaseMode = '1' | '2'
|
||||
|
||||
export type PurchaseSelection = {
|
||||
kind: PurchaseKind
|
||||
mode: PurchaseMode
|
||||
live: string
|
||||
quota: number
|
||||
expire: string
|
||||
dailyLimit: number
|
||||
}
|
||||
|
||||
function getPurchasePayload(selection: PurchaseSelection) {
|
||||
return {
|
||||
mode: Number(selection.mode),
|
||||
live: Number(selection.live),
|
||||
expire: selection.mode === '1' ? Number(selection.expire) : undefined,
|
||||
quota: selection.mode === '1' ? Number(selection.dailyLimit) : Number(selection.quota),
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPurchaseResource(selection: PurchaseSelection): CreateResourceReq {
|
||||
const payload = getPurchasePayload(selection)
|
||||
|
||||
if (selection.kind === 'short') {
|
||||
return {
|
||||
type: 1,
|
||||
short: payload,
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
type: 2,
|
||||
long: payload,
|
||||
}
|
||||
}
|
||||
205
src/components/composites/purchase/shared/side-panel.tsx
Normal file
205
src/components/composites/purchase/shared/side-panel.tsx
Normal file
@@ -0,0 +1,205 @@
|
||||
'use client'
|
||||
|
||||
import {use, useEffect, useRef, useState} from 'react'
|
||||
import Link from 'next/link'
|
||||
import {useFormContext, useWatch} from 'react-hook-form'
|
||||
import {Card} from '@/components/ui/card'
|
||||
import {buttonVariants} from '@/components/ui/button'
|
||||
import {useProfileStore} from '@/components/stores/profile'
|
||||
import Pay from '@/components/composites/purchase/pay'
|
||||
import {FieldPayment} from './field-payment'
|
||||
import {buildPurchaseResource, PurchaseKind, PurchaseSelection} from './resource'
|
||||
import {getPrice, getPriceHome} from '@/actions/resource'
|
||||
import {ExtraResp} from '@/lib/api'
|
||||
import {formatPurchaseLiveLabel} from './sku'
|
||||
import {User} from '@/lib/models'
|
||||
import {PurchaseFormValues} from './form-values'
|
||||
import {IdCard} from 'lucide-react'
|
||||
|
||||
const emptyPrice: ExtraResp<typeof getPrice> = {
|
||||
price: '0.00',
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
}
|
||||
|
||||
export type PurchaseSidePanelProps = {
|
||||
kind: PurchaseKind
|
||||
}
|
||||
|
||||
export function PurchaseSidePanel(props: PurchaseSidePanelProps) {
|
||||
const {control} = useFormContext<PurchaseFormValues>()
|
||||
const method = useWatch<PurchaseFormValues>({control, name: 'pay_type'}) as PurchaseFormValues['pay_type']
|
||||
const mode = useWatch<PurchaseFormValues>({control, name: 'type'}) as PurchaseFormValues['type']
|
||||
const live = useWatch<PurchaseFormValues>({control, name: 'live'}) as PurchaseFormValues['live']
|
||||
const quota = useWatch<PurchaseFormValues>({control, name: 'quota'}) as PurchaseFormValues['quota']
|
||||
const expire = useWatch<PurchaseFormValues>({control, name: 'expire'}) as PurchaseFormValues['expire']
|
||||
const dailyLimit = useWatch<PurchaseFormValues>({control, name: 'daily_limit'}) as PurchaseFormValues['daily_limit']
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
const selection: PurchaseSelection = {
|
||||
kind: props.kind,
|
||||
mode,
|
||||
live,
|
||||
quota,
|
||||
expire,
|
||||
dailyLimit,
|
||||
}
|
||||
const priceData = usePurchasePrice(profile, selection)
|
||||
const {price, actual: discountedPrice = '0.00'} = priceData
|
||||
const totalDiscount = getTotalDiscount(price, discountedPrice)
|
||||
const hasDiscount = Number(totalDiscount) > 0
|
||||
const liveLabel = formatPurchaseLiveLabel(live, props.kind)
|
||||
const resource = buildPurchaseResource(selection)
|
||||
|
||||
return (
|
||||
<Card className="flex-none basis-90 p-6 flex flex-col gap-6 relative">
|
||||
<h3>订单详情</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐名称</span>
|
||||
<span className="text-sm">{mode === '2' ? '包量套餐' : '包时套餐'}</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">IP 时效</span>
|
||||
<span className="text-sm">{liveLabel}</span>
|
||||
</li>
|
||||
{mode === '2' ? (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">购买 IP 量</span>
|
||||
<span className="text-sm">{quota} 个</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">¥{price}</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">-¥{totalDiscount}</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐时长</span>
|
||||
<span className="text-sm">{expire} 天</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">每日限额</span>
|
||||
<span className="text-sm">{dailyLimit} 个</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">¥{price}</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">-¥{totalDiscount}</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<div className="border-b border-gray-200"></div>
|
||||
<p className="flex justify-between items-center">
|
||||
<span>实付价格</span>
|
||||
<span className="text-xl text-orange-500">¥{discountedPrice}</span>
|
||||
</p>
|
||||
{profile ? (
|
||||
profile.id_type !== 0 ? (
|
||||
<>
|
||||
<FieldPayment balance={profile.balance}/>
|
||||
<Pay
|
||||
method={method}
|
||||
balance={profile.balance}
|
||||
amount={discountedPrice}
|
||||
resource={resource}
|
||||
/>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
<p className="text-sm text-gray-500">
|
||||
根据监管要求,您需要完成实名认证后才能支付。
|
||||
</p>
|
||||
<Link
|
||||
href="/admin/identify"
|
||||
className={buttonVariants()}
|
||||
>
|
||||
<IdCard size={16} className="mr-1"/>
|
||||
去实名认证
|
||||
</Link>
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)}
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function usePurchasePrice(profile: User | null, selection: PurchaseSelection) {
|
||||
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>(emptyPrice)
|
||||
const requestIdRef = useRef(0)
|
||||
const {kind, mode, live, quota, expire, dailyLimit} = selection
|
||||
|
||||
useEffect(() => {
|
||||
const requestId = ++requestIdRef.current
|
||||
|
||||
const loadPrice = async () => {
|
||||
try {
|
||||
const resource = buildPurchaseResource({
|
||||
kind,
|
||||
mode,
|
||||
live,
|
||||
quota,
|
||||
expire,
|
||||
dailyLimit,
|
||||
})
|
||||
const response = profile
|
||||
? await getPrice(resource)
|
||||
: await getPriceHome(resource)
|
||||
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
if (!response.success) {
|
||||
throw new Error(response.message || '获取价格失败')
|
||||
}
|
||||
|
||||
setPriceData({
|
||||
price: response.data.price,
|
||||
actual: response.data.actual ?? response.data.price ?? '0.00',
|
||||
discounted: response.data.discounted ?? '0.00',
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
if (requestId !== requestIdRef.current) {
|
||||
return
|
||||
}
|
||||
|
||||
console.error('获取价格失败:', error)
|
||||
setPriceData(emptyPrice)
|
||||
}
|
||||
}
|
||||
|
||||
loadPrice()
|
||||
}, [dailyLimit, expire, kind, live, mode, profile, quota])
|
||||
|
||||
return priceData
|
||||
}
|
||||
|
||||
function getTotalDiscount(price: string, discountedPrice: string) {
|
||||
const originalPrice = Number.parseFloat(price)
|
||||
const actualPrice = Number.parseFloat(discountedPrice)
|
||||
|
||||
if (Number.isNaN(originalPrice) || Number.isNaN(actualPrice)) {
|
||||
return '0.00'
|
||||
}
|
||||
|
||||
return (originalPrice - actualPrice).toFixed(2)
|
||||
}
|
||||
180
src/components/composites/purchase/shared/sku.ts
Normal file
180
src/components/composites/purchase/shared/sku.ts
Normal file
@@ -0,0 +1,180 @@
|
||||
import {ProductItem} from '@/actions/product'
|
||||
import {PurchaseKind, PurchaseMode} from './resource'
|
||||
|
||||
export type PurchaseSkuItem = {
|
||||
code: string
|
||||
mode: PurchaseMode
|
||||
live: string
|
||||
expire: string
|
||||
price: string
|
||||
count_min: number
|
||||
}
|
||||
|
||||
export type PurchaseSkuData = {
|
||||
items: PurchaseSkuItem[]
|
||||
priceMap: Map<string, string>
|
||||
countMinMap: Map<string, number>
|
||||
modeList: PurchaseMode[]
|
||||
liveList: string[]
|
||||
expireList: string[]
|
||||
}
|
||||
|
||||
export function parsePurchaseSkuList(kind: PurchaseKind, skuList: ProductItem['skus']): PurchaseSkuData {
|
||||
if (!skuList?.length) {
|
||||
throw new Error('没有套餐数据')
|
||||
}
|
||||
|
||||
const items: PurchaseSkuItem[] = []
|
||||
const priceMap = new Map<string, string>()
|
||||
const countMinMap = new Map<string, number>()
|
||||
const modeSet = new Set<PurchaseMode>()
|
||||
const liveSet = new Set<number>()
|
||||
const expireSet = new Set<number>()
|
||||
|
||||
for (const sku of skuList) {
|
||||
const params = new URLSearchParams(sku.code)
|
||||
const mode = parsePurchaseSkuMode(params.get('mode'))
|
||||
const live = Number(params.get('live') || '0')
|
||||
const expire = Number(params.get('expire') || '0')
|
||||
|
||||
if (!mode || live <= 0) {
|
||||
continue
|
||||
}
|
||||
|
||||
const liveValue = String(live)
|
||||
const expireValue = mode === '1' ? String(expire || '0') : '0'
|
||||
const code = getPurchaseSkuKey({
|
||||
mode,
|
||||
live: liveValue,
|
||||
expire: expireValue,
|
||||
})
|
||||
const countMin = typeof sku.count_min === 'number' ? sku.count_min : Number(sku.count_min) || 0
|
||||
countMinMap.set(code, countMin)
|
||||
|
||||
items.push({
|
||||
code,
|
||||
mode,
|
||||
live: liveValue,
|
||||
expire: expireValue,
|
||||
price: sku.price,
|
||||
count_min: countMin,
|
||||
})
|
||||
priceMap.set(code, sku.price)
|
||||
modeSet.add(mode)
|
||||
|
||||
liveSet.add(live)
|
||||
|
||||
if (kind === 'short') {
|
||||
if (mode === '1' && expire > 0) {
|
||||
expireSet.add(expire)
|
||||
}
|
||||
}
|
||||
else if (expire > 0) {
|
||||
expireSet.add(expire)
|
||||
}
|
||||
}
|
||||
|
||||
if (items.length === 0) {
|
||||
throw new Error('没有可用的套餐数据')
|
||||
}
|
||||
|
||||
return {
|
||||
items,
|
||||
priceMap,
|
||||
countMinMap,
|
||||
modeList: (['2', '1'] as const).filter(mode => modeSet.has(mode)),
|
||||
liveList: sortNumericValues(liveSet),
|
||||
expireList: sortNumericValues(expireSet),
|
||||
}
|
||||
}
|
||||
|
||||
function parsePurchaseSkuMode(mode: string | null): PurchaseMode | null {
|
||||
if (mode === 'time') {
|
||||
return '1'
|
||||
}
|
||||
|
||||
if (mode === 'quota') {
|
||||
return '2'
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
function sortNumericValues(values: Iterable<number>) {
|
||||
return Array.from(values).sort((a, b) => a - b).map(String)
|
||||
}
|
||||
|
||||
export function getPurchaseSkuKey(props: {
|
||||
mode: PurchaseMode
|
||||
live: string
|
||||
expire: string
|
||||
}) {
|
||||
const params = new URLSearchParams()
|
||||
params.set('mode', props.mode === '1' ? 'time' : 'quota')
|
||||
params.set('live', props.live || '0')
|
||||
params.set('expire', props.mode === '1' ? props.expire || '0' : '0')
|
||||
return params.toString()
|
||||
}
|
||||
|
||||
export function getAvailablePurchaseLives(skuData: PurchaseSkuData, props: {
|
||||
mode: PurchaseMode
|
||||
expire?: string
|
||||
}) {
|
||||
return sortNumericValues(new Set(
|
||||
skuData.items
|
||||
.filter(item => item.mode === props.mode)
|
||||
.filter(item => !props.expire || item.expire === props.expire)
|
||||
.map(item => Number(item.live)),
|
||||
))
|
||||
}
|
||||
|
||||
export function getAvailablePurchaseExpires(skuData: PurchaseSkuData, props: {
|
||||
mode: PurchaseMode
|
||||
live?: string
|
||||
}) {
|
||||
return sortNumericValues(new Set(
|
||||
skuData.items
|
||||
.filter(item => item.mode === props.mode)
|
||||
.filter(item => !props.live || item.live === props.live)
|
||||
.filter(item => item.expire !== '0')
|
||||
.map(item => Number(item.expire)),
|
||||
))
|
||||
}
|
||||
|
||||
export function hasPurchaseSku(skuData: PurchaseSkuData, props: {
|
||||
mode: PurchaseMode
|
||||
live: string
|
||||
expire: string
|
||||
}) {
|
||||
return skuData.priceMap.has(getPurchaseSkuKey(props))
|
||||
}
|
||||
|
||||
export function getPurchaseSkuPrice(priceMap: Map<string, string>, props: {
|
||||
mode: PurchaseMode
|
||||
live: string
|
||||
expire: string
|
||||
}) {
|
||||
return priceMap.get(getPurchaseSkuKey(props))
|
||||
}
|
||||
|
||||
export function formatPurchaseLiveLabel(live: string, kind: PurchaseKind) {
|
||||
const minutes = Number(live)
|
||||
|
||||
if (kind === 'long') {
|
||||
return `${minutes / 60} 小时`
|
||||
}
|
||||
|
||||
if (minutes % 60 === 0) {
|
||||
return `${minutes / 60} 小时`
|
||||
}
|
||||
|
||||
return `${minutes} 分钟`
|
||||
}
|
||||
|
||||
export function getPurchaseSkuCountMin(
|
||||
skuData: PurchaseSkuData,
|
||||
props: {mode: PurchaseMode, live: string, expire: string},
|
||||
): number {
|
||||
const key = getPurchaseSkuKey(props)
|
||||
return skuData.countMinMap.get(key) ?? 0
|
||||
}
|
||||
@@ -1,71 +1,91 @@
|
||||
'use client'
|
||||
import {FormField} from '@/components/ui/form'
|
||||
import {RadioGroup} from '@/components/ui/radio-group'
|
||||
import {Input} from '@/components/ui/input'
|
||||
import {Button} from '@/components/ui/button'
|
||||
import {Minus, Plus} from 'lucide-react'
|
||||
import FormOption from '@/components/composites/purchase/option'
|
||||
import Image from 'next/image'
|
||||
import check from '../_assets/check.svg'
|
||||
import {useEffect, useMemo} from 'react'
|
||||
import {useFormContext, useWatch} from 'react-hook-form'
|
||||
import {Schema} from '@/components/composites/purchase/short/form'
|
||||
import {Card} from '@/components/ui/card'
|
||||
import {BillingMethodField} from '../shared/billing-method-field'
|
||||
import {FeatureList} from '../shared/feature-list'
|
||||
import {NumberStepperField} from '../shared/number-stepper-field'
|
||||
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
|
||||
|
||||
export default function Center({
|
||||
priceMap,
|
||||
liveList,
|
||||
expireList,
|
||||
skuData,
|
||||
}: {
|
||||
priceMap: Map<string, string>
|
||||
liveList: string[]
|
||||
expireList: string[]
|
||||
skuData: PurchaseSkuData
|
||||
}) {
|
||||
const form = useFormContext<Schema>()
|
||||
const type = useWatch({name: 'type'})
|
||||
const expire = useWatch({name: 'expire'})
|
||||
const isTime = type === '1'
|
||||
// const {setValue} = useFormContext<Schema>()
|
||||
const {setValue, getValues} = useFormContext<Schema>()
|
||||
const type = useWatch<Schema>({name: 'type'}) as Schema['type']
|
||||
const live = useWatch<Schema>({name: 'live'}) as Schema['live']
|
||||
const expire = useWatch<Schema>({name: 'expire'}) as Schema['expire']
|
||||
const {modeList, priceMap} = skuData
|
||||
const liveList = type === '1'
|
||||
? getAvailablePurchaseLives(skuData, {mode: type, expire})
|
||||
: getAvailablePurchaseLives(skuData, {mode: type})
|
||||
const expireList = type === '1'
|
||||
? getAvailablePurchaseExpires(skuData, {mode: type, live})
|
||||
: []
|
||||
|
||||
const currentCountMin = useMemo(() => {
|
||||
if (!type || !live) return 0
|
||||
const expireValue = type === '1' ? expire : '0'
|
||||
return getPurchaseSkuCountMin(skuData, {mode: type, live, expire: expireValue})
|
||||
}, [type, live, expire, skuData])
|
||||
useEffect(() => {
|
||||
if (type === '1') {
|
||||
const current = getValues('daily_limit')
|
||||
if (current < currentCountMin) {
|
||||
setValue('daily_limit', currentCountMin)
|
||||
}
|
||||
}
|
||||
else {
|
||||
const current = getValues('quota')
|
||||
if (current < currentCountMin) {
|
||||
setValue('quota', currentCountMin)
|
||||
}
|
||||
}
|
||||
}, [currentCountMin, type, setValue, getValues])
|
||||
useEffect(() => {
|
||||
const nextType = modeList.includes(type) ? type : modeList[0]
|
||||
|
||||
if (!nextType) {
|
||||
return
|
||||
}
|
||||
|
||||
if (nextType !== type) {
|
||||
setValue('type', nextType)
|
||||
return
|
||||
}
|
||||
|
||||
const nextLiveList = nextType === '1'
|
||||
? getAvailablePurchaseLives(skuData, {mode: nextType, expire})
|
||||
: getAvailablePurchaseLives(skuData, {mode: nextType})
|
||||
const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0]
|
||||
|
||||
if (nextLive && nextLive !== live) {
|
||||
setValue('live', nextLive)
|
||||
return
|
||||
}
|
||||
|
||||
if (nextType === '2') {
|
||||
if (expire !== '0') {
|
||||
setValue('expire', '0')
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive})
|
||||
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
|
||||
setValue('expire', nextExpireList[0])
|
||||
}
|
||||
}, [expire, live, modeList, setValue, skuData, type])
|
||||
|
||||
return (
|
||||
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
|
||||
|
||||
{/* 计费方式 */}
|
||||
<FormField<Schema, 'type'>
|
||||
className="flex flex-col gap-4"
|
||||
name="type"
|
||||
label="计费方式">
|
||||
{({id, field}) => (
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(v) => {
|
||||
field.onChange(v)
|
||||
if (v === '2') {
|
||||
form.setValue('expire', '0')
|
||||
}
|
||||
else if (expireList.length > 0) {
|
||||
form.setValue('expire', expireList[0])
|
||||
form.setValue('daily_limit', 2000)
|
||||
}
|
||||
}}
|
||||
className="flex gap-4 max-md:flex-col">
|
||||
|
||||
<FormOption
|
||||
id={`${id}-2`}
|
||||
value="2"
|
||||
label="包量套餐"
|
||||
description="适用于短期或不定期高提取业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
<FormOption
|
||||
id={`${id}-1`}
|
||||
value="1"
|
||||
label="包时套餐"
|
||||
description="适用于每日提取量稳定的业务场景"
|
||||
compare={field.value}/>
|
||||
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
<BillingMethodField modeList={modeList} timeDailyLimit={2000}/>
|
||||
|
||||
{/* IP 时效 */}
|
||||
<FormField<Schema, 'live'>
|
||||
@@ -76,14 +96,28 @@ export default function Center({
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={field.onChange}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
|
||||
if (type !== '1') {
|
||||
return
|
||||
}
|
||||
|
||||
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value})
|
||||
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
|
||||
setValue('expire', nextExpireList[0])
|
||||
}
|
||||
}}
|
||||
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
|
||||
{liveList.map((live) => {
|
||||
const params = new URLSearchParams()
|
||||
params.set('mode', isTime ? 'time' : 'quota')
|
||||
params.set('live', live)
|
||||
params.set('expire', isTime ? expire : '0')
|
||||
const price = priceMap.get(params.toString())
|
||||
const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire})
|
||||
? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0'
|
||||
: String(expire)
|
||||
const price = getPurchaseSkuPrice(priceMap, {
|
||||
mode: type,
|
||||
live,
|
||||
expire: priceExpire,
|
||||
})
|
||||
const minutes = Number(live)
|
||||
const hours = minutes / 60
|
||||
const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟`
|
||||
@@ -93,7 +127,7 @@ export default function Center({
|
||||
id={`${id}-${live}`}
|
||||
value={live}
|
||||
label={label}
|
||||
description={price && `¥${price}${!isTime ? '/IP' : ''}`}
|
||||
description={price && `¥${price}/IP`}
|
||||
compare={field.value}
|
||||
/>
|
||||
)
|
||||
@@ -102,146 +136,54 @@ export default function Center({
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 根据套餐类型显示不同表单项 */}
|
||||
{!isTime ? (
|
||||
/* 包量:IP 购买数量 */
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="quota"
|
||||
label="IP 购买数量">
|
||||
{/* 套餐时效 */}
|
||||
{type === '1' && (
|
||||
<FormField className="space-y-4" name="expire" label="套餐时效">
|
||||
{({id, field}) => (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('quota', Math.max(10000, Number(field.value) - 5000))}
|
||||
disabled={Number(field.value) === 10000}>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={10000}
|
||||
step={5000}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (value < 10000) {
|
||||
form.setValue('quota', 10000)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('quota', Number(field.value) + 5000)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
<RadioGroup
|
||||
id={id}
|
||||
value={field.value}
|
||||
onValueChange={(value) => {
|
||||
field.onChange(value)
|
||||
|
||||
const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
|
||||
if (!nextLiveList.includes(live) && nextLiveList[0]) {
|
||||
setValue('live', nextLiveList[0])
|
||||
}
|
||||
}}
|
||||
className="flex gap-4 flex-wrap">
|
||||
{expireList.map(day => (
|
||||
<FormOption
|
||||
key={day}
|
||||
id={`${id}-${day}`}
|
||||
value={day}
|
||||
label={`${day} 天`}
|
||||
compare={field.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
) : (
|
||||
<>
|
||||
{/* 包时:套餐时效 */}
|
||||
<FormField className="space-y-4" name="expire" label="套餐时效">
|
||||
{({id, field}) => (
|
||||
<RadioGroup id={id} value={field.value} onValueChange={field.onChange} className="flex gap-4 flex-wrap">
|
||||
{expireList.map(day => (
|
||||
<FormOption
|
||||
key={day}
|
||||
id={`${id}-${day}`}
|
||||
value={day}
|
||||
label={`${day} 天`}
|
||||
compare={field.value}
|
||||
/>
|
||||
))}
|
||||
</RadioGroup>
|
||||
)}
|
||||
</FormField>
|
||||
|
||||
{/* 包时:每日提取上限 */}
|
||||
<FormField
|
||||
className="space-y-4"
|
||||
name="daily_limit"
|
||||
label="每日提取上限">
|
||||
{({id, field}) => (
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('daily_limit', Math.max(2000, Number(field.value) - 1000))}
|
||||
disabled={Number(field.value) === 2000}>
|
||||
<Minus/>
|
||||
</Button>
|
||||
<Input
|
||||
{...field}
|
||||
id={id}
|
||||
type="number"
|
||||
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
|
||||
min={2000}
|
||||
step={1000}
|
||||
onBlur={(e) => {
|
||||
const value = Number(e.target.value)
|
||||
if (value < 2000) {
|
||||
form.setValue('daily_limit', 2000)
|
||||
}
|
||||
}}
|
||||
/>
|
||||
<Button
|
||||
theme="outline"
|
||||
type="button"
|
||||
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
|
||||
onClick={() => form.setValue('daily_limit', Number(field.value) + 1000)}>
|
||||
<Plus/>
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</FormField>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 产品特性 */}
|
||||
<div className="space-y-6">
|
||||
<h3>产品特性</h3>
|
||||
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">支持高并发提取</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">指定省份、城市或混播</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">账密+白名单验证</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">完备的API接口</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">IP时效3-30分钟(可定制)</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">IP资源定期筛选</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">包量/包时计费方式</span>
|
||||
</p>
|
||||
<p className="flex gap-2 items-center">
|
||||
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
|
||||
<span className="text-sm text-gray-500">每日去重量:500万</span>
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{/* 每日提取上限/购买数量 */}
|
||||
{type === '1' ? (
|
||||
<NumberStepperField
|
||||
name="daily_limit"
|
||||
label="每日提取上限"
|
||||
min={currentCountMin || 2000}
|
||||
step={1000}
|
||||
/>
|
||||
) : (
|
||||
<NumberStepperField
|
||||
name="quota"
|
||||
label="IP 购买数量"
|
||||
min={currentCountMin || 10000}
|
||||
step={5000}
|
||||
/>
|
||||
)}
|
||||
|
||||
<FeatureList/>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
'use client'
|
||||
import {useForm} from 'react-hook-form'
|
||||
import Center from '@/components/composites/purchase/short/center'
|
||||
import Right from '@/components/composites/purchase/short/right'
|
||||
import {Form} from '@/components/ui/form'
|
||||
import * as z from 'zod'
|
||||
import {zodResolver} from '@hookform/resolvers/zod'
|
||||
import {ProductItem} from '@/actions/product'
|
||||
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuCountMin, parsePurchaseSkuList} from '../shared/sku'
|
||||
import {PurchaseSidePanel} from '../shared/side-panel'
|
||||
|
||||
// 定义表单验证架构
|
||||
const schema = z.object({
|
||||
type: z.enum(['1', '2']).default('2'),
|
||||
live: z.string(),
|
||||
@@ -16,50 +16,36 @@ const schema = z.object({
|
||||
daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'),
|
||||
pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
|
||||
})
|
||||
|
||||
export type Schema = z.infer<typeof schema>
|
||||
|
||||
export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) {
|
||||
if (!skuList?.length) throw new Error('没有套餐数据')
|
||||
|
||||
const priceMap = new Map<string, string>()
|
||||
const _liveList = new Set<number>()
|
||||
const _expireList = new Set<number>()
|
||||
|
||||
for (const sku of skuList) {
|
||||
const params = new URLSearchParams(sku.code)
|
||||
const mode = params.get('mode')
|
||||
const live = params.get('live')
|
||||
const expire = params.get('expire')
|
||||
|
||||
if (live && live !== '0') {
|
||||
_liveList.add(Number(live))
|
||||
}
|
||||
if (mode === 'time' && expire && expire !== '0') {
|
||||
_expireList.add(Number(expire))
|
||||
}
|
||||
priceMap.set(sku.code, sku.price)
|
||||
}
|
||||
|
||||
const liveList = Array.from(_liveList).filter(Boolean).map(String)
|
||||
const expireList = Array.from(_expireList).filter(Boolean).map(String)
|
||||
|
||||
const skuData = parsePurchaseSkuList('short', skuList)
|
||||
const defaultMode = skuData.modeList.includes('2') ? '2' : '1'
|
||||
const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || ''
|
||||
const defaultExpire = defaultMode === '1'
|
||||
? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0'
|
||||
: '0'
|
||||
const defaultCountMin = getPurchaseSkuCountMin(skuData, {
|
||||
mode: defaultMode,
|
||||
live: defaultLive,
|
||||
expire: defaultExpire,
|
||||
})
|
||||
const form = useForm<Schema>({
|
||||
resolver: zodResolver(schema),
|
||||
defaultValues: {
|
||||
type: '2', // 默认为包量套餐
|
||||
live: liveList[0] || '',
|
||||
expire: '0', // 包量模式下无效
|
||||
quota: 10_000, // >= 10000,
|
||||
daily_limit: 2_000, // >= 2000
|
||||
type: defaultMode,
|
||||
live: defaultLive,
|
||||
expire: defaultExpire,
|
||||
quota: defaultMode === '2' ? defaultCountMin || 10000 : 10000,
|
||||
daily_limit: defaultMode === '1' ? defaultCountMin || 2000 : 2000,
|
||||
pay_type: 'balance', // 余额支付
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<Form form={form} className="flex flex-col lg:flex-row gap-4">
|
||||
<Center {...{priceMap, liveList, expireList}}/>
|
||||
<Right/>
|
||||
<Center skuData={skuData}/>
|
||||
<PurchaseSidePanel kind="short"/>
|
||||
</Form>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,216 +0,0 @@
|
||||
'use client'
|
||||
import {Suspense, use, useEffect, useState} from 'react'
|
||||
import {Schema} from '@/components/composites/purchase/short/form'
|
||||
import {useProfileStore} from '@/components/stores/profile'
|
||||
import {buttonVariants} from '@/components/ui/button'
|
||||
import Link from 'next/link'
|
||||
import {merge} from '@/lib/utils'
|
||||
import Pay from '@/components/composites/purchase/pay'
|
||||
import {useFormContext, useWatch} from 'react-hook-form'
|
||||
import {Card} from '@/components/ui/card'
|
||||
import {getPrice, getPriceHome} from '@/actions/resource'
|
||||
import {ExtraResp} from '@/lib/api'
|
||||
import {FieldPayment} from '../shared/field-payment'
|
||||
import {ProductItem} from '@/actions/product'
|
||||
|
||||
export default function Right() {
|
||||
const {control} = useFormContext<Schema>()
|
||||
const method = useWatch({control, name: 'pay_type'})
|
||||
const live = useWatch({control, name: 'live'})
|
||||
const mode = useWatch({control, name: 'type'})
|
||||
const expire = useWatch({control, name: 'expire'})
|
||||
const quota = useWatch({control, name: 'quota'})
|
||||
const dailyLimit = useWatch({control, name: 'daily_limit'})
|
||||
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
|
||||
price: '0.00',
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
|
||||
useEffect(() => {
|
||||
const price = async () => {
|
||||
try {
|
||||
const priceResponse = profile
|
||||
? await getPrice({
|
||||
type: 1,
|
||||
short: {
|
||||
live: Number(live),
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
})
|
||||
: await getPriceHome({
|
||||
type: 1,
|
||||
short: {
|
||||
live: Number(live),
|
||||
mode: Number(mode),
|
||||
quota: mode === '1' ? Number(dailyLimit) : Number(quota),
|
||||
expire: mode === '1' ? Number(expire) : undefined,
|
||||
},
|
||||
})
|
||||
|
||||
if (!priceResponse.success) {
|
||||
throw new Error('获取价格失败')
|
||||
}
|
||||
|
||||
const data = priceResponse.data
|
||||
setPriceData({
|
||||
price: data.price,
|
||||
actual: data.actual ?? data.price ?? '',
|
||||
discounted: data.discounted,
|
||||
})
|
||||
}
|
||||
catch (error) {
|
||||
console.error('获取价格失败:', error)
|
||||
setPriceData({
|
||||
price: '0.00',
|
||||
actual: '0.00',
|
||||
discounted: '0.00',
|
||||
})
|
||||
}
|
||||
}
|
||||
price()
|
||||
}, [expire, live, quota, mode, dailyLimit, profile])
|
||||
|
||||
const {price, actual: discountedPrice = ''} = priceData
|
||||
|
||||
// 计算总折扣价(原价 - 实付价格)
|
||||
const calculateTotalDiscount = () => {
|
||||
const originalPrice = parseFloat(price)
|
||||
const actualPrice = parseFloat(discountedPrice)
|
||||
if (isNaN(originalPrice) || isNaN(actualPrice)) {
|
||||
return '0.00'
|
||||
}
|
||||
const discount = originalPrice - actualPrice
|
||||
return discount.toFixed(2)
|
||||
}
|
||||
|
||||
const totalDiscount = calculateTotalDiscount()
|
||||
const hasDiscount = parseFloat(totalDiscount) > 0
|
||||
|
||||
return (
|
||||
<Card className={merge(
|
||||
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
|
||||
)}>
|
||||
<h3>订单详情</h3>
|
||||
<ul className="flex flex-col gap-3">
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐名称</span>
|
||||
<span className="text-sm">
|
||||
{mode === '2' ? `包量套餐` : `包时套餐`}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">IP 时效</span>
|
||||
<span className="text-sm">
|
||||
{live}
|
||||
{' '}
|
||||
分钟
|
||||
</span>
|
||||
</li>
|
||||
{mode === '2' ? (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">购买 IP 量</span>
|
||||
<span className="text-sm">
|
||||
{quota}
|
||||
个
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">套餐时长</span>
|
||||
<span className="text-sm">
|
||||
{expire}
|
||||
天
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">每日限额</span>
|
||||
<span className="text-sm">
|
||||
{dailyLimit}
|
||||
个
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">原价</span>
|
||||
<span className="text-sm">
|
||||
¥{price}
|
||||
</span>
|
||||
</li>
|
||||
{hasDiscount && (
|
||||
<li className="flex justify-between items-center">
|
||||
<span className="text-sm text-gray-500">总折扣</span>
|
||||
<span className="text-sm">
|
||||
-¥{totalDiscount}
|
||||
</span>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ul>
|
||||
<div className="border-b border-gray-200"></div>
|
||||
<p className="flex justify-between items-center">
|
||||
<span>实付价格</span>
|
||||
<span className="text-xl text-orange-500">
|
||||
¥{discountedPrice}
|
||||
</span>
|
||||
</p>
|
||||
<Suspense>
|
||||
<BalanceOrLogin {...{method, discountedPrice, mode, live, quota, expire, dailyLimit}}/>
|
||||
</Suspense>
|
||||
</Card>
|
||||
)
|
||||
}
|
||||
|
||||
function BalanceOrLogin(props: {
|
||||
method: 'wechat' | 'alipay' | 'balance'
|
||||
discountedPrice: string
|
||||
mode: string
|
||||
live: string
|
||||
quota: number
|
||||
expire: string
|
||||
dailyLimit: number
|
||||
}) {
|
||||
const profile = use(useProfileStore(store => store.profile))
|
||||
return profile ? (
|
||||
<>
|
||||
<FieldPayment/>
|
||||
<Pay
|
||||
method={props.method}
|
||||
balance={profile.balance}
|
||||
amount={props.discountedPrice}
|
||||
resource={{
|
||||
type: 1,
|
||||
short: {
|
||||
mode: Number(props.mode),
|
||||
live: Number(props.live),
|
||||
expire: props.mode === '1' ? Number(props.expire) : undefined,
|
||||
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
|
||||
},
|
||||
}}/>
|
||||
</>
|
||||
) : (
|
||||
<Link href="/login" className={buttonVariants()}>
|
||||
登录后支付
|
||||
</Link>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
export type ProductSku = {
|
||||
id: number
|
||||
code: string
|
||||
count_min: number
|
||||
name: string
|
||||
price: string
|
||||
price_min: string
|
||||
|
||||
Reference in New Issue
Block a user