重构项目结构,将数据层集中在 lib 包中;resource 类型更新,支持多个子套餐类型分别表示;新增长效套餐的购买流程,以及已购查询功能

This commit is contained in:
2025-05-22 14:59:22 +08:00
parent 9652181fe4
commit dc83c83cfb
29 changed files with 1827 additions and 1143 deletions

View File

@@ -0,0 +1,18 @@
## TODO
整合多类型的产品支付流程
支付页面组件的布局组件复用,不同产品只提供不同数据
整合多类型的产品提取流程
调整页面大小优化:如果单页大小不超过预期大小,不需要刷新数据
翻页优化:调整页面大小后检查是否需要重置页面到最后一页(需要后端实现)
### 架构改进
考虑使用 swr 或 react query 来代替直接的服务端 react cache 缓存以及客户端 zustand 缓存,以将服务端请求的数据能够水合到客户端,避免重复请求
### 需要确认
页面内操作是否需要关联到 url 上,以在使用后退功能时返回到上一次操作

View File

@@ -1,8 +1,8 @@
'use server' 'use server'
import { PageRecord } from "@/lib/api" import {PageRecord} from '@/lib/api'
import { Announcement } from "@/lib/models" import {Announcement} from '@/lib/models'
import { callByUser } from "./base" import {callByUser} from './base'
export async function listAnnouncements(props: { export async function listAnnouncements(props: {
page: number page: number

View File

@@ -2,9 +2,9 @@
import {callByUser} from '@/actions/base' import {callByUser} from '@/actions/base'
import {Resource} from '@/lib/models' import {Resource} from '@/lib/models'
import {PageRecord} from '@/lib/api' import {ExtraReq, PageRecord} from '@/lib/api'
async function listResourcePss(props: { export async function listResourceShort(props: {
page: number page: number
size: number size: number
resource_no?: string resource_no?: string
@@ -14,58 +14,72 @@ async function listResourcePss(props: {
expire_after?: Date expire_after?: Date
expire_before?: Date expire_before?: Date
}) { }) {
return await callByUser<PageRecord<Resource>>('/api/resource/list/short', props) return await callByUser<PageRecord<Resource<1>>>('/api/resource/list/short', props)
} }
async function allResource(){ export async function listResourceLong(props: {
return callByUser<Resource[]>('/api/resource/all') page: number
size: number
resource_no?: string
type?: number
create_after?: Date
create_before?: Date
expire_after?: Date
expire_before?: Date
}) {
return await callByUser<PageRecord<Resource<2>>>('/api/resource/list/long', props)
} }
type CreateResourceReq = { export async function allResource() {
return callByUser<Resource<1 | 2>[]>('/api/resource/all')
}
export async function createResource(props: {
type: number type: number
short?: {
live: number live: number
mode: number
quota: number quota: number
expire: number expire: number
daily_limit: number daily_limit: number
} }
long?: {
live: number
mode: number
quota: number
expire: number
daily_limit: number
}
}) {
return await callByUser('/api/resource/create', props)
}
type CreateResourceResp = { export async function prepareResource(props: {
method: number
type: number
short?: {
live: number
mode: number
quota: number
expire: number
daily_limit: number
}
long?: {
live: number
mode: number
quota: number
expire: number
daily_limit: number
}
}) {
return await callByUser<{
trade_no: string trade_no: string
pay_url: string pay_url: string
}>('/api/resource/create/prepare', props)
} }
async function createResourceByBalance(props: CreateResourceReq) { export async function completeResource(props: {
return await callByUser('/api/resource/create/balance', props)
}
async function prepareResourceByAlipay(props: CreateResourceReq) {
return await callByUser<CreateResourceResp>('/api/resource/prepare/alipay', props)
}
async function prepareResourceByWechat(props: CreateResourceReq) {
return await callByUser<CreateResourceResp>('/api/resource/prepare/wechat', props)
}
type PaidResourceReq = {
trade_no: string trade_no: string
} }) {
return await callByUser('/api/resource/create/complete', props)
async function createResourceByAlipay(props: PaidResourceReq) {
return await callByUser('/api/resource/create/alipay', props)
}
async function createResourceByWechat(props: PaidResourceReq) {
return await callByUser('/api/resource/create/wechat', props)
}
export {
listResourcePss,
allResource,
prepareResourceByAlipay,
prepareResourceByWechat,
createResourceByBalance,
createResourceByAlipay,
createResourceByWechat,
type CreateResourceReq,
type CreateResourceResp,
} }

View File

@@ -2,7 +2,7 @@
import {callByUser, callPublic} from '@/actions/base' import {callByUser, callPublic} from '@/actions/base'
export async function RechargeByAlipay(props: { export async function RechargeByAlipay(props: {
amount: number amount: string
}) { }) {
return callByUser<{ return callByUser<{
trade_no: string trade_no: string
@@ -17,7 +17,7 @@ export async function RechargeByAlipayConfirm(props: {
} }
export async function RechargeByWechat(props: { export async function RechargeByWechat(props: {
amount: number amount: string
}) { }) {
return callByUser<{ return callByUser<{
trade_no: string trade_no: string

View File

@@ -174,12 +174,6 @@ export default function BillsPage(props: BillsPageProps) {
}, { }, {
accessorKey: 'type', header: `类型`, cell: ({row}) => ( accessorKey: 'type', header: `类型`, cell: ({row}) => (
<div className={`flex gap-2 items-center`}> <div className={`flex gap-2 items-center`}>
{row.original.type === 2 && (
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md`}>
<CreditCard size={16}/>
<span></span>
</div>
)}
{row.original.type === 1 && ( {row.original.type === 1 && (
<div className={`flex gap-2 items-center bg-orange-50 w-fit px-2 py-1 rounded-md`}> <div className={`flex gap-2 items-center bg-orange-50 w-fit px-2 py-1 rounded-md`}>
<CreditCard size={16}/> <CreditCard size={16}/>
@@ -192,6 +186,12 @@ export default function BillsPage(props: BillsPageProps) {
<span>退</span> <span>退</span>
</div> </div>
)} )}
{row.original.type === 3 && (
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md`}>
<CreditCard size={16}/>
<span></span>
</div>
)}
</div> </div>
), ),
}, },
@@ -238,7 +238,7 @@ export default function BillsPage(props: BillsPageProps) {
{ {
accessorKey: 'amount', header: `支付信息`, cell: ({row}) => ( accessorKey: 'amount', header: `支付信息`, cell: ({row}) => (
<div className={`flex gap-1`}> <div className={`flex gap-1`}>
<span> <span className={`text-sm`}>
{!row.original.trade && '余额'} {!row.original.trade && '余额'}
{row.original.trade && row.original.trade.method === 1 && '支付宝'} {row.original.trade && row.original.trade.method === 1 && '支付宝'}
{row.original.trade && row.original.trade.method === 2 && '微信'} {row.original.trade && row.original.trade.method === 2 && '微信'}

View File

@@ -0,0 +1,308 @@
'use client'
import {useStatus} from '@/lib/states'
import {useEffect, useState} from 'react'
import {ExtraResp} from '@/lib/api'
import {listResourceLong} from '@/actions/resource'
import zod from 'zod'
import {useSearchParams} from 'next/navigation'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Label} from '@/components/ui/label'
import DatePicker from '@/components/date-picker'
import {Button} from '@/components/ui/button'
import {Box, Eraser, Search, Timer} from 'lucide-react'
import DataTable from '@/components/data-table'
import {format, intlFormatDistance, isAfter} from 'date-fns'
export type LongResourceProps = {}
export default function LongResource(props: LongResourceProps) {
// ======================
// 查询
// ======================
const [status, setStatus] = useStatus()
const [data, setData] = useState<ExtraResp<typeof listResourceLong>>({
page: 1,
size: 10,
total: 0,
list: [],
})
const refresh = async (page: number, size: number) => {
setStatus('load')
try {
const type = {
all: undefined,
expire: 1,
quota: 2,
}[form.getValues('type')]
const create_after = form.getValues('create_after')
const create_before = form.getValues('create_before')
const expire_after = form.getValues('expire_after')
const expire_before = form.getValues('expire_before')
const resource_no = form.getValues('resource_no')
const res = await listResourceLong({
page, size,
type,
create_after,
create_before,
expire_after,
expire_before,
resource_no,
})
if (res.success) {
setData(res.data)
setStatus('done')
}
else {
throw new Error('Failed to load short resource')
}
}
catch (e) {
setStatus('fail')
}
}
useEffect(() => {
refresh(1, 10).then()
}, [])
// ======================
// 筛选
// ======================
const filterSchema = zod.object({
resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
expire_before: zod.date().optional(),
})
type FilterSchema = zod.infer<typeof filterSchema>
const params = useSearchParams()
let paramType = params.get('type')
if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') {
paramType = 'all'
}
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
resource_no: params.get('resource_no') || '',
type: paramType as 'expire' | 'quota' | 'all',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined,
},
})
const onSubmit = async (value: FilterSchema) => {
await refresh(1, data.size)
}
return <>
{/* 操作区 */}
<section className={`flex justify-between flex-wrap`}>
<div>
</div>
<Form form={form} onSubmit={onSubmit} className={`flex items-end gap-4 flex-wrap`}>
<FormField name={`resource_no`} label={<span className={`text-sm`}></span>}>
{({id, field}) => (
<Input {...field} id={id} className={`h-9`}/>
)}
</FormField>
<FormField name={`type`} label={<span className={`text-sm`}></span>}>
{({field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={`w-24 h-9`}>
<SelectValue placeholder={`选择套餐类型`}/>
</SelectTrigger>
<SelectContent>
<SelectItem value={`all`}></SelectItem>
<SelectItem value={`expire`}></SelectItem>
<SelectItem value={`quota`}></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}></Label>
<div className={`flex items-center`}>
<FormField name={`create_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`create_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}>使</Label>
<div className={`flex items-center`}>
<FormField name={`expire_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`expire_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex gap-4`}>
<Button className={`h-9`}>
<Search/>
<span></span>
</Button>
<Button theme={`outline`} className={`h-9`} onClick={() => form.reset({
type: 'all',
resource_no: '',
create_after: undefined,
create_before: undefined,
expire_after: undefined,
expire_before: undefined,
})}>
<Eraser/>
<span></span>
</Button>
</div>
</Form>
</section>
{/* 数据表 */}
<DataTable
data={data.list}
status={status}
pagination={{
total: data.total,
page: data.page,
size: data.size,
onPageChange: async (page: number) => {
await refresh(page, data.size)
},
onSizeChange: async (size: number) => {
await refresh(data.page, size)
},
}}
columns={[
{
accessorKey: 'resource_no', header: `套餐编号`,
},
{
accessorKey: 'type', header: `类型`, cell: ({row}) => (
<div className={`flex gap-2 items-center`}>
{row.original.long.type === 1 && (
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md`}>
<Timer size={20}/>
<span></span>
</div>
)}
{row.original.long.type === 2 && (
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md`}>
<Box size={20}/>
<span></span>
</div>
)}
</div>
),
},
{
accessorKey: 'live', header: `IP 时效`, cell: ({row}) => (
<span>
{row.original.long.live}
</span>
),
},
{
accessorKey: 'expire', header: `使用情况`, cell: ({row}) => (
<div className={`flex gap-1`}>
{row.original.long.type === 1 ? (
<div className={`flex gap-1`}>
{isAfter(row.original.long.expire, new Date())
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.long.daily_used} / {row.original.long.daily_limit}</span>
<span>|</span>
<span>{intlFormatDistance(row.original.long.expire, new Date())} </span>
</div>
) : row.original.long.type === 2 ? (
<div className={`flex gap-1`}>
{row.original.long.used < row.original.long.quota
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.long.used} / {row.original.long.quota}</span>
</div>
) : (
<span>-</span>
)}
</div>
),
},
{
accessorKey: 'daily_last', header: '最近使用时间', cell: ({row}) => {
return (
format(row.original.long.daily_last, 'yyyy-MM-dd') === '0001-01-01'
? '-'
: format(row.original.long.daily_last, 'yyyy-MM-dd HH:mm')
)
},
},
{
accessorKey: 'created_at', header: '开通时间', cell: ({row}) => (
format(row.getValue('created_at'), 'yyyy-MM-dd HH:mm')
),
},
{
accessorKey: 'action', header: `操作`, cell: (item) => (
<div className={`flex gap-2`}>
-
</div>
),
},
]}
/>
</>
}

View File

@@ -0,0 +1,309 @@
'use client'
import {ReactNode, useEffect, useState} from 'react'
import {Form, FormField} from '@/components/ui/form'
import {Input} from '@/components/ui/input'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Label} from '@/components/ui/label'
import DatePicker from '@/components/date-picker'
import {Button} from '@/components/ui/button'
import {Box, Eraser, Search, Timer} from 'lucide-react'
import DataTable from '@/components/data-table'
import {format, intlFormatDistance, isAfter} from 'date-fns'
import {useStatus} from '@/lib/states'
import {ExtraResp, PageRecord} from '@/lib/api'
import {Resource} from '@/lib/models'
import {listResourceShort} from '@/actions/resource'
import zod from 'zod'
import {useSearchParams} from 'next/navigation'
import {useForm} from 'react-hook-form'
import {zodResolver} from '@hookform/resolvers/zod'
export type ShortResourceProps = {
}
export default function ShortResource(props: ShortResourceProps) {
// ======================
// 查询
// ======================
const [status, setStatus] = useStatus()
const [data, setData] = useState<ExtraResp<typeof listResourceShort>>({
page: 1,
size: 10,
total: 0,
list: [],
})
const refresh = async (page: number, size: number) => {
setStatus('load')
try {
const type = {
all: undefined,
expire: 1,
quota: 2,
}[form.getValues('type')]
const create_after = form.getValues('create_after')
const create_before = form.getValues('create_before')
const expire_after = form.getValues('expire_after')
const expire_before = form.getValues('expire_before')
const resource_no = form.getValues('resource_no')
const res = await listResourceShort({
page, size,
type,
create_after,
create_before,
expire_after,
expire_before,
resource_no,
})
if (res.success) {
setData(res.data)
setStatus('done')
}
else {
throw new Error('Failed to load short resource')
}
}
catch (e) {
setStatus('fail')
}
}
useEffect(() => {
refresh(1, 10).then()
}, [])
// ======================
// 筛选
// ======================
const filterSchema = zod.object({
resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
expire_before: zod.date().optional(),
})
type FilterSchema = zod.infer<typeof filterSchema>
const params = useSearchParams()
let paramType = params.get('type')
if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') {
paramType = 'all'
}
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
resource_no: params.get('resource_no') || '',
type: paramType as 'expire' | 'quota' | 'all',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined,
},
})
const onSubmit = async (value: FilterSchema) => {
await refresh(1, data.size)
}
return <>
{/* 操作区 */}
<section className={`flex justify-between flex-wrap`}>
<div>
</div>
<Form form={form} onSubmit={onSubmit} className={`flex items-end gap-4 flex-wrap`}>
<FormField name={`resource_no`} label={<span className={`text-sm`}></span>}>
{({id, field}) => (
<Input {...field} id={id} className={`h-9`}/>
)}
</FormField>
<FormField name={`type`} label={<span className={`text-sm`}></span>}>
{({field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={`w-24 h-9`}>
<SelectValue placeholder={`选择套餐类型`}/>
</SelectTrigger>
<SelectContent>
<SelectItem value={`all`}></SelectItem>
<SelectItem value={`expire`}></SelectItem>
<SelectItem value={`quota`}></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}></Label>
<div className={`flex items-center`}>
<FormField name={`create_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`create_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}>使</Label>
<div className={`flex items-center`}>
<FormField name={`expire_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`expire_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex gap-4`}>
<Button className={`h-9`}>
<Search/>
<span></span>
</Button>
<Button theme={`outline`} className={`h-9`} onClick={() => form.reset({
type: 'all',
resource_no: '',
create_after: undefined,
create_before: undefined,
expire_after: undefined,
expire_before: undefined,
})}>
<Eraser/>
<span></span>
</Button>
</div>
</Form>
</section>
{/* 数据表 */}
<DataTable
data={data.list}
status={status}
pagination={{
total: data.total,
page: data.page,
size: data.size,
onPageChange: async (page: number) => {
await refresh(page, data.size)
},
onSizeChange: async (size: number) => {
await refresh(data.page, size)
},
}}
columns={[
{
accessorKey: 'resource_no', header: `套餐编号`,
},
{
accessorKey: 'type', header: `类型`, cell: ({row}) => (
<div className={`flex gap-2 items-center`}>
{row.original.short.type === 1 && (
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md`}>
<Timer size={20}/>
<span></span>
</div>
)}
{row.original.short.type === 2 && (
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md`}>
<Box size={20}/>
<span></span>
</div>
)}
</div>
),
},
{
accessorKey: 'live', header: `IP 时效`, cell: ({row}) => (
<span>
{row.original.short.live / 60}
</span>
),
},
{
accessorKey: 'expire', header: `使用情况`, cell: ({row}) => (
<div className={`flex gap-1`}>
{row.original.short.type === 1 ? (
<div className={`flex gap-1`}>
{isAfter(row.original.short.expire, new Date())
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.short.daily_used} / {row.original.short.daily_limit}</span>
<span>|</span>
<span>{intlFormatDistance(row.original.short.expire, new Date())} </span>
</div>
) : row.original.short.type === 2 ? (
<div className={`flex gap-1`}>
{row.original.short.used < row.original.short.quota
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.short.used} / {row.original.short.quota}</span>
</div>
) : (
<span>-</span>
)}
</div>
),
},
{
accessorKey: 'daily_last', header: '最近使用时间', cell: ({row}) => {
return (
format(row.original.short.daily_last, 'yyyy-MM-dd') === '0001-01-01'
? '-'
: format(row.original.short.daily_last, 'yyyy-MM-dd HH:mm')
)
},
},
{
accessorKey: 'created_at', header: '开通时间', cell: ({row}) => (
format(row.getValue('created_at'), 'yyyy-MM-dd HH:mm')
),
},
{
accessorKey: 'action', header: `操作`, cell: (item) => (
<div className={`flex gap-2`}>
-
</div>
),
},
]}
/>
</>
}

View File

@@ -1,117 +1,9 @@
'use client'
import {useEffect, useState} from 'react'
import {PageRecord} from '@/lib/api'
import {Resource} from '@/lib/models'
import {useStatus} from '@/lib/states'
import {listResourcePss} from '@/actions/resource'
import {Box, Eraser, Search, Timer} from 'lucide-react'
import {Select, SelectContent, SelectItem, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Button} from '@/components/ui/button'
import DataTable from '@/components/data-table'
import {format, intlFormatDistance, isAfter} from 'date-fns'
import DatePicker from '@/components/date-picker'
import {Form, FormField} from '@/components/ui/form'
import {useForm} from 'react-hook-form'
import zod from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
import {Label} from '@/components/ui/label'
import {Input} from '@/components/ui/input'
import Page from '@/components/page' import Page from '@/components/page'
import {useSearchParams} from 'next/navigation' import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import ShortResource from '@/app/admin/resources/_client/short'
import LongResource from '@/app/admin/resources/_client/long'
export type ResourcesPageProps = {} export default async function ResourcesPage() {
export default function ResourcesPage(props: ResourcesPageProps) {
// ======================
// 查询
// ======================
const [status, setStatus] = useStatus()
const [data, setData] = useState<PageRecord<Resource>>({
page: 1,
size: 10,
total: 0,
list: [],
})
const refresh = async (page: number, size: number) => {
setStatus('load')
try {
const type = {
all: undefined,
expire: 1,
quota: 2,
}[form.getValues('type')]
const create_after = form.getValues('create_after')
const create_before = form.getValues('create_before')
const expire_after = form.getValues('expire_after')
const expire_before = form.getValues('expire_before')
const resource_no = form.getValues('resource_no')
const res = await listResourcePss({
page, size,
type,
create_after,
create_before,
expire_after,
expire_before,
resource_no,
})
if (res.success) {
setData(res.data)
setStatus('done')
}
else {
throw new Error('Failed to load resource pss')
}
}
catch (e) {
setStatus('fail')
}
}
useEffect(() => {
refresh(1, 10).then()
}, [])
// ======================
// 筛选
// ======================
const filterSchema = zod.object({
resource_no: zod.string().optional().default(''),
type: zod.enum(['expire', 'quota', 'all']).default('all'),
create_after: zod.date().optional(),
create_before: zod.date().optional(),
expire_after: zod.date().optional(),
expire_before: zod.date().optional(),
})
type FilterSchema = zod.infer<typeof filterSchema>
const params = useSearchParams()
let paramType = params.get('type')
if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') {
paramType = 'all'
}
const form = useForm<FilterSchema>({
resolver: zodResolver(filterSchema),
defaultValues: {
resource_no: params.get('resource_no') || '',
type: paramType as 'expire' | 'quota' | 'all',
create_after: params.get('create_after') ? new Date(params.get('create_after')!) : undefined,
create_before: params.get('create_before') ? new Date(params.get('create_before')!) : undefined,
expire_after: params.get('expire_after') ? new Date(params.get('expire_after')!) : undefined,
expire_before: params.get('expire_before') ? new Date(params.get('expire_before')!) : undefined,
},
})
const onSubmit = async (value: FilterSchema) => {
await refresh(1, data.size)
}
// ====================== // ======================
// render // render
@@ -119,199 +11,18 @@ export default function ResourcesPage(props: ResourcesPageProps) {
return ( return (
<Page> <Page>
<Tabs defaultValue={`short`}>
{/* 操作区 */} <TabsList className={`bg-card p-1.5 rounded-lg`}>
<section className={`flex justify-between flex-wrap`}> <TabsTrigger value={`short`} className={`w-30 h-9 data-[state=active]:bg-primary-muted text-base rounded-md`}></TabsTrigger>
<div> <TabsTrigger value={`long`} className={`w-30 h-9 data-[state=active]:bg-primary-muted text-base rounded-md`}></TabsTrigger>
</TabsList>
</div> <TabsContent value={`short`} className={`flex flex-col gap-4`}>
<ShortResource/>
<Form form={form} onSubmit={onSubmit} className={`flex items-end gap-4 flex-wrap`}> </TabsContent>
<FormField name={`resource_no`} label={<span className={`text-sm`}></span>}> <TabsContent value={`long`} className={`flex flex-col gap-4`}>
{({id, field}) => ( <LongResource/>
<Input {...field} id={id} className={`h-9`}/> </TabsContent>
)} </Tabs>
</FormField>
<FormField name={`type`} label={<span className={`text-sm`}></span>}>
{({field}) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger className={`w-24 h-9`}>
<SelectValue placeholder={`选择套餐类型`}/>
</SelectTrigger>
<SelectContent>
<SelectItem value={`all`}></SelectItem>
<SelectItem value={`expire`}></SelectItem>
<SelectItem value={`quota`}></SelectItem>
</SelectContent>
</Select>
)}
</FormField>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}></Label>
<div className={`flex items-center`}>
<FormField name={`create_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`create_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex flex-col gap-2`}>
<Label className={`text-sm`}>使</Label>
<div className={`flex items-center`}>
<FormField name={`expire_after`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`开始时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
<span className={`px-1`}>-</span>
<FormField name={`expire_before`}>
{({field}) => (
<DatePicker
{...field}
className={`w-36`}
placeholder={`结束时间`}
format={`yyyy-MM-dd`}
/>
)}
</FormField>
</div>
</div>
<div className={`flex gap-4`}>
<Button className={`h-9`}>
<Search/>
<span></span>
</Button>
<Button theme={`outline`} className={`h-9`} onClick={() => form.reset({
type: 'all',
resource_no: '',
create_after: undefined,
create_before: undefined,
expire_after: undefined,
expire_before: undefined,
})}>
<Eraser/>
<span></span>
</Button>
</div>
</Form>
</section>
{/* 数据表 */}
<DataTable
data={data.list}
status={status}
pagination={{
total: data.total,
page: data.page,
size: data.size,
onPageChange: async (page: number) => {
await refresh(page, data.size)
},
onSizeChange: async (size: number) => {
await refresh(data.page, size)
},
}}
columns={[
{
accessorKey: 'resource_no', header: `套餐编号`,
},
{
accessorKey: 'type', header: `类型`, cell: ({row}) => (
<div className={`flex gap-2 items-center`}>
{row.original.short.type === 1 && (
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md`}>
<Timer size={20}/>
<span></span>
</div>
)}
{row.original.short.type === 2 && (
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md`}>
<Box size={20}/>
<span></span>
</div>
)}
</div>
),
},
{
accessorKey: 'live', header: `IP 时效`, cell: ({row}) => (
<span>
{row.original.short.live / 60}
</span>
),
},
{
accessorKey: 'expire', header: `使用情况`, cell: ({row}) => (
<div className={`flex gap-1`}>
{row.original.short.type === 1 ? (
<div className={`flex gap-1`}>
{isAfter(row.original.short.expire, new Date())
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.short.daily_used} / {row.original.short.daily_limit}</span>
<span>|</span>
<span>{intlFormatDistance(row.original.short.expire, new Date())} </span>
</div>
) : row.original.short.type === 2 ? (
<div className={`flex gap-1`}>
{row.original.short.used < row.original.short.quota
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.short.used} / {row.original.short.quota}</span>
</div>
) : (
<span>-</span>
)}
</div>
),
},
{
accessorKey: 'daily_last', header: '最近使用时间', cell: ({row}) => {
return (
format(row.original.short.daily_last, 'yyyy-MM-dd') === '0001-01-01'
? '-'
: format(row.original.short.daily_last, 'yyyy-MM-dd HH:mm')
)
},
},
{
accessorKey: 'created_at', header: '开通时间', cell: ({row}) => (
format(row.getValue('created_at'), 'yyyy-MM-dd HH:mm')
),
},
{
accessorKey: 'action', header: `操作`, cell: (item) => (
<div className={`flex gap-2`}>
-
</div>
),
},
]}
/>
</Page> </Page>
) )
} }

View File

@@ -6,27 +6,19 @@ import {RadioGroup, RadioGroupItem} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'
import {Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue} from '@/components/ui/select' import {Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue} from '@/components/ui/select'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {useForm} from 'react-hook-form' import {useForm, useFormContext} from 'react-hook-form'
import {Alert, AlertTitle} from '@/components/ui/alert' import {Alert, AlertTitle} from '@/components/ui/alert'
import {Box, CircleAlert, CopyIcon, ExternalLinkIcon, Loader, Timer} from 'lucide-react' import {Box, CircleAlert, CopyIcon, ExternalLinkIcon, Loader, Timer} from 'lucide-react'
import {useEffect, useMemo, useRef, useState} from 'react' import {memo, useEffect, useRef, useState} from 'react'
import {useStatus} from '@/lib/states' import {useStatus} from '@/lib/states'
import {allResource} from '@/actions/resource' import {allResource} from '@/actions/resource'
import {Resource, name} from '@/lib/models' import {Resource} from '@/lib/models'
import {format, intlFormatDistance} from 'date-fns' import {format, intlFormatDistance} from 'date-fns'
import {toast} from 'sonner' import {toast} from 'sonner'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {Combobox} from '@/components/ui/combobox' import {Combobox} from '@/components/ui/combobox'
import cities from './_assets/cities.json' import cities from './_assets/cities.json'
type ExtractProps = {
className?: string
}
export default function Extract(props: ExtractProps) {
const [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus()
const schema = z.object({ const schema = z.object({
resource: z.number({required_error: '请选择套餐'}), resource: z.number({required_error: '请选择套餐'}),
prov: z.string().optional(), prov: z.string().optional(),
@@ -44,6 +36,11 @@ export default function Extract(props: ExtractProps) {
type Schema = z.infer<typeof schema> type Schema = z.infer<typeof schema>
type ExtractProps = {
className?: string
}
export default function Extract(props: ExtractProps) {
const form = useForm<Schema>({ const form = useForm<Schema>({
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
@@ -59,117 +56,12 @@ export default function Extract(props: ExtractProps) {
}, },
}) })
const regionType = form.watch('regionType')
const resource = form.watch('resource')
const prov = form.watch('prov')
const city = form.watch('city')
const isp = form.watch('isp')
const proto = form.watch('proto')
const authType = form.watch('authType')
const distinct = form.watch('distinct')
const formatType = form.watch('format')
const separator = form.watch('separator')
const breaker = form.watch('breaker')
const count = form.watch('count')
const params = useMemo(() => {
const sp = new URLSearchParams()
if (resource) sp.set('i', String(resource))
if (authType) sp.set('t', authType)
if (proto != 'all') sp.set('x', proto)
if (prov) sp.set('a', prov)
if (city) sp.set('b', city)
if (isp != 'all') sp.set('s', isp)
sp.set('d', distinct)
sp.set('rt', formatType)
sp.set('rs', separator)
sp.set('rb', breaker)
sp.set('n', String(count))
return `/proxies?${sp.toString()}`
}, [resource, authType, proto, isp, distinct, formatType, separator, breaker, count, prov, city])
const type = useRef<'copy' | 'open'>('open')
const onSubmit = async (values: z.infer<typeof schema>) => {
switch (type.current) {
case 'copy':
const url = new URL(window.location.href).origin
const text = `${url}${params}`
// 使用 clipboard API 复制链接
let copied = false
try {
await navigator.clipboard.writeText(text)
copied = true
}
catch (e) {
console.log('剪贴板 API 调用失败,尝试备选方案')
}
// 使用 document.execCommand 作为备选方案
if (!copied) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.success('链接已复制到剪贴板')
break
case 'open':
window.open(params, '_blank')
break
}
}
const getResources = async () => {
setStatus('load')
try {
const resp = await allResource()
if (!resp.success) {
throw new Error('Unable to fetch packages.')
}
setResources(resp.data)
setStatus('done')
}
catch (error) {
console.error('Error fetching packages:', error)
setStatus('fail')
}
}
useEffect(() => {
getResources().then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
// ====================== // ======================
// render // render
// ====================== // ======================
return ( return (
<Form <Form form={form} className={merge(
form={form}
onSubmit={onSubmit}
onError={errors => {
const desc: (string | undefined)[] = []
Object.entries(errors).forEach(([field, error]) => {
if (error.message) {
desc.push(error.message)
}
})
toast.error('请完成填写:', {
description: desc.map((msg, i) => (
<span key={i}>- {msg}</span>
)),
})
}}
className={merge(
`bg-white flex flex-col gap-4 rounded-md`, `bg-white flex flex-col gap-4 rounded-md`,
props.className, props.className,
)} )}
@@ -179,104 +71,22 @@ export default function Extract(props: ExtractProps) {
<AlertTitle>IP前需要将本机IP添加到白名单后才可使用</AlertTitle> <AlertTitle>IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
</Alert> </Alert>
<FormFields/>
<ApplyLink/>
</Form>
)
}
const FormFields = memo(() => {
return (
<div className={`flex flex-col gap-4`}> <div className={`flex flex-col gap-4`}>
{/* 选择套餐 */} {/* 选择套餐 */}
<div className="flex items-center"> <SelectResource/>
<FormField name="resource" label={`选择套餐`}>
{({field}) => (
<Select
value={field.value ? String(field.value) : undefined}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className={`min-h-10 h-auto w-84`}>
<SelectValue placeholder={`选择套餐`}/>
</SelectTrigger>
<SelectContent>
{status === 'load' ? (
<div className={`p-4 flex gap-1 items-center`}>
<Loader className={`animate-spin`} size={20}/>
<span>...</span>
</div>
) : resources.length === 0 ? (
<div className={`p-4 flex gap-1 items-center`}>
<Loader className={`animate-spin`} size={20}/>
<span></span>
</div>
) : resources.map((resource, i) => (<>
<SelectItem
key={`${resource.id}`} value={String(resource.id)} className={`p-3`}>
<div className={`flex flex-col gap-2 w-72`}>
{resource.type === 1 && resource.short.type === 1 && (<>
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm`}>
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className={`flex justify-between gap-2 text-xs text-weak`}>
<span>{format(resource.short.expire, 'yyyy-MM-dd HH:mm')}</span>
<span>{intlFormatDistance(resource.short.expire, new Date())}</span>
</div>
</>)}
{resource.type === 1 && resource.short.type === 2 && (<>
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm`}>
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className={`flex justify-between gap-2 text-xs text-weak`}>
<span>{resource.short.used} / {resource.short.quota}</span>
<span> {resource.short.quota - resource.short.used}</span>
</div>
</>)}
</div>
</SelectItem>
{i < resources.length - 1 && <SelectSeparator className={`m-2`}/>}
</>))
}
</SelectContent>
</Select>
)}
</FormField>
</div>
{/* 地区筛选 */} {/* 地区筛选 */}
<div className="flex flex-col gap-4"> <SelectRegion/>
<FormField name="regionType" label={`地区筛选`}>
{({id, field}) => (
<RadioGroup
onValueChange={(e) => {
field.onChange(e)
if (e === 'unlimited') {
form.setValue('prov', '')
form.setValue('city', '')
}
}}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-unlimited`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="unlimited" id={`${id}-v-unlimited`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-specific`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="specific" id={`${id}-v-specific`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{regionType === 'specific' && (
<Combobox
className={`w-84`}
placeholder={`请选择地区`}
options={cities.options}
value={[prov || '', city || '']}
onChange={value => {
form.setValue('prov', value[0])
form.setValue('city', value[1])
}}
/>
)}
</div>
{/* 运营商筛选 */} {/* 运营商筛选 */}
<div className="flex items-center"> <div className="flex items-center">
@@ -466,34 +276,297 @@ export default function Extract(props: ExtractProps) {
</FormField> </FormField>
</div> </div>
</div> </div>
)
})
FormFields.displayName = 'FormFields'
function SelectResource() {
const [resources, setResources] = useState<Resource[]>([])
const [status, setStatus] = useStatus()
const getResources = async () => {
setStatus('load')
try {
const resp = await allResource()
if (!resp.success) {
throw new Error('Unable to fetch packages.')
}
console.log(resp.data)
setResources(resp.data)
setStatus('done')
}
catch (error) {
console.error('Error fetching packages:', error)
toast.error('获取套餐失败,请稍后再试')
setStatus('fail')
}
}
useEffect(() => {
getResources().then()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [])
return (
<div className="flex items-center">
<FormField name="resource" label={`选择套餐`}>
{({field}) => (
<Select
value={field.value ? String(field.value) : undefined}
onValueChange={value => field.onChange(Number(value))}
>
<SelectTrigger className={`min-h-10 h-auto w-84`}>
<SelectValue placeholder={`选择套餐`}/>
</SelectTrigger>
<SelectContent>
{status === 'load' ? (
<div className={`p-4 flex gap-1 items-center`}>
<Loader className={`animate-spin`} size={20}/>
<span>...</span>
</div>
) : resources.length === 0 ? (
<div className={`p-4 flex gap-1 items-center`}>
<Loader className={`animate-spin`} size={20}/>
<span></span>
</div>
) : resources.map((resource, i) => (<>
<SelectItem
key={`${resource.id}`} value={String(resource.id)} className={`p-3`}>
<div className={`flex flex-col gap-2 w-72`}>
{resource.type === 1 && resource.short.type === 1 && (<>
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm`}>
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className={`flex justify-between gap-2 text-xs text-weak`}>
<span>{format(resource.short.expire, 'yyyy-MM-dd HH:mm')}</span>
<span>{intlFormatDistance(resource.short.expire, new Date())}</span>
</div>
</>)}
{resource.type === 1 && resource.short.type === 2 && (<>
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm`}>
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className={`flex justify-between gap-2 text-xs text-weak`}>
<span>{resource.short.used} / {resource.short.quota}</span>
<span> {resource.short.quota - resource.short.used}</span>
</div>
</>)}
{resource.type === 2 && resource.long.type === 1 && (<>
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm`}>
<Timer size={20}/>
<span>{name(resource)}</span>
</div>
<div className={`flex justify-between gap-2 text-xs text-weak`}>
<span>{format(resource.long.expire, 'yyyy-MM-dd HH:mm')}</span>
<span>{intlFormatDistance(resource.long.expire, new Date())}</span>
</div>
</>)}
{resource.type === 2 && resource.long.type === 2 && (<>
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm`}>
<Box size={20}/>
<span>{name(resource)}</span>
</div>
<div className={`flex justify-between gap-2 text-xs text-weak`}>
<span>{resource.long.used} / {resource.long.quota}</span>
<span> {resource.long.quota - resource.long.used}</span>
</div>
</>)}
</div>
</SelectItem>
{i < resources.length - 1 && <SelectSeparator className={`m-2`}/>}
</>))}
</SelectContent>
</Select>
)}
</FormField>
</div>
)
}
function SelectRegion() {
const form = useFormContext<Schema>()
const regionType = form.watch('regionType')
const prov = form.watch('prov')
const city = form.watch('city')
return (
<div className="flex flex-col gap-4">
<FormField name="regionType" label={`地区筛选`}>
{({id, field}) => (
<RadioGroup
onValueChange={(e) => {
field.onChange(e)
if (e === 'unlimited') {
form.setValue('prov', '')
form.setValue('city', '')
}
}}
defaultValue={field.value}
className="flex gap-4"
>
<FormLabel htmlFor={`${id}-v-unlimited`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="unlimited" id={`${id}-v-unlimited`} className="mr-2"/>
<span></span>
</FormLabel>
<FormLabel htmlFor={`${id}-v-specific`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
<RadioGroupItem value="specific" id={`${id}-v-specific`} className="mr-2"/>
<span></span>
</FormLabel>
</RadioGroup>
)}
</FormField>
{regionType === 'specific' && (
<Combobox
className={`w-84`}
placeholder={`请选择地区`}
options={cities.options}
value={[prov || '', city || '']}
onChange={value => {
form.setValue('prov', value[0])
form.setValue('city', value[1])
}}
/>
)}
</div>
)
}
function ApplyLink() {
const form = useFormContext<Schema>()
const values = form.watch()
const type = useRef<'copy' | 'open'>('open')
const handler = form.handleSubmit(
async (values: z.infer<typeof schema>) => {
const params = link(values)
switch (type.current) {
case 'copy':
const url = new URL(window.location.href).origin
const text = `${url}${params}`
// 使用 clipboard API 复制链接
let copied = false
try {
await navigator.clipboard.writeText(text)
copied = true
}
catch (e) {
console.log('剪贴板 API 调用失败,尝试备选方案')
}
// 使用 document.execCommand 作为备选方案
if (!copied) {
const textarea = document.createElement('textarea')
textarea.value = text
document.body.appendChild(textarea)
textarea.select()
document.execCommand('copy')
document.body.removeChild(textarea)
}
toast.success('链接已复制到剪贴板')
break
case 'open':
window.open(params, '_blank')
break
}
},
errors => {
const desc: (string | undefined)[] = []
Object.entries(errors).forEach(([field, error]) => {
if (error.message) {
desc.push(error.message)
}
})
toast.error('请完成填写:', {
description: desc.map((msg, i) => (
<span key={i}>- {msg}</span>
)),
})
},
)
return (
<div className={merge( <div className={merge(
`flex flex-col gap-4 sticky bottom-0 bg-muted p-4`, `flex flex-col gap-4 sticky bottom-0 bg-muted p-4`,
`rounded-lg`, `rounded-lg`,
)}> )}>
{/* 展示链接地址 */} {/* 展示链接地址 */}
<div className={`bg-neutral-900 text-white p-4 rounded-md break-all`}> <div className={`bg-neutral-900 text-white p-4 rounded-md break-all`}>
{params} {link(values)}
</div> </div>
{/* 操作 */} {/* 操作 */}
<div className="flex gap-4"> <div className="flex gap-4">
<Button <Button
type="submit" type="button"
onClick={() => type.current = 'copy'} onClick={async () => {
type.current = 'copy'
await handler()
}}
> >
<CopyIcon/> <CopyIcon/>
<span></span> <span></span>
</Button> </Button>
<Button <Button
type="submit" type="button"
onClick={() => type.current = 'open'} onClick={async () => {
type.current = 'open'
await handler()
}}
> >
<ExternalLinkIcon/> <ExternalLinkIcon/>
<span></span> <span></span>
</Button> </Button>
</div> </div>
</div> </div>
</Form>
) )
} }
function link(values: Schema) {
const {resource, prov, city, isp, proto, authType, distinct, format: formatType, separator, breaker, count} = values
const sp = new URLSearchParams()
if (resource) sp.set('i', String(resource))
if (authType) sp.set('t', authType)
if (proto != 'all') sp.set('x', proto)
if (prov) sp.set('a', prov)
if (city) sp.set('b', city)
if (isp != 'all') sp.set('s', isp)
sp.set('d', distinct)
sp.set('rt', formatType)
sp.set('rs', separator)
sp.set('rb', breaker)
sp.set('n', String(count))
return `/proxies?${sp.toString()}`
}
function name(resource: Resource) {
switch (resource.type) {
case 1:
// 短效套餐
switch (resource.short.type) {
case 1:
return `短效包时 ${resource.short.live / 60} 分钟`
case 2:
return `短效包量 ${resource.short.live / 60} 分钟`
}
break
case 2:
// 长效套餐
switch (resource.long.type) {
case 1:
return `长效包时 ${resource.long.live} 小时`
case 2:
return `长效包量 ${resource.long.live} 小时`
}
break
}
}

View File

@@ -1,7 +1,8 @@
import PurchaseForm from '@/components/composites/purchase/_client/form'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import {ReactNode} from 'react' import {ReactNode} from 'react'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {Tabs, TabsContent, TabsList, TabsTrigger} from '@/components/ui/tabs'
import LongForm from '@/components/composites/purchase/long/form'
import ShortForm from '@/components/composites/purchase/short/form'
export type PurchaseProps = {} export type PurchaseProps = {}
@@ -17,10 +18,10 @@ export default async function Purchase(props: PurchaseProps) {
<Tab value={`custom`}></Tab> <Tab value={`custom`}></Tab>
</TabsList> </TabsList>
<TabsContent value={`short`}> <TabsContent value={`short`}>
<PurchaseForm/> <ShortForm/>
</TabsContent> </TabsContent>
<TabsContent value={`long`}> <TabsContent value={`long`}>
<PurchaseForm/> <LongForm/>
</TabsContent> </TabsContent>
</Tabs> </Tabs>
</div> </div>

View File

@@ -4,20 +4,15 @@ import {RadioGroup} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input' import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import {Minus, Plus} from 'lucide-react' import {Minus, Plus} from 'lucide-react'
import {PurchaseFormContext, Schema} from '@/components/composites/purchase/_client/form' import FormOption from '@/components/composites/purchase/option'
import {useContext} from 'react'
import FormOption from '@/components/composites/purchase/_client/option'
import Image from 'next/image' import Image from 'next/image'
import check from '@/components/composites/purchase/_assets/check.svg' import check from '@/components/composites/purchase/_assets/check.svg'
import {Schema} from '@/components/composites/purchase/long/form'
import {useFormContext} from 'react-hook-form'
export default function Center() { export default function Center() {
const form = useFormContext<Schema>()
const form = useContext(PurchaseFormContext)?.form const type = form.watch('type')
if (!form) {
throw new Error(`Center component must be used within PurchaseFormContext`)
}
const watchType = form.watch('type')
return ( return (
<div className={`flex-auto p-8 flex flex-col gap-8 relative`}> <div className={`flex-auto p-8 flex flex-col gap-8 relative`}>
@@ -64,17 +59,17 @@ export default function Center() {
onValueChange={field.onChange} onValueChange={field.onChange}
className={`flex gap-4 flex-wrap`}> className={`flex gap-4 flex-wrap`}>
<FormOption id={`${id}-3`} value="3" label="3 分钟" description="¥0.005/IP" compare={field.value}/> <FormOption id={`${id}-1`} value="1" label="1 小时" description="¥0.3/IP" compare={field.value}/>
<FormOption id={`${id}-5`} value="5" label="5 分钟" description="¥0.007/IP" compare={field.value}/> <FormOption id={`${id}-4`} value="4" label="4 小时" description="¥0.8/IP" compare={field.value}/>
<FormOption id={`${id}-10`} value="10" label="10 分钟" description="¥0.010/IP" compare={field.value}/> <FormOption id={`${id}-8`} value="8" label="8 小时" description="¥1.2/IP" compare={field.value}/>
<FormOption id={`${id}-20`} value="20" label="20 分钟" description="¥0.015/IP" compare={field.value}/> <FormOption id={`${id}-12`} value="12" label="12 小时" description="¥1.8/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="30" label="30 分钟" description="¥0.020/IP" compare={field.value}/> <FormOption id={`${id}-24`} value="24" label="24 小时" description="¥3.5/IP" compare={field.value}/>
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>
{/* 根据套餐类型显示不同表单项 */} {/* 根据套餐类型显示不同表单项 */}
{watchType === '2' ? ( {type === '2' ? (
/* 包量IP 购买数量 */ /* 包量IP 购买数量 */
<FormField <FormField
className={`space-y-4`} className={`space-y-4`}

View File

@@ -0,0 +1,52 @@
'use client'
import {createContext} from 'react'
import {useForm, UseFormReturn} from 'react-hook-form'
import Center from '@/components/composites/purchase/long/center'
import Right from '@/components/composites/purchase/long/right'
import {Form} from '@/components/ui/form'
import * as z from 'zod'
import {zodResolver} from '@hookform/resolvers/zod'
// 定义表单验证架构
const schema = z.object({
type: z.enum(['1', '2']).default('2'),
live: z.enum(['1', '4', '8', '12', '24']),
quota: z.number().min(500, '购买数量不能少于 500 个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']),
daily_limit: z.number().min(100, '每日限额不能少于 100 个'),
pay_type: z.enum(['wechat', 'alipay', 'balance']),
})
// 从架构中推断类型
export type Schema = z.infer<typeof schema>
type PurchaseFormContextType = {
form: UseFormReturn<Schema>
onSubmit?: () => void
}
export const LongFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
export default function LongForm() {
const form = useForm<Schema>({
resolver: zodResolver(schema),
defaultValues: {
type: '2', // 默认为包量套餐
live: '1', // 小时
quota: 500,
expire: '30', // 天
daily_limit: 100,
pay_type: 'balance', // 余额支付
},
})
return (
<Form form={form} className={`bg-white rounded-lg flex flex-row`}>
<LongFormContext.Provider value={{form}}>
<Center/>
<Right/>
</LongFormContext.Provider>
</Form>
)
}

View File

@@ -1,56 +1,51 @@
'use client' 'use client'
import {useContext, useMemo} from 'react' import {useContext, useMemo} from 'react'
import {PurchaseFormContext} from '@/components/composites/purchase/_client/form' import {PurchaseFormContext} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form' import {FormField} from '@/components/ui/form'
import FormOption from '@/components/composites/purchase/_client/option' import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image' import Image from 'next/image'
import alipay from '@/components/composites/purchase/_assets/alipay.svg' import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import wechat from '@/components/composites/purchase/_assets/wechat.svg' import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import balance from '@/components/composites/purchase/_assets/balance.svg' import balance from '@/components/composites/purchase/_assets/balance.svg'
import {useProfileStore} from '@/components/providers/StoreProvider' import {useProfileStore} from '@/components/providers/StoreProvider'
import RechargeModal from '@/components/composites/recharge' import RechargeModal from '@/components/composites/recharge'
import Pay from '@/components/composites/purchase/_client/pay' import Pay from '@/components/composites/purchase/pay'
import {buttonVariants} from '@/components/ui/button' import {buttonVariants} from '@/components/ui/button'
import Link from 'next/link' import Link from 'next/link'
import {merge} from '@/lib/utils' import {merge} from '@/lib/utils'
import {useFormContext} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/long/form'
export type RightProps = {} export default function Right() {
export default function Right(props: RightProps) {
const profile = useProfileStore(store => store.profile) const profile = useProfileStore(store => store.profile)
const form = useFormContext<Schema>()
const form = useContext(PurchaseFormContext)?.form const method = form.watch('pay_type')
if (!form) { const mode = form.watch('type')
throw new Error(`Center component must be used within PurchaseFormContext`) const live = form.watch('live')
} const quota = form.watch('quota')
const expire = form.watch('expire')
const watchType = form.watch('type') const dailyLimit = form.watch('daily_limit')
const watchLive = form.watch('live')
const watchQuota = form.watch('quota')
const watchExpire = form.watch('expire')
const watchDailyLimit = form.watch('daily_limit')
const payType = form.watch('pay_type')
const price = useMemo(() => { const price = useMemo(() => {
const count = watchType === '1' ? watchDailyLimit : watchQuota const base = {
'1': 30,
let seconds = parseInt(watchLive, 10) * 60 '4': 80,
if (seconds == 180) { '8': 120,
seconds = 150 '12': 180,
} '24': 350,
}[live]
let times = parseInt(watchExpire, 10) const factor = {
if (watchType === '2') { '1': Number(expire) * dailyLimit,
times = 1 '2': quota,
} }[mode]
return (base * factor / 100).toFixed(2)
return count * seconds * times / 30000 }, [dailyLimit, expire, live, quota, mode])
}, [watchDailyLimit, watchExpire, watchLive, watchQuota, watchType])
return ( return (
<div className={merge( <div className={merge(
`flex-none basis-80 p-6 flex flex-col gap-6 relative`, `flex-none basis-90 p-6 flex flex-col gap-6 relative`,
`after:absolute after:inset-0 after:my-6 after:border-l after:border-gray-200 after:select-none after:pointer-events-none`, `after:absolute after:inset-0 after:my-6 after:border-l after:border-gray-200 after:select-none after:pointer-events-none`,
)}> )}>
<h3></h3> <h3></h3>
@@ -58,33 +53,33 @@ export default function Right(props: RightProps) {
<li className={`flex justify-between items-center`}> <li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}></span> <span className={`text-sm text-gray-500`}></span>
<span className={`text-sm`}> <span className={`text-sm`}>
{watchType === '2' ? `包量套餐` : `包时套餐`} {mode === '2' ? `包量套餐` : `包时套餐`}
</span> </span>
</li> </li>
<li className={`flex justify-between items-center`}> <li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}>IP </span> <span className={`text-sm text-gray-500`}>IP </span>
<span className={`text-sm`}> <span className={`text-sm`}>
{watchLive} {live}
</span> </span>
</li> </li>
{watchType === '2' ? ( {mode === '2' ? (
<li className={`flex justify-between items-center`}> <li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}> IP </span> <span className={`text-sm text-gray-500`}> IP </span>
<span className={`text-sm`}> <span className={`text-sm`}>
{watchQuota} {quota}
</span> </span>
</li> </li>
) : <> ) : <>
<li className={`flex justify-between items-center`}> <li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}></span> <span className={`text-sm text-gray-500`}></span>
<span className={`text-sm`}> <span className={`text-sm`}>
{watchExpire} {expire}
</span> </span>
</li> </li>
<li className={`flex justify-between items-center`}> <li className={`flex justify-between items-center`}>
<span className={`text-sm text-gray-500`}></span> <span className={`text-sm text-gray-500`}></span>
<span className={`text-sm`}> <span className={`text-sm`}>
{watchDailyLimit} {dailyLimit}
</span> </span>
</li> </li>
</>} </>}
@@ -141,12 +136,15 @@ export default function Right(props: RightProps) {
</RadioGroup> </RadioGroup>
)} )}
</FormField> </FormField>
<Pay method={payType} amount={price} resource={{ <Pay method={method} amount={price} resource={{
type: Number(watchType), type: 2,
live: Number(watchLive) * 60, long: {
quota: watchQuota, mode: Number(mode),
expire: Number(watchExpire), live: Number(live),
daily_limit: watchDailyLimit, daily_limit: dailyLimit,
expire: Number(expire),
quota: quota,
},
}}/> }}/>
</> : ( </> : (
<Link href={`/login`} className={buttonVariants()}> <Link href={`/login`} className={buttonVariants()}>

View File

@@ -1,33 +1,24 @@
'use client' 'use client'
import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog' import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog'
import {Button} from '@/components/ui/button' import {Button} from '@/components/ui/button'
import alipay from '../_assets/alipay.svg' 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 Image from 'next/image' import Image from 'next/image'
import {useContext, useEffect, useRef, useState} from 'react' import {useEffect, useRef, useState} from 'react'
import {StoreContext, useProfileStore} from '@/components/providers/StoreProvider' import {useProfileStore} from '@/components/providers/StoreProvider'
import {Alert, AlertDescription} from '@/components/ui/alert' import {Alert, AlertDescription} from '@/components/ui/alert'
import { import {ApiResponse, ExtraResp, ExtraReq} from '@/lib/api'
prepareResourceByAlipay,
prepareResourceByWechat,
CreateResourceReq,
CreateResourceResp,
createResourceByBalance,
createResourceByAlipay,
createResourceByWechat,
} from '@/actions/resource'
import {ApiResponse} from '@/lib/api'
import {toast} from 'sonner' import {toast} from 'sonner'
import {Loader} from 'lucide-react' import {Loader} from 'lucide-react'
import {useRouter} from 'next/navigation' import {useRouter} from 'next/navigation'
import * as qrcode from 'qrcode' import * as qrcode from 'qrcode'
import {completeResource, createResource, prepareResource} from '@/actions/resource'
export type PayProps = { export type PayProps = {
method: 'alipay' | 'wechat' | 'balance' method: 'alipay' | 'wechat' | 'balance'
amount: number amount: string
resource: CreateResourceReq resource: ExtraReq<typeof createResource>
} }
export default function Pay(props: PayProps) { export default function Pay(props: PayProps) {
@@ -36,14 +27,14 @@ export default function Pay(props: PayProps) {
const refreshProfile = useProfileStore(store => store.refreshProfile) const refreshProfile = useProfileStore(store => store.refreshProfile)
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [payInfo, setPayInfo] = useState<CreateResourceResp | undefined>() const [payInfo, setPayInfo] = useState<ExtraResp<typeof prepareResource> | undefined>()
const canvas = useRef<HTMLCanvasElement>(null) const canvas = useRef<HTMLCanvasElement>(null)
useEffect(() => { useEffect(() => {
if (canvas.current && payInfo) { if (canvas.current && payInfo) {
qrcode.toCanvas(canvas.current, payInfo.pay_url, { qrcode.toCanvas(canvas.current, payInfo.pay_url, {
width: 200, width: 200,
margin: 0, margin: 0,
}) }).then()
} }
}, [payInfo]) }, [payInfo])
@@ -54,15 +45,15 @@ export default function Pay(props: PayProps) {
return return
} }
let resp: ApiResponse<CreateResourceResp> const method = {
switch (props.method) { alipay: 1,
case 'alipay': wechat: 2,
resp = await prepareResourceByAlipay(props.resource) }[props.method]
break
case 'wechat': const resp = await prepareResource({
resp = await prepareResourceByWechat(props.resource) ...props.resource,
break method,
} })
if (!resp.success) { if (!resp.success) {
toast.error(`创建订单失败: ${resp.message}`) toast.error(`创建订单失败: ${resp.message}`)
setOpen(false) setOpen(false)
@@ -78,17 +69,20 @@ export default function Pay(props: PayProps) {
try { try {
switch (props.method) { switch (props.method) {
case 'alipay': case 'alipay':
resp = await createResourceByAlipay({
trade_no: payInfo!.trade_no,
})
break
case 'wechat': case 'wechat':
resp = await createResourceByWechat({ if (!payInfo) {
trade_no: payInfo!.trade_no, toast.error('无法读取支付信息', {
description: `请联系客服确认支付状态`,
})
return
}
resp = await completeResource({
trade_no: payInfo.trade_no,
}) })
break break
case 'balance': case 'balance':
resp = await createResourceByBalance(props.resource) resp = await createResource(props.resource)
break break
} }
@@ -116,6 +110,8 @@ export default function Pay(props: PayProps) {
} }
} }
const balanceEnough = profile && profile.balance >= Number(props.amount)
return ( return (
<Dialog open={open} onOpenChange={setOpen}> <Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild> <DialogTrigger asChild>
@@ -156,26 +152,24 @@ export default function Pay(props: PayProps) {
<hr className="my-2"/> <hr className="my-2"/>
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<span className="text-weak text-sm"></span> <span className="text-weak text-sm"></span>
<span className={`text-lg ${profile.balance > props.amount ? 'text-done' : `text-fail`}`}> <span className={`text-lg ${balanceEnough ? 'text-done' : `text-fail`}`}>
{profile.balance - props.amount} {(profile.balance - Number(props.amount)).toFixed(2)}
</span> </span>
</div> </div>
</div> </div>
{profile.balance < props.amount && ( {balanceEnough ? (
<Alert variant="fail">
<AlertDescription>
</AlertDescription>
</Alert>
)}
{profile.balance >= props.amount && (
<Alert> <Alert>
<AlertDescription> <AlertDescription>
</AlertDescription> </AlertDescription>
</Alert> </Alert>
) : (
<Alert variant="fail">
<AlertDescription>
</AlertDescription>
</Alert>
)} )}
</div> </div>
) )
@@ -205,7 +199,7 @@ export default function Pay(props: PayProps) {
<DialogFooter> <DialogFooter>
<Button <Button
type="button" type="button"
disabled={props.method === 'balance' && !!profile && profile.balance < props.amount} disabled={props.method === 'balance' && !!profile && !balanceEnough}
onClick={onSubmit} onClick={onSubmit}
> >
{props.method === 'balance' ? '确认支付' : '已完成支付'} {props.method === 'balance' ? '确认支付' : '已完成支付'}

View File

@@ -0,0 +1,210 @@
'use client'
import {FormField} from '@/components/ui/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {Input} from '@/components/ui/input'
import {Button} from '@/components/ui/button'
import {Minus, Plus} from 'lucide-react'
import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import check from '@/components/composites/purchase/_assets/check.svg'
import {useFormContext} from 'react-hook-form'
import {Schema} from '@/components/composites/purchase/short/form'
export default function Center() {
const form = useFormContext<Schema>()
const type = form.watch('type')
return (
<div className={`flex-auto p-8 flex flex-col gap-8 relative`}>
{/* 计费方式 */}
<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`}>
<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 时效 */}
<FormField<Schema, 'live'>
className={`space-y-4`}
name={`live`}
label={`IP 时效`}>
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className={`flex gap-4 flex-wrap`}>
<FormOption id={`${id}-3`} value="180" label="3 分钟" description="¥0.005/IP" compare={field.value}/>
<FormOption id={`${id}-5`} value="300" label="5 分钟" description="¥0.01/IP" compare={field.value}/>
<FormOption id={`${id}-10`} value="600" label="10 分钟" description="¥0.02/IP" compare={field.value}/>
<FormOption id={`${id}-20`} value="1200" label="20 分钟" description="¥0.03/IP" compare={field.value}/>
<FormOption id={`${id}-30`} value="1800" label="30 分钟" description="¥0.06/IP" compare={field.value}/>
</RadioGroup>
)}
</FormField>
{/* 根据套餐类型显示不同表单项 */}
{type === '2' ? (
/* 包量IP 购买数量 */
<FormField
className={`space-y-4`}
name={`quota`}
label={`IP 购买数量`}>
{({id, field}) => (
<div className={`flex gap-2 items-center`}>
<Button
theme={`outline`}
type="button"
className={`h-10 w-10 border border-gray-200 rounded-sm flex items-center justify-center text-lg`}
onClick={() => form.setValue('quota', Math.max(10_000, Number(field.value) - 5_000))}
disabled={Number(field.value) === 10_000}>
<Minus/>
</Button>
<Input
{...field}
id={id}
type="number"
className={`w-40 h-10 border border-gray-200 rounded-sm text-center`}
min={10_000}
step={5_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('quota', Number(field.value) + 5_000)}>
<Plus/>
</Button>
</div>
)}
</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}
/>
<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`}>
<h3></h3>
<div className={`grid grid-cols-3 auto-rows-fr gap-y-6`}>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>API接口</span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>IP时效3-30()</span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>IP资源定期筛选</span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>API接口</span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>/</span>
</p>
<p className={`flex gap-2 items-center`}>
<Image src={check} alt={`check`} aria-hidden className={`w-4 h-4`}/>
<span className={`text-sm text-gray-500`}>500</span>
</p>
</div>
</div>
</div>
)
}

View File

@@ -1,8 +1,8 @@
'use client' 'use client'
import {createContext} from 'react' import {createContext} from 'react'
import {useForm, UseFormReturn} from 'react-hook-form' import {useForm, UseFormReturn} from 'react-hook-form'
import Center from '@/components/composites/purchase/_client/center' import Center from '@/components/composites/purchase/short/center'
import Right from '@/components/composites/purchase/_client/right' 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'
@@ -10,7 +10,7 @@ import {zodResolver} from '@hookform/resolvers/zod'
// 定义表单验证架构 // 定义表单验证架构
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', '20', '30']), live: z.enum(['180', '300', '600', '1200', '1800']),
quota: z.number().min(10000, '购买数量不能少于10000个'), quota: z.number().min(10000, '购买数量不能少于10000个'),
expire: z.enum(['7', '15', '30', '90', '180', '365']), expire: z.enum(['7', '15', '30', '90', '180', '365']),
daily_limit: z.number().min(2000, '每日限额不能少于2000个'), daily_limit: z.number().min(2000, '每日限额不能少于2000个'),
@@ -34,7 +34,7 @@ export default function PurchaseForm(props: PurchaseFormProps) {
resolver: zodResolver(schema), resolver: zodResolver(schema),
defaultValues: { defaultValues: {
type: '2', // 默认为包量套餐 type: '2', // 默认为包量套餐
live: '3', // 分钟 live: '180', // 分钟
quota: 10_000, // >= 10000 quota: 10_000, // >= 10000
expire: '30', // 天 expire: '30', // 天
daily_limit: 2_000, // >= 2000 daily_limit: 2_000, // >= 2000
@@ -43,14 +43,10 @@ export default function PurchaseForm(props: PurchaseFormProps) {
}) })
return ( return (
<section role={`tabpanel`} className={`bg-white rounded-lg`}> <Form form={form} className={`bg-white rounded-lg flex flex-row`}>
<Form form={form} className={`flex flex-row`}>
<PurchaseFormContext.Provider value={{form}}>
<Center/> <Center/>
<Right/> <Right/>
</PurchaseFormContext.Provider>
</Form> </Form>
</section>
) )
} }

View File

@@ -0,0 +1,167 @@
'use client'
import {useMemo} from 'react'
import {Schema} from '@/components/composites/purchase/short/form'
import {RadioGroup} from '@/components/ui/radio-group'
import {FormField} from '@/components/ui/form'
import FormOption from '@/components/composites/purchase/option'
import Image from 'next/image'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import balance from '@/components/composites/purchase/_assets/balance.svg'
import {useProfileStore} from '@/components/providers/StoreProvider'
import RechargeModal from '@/components/composites/recharge'
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} from 'react-hook-form'
export default function Right() {
const profile = useProfileStore(store => store.profile)
const form = useFormContext<Schema>()
const method = form.watch('pay_type')
const live = form.watch('live')
const mode = form.watch('type')
const dailyLimit = form.watch('daily_limit')
const expire = form.watch('expire')
const quota = form.watch('quota')
const price = useMemo(() => {
// var factor int32
// switch data.Mode {
// case 1:
// factor = data.DailyLimit * data.Expire
// case 2:
// factor = data.Quota
// }
//
// var base = data.Live
// if base == 180 {
// base = 150
// }
//
// var dec = decimal.Decimal{}.
// Add(decimal.NewFromInt32(base * factor)).
// Div(decimal.NewFromInt(30000))
// data.price = &dec
const base = live === '180' ? 150 : Number(live) * 60
const factor = {
'1': Number(expire) * dailyLimit,
'2': quota,
}[mode]
return (base * factor / 30000).toFixed(2)
}, [dailyLimit, expire, live, quota, mode])
return (
<div className={merge(
`flex-none basis-90 p-6 flex flex-col gap-6 relative`,
`after:absolute after:inset-0 after:my-6 after:border-l after:border-gray-200 after:select-none after:pointer-events-none`,
)}>
<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`}>
{Number(live) / 60}
</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`}>
{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>
</>}
</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`}>{price}</span>
</p>
{profile ? <>
<FormField name={`pay_type`} label={`支付方式`} className={`flex flex-col gap-6`}>
{({id, field}) => (
<RadioGroup
id={id}
defaultValue={field.value}
onValueChange={field.onChange}
className={`flex flex-col gap-3`}>
<div className={`w-full p-3 flex flex-col gap-4 bg-gray-100 rounded-md`}>
<p className={`flex items-center gap-3`}>
<Image src={balance} alt={`余额icon`}/>
<span className={`text-sm text-gray-500`}></span>
</p>
<p className={`flex justify-between items-center`}>
<span className={`text-xl`}>{profile?.balance}</span>
<RechargeModal/>
</p>
</div>
<FormOption
id={`${id}-balance`}
value={`balance`}
compare={field.value}
className={`p-3 w-full flex-row gap-2 justify-center`}>
<Image src={balance} alt={`余额 icon`}/>
<span></span>
</FormOption>
<FormOption
id={`${id}-wechat`}
value={`wechat`}
compare={field.value}
className={`p-3 w-full flex-row gap-2 justify-center`}>
<Image src={wechat} alt={`微信 logo`}/>
<span></span>
</FormOption>
<FormOption
id={`${id}-alipay`}
value={`alipay`}
compare={field.value}
className={`p-3 w-full flex-row gap-2 justify-center`}>
<Image src={alipay} alt={`支付宝 logo`}/>
<span></span>
</FormOption>
</RadioGroup>
)}
</FormField>
<Pay method={method} amount={price} resource={{
type: 1,
short: {
mode: Number(mode),
live: Number(live),
quota: quota,
expire: Number(expire),
daily_limit: dailyLimit,
},
}}/>
</> : (
<Link href={`/login`} className={buttonVariants()}>
</Link>
)}
</div>
)
}

View File

@@ -10,7 +10,7 @@ import {Button} from '@/components/ui/button'
import {Form, FormField} from '@/components/ui/form' import {Form, FormField} from '@/components/ui/form'
import {useForm} from 'react-hook-form' import {useForm} from 'react-hook-form'
import zod from 'zod' import zod from 'zod'
import FormOption from '@/components/composites/purchase/_client/option' import FormOption from '@/components/composites/purchase/option'
import {RadioGroup} from '@/components/ui/radio-group' import {RadioGroup} from '@/components/ui/radio-group'
import Image from 'next/image' import Image from 'next/image'
import {zodResolver} from '@hookform/resolvers/zod' import {zodResolver} from '@hookform/resolvers/zod'
@@ -75,7 +75,7 @@ export default function RechargeModal(props: RechargeModelProps) {
switch (data.method) { switch (data.method) {
case 'alipay': case 'alipay':
const aliRes = await RechargeByAlipay({ const aliRes = await RechargeByAlipay({
amount: data.amount, amount: data.amount.toString(),
}) })
if (aliRes.success) { if (aliRes.success) {
setStep(1) setStep(1)
@@ -89,7 +89,7 @@ export default function RechargeModal(props: RechargeModelProps) {
break break
case 'wechat': case 'wechat':
const weRes = await RechargeByWechat({ const weRes = await RechargeByWechat({
amount: data.amount, amount: data.amount.toString(),
}) })
if (weRes.success) { if (weRes.success) {
setStep(1) setStep(1)

View File

@@ -3,8 +3,8 @@ import {User} from '@/lib/models'
import {createContext, ReactNode, useContext, useRef} from 'react' import {createContext, ReactNode, useContext, useRef} from 'react'
import {StoreApi} from 'zustand/vanilla' import {StoreApi} from 'zustand/vanilla'
import {useStore} from 'zustand/react' import {useStore} from 'zustand/react'
import {createProfileStore, ProfileStore} from '@/stores/profile' import {createProfileStore, ProfileStore} from '@/lib/stores/profile'
import {createLayoutStore, LayoutStore} from '@/stores/layout' import {createLayoutStore, LayoutStore} from '@/lib/stores/layout'
export type StoreContextType = { export type StoreContextType = {

View File

@@ -1,177 +0,0 @@
"use client"
import * as React from "react"
import { Command as CommandPrimitive } from "cmdk"
import { SearchIcon } from "lucide-react"
import { merge } from "@/lib/utils"
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog"
function Command({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive>) {
return (
<CommandPrimitive
data-slot="command"
className={merge(
"bg-popover text-popover-foreground flex h-full w-full flex-col overflow-hidden rounded-md",
className
)}
{...props}
/>
)
}
function CommandDialog({
title = "Command Palette",
description = "Search for a command to run...",
children,
...props
}: React.ComponentProps<typeof Dialog> & {
title?: string
description?: string
}) {
return (
<Dialog {...props}>
<DialogHeader className="sr-only">
<DialogTitle>{title}</DialogTitle>
<DialogDescription>{description}</DialogDescription>
</DialogHeader>
<DialogContent className="overflow-hidden p-0">
<Command className="[&_[cmdk-group-heading]]:text-muted-foreground **:data-[slot=command-input-wrapper]:h-12 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:font-medium [&_[cmdk-group]]:px-2 [&_[cmdk-group]:not([hidden])_~[cmdk-group]]:pt-0 [&_[cmdk-input-wrapper]_svg]:h-5 [&_[cmdk-input-wrapper]_svg]:w-5 [&_[cmdk-input]]:h-12 [&_[cmdk-item]]:px-2 [&_[cmdk-item]]:py-3 [&_[cmdk-item]_svg]:h-5 [&_[cmdk-item]_svg]:w-5">
{children}
</Command>
</DialogContent>
</Dialog>
)
}
function CommandInput({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Input>) {
return (
<div
data-slot="command-input-wrapper"
className="flex h-9 items-center gap-2 border-b px-3"
>
<SearchIcon className="size-4 shrink-0 opacity-50" />
<CommandPrimitive.Input
data-slot="command-input"
className={merge(
"placeholder:text-muted-foreground flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-hidden disabled:cursor-not-allowed disabled:opacity-50",
className
)}
{...props}
/>
</div>
)
}
function CommandList({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.List>) {
return (
<CommandPrimitive.List
data-slot="command-list"
className={merge(
"max-h-[300px] scroll-py-1 overflow-x-hidden overflow-y-auto",
className
)}
{...props}
/>
)
}
function CommandEmpty({
...props
}: React.ComponentProps<typeof CommandPrimitive.Empty>) {
return (
<CommandPrimitive.Empty
data-slot="command-empty"
className="py-6 text-center text-sm"
{...props}
/>
)
}
function CommandGroup({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Group>) {
return (
<CommandPrimitive.Group
data-slot="command-group"
className={merge(
"text-foreground [&_[cmdk-group-heading]]:text-muted-foreground overflow-hidden p-1 [&_[cmdk-group-heading]]:px-2 [&_[cmdk-group-heading]]:py-1.5 [&_[cmdk-group-heading]]:text-xs [&_[cmdk-group-heading]]:font-medium",
className
)}
{...props}
/>
)
}
function CommandSeparator({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Separator>) {
return (
<CommandPrimitive.Separator
data-slot="command-separator"
className={merge("bg-border -mx-1 h-px", className)}
{...props}
/>
)
}
function CommandItem({
className,
...props
}: React.ComponentProps<typeof CommandPrimitive.Item>) {
return (
<CommandPrimitive.Item
data-slot="command-item"
className={merge(
"data-[selected=true]:bg-muted data-[selected=true]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled=true]:pointer-events-none data-[disabled=true]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
className
)}
{...props}
/>
)
}
function CommandShortcut({
className,
...props
}: React.ComponentProps<"span">) {
return (
<span
data-slot="command-shortcut"
className={merge(
"text-muted-foreground ml-auto text-xs tracking-widest",
className
)}
{...props}
/>
)
}
export {
Command,
CommandDialog,
CommandInput,
CommandList,
CommandEmpty,
CommandGroup,
CommandItem,
CommandShortcut,
CommandSeparator,
}

View File

@@ -23,7 +23,7 @@ function TableHeader({className, ...props}: React.ComponentProps<'thead'>) {
return ( return (
<thead <thead
data-slot="table-header" data-slot="table-header"
className={merge('[&_tr]:border-b', className)} className={merge('[&_tr]:after:inset-0', className)}
{...props} {...props}
/> />
) )
@@ -33,7 +33,7 @@ function TableBody({className, ...props}: React.ComponentProps<'tbody'>) {
return ( return (
<tbody <tbody
data-slot="table-body" data-slot="table-body"
className={merge('[&_tr:last-child]:border-0', className)} className={merge('[&_tr:last-child]:after:border-0', className)}
{...props} {...props}
/> />
) )
@@ -57,7 +57,8 @@ function TableRow({className, ...props}: React.ComponentProps<'tr'>) {
<tr <tr
data-slot="table-row" data-slot="table-row"
className={merge( className={merge(
'hover:bg-muted/50 data-[state=selected]:bg-muted border-b transition-colors', 'hover:bg-muted/50 data-[state=selected]:bg-muted transition-colors relative',
`after:border-b after:border-border after:absolute after:left-2 after:right-2 after:bottom-0 after:pointer-events-none`,
className, className,
)} )}
{...props} {...props}
@@ -70,7 +71,7 @@ function TableHead({className, ...props}: React.ComponentProps<'th'>) {
<th <th
data-slot="table-head" data-slot="table-head"
className={merge( className={merge(
'text-weak h-10 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', 'text-foreground h-10 px-2 text-left align-middle whitespace-nowrap [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
className, className,
)} )}
{...props} {...props}

View File

@@ -21,7 +21,8 @@ type PageRecord<T = unknown> = {
list: T[] list: T[]
} }
type ExtractData<T extends (...args: never) => unknown> = Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never type ExtraReq<T extends (...args: never) => unknown> = T extends (...args: infer P) => unknown ? P[0] : never
type ExtraResp<T extends (...args: never) => unknown> = Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
// 预定义错误 // 预定义错误
const UnauthorizedError = new Error('未授权访问') const UnauthorizedError = new Error('未授权访问')
@@ -32,6 +33,7 @@ export {
CLIENT_SECRET, CLIENT_SECRET,
type ApiResponse, type ApiResponse,
type PageRecord, type PageRecord,
type ExtractData, type ExtraReq,
type ExtraResp,
UnauthorizedError, UnauthorizedError,
} }

2
src/lib/models/index.ts Normal file
View File

@@ -0,0 +1,2 @@
export * from './models'
export * from './resource'

View File

@@ -1,3 +1,5 @@
import {Resource} from '@/lib/models'
export type User = { export type User = {
id: number id: number
admin_id: number admin_id: number
@@ -20,45 +22,6 @@ export type User = {
updated_at: Date updated_at: Date
} }
export type Resource = {
id: number
user_id: number
resource_no: string
active: boolean
type: number
created_at: Date
updated_at: Date
short: ResourceShort
}
export function name(obj: Resource) {
switch (obj.type) {
case 1:
switch (obj.short.type) {
case 1:
return `包时 ${obj.short.live / 60}分钟`
case 2:
return `包量 ${obj.short.live / 60}分钟`
}
break
}
}
export type ResourceShort = {
id: number
resource_id: number
type: number
live: number
expire: Date
quota: number
used: number
daily_limit: number
daily_used: number
daily_last: Date
created_at: Date
updated_at: Date
}
export type Bill = { export type Bill = {
id: number id: number
user_id: number user_id: number

View File

@@ -0,0 +1,47 @@
type ResourceShort = {
id: number
resource_id: number
type: number
live: number
expire: Date
quota: number
used: number
daily_limit: number
daily_used: number
daily_last: Date
created_at: Date
updated_at: Date
}
type ResourceLong = {
id: number
resource_id: number
type: number
live: number
expire: Date
quota: number
used: number
daily_limit: number
daily_used: number
daily_last: Date
created_at: Date
updated_at: Date
}
export type Resource<T extends 1 | 2 = 1 | 2> = {
id: number
user_id: number
resource_no: string
active: boolean
created_at: Date
updated_at: Date
} & (
T extends 1 ? {
type: 1
short: ResourceShort
} :
T extends 2 ? {
type: 2
long: ResourceLong
} : {}
)