7 Commits

47 changed files with 1310 additions and 1205 deletions

View File

@@ -1,4 +1,4 @@
# 开发环境配置 # 开发环境配置
NEXT_PUBLIC_API_BASE_URL=http://192.168.3.42:8080 API_BASE_URL=http://192.168.3.42:8080
CLIENT_ID=web CLIENT_ID=web
CLIENT_SECRET=web CLIENT_SECRET=web

View File

@@ -122,7 +122,7 @@ type UserProfile = ExtraResp<typeof getProfile>
## 环境变量 ## 环境变量
需要配置: 需要配置:
- `NEXT_PUBLIC_API_BASE_URL` - 后端 API 地址 - `API_BASE_URL` - 后端 API 地址
- `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据 - `CLIENT_ID`, `CLIENT_SECRET` - OAuth2 设备认证凭据
## 部署 ## 部署

View File

@@ -32,6 +32,7 @@ RUN adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./ COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
RUN rm .env
USER nextjs USER nextjs

View File

@@ -1,6 +1,6 @@
{ {
"name": "lanhu-web", "name": "lanhu-web",
"version": "1.3.0", "version": "1.6.0",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "next dev -H 0.0.0.0 --turbopack", "dev": "next dev -H 0.0.0.0 --turbopack",

View File

@@ -12,6 +12,13 @@ export type TokenResp = {
scope?: string scope?: string
} }
export async function getApiUrl() {
return {
success: true,
data: API_BASE_URL,
} satisfies ApiResponse<string>
}
// ====================== // ======================
// public // public
// ====================== // ======================

View File

@@ -32,5 +32,5 @@ export async function createChannels(params: {
city?: string city?: string
isp?: number isp?: number
}) { }) {
return callPublic<CreateChannelsResp[]>('/api/channel/create', params) return callByUser<CreateChannelsResp[]>('/api/channel/create', params)
} }

12
src/actions/product.ts Normal file
View File

@@ -0,0 +1,12 @@
import {callByUser, callPublic} from './base'
import {Product} from '@/lib/models/product'
export type ProductItem = Product
export async function listProduct(props: {}) {
return callByUser<Product[]>('/api/product/list', props)
}
export async function listProductHome(props: {}) {
return callPublic<Product[]>('/api/product/list', props)
}

View File

@@ -89,9 +89,17 @@ export async function payClose(props: {
} }
export async function getPrice(props: CreateResourceReq) { export async function getPrice(props: CreateResourceReq) {
return callByDevice<{ return callByUser<{
price: string price: string
discounted_price?: string actual?: string
discounted?: number discounted?: string
}>('/api/resource/price', props)
}
export async function getPriceHome(props: CreateResourceReq) {
return callByDevice<{
price: string
actual?: string
discounted?: string
}>('/api/resource/price', props) }>('/api/resource/price', props)
} }

View File

@@ -38,16 +38,16 @@ export async function Identify(props: {
} }
export async function update(props: { export async function update(props: {
username: string username?: string
email: string email?: string
contact_qq: string contact_qq?: string
contact_wechat: string contact_wechat?: string
}) { }) {
return await callByUser('/api/user/update', props) return await callByUser('/api/user/update', props)
} }
export async function updatePassword(props: { export async function updatePassword(props: {
phone: string // phone: string
code: string code: string
password: string password: string
}) { }) {

View File

@@ -1,6 +1,6 @@
'use server' 'use server'
import {ApiResponse} from '@/lib/api' import {ApiResponse} from '@/lib/api'
import {callByDevice} from '@/actions/base' import {callByDevice, callByUser} from '@/actions/base'
import {getCap} from '@/lib/cap' import {getCap} from '@/lib/cap'
export async function sendSMS(props: { export async function sendSMS(props: {
@@ -38,3 +38,35 @@ export async function sendSMS(props: {
throw new Error('验证码验证失败', {cause: error}) throw new Error('验证码验证失败', {cause: error})
} }
} }
export async function updateSendSMS(props: {
captcha: string
}): Promise<ApiResponse> {
try {
// 人机验证
if (!props.captcha?.length) {
return {
success: false,
status: 400,
message: '请输入验证码',
}
}
const cap = await getCap()
const valid = await cap.validateToken(props.captcha)
if (!valid) {
return {
success: false,
status: 400,
message: '验证码错误或已过期',
}
}
// 请求发送短信
return await callByUser('/api/verify/sms/password', {})
}
catch (error) {
console.error('验证码验证失败:', error)
throw new Error('验证码验证失败', {cause: error})
}
}

View File

@@ -34,20 +34,12 @@ export type LoginSchema = zod.infer<typeof smsSchema | typeof pwdSchema>
export default function LoginCard() { export default function LoginCard() {
const router = useRouter() const router = useRouter()
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
const [mode, setMode] = useState<LoginMode>('phone_code') const [mode, setMode] = useState<LoginMode>('password')
const [submitting, setSubmitting] = useState(false) const [submitting, setSubmitting] = useState(false)
const updateLoginMode = (mode: LoginMode) => { const updateLoginMode = (mode: LoginMode) => {
sessionStorage.setItem('login_mode', mode) sessionStorage.setItem('login_mode', mode)
} }
useEffect(() => {
const mode = sessionStorage.getItem('login_mode')
if (mode) {
setMode(mode as LoginMode)
}
}, [])
const form = useForm<LoginSchema>({ const form = useForm<LoginSchema>({
resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema), resolver: zodResolver(mode === 'phone_code' ? smsSchema : pwdSchema),
defaultValues: { defaultValues: {
@@ -55,7 +47,16 @@ export default function LoginCard() {
password: '', password: '',
remember: false, remember: false,
}, },
mode: 'onChange',
}) })
useEffect(() => {
const savedMode = sessionStorage.getItem('login_mode') as LoginMode
if (savedMode && savedMode === 'phone_code') {
setMode(savedMode)
}
}, [])
const handler = form.handleSubmit(async (data) => { const handler = form.handleSubmit(async (data) => {
setSubmitting(true) setSubmitting(true)
try { try {
@@ -93,13 +94,14 @@ export default function LoginCard() {
<Tabs <Tabs
value={mode} value={mode}
onValueChange={(val) => { onValueChange={(val) => {
setMode(val as typeof mode) setMode(val as LoginMode)
form.reset({username: form.getValues('username'), password: '', remember: false}) form.reset({username: '', password: '', remember: false})
form.clearErrors()
}} }}
className="mb-6"> className="mb-6">
<TabsList className="w-full p-0 bg-white"> <TabsList className="w-full p-0 bg-white">
<Tab value="password"></Tab> <Tab value="password"></Tab>
<Tab value="phone_code"></Tab> <Tab value="phone_code">/</Tab>
</TabsList> </TabsList>
</Tabs> </Tabs>
<Form<LoginSchema> className="space-y-6" form={form} handler={handler}> <Form<LoginSchema> className="space-y-6" form={form} handler={handler}>
@@ -124,6 +126,7 @@ export default function LoginCard() {
className="h-10" className="h-10"
placeholder="请输入验证码" placeholder="请输入验证码"
autoComplete="one-time-code" autoComplete="one-time-code"
disabled={submitting}
/> />
<SendMsgByUsername/> <SendMsgByUsername/>
</div> </div>
@@ -137,6 +140,7 @@ export default function LoginCard() {
placeholder="至少6位密码需包含字母和数字" placeholder="至少6位密码需包含字母和数字"
autoComplete="current-password" autoComplete="current-password"
minLength={6} minLength={6}
disabled={submitting}
/> />
<button <button
type="button" type="button"
@@ -162,6 +166,7 @@ export default function LoginCard() {
id={id} id={id}
checked={field.value} checked={field.value}
onCheckedChange={field.onChange} onCheckedChange={field.onChange}
disabled={submitting}
/> />
<div className="space-y-1 leading-none"> <div className="space-y-1 leading-none">
<Label></Label> <Label></Label>

View File

@@ -1,6 +1,6 @@
'use client' 'use client'
import {useContext, useState} from 'react' import {useContext, useEffect, useState} from 'react'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {X} from 'lucide-react' import {X} from 'lucide-react'
import {HeaderContext} from './common' import {HeaderContext} from './common'
@@ -21,6 +21,8 @@ import h03 from '@/assets/header/help/03.svg'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import Link from 'next/link' import Link from 'next/link'
import logo from '@/assets/logo.webp' import logo from '@/assets/logo.webp'
import {Product} from '@/lib/models/product'
import {listProductHome} from '@/actions/product'
export type MobileMenuProps = {} export type MobileMenuProps = {}
@@ -37,7 +39,18 @@ export default function MobileMenu(props: MobileMenuProps) {
ctx.setMenu(false) ctx.setMenu(false)
router.push(href) router.push(href)
} }
const [productList, setProductList] = useState<Product[]>([])
useEffect(() => {
const fetchProducts = async () => {
const res = await listProductHome({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [])
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
return ( return (
<div className="h-full flex flex-col bg-white"> <div className="h-full flex flex-col bg-white">
<div className="flex items-center justify-between px-4 h-16 border-b border-gray-100"> <div className="flex items-center justify-between px-4 h-16 border-b border-gray-100">
@@ -87,20 +100,24 @@ export default function MobileMenu(props: MobileMenuProps) {
{productTab === 'domestic' && ( {productTab === 'domestic' && (
<div className="space-y-2"> <div className="space-y-2">
<ProductItem {shortProduct && (
icon={prod} <ProductItem
label="短效动态IP" icon={prod}
badge="最低4.5折" label="短效动态IP"
href="/product?type=short" badge="最低4.5折"
onNavigate={navigate} href={`/product?type=${shortProduct.code}`}
/> onNavigate={navigate}
<ProductItem />
icon={prod} )}
label="长效静态IP" {longProduct && (
badge="最低4.5折" <ProductItem
href="/product?type=long" icon={prod}
onNavigate={navigate} label="长效静态IP"
/> badge="最低4.5折"
href={`/product?type=${longProduct.code}`}
onNavigate={navigate}
/>
)}
<ProductItem <ProductItem
icon={custom} icon={custom}
label="优质/企业/精选IP" label="优质/企业/精选IP"

View File

@@ -1,5 +1,5 @@
'use client' 'use client'
import {ReactNode, useContext, useState} from 'react' import {ReactNode, useContext, useEffect, useState} from 'react'
import Wrap from '@/components/wrap' import Wrap from '@/components/wrap'
import Image, {StaticImageData} from 'next/image' import Image, {StaticImageData} from 'next/image'
import Link from 'next/link' import Link from 'next/link'
@@ -8,7 +8,10 @@ import prod from '@/assets/header/product/prod.svg'
import custom from '@/assets/header/product/custom.svg' import custom from '@/assets/header/product/custom.svg'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import {HeaderContext} from './common' import {HeaderContext} from './common'
import {Product} from '@/lib/models/product'
import {listProductHome} from '@/actions/product'
export type ProductItem = Product
type TabType = 'domestic' | 'oversea' type TabType = 'domestic' | 'oversea'
export default function ProductMenu() { export default function ProductMenu() {
@@ -53,24 +56,41 @@ export function Tab(props: {
} }
export function Domestic(props: {}) { export function Domestic(props: {}) {
const [productList, setProductList] = useState<Product[]>([])
useEffect(() => {
const fetchProducts = async () => {
const res = await listProductHome({})
if (res.success) {
setProductList(res.data)
}
}
fetchProducts()
}, [])
const shortProduct = productList.find(p => p.name?.includes('短效') || p.code === 'short')
const longProduct = productList.find(p => p.name?.includes('长效') || p.code === 'long')
return ( return (
<section role="tabpanel" className="flex-auto"> <section role="tabpanel" className="flex-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-3"> <div className="grid grid-cols-1 lg:grid-cols-2 gap-3">
<div className="grid grid-cols-1 gap-3"> <div className="grid grid-cols-1 gap-3">
<ProductCard {shortProduct && (
icon={prod} <ProductCard
label="短效动态 IP" icon={prod}
discount="最低4.5折" label="短效动态 IP"
desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。" discount="最低4.5折"
href="/product?type=short" desc="全国 300+ 城市级定位节点IP 池资源充足自动高频切换。适用于数据采集、市场调研、SEO 优化等高并发场景。稳定可靠,响应迅速,助力业务高效运转。"
/> href={`/product?type=${shortProduct.code}`}
<ProductCard />
icon={prod} )}
label="长效静态 IP" {longProduct && (
discount="最低4.5折" <ProductCard
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。" icon={prod}
href="/product?type=long" label="长效动态 IP"
/> discount="最低4.5折"
desc="IP 存活时长可达数小时至数天,连接稳定不掉线。适用于账号养号、社交运营、电商管理等需要持续在线的场景。优质线路保障,为您的长期业务保驾护航。"
href={`/product?type=${longProduct.code}`}
/>
)}
</div> </div>
<div className="flex flex-col gap-3"> <div className="flex flex-col gap-3">
<ProductCard <ProductCard

View File

@@ -282,7 +282,7 @@ function ProfileOrLogin() {
<span></span> <span></span>
</Link> </Link>
<Link <Link
href="/login" href="/login?tab=sms"
className={[ className={[
`w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`, `w-20 lg:w-24 h-10 lg:h-12 bg-linear-to-r rounded-sm flex items-center justify-center lg:text-lg text-white`,
`transition-colors duration-200 ease-in-out`, `transition-colors duration-200 ease-in-out`,

View File

@@ -2,7 +2,6 @@
import {ReactNode, Suspense, use, useState} from 'react' import {ReactNode, Suspense, use, useState} from 'react'
import Image from 'next/image' import Image from 'next/image'
import Link from 'next/link' import Link from 'next/link'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog' import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import UserCenter from '@/components/composites/user-center' import UserCenter from '@/components/composites/user-center'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
@@ -75,6 +74,7 @@ export function Content(props: {children: ReactNode}) {
} }
function ContentResolved() { function ContentResolved() {
const profile = use(useProfileStore(store => store.profile)) const profile = use(useProfileStore(store => store.profile))
if (profile) if (profile)
return ( return (
<> <>
@@ -82,10 +82,10 @@ function ContentResolved() {
triggerClassName="hidden" triggerClassName="hidden"
defaultOpen={!profile.id_token} defaultOpen={!profile.id_token}
/> />
<ChangePasswordDialog {/* <ChangePasswordDialog
triggerClassName="hidden" triggerClassName="hidden"
defaultOpen={!profile.has_password} defaultOpen={!profile.has_password}
/> /> */}
</> </>
) )
} }

View File

@@ -78,8 +78,8 @@ export function BasicForm(props: {
}) { }) {
const schema = z.object({ const schema = z.object({
username: z.string(), username: z.string(),
email: z.string(), email: z.string().email('请输入正确的邮箱格式').or(z.literal('')),
contact_qq: z.string(), contact_qq: z.string().regex(/^\d*$/, 'QQ号只能包含数字'),
contact_wechat: z.string(), contact_wechat: z.string(),
}) })
type Schema = z.infer<typeof schema> type Schema = z.infer<typeof schema>

View File

@@ -3,16 +3,17 @@ import {Card, CardContent, CardHeader, CardTitle} from '@/components/ui/card'
import {CheckCircle} from 'lucide-react' import {CheckCircle} from 'lucide-react'
import Image from 'next/image' import Image from 'next/image'
import banner from '@/app/admin/identify/_assets/banner.webp' import banner from '@/app/admin/identify/_assets/banner.webp'
import RechargeModal from '@/components/composites/recharge'
import {RealnameAuthDialog} from '@/components/composites/dialogs/realname-auth-dialog'
import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog' import {ChangePasswordDialog} from '@/components/composites/dialogs/change-password-dialog'
import {getProfile} from '@/actions/auth' import {getProfile} from '@/actions/auth'
import {Aftersale, BasicForm} from './clients' import {Aftersale, BasicForm} from './clients'
import {Button} from '@/components/ui/button'
import Link from 'next/link'
export type ProfilePageProps = {} export type ProfilePageProps = {}
export default async function ProfilePage(props: ProfilePageProps) { export default async function ProfilePage(props: ProfilePageProps) {
const profile = await getProfile() const profile = await getProfile()
if (!profile.success) { if (!profile.success) {
return ( return (
<Page> <Page>
@@ -22,6 +23,7 @@ export default async function ProfilePage(props: ProfilePageProps) {
} }
const user = profile.data const user = profile.data
return ( return (
<Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col"> <Page className="lg:flex-row lg:items-stretch md:flex-col max-sm:flex-col">
<div className="flex-3/4 flex flex-col gap-4"> <div className="flex-3/4 flex flex-col gap-4">
@@ -34,26 +36,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
{/* 块信息 */} {/* 块信息 */}
<div className="flex gap-4 max-md:flex-col max-sm:flex-col"> <div className="flex gap-4 max-md:flex-col max-sm:flex-col">
{/* <Card className="flex-1 ">
<CardHeader>
<CardTitle className="font-normal">账户余额(元)</CardTitle>
</CardHeader>
<CardContent className="flex-auto flex justify-between items-center px-8">
<p className="text-xl">{user.balance}</p>
<RechargeModal classNames={{
trigger: `h-10 px-6`,
}}/>
</CardContent>
</Card> */}
<Card className="flex-1 "> <Card className="flex-1 ">
<CardHeader> <CardHeader>
<CardTitle className="font-normal"></CardTitle> <CardTitle className="font-normal"></CardTitle>
</CardHeader> </CardHeader>
<CardContent className="flex-auto flex justify-between items-center px-8"> <CardContent className="flex-auto flex justify-between items-center px-8">
<p>{user.phone}</p> <p>{user.phone}</p>
<ChangePasswordDialog triggerClassName="w-24 h-9"/> <ChangePasswordDialog triggerClassName="w-24 h-9" phone={user?.phone}/>
</CardContent> </CardContent>
</Card> </Card>
@@ -66,10 +55,13 @@ export default async function ProfilePage(props: ProfilePageProps) {
? ( ? (
<> <>
<p className="text-sm">使</p> <p className="text-sm">使</p>
<RealnameAuthDialog {/* <RealnameAuthDialog
defaultOpen={!user.id_token} // defaultOpen={!user.id_token}
triggerClassName="w-24" triggerClassName="w-24"
/> /> */}
<Link href="/admin/identify">
<Button></Button>
</Link>
</> </>
) )
: ( : (

View File

@@ -164,16 +164,21 @@ export default function RecordPage(props: RecordPageProps) {
cell: ({row}) => <div>{row.original.prov}</div>, cell: ({row}) => <div>{row.original.prov}</div>,
accessorKey: 'prov', accessorKey: 'prov',
}, },
{
header: '城市',
cell: ({row}) => <div>{row.original.city}</div>,
accessorKey: 'city',
},
{ {
header: '提取数量', header: '提取数量',
cell: ({row}) => <div>{row.original.count}</div>, cell: ({row}) => <div>{row.original.count}</div>,
accessorKey: 'count', accessorKey: 'count',
}, },
{ // {
header: '资源数量', // header: '资源数量',
cell: ({row}) => <div>{row.original.resource_id}</div>, // cell: ({row}) => <div>{row.original.resource_id}</div>,
accessorKey: 'resource_id', // accessorKey: 'resource_id',
}, // },
{ {
header: '提取时间', header: '提取时间',
cell: ({row}) => { cell: ({row}) => {

View File

@@ -219,6 +219,17 @@ export default function ResourceList({resourceType}: ResourceListProps) {
header: '开通时间', header: '开通时间',
cell: ({row}) => formatDateTime(row.original.created_at), cell: ({row}) => formatDateTime(row.original.created_at),
}, },
{
header: '状态',
cell: ({row}) => {
const isActive = row.original.active
return (
<span className={isActive ? 'text-green-500' : 'text-red-500'}>
{isActive ? '启用' : '禁用'}
</span>
)
},
},
] ]
// 短效资源增加到期时间列 // 短效资源增加到期时间列

View File

@@ -8,6 +8,8 @@ import {LayoutStoreProvider} from '@/components/stores/layout'
import {ClientStoreProvider} from '@/components/stores/client' import {ClientStoreProvider} from '@/components/stores/client'
import {getProfile} from '@/actions/auth' import {getProfile} from '@/actions/auth'
import Script from 'next/script' import Script from 'next/script'
import {AppStoreProvider} from '@/components/stores/app'
import {getApiUrl} from '@/actions/base'
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
return { return {
@@ -30,12 +32,14 @@ export default async function RootLayout(props: Readonly<{
) )
} }
function StoreProviders(props: {children: ReactNode}) { async function StoreProviders(props: {children: ReactNode}) {
return ( return (
<ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}> <ProfileStoreProvider profile={getProfile().then(resp => resp.success ? resp.data : null)}>
<LayoutStoreProvider> <LayoutStoreProvider>
<ClientStoreProvider> <ClientStoreProvider>
{props.children} <AppStoreProvider url={await getApiUrl().then(r => r.data)}>
{props.children}
</AppStoreProvider>
</ClientStoreProvider> </ClientStoreProvider>
</LayoutStoreProvider> </LayoutStoreProvider>
</ProfileStoreProvider> </ProfileStoreProvider>

View File

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

View File

@@ -46,7 +46,7 @@ export function DesktopPayment(props: PaymentModalProps) {
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
使 使
{decoration.text} {decoration.text}
{/* 扫码支付 */}
</p> </p>
<div className="w-full text-center space-y-2"> <div className="w-full text-center space-y-2">

View File

@@ -7,6 +7,7 @@ import {PaymentProps} from './type'
import {payClose} from '@/actions/resource' import {payClose} from '@/actions/resource'
import {useEffect} from 'react' import {useEffect} from 'react'
import {UniversalDesktopPayment} from './universal-desktop-payment' import {UniversalDesktopPayment} from './universal-desktop-payment'
import {useAppStore} from '@/components/stores/app'
export type PaymentModalProps = { export type PaymentModalProps = {
onConfirm: (showFail: boolean) => Promise<void> onConfirm: (showFail: boolean) => Promise<void>
@@ -34,9 +35,10 @@ export function PaymentModal(props: PaymentModalProps) {
} }
// SSE处理方式检查支付状态 // SSE处理方式检查支付状态
const apiUrl = useAppStore('apiUrl')
useEffect(() => { useEffect(() => {
const eventSource = new EventSource( const eventSource = new EventSource(
`${process.env.NEXT_PUBLIC_API_BASE_URL}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`, `${apiUrl}/api/trade/check?trade_no=${props.inner_no}&method=${props.method}`,
) )
eventSource.onmessage = async (event) => { eventSource.onmessage = async (event) => {
switch (event.data) { switch (event.data) {
@@ -53,7 +55,7 @@ export function PaymentModal(props: PaymentModalProps) {
return () => { return () => {
eventSource.close() eventSource.close()
} }
}, [props]) }, [apiUrl, props])
return ( return (
<Dialog <Dialog

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

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

View File

@@ -1,246 +1,164 @@
'use client' 'use client'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Minus, Plus} from 'lucide-react'
import FormOption from '@/components/composites/purchase/option' import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import check from '../_assets/check.svg'
import {Schema} from '@/components/composites/purchase/long/form' import {Schema} from '@/components/composites/purchase/long/form'
import {useEffect} from 'react'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card' import {Card} from '@/components/ui/card'
import {useEffect} from 'react' import {BillingMethodField} from '../shared/billing-method-field'
import {FeatureList} from '../shared/feature-list'
import {NumberStepperField} from '../shared/number-stepper-field'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
export default function Center({skuData}: {
skuData: PurchaseSkuData
}) {
const {setValue} = 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})
: []
export default function Center() {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => { useEffect(() => {
if (type === '1') { const nextType = modeList.includes(type) ? type : modeList[0]
form.setValue('daily_limit', 100)
if (!nextType) {
return
} }
else {
form.setValue('quota', 500) if (nextType !== type) {
setValue('type', nextType)
return
} }
}, [type, form])
const nextLiveList = nextType === '1'
? getAvailablePurchaseLives(skuData, {mode: nextType, expire})
: getAvailablePurchaseLives(skuData, {mode: nextType})
const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0]
if (nextLive && nextLive !== live) {
setValue('live', nextLive)
return
}
if (nextType === '2') {
if (expire !== '0') {
setValue('expire', '0')
}
return
}
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive})
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
setValue('expire', nextExpireList[0])
}
}, [expire, live, modeList, setValue, skuData, type])
return ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-6 relative">
<BillingMethodField modeList={modeList} timeDailyLimit={100}/>
{/* 计费方式 */}
<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>
{/* IP 时效 */} {/* IP 时效 */}
<FormField <FormField<Schema, 'live'>
className="space-y-4" className="space-y-4"
name="live" name="live"
label="IP 时效"> label="IP 时效">
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
id={id} id={id}
defaultValue={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={(value) => {
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> field.onChange(value)
<FormOption id={`${id}-1`} value="1" label="1 小时" description="¥0.3/IP" compare={field.value}/> if (type !== '1') {
<FormOption id={`${id}-4`} value="4" label="4 小时" description="¥0.8/IP" compare={field.value}/> return
<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}/> 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 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> </RadioGroup>
)} )}
</FormField> </FormField>
{/* 根据套餐类型显示不同表单项 */} {/* 套餐时效 */}
{type === '2' ? ( {type === '1' && (
/* 包量IP 购买数量 */ <FormField className="space-y-4" name="expire" label="套餐时效">
<FormField {({id, field}) => (
className="space-y-4" <RadioGroup
name="quota" id={id}
label="IP 购买数量"> value={field.value}
{({id, field}) => { onValueChange={(value) => {
const value = Number(field.value) || 500 field.onChange(value)
const minValue = 500
const step = 100 const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
return ( if (!nextLiveList.includes(live) && nextLiveList[0]) {
<div className="flex gap-2 items-center"> setValue('live', nextLiveList[0])
<Button }
theme="outline" }}
type="button" className="flex gap-4 flex-wrap">
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg" {expireList.map(day => (
onClick={() => form.setValue('quota', Math.max(minValue, value - step))} <FormOption
disabled={value === minValue}> key={day}
<Minus/> id={`${id}-${day}`}
</Button> value={day}
<Input label={`${day}`}
{...field} compare={field.value}
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" </RadioGroup>
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> </FormField>
) : (
<>
{/* 包时:套餐时效 */}
<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>
{/* 包时:每日提取上限 */}
<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>
)
}}
</FormField>
</>
)} )}
{/* 产品特性 */} {/* 每日提取上限/购买数量 */}
<div className="space-y-6"> {type === '1' ? (
<h3></h3> <NumberStepperField
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 gap-y-6"> name="daily_limit"
<p className="flex gap-2 items-center"> label="每日提取上限"
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> min={100}
<span className="text-sm text-gray-500"></span> step={100}
</p> />
<p className="flex gap-2 items-center"> ) : (
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <NumberStepperField
<span className="text-sm text-gray-500"></span> name="quota"
</p> label="IP 购买数量"
<p className="flex gap-2 items-center"> min={500}
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> step={100}
<span className="text-sm text-gray-500"></span> />
</p> )}
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <FeatureList/>
<span className="text-sm text-gray-500">API接口</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP时效3-30()</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP资源定期筛选</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">API接口</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">/</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">500</span>
</p>
</div>
</div>
</Card> </Card>
) )
} }

View File

@@ -1,32 +1,38 @@
'use client' 'use client'
import {useForm} from 'react-hook-form' import {useForm} from 'react-hook-form'
import Center from '@/components/composites/purchase/long/center' import Center from '@/components/composites/purchase/long/center'
import Right from '@/components/composites/purchase/long/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, parsePurchaseSkuList} from '../shared/sku'
import {PurchaseSidePanel} from '../shared/side-panel'
// 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.enum(['1', '4', '8', '12', '24']), live: z.string(),
quota: z.number().min(500, '购买数量不能少于 500 个'), 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 个'), daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']),
}) })
// 从架构中推断类型
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
export default function LongForm() { export default function LongForm({skuList}: {skuList: ProductItem['skus']}) {
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 form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: defaultMode,
live: '1', // 小时 live: defaultLive,
expire: defaultExpire,
quota: 500, quota: 500,
expire: '30', // 天
daily_limit: 100, daily_limit: 100,
pay_type: 'balance', // 余额支付 pay_type: 'balance', // 余额支付
}, },
@@ -34,8 +40,8 @@ export default function LongForm() {
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/> <Center skuData={skuData}/>
<Right/> <PurchaseSidePanel kind="long"/>
</Form> </Form>
) )
} }

View File

@@ -1,181 +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} 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',
discounted_price: '0.00',
discounted: 0,
})
useEffect(() => {
const price = async () => {
try {
const resp = await getPrice({
type: 2,
long: {
live: Number(live) * 60,
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 ?? '',
discounted: resp.data.discounted,
})
}
catch (error) {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [dailyLimit, expire, live, quota, mode])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
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>
</>
) : (
<>
<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>
{/* {discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">总折扣</span>
<span className="text-sm">
-¥{discounted}
</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) * 60,
expire: props.mode === '1' ? Number(props.expire) : undefined,
quota: props.mode === '1' ? Number(props.dailyLimit) : Number(props.quota),
},
}}/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)
}

View File

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

View File

@@ -0,0 +1,61 @@
'use client'
import {useFormContext} from 'react-hook-form'
import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group'
import FormOption from '../option'
import {PurchaseMode} from './resource'
import {PurchaseFormValues} from './form-values'
export function BillingMethodField(props: {
modeList: PurchaseMode[]
timeDailyLimit: number
}) {
const {setValue} = useFormContext<PurchaseFormValues>()
return (
<FormField<PurchaseFormValues, 'type'>
className="flex flex-col gap-4"
name="type"
label="计费方式"
>
{({id, field}) => (
<RadioGroup
id={id}
value={field.value}
onValueChange={(value) => {
field.onChange(value)
if (value === '2') {
setValue('expire', '0')
return
}
setValue('daily_limit', props.timeDailyLimit)
}}
className="flex gap-4 max-md:flex-col"
>
{props.modeList.includes('2') && (
<FormOption
id={`${id}-2`}
value="2"
label="包量套餐"
description="适用于短期或不定期高提取业务场景"
compare={field.value}
/>
)}
{props.modeList.includes('1') && (
<FormOption
id={`${id}-1`}
value="1"
label="包时套餐"
description="适用于每日提取量稳定的业务场景"
compare={field.value}
/>
)}
</RadioGroup>
)}
</FormField>
)
}

View File

@@ -0,0 +1,35 @@
'use client'
import Image from 'next/image'
import check from '../_assets/check.svg'
const defaultFeatures = [
'支持高并发提取',
'指定省份、城市或混播',
'账密+白名单验证',
'完备的API接口',
'IP时效3-30分钟(可定制)',
'IP资源定期筛选',
'包量/包时计费方式',
'每日去重量500万',
]
export function FeatureList(props: {
items?: string[]
}) {
const items = props.items || defaultFeatures
return (
<div className="space-y-6">
<h3></h3>
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6">
{items.map(item => (
<p key={item} className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">{item}</span>
</p>
))}
</div>
</div>
)
}

View File

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

View File

@@ -0,0 +1,8 @@
export type PurchaseFormValues = {
type: '1' | '2'
live: string
quota: number
expire: string
daily_limit: number
pay_type: 'wechat' | 'alipay' | 'balance'
}

View File

@@ -0,0 +1,73 @@
'use client'
import {useFormContext} from 'react-hook-form'
import {Minus, Plus} from 'lucide-react'
import {FormField} from '@/components/ui/form'
import {Button} from '@/components/ui/button'
import {Input} from '@/components/ui/input'
import {PurchaseFormValues} from './form-values'
type PurchaseStepperFieldName = 'quota' | 'daily_limit'
type NumberStepperFieldProps = {
name: PurchaseStepperFieldName
label: string
min: number
step: number
}
export function NumberStepperField(props: NumberStepperFieldProps) {
const form = useFormContext<PurchaseFormValues>()
const setValue = (value: number) => {
form.setValue(props.name, value)
}
return (
<FormField<PurchaseFormValues, PurchaseStepperFieldName>
className="space-y-4"
name={props.name}
label={props.label}
>
{({id, field}) => {
const value = Number(field.value) || props.min
return (
<div className="flex gap-2 items-center">
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => setValue(Math.max(props.min, value - props.step))}
disabled={value === props.min}
>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={props.min}
step={props.step}
onBlur={(event) => {
field.onBlur()
const nextValue = Number(event.target.value)
if (nextValue < props.min) {
setValue(props.min)
}
}}
/>
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => setValue(value + props.step)}
>
<Plus/>
</Button>
</div>
)
}}
</FormField>
)
}

View File

@@ -0,0 +1,38 @@
import {CreateResourceReq} from '@/actions/resource'
export type PurchaseKind = 'short' | 'long'
export type PurchaseMode = '1' | '2'
export type PurchaseSelection = {
kind: PurchaseKind
mode: PurchaseMode
live: string
quota: number
expire: string
dailyLimit: number
}
function getPurchasePayload(selection: PurchaseSelection) {
return {
mode: Number(selection.mode),
live: Number(selection.live),
expire: selection.mode === '1' ? Number(selection.expire) : undefined,
quota: selection.mode === '1' ? Number(selection.dailyLimit) : Number(selection.quota),
}
}
export function buildPurchaseResource(selection: PurchaseSelection): CreateResourceReq {
const payload = getPurchasePayload(selection)
if (selection.kind === 'short') {
return {
type: 1,
short: payload,
}
}
return {
type: 2,
long: payload,
}
}

View File

@@ -0,0 +1,189 @@
'use client'
import {use, useEffect, useRef, useState} from 'react'
import Link from 'next/link'
import {useFormContext, useWatch} from 'react-hook-form'
import {Card} from '@/components/ui/card'
import {buttonVariants} from '@/components/ui/button'
import {useProfileStore} from '@/components/stores/profile'
import Pay from '@/components/composites/purchase/pay'
import {FieldPayment} from './field-payment'
import {buildPurchaseResource, PurchaseKind, PurchaseSelection} from './resource'
import {getPrice, getPriceHome} from '@/actions/resource'
import {ExtraResp} from '@/lib/api'
import {formatPurchaseLiveLabel} from './sku'
import {User} from '@/lib/models'
import {PurchaseFormValues} from './form-values'
const emptyPrice: ExtraResp<typeof getPrice> = {
price: '0.00',
actual: '0.00',
discounted: '0.00',
}
export type PurchaseSidePanelProps = {
kind: PurchaseKind
}
export function PurchaseSidePanel(props: PurchaseSidePanelProps) {
const {control} = useFormContext<PurchaseFormValues>()
const method = useWatch<PurchaseFormValues>({control, name: 'pay_type'}) as PurchaseFormValues['pay_type']
const mode = useWatch<PurchaseFormValues>({control, name: 'type'}) as PurchaseFormValues['type']
const live = useWatch<PurchaseFormValues>({control, name: 'live'}) as PurchaseFormValues['live']
const quota = useWatch<PurchaseFormValues>({control, name: 'quota'}) as PurchaseFormValues['quota']
const expire = useWatch<PurchaseFormValues>({control, name: 'expire'}) as PurchaseFormValues['expire']
const dailyLimit = useWatch<PurchaseFormValues>({control, name: 'daily_limit'}) as PurchaseFormValues['daily_limit']
const profile = use(useProfileStore(store => store.profile))
const selection: PurchaseSelection = {
kind: props.kind,
mode,
live,
quota,
expire,
dailyLimit,
}
const priceData = usePurchasePrice(profile, selection)
const {price, actual: discountedPrice = '0.00'} = priceData
const totalDiscount = getTotalDiscount(price, discountedPrice)
const hasDiscount = Number(totalDiscount) > 0
const liveLabel = formatPurchaseLiveLabel(live, props.kind)
const resource = buildPurchaseResource(selection)
return (
<Card className="flex-none basis-90 p-6 flex flex-col gap-6 relative">
<h3></h3>
<ul className="flex flex-col gap-3">
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{mode === '2' ? '包量套餐' : '包时套餐'}</span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">IP </span>
<span className="text-sm">{liveLabel}</span>
</li>
{mode === '2' ? (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"> IP </span>
<span className="text-sm">{quota} </span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{price}</span>
</li>
{hasDiscount && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">-{totalDiscount}</span>
</li>
)}
</>
) : (
<>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{expire} </span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{dailyLimit} </span>
</li>
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">{price}</span>
</li>
{hasDiscount && (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500"></span>
<span className="text-sm">-{totalDiscount}</span>
</li>
)}
</>
)}
</ul>
<div className="border-b border-gray-200"></div>
<p className="flex justify-between items-center">
<span></span>
<span className="text-xl text-orange-500">{discountedPrice}</span>
</p>
{profile ? (
<>
<FieldPayment balance={profile.balance}/>
<Pay
method={method}
balance={profile.balance}
amount={discountedPrice}
resource={resource}
/>
</>
) : (
<Link href="/login" className={buttonVariants()}>
</Link>
)}
</Card>
)
}
function usePurchasePrice(profile: User | null, selection: PurchaseSelection) {
const [priceData, setPriceData] = useState<ExtraResp<typeof getPrice>>(emptyPrice)
const requestIdRef = useRef(0)
const {kind, mode, live, quota, expire, dailyLimit} = selection
useEffect(() => {
const requestId = ++requestIdRef.current
const loadPrice = async () => {
try {
const resource = buildPurchaseResource({
kind,
mode,
live,
quota,
expire,
dailyLimit,
})
const response = profile
? await getPrice(resource)
: await getPriceHome(resource)
if (requestId !== requestIdRef.current) {
return
}
if (!response.success) {
throw new Error(response.message || '获取价格失败')
}
setPriceData({
price: response.data.price,
actual: response.data.actual ?? response.data.price ?? '0.00',
discounted: response.data.discounted ?? '0.00',
})
}
catch (error) {
if (requestId !== requestIdRef.current) {
return
}
console.error('获取价格失败:', error)
setPriceData(emptyPrice)
}
}
loadPrice()
}, [dailyLimit, expire, kind, live, mode, profile, quota])
return priceData
}
function getTotalDiscount(price: string, discountedPrice: string) {
const originalPrice = Number.parseFloat(price)
const actualPrice = Number.parseFloat(discountedPrice)
if (Number.isNaN(originalPrice) || Number.isNaN(actualPrice)) {
return '0.00'
}
return (originalPrice - actualPrice).toFixed(2)
}

View File

@@ -0,0 +1,165 @@
import {ProductItem} from '@/actions/product'
import {PurchaseKind, PurchaseMode} from './resource'
export type PurchaseSkuItem = {
code: string
mode: PurchaseMode
live: string
expire: string
price: string
}
export type PurchaseSkuData = {
items: PurchaseSkuItem[]
priceMap: Map<string, string>
modeList: PurchaseMode[]
liveList: string[]
expireList: string[]
}
export function parsePurchaseSkuList(kind: PurchaseKind, skuList: ProductItem['skus']): PurchaseSkuData {
if (!skuList?.length) {
throw new Error('没有套餐数据')
}
const items: PurchaseSkuItem[] = []
const priceMap = new Map<string, string>()
const modeSet = new Set<PurchaseMode>()
const liveSet = new Set<number>()
const expireSet = new Set<number>()
for (const sku of skuList) {
const params = new URLSearchParams(sku.code)
const mode = parsePurchaseSkuMode(params.get('mode'))
const live = Number(params.get('live') || '0')
const expire = Number(params.get('expire') || '0')
if (!mode || live <= 0) {
continue
}
const liveValue = String(live)
const expireValue = mode === '1' ? String(expire || '0') : '0'
const code = getPurchaseSkuKey({
mode,
live: liveValue,
expire: expireValue,
})
items.push({
code,
mode,
live: liveValue,
expire: expireValue,
price: sku.price,
})
priceMap.set(code, sku.price)
modeSet.add(mode)
liveSet.add(live)
if (kind === 'short') {
if (mode === '1' && expire > 0) {
expireSet.add(expire)
}
}
else if (expire > 0) {
expireSet.add(expire)
}
}
if (items.length === 0) {
throw new Error('没有可用的套餐数据')
}
return {
items,
priceMap,
modeList: (['2', '1'] as const).filter(mode => modeSet.has(mode)),
liveList: sortNumericValues(liveSet),
expireList: sortNumericValues(expireSet),
}
}
function parsePurchaseSkuMode(mode: string | null): PurchaseMode | null {
if (mode === 'time') {
return '1'
}
if (mode === 'quota') {
return '2'
}
return null
}
function sortNumericValues(values: Iterable<number>) {
return Array.from(values).sort((a, b) => a - b).map(String)
}
export function getPurchaseSkuKey(props: {
mode: PurchaseMode
live: string
expire: string
}) {
const params = new URLSearchParams()
params.set('mode', props.mode === '1' ? 'time' : 'quota')
params.set('live', props.live || '0')
params.set('expire', props.mode === '1' ? props.expire || '0' : '0')
return params.toString()
}
export function getAvailablePurchaseLives(skuData: PurchaseSkuData, props: {
mode: PurchaseMode
expire?: string
}) {
return sortNumericValues(new Set(
skuData.items
.filter(item => item.mode === props.mode)
.filter(item => !props.expire || item.expire === props.expire)
.map(item => Number(item.live)),
))
}
export function getAvailablePurchaseExpires(skuData: PurchaseSkuData, props: {
mode: PurchaseMode
live?: string
}) {
return sortNumericValues(new Set(
skuData.items
.filter(item => item.mode === props.mode)
.filter(item => !props.live || item.live === props.live)
.filter(item => item.expire !== '0')
.map(item => Number(item.expire)),
))
}
export function hasPurchaseSku(skuData: PurchaseSkuData, props: {
mode: PurchaseMode
live: string
expire: string
}) {
return skuData.priceMap.has(getPurchaseSkuKey(props))
}
export function getPurchaseSkuPrice(priceMap: Map<string, string>, props: {
mode: PurchaseMode
live: string
expire: string
}) {
return priceMap.get(getPurchaseSkuKey(props))
}
export function formatPurchaseLiveLabel(live: string, kind: PurchaseKind) {
const minutes = Number(live)
if (kind === 'long') {
return `${minutes / 60} 小时`
}
if (minutes % 60 === 0) {
return `${minutes / 60} 小时`
}
return `${minutes} 分钟`
}

View File

@@ -1,60 +1,71 @@
'use client' 'use client'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Minus, Plus} from 'lucide-react'
import FormOption from '@/components/composites/purchase/option' import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image' import {useEffect} from 'react'
import check from '../_assets/check.svg'
import {useFormContext, useWatch} from 'react-hook-form' import {useFormContext, useWatch} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form' import {Schema} from '@/components/composites/purchase/short/form'
import {Card} from '@/components/ui/card' import {Card} from '@/components/ui/card'
import {useEffect} from 'react' import {BillingMethodField} from '../shared/billing-method-field'
import {FeatureList} from '../shared/feature-list'
import {NumberStepperField} from '../shared/number-stepper-field'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, getPurchaseSkuPrice, hasPurchaseSku, PurchaseSkuData} from '../shared/sku'
export default function Center({
skuData,
}: {
skuData: PurchaseSkuData
}) {
const {setValue} = 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})
: []
export default function Center() {
const form = useFormContext<Schema>()
const type = useWatch({name: 'type'})
useEffect(() => { useEffect(() => {
if (type === '1') { const nextType = modeList.includes(type) ? type : modeList[0]
form.setValue('daily_limit', 2000)
if (!nextType) {
return
} }
else {
form.setValue('quota', 10000) if (nextType !== type) {
setValue('type', nextType)
return
} }
}, [type, form])
const nextLiveList = nextType === '1'
? getAvailablePurchaseLives(skuData, {mode: nextType, expire})
: getAvailablePurchaseLives(skuData, {mode: nextType})
const nextLive = nextLiveList.includes(live) ? live : nextLiveList[0]
if (nextLive && nextLive !== live) {
setValue('live', nextLive)
return
}
if (nextType === '2') {
if (expire !== '0') {
setValue('expire', '0')
}
return
}
const nextExpireList = getAvailablePurchaseExpires(skuData, {mode: nextType, live: nextLive})
if (!nextExpireList.includes(expire) && nextExpireList[0]) {
setValue('expire', nextExpireList[0])
}
}, [expire, live, modeList, setValue, skuData, type])
return ( return (
<Card className="flex-auto p-6 flex flex-col gap-6 relative"> <Card className="flex-auto p-6 flex flex-col gap-6 relative">
<BillingMethodField modeList={modeList} timeDailyLimit={2000}/>
{/* 计费方式 */}
<FormField<Schema, 'type'>
className="flex flex-col gap-4"
name="type"
label="计费方式">
{({id, field}) => (
<RadioGroup
id={id}
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>
{/* IP 时效 */} {/* IP 时效 */}
<FormField<Schema, 'live'> <FormField<Schema, 'live'>
@@ -64,168 +75,95 @@ export default function Center() {
{({id, field}) => ( {({id, field}) => (
<RadioGroup <RadioGroup
id={id} id={id}
defaultValue={field.value} value={field.value}
onValueChange={field.onChange} onValueChange={(value) => {
className="grid grid-cols-[repeat(auto-fill,minmax(120px,1fr))] gap-4"> field.onChange(value)
<FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/> if (type !== '1') {
<FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.01/IP" compare={field.value}/> return
<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}/> 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 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} 分钟`
return (
<FormOption
key={live}
id={`${id}-${live}`}
value={live}
label={label}
description={price && `${price}/IP`}
compare={field.value}
/>
)
})}
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>
{/* 根据套餐类型显示不同表单项 */} {/* 套餐时效 */}
{type === '2' ? ( {type === '1' && (
/* 包量IP 购买数量 */ <FormField className="space-y-4" name="expire" label="套餐时效">
<FormField
className="space-y-4"
name="quota"
label="IP 购买数量">
{({id, field}) => ( {({id, field}) => (
<div className="flex gap-2 items-center"> <RadioGroup
<Button id={id}
theme="outline" value={field.value}
type="button" onValueChange={(value) => {
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg" field.onChange(value)
onClick={() => form.setValue('quota', Math.max(10000, Number(field.value) - 5000))}
disabled={Number(field.value) === 10000}> const nextLiveList = getAvailablePurchaseLives(skuData, {mode: type, expire: value})
<Minus/> if (!nextLiveList.includes(live) && nextLiveList[0]) {
</Button> setValue('live', nextLiveList[0])
<Input }
{...field} }}
id={id} className="flex gap-4 flex-wrap">
type="number" {expireList.map(day => (
className="w-40 h-10 border border-gray-200 rounded-sm text-center" <FormOption
min={10000} key={day}
step={5000} id={`${id}-${day}`}
onBlur={(e) => { value={day}
const value = Number(e.target.value) label={`${day}`}
if (value < 10000) { compare={field.value}
form.setValue('quota', 10000) />
} ))}
}} </RadioGroup>
/>
<Button
theme="outline"
type="button"
className="h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg"
onClick={() => form.setValue('quota', Number(field.value) + 5000)}>
<Plus/>
</Button>
</div>
)} )}
</FormField> </FormField>
) : (
<>
{/* 包时:套餐时效 */}
<FormField
className="space-y-4"
name="expire"
label="套餐时效">
{({id, field}) => (
<RadioGroup
id={id}
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>
{/* 包时:每日提取上限 */}
<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(2_000, Number(field.value) - 1_000))}
disabled={Number(field.value) === 2_000}>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className="w-40 h-10 border border-gray-200 rounded-sm text-center"
min={2_000}
step={1_000}
onBlur={(e) => {
const value = Number(e.target.value)
if (value < 2_000) {
form.setValue('daily_limit', 2_000)
}
}}
/>
<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) + 1_000)}>
<Plus/>
</Button>
</div>
)}
</FormField>
</>
)} )}
{/* 产品特性 */} {/* 每日提取上限/购买数量 */}
<div className="space-y-6"> {type === '1' ? (
<h3></h3> <NumberStepperField
<div className="grid grid-cols-2 md:grid-cols-3 auto-rows-fr gap-4 md:gap-y-6"> name="daily_limit"
<p className="flex gap-2 items-center"> label="每日提取上限"
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> min={2000}
<span className="text-sm text-gray-500"></span> step={1000}
</p> />
<p className="flex gap-2 items-center"> ) : (
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <NumberStepperField
<span className="text-sm text-gray-500"></span> name="quota"
</p> label="IP 购买数量"
<p className="flex gap-2 items-center"> min={10000}
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> step={5000}
<span className="text-sm text-gray-500"></span> />
</p> )}
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/> <FeatureList/>
<span className="text-sm text-gray-500">API接口</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP时效3-30()</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">IP资源定期筛选</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">API接口</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">/</span>
</p>
<p className="flex gap-2 items-center">
<Image src={check} alt="check" aria-hidden className="w-4 h-4"/>
<span className="text-sm text-gray-500">500</span>
</p>
</div>
</div>
</Card> </Card>
) )
} }

View File

@@ -1,32 +1,38 @@
'use client' 'use client'
import {useForm} from 'react-hook-form' import {useForm} from 'react-hook-form'
import Center from '@/components/composites/purchase/short/center' import Center from '@/components/composites/purchase/short/center'
import Right from '@/components/composites/purchase/short/right'
import {Form} from '@/components/ui/form' import {Form} from '@/components/ui/form'
import * as z from 'zod' import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
import {ProductItem} from '@/actions/product'
import {getAvailablePurchaseExpires, getAvailablePurchaseLives, parsePurchaseSkuList} from '../shared/sku'
import {PurchaseSidePanel} from '../shared/side-panel'
// 定义表单验证架构
const schema = z.object({ const schema = z.object({
type: z.enum(['1', '2']).default('2'), type: z.enum(['1', '2']).default('2'),
live: z.enum(['3', '5', '10', '15', '30']), live: z.string(),
quota: z.number().min(10000, '购买数量不能少于10000个'), quota: z.number().min(10000, '购买数量不能少于 10000 个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']), expire: z.string(),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'), daily_limit: z.number().min(2000, '每日限额不能少于 2000 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']), pay_type: z.enum(['wechat', 'alipay', 'balance']).default('balance'),
}) })
// 从架构中推断类型
export type Schema = z.infer<typeof schema> export type Schema = z.infer<typeof schema>
export default function ShortForm() { export default function ShortForm({skuList}: {skuList: ProductItem['skus']}) {
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 form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: defaultMode,
live: '3', // 分钟 live: defaultLive,
quota: 10_000, // >= 10000 expire: defaultExpire,
expire: '30', // 天 quota: 10_000, // >= 10000,
daily_limit: 2_000, // >= 2000 daily_limit: 2_000, // >= 2000
pay_type: 'balance', // 余额支付 pay_type: 'balance', // 余额支付
}, },
@@ -34,8 +40,8 @@ export default function ShortForm() {
return ( return (
<Form form={form} className="flex flex-col lg:flex-row gap-4"> <Form form={form} className="flex flex-col lg:flex-row gap-4">
<Center/> <Center skuData={skuData}/>
<Right/> <PurchaseSidePanel kind="short"/>
</Form> </Form>
) )
} }

View File

@@ -1,182 +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} 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 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',
discounted_price: '0.00',
discounted: 0,
})
useEffect(() => {
const price = async () => {
try {
const priceResponse = await getPrice({
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,
discounted_price: data.discounted_price ?? data.price ?? '',
discounted: data.discounted,
})
}
catch (error) {
console.error('获取价格失败:', error)
setPriceData({
price: '0.00',
discounted_price: '0.00',
discounted: 0,
})
}
}
price()
}, [expire, live, quota, mode, dailyLimit])
const {price, discounted_price: discountedPrice = '', discounted} = priceData
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>
</>
) : (
<>
<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>
{/* {discounted === 1 ? '' : (
<li className="flex justify-between items-center">
<span className="text-sm text-gray-500">总折扣</span>
<span className="text-sm">
-¥{discounted === 1 ? '' : discounted}
</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

@@ -0,0 +1,38 @@
'use client'
import {createStore, StoreApi} from 'zustand/vanilla'
import {createContext, ReactNode, useContext, useState} from 'react'
import {useStore} from 'zustand/react'
// store
type AppStoreState = {
apiUrl: string
}
export function createAppStore(url: string) {
return createStore<AppStoreState>()(() => ({
apiUrl: url,
}))
}
// provider
const AppStoreContext = createContext<StoreApi<AppStoreState> | null>(null)
export function AppStoreProvider(props: {
url: string
children: ReactNode
}) {
const [store] = useState(() => createAppStore(props.url))
return (
<AppStoreContext.Provider value={store}>
{props.children}
</AppStoreContext.Provider>
)
}
export function useAppStore(name: keyof AppStoreState) {
const context = useContext(AppStoreContext)
if (!context) {
throw new Error('AppStoreContext 没有正确初始化')
}
return useStore(context, c => c[name])
}

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

View File

@@ -1,6 +1,6 @@
// 定义后端服务URL和OAuth2配置 // 定义后端服务URL和OAuth2配置
const _api_base_url = process.env.NEXT_PUBLIC_API_BASE_URL const _api_base_url = process.env.API_BASE_URL
if (!_api_base_url) throw new Error('NEXT_PUBLIC_API_BASE_URL is not set') if (!_api_base_url) throw new Error('API_BASE_URL is not set')
const API_BASE_URL = _api_base_url const API_BASE_URL = _api_base_url
const _client_id = process.env.CLIENT_ID const _client_id = process.env.CLIENT_ID

View File

@@ -8,4 +8,5 @@ export type Batch = {
time: string time: string
user_id: number user_id: number
prov: string prov: string
city: string
} }

View File

@@ -0,0 +1,10 @@
export type ProductSku = {
id: number
code: string
name: string
price: string
price_min: string
product_id: number
discount_id: number
status: number
}

11
src/lib/models/product.ts Normal file
View File

@@ -0,0 +1,11 @@
import {ProductSku} from './product-sku'
export type Product = {
id: number
code: string
name: string
description: string
sort: number
status: number
skus?: ProductSku[]
}