Compare commits

..

5 Commits

Author SHA1 Message Date
shanshanzhong147 a6246c2453 修复(#14): 过滤提现查询非法用户ID 2026-06-09 07:19:00 -07:00
shanshanzhong147 09c5216f60 修复(#14): 调整 USDT 提现方式枚举口径
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
2026-06-09 06:37:15 -07:00
shanshanzhong147 4d84095af2 修复(#14): 支持 USDT 提现方式展示 2026-06-09 06:37:14 -07:00
shanshanzhong147 d66b664198 修复(#122): 补齐促销价档位提交
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
2026-05-29 02:36:30 -07:00
shanshanzhong147 a7d89acc7f 新功能(#113): 发布促销管理后台页面到main
PR Check / Lint, Check, Test and Build (push) Has been cancelled
Build and Release / Build (push) Has been cancelled
2026-05-29 00:40:33 -07:00
11 changed files with 75 additions and 632 deletions
@@ -16,11 +16,10 @@
"0": "Status",
"1": "Pending",
"2": "Paid",
"3": "Closed",
"4": "Failed",
"5": "Finished",
"6": "Claimed",
"7": "Refunded"
"3": "Cancelled",
"4": "Closed",
"5": "Completed",
"6": "Refunded"
},
"subscribe": "Subscribe",
"subscribePrice": "Subscription Price",
@@ -34,8 +33,5 @@
"4": "Recharge"
},
"updateTime": "Update Time",
"user": "User",
"refundFailed": "Refund failed. Please try again.",
"statusClaimedBlocked": "Claimed and refunded states must use the dedicated processing flow.",
"statusUpdateFailed": "Status update failed. Please try again."
"user": "User"
}
@@ -16,11 +16,10 @@
"0": "状态",
"1": "待支付",
"2": "已支付",
"3": "已关闭",
"4": "失败",
"3": "已取消",
"4": "已关闭",
"5": "已完成",
"6": "处理中",
"7": "已退费"
"6": "已退费"
},
"subscribe": "订阅",
"subscribePrice": "订阅价格",
@@ -34,8 +33,5 @@
"4": "充值"
},
"updateTime": "更新时间",
"user": "用户",
"refundFailed": "退费失败,请重试。",
"statusClaimedBlocked": "处理中和已退费状态必须走专用流程,不能通过通用改状态提交。",
"statusUpdateFailed": "状态更新失败,请重试。"
"user": "用户"
}
@@ -1,466 +0,0 @@
/**
* @vitest-environment jsdom
*/
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import {
cleanup,
fireEvent,
render,
screen,
waitFor,
} from "@testing-library/react";
import {
activateOrder,
getOrderList,
refundOrder,
updateOrderStatus,
} from "@workspace/ui/services/admin/order";
import type { AxiosResponse } from "axios";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import Order, { canManuallyUpdateOrderStatus, canRefundOrderStatus } from ".";
vi.mock("react-i18next", () => ({
useTranslation: () => ({
t: (_key: string, fallback: string): string => fallback,
}),
}));
vi.mock("@tanstack/react-router", () => ({
useSearch: () => ({}),
}));
vi.mock("@/stores/global", () => ({
useGlobalStore: (
selector?: (state: {
user: { is_admin: boolean };
common: { currency: { currency_symbol: string } };
}) => unknown
) => {
const state = {
user: {
is_admin: true,
},
common: {
currency: {
currency_symbol: "$",
},
},
};
return typeof selector === "function" ? selector(state) : state;
},
}));
vi.mock("@/stores/subscribe", () => ({
useSubscribe: () => ({
subscribes: [
{
id: 9,
name: "Pro Plan",
},
],
getSubscribeName: (id?: number) => (id === 9 ? "Pro Plan" : "--"),
}),
}));
vi.mock("@/utils/common", () => ({
formatDate: (timestamp: number) => `date-${timestamp}`,
}));
vi.mock("../user/user-detail", () => ({
UserDetail: ({ id }: { id: number }) => <span>User #{id}</span>,
}));
vi.mock("@/components/display", () => ({
Display: ({ value }: { value: number }) => <span>{value}</span>,
}));
const toastSuccess = vi.fn();
const toastError = vi.fn();
vi.mock("sonner", () => ({
toast: {
success: (...args: unknown[]) => toastSuccess(...args),
error: (...args: unknown[]) => toastError(...args),
},
}));
vi.mock("@workspace/ui/services/admin/order", () => ({
activateOrder: vi.fn(),
getOrderList: vi.fn(),
refundOrder: vi.fn(),
updateOrderStatus: vi.fn(),
}));
vi.mock("@workspace/ui/components/alert-dialog", () => ({
AlertDialog: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogTrigger: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogContent: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogHeader: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogTitle: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogDescription: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogFooter: ({ children }: { children: React.ReactNode }) => (
<div>{children}</div>
),
AlertDialogCancel: ({
children,
...props
}: React.ComponentProps<"button">) => <button {...props}>{children}</button>,
AlertDialogAction: ({
children,
...props
}: React.ComponentProps<"button">) => <button {...props}>{children}</button>,
}));
vi.mock("@workspace/ui/composed/combobox", () => ({
Combobox: ({
options = [],
value,
onChange,
placeholder,
}: {
options?: Array<{ label: string; value: number | string }>;
value?: number | string;
onChange: (value: number) => void;
placeholder?: string;
}) => (
<label>
<span>{placeholder}</span>
<select
aria-label={placeholder}
onChange={(event) => onChange(Number(event.target.value))}
value={String(value ?? "")}
>
{options.map((option) => (
<option key={String(option.value)} value={String(option.value)}>
{option.label}
</option>
))}
</select>
</label>
),
}));
vi.mock("@workspace/ui/composed/pro-table/pro-table", async () => {
const React = await import("react");
type Column = {
accessorKey?: string;
cell?: (props: {
row: {
original: Record<string, unknown>;
getValue: (key: string) => unknown;
};
}) => React.ReactNode;
};
return {
ProTable: ({
columns,
request,
actions,
}: {
columns: Column[];
request: (
pagination: { page: number; size: number },
filter: Record<string, unknown>
) => Promise<{ list: Record<string, unknown>[]; total: number }>;
actions?: {
render?: (row: Record<string, unknown>) => React.ReactNode[];
};
}) => {
const [state, setState] = React.useState<{
data: Record<string, unknown>[];
error: boolean;
loading: boolean;
}>({
data: [],
error: false,
loading: true,
});
React.useEffect(() => {
let cancelled = false;
request({ page: 1, size: 200 }, {})
.then((response) => {
if (cancelled) return;
setState({
data: response.list,
error: false,
loading: false,
});
})
.catch(() => {
if (cancelled) return;
setState({
data: [],
error: true,
loading: false,
});
});
return () => {
cancelled = true;
};
}, [request]);
if (state.loading) {
return <div aria-label="Loading data" role="status" />;
}
if (state.error) {
return <div>Failed to load orders</div>;
}
if (state.data.length === 0) {
return <div>No orders</div>;
}
return (
<div>
{state.data.map((row) => (
<article key={String(row.id)}>
{columns.map((column, index) => {
const value = column.accessorKey
? row[column.accessorKey]
: undefined;
const rowApi = {
original: row,
getValue: (key: string) => row[key],
};
return (
<div key={`${row.id}-${index}`}>
{column.cell
? column.cell({ row: rowApi })
: typeof value === "object"
? JSON.stringify(value)
: String(value ?? "")}
</div>
);
})}
<div>{actions?.render?.(row)}</div>
</article>
))}
</div>
);
},
};
});
const mockedGetOrderList = vi.mocked(getOrderList);
const mockedRefundOrder = vi.mocked(refundOrder);
const mockedUpdateOrderStatus = vi.mocked(updateOrderStatus);
const mockedActivateOrder = vi.mocked(activateOrder);
function createOrderListResponse(
list: API.Order[]
): AxiosResponse<API.Response & { data?: API.GetOrderListResponse }> {
return {
data: {
code: 200,
data: {
list,
total: list.length,
},
},
status: 200,
statusText: "OK",
headers: {},
config: {
headers: {} as AxiosResponse["config"]["headers"],
},
};
}
function createSuccessResponse(): AxiosResponse<
API.Response & { data?: unknown }
> {
return {
data: {
code: 200,
},
status: 200,
statusText: "OK",
headers: {},
config: {
headers: {} as AxiosResponse["config"]["headers"],
},
};
}
function createOrder(status: number): API.Order {
return {
id: status,
amount: 1999,
coupon_discount: 0,
discount: 0,
fee_amount: 0,
order_no: `ORDER-${status}`,
payment: {
name: "Stripe",
platform: "stripe",
},
price: 1999,
quantity: 1,
status,
status_name:
status === 6 ? "claimed" : status === 7 ? "refunded" : "pending",
subscribe_id: 9,
trade_no: `TRADE-${status}`,
type: 1,
updated_at: 1_716_800_000,
user_id: 88,
} as API.Order;
}
function renderOrderPage() {
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
},
},
});
return render(
<QueryClientProvider client={queryClient}>
<Order />
</QueryClientProvider>
);
}
beforeEach(() => {
localStorage.setItem("timezone", "UTC");
});
afterEach(() => {
cleanup();
vi.clearAllMocks();
});
describe("Order", () => {
it("uses the dedicated refund endpoint for refundable orders", async () => {
mockedGetOrderList.mockResolvedValue(
createOrderListResponse([createOrder(2)])
);
mockedRefundOrder.mockResolvedValue(createSuccessResponse());
renderOrderPage();
expect(await screen.findByText("ORDER-2")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Confirm refund" }));
await waitFor(() =>
expect(mockedRefundOrder).toHaveBeenCalledWith(
{ id: 2 },
{ skipErrorHandler: true }
)
);
expect(toastSuccess).toHaveBeenCalledWith("Refund completed.");
});
it("blocks status 6 and 7 from the generic status editor", async () => {
mockedGetOrderList.mockResolvedValue(
createOrderListResponse([createOrder(1)])
);
mockedUpdateOrderStatus.mockResolvedValue(createSuccessResponse());
renderOrderPage();
const statusSelect = await screen.findByLabelText("Status");
const optionValues = Array.from(
statusSelect.querySelectorAll("option")
).map((option) => option.textContent);
expect(optionValues).not.toContain("Claimed");
expect(optionValues).not.toContain("Refunded");
expect(canManuallyUpdateOrderStatus(6)).toBe(false);
expect(canManuallyUpdateOrderStatus(7)).toBe(false);
fireEvent.change(statusSelect, { target: { value: "2" } });
await waitFor(() =>
expect(mockedUpdateOrderStatus).toHaveBeenCalledWith(
{ id: 1, status: 2 },
{ skipErrorHandler: true }
)
);
});
it("renders loading state while orders are being fetched", () => {
mockedGetOrderList.mockReturnValue(
new Promise(() => {
// Keep the request pending so the loading UI remains visible.
}) as ReturnType<typeof getOrderList>
);
renderOrderPage();
expect(screen.getByRole("status", { name: "Loading data" })).not.toBeNull();
});
it("renders the empty state when there are no orders", async () => {
mockedGetOrderList.mockResolvedValue(createOrderListResponse([]));
renderOrderPage();
expect(await screen.findByText("No orders")).not.toBeNull();
});
it("shows an error toast when refund fails", async () => {
mockedGetOrderList.mockResolvedValue(
createOrderListResponse([createOrder(5)])
);
mockedRefundOrder.mockRejectedValue(new Error("refund failed"));
renderOrderPage();
expect(await screen.findByText("ORDER-5")).not.toBeNull();
fireEvent.click(screen.getByRole("button", { name: "Confirm refund" }));
await waitFor(() =>
expect(toastError).toHaveBeenCalledWith(
"Refund failed. Please try again."
)
);
});
it("renders the error state when the order list request fails", async () => {
mockedGetOrderList.mockRejectedValue(new Error("network failed"));
renderOrderPage();
expect(await screen.findByText("Failed to load orders")).not.toBeNull();
});
it("keeps the order actions visible on a narrow viewport", async () => {
window.innerWidth = 375;
window.dispatchEvent(new Event("resize"));
mockedGetOrderList.mockResolvedValue(
createOrderListResponse([createOrder(2), createOrder(6), createOrder(7)])
);
renderOrderPage();
expect(await screen.findByText("ORDER-2")).not.toBeNull();
expect(screen.getByText("Claimed")).not.toBeNull();
expect(screen.getAllByText("Refund").length).toBe(1);
expect(canRefundOrderStatus(2)).toBe(true);
expect(canRefundOrderStatus(6)).toBe(false);
expect(canRefundOrderStatus(7)).toBe(false);
expect(mockedActivateOrder).not.toHaveBeenCalled();
});
});
+29 -109
View File
@@ -40,31 +40,7 @@ import { useSubscribe } from "@/stores/subscribe";
import { formatDate } from "@/utils/common";
import { UserDetail } from "../user/user-detail";
const ORDER_STATUS_PENDING = 1;
const ORDER_STATUS_PAID = 2;
const ORDER_STATUS_CLOSED = 3;
const ORDER_STATUS_FAILED = 4;
const ORDER_STATUS_FINISHED = 5;
const ORDER_STATUS_CLAIMED = 6;
const ORDER_STATUS_REFUNDED = 7;
const MANUALLY_EDITABLE_ORDER_STATUSES = [
ORDER_STATUS_PENDING,
ORDER_STATUS_PAID,
ORDER_STATUS_CLOSED,
ORDER_STATUS_FAILED,
ORDER_STATUS_FINISHED,
] as const;
export function canManuallyUpdateOrderStatus(status: number) {
return MANUALLY_EDITABLE_ORDER_STATUSES.includes(
status as (typeof MANUALLY_EDITABLE_ORDER_STATUSES)[number]
);
}
export function canRefundOrderStatus(status: number) {
return [ORDER_STATUS_PAID, ORDER_STATUS_FINISHED].includes(status);
}
const REFUNDED_ORDER_STATUS = 6;
export default function Order() {
const { t } = useTranslation("order");
@@ -80,46 +56,30 @@ export default function Order() {
subscribe_id: sp.subscribe_id || undefined,
};
const displayStatusOptions = [
const statusOptions = [
{
value: ORDER_STATUS_PENDING,
value: 1,
label: t("status.1", "Pending"),
className: "bg-orange-500",
},
{ value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
{
value: ORDER_STATUS_PAID,
label: t("status.2", "Paid"),
className: "bg-green-500",
},
{
value: ORDER_STATUS_CLOSED,
label: t("status.3", "Closed"),
value: 3,
label: t("status.3", "Cancelled"),
className: "bg-gray-500",
},
{ value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
{
value: ORDER_STATUS_FAILED,
label: t("status.4", "Failed"),
className: "bg-red-500",
},
{
value: ORDER_STATUS_FINISHED,
label: t("status.5", "Finished"),
value: 5,
label: t("status.5", "Completed"),
className: "bg-green-500",
},
{
value: ORDER_STATUS_CLAIMED,
label: t("status.6", "Claimed"),
className: "bg-amber-500",
},
{
value: ORDER_STATUS_REFUNDED,
label: t("status.7", "Refunded"),
value: REFUNDED_ORDER_STATUS,
label: t("status.6", "Refunded"),
className: "bg-red-500",
},
];
const editableStatusOptions = displayStatusOptions.filter((option) =>
canManuallyUpdateOrderStatus(option.value)
);
const typeOptions = [
{ value: 1, label: t("type.1", "New Purchase") },
@@ -135,36 +95,29 @@ export default function Order() {
const refundMutation = useMutation({
mutationFn: async (order: API.Order) => {
await refundOrder(
{ id: order.id },
{
skipErrorHandler: true,
}
);
await refundOrder({ id: order.id });
},
onSuccess: () => {
toast.success(t("refundSuccess", "Refund completed."));
setConfirmingOrderId(null);
ref.current?.refresh();
},
onError: () => {
toast.error(t("refundFailed", "Refund failed. Please try again."));
onError: (error) => {
console.error("Refund order failed", error);
setConfirmingOrderId(null);
},
});
const isRefundedOrder = (order: API.Order) =>
order.status === ORDER_STATUS_REFUNDED ||
order.status_name === t("status.7", "Refunded") ||
order.status === REFUNDED_ORDER_STATUS ||
order.status_name === t("status.6", "Refunded") ||
order.status_name?.toLowerCase() === "refunded";
const canRefundOrder = (order: API.Order) =>
canRefundOrders &&
canRefundOrderStatus(order.status) &&
!isRefundedOrder(order);
canRefundOrders && !isRefundedOrder(order);
return (
<ProTable<API.Order, Record<string, unknown>>
<ProTable<API.Order, any>
action={ref}
actions={{
render: (order) => {
@@ -220,11 +173,7 @@ export default function Order() {
onClick={async (event) => {
event.preventDefault();
if (isPending) return;
try {
await refundMutation.mutateAsync(order);
} catch {
// Errors are surfaced through the mutation onError handler.
}
await refundMutation.mutateAsync(order);
}}
>
{isPending
@@ -386,55 +335,26 @@ export default function Order() {
header: t("status.0", "Status"),
cell: ({ row }) => {
const order = row.original as API.Order;
const option = displayStatusOptions.find(
const option = statusOptions.find(
(opt) => opt.value === order.status
);
if (
[ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED, ORDER_STATUS_FAILED]
.map(String)
.includes(String(row.getValue("status")))
) {
if ([1, 3, 4].includes(row.getValue("status"))) {
return (
<div className="flex items-center gap-1">
<Combobox<number, false>
className={cn(option?.className)}
onChange={async (value) => {
if (!canManuallyUpdateOrderStatus(value)) {
toast.error(
t(
"statusClaimedBlocked",
"Claimed and refunded states must use the dedicated processing flow."
)
);
return;
}
try {
await updateOrderStatus(
{
id: order.id,
status: value,
},
{
skipErrorHandler: true,
}
);
ref.current?.refresh();
} catch {
toast.error(
t(
"statusUpdateFailed",
"Status update failed. Please try again."
)
);
}
await updateOrderStatus({
id: order.id,
status: value,
});
ref.current?.refresh();
}}
options={editableStatusOptions}
options={statusOptions}
placeholder={t("status.0", "Status")}
value={order.status}
/>
{[ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED].includes(
order.status
) && (
{[1, 3].includes(order.status) && (
<Button
onClick={async () => {
await activateOrder({ order_no: order.order_no });
@@ -467,7 +387,7 @@ export default function Order() {
{
key: "status",
placeholder: t("status.0", "Status"),
options: displayStatusOptions.map((item) => ({
options: statusOptions.map((item) => ({
label: item.label,
value: String(item.value),
})),
+11 -5
View File
@@ -44,7 +44,7 @@ const WITHDRAWAL_METHOD_MAP: Record<number, string> = {
0: "其他",
1: "支付宝",
2: "微信",
3: "银行卡",
3: "USDT(TRC20)",
};
const rejectSchema = z.object({
@@ -55,6 +55,12 @@ const rejectSchema = z.object({
.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>;
@@ -143,7 +149,7 @@ export default function WithdrawalPage() {
const methodOptions = [
{ value: "1", label: "支付宝" },
{ value: "2", label: "微信" },
{ value: "3", label: "银行卡" },
{ value: "3", label: "USDT(TRC20)" },
{ value: "0", label: "其他" },
];
@@ -309,9 +315,9 @@ export default function WithdrawalPage() {
const { data } = await getWithdrawalList({
page: pagination.page,
size: pagination.size,
status: filter.status ? Number(filter.status) : undefined,
method: filter.method ? Number(filter.method) : undefined,
user_id: filter.user_id ? Number(filter.user_id) : undefined,
status: toOptionalNumber(filter.status),
method: toOptionalNumber(filter.method),
user_id: toOptionalNumber(filter.user_id),
});
return {
@@ -9,7 +9,6 @@
"originalPrice": "Original Price (Monthly)",
"price": "Price",
"productDiscount": "Product Discount",
"promoDiscount": "Promo Discount",
"total": "Total"
},
"buy": "Buy",
@@ -9,7 +9,6 @@
"originalPrice": "原价(按月)",
"price": "价格",
"productDiscount": "商品折扣",
"promoDiscount": "促销优惠",
"total": "总价"
},
"buy": "购买",
@@ -10,7 +10,6 @@ interface SubscribeBillingProps {
unit_price: number;
unit_time: string;
subscribe_discount: number;
promo_discount: number;
show_original_price?: boolean;
}
>;
@@ -67,14 +66,6 @@ export function SubscribeBilling({ order }: Readonly<SubscribeBillingProps>) {
<Display type="currency" value={order?.discount} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("billing.promoDiscount", "Promo Discount")}
</span>
<span>
<Display type="currency" value={order?.promo_discount} />
</span>
</li>
<li>
<span className="text-muted-foreground">
{t("billing.couponDiscount", "Coupon Discount")}
@@ -18,10 +18,10 @@ export function PromoInfo({ discount }: Readonly<PromoInfoProps>) {
<span className="font-medium">
{promo.rule_name || t("promo.available", "Promo Available")}
</span>
{promo.expires_at && (
{promo.end_time && (
<span className="text-muted-foreground text-xs">
{t("promo.validUntil", "Valid until")}:{" "}
{new Date(promo.expires_at * 1000).toLocaleString()}
{new Date(promo.end_time * 1000).toLocaleString()}
</span>
)}
</div>
+23 -19
View File
@@ -63,7 +63,7 @@ import { useGlobalStore } from "@/stores/global";
const WITHDRAWAL_METHODS = [
{ value: 1, label: "支付宝" },
{ value: 2, label: "微信" },
{ value: 3, label: "银行卡" },
{ value: 3, label: "USDT(TRC20)" },
{ value: 0, label: "其他" },
] as const;
@@ -111,13 +111,21 @@ function QrCodeView({ url }: { url: string }) {
<img
alt="收款码"
className="h-16 w-16 cursor-zoom-in object-cover"
height={64}
src={url}
width={64}
/>
</button>
{zoomOpen && (
<Dialog onOpenChange={setZoomOpen} open={zoomOpen}>
<DialogContent className="max-w-sm">
<img alt="收款码" className="w-full" src={url} />
<img
alt="收款码"
className="w-full"
height={320}
src={url}
width={320}
/>
</DialogContent>
</Dialog>
)}
@@ -153,18 +161,16 @@ const withdrawalFormSchema = z
if (!data.account) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "银行卡收款账号为必填",
path: ["account"],
});
}
} else if (data.method === 0) {
if (!data.account && !data.content) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "收款账号和备注至少填一项",
message: "USDT(TRC20) 地址为必填",
path: ["account"],
});
}
} else if (data.method === 0 && !(data.account || data.content)) {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: "收款账号和备注至少填一项",
path: ["account"],
});
}
});
@@ -421,9 +427,7 @@ export default function Affiliate() {
<FormItem>
<FormLabel>
<span className="ml-1 text-destructive">
*
</span>
<span className="ml-1 text-destructive">*</span>
</FormLabel>
{watchedQrCodeUrl ? (
<div className="relative inline-block">
@@ -446,7 +450,9 @@ export default function Affiliate() {
returnType="file"
>
<div className="flex h-24 w-24 flex-col items-center justify-center rounded border-2 border-dashed text-muted-foreground text-xs">
{qrUploading ? "上传中..." : "点击上传\n收款码"}
{qrUploading
? "上传中..."
: "点击上传\n收款码"}
</div>
</UploadImage>
)}
@@ -474,7 +480,7 @@ export default function Affiliate() {
<Input
placeholder={
watchedMethod === 3
? "请输入银行卡号或账户信息"
? "请输入 USDT(TRC20) 收款地址"
: "请输入收款账号(与备注至少填一项)"
}
{...field}
@@ -519,9 +525,7 @@ export default function Affiliate() {
</Button>
<Button
disabled={
submitting ||
qrUploading ||
availableCommission <= 0
submitting || qrUploading || availableCommission <= 0
}
form="withdrawal-form"
type="submit"
+1 -3
View File
@@ -548,7 +548,6 @@ declare namespace API {
price: number;
amount: number;
discount: number;
promo_discount: number;
gift_amount: number;
coupon: string;
coupon_discount: number;
@@ -1025,9 +1024,8 @@ declare namespace API {
type SubscribeDiscountPromo = {
rule_name?: string;
rule_type?: string;
promo_price: number;
expires_at?: number;
end_time?: number;
};
type SubscribeGroup = {