完善支付功能,添加支付宝的准备与创建逻辑

This commit is contained in:
2025-04-16 18:51:17 +08:00
parent d435d98887
commit 9a438491be
9 changed files with 258 additions and 86 deletions

View File

@@ -21,28 +21,47 @@ async function allResource(){
return callByUser<Resource[]>('/api/resource/all')
}
async function createResourceByBalance(props: {
type CreateResourceReq = {
type: number
live: number
quota: number
expire: number
daily_limit: number
}) {
}
type CreateResourceResp = {
trade_no: string
pay_url: string
}
async function createResourceByBalance(props: CreateResourceReq) {
return await callByUser('/api/resource/create/balance', props)
}
async function createResourceByAlipay() {
throw new Error('Not implemented')
async function prepareResourceByAlipay(props: CreateResourceReq) {
return await callByUser<CreateResourceResp>('/api/resource/prepare/alipay', props)
}
async function createResourceByWechat() {
throw new Error('Not implemented')
async function prepareResourceByWechat(props: CreateResourceReq) {
return await callByUser<CreateResourceResp>('/api/resource/prepare/wechat', props)
}
async function createResourceByAlipay(props: CreateResourceReq) {
return await callByUser('/api/resource/create/alipay', props)
}
async function createResourceByWechat(props: CreateResourceReq) {
return await callByUser('/api/resource/create/wechat', props)
}
export {
listResourcePss,
allResource,
prepareResourceByAlipay,
prepareResourceByWechat,
createResourceByBalance,
createResourceByAlipay,
createResourceByWechat,
type CreateResourceReq,
type CreateResourceResp,
}

View File

@@ -1,27 +0,0 @@
'use server'
import { callByUser } from "@/actions/base"
export async function tradeRecharge(props: {
amount: number
method: string
}) {
let method: number
switch (props.method) {
case 'alipay':
method = 1
break
case 'wechat':
method = 2
break
default:
throw new Error(`${props.method} is not a valid method`)
}
return await callByUser('/api/trade/create', {
subject: '余额充值',
amount: Number(props.amount * 100),
method: method,
})
}

View File

@@ -247,7 +247,7 @@ export default function BillsPage(props: BillsPageProps) {
<div className={`flex flex-col gap-1`}>
<span>{row.original.info}</span>
{row.original.type === 1 && (
{row.original.type === 1 && row.original.trade.status === 1 && (
<Link
href={`/admin/resources?resource_no=${row.original.resource.resource_no}`}
className={`text-sm text-blue-500 hover:underline`}>

View File

@@ -270,7 +270,7 @@ export default function ResourcesPage(props: ResourcesPageProps) {
<div className={`flex gap-1`}>
{isAfter(row.original.pss.expire, new Date())
? <span className={`text-green-500`}></span>
: <span className={`text-red-500`}></span>}
: <span className={`text-red-500`}></span>}
<span>|</span>
<span>{row.original.pss.daily_used} / {row.original.pss.daily_limit}</span>
<span>|</span>

View File

@@ -27,6 +27,7 @@ export type Schema = z.infer<typeof schema>
type PurchaseFormContextType = {
form: UseFormReturn<Schema>
onSubmit?: () => void
}
export const PurchaseFormContext = createContext<PurchaseFormContextType | undefined>(undefined)
@@ -35,7 +36,6 @@ export type PurchaseFormProps = {}
export default function PurchaseForm(props: PurchaseFormProps) {
console.log('PurchaseForm render')
const authCtx = useContext(AuthContext)
const form = useForm<Schema>({
resolver: zodResolver(schema),
@@ -49,48 +49,9 @@ export default function PurchaseForm(props: PurchaseFormProps) {
},
})
const router = useRouter()
const toExtract = () => {
router.push('/admin/extract')
}
const onSubmit = async (value: Schema) => {
try {
const resp = await createResourceByBalance({
type: Number(value.type),
live: Number(value.live) * 60,
quota: Number(value.quota),
expire: Number(value.expire) * 24 * 3600,
daily_limit: Number(value.daily_limit),
})
if (!resp.success) {
throw new Error(resp.message)
}
toast.success('购买成功', {
duration: 10 * 1000,
closeButton: true,
action: {
label: `去提取`,
onClick: toExtract,
},
})
await authCtx.refreshProfile()
}
catch (e) {
console.log(e)
toast.error('购买失败', {
description: (e as Error).message,
})
}
}
return (
<section role={`tabpanel`} className={`bg-white rounded-lg`}>
<Form form={form} onSubmit={onSubmit} className={`flex flex-row`}>
<Form form={form} className={`flex flex-row`}>
<PurchaseFormContext.Provider value={{form}}>
<Left/>
<Center/>

View File

@@ -0,0 +1,210 @@
'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 Image from 'next/image'
import {useContext, useState} from 'react'
import {AuthContext} from '@/components/providers/AuthProvider'
import {Alert, AlertDescription} from '@/components/ui/alert'
import {
prepareResourceByAlipay,
prepareResourceByWechat,
CreateResourceReq,
CreateResourceResp,
createResourceByBalance,
createResourceByAlipay,
createResourceByWechat,
} from '@/actions/resource'
import {ApiResponse} from '@/lib/api'
import {toast} from 'sonner'
import {Loader} from 'lucide-react'
import {useRouter} from 'next/navigation'
export type PayProps = {
method: 'alipay' | 'wechat' | 'balance'
amount: number
resource: CreateResourceReq
}
export default function Pay(props: PayProps) {
const ctx = useContext(AuthContext)
const [open, setOpen] = useState(false)
const [payInfo, setPayInfo] = useState<CreateResourceResp | undefined>()
const onOpen = async () => {
setOpen(true)
if (props.method === 'balance') {
return
}
let resp: ApiResponse<CreateResourceResp>
switch (props.method) {
case 'alipay':
resp = await prepareResourceByAlipay(props.resource)
break
case 'wechat':
resp = await prepareResourceByWechat(props.resource)
break
}
if (!resp.success) {
toast.error(`创建订单失败: ${resp.message}`)
setOpen(false)
return
}
setPayInfo(resp.data)
}
const router = useRouter()
const onSubmit = async () => {
let resp: ApiResponse
try {
switch (props.method) {
case 'alipay':
resp = await createResourceByAlipay(props.resource)
break
case 'wechat':
resp = await createResourceByWechat(props.resource)
break
case 'balance':
resp = await createResourceByBalance(props.resource)
break
}
if (!resp.success) {
throw new Error(resp.message)
}
toast.success('购买成功', {
duration: 10 * 1000,
closeButton: true,
action: {
label: `去提取`,
onClick: () => router.push('/admin/extract'),
},
})
setOpen(false)
await ctx.refreshProfile()
}
catch (e) {
console.log(e)
toast.error('购买失败', {
description: (e as Error).message,
})
}
}
return (
<Dialog open={open} onOpenChange={setOpen}>
<DialogTrigger asChild>
<Button className={`mt-4 h-12`} onClick={onOpen}>
</Button>
</DialogTrigger>
<DialogContent>
<DialogHeader>
<DialogTitle className={`flex gap-2 items-center`}>
{props.method === 'alipay' && (<>
<Image src={alipay} alt={`支付宝`} width={20} height={20}/>
<span></span>
</>)}
{props.method === 'wechat' && (<>
<Image src={wechat} alt={`微信`} width={20} height={20}/>
<span></span>
</>)}
{props.method === 'balance' && (<>
<Image src={balance} alt={`余额`} width={20} height={20}/>
<span></span>
</>)}
</DialogTitle>
</DialogHeader>
{props.method === 'balance' ? (
ctx.profile && (
<div className="flex flex-col gap-4">
<div className="flex flex-col gap-1">
<div className="flex justify-between items-center">
<span className="text-weak text-sm"></span>
<span className={`text-lg`}>{ctx.profile.balance}</span>
</div>
<div className="flex justify-between items-center">
<span className="text-weak text-sm"></span>
<span className="text-lg text-accent">- {props.amount}</span>
</div>
<hr className="my-2"/>
<div className="flex justify-between items-center">
<span className="text-weak text-sm"></span>
<span className={`text-lg ${ctx.profile.balance > props.amount ? 'text-done' : `text-fail`}`}>
{ctx.profile.balance - props.amount}
</span>
</div>
</div>
{ctx.profile.balance < props.amount && (
<Alert variant="fail">
<AlertDescription>
</AlertDescription>
</Alert>
)}
{ctx.profile.balance >= props.amount && (
<Alert>
<AlertDescription>
</AlertDescription>
</Alert>
)}
</div>
)
) : (
<div className="flex flex-col items-center gap-4">
<div className="flex flex-col items-center gap-3">
<div className="bg-gray-100 w-52 h-52 flex items-center justify-center">
{payInfo ? (
<iframe
src={payInfo.pay_url}
className="w-full h-full"
title="支付二维码"
/>
) : (
<Loader size={40} className={`animate-spin`}/>
)}
</div>
<p className="text-sm text-gray-600 text-center">
使{props.method === 'alipay' ? '支付宝' : '微信'}
</p>
</div>
<div className="text-center space-y-1">
<p className="font-medium">: <span className="text-accent">{props.amount}</span></p>
<p className="text-xs text-gray-500">: {payInfo?.trade_no || '创建订单中...'}</p>
</div>
</div>
)}
<DialogFooter>
<Button
type="button"
disabled={props.method === 'balance' && !!ctx.profile && ctx.profile.balance < props.amount}
onClick={onSubmit}
>
{props.method === 'balance' ? '确认支付' : '已完成支付'}
</Button>
<Button
type="button"
theme="outline"
onClick={() => setOpen(false)}
>
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
)
}

View File

@@ -13,12 +13,11 @@ import zod from 'zod'
import FormOption from '@/components/composites/purchase/_client/option'
import {RadioGroup} from '@/components/ui/radio-group'
import Image from 'next/image'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
import {zodResolver} from '@hookform/resolvers/zod'
import {tradeRecharge} from '@/actions/trade'
import {toast} from 'sonner'
import {useRouter} from 'next/navigation'
import wechat from '@/components/composites/purchase/_assets/wechat.svg'
import alipay from '@/components/composites/purchase/_assets/alipay.svg'
const schema = zod.object({
method: zod.enum(['alipay', 'wechat']),
@@ -43,10 +42,10 @@ export default function RechargeModal(props: RechargeModelProps) {
const onSubmit = async (data: Schema) => {
try {
const resp = await tradeRecharge(data)
if (!resp.success) {
throw new Error(resp.message)
}
// const resp = await tradeRecharge(data)
// if (!resp.success) {
// throw new Error(resp.message)
// }
// todo 跳转支付页
router.push('/pay')

View File

@@ -11,6 +11,7 @@ import balance from '@/components/composites/purchase/_assets/balance.svg'
import {Button} from '@/components/ui/button'
import {AuthContext} from '@/components/providers/AuthProvider'
import RechargeModal from '@/components/composites/purchase/_client/recharge'
import Pay from '@/components/composites/purchase/_client/pay'
export type RightProps = {}
@@ -30,6 +31,7 @@ export default function Right(props: RightProps) {
const watchQuota = form.watch('quota')
const watchExpire = form.watch('expire')
const watchDailyLimit = form.watch('daily_limit')
const payType = form.watch('pay_type')
return (
<div className={`flex-none basis-80 p-6 flex flex-col gap-6`}>
@@ -119,9 +121,13 @@ export default function Right(props: RightProps) {
</RadioGroup>
)}
</FormField>
<Button className={`mt-4 h-12`} type="submit">
</Button>
<Pay method={payType} amount={100} resource={{
type: Number(watchType),
live: Number(watchLive) * 60,
quota: watchQuota,
expire: Number(watchExpire) * 24 * 3600,
daily_limit: watchDailyLimit,
}}/>
</div>
)
}

View File

@@ -15,6 +15,7 @@ type ApiResponse<T = undefined> = {
data: T
}
type PageRecord<T = unknown> = {
total: number
page: number
@@ -22,6 +23,8 @@ type PageRecord<T = unknown> = {
list: T[]
}
type ExtractData<T extends (...args: never) => unknown> = Awaited<ReturnType<T>> extends ApiResponse<infer D> ? D : never
// 预定义错误
const UnauthorizedError = new Error('未授权访问')
@@ -31,5 +34,6 @@ export {
CLIENT_SECRET,
type ApiResponse,
type PageRecord,
type ExtractData,
UnauthorizedError,
}