2 Commits

Author SHA1 Message Date
5f7756199a 修复 sku 缺失显示问题 2026-04-18 15:47:20 +08:00
6aa108e8d3 优化完善套餐购买页面 2026-04-18 15:35:35 +08:00
17 changed files with 871 additions and 1220 deletions

View File

@@ -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>
</>
)
}

View File

@@ -41,7 +41,6 @@ export default function Purchase() {
const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = { const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = {
short: ShortForm, short: ShortForm,
long: LongForm, long: LongForm,
// static: StaticForm
} }
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">

View File

@@ -1,292 +1,164 @@
'use client' 'use client'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group' 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 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 {Schema} from '@/components/composites/purchase/long/form'
import {useEffect} from 'react'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card' 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, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
export default function Center({map, expireList, liveList}: { export default function Center({skuData}: {
map: Map<string, string> skuData: PurchaseSkuData
liveList: string[]
expireList: string[]
}) { }) {
const form = useFormContext<Schema>() const {setValue} = useFormContext<Schema>()
const type = useWatch({name: 'type'}) 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})
: []
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 ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-6 relative">
<BillingMethodField modeList={modeList} timeDailyLimit={100}/>
{/* 计费方式 */}
<BillingMethod expireList={expireList}/>
{/* IP 时效 */} {/* 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)
{/* 根据套餐类型显示不同表单项 */} if (type !== '1') {
{type === '2' ? ( return
/* 包量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}/>
{/* 包时:每日提取上限 */} const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: type, live: value})
<FormField if (!nextExpireList.includes(expire) && nextExpireList[0]) {
className="space-y-4" setValue('expire', nextExpireList[0])
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>
)
}} }}
</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"> {type === '1' ? (
<h3></h3> <NumberStepperField
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 gap-y-6"> name="daily_limit"
<p className="flex gap-2 items-center"> label="每日提取上限"
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> min={100}
<span className="text-sm text-gray-500"></span> step={100}
</p> />
<p className="flex gap-2 items-center"> ) : (
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <NumberStepperField
<span className="text-sm text-gray-500"></span> name="quota"
</p> label="IP 购买数量"
<p className="flex gap-2 items-center"> min={500}
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> step={100}
<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"/> <FeatureList/>
<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>
</Card> </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>
)
}

View File

@@ -1,13 +1,13 @@
'use client' 'use client'
import {useForm} from 'react-hook-form' import {useForm} from 'react-hook-form'
import Center from '@/components/composites/purchase/long/center' import Center from '@/components/composites/purchase/long/center'
import Right from '@/components/composites/purchase/long/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product' import {ProductItem} from '@/actions/product'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, parsePurchaseSkuList} from '../shared/sku'
import {PurchaseSidePanel} from '../shared/side-panel'
// 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.string(), live: z.string(),
@@ -16,34 +16,22 @@ const schema = z.object({
daily_limit: z.number().min(100, '每日限额不能少于 100 个'), daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']),
}) })
// 从架构中推断类型
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
export default function LongForm({skuList}: {skuList: ProductItem['skus']}) { export default function LongForm({skuList}: {skuList: ProductItem['skus']}) {
if (!skuList) throw new Error('没有套餐数据') const skuData = parsePurchaseSkuList('long', skuList)
const defaultMode = skuData.modeList.includes('2') ? '2' : '1'
const map = new Map<string, string>() const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || ''
// const _modeList = new Set<string>() const defaultExpire = defaultMode === '1'
const _liveList = new Set<number>() ? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0'
const _expireList = new Set<number>() : '0'
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 form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: defaultMode,
live: liveList[0], // 分钟 live: defaultLive,
expire: '0', // 天 expire: defaultExpire,
quota: 500, quota: 500,
daily_limit: 100, daily_limit: 100,
pay_type: 'balance', // 余额支付 pay_type: 'balance', // 余额支付
@@ -52,8 +40,8 @@ export default function LongForm({skuList}: {skuList: ProductItem['skus']}) {
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center {...{liveList, map, expireList}}/> <Center skuData={skuData}/>
<Right/> <PurchaseSidePanel kind="long"/>
</Form> </Form>
) )
} }

View File

@@ -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>
)
}

View File

@@ -15,7 +15,6 @@ import {
} from '@/lib/models/trade' } from '@/lib/models/trade'
import {PaymentModal} from '@/components/composites/payment/payment-modal' import {PaymentModal} from '@/components/composites/payment/payment-modal'
import {PaymentProps} from '@/components/composites/payment/type' import {PaymentProps} from '@/components/composites/payment/type'
import {usePlatformType} from '@/lib/hooks'
export type PayProps = { export type PayProps = {
amount: string amount: string
@@ -32,36 +31,35 @@ export default function Pay(props: PayProps) {
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [trade, setTrade] = useState<PaymentProps | null>(null) const [trade, setTrade] = useState<PaymentProps | null>(null)
const router = useRouter() const router = useRouter()
// const platform = usePlatformType()
const onOpen = async () => { const onOpen = async () => {
setOpen(true) setOpen(true)
if (props.method === 'balance') return if (props.method === 'balance') {
return
}
const method = props.method === 'alipay' const method = props.method === 'alipay'
? TradeMethod.SftAlipay ? TradeMethod.SftAlipay
: TradeMethod.SftWechat : TradeMethod.SftWechat
const req = { const response = await prepareResource({
...props.resource, ...props.resource,
payment_method: method, payment_method: method,
payment_platform: TradePlatform.Desktop, payment_platform: TradePlatform.Desktop,
} })
const resp = await prepareResource(req) if (!response.success) {
toast.error(`创建订单失败: ${response.message}`)
if (!resp.success) {
toast.error(`创建订单失败: ${resp.message}`)
setOpen(false) setOpen(false)
return return
} }
setTrade({ setTrade({
inner_no: resp.data.trade_no, inner_no: response.data.trade_no,
pay_url: resp.data.pay_url, pay_url: response.data.pay_url,
amount: Number(props.amount), amount: Number(props.amount),
platform: TradePlatform.Desktop, platform: TradePlatform.Desktop,
method: method, method,
}) })
} }
@@ -112,7 +110,6 @@ export default function Pay(props: PayProps) {
</Button> </Button>
{/* 余额支付对话框 */}
{props.method === 'balance' && ( {props.method === 'balance' && (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogContent> <DialogContent>
@@ -178,7 +175,6 @@ export default function Pay(props: PayProps) {
</Dialog> </Dialog>
)} )}
{/* 支付宝/微信支付 */}
{props.method !== 'balance' && trade && ( {props.method !== 'balance' && trade && (
<PaymentModal <PaymentModal
{...trade} {...trade}

View File

@@ -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>
)
}

View 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>
)
}

View File

@@ -6,20 +6,16 @@ import alipay from '../_assets/alipay.svg'
import wechat from '../_assets/wechat.svg' import wechat from '../_assets/wechat.svg'
import balance from '../_assets/balance.svg' import balance from '../_assets/balance.svg'
import RechargeModal from '@/components/composites/recharge' 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() { export function FieldPayment(props: {
const profile = use(useProfileStore(store => store.profile)) balance: number
}) {
return profile ? ( return (
<FormField name="pay_type" label="支付方式" className="flex flex-col gap-6"> <FormField name="pay_type" label="支付方式" className="flex flex-col gap-6">
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
id={id} id={id}
defaultValue={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={field.onChange}
className="flex flex-col gap-3"> className="flex flex-col gap-3">
@@ -29,7 +25,7 @@ export function FieldPayment() {
<span className="text-sm text-gray-500"></span> <span className="text-sm text-gray-500"></span>
</p> </p>
<p className="flex justify-between items-center"> <p className="flex justify-between items-center">
<span className="text-xl">{profile.balance}</span> <span className="text-xl">{props.balance}</span>
<RechargeModal/> <RechargeModal/>
</p> </p>
</div> </div>
@@ -60,9 +56,5 @@ export function FieldPayment() {
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
) )
} }

View 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'
}

View File

@@ -0,0 +1,73 @@
'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}
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>
)
}

View 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,
}
}

View File

@@ -0,0 +1,189 @@
'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'
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 ? (
<>
<FieldPayment balance={profile.balance}/>
<Pay
method={method}
balance={profile.balance}
amount={discountedPrice}
resource={resource}
/>
</>
) : (
<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)
}

View File

@@ -0,0 +1,165 @@
import {ProductItem} from '@/actions/product'
import {PurchaseKind, PurchaseMode} from './resource'
export type PurchaseSkuItem = {
code: string
mode: PurchaseMode
live: string
expire: string
price: string
}
export type PurchaseSkuData = {
items: PurchaseSkuItem[]
priceMap: Map<string, string>
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 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,
})
items.push({
code,
mode,
live: liveValue,
expire: expireValue,
price: sku.price,
})
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,
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} 分钟`
}

View File

@@ -1,71 +1,71 @@
'use client' 'use client'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group' 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 FormOption from '@/components/composites/purchase/option'
import Image from 'next/image' import {useEffect} from 'react'
import check from '../_assets/check.svg'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form' import {Schema} from '@/components/composites/purchase/short/form'
import {Card} from '@/components/ui/card' 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, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
export default function Center({ export default function Center({
priceMap, skuData,
liveList,
expireList,
}: { }: {
priceMap: Map<string, string> skuData: PurchaseSkuData
liveList: string[]
expireList: string[]
}) { }) {
const form = useFormContext<Schema>() const {setValue} = useFormContext<Schema>()
const type = useWatch({name: 'type'}) const type = useWatch<Schema>({name: 'type'}) as Schema['type']
const expire = useWatch({name: 'expire'}) const live = useWatch<Schema>({name: 'live'}) as Schema['live']
const isTime = type === '1' 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})
: []
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 ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-6 relative">
<BillingMethodField modeList={modeList} timeDailyLimit={2000}/>
{/* 计费方式 */}
<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>
{/* IP 时效 */} {/* IP 时效 */}
<FormField<Schema, 'live'> <FormField<Schema, 'live'>
@@ -76,14 +76,28 @@ export default function Center({
<RadioGroup <RadioGroup
id={id} id={id}
value={field.value} 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"> className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
{liveList.map((live) => { {liveList.map((live) => {
const params = new URLSearchParams() const priceExpire = type === '1' && !hasPurchaseSku(skuData, {mode: type, live, expire})
params.set('mode', isTime ? 'time' : 'quota') ? getAvailablePurchaseExpires(skuData, {mode: type, live})[0] || '0'
params.set('live', live) : String(expire)
params.set('expire', isTime ? expire : '0') const price = getPurchaseSkuPrice(priceMap, {
const price = priceMap.get(params.toString()) mode: type,
live,
expire: priceExpire,
})
const minutes = Number(live) const minutes = Number(live)
const hours = minutes / 60 const hours = minutes / 60
const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟` const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟`
@@ -93,7 +107,7 @@ export default function Center({
id={`${id}-${live}`} id={`${id}-${live}`}
value={live} value={live}
label={label} label={label}
description={price && `${price}${!isTime ? '/IP' : ''}`} description={price && `${price}/IP`}
compare={field.value} compare={field.value}
/> />
) )
@@ -102,146 +116,54 @@ export default function Center({
)} )}
</FormField> </FormField>
{/* 根据套餐类型显示不同表单项 */} {/* 套餐时效 */}
{!isTime ? ( {type === '1' && (
/* 包量IP 购买数量 */ <FormField className="space-y-4" name="expire" label="套餐时效">
<FormField
className="space-y-4"
name="quota"
label="IP 购买数量">
{({id, field}) => ( {({id, field}) => (
<div className="flex gap-2 items-center"> <RadioGroup
<Button id={id}
theme="outline" value={field.value}
type="button" onValueChange={(value) => {
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg" field.onChange(value)
onClick={() => form.setValue('quota', Math.max(10000, Number(field.value) - 5000))}
disabled={Number(field.value) === 10000}> const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
<Minus/> if (!nextLiveList.includes(live) && nextLiveList[0]) {
</Button> setValue('live', nextLiveList[0])
<Input }
{...field} }}
id={id} className="flex gap-4 flex-wrap">
type="number" {expireList.map(day => (
className="w-40 h-10 border border-gray-200 rounded-sm text-center" <FormOption
min={10000} key={day}
step={5000} id={`${id}-${day}`}
onBlur={(e) => { value={day}
const value = Number(e.target.value) label={`${day}`}
if (value < 10000) { compare={field.value}
form.setValue('quota', 10000) />
} ))}
}} </RadioGroup>
/>
<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>
)} )}
</FormField> </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"> {type === '1' ? (
<h3></h3> <NumberStepperField
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6"> name="daily_limit"
<p className="flex gap-2 items-center"> label="每日提取上限"
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> min={2000}
<span className="text-sm text-gray-500"></span> step={1000}
</p> />
<p className="flex gap-2 items-center"> ) : (
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <NumberStepperField
<span className="text-sm text-gray-500"></span> name="quota"
</p> label="IP 购买数量"
<p className="flex gap-2 items-center"> min={10000}
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> step={5000}
<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"/> <FeatureList/>
<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>
</Card> </Card>
) )
} }

View File

@@ -1,13 +1,13 @@
'use client' 'use client'
import {useForm} from 'react-hook-form' import {useForm} from 'react-hook-form'
import Center from '@/components/composites/purchase/short/center' import Center from '@/components/composites/purchase/short/center'
import Right from '@/components/composites/purchase/short/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product' import {ProductItem} from '@/actions/product'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, parsePurchaseSkuList} from '../shared/sku'
import {PurchaseSidePanel} from '../shared/side-panel'
// 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.string(), live: z.string(),
@@ -16,40 +16,22 @@ const schema = z.object({
daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'), daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'), pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
}) })
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) { export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) {
if (!skuList?.length) throw new Error('没有套餐数据') const skuData = parsePurchaseSkuList('short', skuList)
const defaultMode = skuData.modeList.includes('2') ? '2' : '1'
const priceMap = new Map<string, string>() const defaultLive = getAvailablePurchaseLives(skuData, {mode: defaultMode})[0] || ''
const _liveList = new Set<number>() const defaultExpire = defaultMode === '1'
const _expireList = new Set<number>() ? getAvailablePurchaseExpires(skuData, {mode: defaultMode, live: defaultLive})[0] || '0'
: '0'
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 form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: defaultMode,
live: liveList[0] || '', live: defaultLive,
expire: '0', // 包量模式下无效 expire: defaultExpire,
quota: 10_000, // >= 10000, quota: 10_000, // >= 10000,
daily_limit: 2_000, // >= 2000 daily_limit: 2_000, // >= 2000
pay_type: 'balance', // 余额支付 pay_type: 'balance', // 余额支付
@@ -58,8 +40,8 @@ export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) {
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center {...{priceMap, liveList, expireList}}/> <Center skuData={skuData}/>
<Right/> <PurchaseSidePanel kind="short"/>
</Form> </Form>
) )
} }

View File

@@ -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>
)
}