710 lines
26 KiB
TypeScript
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>
|
|
);
|
|
}
|