Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bef8a12064 |
@@ -16,11 +16,10 @@
|
|||||||
"0": "Status",
|
"0": "Status",
|
||||||
"1": "Pending",
|
"1": "Pending",
|
||||||
"2": "Paid",
|
"2": "Paid",
|
||||||
"3": "Closed",
|
"3": "Cancelled",
|
||||||
"4": "Failed",
|
"4": "Closed",
|
||||||
"5": "Finished",
|
"5": "Completed",
|
||||||
"6": "Claimed",
|
"6": "Refunded"
|
||||||
"7": "Refunded"
|
|
||||||
},
|
},
|
||||||
"subscribe": "Subscribe",
|
"subscribe": "Subscribe",
|
||||||
"subscribePrice": "Subscription Price",
|
"subscribePrice": "Subscription Price",
|
||||||
@@ -34,8 +33,5 @@
|
|||||||
"4": "Recharge"
|
"4": "Recharge"
|
||||||
},
|
},
|
||||||
"updateTime": "Update Time",
|
"updateTime": "Update Time",
|
||||||
"user": "User",
|
"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."
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,11 +16,10 @@
|
|||||||
"0": "状态",
|
"0": "状态",
|
||||||
"1": "待支付",
|
"1": "待支付",
|
||||||
"2": "已支付",
|
"2": "已支付",
|
||||||
"3": "已关闭",
|
"3": "已取消",
|
||||||
"4": "失败",
|
"4": "已关闭",
|
||||||
"5": "已完成",
|
"5": "已完成",
|
||||||
"6": "处理中",
|
"6": "已退费"
|
||||||
"7": "已退费"
|
|
||||||
},
|
},
|
||||||
"subscribe": "订阅",
|
"subscribe": "订阅",
|
||||||
"subscribePrice": "订阅价格",
|
"subscribePrice": "订阅价格",
|
||||||
@@ -34,8 +33,5 @@
|
|||||||
"4": "充值"
|
"4": "充值"
|
||||||
},
|
},
|
||||||
"updateTime": "更新时间",
|
"updateTime": "更新时间",
|
||||||
"user": "用户",
|
"user": "用户"
|
||||||
"refundFailed": "退费失败,请重试。",
|
|
||||||
"statusClaimedBlocked": "处理中和已退费状态必须走专用流程,不能通过通用改状态提交。",
|
|
||||||
"statusUpdateFailed": "状态更新失败,请重试。"
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -40,31 +40,7 @@ import { useSubscribe } from "@/stores/subscribe";
|
|||||||
import { formatDate } from "@/utils/common";
|
import { formatDate } from "@/utils/common";
|
||||||
import { UserDetail } from "../user/user-detail";
|
import { UserDetail } from "../user/user-detail";
|
||||||
|
|
||||||
const ORDER_STATUS_PENDING = 1;
|
const REFUNDED_ORDER_STATUS = 6;
|
||||||
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);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default function Order() {
|
export default function Order() {
|
||||||
const { t } = useTranslation("order");
|
const { t } = useTranslation("order");
|
||||||
@@ -80,46 +56,30 @@ export default function Order() {
|
|||||||
subscribe_id: sp.subscribe_id || undefined,
|
subscribe_id: sp.subscribe_id || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
const displayStatusOptions = [
|
const statusOptions = [
|
||||||
{
|
{
|
||||||
value: ORDER_STATUS_PENDING,
|
value: 1,
|
||||||
label: t("status.1", "Pending"),
|
label: t("status.1", "Pending"),
|
||||||
className: "bg-orange-500",
|
className: "bg-orange-500",
|
||||||
},
|
},
|
||||||
|
{ value: 2, label: t("status.2", "Paid"), className: "bg-green-500" },
|
||||||
{
|
{
|
||||||
value: ORDER_STATUS_PAID,
|
value: 3,
|
||||||
label: t("status.2", "Paid"),
|
label: t("status.3", "Cancelled"),
|
||||||
className: "bg-green-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ORDER_STATUS_CLOSED,
|
|
||||||
label: t("status.3", "Closed"),
|
|
||||||
className: "bg-gray-500",
|
className: "bg-gray-500",
|
||||||
},
|
},
|
||||||
|
{ value: 4, label: t("status.4", "Closed"), className: "bg-red-500" },
|
||||||
{
|
{
|
||||||
value: ORDER_STATUS_FAILED,
|
value: 5,
|
||||||
label: t("status.4", "Failed"),
|
label: t("status.5", "Completed"),
|
||||||
className: "bg-red-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ORDER_STATUS_FINISHED,
|
|
||||||
label: t("status.5", "Finished"),
|
|
||||||
className: "bg-green-500",
|
className: "bg-green-500",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
value: ORDER_STATUS_CLAIMED,
|
value: REFUNDED_ORDER_STATUS,
|
||||||
label: t("status.6", "Claimed"),
|
label: t("status.6", "Refunded"),
|
||||||
className: "bg-amber-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
value: ORDER_STATUS_REFUNDED,
|
|
||||||
label: t("status.7", "Refunded"),
|
|
||||||
className: "bg-red-500",
|
className: "bg-red-500",
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const editableStatusOptions = displayStatusOptions.filter((option) =>
|
|
||||||
canManuallyUpdateOrderStatus(option.value)
|
|
||||||
);
|
|
||||||
|
|
||||||
const typeOptions = [
|
const typeOptions = [
|
||||||
{ value: 1, label: t("type.1", "New Purchase") },
|
{ value: 1, label: t("type.1", "New Purchase") },
|
||||||
@@ -135,36 +95,29 @@ export default function Order() {
|
|||||||
|
|
||||||
const refundMutation = useMutation({
|
const refundMutation = useMutation({
|
||||||
mutationFn: async (order: API.Order) => {
|
mutationFn: async (order: API.Order) => {
|
||||||
await refundOrder(
|
await refundOrder({ id: order.id });
|
||||||
{ id: order.id },
|
|
||||||
{
|
|
||||||
skipErrorHandler: true,
|
|
||||||
}
|
|
||||||
);
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
toast.success(t("refundSuccess", "Refund completed."));
|
toast.success(t("refundSuccess", "Refund completed."));
|
||||||
setConfirmingOrderId(null);
|
setConfirmingOrderId(null);
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
},
|
},
|
||||||
onError: () => {
|
onError: (error) => {
|
||||||
toast.error(t("refundFailed", "Refund failed. Please try again."));
|
console.error("Refund order failed", error);
|
||||||
setConfirmingOrderId(null);
|
setConfirmingOrderId(null);
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
const isRefundedOrder = (order: API.Order) =>
|
const isRefundedOrder = (order: API.Order) =>
|
||||||
order.status === ORDER_STATUS_REFUNDED ||
|
order.status === REFUNDED_ORDER_STATUS ||
|
||||||
order.status_name === t("status.7", "Refunded") ||
|
order.status_name === t("status.6", "Refunded") ||
|
||||||
order.status_name?.toLowerCase() === "refunded";
|
order.status_name?.toLowerCase() === "refunded";
|
||||||
|
|
||||||
const canRefundOrder = (order: API.Order) =>
|
const canRefundOrder = (order: API.Order) =>
|
||||||
canRefundOrders &&
|
canRefundOrders && !isRefundedOrder(order);
|
||||||
canRefundOrderStatus(order.status) &&
|
|
||||||
!isRefundedOrder(order);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.Order, Record<string, unknown>>
|
<ProTable<API.Order, any>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (order) => {
|
render: (order) => {
|
||||||
@@ -220,11 +173,7 @@ export default function Order() {
|
|||||||
onClick={async (event) => {
|
onClick={async (event) => {
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
if (isPending) return;
|
if (isPending) return;
|
||||||
try {
|
await refundMutation.mutateAsync(order);
|
||||||
await refundMutation.mutateAsync(order);
|
|
||||||
} catch {
|
|
||||||
// Errors are surfaced through the mutation onError handler.
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{isPending
|
{isPending
|
||||||
@@ -386,55 +335,26 @@ export default function Order() {
|
|||||||
header: t("status.0", "Status"),
|
header: t("status.0", "Status"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
const order = row.original as API.Order;
|
const order = row.original as API.Order;
|
||||||
const option = displayStatusOptions.find(
|
const option = statusOptions.find(
|
||||||
(opt) => opt.value === order.status
|
(opt) => opt.value === order.status
|
||||||
);
|
);
|
||||||
if (
|
if ([1, 3, 4].includes(row.getValue("status"))) {
|
||||||
[ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED, ORDER_STATUS_FAILED]
|
|
||||||
.map(String)
|
|
||||||
.includes(String(row.getValue("status")))
|
|
||||||
) {
|
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<Combobox<number, false>
|
<Combobox<number, false>
|
||||||
className={cn(option?.className)}
|
className={cn(option?.className)}
|
||||||
onChange={async (value) => {
|
onChange={async (value) => {
|
||||||
if (!canManuallyUpdateOrderStatus(value)) {
|
await updateOrderStatus({
|
||||||
toast.error(
|
id: order.id,
|
||||||
t(
|
status: value,
|
||||||
"statusClaimedBlocked",
|
});
|
||||||
"Claimed and refunded states must use the dedicated processing flow."
|
ref.current?.refresh();
|
||||||
)
|
|
||||||
);
|
|
||||||
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."
|
|
||||||
)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}}
|
}}
|
||||||
options={editableStatusOptions}
|
options={statusOptions}
|
||||||
placeholder={t("status.0", "Status")}
|
placeholder={t("status.0", "Status")}
|
||||||
value={order.status}
|
value={order.status}
|
||||||
/>
|
/>
|
||||||
{[ORDER_STATUS_PENDING, ORDER_STATUS_CLOSED].includes(
|
{[1, 3].includes(order.status) && (
|
||||||
order.status
|
|
||||||
) && (
|
|
||||||
<Button
|
<Button
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
await activateOrder({ order_no: order.order_no });
|
await activateOrder({ order_no: order.order_no });
|
||||||
@@ -467,7 +387,7 @@ export default function Order() {
|
|||||||
{
|
{
|
||||||
key: "status",
|
key: "status",
|
||||||
placeholder: t("status.0", "Status"),
|
placeholder: t("status.0", "Status"),
|
||||||
options: displayStatusOptions.map((item) => ({
|
options: statusOptions.map((item) => ({
|
||||||
label: item.label,
|
label: item.label,
|
||||||
value: String(item.value),
|
value: String(item.value),
|
||||||
})),
|
})),
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"inviteCode": "Invite Code",
|
"inviteCode": "Invite Code",
|
||||||
"inviteRecords": "Invite Records",
|
"inviteRecords": "Invite Records",
|
||||||
"noReason": "None",
|
"noReason": "None",
|
||||||
|
"recordsTabs": {
|
||||||
|
"withdrawal": "Withdrawal Records",
|
||||||
|
"refund": "Refund Records"
|
||||||
|
},
|
||||||
"rejectReason": "Reject Reason",
|
"rejectReason": "Reject Reason",
|
||||||
"registrationTime": "Registration Time",
|
"registrationTime": "Registration Time",
|
||||||
"submitWithdraw": "Submit Request",
|
"submitWithdraw": "Submit Request",
|
||||||
@@ -28,10 +32,27 @@
|
|||||||
"withdrawDescription": "Submit the withdrawal amount and payout details, then wait for review.",
|
"withdrawDescription": "Submit the withdrawal amount and payout details, then wait for review.",
|
||||||
"withdrawError": "Failed to submit withdrawal request",
|
"withdrawError": "Failed to submit withdrawal request",
|
||||||
"withdrawRecords": "Withdrawal Records",
|
"withdrawRecords": "Withdrawal Records",
|
||||||
|
"withdrawalRecordsDescription": "Review your withdrawal requests, statuses, and payout details.",
|
||||||
|
"withdrawalEmpty": "No withdrawal records yet",
|
||||||
"withdrawStatus": {
|
"withdrawStatus": {
|
||||||
"approved": "Approved",
|
"approved": "Approved",
|
||||||
"pending": "Pending Review",
|
"pending": "Pending Review",
|
||||||
"rejected": "Rejected"
|
"rejected": "Rejected"
|
||||||
},
|
},
|
||||||
"withdrawSuccess": "Withdrawal request submitted"
|
"withdrawSuccess": "Withdrawal request submitted",
|
||||||
|
"commissionReturn": {
|
||||||
|
"description": "Review order refunds and returned withdrawal commissions.",
|
||||||
|
"empty": "No refund records yet",
|
||||||
|
"eventType": {
|
||||||
|
"orderRefund": "Order Refund",
|
||||||
|
"withdrawRejectReturn": "Withdrawal Rejected Return",
|
||||||
|
"withdrawCancelReturn": "Withdrawal Cancel Return",
|
||||||
|
"unknown": "Commission Return"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"eventType": "Type",
|
||||||
|
"orderNo": "Order No.",
|
||||||
|
"createdAt": "Created At"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -16,6 +16,10 @@
|
|||||||
"inviteCode": "邀请码",
|
"inviteCode": "邀请码",
|
||||||
"inviteRecords": "邀请记录",
|
"inviteRecords": "邀请记录",
|
||||||
"noReason": "无",
|
"noReason": "无",
|
||||||
|
"recordsTabs": {
|
||||||
|
"withdrawal": "提现记录",
|
||||||
|
"refund": "退款记录"
|
||||||
|
},
|
||||||
"rejectReason": "拒绝原因",
|
"rejectReason": "拒绝原因",
|
||||||
"registrationTime": "注册时间",
|
"registrationTime": "注册时间",
|
||||||
"submitWithdraw": "提交申请",
|
"submitWithdraw": "提交申请",
|
||||||
@@ -28,10 +32,27 @@
|
|||||||
"withdrawDescription": "填写提现金额和收款信息后提交申请,等待后台审核。",
|
"withdrawDescription": "填写提现金额和收款信息后提交申请,等待后台审核。",
|
||||||
"withdrawError": "提现申请提交失败",
|
"withdrawError": "提现申请提交失败",
|
||||||
"withdrawRecords": "提现记录",
|
"withdrawRecords": "提现记录",
|
||||||
|
"withdrawalRecordsDescription": "查看你的提现申请、审核状态和收款信息。",
|
||||||
|
"withdrawalEmpty": "暂无提现记录",
|
||||||
"withdrawStatus": {
|
"withdrawStatus": {
|
||||||
"approved": "已通过",
|
"approved": "已通过",
|
||||||
"pending": "审核中",
|
"pending": "审核中",
|
||||||
"rejected": "已拒绝"
|
"rejected": "已拒绝"
|
||||||
},
|
},
|
||||||
"withdrawSuccess": "提现申请已提交"
|
"withdrawSuccess": "提现申请已提交",
|
||||||
|
"commissionReturn": {
|
||||||
|
"description": "查看订单退款和提现退回记录。",
|
||||||
|
"empty": "暂无退款记录",
|
||||||
|
"eventType": {
|
||||||
|
"orderRefund": "订单退款",
|
||||||
|
"withdrawRejectReturn": "提现拒绝退回",
|
||||||
|
"withdrawCancelReturn": "用户取消提现退回",
|
||||||
|
"unknown": "佣金返回"
|
||||||
|
},
|
||||||
|
"fields": {
|
||||||
|
"eventType": "类型",
|
||||||
|
"orderNo": "订单号",
|
||||||
|
"createdAt": "创建时间"
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { describe, expect, it, vi } from "vitest";
|
||||||
|
import { getCommissionReturnEventLabel } from "./commission-return";
|
||||||
|
|
||||||
|
const translations = {
|
||||||
|
"commissionReturn.eventType.orderRefund": "订单退款",
|
||||||
|
"commissionReturn.eventType.withdrawRejectReturn": "提现拒绝退回",
|
||||||
|
"commissionReturn.eventType.withdrawCancelReturn": "用户取消提现退回",
|
||||||
|
"commissionReturn.eventType.unknown": "佣金返回",
|
||||||
|
} as const;
|
||||||
|
|
||||||
|
describe("getCommissionReturnEventLabel", () => {
|
||||||
|
const t = (key: string, defaultValue: string) =>
|
||||||
|
translations[key as keyof typeof translations] ?? defaultValue ?? key;
|
||||||
|
|
||||||
|
it("maps 333 to order refund", () => {
|
||||||
|
expect(getCommissionReturnEventLabel(333, t)).toBe("订单退款");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 337 to withdraw reject return", () => {
|
||||||
|
expect(getCommissionReturnEventLabel(337, t)).toBe("提现拒绝退回");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("maps 338 to withdraw cancel return", () => {
|
||||||
|
expect(getCommissionReturnEventLabel(338, t)).toBe("用户取消提现退回");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("falls back for unknown event types and warns", () => {
|
||||||
|
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {
|
||||||
|
/* intentional noop */
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(getCommissionReturnEventLabel(999, t)).toBe("佣金返回");
|
||||||
|
expect(warnSpy).toHaveBeenCalledWith(999);
|
||||||
|
|
||||||
|
warnSpy.mockRestore();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
export type AffiliateTranslation = (
|
||||||
|
key: string,
|
||||||
|
defaultValue: string
|
||||||
|
) => string;
|
||||||
|
|
||||||
|
export function getCommissionReturnEventLabel(
|
||||||
|
eventType: number,
|
||||||
|
t: AffiliateTranslation
|
||||||
|
) {
|
||||||
|
switch (eventType) {
|
||||||
|
case 333:
|
||||||
|
return t("commissionReturn.eventType.orderRefund", "Order Refund");
|
||||||
|
case 337:
|
||||||
|
return t(
|
||||||
|
"commissionReturn.eventType.withdrawRejectReturn",
|
||||||
|
"Withdrawal Rejected Return"
|
||||||
|
);
|
||||||
|
case 338:
|
||||||
|
return t(
|
||||||
|
"commissionReturn.eventType.withdrawCancelReturn",
|
||||||
|
"Withdrawal Cancel Return"
|
||||||
|
);
|
||||||
|
default:
|
||||||
|
console.warn(eventType);
|
||||||
|
return t("commissionReturn.eventType.unknown", "Commission Return");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -35,13 +35,21 @@ import {
|
|||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@workspace/ui/components/select";
|
} from "@workspace/ui/components/select";
|
||||||
|
import {
|
||||||
|
Tabs,
|
||||||
|
TabsContent,
|
||||||
|
TabsList,
|
||||||
|
TabsTrigger,
|
||||||
|
} from "@workspace/ui/components/tabs";
|
||||||
import { Textarea } from "@workspace/ui/components/textarea";
|
import { Textarea } from "@workspace/ui/components/textarea";
|
||||||
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import Empty from "@workspace/ui/composed/empty";
|
||||||
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";
|
||||||
import {
|
import {
|
||||||
commissionWithdraw,
|
commissionWithdraw,
|
||||||
|
queryCommissionReturnLog,
|
||||||
queryUserAffiliate,
|
queryUserAffiliate,
|
||||||
queryUserAffiliateList,
|
queryUserAffiliateList,
|
||||||
queryWithdrawalLog,
|
queryWithdrawalLog,
|
||||||
@@ -59,6 +67,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 { getCommissionReturnEventLabel } from "./commission-return";
|
||||||
|
|
||||||
const WITHDRAWAL_METHODS = [
|
const WITHDRAWAL_METHODS = [
|
||||||
{ value: 1, label: "支付宝" },
|
{ value: 1, label: "支付宝" },
|
||||||
@@ -111,13 +120,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={512}
|
||||||
|
src={url}
|
||||||
|
width={512}
|
||||||
|
/>
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
@@ -157,14 +174,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"],
|
});
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -173,6 +188,7 @@ type WithdrawalFormValues = z.infer<typeof withdrawalFormSchema>;
|
|||||||
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 [activeRecordTab, setActiveRecordTab] = useState("withdrawal");
|
||||||
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
const [withdrawalOpen, setWithdrawalOpen] = useState(false);
|
||||||
const [submitting, startTransition] = useTransition();
|
const [submitting, startTransition] = useTransition();
|
||||||
const [qrUploading, setQrUploading] = useState(false);
|
const [qrUploading, setQrUploading] = useState(false);
|
||||||
@@ -421,9 +437,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 +460,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 +535,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,107 +578,218 @@ export default function Affiliate() {
|
|||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
<ProList<API.WithdrawalLog, Record<string, unknown>>
|
<Card>
|
||||||
action={withdrawalListActionRef}
|
<CardHeader className="gap-3">
|
||||||
header={{
|
<CardTitle>{t("withdrawRecords", "Withdrawal Records")}</CardTitle>
|
||||||
title: t("withdrawal.records", "Withdrawal Records"),
|
<CardDescription>
|
||||||
}}
|
{activeRecordTab === "withdrawal"
|
||||||
renderItem={(item) => {
|
? t(
|
||||||
const statusMeta = getWithdrawalStatusMeta(item.status, t);
|
"withdrawalRecordsDescription",
|
||||||
return (
|
"Review your withdrawal requests, statuses, and payout details."
|
||||||
<Card className="overflow-hidden">
|
)
|
||||||
<CardContent className="space-y-3 p-4 text-sm">
|
: t(
|
||||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
"commissionReturn.description",
|
||||||
<div className="font-semibold text-lg">
|
"Review order refunds and returned withdrawal commissions."
|
||||||
<Display type="currency" value={item.amount} />
|
)}
|
||||||
</div>
|
</CardDescription>
|
||||||
<div className="flex items-center gap-2">
|
<Tabs
|
||||||
<span
|
defaultValue="withdrawal"
|
||||||
className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
|
onValueChange={setActiveRecordTab}
|
||||||
>
|
value={activeRecordTab}
|
||||||
{statusMeta.label}
|
>
|
||||||
</span>
|
<TabsList className="grid w-full grid-cols-2 md:w-fit">
|
||||||
{item.status === 0 && (
|
<TabsTrigger value="withdrawal">
|
||||||
<ConfirmButton
|
{t("recordsTabs.withdrawal", "Withdrawal Records")}
|
||||||
cancelText="取消"
|
</TabsTrigger>
|
||||||
confirmText="确认取消"
|
<TabsTrigger value="refund">
|
||||||
description="取消后该提现申请将关闭,佣金不会退还。确定取消吗?"
|
{t("recordsTabs.refund", "Refund Records")}
|
||||||
onConfirm={async () => {
|
</TabsTrigger>
|
||||||
await cancelMutation.mutateAsync(item.id);
|
</TabsList>
|
||||||
}}
|
|
||||||
title="取消提现申请"
|
<TabsContent className="mt-4" value="withdrawal">
|
||||||
trigger={
|
<ProList<API.WithdrawalLog, Record<string, unknown>>
|
||||||
<Button
|
action={withdrawalListActionRef}
|
||||||
disabled={cancelMutation.isPending}
|
empty={
|
||||||
size="sm"
|
<Empty
|
||||||
variant="outline"
|
description={t(
|
||||||
>
|
"withdrawalEmpty",
|
||||||
取消申请
|
"No withdrawal records yet"
|
||||||
</Button>
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
/>
|
||||||
</div>
|
}
|
||||||
<ul className="grid gap-3 md:grid-cols-2">
|
renderItem={(item) => {
|
||||||
<li className="flex flex-col gap-1">
|
const statusMeta = getWithdrawalStatusMeta(item.status, t);
|
||||||
<span className="text-muted-foreground">收款方式</span>
|
return (
|
||||||
<span>{getWithdrawalMethodLabel(item.method)}</span>
|
<Card className="overflow-hidden">
|
||||||
</li>
|
<CardContent className="space-y-3 p-4 text-sm">
|
||||||
{item.account && (
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
<li className="flex flex-col gap-1">
|
<div className="font-semibold text-lg">
|
||||||
<span className="text-muted-foreground">收款账号</span>
|
<Display type="currency" value={item.amount} />
|
||||||
<span className="break-all">{item.account}</span>
|
</div>
|
||||||
</li>
|
<div className="flex items-center gap-2">
|
||||||
)}
|
<span
|
||||||
{item.qr_code_url && (
|
className={`inline-flex rounded-full px-3 py-1 font-medium text-xs ${statusMeta.className}`}
|
||||||
<li className="flex flex-col gap-1">
|
>
|
||||||
<span className="text-muted-foreground">收款码</span>
|
{statusMeta.label}
|
||||||
<QrCodeView url={item.qr_code_url} />
|
</span>
|
||||||
</li>
|
{item.status === 0 && (
|
||||||
)}
|
<ConfirmButton
|
||||||
{item.content && (
|
cancelText="取消"
|
||||||
<li className="flex flex-col gap-1">
|
confirmText="确认取消"
|
||||||
<span className="text-muted-foreground">
|
description="取消后该提现申请将关闭,佣金不会退还。确定取消吗?"
|
||||||
{t("withdrawal.record.content", "Payout Information")}
|
onConfirm={async () => {
|
||||||
</span>
|
await cancelMutation.mutateAsync(item.id);
|
||||||
<span className="whitespace-pre-wrap break-words">
|
}}
|
||||||
{item.content}
|
title="取消提现申请"
|
||||||
</span>
|
trigger={
|
||||||
</li>
|
<Button
|
||||||
)}
|
disabled={cancelMutation.isPending}
|
||||||
<li className="flex flex-col gap-1">
|
size="sm"
|
||||||
<span className="text-muted-foreground">
|
variant="outline"
|
||||||
{t("withdrawal.record.createdAt", "Created At")}
|
>
|
||||||
</span>
|
取消申请
|
||||||
<time>{formatDate(item.created_at)}</time>
|
</Button>
|
||||||
</li>
|
}
|
||||||
{item.reason && (
|
/>
|
||||||
<li className="flex flex-col gap-1 md:col-span-2">
|
)}
|
||||||
<span className="text-muted-foreground">
|
</div>
|
||||||
{t("withdrawal.record.reason", "Reject Reason")}
|
</div>
|
||||||
</span>
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
<span className="whitespace-pre-wrap break-words text-destructive">
|
<li className="flex flex-col gap-1">
|
||||||
{item.reason}
|
<span className="text-muted-foreground">
|
||||||
</span>
|
收款方式
|
||||||
</li>
|
</span>
|
||||||
)}
|
<span>{getWithdrawalMethodLabel(item.method)}</span>
|
||||||
</ul>
|
</li>
|
||||||
</CardContent>
|
{item.account && (
|
||||||
</Card>
|
<li className="flex flex-col gap-1">
|
||||||
);
|
<span className="text-muted-foreground">
|
||||||
}}
|
收款账号
|
||||||
request={async (pagination) => {
|
</span>
|
||||||
const response = await queryWithdrawalLog({
|
<span className="break-all">{item.account}</span>
|
||||||
page: pagination.page,
|
</li>
|
||||||
size: pagination.size,
|
)}
|
||||||
});
|
{item.qr_code_url && (
|
||||||
return {
|
<li className="flex flex-col gap-1">
|
||||||
list: response.data.data?.list || [],
|
<span className="text-muted-foreground">
|
||||||
total: response.data.data?.total || 0,
|
收款码
|
||||||
};
|
</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 className="mt-4" value="refund">
|
||||||
|
<ProList<API.CommissionReturnLog, Record<string, unknown>>
|
||||||
|
empty={
|
||||||
|
<Empty
|
||||||
|
description={t(
|
||||||
|
"commissionReturn.empty",
|
||||||
|
"No refund records yet"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
renderItem={(item) => (
|
||||||
|
<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>
|
||||||
|
<span className="inline-flex rounded-full bg-primary/10 px-3 py-1 font-medium text-primary text-xs">
|
||||||
|
{getCommissionReturnEventLabel(item.event_type, t)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<ul className="grid gap-3 md:grid-cols-2">
|
||||||
|
<li className="flex flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commissionReturn.fields.eventType", "Type")}
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
{getCommissionReturnEventLabel(item.event_type, t)}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("commissionReturn.fields.orderNo", "Order No.")}
|
||||||
|
</span>
|
||||||
|
<span className="break-all">
|
||||||
|
{item.order_no || "-"}
|
||||||
|
</span>
|
||||||
|
</li>
|
||||||
|
<li className="flex flex-col gap-1">
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"commissionReturn.fields.createdAt",
|
||||||
|
"Created At"
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<time>{formatDate(item.created_at)}</time>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
request={async (pagination) => {
|
||||||
|
const response = await queryCommissionReturnLog({
|
||||||
|
page: pagination.page,
|
||||||
|
size: pagination.size,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: response.data.data?.list || [],
|
||||||
|
total: response.data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
<ProList<API.UserAffiliate, Record<string, unknown>>
|
<ProList<API.UserAffiliate, Record<string, unknown>>
|
||||||
header={{
|
header={{
|
||||||
title: t("inviteRecords", "Invite Records"),
|
title: t("inviteRecords", "Invite Records"),
|
||||||
|
|||||||
+26
@@ -170,6 +170,17 @@ declare namespace API {
|
|||||||
timestamp: number;
|
timestamp: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type CommissionReturnLog = {
|
||||||
|
id: number;
|
||||||
|
user_id: number;
|
||||||
|
amount: number;
|
||||||
|
event_type: number;
|
||||||
|
order_no?: string;
|
||||||
|
content?: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
type CommissionWithdrawRequest = {
|
type CommissionWithdrawRequest = {
|
||||||
amount: number;
|
amount: number;
|
||||||
method: number;
|
method: number;
|
||||||
@@ -821,6 +832,21 @@ declare namespace API {
|
|||||||
size: number;
|
size: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type QueryCommissionReturnLogListRequest = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryCommissionReturnLogListResponse = {
|
||||||
|
list: CommissionReturnLog[];
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type QueryCommissionReturnLogParams = {
|
||||||
|
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";
|
||||||
|
|
||||||
@@ -471,6 +470,25 @@ export async function queryWithdrawalLog(
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Query Commission Return Log GET /v1/public/user/commission_return_log */
|
||||||
|
export async function queryCommissionReturnLog(
|
||||||
|
params: API.QueryCommissionReturnLogParams,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<
|
||||||
|
API.Response & { data?: API.QueryCommissionReturnLogListResponse }
|
||||||
|
>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/public/user/commission_return_log`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
/** Cancel Withdrawal POST /v1/public/user/withdrawal_cancel */
|
/** Cancel Withdrawal POST /v1/public/user/withdrawal_cancel */
|
||||||
export async function withdrawalCancel(
|
export async function withdrawalCancel(
|
||||||
body: API.WithdrawalCancelRequest,
|
body: API.WithdrawalCancelRequest,
|
||||||
|
|||||||
Reference in New Issue
Block a user