Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
140 changes: 117 additions & 23 deletions frontend/components/common/merchant/merchant-distribute.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
"use client"

import * as React from "react"
import { useState } from "react"
import { useEffect, useState } from "react"
import { toast } from "sonner"
import { Coins } from "lucide-react"
import { Button } from "@/components/ui/button"
Expand All @@ -19,7 +19,18 @@ import { Label } from "@/components/ui/label"
import { Input } from "@/components/ui/input"
import { Textarea } from "@/components/ui/textarea"
import { Spinner } from "@/components/ui/spinner"
import { MerchantService, type MerchantAPIKey } from "@/lib/services"
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from "@/components/ui/alert-dialog"
import { ConfigService, MerchantService, type MerchantAPIKey, type UserPayConfig } from "@/lib/services"
import { useUser } from "@/contexts/user-context"

interface DistributeDialogProps {
/** 自定义触发器 */
Expand All @@ -34,12 +45,38 @@ interface DistributeDialogProps {
*/
export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
const [open, setOpen] = useState(false)
const [confirmOpen, setConfirmOpen] = useState(false)
const [loading, setLoading] = useState(false)
const [userPayConfigs, setUserPayConfigs] = useState<UserPayConfig[]>([])

const [userId, setUserId] = useState("")
const [username, setUsername] = useState("")
const [amount, setAmount] = useState("")
const [remark, setRemark] = useState("")
const { user } = useUser()

useEffect(() => {
const loadUserPayConfigs = async () => {
try {
setUserPayConfigs(await ConfigService.getUserPayConfigs())
} catch {
// The distribution request remains authoritative if the rate preview cannot load.
}
}

loadUserPayConfigs()
}, [])

const distributeRate = userPayConfigs.find(
(config) => user?.pay_level !== undefined && config.level === user.pay_level,
)?.distribute_rate
const distributeRatePercent = distributeRate === undefined
? null
: Number(distributeRate) * 100
const distributionAmount = Number(amount)
const recipientAmount = Number.isFinite(distributionAmount) && distributeRatePercent !== null
? distributionAmount - Math.round(distributionAmount * (distributeRatePercent / 100) * 100) / 100
: null

const resetForm = () => {
setUserId("")
Expand All @@ -52,41 +89,78 @@ export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
if (newOpen && !loading) {
resetForm()
}
if (!newOpen) {
setConfirmOpen(false)
}
setOpen(newOpen)
}

const handleSubmit = async () => {
const validateForm = (): string | null => {
if (!userId.trim()) {
toast.error('表单验证失败', { description: '请填写用户 ID' })
return
return '请填写用户 ID'
}

if (!username.trim()) {
toast.error('表单验证失败', { description: '请填写用户名' })
return
return '请填写用户名'
}

if (!amount.trim()) {
toast.error('表单验证失败', { description: '请填写分发金额' })
return
return '请填写分发金额'
}

const amountNum = parseFloat(amount)
if (isNaN(amountNum) || amountNum <= 0) {
toast.error('表单验证失败', { description: '分发金额必须大于 0' })
return
if (!Number.isFinite(distributionAmount) || distributionAmount <= 0) {
return '分发金额必须大于 0'
}

if (amountNum > 999999.99) {
toast.error('表单验证失败', { description: '分发金额不能超过 999999.99' })
return
if (distributionAmount > 999999.99) {
return '分发金额不能超过 999999.99'
}

if (remark.length > 100) {
toast.error('表单验证失败', { description: '备注不能超过 100 个字符' })
return '备注不能超过 100 个字符'
}

return null
}

const handleDistributeClick = () => {
const validationError = validateForm()
if (validationError) {
toast.error('表单验证失败', { description: validationError })
return
}

setConfirmOpen(true)
}

const handleServiceError = (error: unknown) => {
console.error('积分分发失败:', error)

const errorMessage = error instanceof Error ? error.message : ''
const errorMap: Record<string, string> = {
'收款人不存在': '用户 ID 与用户名不匹配或用户不存在',
'不能转账给自己': '不能向自己的账户分发积分',
'余额不足': '可用积分不足,请减少分发金额后重试',
'已超过每日限额': '今日分发额度已达上限,请明日再试',
'商户信息不存在': '商户账户不可用,请检查应用凭证或联系管理员',
'支付配置不存在': '当前账户的分发配置不可用,请联系管理员',
'金额必须大于0': '分发金额必须大于 0',
'金额小数位数不能超过2位': '分发金额最多保留两位小数',
'认证': '应用凭证无效或已失效,请重新创建应用凭证',
'无法连接': '无法连接到分发服务,请稍后重试',
'请求超时': '分发请求超时,请稍后重试',
'请求频率': '操作过于频繁,请稍后重试',
'权限不足': '当前应用没有分发权限,请检查应用凭证',
'服务器内部错误': '分发服务暂时不可用,请稍后重试',
}
const userMessage = Object.entries(errorMap).find(([key]) =>
errorMessage.includes(key),
)?.[1] || '分发失败,请检查填写内容后重试'

toast.error('分发失败', { description: userMessage })
}

const handleSubmit = async () => {
try {
setLoading(true)

Expand All @@ -95,25 +169,24 @@ export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
return
}

const result = await MerchantService.distribute({
const response = await MerchantService.distribute({
user_id: Number(userId.trim()),
username: username.trim(),
amount: amountNum,
amount: distributionAmount,
remark: remark.trim() || undefined,
}, {
client_id: apiKey.client_id,
client_secret: apiKey.client_secret,
})

toast.success('分发成功', {
description: `订单号: ${ result.trade_no }`
description: `订单号: ${response.data.trade_no}`
})

setOpen(false)
resetForm()
} catch (error: unknown) {
const errorMessage = error instanceof Error ? error.message : '分发失败'
toast.error('分发失败', { description: errorMessage })
handleServiceError(error)
} finally {
setLoading(false)
}
Expand All @@ -138,6 +211,10 @@ export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
</DialogHeader>

<div className="grid gap-4 py-4">
<div className="rounded-md border border-yellow-500/40 bg-yellow-500/10 px-3 py-2 text-xs text-yellow-700 dark:text-yellow-400">
分发费率: {distributeRatePercent === null ? '加载中' : `${distributeRatePercent.toFixed(2)}%`}
</div>

<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="user-id" className="text-xs">
Expand Down Expand Up @@ -208,14 +285,31 @@ export function DistributeDialog({ trigger, apiKey }: DistributeDialogProps) {
</Button>
</DialogClose>
<Button
onClick={(e) => { e.preventDefault(); handleSubmit() }}
onClick={(e) => { e.preventDefault(); handleDistributeClick() }}
disabled={loading}
className="h-8 text-xs"
>
{loading ? <><Spinner /> 分发中</> : '分发'}
</Button>
</DialogFooter>
</DialogContent>

<AlertDialog open={confirmOpen} onOpenChange={(newOpen) => !loading && setConfirmOpen(newOpen)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>确认分发</AlertDialogTitle>
<AlertDialogDescription>
确定分发 {distributionAmount.toFixed(2)} 积分给 {username.trim()} 吗?对方预计将收到 {recipientAmount === null ? '待确认费率' : recipientAmount.toFixed(2)} 积分。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={loading}>取消</AlertDialogCancel>
<AlertDialogAction onClick={handleSubmit} disabled={loading}>
{loading ? <><Spinner /> 分发中</> : '确认分发'}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</Dialog>
)
}
11 changes: 6 additions & 5 deletions frontend/lib/services/merchant/merchant.service.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { AxiosHeaders, type InternalAxiosRequestConfig } from 'axios';
import { BaseService } from '../core/base.service';
import type { ApiResponse } from '../core/types';
import { encodeBase64 } from '../../utils';
import type {
MerchantAPIKey,
Expand Down Expand Up @@ -457,14 +458,14 @@ export class MerchantService extends BaseService {
*
* @example
* ```typescript
* const result = await MerchantService.distribute({
* user_id: 123,
* const response = await MerchantService.distribute({
* user_id: 123,
* username: 'alice',
* amount: 100,
* out_trade_no: 'DIST20251231001',
* remark: '新年奖励'
* });
* console.log('订单号:', result.trade_no);
* console.log('订单号:', response.data.trade_no);
* ```
*
* @remarks
Expand All @@ -477,7 +478,7 @@ export class MerchantService extends BaseService {
static async distribute(
request: MerchantDistributeRequest,
auth?: { client_id: string; client_secret: string }
): Promise<MerchantDistributeResponse> {
): Promise<ApiResponse<MerchantDistributeResponse>> {
const config: InternalAxiosRequestConfig | undefined = auth
? {
headers: new AxiosHeaders({
Expand All @@ -486,6 +487,6 @@ export class MerchantService extends BaseService {
}
: undefined;

return this.rawPost<MerchantDistributeResponse>('/lpay/distribute', request, config);
return this.rawPost<ApiResponse<MerchantDistributeResponse>>('/lpay/distribute', request, config);
}
}
Loading