重构项目结构,将数据层集中在 lib 包中;resource 类型更新,支持多个子套餐类型分别表示;新增长效套餐的购买流程,以及已购查询功能
This commit is contained in:
18
README.md
18
README.md
@@ -0,0 +1,18 @@
|
|||||||
|
## TODO
|
||||||
|
|
||||||
|
整合多类型的产品支付流程
|
||||||
|
|
||||||
|
支付页面组件的布局组件复用,不同产品只提供不同数据
|
||||||
|
|
||||||
|
整合多类型的产品提取流程
|
||||||
|
|
||||||
|
调整页面大小优化:如果单页大小不超过预期大小,不需要刷新数据
|
||||||
|
翻页优化:调整页面大小后检查是否需要重置页面到最后一页(需要后端实现)
|
||||||
|
|
||||||
|
### 架构改进
|
||||||
|
|
||||||
|
考虑使用 swr 或 react query 来代替直接的服务端 react cache 缓存以及客户端 zustand 缓存,以将服务端请求的数据能够水合到客户端,避免重复请求
|
||||||
|
|
||||||
|
### 需要确认
|
||||||
|
|
||||||
|
页面内操作是否需要关联到 url 上,以在使用后退功能时返回到上一次操作
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -16,4 +16,4 @@ export async function listAnnouncements(props: {
|
|||||||
update_before?: Date
|
update_before?: Date
|
||||||
}) {
|
}) {
|
||||||
return await callByUser<PageRecord<Announcement>>('/api/announcement/list', props)
|
return await callByUser<PageRecord<Announcement>>('/api/announcement/list', props)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
live: number
|
short?: {
|
||||||
quota: number
|
live: number
|
||||||
expire: number
|
mode: number
|
||||||
daily_limit: number
|
quota: number
|
||||||
|
expire: 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
|
||||||
|
pay_url: string
|
||||||
|
}>('/api/resource/create/prepare', props)
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function completeResource(props: {
|
||||||
trade_no: string
|
trade_no: string
|
||||||
pay_url: string
|
}) {
|
||||||
}
|
return await callByUser('/api/resource/create/complete', props)
|
||||||
|
|
||||||
async function createResourceByBalance(props: CreateResourceReq) {
|
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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 && '微信'}
|
||||||
|
|||||||
308
src/app/admin/resources/_client/long.tsx
Normal file
308
src/app/admin/resources/_client/long.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
309
src/app/admin/resources/_client/short.tsx
Normal file
309
src/app/admin/resources/_client/short.tsx
Normal 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>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
/>
|
||||||
|
</>
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,44 +6,41 @@ 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'
|
||||||
|
|
||||||
|
const schema = z.object({
|
||||||
|
resource: z.number({required_error: '请选择套餐'}),
|
||||||
|
prov: z.string().optional(),
|
||||||
|
city: z.string().optional(),
|
||||||
|
regionType: z.enum(['unlimited', 'specific']).default('unlimited'),
|
||||||
|
isp: z.enum(['all', '1', '2', '3'], {required_error: '请选择运营商'}),
|
||||||
|
proto: z.enum(['all', '1', '2', '3'], {required_error: '请选择协议'}),
|
||||||
|
authType: z.enum(['1', '2'], {required_error: '请选择认证方式'}),
|
||||||
|
distinct: z.enum(['1', '0'], {required_error: '请选择去重选项'}),
|
||||||
|
format: z.enum(['text', 'json'], {required_error: '请选择导出格式'}),
|
||||||
|
separator: z.string({required_error: '请选择分隔符'}),
|
||||||
|
breaker: z.string({required_error: '请选择换行符'}),
|
||||||
|
count: z.number({required_error: '请输入有效的数量'}).min(1),
|
||||||
|
})
|
||||||
|
|
||||||
|
type Schema = z.infer<typeof schema>
|
||||||
|
|
||||||
type ExtractProps = {
|
type ExtractProps = {
|
||||||
className?: string
|
className?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function Extract(props: ExtractProps) {
|
export default function Extract(props: ExtractProps) {
|
||||||
const [resources, setResources] = useState<Resource[]>([])
|
|
||||||
const [status, setStatus] = useStatus()
|
|
||||||
|
|
||||||
const schema = z.object({
|
|
||||||
resource: z.number({required_error: '请选择套餐'}),
|
|
||||||
prov: z.string().optional(),
|
|
||||||
city: z.string().optional(),
|
|
||||||
regionType: z.enum(['unlimited', 'specific']).default('unlimited'),
|
|
||||||
isp: z.enum(['all', '1', '2', '3'], {required_error: '请选择运营商'}),
|
|
||||||
proto: z.enum(['all', '1', '2', '3'], {required_error: '请选择协议'}),
|
|
||||||
authType: z.enum(['1', '2'], {required_error: '请选择认证方式'}),
|
|
||||||
distinct: z.enum(['1', '0'], {required_error: '请选择去重选项'}),
|
|
||||||
format: z.enum(['text', 'json'], {required_error: '请选择导出格式'}),
|
|
||||||
separator: z.string({required_error: '请选择分隔符'}),
|
|
||||||
breaker: z.string({required_error: '请选择换行符'}),
|
|
||||||
count: z.number({required_error: '请输入有效的数量'}).min(1),
|
|
||||||
})
|
|
||||||
|
|
||||||
type Schema = z.infer<typeof schema>
|
|
||||||
|
|
||||||
const form = useForm<Schema>({
|
const form = useForm<Schema>({
|
||||||
resolver: zodResolver(schema),
|
resolver: zodResolver(schema),
|
||||||
defaultValues: {
|
defaultValues: {
|
||||||
@@ -59,74 +56,233 @@ export default function Extract(props: ExtractProps) {
|
|||||||
},
|
},
|
||||||
})
|
})
|
||||||
|
|
||||||
const regionType = form.watch('regionType')
|
// ======================
|
||||||
|
// render
|
||||||
|
// ======================
|
||||||
|
|
||||||
const resource = form.watch('resource')
|
return (
|
||||||
const prov = form.watch('prov')
|
<Form form={form} className={merge(
|
||||||
const city = form.watch('city')
|
`bg-white flex flex-col gap-4 rounded-md`,
|
||||||
const isp = form.watch('isp')
|
props.className,
|
||||||
const proto = form.watch('proto')
|
)}
|
||||||
const authType = form.watch('authType')
|
>
|
||||||
const distinct = form.watch('distinct')
|
<Alert variant={`warn`}>
|
||||||
const formatType = form.watch('format')
|
<CircleAlert/>
|
||||||
const separator = form.watch('separator')
|
<AlertTitle>提取IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
|
||||||
const breaker = form.watch('breaker')
|
</Alert>
|
||||||
const count = form.watch('count')
|
|
||||||
|
|
||||||
const params = useMemo(() => {
|
<FormFields/>
|
||||||
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)
|
<ApplyLink/>
|
||||||
if (city) sp.set('b', city)
|
</Form>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
if (isp != 'all') sp.set('s', isp)
|
const FormFields = memo(() => {
|
||||||
sp.set('d', distinct)
|
return (
|
||||||
sp.set('rt', formatType)
|
<div className={`flex flex-col gap-4`}>
|
||||||
sp.set('rs', separator)
|
{/* 选择套餐 */}
|
||||||
sp.set('rb', breaker)
|
<SelectResource/>
|
||||||
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>) => {
|
<SelectRegion/>
|
||||||
switch (type.current) {
|
|
||||||
case 'copy':
|
|
||||||
const url = new URL(window.location.href).origin
|
|
||||||
const text = `${url}${params}`
|
|
||||||
|
|
||||||
// 使用 clipboard API 复制链接
|
{/* 运营商筛选 */}
|
||||||
let copied = false
|
<div className="flex items-center">
|
||||||
try {
|
<FormField name="isp" label={`运营商筛选`}>
|
||||||
await navigator.clipboard.writeText(text)
|
{({id, field}) => (
|
||||||
copied = true
|
<RadioGroup
|
||||||
}
|
onValueChange={field.onChange}
|
||||||
catch (e) {
|
defaultValue={field.value}
|
||||||
console.log('剪贴板 API 调用失败,尝试备选方案')
|
className="flex gap-4">
|
||||||
}
|
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="all" id={`${id}-v-all`}/>
|
||||||
|
<span>不限</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-telecom`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="1" id={`${id}-v-telecom`}/>
|
||||||
|
<span>电信</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-mobile`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="2" id={`${id}-v-mobile`}/>
|
||||||
|
<span>联通</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-unicom`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="3" id={`${id}-v-unicom`}/>
|
||||||
|
<span>移动</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
// 使用 document.execCommand 作为备选方案
|
{/* 协议类型 */}
|
||||||
if (!copied) {
|
<div className="flex items-center">
|
||||||
const textarea = document.createElement('textarea')
|
<FormField name="proto" label={`协议类型`}>
|
||||||
textarea.value = text
|
{({id, field}) => (
|
||||||
document.body.appendChild(textarea)
|
<RadioGroup
|
||||||
textarea.select()
|
onValueChange={field.onChange}
|
||||||
document.execCommand('copy')
|
defaultValue={field.value}
|
||||||
document.body.removeChild(textarea)
|
className="flex gap-4">
|
||||||
}
|
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="all" id={`${id}-v-all`} className="mr-2"/>
|
||||||
|
<span>不限</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-http`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
|
||||||
|
<span>HTTP</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-https`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
|
||||||
|
<span>HTTPS</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-socks5`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="3" id={`${id}-v-socks5`} className="mr-2"/>
|
||||||
|
<span>SOCKS5</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
toast.success('链接已复制到剪贴板')
|
{/* 认证方式 */}
|
||||||
break
|
<div className="flex items-center">
|
||||||
case 'open':
|
<FormField name="authType" label={`协议类型`}>
|
||||||
window.open(params, '_blank')
|
{({id, field}) => (
|
||||||
break
|
<RadioGroup
|
||||||
}
|
onValueChange={field.onChange}
|
||||||
}
|
defaultValue={field.value}
|
||||||
|
className="flex gap-4">
|
||||||
|
<FormLabel htmlFor={`${id}-v-http`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
|
||||||
|
<span>白名单</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-https`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
|
||||||
|
<span>密码</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 去重选项 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormField name="distinct" label={`去重选项`}>
|
||||||
|
{({id, field}) => (
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex gap-4">
|
||||||
|
<FormLabel htmlFor={`${id}-v-true`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="1" id={`${id}-v-true`} className="mr-2"/>
|
||||||
|
<span>去重</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-false`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="0" id={`${id}-v-false`} className="mr-2"/>
|
||||||
|
<span>不去重</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 导出格式 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormField name="format" label={`导出格式`}>
|
||||||
|
{({id, field}) => (
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex gap-4"
|
||||||
|
>
|
||||||
|
<FormLabel htmlFor={`${id}-v-txt`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="text" id={`${id}-v-txt`} className="mr-2"/>
|
||||||
|
<span>TXT 格式</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-json`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="json" id={`${id}-v-json`} className="mr-2"/>
|
||||||
|
<span>JSON 格式</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 分隔符 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormField name="separator" label={`分隔符`}>
|
||||||
|
{({id, field}) => (
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex gap-4">
|
||||||
|
<FormLabel htmlFor={`${id}-v-comma`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="124" id={`${id}-v-comma`} className="mr-2"/>
|
||||||
|
<span>竖线 ( | )</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-semicolon`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="58" id={`${id}-v-semicolon`} className="mr-2"/>
|
||||||
|
<span>冒号 ( : )</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-space`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="9" id={`${id}-v-space`} className="mr-2"/>
|
||||||
|
<span>制表符 ( \t )</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 换行符 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormField name="breaker" label={`换行符`}>
|
||||||
|
{({id, field}) => (
|
||||||
|
<RadioGroup
|
||||||
|
onValueChange={field.onChange}
|
||||||
|
defaultValue={field.value}
|
||||||
|
className="flex gap-4">
|
||||||
|
<FormLabel htmlFor={`${id}-v-newline2`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="13,10" id={`${id}-v-newline2`} className="mr-2"/>
|
||||||
|
<span>回车换行 ( \r\n )</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-newline`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="10" id={`${id}-v-newline`} className="mr-2"/>
|
||||||
|
<span>换行 ( \n )</span>
|
||||||
|
</FormLabel>
|
||||||
|
<FormLabel htmlFor={`${id}-v-newline3`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
||||||
|
<RadioGroupItem value="13" id={`${id}-v-newline3`} className="mr-2"/>
|
||||||
|
<span>回车 ( \r )</span>
|
||||||
|
</FormLabel>
|
||||||
|
</RadioGroup>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 提取数量 */}
|
||||||
|
<div className="flex items-center">
|
||||||
|
<FormField name="count" label={`提取数量`}>
|
||||||
|
{({id, field}) => (
|
||||||
|
<Input
|
||||||
|
{...field}
|
||||||
|
id={id}
|
||||||
|
type="number"
|
||||||
|
onChange={e => field.onChange(Number(e.target.value))}
|
||||||
|
className="h-10 w-84"
|
||||||
|
placeholder="输入提取数量"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</FormField>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
})
|
||||||
|
FormFields.displayName = 'FormFields'
|
||||||
|
|
||||||
|
function SelectResource() {
|
||||||
|
const [resources, setResources] = useState<Resource[]>([])
|
||||||
|
const [status, setStatus] = useStatus()
|
||||||
const getResources = async () => {
|
const getResources = async () => {
|
||||||
setStatus('load')
|
setStatus('load')
|
||||||
try {
|
try {
|
||||||
@@ -134,11 +290,13 @@ export default function Extract(props: ExtractProps) {
|
|||||||
if (!resp.success) {
|
if (!resp.success) {
|
||||||
throw new Error('Unable to fetch packages.')
|
throw new Error('Unable to fetch packages.')
|
||||||
}
|
}
|
||||||
|
console.log(resp.data)
|
||||||
setResources(resp.data)
|
setResources(resp.data)
|
||||||
setStatus('done')
|
setStatus('done')
|
||||||
}
|
}
|
||||||
catch (error) {
|
catch (error) {
|
||||||
console.error('Error fetching packages:', error)
|
console.error('Error fetching packages:', error)
|
||||||
|
toast.error('获取套餐失败,请稍后再试')
|
||||||
setStatus('fail')
|
setStatus('fail')
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -148,352 +306,267 @@ export default function Extract(props: ExtractProps) {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [])
|
}, [])
|
||||||
|
|
||||||
// ======================
|
|
||||||
// render
|
|
||||||
// ======================
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Form
|
<div className="flex items-center">
|
||||||
form={form}
|
<FormField name="resource" label={`选择套餐`}>
|
||||||
onSubmit={onSubmit}
|
{({field}) => (
|
||||||
onError={errors => {
|
<Select
|
||||||
const desc: (string | undefined)[] = []
|
value={field.value ? String(field.value) : undefined}
|
||||||
Object.entries(errors).forEach(([field, error]) => {
|
onValueChange={value => field.onChange(Number(value))}
|
||||||
if (error.message) {
|
>
|
||||||
desc.push(error.message)
|
<SelectTrigger className={`min-h-10 h-auto w-84`}>
|
||||||
}
|
<SelectValue placeholder={`选择套餐`}/>
|
||||||
})
|
</SelectTrigger>
|
||||||
toast.error('请完成填写:', {
|
<SelectContent>
|
||||||
description: desc.map((msg, i) => (
|
{status === 'load' ? (
|
||||||
<span key={i}>- {msg}</span>
|
<div className={`p-4 flex gap-1 items-center`}>
|
||||||
)),
|
<Loader className={`animate-spin`} size={20}/>
|
||||||
})
|
<span>加载中...</span>
|
||||||
}}
|
</div>
|
||||||
className={merge(
|
) : resources.length === 0 ? (
|
||||||
`bg-white flex flex-col gap-4 rounded-md`,
|
<div className={`p-4 flex gap-1 items-center`}>
|
||||||
props.className,
|
<Loader className={`animate-spin`} size={20}/>
|
||||||
)}
|
<span>暂无可用套餐</span>
|
||||||
>
|
</div>
|
||||||
<Alert variant={`warn`}>
|
) : resources.map((resource, i) => (<>
|
||||||
<CircleAlert/>
|
<SelectItem
|
||||||
<AlertTitle>提取IP前需要将本机IP添加到白名单后才可使用</AlertTitle>
|
key={`${resource.id}`} value={String(resource.id)} className={`p-3`}>
|
||||||
</Alert>
|
<div className={`flex flex-col gap-2 w-72`}>
|
||||||
|
{resource.type === 1 && resource.short.type === 1 && (<>
|
||||||
<div className={`flex flex-col gap-4`}>
|
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm`}>
|
||||||
{/* 选择套餐 */}
|
<Timer size={20}/>
|
||||||
<div className="flex items-center">
|
<span>{name(resource)}</span>
|
||||||
<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>
|
</div>
|
||||||
</SelectItem>
|
<div className={`flex justify-between gap-2 text-xs text-weak`}>
|
||||||
{i < resources.length - 1 && <SelectSeparator className={`m-2`}/>}
|
<span>到期时间:{format(resource.short.expire, 'yyyy-MM-dd HH:mm')}</span>
|
||||||
</>))
|
<span>{intlFormatDistance(resource.short.expire, new Date())}</span>
|
||||||
}
|
</div>
|
||||||
</SelectContent>
|
</>)}
|
||||||
</Select>
|
{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`}>
|
||||||
</FormField>
|
<Box size={20}/>
|
||||||
</div>
|
<span>{name(resource)}</span>
|
||||||
|
</div>
|
||||||
{/* 地区筛选 */}
|
<div className={`flex justify-between gap-2 text-xs text-weak`}>
|
||||||
<div className="flex flex-col gap-4">
|
<span>提取数量:{resource.short.used} / {resource.short.quota}</span>
|
||||||
<FormField name="regionType" label={`地区筛选`}>
|
<span>剩余 {resource.short.quota - resource.short.used}</span>
|
||||||
{({id, field}) => (
|
</div>
|
||||||
<RadioGroup
|
</>)}
|
||||||
onValueChange={(e) => {
|
{resource.type === 2 && resource.long.type === 1 && (<>
|
||||||
field.onChange(e)
|
<div className={`flex gap-2 items-center bg-green-50 w-fit px-2 py-1 rounded-md text-sm`}>
|
||||||
if (e === 'unlimited') {
|
<Timer size={20}/>
|
||||||
form.setValue('prov', '')
|
<span>{name(resource)}</span>
|
||||||
form.setValue('city', '')
|
</div>
|
||||||
}
|
<div className={`flex justify-between gap-2 text-xs text-weak`}>
|
||||||
}}
|
<span>到期时间:{format(resource.long.expire, 'yyyy-MM-dd HH:mm')}</span>
|
||||||
defaultValue={field.value}
|
<span>{intlFormatDistance(resource.long.expire, new Date())}</span>
|
||||||
className="flex gap-4"
|
</div>
|
||||||
>
|
</>)}
|
||||||
<FormLabel htmlFor={`${id}-v-unlimited`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
{resource.type === 2 && resource.long.type === 2 && (<>
|
||||||
<RadioGroupItem value="unlimited" id={`${id}-v-unlimited`} className="mr-2"/>
|
<div className={`flex gap-2 items-center bg-blue-50 w-fit px-2 py-1 rounded-md text-sm`}>
|
||||||
<span>不限地区</span>
|
<Box size={20}/>
|
||||||
</FormLabel>
|
<span>{name(resource)}</span>
|
||||||
<FormLabel htmlFor={`${id}-v-specific`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
</div>
|
||||||
<RadioGroupItem value="specific" id={`${id}-v-specific`} className="mr-2"/>
|
<div className={`flex justify-between gap-2 text-xs text-weak`}>
|
||||||
<span>指定地区</span>
|
<span>提取数量:{resource.long.used} / {resource.long.quota}</span>
|
||||||
</FormLabel>
|
<span>剩余 {resource.long.quota - resource.long.used}</span>
|
||||||
</RadioGroup>
|
</div>
|
||||||
)}
|
</>)}
|
||||||
</FormField>
|
</div>
|
||||||
|
</SelectItem>
|
||||||
{regionType === 'specific' && (
|
{i < resources.length - 1 && <SelectSeparator className={`m-2`}/>}
|
||||||
<Combobox
|
</>))}
|
||||||
className={`w-84`}
|
</SelectContent>
|
||||||
placeholder={`请选择地区`}
|
</Select>
|
||||||
options={cities.options}
|
)}
|
||||||
value={[prov || '', city || '']}
|
</FormField>
|
||||||
onChange={value => {
|
</div>
|
||||||
form.setValue('prov', value[0])
|
|
||||||
form.setValue('city', value[1])
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 运营商筛选 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="isp" label={`运营商筛选`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4">
|
|
||||||
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="all" id={`${id}-v-all`}/>
|
|
||||||
<span>不限</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-telecom`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="1" id={`${id}-v-telecom`}/>
|
|
||||||
<span>电信</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-mobile`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="2" id={`${id}-v-mobile`}/>
|
|
||||||
<span>联通</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-unicom`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="3" id={`${id}-v-unicom`}/>
|
|
||||||
<span>移动</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 协议类型 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="proto" label={`协议类型`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4">
|
|
||||||
<FormLabel htmlFor={`${id}-v-all`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="all" id={`${id}-v-all`} className="mr-2"/>
|
|
||||||
<span>不限</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-http`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
|
|
||||||
<span>HTTP</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-https`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
|
|
||||||
<span>HTTPS</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-socks5`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="3" id={`${id}-v-socks5`} className="mr-2"/>
|
|
||||||
<span>SOCKS5</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 认证方式 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="authType" label={`协议类型`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4">
|
|
||||||
<FormLabel htmlFor={`${id}-v-http`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="1" id={`${id}-v-http`} className="mr-2"/>
|
|
||||||
<span>白名单</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-https`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="2" id={`${id}-v-https`} className="mr-2"/>
|
|
||||||
<span>密码</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 去重选项 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="distinct" label={`去重选项`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4">
|
|
||||||
<FormLabel htmlFor={`${id}-v-true`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="1" id={`${id}-v-true`} className="mr-2"/>
|
|
||||||
<span>去重</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-false`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="0" id={`${id}-v-false`} className="mr-2"/>
|
|
||||||
<span>不去重</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 导出格式 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="format" label={`导出格式`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4"
|
|
||||||
>
|
|
||||||
<FormLabel htmlFor={`${id}-v-txt`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="text" id={`${id}-v-txt`} className="mr-2"/>
|
|
||||||
<span>TXT 格式</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-json`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="json" id={`${id}-v-json`} className="mr-2"/>
|
|
||||||
<span>JSON 格式</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 分隔符 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="separator" label={`分隔符`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4">
|
|
||||||
<FormLabel htmlFor={`${id}-v-comma`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="124" id={`${id}-v-comma`} className="mr-2"/>
|
|
||||||
<span>竖线 ( | )</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-semicolon`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="58" id={`${id}-v-semicolon`} className="mr-2"/>
|
|
||||||
<span>冒号 ( : )</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-space`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="9" id={`${id}-v-space`} className="mr-2"/>
|
|
||||||
<span>制表符 ( \t )</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 换行符 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="breaker" label={`换行符`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<RadioGroup
|
|
||||||
onValueChange={field.onChange}
|
|
||||||
defaultValue={field.value}
|
|
||||||
className="flex gap-4">
|
|
||||||
<FormLabel htmlFor={`${id}-v-newline2`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="13,10" id={`${id}-v-newline2`} className="mr-2"/>
|
|
||||||
<span>回车换行 ( \r\n )</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-newline`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="10" id={`${id}-v-newline`} className="mr-2"/>
|
|
||||||
<span>换行 ( \n )</span>
|
|
||||||
</FormLabel>
|
|
||||||
<FormLabel htmlFor={`${id}-v-newline3`} className={`px-3 h-10 border rounded-md flex items-center w-40 text-sm`}>
|
|
||||||
<RadioGroupItem value="13" id={`${id}-v-newline3`} className="mr-2"/>
|
|
||||||
<span>回车 ( \r )</span>
|
|
||||||
</FormLabel>
|
|
||||||
</RadioGroup>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 提取数量 */}
|
|
||||||
<div className="flex items-center">
|
|
||||||
<FormField name="count" label={`提取数量`}>
|
|
||||||
{({id, field}) => (
|
|
||||||
<Input
|
|
||||||
{...field}
|
|
||||||
id={id}
|
|
||||||
type="number"
|
|
||||||
onChange={e => field.onChange(Number(e.target.value))}
|
|
||||||
className="h-10 w-84"
|
|
||||||
placeholder="输入提取数量"
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</FormField>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className={merge(
|
|
||||||
`flex flex-col gap-4 sticky bottom-0 bg-muted p-4`,
|
|
||||||
`rounded-lg`,
|
|
||||||
)}>
|
|
||||||
{/* 展示链接地址 */}
|
|
||||||
<div className={`bg-neutral-900 text-white p-4 rounded-md break-all`}>
|
|
||||||
{params}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* 操作 */}
|
|
||||||
<div className="flex gap-4">
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={() => type.current = 'copy'}
|
|
||||||
>
|
|
||||||
<CopyIcon/>
|
|
||||||
<span>复制链接</span>
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
type="submit"
|
|
||||||
onClick={() => type.current = 'open'}
|
|
||||||
>
|
|
||||||
<ExternalLinkIcon/>
|
|
||||||
<span>打开链接</span>
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</Form>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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(
|
||||||
|
`flex flex-col gap-4 sticky bottom-0 bg-muted p-4`,
|
||||||
|
`rounded-lg`,
|
||||||
|
)}>
|
||||||
|
{/* 展示链接地址 */}
|
||||||
|
<div className={`bg-neutral-900 text-white p-4 rounded-md break-all`}>
|
||||||
|
{link(values)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 操作 */}
|
||||||
|
<div className="flex gap-4">
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
type.current = 'copy'
|
||||||
|
await handler()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<CopyIcon/>
|
||||||
|
<span>复制链接</span>
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
onClick={async () => {
|
||||||
|
type.current = 'open'
|
||||||
|
await handler()
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<ExternalLinkIcon/>
|
||||||
|
<span>打开链接</span>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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`}
|
||||||
52
src/components/composites/purchase/long/form.tsx
Normal file
52
src/components/composites/purchase/long/form.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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()}>
|
||||||
@@ -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' ? '确认支付' : '已完成支付'}
|
||||||
210
src/components/composites/purchase/short/center.tsx
Normal file
210
src/components/composites/purchase/short/center.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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`}>
|
<Center/>
|
||||||
<PurchaseFormContext.Provider value={{form}}>
|
<Right/>
|
||||||
<Center/>
|
</Form>
|
||||||
<Right/>
|
|
||||||
</PurchaseFormContext.Provider>
|
|
||||||
</Form>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
167
src/components/composites/purchase/short/right.tsx
Normal file
167
src/components/composites/purchase/short/right.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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 = {
|
||||||
|
|||||||
@@ -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,
|
|
||||||
}
|
|
||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
2
src/lib/models/index.ts
Normal file
@@ -0,0 +1,2 @@
|
|||||||
|
export * from './models'
|
||||||
|
export * from './resource'
|
||||||
@@ -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
|
||||||
47
src/lib/models/resource.ts
Normal file
47
src/lib/models/resource.ts
Normal 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
|
||||||
|
} : {}
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user