From 11c3ce26a6dc91e0d72cbe6261bd5945af125706 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Sun, 24 May 2026 20:30:19 -0700 Subject: [PATCH] feat: add withdrawal management pages --- apps/admin/src/routeTree.gen.ts | 25 + .../src/routes/dashboard/withdrawal.lazy.tsx | 6 + .../src/sections/user/affiliate/index.tsx | 515 +++++++++--------- packages/ui/src/services/admin/index.ts | 2 + packages/ui/src/services/admin/typings.d.ts | 32 ++ packages/ui/src/services/user/typings.d.ts | 8 + packages/ui/src/services/user/user.ts | 18 + 7 files changed, 361 insertions(+), 245 deletions(-) create mode 100644 apps/admin/src/routes/dashboard/withdrawal.lazy.tsx diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index 0398349..fe38819 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -15,6 +15,9 @@ import { Route as rootRouteImport } from './routes/__root' const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')() const IndexLazyRouteImport = createFileRoute('/')() const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')() +const DashboardWithdrawalLazyRouteImport = createFileRoute( + '/dashboard/withdrawal', +)() const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')() const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')() const DashboardWithdrawalIndexLazyRouteImport = createFileRoute( @@ -112,6 +115,13 @@ const DashboardIndexLazyRoute = DashboardIndexLazyRouteImport.update({ } as any).lazy(() => import('./routes/dashboard/index.lazy').then((d) => d.Route), ) +const DashboardWithdrawalLazyRoute = DashboardWithdrawalLazyRouteImport.update({ + id: '/withdrawal', + path: '/withdrawal', + getParentRoute: () => DashboardRouteLazyRoute, +} as any).lazy(() => + import('./routes/dashboard/withdrawal.lazy').then((d) => d.Route), +) const DashboardServersLazyRoute = DashboardServersLazyRouteImport.update({ id: '/servers', path: '/servers', @@ -357,6 +367,7 @@ export interface FileRoutesByFullPath { '/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute + '/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute @@ -392,6 +403,7 @@ export interface FileRoutesByTo { '/': typeof IndexLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute + '/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute '/dashboard': typeof DashboardIndexLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute @@ -429,6 +441,7 @@ export interface FileRoutesById { '/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute + '/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute @@ -467,6 +480,7 @@ export interface FileRouteTypes { | '/dashboard' | '/dashboard/nodes' | '/dashboard/servers' + | '/dashboard/withdrawal' | '/dashboard/' | '/dashboard/log/balance' | '/dashboard/log/commission' @@ -502,6 +516,7 @@ export interface FileRouteTypes { | '/' | '/dashboard/nodes' | '/dashboard/servers' + | '/dashboard/withdrawal' | '/dashboard' | '/dashboard/log/balance' | '/dashboard/log/commission' @@ -538,6 +553,7 @@ export interface FileRouteTypes { | '/dashboard' | '/dashboard/nodes' | '/dashboard/servers' + | '/dashboard/withdrawal' | '/dashboard/' | '/dashboard/log/balance' | '/dashboard/log/commission' @@ -598,6 +614,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardIndexLazyRouteImport parentRoute: typeof DashboardRouteLazyRoute } + '/dashboard/withdrawal': { + id: '/dashboard/withdrawal' + path: '/withdrawal' + fullPath: '/dashboard/withdrawal' + preLoaderRoute: typeof DashboardWithdrawalLazyRouteImport + parentRoute: typeof DashboardRouteLazyRoute + } '/dashboard/servers': { id: '/dashboard/servers' path: '/servers' @@ -821,6 +844,7 @@ declare module '@tanstack/react-router' { interface DashboardRouteLazyRouteChildren { DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute DashboardServersLazyRoute: typeof DashboardServersLazyRoute + DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRoute DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute @@ -856,6 +880,7 @@ interface DashboardRouteLazyRouteChildren { const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { DashboardNodesLazyRoute: DashboardNodesLazyRoute, DashboardServersLazyRoute: DashboardServersLazyRoute, + DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRoute, DashboardIndexLazyRoute: DashboardIndexLazyRoute, DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute, DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute, diff --git a/apps/admin/src/routes/dashboard/withdrawal.lazy.tsx b/apps/admin/src/routes/dashboard/withdrawal.lazy.tsx new file mode 100644 index 0000000..de30e22 --- /dev/null +++ b/apps/admin/src/routes/dashboard/withdrawal.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import WithdrawalManagementPage from "@/sections/withdrawal"; + +export const Route = createLazyFileRoute("/dashboard/withdrawal")({ + component: WithdrawalManagementPage, +}); diff --git a/apps/user/src/sections/user/affiliate/index.tsx b/apps/user/src/sections/user/affiliate/index.tsx index f077d4e..70b4867 100644 --- a/apps/user/src/sections/user/affiliate/index.tsx +++ b/apps/user/src/sections/user/affiliate/index.tsx @@ -1,7 +1,7 @@ "use client"; -import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; -import { Badge } from "@workspace/ui/components/badge"; +import { zodResolver } from "@hookform/resolvers/zod"; +import { useQuery } from "@tanstack/react-query"; import { Button } from "@workspace/ui/components/button"; import { Card, @@ -17,9 +17,19 @@ import { 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 { Textarea } from "@workspace/ui/components/textarea"; +import type { ProListActions } from "@workspace/ui/composed/pro-list/pro-list"; import { ProList } from "@workspace/ui/composed/pro-list/pro-list"; import { commissionWithdraw, @@ -28,248 +38,268 @@ import { queryWithdrawalLog, } from "@workspace/ui/services/user/user"; import { formatDate } from "@workspace/ui/utils/formatting"; -import { Copy, Wallet } from "lucide-react"; -import { useState } from "react"; +import { unitConversion } from "@workspace/ui/utils/unit-conversions"; +import { Copy } 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"; +function getWithdrawalStatusMeta( + status: number, + t: ReturnType>["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", + }; + default: + return { + label: t("withdrawal.status.pending", "Pending"), + className: "bg-amber-500/10 text-amber-600", + }; + } +} + export default function Affiliate() { const { t } = useTranslation("affiliate"); const { user, common, getUserInfo } = useGlobalStore(); - const queryClient = useQueryClient(); - const [withdrawOpen, setWithdrawOpen] = useState(false); - const [amountInput, setAmountInput] = useState(""); - const [contentInput, setContentInput] = useState(""); - const [amountError, setAmountError] = useState(""); - const [contentError, setContentError] = useState(""); - const { data } = useQuery({ + const [withdrawalOpen, setWithdrawalOpen] = useState(false); + const [submitting, startTransition] = useTransition(); + const withdrawalListActionRef = useRef(undefined); + + const affiliateQuery = useQuery({ queryKey: ["queryUserAffiliate"], queryFn: async () => { const response = await queryUserAffiliate(); return response.data.data; }, }); - const withdrawMutation = useMutation({ - mutationFn: async (values: { amount: number; content: string }) => - commissionWithdraw({ - amount: Math.round(values.amount * 100), - content: values.content.trim(), - }), - onSuccess: async () => { - toast.success(t("withdrawSuccess", "提现申请已提交")); - setAmountInput(""); - setContentInput(""); - setAmountError(""); - setContentError(""); - setWithdrawOpen(false); - await Promise.all([ - queryClient.invalidateQueries({ queryKey: ["queryUserAffiliate"] }), - queryClient.invalidateQueries({ queryKey: ["queryUserInfo"] }), - getUserInfo(), - ]); - }, - onError: (error) => { - console.error("Withdraw request failed", error); - toast.error(t("withdrawError", "提现申请提交失败")); + const availableCommission = Number( + user?.commission ?? affiliateQuery.data?.total_commission ?? 0 + ); + const withdrawalFormSchema = z.object({ + amount: z + .number() + .int(t("withdrawal.validation.amountInvalid", "Amount is invalid")) + .positive( + t( + "withdrawal.validation.amountPositive", + "Amount must be greater than 0" + ) + ) + .max( + availableCommission, + t( + "withdrawal.validation.amountExceeded", + "Amount cannot exceed available commission" + ) + ), + content: z + .string() + .trim() + .min( + 1, + t( + "withdrawal.validation.contentRequired", + "Withdrawal info is required" + ) + ) + .max( + 1000, + t("withdrawal.validation.contentTooLong", "Withdrawal info is too long") + ), + }); + const withdrawalForm = useForm>({ + resolver: zodResolver(withdrawalFormSchema), + defaultValues: { + amount: 0, + content: "", }, }); - const statusMap: Record = { - 0: { - label: t("withdrawStatus.pending", "审核中"), - className: "bg-amber-100 text-amber-700 border-amber-200", - }, - 1: { - label: t("withdrawStatus.approved", "已通过"), - className: "bg-emerald-100 text-emerald-700 border-emerald-200", - }, - 2: { - label: t("withdrawStatus.rejected", "已拒绝"), - className: "bg-rose-100 text-rose-700 border-rose-200", - }, - }; - - const currentCommission = user?.commission ?? 0; - const totalCommission = data?.total_commission ?? 0; - - const submitWithdraw = async () => { - const amount = Number(amountInput); - const content = contentInput.trim(); - let hasError = false; - - if (!Number.isFinite(amount) || amount <= 0) { - setAmountError(t("amountPositive", "提现金额必须大于 0")); - hasError = true; - } else { - setAmountError(""); - } - - if (content.length < 2) { - setContentError(t("accountRequired", "请填写收款信息")); - hasError = true; - } else if (content.length > 200) { - setContentError(t("accountTooLong", "收款信息不能超过 200 个字符")); - hasError = true; - } else { - setContentError(""); - } - - if (hasError) return; - - await withdrawMutation.mutateAsync({ - amount, - content, + const submitWithdrawal = (values: z.infer) => { + startTransition(async () => { + await commissionWithdraw({ + amount: values.amount, + content: values.content.trim(), + }); + toast.success( + t("withdrawal.submitSuccess", "Withdrawal request submitted") + ); + await Promise.all([ + affiliateQuery.refetch(), + getUserInfo(), + withdrawalListActionRef.current?.refresh(), + ]); + setWithdrawalOpen(false); + withdrawalForm.reset({ + amount: 0, + content: "", + }); }); }; return (
- -
- {t("totalCommission", "Total Commission")} - - {t("commissionInfo", "Commission Info")} - -
- - - - - {t("applyWithdraw", "申请提现")} - - {t( - "withdrawDescription", - "填写提现金额和收款信息后提交申请,等待后台审核。" - )} - - -
-
-
- - {t("currentCommission", "当前可见佣金")} - - - - -
-
- - {t("totalCommission", "Total Commission")} - - - - -
-
-
- - { - setAmountInput(event.target.value); - if (amountError) setAmountError(""); - }} - placeholder={t( - "withdrawAmountPlaceholder", - "请输入提现金额,单位为元" - )} - step="0.01" - type="number" - value={amountInput} - /> - {amountError ? ( -

{amountError}

- ) : null} -
-
- -