动态生成购买套餐 & 取消初次进后台修改密码的弹窗 & 添加总折扣字段 & 发布v1.5.0版本

This commit is contained in:
Eamon-meng
2026-04-16 14:41:42 +08:00
parent 319baea5e8
commit 5607217625
25 changed files with 619 additions and 273 deletions

View File

@@ -4,7 +4,7 @@ import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTr
import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {useForm, useFormContext, useWatch} from 'react-hook-form'
import {useForm, useFormContext} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import * as z from 'zod'
import {toast} from 'sonner'
@@ -14,7 +14,6 @@ import dynamic from 'next/dynamic'
// 表单验证规则
const schema = z.object({
phone: z.string().regex(/^1\d{10}$/, `请输入正确的手机号`),
captcha: z.string().nonempty('请输入验证码'),
code: z.string().regex(/^\d{6}$/, `请输入正确的验证码`),
password: z.string().min(6, `密码至少6位`),
@@ -32,6 +31,7 @@ interface ChangePasswordDialogProps {
defaultOpen?: boolean
onOpenChange?: (open: boolean) => void
onSuccess?: () => void
phone?: string
}
export function ChangePasswordDialog({
@@ -40,12 +40,28 @@ export function ChangePasswordDialog({
defaultOpen,
onOpenChange,
onSuccess,
phone,
}: ChangePasswordDialogProps) {
const [internalOpen, setInternalOpen] = useState(defaultOpen || false)
const router = useRouter()
const actualOpen = open !== undefined ? open : internalOpen
const actualOnOpenChange = onOpenChange || setInternalOpen
const actualOnOpenChange = (open: boolean) => {
if (!open) {
form.reset({
captcha: '',
code: '',
password: '',
confirm_password: '',
})
}
if (onOpenChange) {
onOpenChange(open)
}
else {
setInternalOpen(open)
}
}
// 表单初始化
const form = useForm<Schema>({
@@ -59,7 +75,6 @@ export function ChangePasswordDialog({
),
),
defaultValues: {
phone: '',
captcha: '',
code: '',
password: '',
@@ -71,7 +86,6 @@ export function ChangePasswordDialog({
const handler = async (value: Schema) => {
try {
const resp = await updatePassword({
phone: value.phone,
code: value.code,
password: value.password,
})
@@ -108,14 +122,12 @@ export function ChangePasswordDialog({
<DialogHeader>
<DialogTitle></DialogTitle>
</DialogHeader>
{/* 手机号输入 */}
<FormField<Schema> name="phone" label="手机号" className="flex-auto">
{({field}) => (
<Input {...field} placeholder="请输入手机号" autoComplete="tel-national"/>
)}
</FormField>
{/* 短信验证码 */}
{phone && (
<div className="flex items-center gap-2 p-3 bg-gray-50 rounded-lg">
<span className="text-sm text-gray-500"></span>
<span className="text-sm font-medium">{phone}</span>
</div>
)}
<div className="flex gap-4 items-end">
<FormField<Schema> name="code" label="验证码" className="flex-auto">
{({field}) => (
@@ -124,15 +136,11 @@ export function ChangePasswordDialog({
</FormField>
<SendMsgByPhone/>
</div>
{/* 新密码 */}
<FormField<Schema> name="password" label="新密码" className="flex-auto">
{({field}) => (
<Input {...field} placeholder="请输入新密码" type="password" autoComplete="new-password"/>
)}
</FormField>
{/* 确认密码 */}
<FormField<Schema> name="confirm_password" label="确认密码" className="flex-auto">
{({field}) => (
<Input {...field} placeholder="请再次输入新密码" type="password" autoComplete="new-password"/>
@@ -144,8 +152,13 @@ export function ChangePasswordDialog({
theme="outline"
type="button"
onClick={() => {
form.reset({
captcha: '',
code: '',
password: '',
confirm_password: '',
})
actualOnOpenChange(false)
form.reset()
}}>
</Button>
@@ -159,8 +172,7 @@ export function ChangePasswordDialog({
function SendMsgByPhone() {
const {control} = useFormContext<Schema>()
const phone = useWatch({control, name: 'phone'})
return <SendMsg phone={phone}/>
return <SendMsg/>
}
const SendMsg = dynamic(() => import('@/components/send-msg'), {ssr: false})
const SendMsg = dynamic(() => import('@/components/updateSend-msg'), {ssr: false})

View File

@@ -337,8 +337,6 @@ function SelectResource() {
setStatus('load')
try {
const resp = await allResource()
console.log(resp, '/api/resource/all')
if (!resp.success) {
throw new Error('获取套餐失败,请稍后再试')
}

View File

@@ -1,11 +1,12 @@
'use client'
import {ReactNode} from 'react'
import {ReactNode, useEffect, useState} from 'react'
import {merge} from '@/lib/utils'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import LongForm from '@/components/composites/purchase/long/form'
import ShortForm from '@/components/composites/purchase/short/form'
import {usePathname, useRouter, useSearchParams} from 'next/navigation'
import SelfDesc from '@/components/features/self-desc'
import {listProduct, ProductItem} from '@/actions/product'
export type TabType = 'short' | 'long' | 'fixed' | 'custom'
export default function Purchase() {
@@ -13,7 +14,8 @@ export default function Purchase() {
const path = usePathname()
const params = useSearchParams()
const tab = params.get('type') as TabType || 'short'
const [productList, setProductList] = useState<ProductItem[]>([])
const tab = (params.get('type') as TabType) || productList[0]?.code || 'short'
const updateTab = (tab: string) => {
const newParams = new URLSearchParams(params)
@@ -21,27 +23,47 @@ export default function Purchase() {
router.push(`${path}?${newParams.toString()}`)
}
useEffect(() => {
const fetchProducts = async () => {
const res = await listProduct({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [])
const currentProduct = productList.find(item => item.code === tab)
const currentSkuList = currentProduct?.skus || []
const componentMap: Record<string, React.FC<{skuList: ProductItem['skus']}>> = {
short: ShortForm,
long: LongForm,
// static: StaticForm
}
return (
<div className="flex flex-col gap-4">
<Tabs value={tab} onValueChange={updateTab} className="gap-4">
<TabsList className="w-full p-2 bg-white rounded-lg justify-start md:justify-center overflow-auto">
<Tab value="short"></Tab>
<Tab value="long"></Tab>
<Tab value="fixed"></Tab>
{productList.map(item => (
<Tab key={item.code} value={item.code}>
{item.name}
</Tab>
))}
{/* 固定的定制套餐tab */}
<Tab value="custom"></Tab>
</TabsList>
<TabsContent value="short">
<ShortForm/>
</TabsContent>
<TabsContent value="long">
<LongForm/>
</TabsContent>
<TabsContent value="fixed">
</TabsContent>
{productList.map((item) => {
const Component = componentMap[item.code]
const skuList = item.skus || []
return (
<TabsContent key={item.code} value={item.code}>
{Component ? <Component skuList={skuList}/> : <div></div>}
</TabsContent>
)
})}
<TabsContent value="custom">
<SelfDesc onInquiry={() => {
router.push('/custom')
}}/>
<SelfDesc onInquiry={() => router.push('/custom')}/>
</TabsContent>
</Tabs>
</div>

View File

@@ -10,72 +10,22 @@ import check from '../_assets/check.svg'
import {Schema} from '@/components/composites/purchase/long/form'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {useEffect} from 'react'
export default function Center() {
export default function Center({map, expireList, liveList}: {
map: Map<string, string>
liveList: string[]
expireList: string[]
}) {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => {
if (type === '1') {
form.setValue('daily_limit', 100)
}
else {
form.setValue('quota', 500)
}
}, [type, form])
return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
{/* 计费方式 */}
<FormField
className="flex flex-col gap-4"
name="type"
label="计费方式">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
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>
<BillingMethod expireList={expireList}/>
{/* IP 时效 */}
<FormField
className="space-y-4"
name="live"
label="IP 时效">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
<FormOption id={`${id}-1`} value="1" label="1 小时" description="¥0.3/IP" compare={field.value}/>
<FormOption id={`${id}-4`} value="4" label="4 小时" description="¥0.8/IP" compare={field.value}/>
<FormOption id={`${id}-8`} value="8" label="8 小时" description="¥1.2/IP" compare={field.value}/>
<FormOption id={`${id}-12`} value="12" label="12 小时" description="¥1.8/IP" compare={field.value}/>
<FormOption id={`${id}-24`} value="24" label="24 小时" description="¥3.5/IP" compare={field.value}/>
</RadioGroup>
)}
</FormField>
<IpTime {...{map, liveList}}/>
{/* 根据套餐类型显示不同表单项 */}
{type === '2' ? (
@@ -126,26 +76,7 @@ export default function Center() {
) : (
<>
{/* 包时:套餐时效 */}
<FormField
className="space-y-4"
name="expire"
label="套餐时效">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex gap-4 flex-wrap">
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
</RadioGroup>
)}
</FormField>
<ComboValidity expireList={expireList}/>
{/* 包时:每日提取上限 */}
<FormField
@@ -244,3 +175,118 @@ export default function Center() {
</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

@@ -5,13 +5,14 @@ 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'
// 定义表单验证架构
const schema = z.object({
type: z.enum(['1', '2']).default('2'),
live: z.enum(['1', '4', '8', '12', '24']),
live: z.string(),
quota: z.number().min(500, '购买数量不能少于 500 个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']),
expire: z.string(),
daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']),
})
@@ -19,14 +20,31 @@ const schema = z.object({
// 从架构中推断类型
export type Schema = z.infer<typeof schema>
export default function LongForm() {
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 form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
type: '2', // 默认为包量套餐
live: '1', // 小时
live: liveList[0], // 分钟
expire: '0', // 天
quota: 500,
expire: '30', // 天
daily_limit: 100,
pay_type: 'balance', // 余额支付
},
@@ -34,7 +52,7 @@ export default function LongForm() {
return (
<Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/>
<Center {...{liveList, map, expireList}}/>
<Right/>
</Form>
)

View File

@@ -22,8 +22,8 @@ export default function Right() {
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
actual: '0.00',
discounted: '0.00',
})
useEffect(() => {
@@ -32,20 +32,18 @@ export default function Right() {
const resp = await getPrice({
type: 2,
long: {
live: Number(live) * 60,
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,
discounted_price: resp.data.discounted_price ?? resp.data.price ?? '',
actual: resp.data.actual ?? resp.data.price ?? '',
discounted: resp.data.discounted,
})
}
@@ -53,16 +51,28 @@ export default function Right() {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
actual: '0.00',
discounted: '0.00',
})
}
}
price()
}, [dailyLimit, expire, live, quota, mode])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
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`,
@@ -80,7 +90,7 @@ export default function Right() {
<span className="text-sm">
{live}
{' '}
</span>
</li>
{mode === '2' ? (
@@ -93,11 +103,19 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<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>
)}
</>
) : (
<>
@@ -116,19 +134,19 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{/* {discounted === 1 ? '' : (
{hasDiscount && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-¥{discounted}
-{totalDiscount}
</span>
</li>
)} */}
)}
</>
)}
</ul>
@@ -167,7 +185,7 @@ function BalanceOrLogin(props: {
type: 2,
long: {
mode: Number(props.mode),
live: Number(props.live) * 60,
live: Number(props.live),
expire: props.mode === '1' ? Number(props.expire) : undefined,
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
},

View File

@@ -47,7 +47,6 @@ export default function Pay(props: PayProps) {
payment_method: method,
payment_platform: TradePlatform.Desktop,
}
console.log(req, 'req')
const resp = await prepareResource(req)

View File

@@ -10,19 +10,21 @@ import check from '../_assets/check.svg'
import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form'
import {Card} from '@/components/ui/card'
import {useEffect} from 'react'
export default function Center() {
export default function Center({
priceMap,
liveList,
expireList,
}: {
priceMap: Map<string, string>
liveList: string[]
expireList: string[]
}) {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => {
if (type === '1') {
form.setValue('daily_limit', 2000)
}
else {
form.setValue('quota', 10000)
}
}, [type, form])
const expire = useWatch({name: 'expire'})
const isTime = type === '1'
return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative">
@@ -34,8 +36,17 @@ export default function Center() {
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
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
@@ -64,21 +75,35 @@ export default function Center() {
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
value={field.value}
onValueChange={field.onChange}
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4">
<FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
<FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
<FormOption id={`${id}-10`} value="10" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
<FormOption id={`${id}-20`} value="15" label="15 分钟" description="¥0.03/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
{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 minutes = Number(live)
const hours = minutes / 60
const label = minutes % 60 === 0 ? `${hours} 小时` : `${minutes} 分钟`
return (
<FormOption
key={live}
id={`${id}-${live}`}
value={live}
label={label}
description={price && `${price}${!isTime ? '/IP' : ''}`}
compare={field.value}
/>
)
})}
</RadioGroup>
)}
</FormField>
{/* 根据套餐类型显示不同表单项 */}
{type === '2' ? (
{!isTime ? (
/* 包量IP 购买数量 */
<FormField
className="space-y-4"
@@ -121,23 +146,18 @@ export default function Center() {
) : (
<>
{/* 包时:套餐时效 */}
<FormField
className="space-y-4"
name="expire"
label="套餐时效">
<FormField className="space-y-4" name="expire" label="套餐时效">
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className="flex gap-4 flex-wrap">
<FormOption id={`${id}-7`} value="7" label="7天" compare={field.value}/>
<FormOption id={`${id}-15`} value="15" label="15天" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30天" compare={field.value}/>
<FormOption id={`${id}-90`} value="90" label="90天" compare={field.value}/>
<FormOption id={`${id}-180`} value="180" label="180天" compare={field.value}/>
<FormOption id={`${id}-365`} value="365" label="365天" compare={field.value}/>
<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>
@@ -153,8 +173,8 @@ export default function Center() {
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(2_000, Number(field.value) - 1_000))}
disabled={Number(field.value) === 2_000}>
onClick={() => form.setValue('daily_limit', Math.max(2000, Number(field.value) - 1000))}
disabled={Number(field.value) === 2000}>
<Minus/>
</Button>
<Input
@@ -162,12 +182,12 @@ export default function Center() {
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={2_000}
step={1_000}
min={2000}
step={1000}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 2_000) {
form.setValue('daily_limit', 2_000)
if (value < 2000) {
form.setValue('daily_limit', 2000)
}
}}
/>
@@ -175,7 +195,7 @@ export default function Center() {
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) + 1_000)}>
onClick={() => form.setValue('daily_limit', Number(field.value) + 1000)}>
<Plus/>
</Button>
</div>
@@ -212,10 +232,6 @@ export default function 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>

View File

@@ -5,28 +5,52 @@ 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'
// 定义表单验证架构
const schema = z.object({
type: z.enum(['1', '2']).default('2'),
live: z.enum(['3', '5', '10', '15', '30']),
quota: z.number().min(10000, '购买数量不能少于10000个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']),
live: z.string(),
quota: z.number().min(10000, '购买数量不能少于 10000 个'),
expire: z.string(),
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() {
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 form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
type: '2', // 默认为包量套餐
live: '3', // 分钟
quota: 10_000, // >= 10000
expire: '30', // 天
live: liveList[0] || '',
expire: '0', // 包量模式下无效
quota: 10_000, // >= 10000,
daily_limit: 2_000, // >= 2000
pay_type: 'balance', // 余额支付
},
@@ -34,8 +58,8 @@ export default function ShortForm() {
return (
<Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/>
<Right/>
<Center {...{priceMap, liveList, expireList}}/>
<Right {...{skuList, priceMap}}/>
</Form>
)
}

View File

@@ -11,8 +11,9 @@ import {Card} from '@/components/ui/card'
import {getPrice} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {FieldPayment} from '../shared/field-payment'
import {ProductItem} from '@/actions/product'
export default function Right() {
export default function Right({skuList}: {skuList: ProductItem['skus']}) {
const {control} = useFormContext<Schema>()
const method = useWatch({control, name: 'pay_type'})
const live = useWatch({control, name: 'live'})
@@ -22,8 +23,8 @@ export default function Right() {
const dailyLimit = useWatch({control, name: 'daily_limit'})
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
actual: '0.00',
discounted: '0.00',
})
useEffect(() => {
@@ -38,7 +39,6 @@ export default function Right() {
expire: mode === '1' ? Number(expire) : undefined,
},
})
if (!priceResponse.success) {
throw new Error('获取价格失败')
}
@@ -46,7 +46,7 @@ export default function Right() {
const data = priceResponse.data
setPriceData({
price: data.price,
discounted_price: data.discounted_price ?? data.price ?? '',
actual: data.actual ?? data.price ?? '',
discounted: data.discounted,
})
}
@@ -54,15 +54,29 @@ export default function Right() {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
actual: '0.00',
discounted: '0.00',
})
}
}
price()
}, [expire, live, quota, mode, dailyLimit])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
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(
@@ -94,11 +108,19 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<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>
)}
</>
) : (
<>
@@ -117,19 +139,19 @@ export default function Right() {
</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
{price}
</span>
</li>
{/* {discounted === 1 ? '' : (
{hasDiscount && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">
-¥{discounted === 1 ? '' : discounted}
-{totalDiscount}
</span>
</li>
)} */}
)}
</>
)}
</ul>

View File

@@ -0,0 +1,78 @@
'use client'
import {useEffect, useRef, useState} from 'react'
import Cap from '@cap.js/widget'
import {updateSendSMS} from '@/actions/verify'
import {toast} from 'sonner'
import {Button} from '@/components/ui/button'
import {LoaderIcon} from 'lucide-react'
export default function SendMsg() {
const [countdown, setCountdown] = useState(0)
const [progress, setProgress] = useState(0)
const [mode, setMode] = useState<'ready' | 'wait' | 'check'>('ready')
const cap = useRef(new Cap({apiEndpoint: '/'}))
cap.current.addEventListener('progress', (event) => {
setProgress(event.detail.progress)
})
// 发送验证码
const sendCode = async () => {
try {
setMode('check')
// 完成挑战
const result = await cap.current.solve()
if (!result.success || !cap.current.token) {
throw new Error('人机验证失败')
}
// 发送验证码
const resp = await updateSendSMS({
captcha: cap.current.token,
})
if (!resp.success) {
throw new Error(`验证码发送失败: ${resp.message}`)
}
setMode('wait')
setCountdown(60)
toast.success('验证码已发送')
}
catch (e) {
setMode('ready')
toast.error('验证码发送失败', {
description: (e as Error).message,
})
}
}
// 计时
useEffect(() => {
const interval = setInterval(() => {
if (countdown > 0) {
setCountdown(countdown - 1)
}
else if (mode === 'wait') {
setMode('ready')
}
}, 1000)
return () => clearInterval(interval)
}, [countdown, mode])
return (
<Button
type="button"
theme="outline"
className="whitespace-nowrap h-10"
disabled={countdown > 0}
onClick={sendCode}
>
{mode === 'check' && <LoaderIcon className="animate-spin"/>}
<span>
{mode === 'check' && '检查登录环境'}
{mode === 'wait' && `${countdown}秒后重发`}
{mode === 'ready' && '获取验证码'}
</span>
</Button>
)
}