394 lines
11 KiB
TypeScript
394 lines
11 KiB
TypeScript
"use client";
|
||
|
||
import { zodResolver } from "@hookform/resolvers/zod";
|
||
import { useMutation } from "@tanstack/react-query";
|
||
import { Badge } from "@workspace/ui/components/badge";
|
||
import { Button } from "@workspace/ui/components/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogDescription,
|
||
DialogFooter,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
} from "@workspace/ui/components/dialog";
|
||
import {
|
||
Form,
|
||
FormControl,
|
||
FormField,
|
||
FormItem,
|
||
FormLabel,
|
||
FormMessage,
|
||
} from "@workspace/ui/components/form";
|
||
import { Textarea } from "@workspace/ui/components/textarea";
|
||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||
import {
|
||
ProTable,
|
||
type ProTableActions,
|
||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||
import {
|
||
approveWithdrawal,
|
||
getWithdrawalList,
|
||
rejectWithdrawal,
|
||
} from "@workspace/ui/services/admin/withdrawal";
|
||
import { useRef, useState } from "react";
|
||
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 { UserDetail } from "@/sections/user/user-detail";
|
||
import { formatDate } from "@/utils/common";
|
||
|
||
const WITHDRAWAL_METHOD_MAP: Record<number, string> = {
|
||
0: "其他",
|
||
1: "支付宝",
|
||
2: "微信",
|
||
3: "USDT(TRC20)",
|
||
};
|
||
|
||
const rejectSchema = z.object({
|
||
reason: z
|
||
.string()
|
||
.trim()
|
||
.min(2, "请填写拒绝原因")
|
||
.max(500, "拒绝原因不能超过 500 个字符"),
|
||
});
|
||
|
||
function toOptionalNumber(value?: string) {
|
||
if (!value) return;
|
||
const parsed = Number(value);
|
||
return Number.isFinite(parsed) ? parsed : undefined;
|
||
}
|
||
|
||
function QrCodeCell({ url }: { url?: string }) {
|
||
const [zoomOpen, setZoomOpen] = useState(false);
|
||
if (!url) return <span>--</span>;
|
||
return (
|
||
<>
|
||
<button
|
||
className="block overflow-hidden rounded border"
|
||
onClick={() => setZoomOpen(true)}
|
||
type="button"
|
||
>
|
||
<img
|
||
alt="收款码"
|
||
className="h-12 w-12 cursor-zoom-in object-cover"
|
||
height={48}
|
||
src={url}
|
||
width={48}
|
||
/>
|
||
</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>
|
||
)}
|
||
</>
|
||
);
|
||
}
|
||
|
||
export default function WithdrawalPage() {
|
||
const { t } = useTranslation("user");
|
||
const ref = useRef<ProTableActions>(null);
|
||
const [rejectOpen, setRejectOpen] = useState(false);
|
||
const [currentRow, setCurrentRow] = useState<API.AdminWithdrawalLog | null>(
|
||
null
|
||
);
|
||
const form = useForm<z.infer<typeof rejectSchema>>({
|
||
resolver: zodResolver(rejectSchema),
|
||
defaultValues: {
|
||
reason: "",
|
||
},
|
||
});
|
||
|
||
const approveMutation = useMutation({
|
||
mutationFn: async (withdrawalId: number) =>
|
||
approveWithdrawal({ withdrawal_id: withdrawalId }),
|
||
onSuccess: () => {
|
||
toast.success("提现申请已通过");
|
||
ref.current?.refresh();
|
||
},
|
||
onError: (error) => {
|
||
console.error("Approve withdrawal failed", error);
|
||
toast.error("提现通过失败");
|
||
},
|
||
});
|
||
|
||
const rejectMutation = useMutation({
|
||
mutationFn: async (values: { withdrawal_id: number; reason: string }) =>
|
||
rejectWithdrawal(values),
|
||
onSuccess: () => {
|
||
toast.success("提现申请已拒绝");
|
||
setRejectOpen(false);
|
||
setCurrentRow(null);
|
||
form.reset({ reason: "" });
|
||
ref.current?.refresh();
|
||
},
|
||
onError: (error) => {
|
||
console.error("Reject withdrawal failed", error);
|
||
toast.error("提现拒绝失败");
|
||
},
|
||
});
|
||
|
||
const statusOptions = [
|
||
{ value: "0", label: "审核中" },
|
||
{ value: "1", label: "已通过" },
|
||
{ value: "2", label: "已拒绝" },
|
||
{ value: "3", label: "已取消" },
|
||
];
|
||
|
||
const methodOptions = [
|
||
{ value: "1", label: "支付宝" },
|
||
{ value: "2", label: "微信" },
|
||
{ value: "3", label: "USDT(TRC20)" },
|
||
{ value: "0", label: "其他" },
|
||
];
|
||
|
||
const statusMap: Record<number, { label: string; className: string }> = {
|
||
0: {
|
||
label: "审核中",
|
||
className: "bg-amber-100 text-amber-700 border-amber-200",
|
||
},
|
||
1: {
|
||
label: "已通过",
|
||
className: "bg-emerald-100 text-emerald-700 border-emerald-200",
|
||
},
|
||
2: {
|
||
label: "已拒绝",
|
||
className: "bg-rose-100 text-rose-700 border-rose-200",
|
||
},
|
||
3: {
|
||
label: "已取消",
|
||
className: "bg-gray-100 text-gray-500 border-gray-200",
|
||
},
|
||
};
|
||
|
||
return (
|
||
<>
|
||
<ProTable<
|
||
API.AdminWithdrawalLog,
|
||
{ status?: string; user_id?: string; method?: string }
|
||
>
|
||
action={ref}
|
||
actions={{
|
||
render: (row) => {
|
||
if (row.status !== 0) return [];
|
||
|
||
return [
|
||
<ConfirmButton
|
||
cancelText={t("cancel", "Cancel")}
|
||
confirmText={t("confirm", "Confirm")}
|
||
description="确认后将标记该提现为已通过,请确保线下打款流程已完成。"
|
||
key="approve"
|
||
onConfirm={async () => {
|
||
await approveMutation.mutateAsync(row.id);
|
||
}}
|
||
title="确认通过提现吗?"
|
||
trigger={
|
||
<Button
|
||
disabled={approveMutation.isPending}
|
||
size="sm"
|
||
variant="default"
|
||
>
|
||
通过
|
||
</Button>
|
||
}
|
||
/>,
|
||
<Button
|
||
key="reject"
|
||
onClick={() => {
|
||
setCurrentRow(row);
|
||
setRejectOpen(true);
|
||
}}
|
||
size="sm"
|
||
variant="destructive"
|
||
>
|
||
拒绝
|
||
</Button>,
|
||
];
|
||
},
|
||
}}
|
||
columns={[
|
||
{
|
||
accessorKey: "id",
|
||
header: "ID",
|
||
},
|
||
{
|
||
accessorKey: "user_id",
|
||
header: t("user", "User"),
|
||
cell: ({ row }) => <UserDetail id={row.original.user_id} />,
|
||
},
|
||
{
|
||
accessorKey: "amount",
|
||
header: "提现金额",
|
||
cell: ({ row }) => (
|
||
<Display type="currency" value={row.original.amount} />
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "method",
|
||
header: "收款方式",
|
||
cell: ({ row }) =>
|
||
WITHDRAWAL_METHOD_MAP[row.original.method] ?? "--",
|
||
},
|
||
{
|
||
accessorKey: "account",
|
||
header: "收款账号",
|
||
cell: ({ row }) => (
|
||
<div className="max-w-[200px] break-all text-sm">
|
||
{row.original.account || "--"}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "qr_code_url",
|
||
header: "收款码",
|
||
cell: ({ row }) => <QrCodeCell url={row.original.qr_code_url} />,
|
||
},
|
||
{
|
||
accessorKey: "content",
|
||
header: "备注",
|
||
cell: ({ row }) => (
|
||
<div className="max-w-[200px] break-all text-sm">
|
||
{row.original.content || "--"}
|
||
</div>
|
||
),
|
||
},
|
||
{
|
||
accessorKey: "status",
|
||
header: "状态",
|
||
cell: ({ row }) => {
|
||
const status = statusMap[row.original.status] ?? {
|
||
label: `未知 (${row.original.status})`,
|
||
className: "",
|
||
};
|
||
return (
|
||
<Badge className={status.className} variant="outline">
|
||
{status.label}
|
||
</Badge>
|
||
);
|
||
},
|
||
},
|
||
{
|
||
accessorKey: "reason",
|
||
header: "拒绝原因",
|
||
cell: ({ row }) => row.original.reason || "--",
|
||
},
|
||
{
|
||
accessorKey: "created_at",
|
||
header: "申请时间",
|
||
cell: ({ row }) => formatDate(row.original.created_at),
|
||
},
|
||
{
|
||
accessorKey: "updated_at",
|
||
header: "更新时间",
|
||
cell: ({ row }) => formatDate(row.original.updated_at),
|
||
},
|
||
]}
|
||
header={{ title: "提现管理" }}
|
||
params={[
|
||
{
|
||
key: "status",
|
||
options: statusOptions,
|
||
placeholder: "状态",
|
||
},
|
||
{
|
||
key: "method",
|
||
options: methodOptions,
|
||
placeholder: "收款方式",
|
||
},
|
||
{
|
||
key: "user_id",
|
||
placeholder: "用户 ID",
|
||
},
|
||
]}
|
||
request={async (pagination, filter) => {
|
||
const { data } = await getWithdrawalList({
|
||
page: pagination.page,
|
||
size: pagination.size,
|
||
status: toOptionalNumber(filter.status),
|
||
method: toOptionalNumber(filter.method),
|
||
user_id: toOptionalNumber(filter.user_id),
|
||
});
|
||
|
||
return {
|
||
list: data.data?.list || [],
|
||
total: data.data?.total || 0,
|
||
};
|
||
}}
|
||
/>
|
||
|
||
<Dialog
|
||
onOpenChange={(open) => {
|
||
setRejectOpen(open);
|
||
if (!open) {
|
||
setCurrentRow(null);
|
||
form.reset({ reason: "" });
|
||
}
|
||
}}
|
||
open={rejectOpen}
|
||
>
|
||
<DialogContent>
|
||
<DialogHeader>
|
||
<DialogTitle>拒绝提现申请</DialogTitle>
|
||
<DialogDescription>
|
||
为用户填写本次拒绝原因,提交后该记录会更新为已拒绝。
|
||
</DialogDescription>
|
||
</DialogHeader>
|
||
<Form {...form}>
|
||
<form
|
||
className="space-y-4"
|
||
onSubmit={form.handleSubmit(async (values) => {
|
||
if (!currentRow) return;
|
||
await rejectMutation.mutateAsync({
|
||
withdrawal_id: currentRow.id,
|
||
reason: values.reason.trim(),
|
||
});
|
||
})}
|
||
>
|
||
<FormField
|
||
control={form.control}
|
||
name="reason"
|
||
render={({ field }) => (
|
||
<FormItem>
|
||
<FormLabel>拒绝原因</FormLabel>
|
||
<FormControl>
|
||
<Textarea
|
||
placeholder="请输入拒绝原因,用户端会看到这条说明"
|
||
rows={5}
|
||
{...field}
|
||
/>
|
||
</FormControl>
|
||
<FormMessage />
|
||
</FormItem>
|
||
)}
|
||
/>
|
||
<DialogFooter>
|
||
<Button
|
||
onClick={() => setRejectOpen(false)}
|
||
type="button"
|
||
variant="outline"
|
||
>
|
||
{t("cancel", "Cancel")}
|
||
</Button>
|
||
<Button disabled={rejectMutation.isPending} type="submit">
|
||
{rejectMutation.isPending ? "提交中..." : "确认拒绝"}
|
||
</Button>
|
||
</DialogFooter>
|
||
</form>
|
||
</Form>
|
||
</DialogContent>
|
||
</Dialog>
|
||
</>
|
||
);
|
||
}
|