新功能(#25): 拆分用户中心提现退款记录页签
This commit is contained in:
@@ -33,5 +33,64 @@
|
|||||||
"pending": "Pending Review",
|
"pending": "Pending Review",
|
||||||
"rejected": "Rejected"
|
"rejected": "Rejected"
|
||||||
},
|
},
|
||||||
"withdrawSuccess": "Withdrawal request submitted"
|
"withdrawSuccess": "Withdrawal request submitted",
|
||||||
|
"withdrawal": {
|
||||||
|
"amount": "Amount",
|
||||||
|
"amountHint": "Available",
|
||||||
|
"amountPlaceholder": "Enter withdrawal amount",
|
||||||
|
"apply": "Apply for Withdrawal",
|
||||||
|
"applyDescription": "Enter the withdrawal amount and payout details.",
|
||||||
|
"applyTitle": "Apply for Withdrawal",
|
||||||
|
"availableCommission": "Available for withdrawal",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"empty": {
|
||||||
|
"refund": "No refund records yet.",
|
||||||
|
"withdrawal": "No withdrawal records yet."
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"refundDescription": "Please try again later.",
|
||||||
|
"refundTitle": "Failed to load refund records",
|
||||||
|
"withdrawalDescription": "Please try again later.",
|
||||||
|
"withdrawalTitle": "Failed to load withdrawal records"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"account": "Payout Account",
|
||||||
|
"content": "Payout Information",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"method": "Payout Method",
|
||||||
|
"qrCode": "Payment QR Code",
|
||||||
|
"reason": "Reject Reason"
|
||||||
|
},
|
||||||
|
"records": "Withdrawal Records",
|
||||||
|
"recordsDescription": "View withdrawal and refund records separately.",
|
||||||
|
"refund": {
|
||||||
|
"amount": "Amount",
|
||||||
|
"content": "Description",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"event": {
|
||||||
|
"333": "Order refund",
|
||||||
|
"337": "Withdrawal rejected refund",
|
||||||
|
"338": "Withdrawal cancelled refund",
|
||||||
|
"other": "Other refund"
|
||||||
|
},
|
||||||
|
"eventType": "Type"
|
||||||
|
},
|
||||||
|
"retry": "Retry",
|
||||||
|
"status": {
|
||||||
|
"approved": "Approved",
|
||||||
|
"cancelled": "Cancelled",
|
||||||
|
"pending": "Pending",
|
||||||
|
"rejected": "Rejected"
|
||||||
|
},
|
||||||
|
"submit": "Submit Withdrawal",
|
||||||
|
"submitSuccess": "Withdrawal request submitted",
|
||||||
|
"submitting": "Submitting...",
|
||||||
|
"tabs": {
|
||||||
|
"refund": "Refund Records",
|
||||||
|
"withdrawal": "Withdrawal Records"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"amountExceeded": "Amount cannot exceed available commission"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -33,5 +33,64 @@
|
|||||||
"pending": "审核中",
|
"pending": "审核中",
|
||||||
"rejected": "已拒绝"
|
"rejected": "已拒绝"
|
||||||
},
|
},
|
||||||
"withdrawSuccess": "提现申请已提交"
|
"withdrawSuccess": "提现申请已提交",
|
||||||
|
"withdrawal": {
|
||||||
|
"amount": "金额",
|
||||||
|
"amountHint": "可提现",
|
||||||
|
"amountPlaceholder": "请输入提现金额",
|
||||||
|
"apply": "申请提现",
|
||||||
|
"applyDescription": "填写提现金额和收款信息后提交,等待审核。",
|
||||||
|
"applyTitle": "申请提现",
|
||||||
|
"availableCommission": "可提现佣金",
|
||||||
|
"cancel": "取消",
|
||||||
|
"empty": {
|
||||||
|
"refund": "暂无退款记录。",
|
||||||
|
"withdrawal": "暂无提现记录。"
|
||||||
|
},
|
||||||
|
"error": {
|
||||||
|
"refundDescription": "请稍后重试。",
|
||||||
|
"refundTitle": "退款记录加载失败",
|
||||||
|
"withdrawalDescription": "请稍后重试。",
|
||||||
|
"withdrawalTitle": "提现记录加载失败"
|
||||||
|
},
|
||||||
|
"record": {
|
||||||
|
"account": "收款账号",
|
||||||
|
"content": "收款信息",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"method": "收款方式",
|
||||||
|
"qrCode": "收款码",
|
||||||
|
"reason": "拒绝原因"
|
||||||
|
},
|
||||||
|
"records": "提现记录",
|
||||||
|
"recordsDescription": "提现记录与退款记录独立展示。",
|
||||||
|
"refund": {
|
||||||
|
"amount": "金额",
|
||||||
|
"content": "说明",
|
||||||
|
"createdAt": "时间",
|
||||||
|
"event": {
|
||||||
|
"333": "订单退款",
|
||||||
|
"337": "提现拒绝退回",
|
||||||
|
"338": "用户取消提现退回",
|
||||||
|
"other": "其他退款"
|
||||||
|
},
|
||||||
|
"eventType": "类型"
|
||||||
|
},
|
||||||
|
"retry": "重试",
|
||||||
|
"status": {
|
||||||
|
"approved": "已通过",
|
||||||
|
"cancelled": "已取消",
|
||||||
|
"pending": "审核中",
|
||||||
|
"rejected": "已拒绝"
|
||||||
|
},
|
||||||
|
"submit": "提交提现",
|
||||||
|
"submitSuccess": "提现申请已提交",
|
||||||
|
"submitting": "提交中...",
|
||||||
|
"tabs": {
|
||||||
|
"refund": "退款记录",
|
||||||
|
"withdrawal": "提现记录"
|
||||||
|
},
|
||||||
|
"validation": {
|
||||||
|
"amountExceeded": "提现金额不能超过可提现佣金"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -36,7 +36,6 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@workspace/ui/components/select";
|
} from "@workspace/ui/components/select";
|
||||||
import { Textarea } from "@workspace/ui/components/textarea";
|
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 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 { UploadImage } from "@workspace/ui/composed/upload-image";
|
import { UploadImage } from "@workspace/ui/composed/upload-image";
|
||||||
@@ -44,7 +43,6 @@ import {
|
|||||||
commissionWithdraw,
|
commissionWithdraw,
|
||||||
queryUserAffiliate,
|
queryUserAffiliate,
|
||||||
queryUserAffiliateList,
|
queryUserAffiliateList,
|
||||||
queryWithdrawalLog,
|
|
||||||
uploadFile,
|
uploadFile,
|
||||||
withdrawalCancel,
|
withdrawalCancel,
|
||||||
} from "@workspace/ui/services/user/user";
|
} from "@workspace/ui/services/user/user";
|
||||||
@@ -59,6 +57,7 @@ import { toast } from "sonner";
|
|||||||
import { z } from "zod";
|
import { z } from "zod";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
import { useGlobalStore } from "@/stores/global";
|
import { useGlobalStore } from "@/stores/global";
|
||||||
|
import { WithdrawalRecords } from "./withdrawal-records";
|
||||||
|
|
||||||
const WITHDRAWAL_METHODS = [
|
const WITHDRAWAL_METHODS = [
|
||||||
{ value: 1, label: "支付宝" },
|
{ value: 1, label: "支付宝" },
|
||||||
@@ -67,38 +66,6 @@ const WITHDRAWAL_METHODS = [
|
|||||||
{ value: 0, label: "其他" },
|
{ value: 0, label: "其他" },
|
||||||
] as const;
|
] 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 }) {
|
function QrCodeView({ url }: { url: string }) {
|
||||||
const [zoomOpen, setZoomOpen] = useState(false);
|
const [zoomOpen, setZoomOpen] = useState(false);
|
||||||
return (
|
return (
|
||||||
@@ -111,13 +78,21 @@ function QrCodeView({ url }: { url: string }) {
|
|||||||
<img
|
<img
|
||||||
alt="收款码"
|
alt="收款码"
|
||||||
className="h-16 w-16 cursor-zoom-in object-cover"
|
className="h-16 w-16 cursor-zoom-in object-cover"
|
||||||
|
height={64}
|
||||||
src={url}
|
src={url}
|
||||||
|
width={64}
|
||||||
/>
|
/>
|
||||||
</button>
|
</button>
|
||||||
{zoomOpen && (
|
{zoomOpen && (
|
||||||
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
|
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
|
||||||
<DialogContent className="max-w-sm">
|
<DialogContent className="max-w-sm">
|
||||||
<img alt="收款码" className="w-full" src={url} />
|
<img
|
||||||
|
alt="收款码"
|
||||||
|
className="w-full"
|
||||||
|
height={320}
|
||||||
|
src={url}
|
||||||
|
width={320}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
@@ -157,14 +132,12 @@ const withdrawalFormSchema = z
|
|||||||
path: ["account"],
|
path: ["account"],
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
} else if (data.method === 0) {
|
} else if (data.method === 0 && !(data.account || data.content)) {
|
||||||
if (!data.account && !data.content) {
|
ctx.addIssue({
|
||||||
ctx.addIssue({
|
code: z.ZodIssueCode.custom,
|
||||||
code: z.ZodIssueCode.custom,
|
message: "收款账号和备注至少填一项",
|
||||||
message: "收款账号和备注至少填一项",
|
path: ["account"],
|
||||||
path: ["account"],
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -421,9 +394,7 @@ export default function Affiliate() {
|
|||||||
<FormItem>
|
<FormItem>
|
||||||
<FormLabel>
|
<FormLabel>
|
||||||
收款码
|
收款码
|
||||||
<span className="ml-1 text-destructive">
|
<span className="ml-1 text-destructive">*</span>
|
||||||
*
|
|
||||||
</span>
|
|
||||||
</FormLabel>
|
</FormLabel>
|
||||||
{watchedQrCodeUrl ? (
|
{watchedQrCodeUrl ? (
|
||||||
<div className="relative inline-block">
|
<div className="relative inline-block">
|
||||||
@@ -446,7 +417,9 @@ export default function Affiliate() {
|
|||||||
returnType="file"
|
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">
|
<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收款码"}
|
{qrUploading
|
||||||
|
? "上传中..."
|
||||||
|
: "点击上传\n收款码"}
|
||||||
</div>
|
</div>
|
||||||
</UploadImage>
|
</UploadImage>
|
||||||
)}
|
)}
|
||||||
@@ -519,9 +492,7 @@ export default function Affiliate() {
|
|||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
disabled={
|
disabled={
|
||||||
submitting ||
|
submitting || qrUploading || availableCommission <= 0
|
||||||
qrUploading ||
|
|
||||||
availableCommission <= 0
|
|
||||||
}
|
}
|
||||||
form="withdrawal-form"
|
form="withdrawal-form"
|
||||||
type="submit"
|
type="submit"
|
||||||
@@ -564,106 +535,10 @@ export default function Affiliate() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ProList<API.WithdrawalLog, Record<string, unknown>>
|
<WithdrawalRecords
|
||||||
action={withdrawalListActionRef}
|
cancelMutation={cancelMutation}
|
||||||
header={{
|
t={t}
|
||||||
title: t("withdrawal.records", "Withdrawal Records"),
|
withdrawalListActionRef={withdrawalListActionRef}
|
||||||
}}
|
|
||||||
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>>
|
<ProList<API.UserAffiliate, Record<string, unknown>>
|
||||||
header={{
|
header={{
|
||||||
|
|||||||
@@ -0,0 +1,254 @@
|
|||||||
|
/**
|
||||||
|
* @vitest-environment jsdom
|
||||||
|
*/
|
||||||
|
import {
|
||||||
|
cleanup,
|
||||||
|
fireEvent,
|
||||||
|
render,
|
||||||
|
screen,
|
||||||
|
waitFor,
|
||||||
|
} from "@testing-library/react";
|
||||||
|
import {
|
||||||
|
queryUserCommissionReturnLog,
|
||||||
|
queryWithdrawalLog,
|
||||||
|
} from "@workspace/ui/services/user/user";
|
||||||
|
import type { AxiosResponse } from "axios";
|
||||||
|
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||||
|
import { WithdrawalRecords } from "./withdrawal-records";
|
||||||
|
|
||||||
|
vi.mock("@workspace/ui/services/user/user", () => ({
|
||||||
|
queryUserCommissionReturnLog: vi.fn(),
|
||||||
|
queryWithdrawalLog: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/components/display", () => ({
|
||||||
|
Display: ({
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
type: string;
|
||||||
|
value: number | string | null | undefined;
|
||||||
|
}) => <span>{String(value ?? "")}</span>,
|
||||||
|
}));
|
||||||
|
|
||||||
|
const mockedQueryWithdrawalLog = vi.mocked(queryWithdrawalLog);
|
||||||
|
const mockedQueryUserCommissionReturnLog = vi.mocked(
|
||||||
|
queryUserCommissionReturnLog
|
||||||
|
);
|
||||||
|
|
||||||
|
function t(key: string, defaultValue?: string): string {
|
||||||
|
return defaultValue ?? key;
|
||||||
|
}
|
||||||
|
|
||||||
|
function createResponse<T extends Record<string, unknown>>(
|
||||||
|
data: T
|
||||||
|
): AxiosResponse<API.Response & { data?: T }> {
|
||||||
|
return {
|
||||||
|
data: {
|
||||||
|
code: 200,
|
||||||
|
data,
|
||||||
|
},
|
||||||
|
status: 200,
|
||||||
|
statusText: "OK",
|
||||||
|
headers: {},
|
||||||
|
config: {
|
||||||
|
headers: {} as AxiosResponse["config"]["headers"],
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderWithdrawalRecords(
|
||||||
|
defaultTab: "withdrawal" | "refund" = "withdrawal"
|
||||||
|
) {
|
||||||
|
return render(
|
||||||
|
<WithdrawalRecords
|
||||||
|
cancelMutation={{
|
||||||
|
isPending: false,
|
||||||
|
mutateAsync: vi.fn().mockResolvedValue(undefined),
|
||||||
|
}}
|
||||||
|
defaultTab={defaultTab}
|
||||||
|
t={
|
||||||
|
t as ReturnType<
|
||||||
|
typeof import("react-i18next").useTranslation<"affiliate">
|
||||||
|
>["t"]
|
||||||
|
}
|
||||||
|
withdrawalListActionRef={{ current: undefined }}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
afterEach(() => {
|
||||||
|
cleanup();
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("WithdrawalRecords", () => {
|
||||||
|
it("renders withdrawal records by default", async () => {
|
||||||
|
mockedQueryWithdrawalLog.mockResolvedValue(
|
||||||
|
createResponse({
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
amount: 3200,
|
||||||
|
method: 3,
|
||||||
|
account: "6222",
|
||||||
|
status: 1,
|
||||||
|
created_at: 1_716_800_000,
|
||||||
|
updated_at: 1_716_800_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mockedQueryUserCommissionReturnLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithdrawalRecords();
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedQueryWithdrawalLog).toHaveBeenCalledWith({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("32")).not.toBeNull();
|
||||||
|
expect(screen.getByText("6222")).not.toBeNull();
|
||||||
|
expect(screen.getByText("Approved")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows loading state while withdrawal records are being fetched", () => {
|
||||||
|
mockedQueryWithdrawalLog.mockReturnValue(
|
||||||
|
new Promise(() => {
|
||||||
|
// keep pending to assert the loading state
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mockedQueryUserCommissionReturnLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithdrawalRecords();
|
||||||
|
|
||||||
|
expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows refund empty state independently", async () => {
|
||||||
|
mockedQueryWithdrawalLog.mockResolvedValue(
|
||||||
|
createResponse({
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: 1,
|
||||||
|
user_id: 1,
|
||||||
|
amount: 3200,
|
||||||
|
method: 3,
|
||||||
|
account: "6222",
|
||||||
|
status: 1,
|
||||||
|
created_at: 1_716_800_000,
|
||||||
|
updated_at: 1_716_800_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
mockedQueryUserCommissionReturnLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithdrawalRecords("refund");
|
||||||
|
|
||||||
|
await waitFor(() =>
|
||||||
|
expect(mockedQueryUserCommissionReturnLog).toHaveBeenCalledWith({
|
||||||
|
page: 1,
|
||||||
|
size: 10,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(await screen.findByText("No refund records yet.")).not.toBeNull();
|
||||||
|
expect(screen.queryByText("3200")).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("shows refund error state and retries", async () => {
|
||||||
|
mockedQueryWithdrawalLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
mockedQueryUserCommissionReturnLog
|
||||||
|
.mockRejectedValueOnce(new Error("network failed"))
|
||||||
|
.mockResolvedValueOnce(
|
||||||
|
createResponse({
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
user_id: 1,
|
||||||
|
amount: 500,
|
||||||
|
event_type: 333,
|
||||||
|
content: "order refunded",
|
||||||
|
created_at: 1_716_800_000,
|
||||||
|
updated_at: 1_716_800_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithdrawalRecords("refund");
|
||||||
|
|
||||||
|
expect(
|
||||||
|
await screen.findByText("Failed to load refund records")
|
||||||
|
).not.toBeNull();
|
||||||
|
|
||||||
|
fireEvent.click(screen.getByRole("button", { name: "Retry" }));
|
||||||
|
|
||||||
|
expect(await screen.findByText("Order refund")).not.toBeNull();
|
||||||
|
expect(screen.getByText("5")).not.toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for unknown refund event types and warns once", async () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||||
|
// suppress expected warning in test output
|
||||||
|
});
|
||||||
|
mockedQueryWithdrawalLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
mockedQueryUserCommissionReturnLog.mockResolvedValue(
|
||||||
|
createResponse({
|
||||||
|
list: [
|
||||||
|
{
|
||||||
|
id: 2,
|
||||||
|
user_id: 1,
|
||||||
|
amount: 500,
|
||||||
|
event_type: 999,
|
||||||
|
content: "unexpected",
|
||||||
|
created_at: 1_716_800_000,
|
||||||
|
updated_at: 1_716_800_000,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
total: 1,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
renderWithdrawalRecords("refund");
|
||||||
|
|
||||||
|
expect(await screen.findByText("Other refund")).not.toBeNull();
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(
|
||||||
|
"Unknown commission return event type:",
|
||||||
|
999
|
||||||
|
);
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("keeps mobile-friendly tab layout classes", async () => {
|
||||||
|
mockedQueryWithdrawalLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
mockedQueryUserCommissionReturnLog.mockResolvedValue(
|
||||||
|
createResponse({ list: [], total: 0 })
|
||||||
|
);
|
||||||
|
|
||||||
|
const { container } = renderWithdrawalRecords();
|
||||||
|
|
||||||
|
await screen.findByText("No withdrawal records yet.");
|
||||||
|
|
||||||
|
expect(container.querySelector(".grid-cols-2")).not.toBeNull();
|
||||||
|
expect(container.querySelector(".w-full")).not.toBeNull();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,410 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Card, CardContent } from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@workspace/ui/components/tabs";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
|
import {
|
||||||
|
ProList,
|
||||||
|
type ProListActions,
|
||||||
|
} from "@workspace/ui/composed/pro-list/pro-list";
|
||||||
|
import {
|
||||||
|
queryUserCommissionReturnLog,
|
||||||
|
queryWithdrawalLog,
|
||||||
|
} from "@workspace/ui/services/user/user";
|
||||||
|
import { formatDate } from "@workspace/ui/utils/formatting";
|
||||||
|
import { unitConversion } from "@workspace/ui/utils/unit-conversions";
|
||||||
|
import { AlertCircleIcon, X } from "lucide-react";
|
||||||
|
import { type RefObject, useState } from "react";
|
||||||
|
|
||||||
|
type AffiliateTranslation = ReturnType<
|
||||||
|
typeof import("react-i18next").useTranslation<"affiliate">
|
||||||
|
>["t"];
|
||||||
|
|
||||||
|
const WITHDRAWAL_METHODS = [
|
||||||
|
{ value: 1, label: "支付宝" },
|
||||||
|
{ value: 2, label: "微信" },
|
||||||
|
{ value: 3, label: "银行卡" },
|
||||||
|
{ value: 0, label: "其他" },
|
||||||
|
] as const;
|
||||||
|
|
||||||
|
const warnedCommissionReturnEventTypes = new Set<number>();
|
||||||
|
|
||||||
|
interface WithdrawalRecordsProps {
|
||||||
|
t: AffiliateTranslation;
|
||||||
|
cancelMutation: {
|
||||||
|
isPending: boolean;
|
||||||
|
mutateAsync: (withdrawalId: number) => Promise<unknown>;
|
||||||
|
};
|
||||||
|
defaultTab?: "withdrawal" | "refund";
|
||||||
|
withdrawalListActionRef: RefObject<ProListActions | undefined>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function CurrencyValue({ value }: { value?: number | null }) {
|
||||||
|
const amount = Number(value ?? 0);
|
||||||
|
return <>{unitConversion("centsToDollars", amount)}</>;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWithdrawalMethodLabel(method: number): string {
|
||||||
|
return WITHDRAWAL_METHODS.find((m) => m.value === method)?.label ?? "未知";
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWithdrawalStatusMeta(status: number, t: AffiliateTranslation) {
|
||||||
|
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",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getCommissionReturnEventLabel(
|
||||||
|
eventType: number,
|
||||||
|
t: AffiliateTranslation
|
||||||
|
) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 333:
|
||||||
|
return t("withdrawal.refund.event.333", "Order refund");
|
||||||
|
case 337:
|
||||||
|
return t("withdrawal.refund.event.337", "Withdrawal rejected refund");
|
||||||
|
case 338:
|
||||||
|
return t("withdrawal.refund.event.338", "Withdrawal cancelled refund");
|
||||||
|
default:
|
||||||
|
if (!warnedCommissionReturnEventTypes.has(eventType)) {
|
||||||
|
warnedCommissionReturnEventTypes.add(eventType);
|
||||||
|
console.warn("Unknown commission return event type:", eventType);
|
||||||
|
}
|
||||||
|
return t("withdrawal.refund.event.other", "Other refund");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderLogErrorState(
|
||||||
|
t: AffiliateTranslation,
|
||||||
|
title: string,
|
||||||
|
description: string,
|
||||||
|
retry: () => void
|
||||||
|
) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
<Card className="w-full max-w-md border-dashed">
|
||||||
|
<CardContent className="flex flex-col items-center gap-3 p-6 text-center">
|
||||||
|
<AlertCircleIcon className="h-6 w-6 text-destructive" />
|
||||||
|
<div className="space-y-1">
|
||||||
|
<p className="font-semibold">{title}</p>
|
||||||
|
<p className="text-muted-foreground text-sm">{description}</p>
|
||||||
|
</div>
|
||||||
|
<Button onClick={retry} size="sm" variant="outline">
|
||||||
|
{t("withdrawal.retry", "Retry")}
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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"
|
||||||
|
src={url}
|
||||||
|
height={64}
|
||||||
|
width={64}
|
||||||
|
/>
|
||||||
|
</button>
|
||||||
|
{zoomOpen && (
|
||||||
|
<div
|
||||||
|
aria-label="QR code preview"
|
||||||
|
className="fixed inset-0 z-50 flex items-center justify-center bg-background/80 p-4"
|
||||||
|
role="dialog"
|
||||||
|
>
|
||||||
|
<div className="relative max-w-sm rounded-lg bg-background p-4 shadow-xl">
|
||||||
|
<button
|
||||||
|
aria-label="Close QR code preview"
|
||||||
|
className="absolute top-2 right-2 rounded p-1"
|
||||||
|
onClick={() => setZoomOpen(false)}
|
||||||
|
type="button"
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
<img
|
||||||
|
alt="收款码"
|
||||||
|
className="w-full"
|
||||||
|
height={320}
|
||||||
|
src={url}
|
||||||
|
width={320}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function WithdrawalRecords({
|
||||||
|
t,
|
||||||
|
cancelMutation,
|
||||||
|
defaultTab = "withdrawal",
|
||||||
|
withdrawalListActionRef,
|
||||||
|
}: WithdrawalRecordsProps) {
|
||||||
|
return (
|
||||||
|
<Tabs className="gap-4" defaultValue={defaultTab}>
|
||||||
|
<div className="flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold text-lg">
|
||||||
|
{t("withdrawal.records", "Withdrawal Records")}
|
||||||
|
</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
{t(
|
||||||
|
"withdrawal.recordsDescription",
|
||||||
|
"View withdrawal and refund records separately."
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<TabsList className="grid h-auto w-full grid-cols-2 sm:w-fit">
|
||||||
|
<TabsTrigger className="py-2" value="withdrawal">
|
||||||
|
{t("withdrawal.tabs.withdrawal", "Withdrawal Records")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger className="py-2" value="refund">
|
||||||
|
{t("withdrawal.tabs.refund", "Refund Records")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<TabsContent value="withdrawal">
|
||||||
|
<ProList<API.WithdrawalLog, Record<string, unknown>>
|
||||||
|
action={withdrawalListActionRef}
|
||||||
|
empty={
|
||||||
|
<Empty
|
||||||
|
description={t(
|
||||||
|
"withdrawal.empty.withdrawal",
|
||||||
|
"No withdrawal records yet."
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
error={(retry) =>
|
||||||
|
renderLogErrorState(
|
||||||
|
t,
|
||||||
|
t(
|
||||||
|
"withdrawal.error.withdrawalTitle",
|
||||||
|
"Failed to load withdrawal records"
|
||||||
|
),
|
||||||
|
t(
|
||||||
|
"withdrawal.error.withdrawalDescription",
|
||||||
|
"Please try again later."
|
||||||
|
),
|
||||||
|
retry
|
||||||
|
)
|
||||||
|
}
|
||||||
|
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">
|
||||||
|
<CurrencyValue 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">
|
||||||
|
{t("withdrawal.record.method", "Payout Method")}
|
||||||
|
</span>
|
||||||
|
<span>{getWithdrawalMethodLabel(item.method)}</span>
|
||||||
|
</li>
|
||||||
|
{item.account && (
|
||||||
|
<li className="flex flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("withdrawal.record.account", "Payout Account")}
|
||||||
|
</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">
|
||||||
|
{t("withdrawal.record.qrCode", "Payment QR Code")}
|
||||||
|
</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,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="refund">
|
||||||
|
<ProList<API.CommissionReturnLog, Record<string, unknown>>
|
||||||
|
empty={
|
||||||
|
<Empty
|
||||||
|
description={t(
|
||||||
|
"withdrawal.empty.refund",
|
||||||
|
"No refund records yet."
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
error={(retry) =>
|
||||||
|
renderLogErrorState(
|
||||||
|
t,
|
||||||
|
t(
|
||||||
|
"withdrawal.error.refundTitle",
|
||||||
|
"Failed to load refund records"
|
||||||
|
),
|
||||||
|
t(
|
||||||
|
"withdrawal.error.refundDescription",
|
||||||
|
"Please try again later."
|
||||||
|
),
|
||||||
|
retry
|
||||||
|
)
|
||||||
|
}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<Card className="overflow-hidden">
|
||||||
|
<CardContent className="p-4 text-sm">
|
||||||
|
<ul className="grid gap-3 md:grid-cols-3">
|
||||||
|
<li className="flex flex-col gap-1 font-semibold">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("withdrawal.refund.createdAt", "Created At")}
|
||||||
|
</span>
|
||||||
|
<time>{formatDate(item.created_at)}</time>
|
||||||
|
</li>
|
||||||
|
<li className="flex flex-col gap-1 font-semibold">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("withdrawal.refund.eventType", "Type")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{getCommissionReturnEventLabel(item.event_type, t)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex flex-col gap-1 font-semibold">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("withdrawal.refund.amount", "Amount")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<CurrencyValue value={item.amount} />
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
{item.content && (
|
||||||
|
<li className="flex flex-col gap-1 md:col-span-3">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("withdrawal.refund.content", "Description")}
|
||||||
|
</span>
|
||||||
|
<span className="whitespace-pre-wrap break-words">
|
||||||
|
{item.content}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
)}
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
request={async (pagination) => {
|
||||||
|
const response = await queryUserCommissionReturnLog({
|
||||||
|
page: pagination.page,
|
||||||
|
size: pagination.size,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: response.data.data?.list || [],
|
||||||
|
total: response.data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -14,6 +14,7 @@ import {
|
|||||||
} from "@workspace/ui/components/alert";
|
} from "@workspace/ui/components/alert";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||||
|
import { Spinner } from "@workspace/ui/components/spinner";
|
||||||
import Empty from "@workspace/ui/composed/empty";
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
import {
|
import {
|
||||||
ColumnFilter,
|
ColumnFilter,
|
||||||
@@ -21,7 +22,7 @@ import {
|
|||||||
} from "@workspace/ui/composed/pro-list/column-filter";
|
} from "@workspace/ui/composed/pro-list/column-filter";
|
||||||
import { Pagination } from "@workspace/ui/composed/pro-list/pagination";
|
import { Pagination } from "@workspace/ui/composed/pro-list/pagination";
|
||||||
import { cn } from "@workspace/ui/lib/utils";
|
import { cn } from "@workspace/ui/lib/utils";
|
||||||
import { ListRestart, Loader, RefreshCcw } from "lucide-react";
|
import { AlertCircleIcon, ListRestart, RefreshCcw } from "lucide-react";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useImperativeHandle, useState } from "react";
|
import { useEffect, useImperativeHandle, useState } from "react";
|
||||||
|
|
||||||
@@ -47,6 +48,7 @@ export interface ProListProps<TData, TValue> {
|
|||||||
selectedRowsText: (total: number) => string;
|
selectedRowsText: (total: number) => string;
|
||||||
}>;
|
}>;
|
||||||
empty?: React.ReactNode;
|
empty?: React.ReactNode;
|
||||||
|
error?: (retry: () => void) => React.ReactNode;
|
||||||
}
|
}
|
||||||
export interface ProListActions {
|
export interface ProListActions {
|
||||||
refresh: () => void;
|
refresh: () => void;
|
||||||
@@ -62,6 +64,7 @@ export function ProList<TData, TValue extends Record<string, unknown>>({
|
|||||||
action,
|
action,
|
||||||
texts,
|
texts,
|
||||||
empty,
|
empty,
|
||||||
|
error,
|
||||||
}: ProListProps<TData, TValue>) {
|
}: ProListProps<TData, TValue>) {
|
||||||
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
const [columnFilters, setColumnFilters] = useState<ColumnFiltersState>([]);
|
||||||
const [rowSelection, setRowSelection] = useState<{ [key: number]: boolean }>(
|
const [rowSelection, setRowSelection] = useState<{ [key: number]: boolean }>(
|
||||||
@@ -74,6 +77,7 @@ export function ProList<TData, TValue extends Record<string, unknown>>({
|
|||||||
pageSize: 10,
|
pageSize: 10,
|
||||||
});
|
});
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [requestError, setRequestError] = useState(false);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
data,
|
data,
|
||||||
@@ -96,6 +100,7 @@ export function ProList<TData, TValue extends Record<string, unknown>>({
|
|||||||
|
|
||||||
const fetchData = async () => {
|
const fetchData = async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
|
setRequestError(false);
|
||||||
try {
|
try {
|
||||||
const response = await request(
|
const response = await request(
|
||||||
{
|
{
|
||||||
@@ -108,8 +113,10 @@ export function ProList<TData, TValue extends Record<string, unknown>>({
|
|||||||
);
|
);
|
||||||
setData(response.list);
|
setData(response.list);
|
||||||
setRowCount(response.total);
|
setRowCount(response.total);
|
||||||
} catch (error) {
|
} catch {
|
||||||
console.log("Fetch data error:", error);
|
setData([]);
|
||||||
|
setRowCount(0);
|
||||||
|
setRequestError(true);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -196,7 +203,22 @@ export function ProList<TData, TValue extends Record<string, unknown>>({
|
|||||||
})}
|
})}
|
||||||
>
|
>
|
||||||
<div className="grid grid-cols-1 gap-4">
|
<div className="grid grid-cols-1 gap-4">
|
||||||
{data.length ? (
|
{requestError ? (
|
||||||
|
<div className="flex items-center justify-center py-24">
|
||||||
|
{error?.(fetchData) || (
|
||||||
|
<Alert className="max-w-md">
|
||||||
|
<AlertCircleIcon />
|
||||||
|
<AlertTitle>Failed to load data</AlertTitle>
|
||||||
|
<AlertDescription className="gap-3">
|
||||||
|
<p>Please try again.</p>
|
||||||
|
<Button onClick={fetchData} size="sm" variant="outline">
|
||||||
|
Retry
|
||||||
|
</Button>
|
||||||
|
</AlertDescription>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
) : data.length ? (
|
||||||
data.map((item, index) => {
|
data.map((item, index) => {
|
||||||
const isSelected = !!rowSelection[index];
|
const isSelected = !!rowSelection[index];
|
||||||
|
|
||||||
@@ -221,7 +243,7 @@ export function ProList<TData, TValue extends Record<string, unknown>>({
|
|||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
|
<div className="absolute top-0 z-20 flex h-full w-full items-center justify-center bg-muted/80">
|
||||||
<Loader className="h-4 w-4 animate-spin" />
|
<Spinner aria-label="Loading data" className="h-4 w-4" />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+25
@@ -170,6 +170,16 @@ declare namespace API {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommissionReturnLog = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
amount: number;
|
||||||
|
event_type: number;
|
||||||
|
content: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
type CommissionWithdrawRequest = {
|
type CommissionWithdrawRequest = {
|
||||||
amount: number;
|
amount: number;
|
||||||
method: number;
|
method: number;
|
||||||
@@ -816,11 +826,26 @@ declare namespace API {
|
|||||||
total: number;
|
total: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QueryUserCommissionReturnLogListRequest = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryUserCommissionReturnLogListResponse = {
|
||||||
|
list: CommissionReturnLog[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
type QueryUserCommissionLogParams = {
|
type QueryUserCommissionLogParams = {
|
||||||
page: number;
|
page: number;
|
||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QueryUserCommissionReturnLogParams = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
type QueryUserSubscribeListResponse = {
|
type QueryUserSubscribeListResponse = {
|
||||||
list: UserSubscribe[];
|
list: UserSubscribe[];
|
||||||
total: number;
|
total: number;
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
// @ts-expect-error
|
|
||||||
/* eslint-disable */
|
/* eslint-disable */
|
||||||
import request from "@workspace/ui/lib/request";
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
@@ -144,6 +143,26 @@ export async function queryUserCommissionLog(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Query User Commission Return Log GET /v1/public/user/commission_return_log */
|
||||||
|
export async function queryUserCommissionReturnLog(
|
||||||
|
// 叠加生成的Param类型 (非body参数swagger默认没有生成对象)
|
||||||
|
params: API.QueryUserCommissionReturnLogParams,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<
|
||||||
|
API.Response & { data?: API.QueryUserCommissionReturnLogListResponse }
|
||||||
|
>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/user/commission_return_log`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Commission Withdraw POST /v1/public/user/commission_withdraw */
|
/** Commission Withdraw POST /v1/public/user/commission_withdraw */
|
||||||
export async function commissionWithdraw(
|
export async function commissionWithdraw(
|
||||||
body: API.CommissionWithdrawRequest,
|
body: API.CommissionWithdrawRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user