From dc83c83cfb9b60447aad16e1bffdf62ebef82fda Mon Sep 17 00:00:00 2001 From: luorijun Date: Thu, 22 May 2025 14:59:22 +0800 Subject: [PATCH] =?UTF-8?q?=E9=87=8D=E6=9E=84=E9=A1=B9=E7=9B=AE=E7=BB=93?= =?UTF-8?q?=E6=9E=84=EF=BC=8C=E5=B0=86=E6=95=B0=E6=8D=AE=E5=B1=82=E9=9B=86?= =?UTF-8?q?=E4=B8=AD=E5=9C=A8=20lib=20=E5=8C=85=E4=B8=AD=EF=BC=9Bresource?= =?UTF-8?q?=20=E7=B1=BB=E5=9E=8B=E6=9B=B4=E6=96=B0=EF=BC=8C=E6=94=AF?= =?UTF-8?q?=E6=8C=81=E5=A4=9A=E4=B8=AA=E5=AD=90=E5=A5=97=E9=A4=90=E7=B1=BB?= =?UTF-8?q?=E5=9E=8B=E5=88=86=E5=88=AB=E8=A1=A8=E7=A4=BA=EF=BC=9B=E6=96=B0?= =?UTF-8?q?=E5=A2=9E=E9=95=BF=E6=95=88=E5=A5=97=E9=A4=90=E7=9A=84=E8=B4=AD?= =?UTF-8?q?=E4=B9=B0=E6=B5=81=E7=A8=8B=EF=BC=8C=E4=BB=A5=E5=8F=8A=E5=B7=B2?= =?UTF-8?q?=E8=B4=AD=E6=9F=A5=E8=AF=A2=E5=8A=9F=E8=83=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- README.md | 18 + src/actions/announcement.ts | 8 +- src/actions/resource.ts | 110 ++- src/actions/user.ts | 4 +- src/app/admin/bills/page.tsx | 14 +- src/app/admin/resources/_client/long.tsx | 308 ++++++ src/app/admin/resources/_client/short.tsx | 309 ++++++ src/app/admin/resources/page.tsx | 321 +----- src/components/composites/extract/index.tsx | 925 ++++++++++-------- src/components/composites/purchase/index.tsx | 9 +- .../purchase/{_client => long}/center.tsx | 27 +- .../composites/purchase/long/form.tsx | 52 + .../purchase/{_client => long}/right.tsx | 86 +- .../composites/purchase/{_client => }/nav.tsx | 0 .../purchase/{_client => }/option.tsx | 0 .../composites/purchase/{_client => }/pay.tsx | 90 +- .../composites/purchase/short/center.tsx | 210 ++++ .../purchase/{_client => short}/form.tsx | 20 +- .../composites/purchase/short/right.tsx | 167 ++++ src/components/composites/recharge/index.tsx | 6 +- src/components/providers/StoreProvider.tsx | 4 +- src/components/ui/command.tsx | 177 ---- src/components/ui/table.tsx | 9 +- src/lib/api.ts | 6 +- src/lib/models/index.ts | 2 + src/lib/{ => models}/models.ts | 41 +- src/lib/models/resource.ts | 47 + src/{ => lib}/stores/layout.ts | 0 src/{ => lib}/stores/profile.ts | 0 29 files changed, 1827 insertions(+), 1143 deletions(-) create mode 100644 src/app/admin/resources/_client/long.tsx create mode 100644 src/app/admin/resources/_client/short.tsx rename src/components/composites/purchase/{_client => long}/center.tsx (88%) create mode 100644 src/components/composites/purchase/long/form.tsx rename src/components/composites/purchase/{_client => long}/right.tsx (74%) rename src/components/composites/purchase/{_client => }/nav.tsx (100%) rename src/components/composites/purchase/{_client => }/option.tsx (100%) rename src/components/composites/purchase/{_client => }/pay.tsx (79%) create mode 100644 src/components/composites/purchase/short/center.tsx rename src/components/composites/purchase/{_client => short}/form.tsx (71%) create mode 100644 src/components/composites/purchase/short/right.tsx delete mode 100644 src/components/ui/command.tsx create mode 100644 src/lib/models/index.ts rename src/lib/{ => models}/models.ts (70%) create mode 100644 src/lib/models/resource.ts rename src/{ => lib}/stores/layout.ts (100%) rename src/{ => lib}/stores/profile.ts (100%) diff --git a/README.md b/README.md index e69de29..a771ce8 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,18 @@ +## TODO + +整合多类型的产品支付流程 + +支付页面组件的布局组件复用,不同产品只提供不同数据 + +整合多类型的产品提取流程 + +调整页面大小优化:如果单页大小不超过预期大小,不需要刷新数据 +翻页优化:调整页面大小后检查是否需要重置页面到最后一页(需要后端实现) + +### 架构改进 + +考虑使用 swr 或 react query 来代替直接的服务端 react cache 缓存以及客户端 zustand 缓存,以将服务端请求的数据能够水合到客户端,避免重复请求 + +### 需要确认 + +页面内操作是否需要关联到 url 上,以在使用后退功能时返回到上一次操作 diff --git a/src/actions/announcement.ts b/src/actions/announcement.ts index f828db6..a8460b4 100644 --- a/src/actions/announcement.ts +++ b/src/actions/announcement.ts @@ -1,8 +1,8 @@ 'use server' -import { PageRecord } from "@/lib/api" -import { Announcement } from "@/lib/models" -import { callByUser } from "./base" +import {PageRecord} from '@/lib/api' +import {Announcement} from '@/lib/models' +import {callByUser} from './base' export async function listAnnouncements(props: { page: number @@ -16,4 +16,4 @@ export async function listAnnouncements(props: { update_before?: Date }) { return await callByUser>('/api/announcement/list', props) -} \ No newline at end of file +} diff --git a/src/actions/resource.ts b/src/actions/resource.ts index b586db7..9befd03 100644 --- a/src/actions/resource.ts +++ b/src/actions/resource.ts @@ -2,9 +2,9 @@ import {callByUser} from '@/actions/base' 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 size: number resource_no?: string @@ -14,58 +14,72 @@ async function listResourcePss(props: { expire_after?: Date expire_before?: Date }) { - return await callByUser>('/api/resource/list/short', props) + return await callByUser>>('/api/resource/list/short', props) } -async function allResource(){ - return callByUser('/api/resource/all') +export async function listResourceLong(props: { + page: number + size: number + resource_no?: string + type?: number + create_after?: Date + create_before?: Date + expire_after?: Date + expire_before?: Date +}) { + return await callByUser>>('/api/resource/list/long', props) } -type CreateResourceReq = { +export async function allResource() { + return callByUser[]>('/api/resource/all') +} + +export async function createResource(props: { type: number - live: number - quota: number - expire: number - daily_limit: 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('/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 - pay_url: string -} - -async function createResourceByBalance(props: CreateResourceReq) { - return await callByUser('/api/resource/create/balance', props) -} - -async function prepareResourceByAlipay(props: CreateResourceReq) { - return await callByUser('/api/resource/prepare/alipay', props) -} - -async function prepareResourceByWechat(props: CreateResourceReq) { - return await callByUser('/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, +}) { + return await callByUser('/api/resource/create/complete', props) } diff --git a/src/actions/user.ts b/src/actions/user.ts index 282b0ec..9086d80 100644 --- a/src/actions/user.ts +++ b/src/actions/user.ts @@ -2,7 +2,7 @@ import {callByUser, callPublic} from '@/actions/base' export async function RechargeByAlipay(props: { - amount: number + amount: string }) { return callByUser<{ trade_no: string @@ -17,7 +17,7 @@ export async function RechargeByAlipayConfirm(props: { } export async function RechargeByWechat(props: { - amount: number + amount: string }) { return callByUser<{ trade_no: string diff --git a/src/app/admin/bills/page.tsx b/src/app/admin/bills/page.tsx index b130597..d63a542 100644 --- a/src/app/admin/bills/page.tsx +++ b/src/app/admin/bills/page.tsx @@ -174,12 +174,6 @@ export default function BillsPage(props: BillsPageProps) { }, { accessorKey: 'type', header: `类型`, cell: ({row}) => (
- {row.original.type === 2 && ( -
- - 充值 -
- )} {row.original.type === 1 && (
@@ -192,6 +186,12 @@ export default function BillsPage(props: BillsPageProps) { 退款
)} + {row.original.type === 3 && ( +
+ + 充值 +
+ )}
), }, @@ -238,7 +238,7 @@ export default function BillsPage(props: BillsPageProps) { { accessorKey: 'amount', header: `支付信息`, cell: ({row}) => (
- + {!row.original.trade && '余额'} {row.original.trade && row.original.trade.method === 1 && '支付宝'} {row.original.trade && row.original.trade.method === 2 && '微信'} diff --git a/src/app/admin/resources/_client/long.tsx b/src/app/admin/resources/_client/long.tsx new file mode 100644 index 0000000..23d21a8 --- /dev/null +++ b/src/app/admin/resources/_client/long.tsx @@ -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>({ + 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 + + const params = useSearchParams() + let paramType = params.get('type') + if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') { + paramType = 'all' + } + + const form = useForm({ + 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 <> + {/* 操作区 */} +
+
+ +
+ +
+ 套餐编号}> + {({id, field}) => ( + + )} + + 类型}> + {({field}) => ( + + )} + +
+ +
+ + {({field}) => ( + + )} + + - + + {({field}) => ( + + )} + +
+
+
+ +
+ + {({field}) => ( + + )} + + - + + {({field}) => ( + + )} + +
+
+
+ + +
+
+
+ + {/* 数据表 */} + { + await refresh(page, data.size) + }, + onSizeChange: async (size: number) => { + await refresh(data.page, size) + }, + }} + columns={[ + { + accessorKey: 'resource_no', header: `套餐编号`, + }, + { + accessorKey: 'type', header: `类型`, cell: ({row}) => ( +
+ {row.original.long.type === 1 && ( +
+ + 包时 +
+ )} + {row.original.long.type === 2 && ( +
+ + 包量 +
+ )} +
+ ), + }, + { + accessorKey: 'live', header: `IP 时效`, cell: ({row}) => ( + + {row.original.long.live} 小时 + + ), + }, + { + accessorKey: 'expire', header: `使用情况`, cell: ({row}) => ( +
+ {row.original.long.type === 1 ? ( +
+ {isAfter(row.original.long.expire, new Date()) + ? 正常 + : 过期} + | + 今日限额:{row.original.long.daily_used} / {row.original.long.daily_limit} + | + {intlFormatDistance(row.original.long.expire, new Date())} 到期 +
+ ) : row.original.long.type === 2 ? ( +
+ {row.original.long.used < row.original.long.quota + ? 正常 + : 已用完} + | + 用量统计:{row.original.long.used} / {row.original.long.quota} +
+ ) : ( + - + )} +
+ ), + }, + { + 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) => ( +
+ - +
+ ), + }, + ]} + /> + +} diff --git a/src/app/admin/resources/_client/short.tsx b/src/app/admin/resources/_client/short.tsx new file mode 100644 index 0000000..2521d68 --- /dev/null +++ b/src/app/admin/resources/_client/short.tsx @@ -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>({ + 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 + + const params = useSearchParams() + let paramType = params.get('type') + if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') { + paramType = 'all' + } + + const form = useForm({ + 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 <> + {/* 操作区 */} +
+
+ +
+ +
+ 套餐编号}> + {({id, field}) => ( + + )} + + 类型}> + {({field}) => ( + + )} + +
+ +
+ + {({field}) => ( + + )} + + - + + {({field}) => ( + + )} + +
+
+
+ +
+ + {({field}) => ( + + )} + + - + + {({field}) => ( + + )} + +
+
+
+ + +
+
+
+ + {/* 数据表 */} + { + await refresh(page, data.size) + }, + onSizeChange: async (size: number) => { + await refresh(data.page, size) + }, + }} + columns={[ + { + accessorKey: 'resource_no', header: `套餐编号`, + }, + { + accessorKey: 'type', header: `类型`, cell: ({row}) => ( +
+ {row.original.short.type === 1 && ( +
+ + 包时 +
+ )} + {row.original.short.type === 2 && ( +
+ + 包量 +
+ )} +
+ ), + }, + { + accessorKey: 'live', header: `IP 时效`, cell: ({row}) => ( + + {row.original.short.live / 60} 分钟 + + ), + }, + { + accessorKey: 'expire', header: `使用情况`, cell: ({row}) => ( +
+ {row.original.short.type === 1 ? ( +
+ {isAfter(row.original.short.expire, new Date()) + ? 正常 + : 过期} + | + 今日限额:{row.original.short.daily_used} / {row.original.short.daily_limit} + | + {intlFormatDistance(row.original.short.expire, new Date())} 到期 +
+ ) : row.original.short.type === 2 ? ( +
+ {row.original.short.used < row.original.short.quota + ? 正常 + : 已用完} + | + 用量统计:{row.original.short.used} / {row.original.short.quota} +
+ ) : ( + - + )} +
+ ), + }, + { + 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) => ( +
+ - +
+ ), + }, + ]} + /> + +} diff --git a/src/app/admin/resources/page.tsx b/src/app/admin/resources/page.tsx index 221392c..cec2cb7 100644 --- a/src/app/admin/resources/page.tsx +++ b/src/app/admin/resources/page.tsx @@ -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 {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 function ResourcesPage(props: ResourcesPageProps) { - - // ====================== - // 查询 - // ====================== - - const [status, setStatus] = useStatus() - const [data, setData] = useState>({ - 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 - - const params = useSearchParams() - let paramType = params.get('type') - if (paramType != 'all' && paramType != 'expire' && paramType != 'quota') { - paramType = 'all' - } - - const form = useForm({ - 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) - } +export default async function ResourcesPage() { // ====================== // render @@ -119,199 +11,18 @@ export default function ResourcesPage(props: ResourcesPageProps) { return ( - - {/* 操作区 */} -
-
- -
- -
- 套餐编号}> - {({id, field}) => ( - - )} - - 类型}> - {({field}) => ( - - )} - -
- -
- - {({field}) => ( - - )} - - - - - {({field}) => ( - - )} - -
-
-
- -
- - {({field}) => ( - - )} - - - - - {({field}) => ( - - )} - -
-
-
- - -
-
-
- - {/* 数据表 */} - { - await refresh(page, data.size) - }, - onSizeChange: async (size: number) => { - await refresh(data.page, size) - }, - }} - columns={[ - { - accessorKey: 'resource_no', header: `套餐编号`, - }, - { - accessorKey: 'type', header: `类型`, cell: ({row}) => ( -
- {row.original.short.type === 1 && ( -
- - 包时 -
- )} - {row.original.short.type === 2 && ( -
- - 包量 -
- )} -
- ), - }, - { - accessorKey: 'live', header: `IP 时效`, cell: ({row}) => ( - - {row.original.short.live / 60} 分钟 - - ), - }, - { - accessorKey: 'expire', header: `使用情况`, cell: ({row}) => ( -
- {row.original.short.type === 1 ? ( -
- {isAfter(row.original.short.expire, new Date()) - ? 正常 - : 过期} - | - 今日限额:{row.original.short.daily_used} / {row.original.short.daily_limit} - | - {intlFormatDistance(row.original.short.expire, new Date())} 到期 -
- ) : row.original.short.type === 2 ? ( -
- {row.original.short.used < row.original.short.quota - ? 正常 - : 已用完} - | - 用量统计:{row.original.short.used} / {row.original.short.quota} -
- ) : ( - - - )} -
- ), - }, - { - 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) => ( -
- - -
- ), - }, - ]} - /> + + + 短效套餐 + 长效套餐 + + + + + + + +
) } diff --git a/src/components/composites/extract/index.tsx b/src/components/composites/extract/index.tsx index 83f551e..a02a8ba 100644 --- a/src/components/composites/extract/index.tsx +++ b/src/components/composites/extract/index.tsx @@ -6,44 +6,41 @@ import {RadioGroup, RadioGroupItem} from '@/components/ui/radio-group' import {Input} from '@/components/ui/input' import {Select, SelectContent, SelectItem, SelectSeparator, SelectTrigger, SelectValue} from '@/components/ui/select' 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 {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 {allResource} from '@/actions/resource' -import {Resource, name} from '@/lib/models' +import {Resource} from '@/lib/models' import {format, intlFormatDistance} from 'date-fns' import {toast} from 'sonner' import {merge} from '@/lib/utils' import {Combobox} from '@/components/ui/combobox' 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 + type ExtractProps = { className?: string } export default function Extract(props: ExtractProps) { - const [resources, setResources] = useState([]) - 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 - const form = useForm({ resolver: zodResolver(schema), defaultValues: { @@ -59,74 +56,233 @@ export default function Extract(props: ExtractProps) { }, }) - const regionType = form.watch('regionType') + // ====================== + // render + // ====================== - const resource = form.watch('resource') - const prov = form.watch('prov') - const city = form.watch('city') - const isp = form.watch('isp') - const proto = form.watch('proto') - const authType = form.watch('authType') - const distinct = form.watch('distinct') - const formatType = form.watch('format') - const separator = form.watch('separator') - const breaker = form.watch('breaker') - const count = form.watch('count') + return ( +
+ + + 提取IP前需要将本机IP添加到白名单后才可使用 + - const params = useMemo(() => { - const sp = new URLSearchParams() - if (resource) sp.set('i', String(resource)) - if (authType) sp.set('t', authType) - if (proto != 'all') sp.set('x', proto) + - if (prov) sp.set('a', prov) - if (city) sp.set('b', city) + + + ) +} - if (isp != 'all') sp.set('s', isp) - sp.set('d', distinct) - sp.set('rt', formatType) - sp.set('rs', separator) - sp.set('rb', breaker) - sp.set('n', String(count)) +const FormFields = memo(() => { + return ( +
+ {/* 选择套餐 */} + - 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) => { - 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 调用失败,尝试备选方案') - } + {/* 运营商筛选 */} +
+ + {({id, field}) => ( + + + + 不限 + + + + 电信 + + + + 联通 + + + + 移动 + + + )} + +
- // 使用 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) - } + {/* 协议类型 */} +
+ + {({id, field}) => ( + + + + 不限 + + + + HTTP + + + + HTTPS + + + + SOCKS5 + + + )} + +
- toast.success('链接已复制到剪贴板') - break - case 'open': - window.open(params, '_blank') - break - } - } + {/* 认证方式 */} +
+ + {({id, field}) => ( + + + + 白名单 + + + + 密码 + + + )} + +
+ {/* 去重选项 */} +
+ + {({id, field}) => ( + + + + 去重 + + + + 不去重 + + + )} + +
+ + {/* 导出格式 */} +
+ + {({id, field}) => ( + + + + TXT 格式 + + + + JSON 格式 + + + )} + +
+ + {/* 分隔符 */} +
+ + {({id, field}) => ( + + + + 竖线 ( | ) + + + + 冒号 ( : ) + + + + 制表符 ( \t ) + + + )} + +
+ + {/* 换行符 */} +
+ + {({id, field}) => ( + + + + 回车换行 ( \r\n ) + + + + 换行 ( \n ) + + + + 回车 ( \r ) + + + )} + +
+ + {/* 提取数量 */} +
+ + {({id, field}) => ( + field.onChange(Number(e.target.value))} + className="h-10 w-84" + placeholder="输入提取数量" + /> + )} + +
+
+ ) +}) +FormFields.displayName = 'FormFields' + +function SelectResource() { + const [resources, setResources] = useState([]) + const [status, setStatus] = useStatus() const getResources = async () => { setStatus('load') try { @@ -134,11 +290,13 @@ export default function Extract(props: ExtractProps) { if (!resp.success) { throw new Error('Unable to fetch packages.') } + console.log(resp.data) setResources(resp.data) setStatus('done') } catch (error) { console.error('Error fetching packages:', error) + toast.error('获取套餐失败,请稍后再试') setStatus('fail') } } @@ -148,352 +306,267 @@ export default function Extract(props: ExtractProps) { // eslint-disable-next-line react-hooks/exhaustive-deps }, []) - // ====================== - // render - // ====================== - return ( -
{ - const desc: (string | undefined)[] = [] - Object.entries(errors).forEach(([field, error]) => { - if (error.message) { - desc.push(error.message) - } - }) - toast.error('请完成填写:', { - description: desc.map((msg, i) => ( - - {msg} - )), - }) - }} - className={merge( - `bg-white flex flex-col gap-4 rounded-md`, - props.className, - )} - > - - - 提取IP前需要将本机IP添加到白名单后才可使用 - - -
- {/* 选择套餐 */} -
- - {({field}) => ( - field.onChange(Number(value))} + > + + + + + {status === 'load' ? ( +
+ + 加载中... +
+ ) : resources.length === 0 ? ( +
+ + 暂无可用套餐 +
+ ) : resources.map((resource, i) => (<> + +
+ {resource.type === 1 && resource.short.type === 1 && (<> +
+ + {name(resource)}
- - {i < resources.length - 1 && } - )) - } - - - )} - -
- - {/* 地区筛选 */} -
- - {({id, field}) => ( - { - field.onChange(e) - if (e === 'unlimited') { - form.setValue('prov', '') - form.setValue('city', '') - } - }} - defaultValue={field.value} - className="flex gap-4" - > - - - 不限地区 - - - - 指定地区 - - - )} - - - {regionType === 'specific' && ( - { - form.setValue('prov', value[0]) - form.setValue('city', value[1]) - }} - /> - )} -
- - {/* 运营商筛选 */} -
- - {({id, field}) => ( - - - - 不限 - - - - 电信 - - - - 联通 - - - - 移动 - - - )} - -
- - {/* 协议类型 */} -
- - {({id, field}) => ( - - - - 不限 - - - - HTTP - - - - HTTPS - - - - SOCKS5 - - - )} - -
- - {/* 认证方式 */} -
- - {({id, field}) => ( - - - - 白名单 - - - - 密码 - - - )} - -
- - {/* 去重选项 */} -
- - {({id, field}) => ( - - - - 去重 - - - - 不去重 - - - )} - -
- - {/* 导出格式 */} -
- - {({id, field}) => ( - - - - TXT 格式 - - - - JSON 格式 - - - )} - -
- - {/* 分隔符 */} -
- - {({id, field}) => ( - - - - 竖线 ( | ) - - - - 冒号 ( : ) - - - - 制表符 ( \t ) - - - )} - -
- - {/* 换行符 */} -
- - {({id, field}) => ( - - - - 回车换行 ( \r\n ) - - - - 换行 ( \n ) - - - - 回车 ( \r ) - - - )} - -
- - {/* 提取数量 */} -
- - {({id, field}) => ( - field.onChange(Number(e.target.value))} - className="h-10 w-84" - placeholder="输入提取数量" - /> - )} - -
-
- -
- {/* 展示链接地址 */} -
- {params} -
- - {/* 操作 */} -
- - -
-
- +
+ 到期时间:{format(resource.short.expire, 'yyyy-MM-dd HH:mm')} + {intlFormatDistance(resource.short.expire, new Date())} +
+ )} + {resource.type === 1 && resource.short.type === 2 && (<> +
+ + {name(resource)} +
+
+ 提取数量:{resource.short.used} / {resource.short.quota} + 剩余 {resource.short.quota - resource.short.used} +
+ )} + {resource.type === 2 && resource.long.type === 1 && (<> +
+ + {name(resource)} +
+
+ 到期时间:{format(resource.long.expire, 'yyyy-MM-dd HH:mm')} + {intlFormatDistance(resource.long.expire, new Date())} +
+ )} + {resource.type === 2 && resource.long.type === 2 && (<> +
+ + {name(resource)} +
+
+ 提取数量:{resource.long.used} / {resource.long.quota} + 剩余 {resource.long.quota - resource.long.used} +
+ )} +
+ + {i < resources.length - 1 && } + ))} + + + )} + +
) } + +function SelectRegion() { + const form = useFormContext() + const regionType = form.watch('regionType') + const prov = form.watch('prov') + const city = form.watch('city') + + return ( +
+ + {({id, field}) => ( + { + field.onChange(e) + if (e === 'unlimited') { + form.setValue('prov', '') + form.setValue('city', '') + } + }} + defaultValue={field.value} + className="flex gap-4" + > + + + 不限地区 + + + + 指定地区 + + + )} + + + {regionType === 'specific' && ( + { + form.setValue('prov', value[0]) + form.setValue('city', value[1]) + }} + /> + )} +
+ ) +} + +function ApplyLink() { + const form = useFormContext() + const values = form.watch() + + const type = useRef<'copy' | 'open'>('open') + const handler = form.handleSubmit( + async (values: z.infer) => { + 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) => ( + - {msg} + )), + }) + }, + ) + + return ( +
+ {/* 展示链接地址 */} +
+ {link(values)} +
+ + {/* 操作 */} +
+ + +
+
+ ) +} + +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 + } +} diff --git a/src/components/composites/purchase/index.tsx b/src/components/composites/purchase/index.tsx index 420023d..7eaca9f 100644 --- a/src/components/composites/purchase/index.tsx +++ b/src/components/composites/purchase/index.tsx @@ -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 {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 = {} @@ -17,10 +18,10 @@ export default async function Purchase(props: PurchaseProps) { 定制套餐 - + - + diff --git a/src/components/composites/purchase/_client/center.tsx b/src/components/composites/purchase/long/center.tsx similarity index 88% rename from src/components/composites/purchase/_client/center.tsx rename to src/components/composites/purchase/long/center.tsx index 67e7212..7df10e1 100644 --- a/src/components/composites/purchase/_client/center.tsx +++ b/src/components/composites/purchase/long/center.tsx @@ -4,20 +4,15 @@ 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 {PurchaseFormContext, Schema} from '@/components/composites/purchase/_client/form' -import {useContext} from 'react' -import FormOption from '@/components/composites/purchase/_client/option' +import FormOption from '@/components/composites/purchase/option' import Image from 'next/image' 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() { - - const form = useContext(PurchaseFormContext)?.form - if (!form) { - throw new Error(`Center component must be used within PurchaseFormContext`) - } - - const watchType = form.watch('type') + const form = useFormContext() + const type = form.watch('type') return (
@@ -64,17 +59,17 @@ export default function Center() { onValueChange={field.onChange} className={`flex gap-4 flex-wrap`}> - - - - - + + + + + )} {/* 根据套餐类型显示不同表单项 */} - {watchType === '2' ? ( + {type === '2' ? ( /* 包量:IP 购买数量 */ + +type PurchaseFormContextType = { + form: UseFormReturn + onSubmit?: () => void +} + +export const LongFormContext = createContext(undefined) + +export default function LongForm() { + const form = useForm({ + resolver: zodResolver(schema), + defaultValues: { + type: '2', // 默认为包量套餐 + live: '1', // 小时 + quota: 500, + expire: '30', // 天 + daily_limit: 100, + pay_type: 'balance', // 余额支付 + }, + }) + + return ( +
+ +
+ + + + ) +} + diff --git a/src/components/composites/purchase/_client/right.tsx b/src/components/composites/purchase/long/right.tsx similarity index 74% rename from src/components/composites/purchase/_client/right.tsx rename to src/components/composites/purchase/long/right.tsx index 0a341af..99751b9 100644 --- a/src/components/composites/purchase/_client/right.tsx +++ b/src/components/composites/purchase/long/right.tsx @@ -1,56 +1,51 @@ 'use client' 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 {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 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 Pay from '@/components/composites/purchase/_client/pay' +import Pay from '@/components/composites/purchase/pay' import {buttonVariants} from '@/components/ui/button' import Link from 'next/link' import {merge} from '@/lib/utils' +import {useFormContext} from 'react-hook-form' +import {Schema} from '@/components/composites/purchase/long/form' -export type RightProps = {} - -export default function Right(props: RightProps) { +export default function Right() { const profile = useProfileStore(store => store.profile) + const form = useFormContext() - const form = useContext(PurchaseFormContext)?.form - if (!form) { - throw new Error(`Center component must be used within PurchaseFormContext`) - } - - const watchType = form.watch('type') - 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 method = form.watch('pay_type') + const mode = form.watch('type') + const live = form.watch('live') + const quota = form.watch('quota') + const expire = form.watch('expire') + const dailyLimit = form.watch('daily_limit') const price = useMemo(() => { - const count = watchType === '1' ? watchDailyLimit : watchQuota - - let seconds = parseInt(watchLive, 10) * 60 - if (seconds == 180) { - seconds = 150 - } - - let times = parseInt(watchExpire, 10) - if (watchType === '2') { - times = 1 - } - - return count * seconds * times / 30000 - }, [watchDailyLimit, watchExpire, watchLive, watchQuota, watchType]) + const base = { + '1': 30, + '4': 80, + '8': 120, + '12': 180, + '24': 350, + }[live] + const factor = { + '1': Number(expire) * dailyLimit, + '2': quota, + }[mode] + return (base * factor / 100).toFixed(2) + }, [dailyLimit, expire, live, quota, mode]) return (

订单详情

@@ -58,33 +53,33 @@ export default function Right(props: RightProps) {
  • 套餐名称 - {watchType === '2' ? `包量套餐` : `包时套餐`} + {mode === '2' ? `包量套餐` : `包时套餐`}
  • IP 时效 - {watchLive}分钟 + {live} 小时
  • - {watchType === '2' ? ( + {mode === '2' ? (
  • 购买 IP 量 - {watchQuota}个 + {quota}个
  • ) : <>
  • 套餐时长 - {watchExpire}天 + {expire}天
  • 每日限额 - {watchDailyLimit}个 + {dailyLimit}个
  • } @@ -141,12 +136,15 @@ export default function Right(props: RightProps) { )} - : ( diff --git a/src/components/composites/purchase/_client/nav.tsx b/src/components/composites/purchase/nav.tsx similarity index 100% rename from src/components/composites/purchase/_client/nav.tsx rename to src/components/composites/purchase/nav.tsx diff --git a/src/components/composites/purchase/_client/option.tsx b/src/components/composites/purchase/option.tsx similarity index 100% rename from src/components/composites/purchase/_client/option.tsx rename to src/components/composites/purchase/option.tsx diff --git a/src/components/composites/purchase/_client/pay.tsx b/src/components/composites/purchase/pay.tsx similarity index 79% rename from src/components/composites/purchase/_client/pay.tsx rename to src/components/composites/purchase/pay.tsx index 5f896f3..271f0a9 100644 --- a/src/components/composites/purchase/_client/pay.tsx +++ b/src/components/composites/purchase/pay.tsx @@ -1,33 +1,24 @@ 'use client' import {Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle, DialogTrigger} from '@/components/ui/dialog' import {Button} from '@/components/ui/button' -import alipay from '../_assets/alipay.svg' -import wechat from '../_assets/wechat.svg' -import balance from '../_assets/balance.svg' +import alipay from './_assets/alipay.svg' +import wechat from './_assets/wechat.svg' +import balance from './_assets/balance.svg' import Image from 'next/image' -import {useContext, useEffect, useRef, useState} from 'react' -import {StoreContext, useProfileStore} from '@/components/providers/StoreProvider' +import {useEffect, useRef, useState} from 'react' +import {useProfileStore} from '@/components/providers/StoreProvider' import {Alert, AlertDescription} from '@/components/ui/alert' -import { - prepareResourceByAlipay, - prepareResourceByWechat, - CreateResourceReq, - CreateResourceResp, - createResourceByBalance, - createResourceByAlipay, - createResourceByWechat, -} from '@/actions/resource' -import {ApiResponse} from '@/lib/api' +import {ApiResponse, ExtraResp, ExtraReq} from '@/lib/api' import {toast} from 'sonner' import {Loader} from 'lucide-react' import {useRouter} from 'next/navigation' import * as qrcode from 'qrcode' +import {completeResource, createResource, prepareResource} from '@/actions/resource' export type PayProps = { method: 'alipay' | 'wechat' | 'balance' - amount: number - resource: CreateResourceReq - + amount: string + resource: ExtraReq } export default function Pay(props: PayProps) { @@ -36,14 +27,14 @@ export default function Pay(props: PayProps) { const refreshProfile = useProfileStore(store => store.refreshProfile) const [open, setOpen] = useState(false) - const [payInfo, setPayInfo] = useState() + const [payInfo, setPayInfo] = useState | undefined>() const canvas = useRef(null) useEffect(() => { if (canvas.current && payInfo) { qrcode.toCanvas(canvas.current, payInfo.pay_url, { width: 200, margin: 0, - }) + }).then() } }, [payInfo]) @@ -54,15 +45,15 @@ export default function Pay(props: PayProps) { return } - let resp: ApiResponse - switch (props.method) { - case 'alipay': - resp = await prepareResourceByAlipay(props.resource) - break - case 'wechat': - resp = await prepareResourceByWechat(props.resource) - break - } + const method = { + alipay: 1, + wechat: 2, + }[props.method] + + const resp = await prepareResource({ + ...props.resource, + method, + }) if (!resp.success) { toast.error(`创建订单失败: ${resp.message}`) setOpen(false) @@ -78,17 +69,20 @@ export default function Pay(props: PayProps) { try { switch (props.method) { case 'alipay': - resp = await createResourceByAlipay({ - trade_no: payInfo!.trade_no, - }) - break case 'wechat': - resp = await createResourceByWechat({ - trade_no: payInfo!.trade_no, + if (!payInfo) { + toast.error('无法读取支付信息', { + description: `请联系客服确认支付状态`, + }) + return + } + resp = await completeResource({ + trade_no: payInfo.trade_no, }) break + case 'balance': - resp = await createResourceByBalance(props.resource) + resp = await createResource(props.resource) break } @@ -116,6 +110,8 @@ export default function Pay(props: PayProps) { } } + const balanceEnough = profile && profile.balance >= Number(props.amount) + return ( @@ -156,26 +152,24 @@ export default function Pay(props: PayProps) {
    支付后余额 - props.amount ? 'text-done' : `text-fail`}`}> - {profile.balance - props.amount}元 + + {(profile.balance - Number(props.amount)).toFixed(2)}元
    - {profile.balance < props.amount && ( - - - 余额不足,请先充值或选择其他支付方式 - - - )} - - {profile.balance >= props.amount && ( + {balanceEnough ? ( 检查无误后,点击确认支付按钮完成支付 + ) : ( + + + 余额不足,请先充值或选择其他支付方式 + + )}
    ) @@ -205,7 +199,7 @@ export default function Pay(props: PayProps) { + + + + )} + + ) : ( + <> + {/* 包时:套餐时效 */} + + {({id, field}) => ( + + + + + + + + + + )} + + + {/* 包时:每日提取上限 */} + + {({id, field}) => ( +
    + + + +
    + )} +
    + + )} + + {/* 产品特性 */} +
    +

    产品特性

    +
    +

    + {`check`} + 支持高并发提取 +

    +

    + {`check`} + 指定省份、城市或混播 +

    +

    + {`check`} + 账密+白名单验证 +

    +

    + {`check`} + 完备的API接口 +

    +

    + {`check`} + IP时效3-30分钟(可定制) +

    +

    + {`check`} + IP资源定期筛选 +

    +

    + {`check`} + 完备的API接口 +

    +

    + {`check`} + 包量/包时计费方式 +

    +

    + {`check`} + 每日去重量:500万 +

    +
    +
    + + ) +} diff --git a/src/components/composites/purchase/_client/form.tsx b/src/components/composites/purchase/short/form.tsx similarity index 71% rename from src/components/composites/purchase/_client/form.tsx rename to src/components/composites/purchase/short/form.tsx index 73879cf..d1856a2 100644 --- a/src/components/composites/purchase/_client/form.tsx +++ b/src/components/composites/purchase/short/form.tsx @@ -1,8 +1,8 @@ 'use client' import {createContext} from 'react' import {useForm, UseFormReturn} from 'react-hook-form' -import Center from '@/components/composites/purchase/_client/center' -import Right from '@/components/composites/purchase/_client/right' +import Center from '@/components/composites/purchase/short/center' +import Right from '@/components/composites/purchase/short/right' import {Form} from '@/components/ui/form' import * as z from 'zod' import {zodResolver} from '@hookform/resolvers/zod' @@ -10,7 +10,7 @@ import {zodResolver} from '@hookform/resolvers/zod' // 定义表单验证架构 const schema = z.object({ 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个'), expire: z.enum(['7', '15', '30', '90', '180', '365']), daily_limit: z.number().min(2000, '每日限额不能少于2000个'), @@ -34,7 +34,7 @@ export default function PurchaseForm(props: PurchaseFormProps) { resolver: zodResolver(schema), defaultValues: { type: '2', // 默认为包量套餐 - live: '3', // 分钟 + live: '180', // 分钟 quota: 10_000, // >= 10000 expire: '30', // 天 daily_limit: 2_000, // >= 2000 @@ -43,14 +43,10 @@ export default function PurchaseForm(props: PurchaseFormProps) { }) return ( -
    -
    - -
    - - - -
    +
    +
    + + ) } diff --git a/src/components/composites/purchase/short/right.tsx b/src/components/composites/purchase/short/right.tsx new file mode 100644 index 0000000..be3c555 --- /dev/null +++ b/src/components/composites/purchase/short/right.tsx @@ -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() + + 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 ( +
    +

    订单详情

    +
      +
    • + 套餐名称 + + {mode === '2' ? `包量套餐` : `包时套餐`} + +
    • +
    • + IP 时效 + + {Number(live) / 60} 分钟 + +
    • + {mode === '2' ? ( +
    • + 购买 IP 量 + + {quota}个 + +
    • + ) : <> +
    • + 套餐时长 + + {expire}天 + +
    • +
    • + 每日限额 + + {dailyLimit}个 + +
    • + } +
    +
    +

    + 价格 + ¥{price} +

    + {profile ? <> + + {({id, field}) => ( + + +
    +

    + {`余额icon`}/ + 账户余额 +

    +

    + {profile?.balance} + +

    +
    + + + {`余额 + 余额 + + + {`微信 + 微信 + + + {`支付宝 + 支付宝 + +
    + )} +
    + + : ( + + 登录后支付 + + )} +
    + ) +} diff --git a/src/components/composites/recharge/index.tsx b/src/components/composites/recharge/index.tsx index 7d6ea75..84480b6 100644 --- a/src/components/composites/recharge/index.tsx +++ b/src/components/composites/recharge/index.tsx @@ -10,7 +10,7 @@ import {Button} from '@/components/ui/button' import {Form, FormField} from '@/components/ui/form' import {useForm} from 'react-hook-form' 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 Image from 'next/image' import {zodResolver} from '@hookform/resolvers/zod' @@ -75,7 +75,7 @@ export default function RechargeModal(props: RechargeModelProps) { switch (data.method) { case 'alipay': const aliRes = await RechargeByAlipay({ - amount: data.amount, + amount: data.amount.toString(), }) if (aliRes.success) { setStep(1) @@ -89,7 +89,7 @@ export default function RechargeModal(props: RechargeModelProps) { break case 'wechat': const weRes = await RechargeByWechat({ - amount: data.amount, + amount: data.amount.toString(), }) if (weRes.success) { setStep(1) diff --git a/src/components/providers/StoreProvider.tsx b/src/components/providers/StoreProvider.tsx index cc01367..e2f4199 100644 --- a/src/components/providers/StoreProvider.tsx +++ b/src/components/providers/StoreProvider.tsx @@ -3,8 +3,8 @@ import {User} from '@/lib/models' import {createContext, ReactNode, useContext, useRef} from 'react' import {StoreApi} from 'zustand/vanilla' import {useStore} from 'zustand/react' -import {createProfileStore, ProfileStore} from '@/stores/profile' -import {createLayoutStore, LayoutStore} from '@/stores/layout' +import {createProfileStore, ProfileStore} from '@/lib/stores/profile' +import {createLayoutStore, LayoutStore} from '@/lib/stores/layout' export type StoreContextType = { diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx deleted file mode 100644 index 4b1fd0e..0000000 --- a/src/components/ui/command.tsx +++ /dev/null @@ -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) { - return ( - - ) -} - -function CommandDialog({ - title = "Command Palette", - description = "Search for a command to run...", - children, - ...props -}: React.ComponentProps & { - title?: string - description?: string -}) { - return ( - - - {title} - {description} - - - - {children} - - - - ) -} - -function CommandInput({ - className, - ...props -}: React.ComponentProps) { - return ( -
    - - -
    - ) -} - -function CommandList({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandEmpty({ - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandGroup({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandSeparator({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandItem({ - className, - ...props -}: React.ComponentProps) { - return ( - - ) -} - -function CommandShortcut({ - className, - ...props -}: React.ComponentProps<"span">) { - return ( - - ) -} - -export { - Command, - CommandDialog, - CommandInput, - CommandList, - CommandEmpty, - CommandGroup, - CommandItem, - CommandShortcut, - CommandSeparator, -} diff --git a/src/components/ui/table.tsx b/src/components/ui/table.tsx index 1eaf248..1f22238 100644 --- a/src/components/ui/table.tsx +++ b/src/components/ui/table.tsx @@ -23,7 +23,7 @@ function TableHeader({className, ...props}: React.ComponentProps<'thead'>) { return ( ) @@ -33,7 +33,7 @@ function TableBody({className, ...props}: React.ComponentProps<'tbody'>) { return ( ) @@ -57,7 +57,8 @@ function TableRow({className, ...props}: React.ComponentProps<'tr'>) { ) { [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, )} {...props} diff --git a/src/lib/api.ts b/src/lib/api.ts index db53f0f..cc08d07 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -21,7 +21,8 @@ type PageRecord = { list: T[] } -type ExtractData unknown> = Awaited> extends ApiResponse ? D : never +type ExtraReq unknown> = T extends (...args: infer P) => unknown ? P[0] : never +type ExtraResp unknown> = Awaited> extends ApiResponse ? D : never // 预定义错误 const UnauthorizedError = new Error('未授权访问') @@ -32,6 +33,7 @@ export { CLIENT_SECRET, type ApiResponse, type PageRecord, - type ExtractData, + type ExtraReq, + type ExtraResp, UnauthorizedError, } diff --git a/src/lib/models/index.ts b/src/lib/models/index.ts new file mode 100644 index 0000000..14a3a35 --- /dev/null +++ b/src/lib/models/index.ts @@ -0,0 +1,2 @@ +export * from './models' +export * from './resource' diff --git a/src/lib/models.ts b/src/lib/models/models.ts similarity index 70% rename from src/lib/models.ts rename to src/lib/models/models.ts index 756998f..6d9f5b8 100644 --- a/src/lib/models.ts +++ b/src/lib/models/models.ts @@ -1,3 +1,5 @@ +import {Resource} from '@/lib/models' + export type User = { id: number admin_id: number @@ -20,45 +22,6 @@ export type User = { 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 = { id: number user_id: number diff --git a/src/lib/models/resource.ts b/src/lib/models/resource.ts new file mode 100644 index 0000000..9930423 --- /dev/null +++ b/src/lib/models/resource.ts @@ -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 = { + 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 + } : {} + ) diff --git a/src/stores/layout.ts b/src/lib/stores/layout.ts similarity index 100% rename from src/stores/layout.ts rename to src/lib/stores/layout.ts diff --git a/src/stores/profile.ts b/src/lib/stores/profile.ts similarity index 100% rename from src/stores/profile.ts rename to src/lib/stores/profile.ts