Files
hi-frontend/apps/user/src/sections/user/affiliate/index.tsx
T
shanshanzhong147 09c5216f60
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
修复(#14): 调整 USDT 提现方式枚举口径
2026-06-09 06:37:15 -07:00

710 lines
26 KiB
TypeScript

"use client";
import { zodResolver } from "@hookform/resolvers/zod";
import { useMutation, useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button";
import {
Card,
CardContent,
CardDescription,
CardHeader,
CardTitle,
} from "@workspace/ui/components/card";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@workspace/ui/components/form";
import { Input } from "@workspace/ui/components/input";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Textarea } from "@workspace/ui/components/textarea";
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
import type { ProListActions } from "@workspace/ui/composed/pro-list/pro-list";
import { ProList } from "@workspace/ui/composed/pro-list/pro-list";
import { UploadImage } from "@workspace/ui/composed/upload-image";
import {
commissionWithdraw,
queryUserAffiliate,
queryUserAffiliateList,
queryWithdrawalLog,
uploadFile,
withdrawalCancel,
} from "@workspace/ui/services/user/user";
import { formatDate } from "@workspace/ui/utils/formatting";
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { Copy, X } from "lucide-react";
import { useRef, useState, useTransition } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next";
import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global";
const WITHDRAWAL_METHODS = [
{ value: 1, label: "支付宝" },
{ value: 2, label: "微信" },
{ value: 3, label: "USDT(TRC20)" },
{ value: 0, label: "其他" },
] as const;
function getWithdrawalMethodLabel(method: number): string {
return WITHDRAWAL_METHODS.find((m) => m.value === method)?.label ?? "未知";
}
function getWithdrawalStatusMeta(
status: number,
t: ReturnType<typeof useTranslation<"affiliate">>["t"]
) {
switch (status) {
case 1:
return {
label: t("withdrawal.status.approved", "Approved"),
className: "bg-emerald-500/10 text-emerald-600",
};
case 2:
return {
label: t("withdrawal.status.rejected", "Rejected"),
className: "bg-destructive/10 text-destructive",
};
case 3:
return {
label: t("withdrawal.status.cancelled", "Cancelled"),
className: "bg-muted text-muted-foreground",
};
default:
return {
label: t("withdrawal.status.pending", "Pending"),
className: "bg-amber-500/10 text-amber-600",
};
}
}
function QrCodeView({ url }: { url: string }) {
const [zoomOpen, setZoomOpen] = useState(false);
return (
<>
<button
className="block overflow-hidden rounded border"
onClick={() => setZoomOpen(true)}
type="button"
>
<img
alt="收款码"
className="h-16 w-16 cursor-zoom-in object-cover"
height={64}
src={url}
width={64}
/>
</button>
{zoomOpen && (
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
<DialogContent className="max-w-sm">
<img
alt="收款码"
className="w-full"
height={320}
src={url}
width={320}
/>
</DialogContent>
</Dialog>
)}
</>
);
}
const withdrawalFormSchema = z
.object({
amount: z.number(),
method: z.number(),
account: z.string().trim().max(200).optional(),
qr_code_url: z.string().optional(),
content: z.string().trim().max(1000).optional(),
})
.superRefine((data, ctx) => {
if (!data.amount || data.amount <= 0) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "提现金额必须大于 0",
path: ["amount"],
});
}
if (data.method === 1 || data.method === 2) {
if (!data.qr_code_url) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "请上传收款码",
path: ["qr_code_url"],
});
}
} else if (data.method === 3) {
if (!data.account) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "USDT(TRC20) 地址为必填",
path: ["account"],
});
}
} else if (data.method === 0 && !(data.account || data.content)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "收款账号和备注至少填一项",
path: ["account"],
});
}
});
type WithdrawalFormValues = z.infer<typeof withdrawalFormSchema>;
export default function Affiliate() {
const { t } = useTranslation("affiliate");
const { user, common, getUserInfo } = useGlobalStore();
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [submitting, startTransition] = useTransition();
const [qrUploading, setQrUploading] = useState(false);
const withdrawalListActionRef = useRef<ProListActions | undefined>(undefined);
const affiliateQuery = useQuery({
queryKey: ["queryUserAffiliate"],
queryFn: async () => {
const response = await queryUserAffiliate();
return response.data.data;
},
});
const availableCommission = Number(
user?.commission ?? affiliateQuery.data?.total_commission ?? 0
);
const withdrawalForm = useForm<WithdrawalFormValues>({
resolver: zodResolver(withdrawalFormSchema),
defaultValues: {
amount: 0,
method: 1,
account: "",
qr_code_url: "",
content: "",
},
});
const watchedMethod = withdrawalForm.watch("method");
const watchedQrCodeUrl = withdrawalForm.watch("qr_code_url");
const handleQrUpload = async (file: string | File) => {
if (!(file instanceof File)) return;
setQrUploading(true);
try {
const formData = new FormData();
formData.append("file", file);
formData.append("biz_type", "withdrawal_qrcode");
const response = await uploadFile(formData);
const uploadedUrl =
response.data.data?.url || response.data.data?.key || "";
withdrawalForm.setValue("qr_code_url", uploadedUrl);
withdrawalForm.clearErrors("qr_code_url");
} catch {
toast.error("收款码上传失败,请重试");
} finally {
setQrUploading(false);
}
};
const cancelMutation = useMutation({
mutationFn: async (withdrawalId: number) =>
withdrawalCancel({ withdrawal_id: withdrawalId }),
onSuccess: () => {
toast.success("提现申请已取消");
withdrawalListActionRef.current?.refresh();
},
onError: () => {
toast.error("取消失败,请重试");
},
});
const submitWithdrawal = (values: WithdrawalFormValues) => {
if (values.amount > availableCommission) {
withdrawalForm.setError("amount", {
message: t(
"withdrawal.validation.amountExceeded",
"Amount cannot exceed available commission"
),
});
return;
}
startTransition(async () => {
await commissionWithdraw({
amount: values.amount,
method: values.method,
account: values.account || undefined,
qr_code_url: values.qr_code_url || undefined,
content: values.content || undefined,
});
toast.success(
t("withdrawal.submitSuccess", "Withdrawal request submitted")
);
await Promise.all([
affiliateQuery.refetch(),
getUserInfo(),
withdrawalListActionRef.current?.refresh(),
]);
setWithdrawalOpen(false);
withdrawalForm.reset({
amount: 0,
method: 1,
account: "",
qr_code_url: "",
content: "",
});
});
};
return (
<div className="flex flex-col gap-4">
<Card>
<CardHeader>
<CardTitle>{t("totalCommission", "Total Commission")}</CardTitle>
<CardDescription>
{t("commissionInfo", "Commission Info")}
</CardDescription>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div className="flex items-baseline gap-2">
<span className="font-bold text-3xl">
<Display
type="currency"
value={affiliateQuery.data?.total_commission}
/>
</span>
<span className="text-muted-foreground text-sm">
({t("commissionRate", "Commission Rate")}:{" "}
{user?.referral_percentage ||
common?.invite?.referral_percentage}
%)
</span>
</div>
<div className="flex flex-col items-start gap-2 md:items-end">
<div className="text-muted-foreground text-sm">
{t(
"withdrawal.availableCommission",
"Available for withdrawal"
)}
</div>
<div className="font-semibold text-xl">
<Display type="currency" value={availableCommission} />
</div>
<Dialog onOpenChange={setWithdrawalOpen} open={withdrawalOpen}>
<DialogTrigger asChild>
<Button>
{t("withdrawal.apply", "Apply for Withdrawal")}
</Button>
</DialogTrigger>
<DialogContent className="max-h-[90vh] overflow-y-auto">
<DialogHeader>
<DialogTitle>
{t("withdrawal.applyTitle", "Apply for Withdrawal")}
</DialogTitle>
<DialogDescription>
{t(
"withdrawal.applyDescription",
"Enter the withdrawal amount and payout details."
)}
</DialogDescription>
</DialogHeader>
<Form {...withdrawalForm}>
<form
className="space-y-4"
id="withdrawal-form"
onSubmit={withdrawalForm.handleSubmit(submitWithdrawal)}
>
<FormField
control={withdrawalForm.control}
name="amount"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("withdrawal.amount", "Amount")}
</FormLabel>
<FormControl>
<Input
inputMode="decimal"
min="0"
onChange={(event) => {
const cents = unitConversion(
"dollarsToCents",
Number(event.target.value || 0)
);
field.onChange(Number(cents) || 0);
}}
placeholder={t(
"withdrawal.amountPlaceholder",
"Enter withdrawal amount"
)}
step="0.01"
type="number"
value={
field.value
? unitConversion(
"centsToDollars",
Number(field.value)
)
: ""
}
/>
</FormControl>
<p className="text-muted-foreground text-xs">
{t("withdrawal.amountHint", "Available")}:{" "}
<Display
type="currency"
value={availableCommission}
/>
</p>
<FormMessage />
</FormItem>
)}
/>
<FormField
control={withdrawalForm.control}
name="method"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<Select
onValueChange={(val) => {
field.onChange(Number(val));
withdrawalForm.setValue("qr_code_url", "");
withdrawalForm.setValue("account", "");
withdrawalForm.clearErrors();
}}
value={String(field.value)}
>
<FormControl>
<SelectTrigger>
<SelectValue placeholder="请选择收款方式" />
</SelectTrigger>
</FormControl>
<SelectContent>
{WITHDRAWAL_METHODS.map((m) => (
<SelectItem
key={m.value}
value={String(m.value)}
>
{m.label}
</SelectItem>
))}
</SelectContent>
</Select>
<FormMessage />
</FormItem>
)}
/>
{(watchedMethod === 1 || watchedMethod === 2) && (
<FormField
control={withdrawalForm.control}
name="qr_code_url"
render={() => (
<FormItem>
<FormLabel>
<span className="ml-1 text-destructive">*</span>
</FormLabel>
{watchedQrCodeUrl ? (
<div className="relative inline-block">
<QrCodeView url={watchedQrCodeUrl} />
<button
className="-top-2 -right-2 absolute flex h-5 w-5 items-center justify-center rounded-full bg-destructive text-destructive-foreground"
onClick={() =>
withdrawalForm.setValue("qr_code_url", "")
}
type="button"
>
<X className="h-3 w-3" />
</button>
</div>
) : (
<UploadImage
className="h-24 w-24"
id="qr-code-upload"
onChange={handleQrUpload}
returnType="file"
>
<div className="flex h-24 w-24 flex-col items-center justify-center rounded border-2 border-dashed text-muted-foreground text-xs">
{qrUploading
? "上传中..."
: "点击上传\n收款码"}
</div>
</UploadImage>
)}
<FormMessage />
</FormItem>
)}
/>
)}
{(watchedMethod === 3 || watchedMethod === 0) && (
<FormField
control={withdrawalForm.control}
name="account"
render={({ field }) => (
<FormItem>
<FormLabel>
{watchedMethod === 3 && (
<span className="ml-1 text-destructive">
*
</span>
)}
</FormLabel>
<FormControl>
<Input
placeholder={
watchedMethod === 3
? "请输入 USDT(TRC20) 收款地址"
: "请输入收款账号(与备注至少填一项)"
}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={withdrawalForm.control}
name="content"
render={({ field }) => (
<FormItem>
<FormLabel></FormLabel>
<FormControl>
<Textarea
placeholder={
watchedMethod === 0
? "备注(与账号至少填一项)"
: "备注(可选)"
}
rows={3}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
disabled={submitting}
onClick={() => setWithdrawalOpen(false)}
variant="outline"
>
{t("withdrawal.cancel", "Cancel")}
</Button>
<Button
disabled={
submitting || qrUploading || availableCommission <= 0
}
form="withdrawal-form"
type="submit"
>
{submitting
? t("withdrawal.submitting", "Submitting...")
: t("withdrawal.submit", "Submit Withdrawal")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</div>
</CardContent>
</Card>
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<CardTitle className="font-medium text-lg">
{t("inviteCode", "Invite Code")}
</CardTitle>
</CardHeader>
<CardContent>
<div className="flex items-center justify-between">
<code className="rounded bg-muted px-2 py-1 font-bold text-2xl">
{user?.refer_code}
</code>
<CopyToClipboard
onCopy={(_, result) => {
if (result) {
toast.success(t("copySuccess", "Copy Success"));
}
}}
text={`${location?.origin}/#/auth?invite=${user?.refer_code}`}
>
<Button className="gap-2" size="sm" variant="secondary">
<Copy className="h-4 w-4" />
{t("copyInviteLink", "Copy Invite Link")}
</Button>
</CopyToClipboard>
</div>
</CardContent>
</Card>
<ProList<API.WithdrawalLog, Record<string, unknown>>
action={withdrawalListActionRef}
header={{
title: t("withdrawal.records", "Withdrawal Records"),
}}
renderItem={(item) => {
const statusMeta = getWithdrawalStatusMeta(item.status, t);
return (
<Card className="overflow-hidden">
<CardContent className="space-y-3 p-4 text-sm">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="font-semibold text-lg">
<Display type="currency" value={item.amount} />
</div>
<div className="flex items-center gap-2">
<span
className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
>
{statusMeta.label}
</span>
{item.status === 0 && (
<ConfirmButton
cancelText="取消"
confirmText="确认取消"
description="取消后该提现申请将关闭,佣金不会退还。确定取消吗?"
onConfirm={async () => {
await cancelMutation.mutateAsync(item.id);
}}
title="取消提现申请"
trigger={
<Button
disabled={cancelMutation.isPending}
size="sm"
variant="outline"
>
</Button>
}
/>
)}
</div>
</div>
<ul className="grid gap-3 md:grid-cols-2">
<li className="flex flex-col gap-1">
<span className="text-muted-foreground"></span>
<span>{getWithdrawalMethodLabel(item.method)}</span>
</li>
{item.account && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground"></span>
<span className="break-all">{item.account}</span>
</li>
)}
{item.qr_code_url && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground"></span>
<QrCodeView url={item.qr_code_url} />
</li>
)}
{item.content && (
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">
{t("withdrawal.record.content", "Payout Information")}
</span>
<span className="whitespace-pre-wrap break-words">
{item.content}
</span>
</li>
)}
<li className="flex flex-col gap-1">
<span className="text-muted-foreground">
{t("withdrawal.record.createdAt", "Created At")}
</span>
<time>{formatDate(item.created_at)}</time>
</li>
{item.reason && (
<li className="flex flex-col gap-1 md:col-span-2">
<span className="text-muted-foreground">
{t("withdrawal.record.reason", "Reject Reason")}
</span>
<span className="whitespace-pre-wrap break-words text-destructive">
{item.reason}
</span>
</li>
)}
</ul>
</CardContent>
</Card>
);
}}
request={async (pagination) => {
const response = await queryWithdrawalLog({
page: pagination.page,
size: pagination.size,
});
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
/>
<ProList<API.UserAffiliate, Record<string, unknown>>
header={{
title: t("inviteRecords", "Invite Records"),
}}
renderItem={(item) => (
<Card className="overflow-hidden">
<CardContent className="p-3 text-sm">
<ul className="grid grid-cols-2 gap-3 *:flex *:flex-col">
<li className="font-semibold">
<span className="text-muted-foreground">
{t("userIdentifier", "User Identifier")}
</span>
<span>{item.identifier}</span>
</li>
<li className="font-semibold">
<span className="text-muted-foreground">
{t("registrationTime", "Registration Time")}
</span>
<time>{formatDate(item.registered_at)}</time>
</li>
</ul>
</CardContent>
</Card>
)}
request={async (pagination, filter) => {
const response = await queryUserAffiliateList({
...pagination,
...filter,
});
return {
list: response.data.data?.list || [],
total: response.data.data?.total || 0,
};
}}
/>
</div>
);
}