7 Commits

Author SHA1 Message Date
Eamon-meng
27e694ee0d 取消关闭支付弹窗调用关闭订单接口 & 添加未实名去支付时的拦截 2026-04-20 16:22:49 +08:00
Eamon-meng
74d53c619d 购买套餐添加count_min字段 2026-04-20 15:36:50 +08:00
8f8def3a87 完善异常跳转 2026-04-20 15:34:49 +08:00
Eamon-meng
ed73d8579f 更新发布v1.7.0 2026-04-18 17:36:22 +08:00
Eamon-meng
e3c61a77e6 修复未登录时提取ip时的套餐显示和不调用获取套餐接口 2026-04-18 17:13:26 +08:00
5f7756199a 修复 sku 缺失显示问题 2026-04-18 15:47:20 +08:00
6aa108e8d3 优化完善套餐购买页面 2026-04-18 15:35:35 +08:00
25 changed files with 1054 additions and 1308 deletions

View File

@@ -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",

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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处理方式检查支付状态

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

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

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

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

View File

@@ -1,6 +1,7 @@
export type ProductSku = {
id: number
code: string
count_min: number
name: string
price: string
price_min: string