feat: add withdrawal management pages
Build and Release / Build (push) Has been cancelled
Issue Close Require / issue-close-require (push) Has been cancelled

This commit is contained in:
2026-05-24 20:30:19 -07:00
parent 65641838f9
commit 11c3ce26a6
7 changed files with 361 additions and 245 deletions
+25
View File
@@ -15,6 +15,9 @@ import { Route as rootRouteImport } from './routes/__root'
const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')() const DashboardRouteLazyRouteImport = createFileRoute('/dashboard')()
const IndexLazyRouteImport = createFileRoute('/')() const IndexLazyRouteImport = createFileRoute('/')()
const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')() const DashboardIndexLazyRouteImport = createFileRoute('/dashboard/')()
const DashboardWithdrawalLazyRouteImport = createFileRoute(
'/dashboard/withdrawal',
)()
const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')() const DashboardServersLazyRouteImport = createFileRoute('/dashboard/servers')()
const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')() const DashboardNodesLazyRouteImport = createFileRoute('/dashboard/nodes')()
const DashboardWithdrawalIndexLazyRouteImport = createFileRoute( const DashboardWithdrawalIndexLazyRouteImport = createFileRoute(
@@ -112,6 +115,13 @@ const DashboardIndexLazyRoute = DashboardIndexLazyRouteImport.update({
} as any).lazy(() => } as any).lazy(() =>
import('./routes/dashboard/index.lazy').then((d) => d.Route), 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({ const DashboardServersLazyRoute = DashboardServersLazyRouteImport.update({
id: '/servers', id: '/servers',
path: '/servers', path: '/servers',
@@ -357,6 +367,7 @@ export interface FileRoutesByFullPath {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -392,6 +403,7 @@ export interface FileRoutesByTo {
'/': typeof IndexLazyRoute '/': typeof IndexLazyRoute
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard': typeof DashboardIndexLazyRoute '/dashboard': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -429,6 +441,7 @@ export interface FileRoutesById {
'/dashboard': typeof DashboardRouteLazyRouteWithChildren '/dashboard': typeof DashboardRouteLazyRouteWithChildren
'/dashboard/nodes': typeof DashboardNodesLazyRoute '/dashboard/nodes': typeof DashboardNodesLazyRoute
'/dashboard/servers': typeof DashboardServersLazyRoute '/dashboard/servers': typeof DashboardServersLazyRoute
'/dashboard/withdrawal': typeof DashboardWithdrawalLazyRoute
'/dashboard/': typeof DashboardIndexLazyRoute '/dashboard/': typeof DashboardIndexLazyRoute
'/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute '/dashboard/log/balance': typeof DashboardLogBalanceLazyRoute
'/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute '/dashboard/log/commission': typeof DashboardLogCommissionLazyRoute
@@ -467,6 +480,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/' | '/dashboard/'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -502,6 +516,7 @@ export interface FileRouteTypes {
| '/' | '/'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard' | '/dashboard'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -538,6 +553,7 @@ export interface FileRouteTypes {
| '/dashboard' | '/dashboard'
| '/dashboard/nodes' | '/dashboard/nodes'
| '/dashboard/servers' | '/dashboard/servers'
| '/dashboard/withdrawal'
| '/dashboard/' | '/dashboard/'
| '/dashboard/log/balance' | '/dashboard/log/balance'
| '/dashboard/log/commission' | '/dashboard/log/commission'
@@ -598,6 +614,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof DashboardIndexLazyRouteImport preLoaderRoute: typeof DashboardIndexLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute parentRoute: typeof DashboardRouteLazyRoute
} }
'/dashboard/withdrawal': {
id: '/dashboard/withdrawal'
path: '/withdrawal'
fullPath: '/dashboard/withdrawal'
preLoaderRoute: typeof DashboardWithdrawalLazyRouteImport
parentRoute: typeof DashboardRouteLazyRoute
}
'/dashboard/servers': { '/dashboard/servers': {
id: '/dashboard/servers' id: '/dashboard/servers'
path: '/servers' path: '/servers'
@@ -821,6 +844,7 @@ declare module '@tanstack/react-router' {
interface DashboardRouteLazyRouteChildren { interface DashboardRouteLazyRouteChildren {
DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute DashboardNodesLazyRoute: typeof DashboardNodesLazyRoute
DashboardServersLazyRoute: typeof DashboardServersLazyRoute DashboardServersLazyRoute: typeof DashboardServersLazyRoute
DashboardWithdrawalLazyRoute: typeof DashboardWithdrawalLazyRoute
DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute DashboardIndexLazyRoute: typeof DashboardIndexLazyRoute
DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute DashboardLogBalanceLazyRoute: typeof DashboardLogBalanceLazyRoute
DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute DashboardLogCommissionLazyRoute: typeof DashboardLogCommissionLazyRoute
@@ -856,6 +880,7 @@ interface DashboardRouteLazyRouteChildren {
const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
DashboardNodesLazyRoute: DashboardNodesLazyRoute, DashboardNodesLazyRoute: DashboardNodesLazyRoute,
DashboardServersLazyRoute: DashboardServersLazyRoute, DashboardServersLazyRoute: DashboardServersLazyRoute,
DashboardWithdrawalLazyRoute: DashboardWithdrawalLazyRoute,
DashboardIndexLazyRoute: DashboardIndexLazyRoute, DashboardIndexLazyRoute: DashboardIndexLazyRoute,
DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute, DashboardLogBalanceLazyRoute: DashboardLogBalanceLazyRoute,
DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute, DashboardLogCommissionLazyRoute: DashboardLogCommissionLazyRoute,
@@ -0,0 +1,6 @@
import { createLazyFileRoute } from "@tanstack/react-router";
import WithdrawalManagementPage from "@/sections/withdrawal";
export const Route = createLazyFileRoute("/dashboard/withdrawal")({
component: WithdrawalManagementPage,
});
+270 -245
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { zodResolver } from "@hookform/resolvers/zod";
import { Badge } from "@workspace/ui/components/badge"; import { useQuery } from "@tanstack/react-query";
import { Button } from "@workspace/ui/components/button"; import { Button } from "@workspace/ui/components/button";
import { import {
Card, Card,
@@ -17,9 +17,19 @@ import {
DialogFooter, DialogFooter,
DialogHeader, DialogHeader,
DialogTitle, DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog"; } 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 { Input } from "@workspace/ui/components/input";
import { Textarea } from "@workspace/ui/components/textarea"; 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 { ProList } from "@workspace/ui/composed/pro-list/pro-list";
import { import {
commissionWithdraw, commissionWithdraw,
@@ -28,248 +38,268 @@ import {
queryWithdrawalLog, queryWithdrawalLog,
} from "@workspace/ui/services/user/user"; } from "@workspace/ui/services/user/user";
import { formatDate } from "@workspace/ui/utils/formatting"; import { formatDate } from "@workspace/ui/utils/formatting";
import { Copy, Wallet } from "lucide-react"; import { unitConversion } from "@workspace/ui/utils/unit-conversions";
import { useState } from "react"; import { Copy } from "lucide-react";
import { useRef, useState, useTransition } from "react";
import { CopyToClipboard } from "react-copy-to-clipboard"; import { CopyToClipboard } from "react-copy-to-clipboard";
import { useForm } from "react-hook-form";
import { useTranslation } from "react-i18next"; import { useTranslation } from "react-i18next";
import { toast } from "sonner"; import { toast } from "sonner";
import { z } from "zod";
import { Display } from "@/components/display"; import { Display } from "@/components/display";
import { useGlobalStore } from "@/stores/global"; import { useGlobalStore } from "@/stores/global";
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",
};
default:
return {
label: t("withdrawal.status.pending", "Pending"),
className: "bg-amber-500/10 text-amber-600",
};
}
}
export default function Affiliate() { export default function Affiliate() {
const { t } = useTranslation("affiliate"); const { t } = useTranslation("affiliate");
const { user, common, getUserInfo } = useGlobalStore(); const { user, common, getUserInfo } = useGlobalStore();
const queryClient = useQueryClient(); const [withdrawalOpen, setWithdrawalOpen] = useState(false);
const [withdrawOpen, setWithdrawOpen] = useState(false); const [submitting, startTransition] = useTransition();
const [amountInput, setAmountInput] = useState(""); const withdrawalListActionRef = useRef<ProListActions | undefined>(undefined);
const [contentInput, setContentInput] = useState("");
const [amountError, setAmountError] = useState(""); const affiliateQuery = useQuery({
const [contentError, setContentError] = useState("");
const { data } = useQuery({
queryKey: ["queryUserAffiliate"], queryKey: ["queryUserAffiliate"],
queryFn: async () => { queryFn: async () => {
const response = await queryUserAffiliate(); const response = await queryUserAffiliate();
return response.data.data; return response.data.data;
}, },
}); });
const withdrawMutation = useMutation({ const availableCommission = Number(
mutationFn: async (values: { amount: number; content: string }) => user?.commission ?? affiliateQuery.data?.total_commission ?? 0
commissionWithdraw({ );
amount: Math.round(values.amount * 100), const withdrawalFormSchema = z.object({
content: values.content.trim(), amount: z
}), .number()
onSuccess: async () => { .int(t("withdrawal.validation.amountInvalid", "Amount is invalid"))
toast.success(t("withdrawSuccess", "提现申请已提交")); .positive(
setAmountInput(""); t(
setContentInput(""); "withdrawal.validation.amountPositive",
setAmountError(""); "Amount must be greater than 0"
setContentError(""); )
setWithdrawOpen(false); )
await Promise.all([ .max(
queryClient.invalidateQueries({ queryKey: ["queryUserAffiliate"] }), availableCommission,
queryClient.invalidateQueries({ queryKey: ["queryUserInfo"] }), t(
getUserInfo(), "withdrawal.validation.amountExceeded",
]); "Amount cannot exceed available commission"
}, )
onError: (error) => { ),
console.error("Withdraw request failed", error); content: z
toast.error(t("withdrawError", "提现申请提交失败")); .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<z.infer<typeof withdrawalFormSchema>>({
resolver: zodResolver(withdrawalFormSchema),
defaultValues: {
amount: 0,
content: "",
}, },
}); });
const statusMap: Record<number, { label: string; className: string }> = { const submitWithdrawal = (values: z.infer<typeof withdrawalFormSchema>) => {
0: { startTransition(async () => {
label: t("withdrawStatus.pending", "审核中"), await commissionWithdraw({
className: "bg-amber-100 text-amber-700 border-amber-200", amount: values.amount,
}, content: values.content.trim(),
1: { });
label: t("withdrawStatus.approved", "已通过"), toast.success(
className: "bg-emerald-100 text-emerald-700 border-emerald-200", t("withdrawal.submitSuccess", "Withdrawal request submitted")
}, );
2: { await Promise.all([
label: t("withdrawStatus.rejected", "已拒绝"), affiliateQuery.refetch(),
className: "bg-rose-100 text-rose-700 border-rose-200", getUserInfo(),
}, withdrawalListActionRef.current?.refresh(),
}; ]);
setWithdrawalOpen(false);
const currentCommission = user?.commission ?? 0; withdrawalForm.reset({
const totalCommission = data?.total_commission ?? 0; amount: 0,
content: "",
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,
}); });
}; };
return ( return (
<div className="flex flex-col gap-4"> <div className="flex flex-col gap-4">
<Card> <Card>
<CardHeader className="flex flex-col gap-4 md:flex-row md:items-start md:justify-between"> <CardHeader>
<div className="space-y-1"> <CardTitle>{t("totalCommission", "Total Commission")}</CardTitle>
<CardTitle>{t("totalCommission", "Total Commission")}</CardTitle> <CardDescription>
<CardDescription> {t("commissionInfo", "Commission Info")}
{t("commissionInfo", "Commission Info")} </CardDescription>
</CardDescription>
</div>
<Dialog onOpenChange={setWithdrawOpen} open={withdrawOpen}>
<Button
className="gap-2 self-start"
onClick={() => setWithdrawOpen(true)}
variant="default"
>
<Wallet className="h-4 w-4" />
{t("applyWithdraw", "申请提现")}
</Button>
<DialogContent>
<DialogHeader>
<DialogTitle>{t("applyWithdraw", "申请提现")}</DialogTitle>
<DialogDescription>
{t(
"withdrawDescription",
"填写提现金额和收款信息后提交申请,等待后台审核。"
)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid gap-3 rounded-lg border bg-muted/30 p-4 text-sm">
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("currentCommission", "当前可见佣金")}
</span>
<span className="font-semibold">
<Display type="currency" value={currentCommission} />
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-muted-foreground">
{t("totalCommission", "Total Commission")}
</span>
<span className="font-semibold">
<Display type="currency" value={totalCommission} />
</span>
</div>
</div>
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="withdraw-amount"
>
{t("withdrawAmount", "提现金额")}
</label>
<Input
id="withdraw-amount"
min="0"
onChange={(event) => {
setAmountInput(event.target.value);
if (amountError) setAmountError("");
}}
placeholder={t(
"withdrawAmountPlaceholder",
"请输入提现金额,单位为元"
)}
step="0.01"
type="number"
value={amountInput}
/>
{amountError ? (
<p className="text-destructive text-sm">{amountError}</p>
) : null}
</div>
<div className="space-y-2">
<label
className="font-medium text-sm"
htmlFor="withdraw-content"
>
{t("accountInfo", "收款信息")}
</label>
<Textarea
id="withdraw-content"
onChange={(event) => {
setContentInput(event.target.value);
if (contentError) setContentError("");
}}
placeholder={t(
"accountPlaceholder",
"请输入银行卡、支付宝、USDT 地址等收款信息"
)}
rows={4}
value={contentInput}
/>
{contentError ? (
<p className="text-destructive text-sm">{contentError}</p>
) : null}
</div>
<DialogFooter>
<Button
onClick={() => setWithdrawOpen(false)}
type="button"
variant="outline"
>
{t("cancel", "取消")}
</Button>
<Button
disabled={withdrawMutation.isPending}
onClick={submitWithdraw}
type="button"
>
{withdrawMutation.isPending
? t("submitting", "提交中...")
: t("submitWithdraw", "提交申请")}
</Button>
</DialogFooter>
</div>
</DialogContent>
</Dialog>
</CardHeader> </CardHeader>
<CardContent> <CardContent>
<div className="grid gap-4 md:grid-cols-2"> <div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div> <div className="flex items-baseline gap-2">
<div className="text-muted-foreground text-sm"> <span className="font-bold text-3xl">
{t("totalCommission", "Total Commission")} <Display
</div> type="currency"
<div className="mt-1 flex items-baseline gap-2"> value={affiliateQuery.data?.total_commission}
<span className="font-bold text-3xl"> />
<Display type="currency" value={totalCommission} /> </span>
</span> <span className="text-muted-foreground text-sm">
<span className="text-muted-foreground text-sm"> ({t("commissionRate", "Commission Rate")}:{" "}
({t("commissionRate", "Commission Rate")}:{" "} {user?.referral_percentage ||
{user?.referral_percentage || common?.invite?.referral_percentage}
common?.invite?.referral_percentage} %)
%) </span>
</span>
</div>
</div> </div>
<div> <div className="flex flex-col items-start gap-2 md:items-end">
<div className="text-muted-foreground text-sm"> <div className="text-muted-foreground text-sm">
{t("currentCommission", "当前可见佣金")} {t(
"withdrawal.availableCommission",
"Available for withdrawal"
)}
</div> </div>
<div className="mt-1 font-bold text-3xl"> <div className="font-semibold text-xl">
<Display type="currency" value={currentCommission} /> <Display type="currency" value={availableCommission} />
</div> </div>
<Dialog onOpenChange={setWithdrawalOpen} open={withdrawalOpen}>
<DialogTrigger asChild>
<Button>
{t("withdrawal.apply", "Apply for Withdrawal")}
</Button>
</DialogTrigger>
<DialogContent>
<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="content"
render={({ field }) => (
<FormItem>
<FormLabel>
{t("withdrawal.content", "Payout Information")}
</FormLabel>
<FormControl>
<Textarea
placeholder={t(
"withdrawal.contentPlaceholder",
"Bank account, wallet address, recipient name, or other payout details"
)}
rows={4}
{...field}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</form>
</Form>
<DialogFooter>
<Button
disabled={submitting}
onClick={() => setWithdrawalOpen(false)}
variant="outline"
>
{t("withdrawal.cancel", "Cancel")}
</Button>
<Button
disabled={submitting || availableCommission <= 0}
form="withdrawal-form"
type="submit"
>
{submitting
? t("withdrawal.submitting", "Submitting...")
: t("withdrawal.submit", "Submit Withdrawal")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div> </div>
</div> </div>
</CardContent> </CardContent>
@@ -302,55 +332,50 @@ export default function Affiliate() {
</CardContent> </CardContent>
</Card> </Card>
<ProList<API.WithdrawalLog, Record<string, unknown>> <ProList<API.WithdrawalLog, Record<string, unknown>>
action={withdrawalListActionRef}
header={{ header={{
title: t("withdrawRecords", "提现记录"), title: t("withdrawal.records", "Withdrawal Records"),
}} }}
renderItem={(item) => { renderItem={(item) => {
const status = statusMap[item.status] ?? { const statusMeta = getWithdrawalStatusMeta(item.status, t);
label: `${t("unknownStatus", "未知状态")} (${item.status})`,
className: "",
};
return ( return (
<Card className="overflow-hidden"> <Card className="overflow-hidden">
<CardContent className="space-y-3 p-4 text-sm"> <CardContent className="space-y-3 p-4 text-sm">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between"> <div className="flex flex-wrap items-center justify-between gap-2">
<div className="space-y-1"> <div className="font-semibold text-lg">
<div className="text-muted-foreground"> <Display type="currency" value={item.amount} />
{t("withdrawAmount", "提现金额")}
</div>
<div className="font-semibold text-lg">
<Display type="currency" value={item.amount} />
</div>
</div> </div>
<Badge className={status.className} variant="outline"> <span
{status.label} className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
</Badge> >
{statusMeta.label}
</span>
</div> </div>
<ul className="grid gap-3 md:grid-cols-2"> <ul className="grid gap-3 md:grid-cols-2">
<li className="flex flex-col gap-1"> <li className="flex flex-col gap-1">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("accountInfo", "收款信息")} {t("withdrawal.record.content", "Payout Information")}
</span> </span>
<span className="break-all font-medium"> <span className="whitespace-pre-wrap break-words">
{item.content || "--"} {item.content}
</span> </span>
</li> </li>
<li className="flex flex-col gap-1"> <li className="flex flex-col gap-1">
<span className="text-muted-foreground"> <span className="text-muted-foreground">
{t("applyTime", "申请时间")} {t("withdrawal.record.createdAt", "Created At")}
</span>
<span className="font-medium">
{formatDate(item.created_at)}
</span>
</li>
<li className="flex flex-col gap-1 md:col-span-2">
<span className="text-muted-foreground">
{t("rejectReason", "拒绝原因")}
</span>
<span className="font-medium">
{item.reason || t("noReason", "无")}
</span> </span>
<time>{formatDate(item.created_at)}</time>
</li> </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>
) : null}
</ul> </ul>
</CardContent> </CardContent>
</Card> </Card>
+2
View File
@@ -22,6 +22,7 @@ import * as system from "./system";
import * as ticket from "./ticket"; import * as ticket from "./ticket";
import * as tool from "./tool"; import * as tool from "./tool";
import * as user from "./user"; import * as user from "./user";
import * as withdrawal from "./withdrawal";
export default { export default {
ads, ads,
announcement, announcement,
@@ -43,4 +44,5 @@ export default {
ticket, ticket,
tool, tool,
user, user,
withdrawal,
}; };
+32
View File
@@ -28,6 +28,21 @@ declare namespace API {
updated_at: number; updated_at: number;
}; };
type AdminWithdrawalLog = {
id: number;
user_id: number;
amount: number;
content: string;
status: number;
reason?: string;
created_at: number;
updated_at: number;
};
type ApproveWithdrawalRequest = {
withdrawal_id: number;
};
type AnyTLS = { type AnyTLS = {
port: number; port: number;
security_config: SecurityConfig; security_config: SecurityConfig;
@@ -71,6 +86,18 @@ declare namespace API {
is_default: boolean; is_default: boolean;
}; };
type GetWithdrawalListParams = {
page: number;
size: number;
user_id?: number;
status?: number;
};
type GetWithdrawalListResponse = {
list: AdminWithdrawalLog[];
total: number;
};
type AppUserSubcbribe = { type AppUserSubcbribe = {
id: number; id: number;
name: string; name: string;
@@ -3075,6 +3102,11 @@ declare namespace API {
confirm: boolean; confirm: boolean;
}; };
type RejectWithdrawalRequest = {
withdrawal_id: number;
reason: string;
};
type PreviewUserNodesRequest = { type PreviewUserNodesRequest = {
user_id: number; user_id: number;
}; };
+8
View File
@@ -846,6 +846,14 @@ declare namespace API {
order_no: string; order_no: string;
}; };
type RedeemCodeRequest = {
code: string;
};
type RedeemCodeResponse = {
message: string;
};
type RegisterConfig = { type RegisterConfig = {
stop_register: boolean; stop_register: boolean;
enable_trial: boolean; enable_trial: boolean;
+18
View File
@@ -251,6 +251,24 @@ export async function updateUserPassword(
); );
} }
/** Redeem Code POST /v1/public/redemption/ */
export async function redeemCode(
body: API.RedeemCodeRequest,
options?: { [key: string]: any }
) {
return request<API.Response & { data?: API.RedeemCodeResponse }>(
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/redemption/`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
},
data: body,
...(options || {}),
}
);
}
/** Update User Rules PUT /v1/public/user/rules */ /** Update User Rules PUT /v1/public/user/rules */
export async function updateUserRules( export async function updateUserRules(
body: API.UpdateUserRulesRequest, body: API.UpdateUserRulesRequest,