diff --git a/apps/admin/public/assets/locales/en-US/invite.json b/apps/admin/public/assets/locales/en-US/invite.json new file mode 100644 index 0000000..f7fcd9d --- /dev/null +++ b/apps/admin/public/assets/locales/en-US/invite.json @@ -0,0 +1,21 @@ +{ + "commission": "Commission", + "days": "{{count}} days", + "disabled": "Disabled", + "empty": "No invite records", + "enabled": "Enabled", + "hasPurchased": "Purchased", + "invitee": "Invitee", + "inviteeGiftDays": "Invitee Gift Days", + "inviteeId": "Invitee ID", + "invitedAt": "Invited At", + "inviter": "Inviter", + "inviterGiftDays": "Inviter Gift Days", + "inviterId": "Inviter ID", + "no": "No", + "orderCount": "Orders", + "searchPlaceholder": "Email or phone", + "status": "Status", + "title": "Invite Management", + "yes": "Yes" +} diff --git a/apps/admin/public/assets/locales/en-US/menu.json b/apps/admin/public/assets/locales/en-US/menu.json index 069ad99..b0d651b 100644 --- a/apps/admin/public/assets/locales/en-US/menu.json +++ b/apps/admin/public/assets/locales/en-US/menu.json @@ -11,6 +11,7 @@ "Email": "Email", "Gift": "Gift", "Group Management": "Group Management", + "Invite Management": "Invite Management", "Login": "Login", "Logs & Analytics": "Logs & Analytics", "Maintenance": "Maintenance", diff --git a/apps/admin/public/assets/locales/zh-CN/invite.json b/apps/admin/public/assets/locales/zh-CN/invite.json new file mode 100644 index 0000000..9f47028 --- /dev/null +++ b/apps/admin/public/assets/locales/zh-CN/invite.json @@ -0,0 +1,21 @@ +{ + "commission": "佣金", + "days": "{{count}} 天", + "disabled": "禁用", + "empty": "暂无邀请记录", + "enabled": "启用", + "hasPurchased": "已购买", + "invitee": "被邀请人", + "inviteeGiftDays": "被邀请人赠送天数", + "inviteeId": "被邀请人 ID", + "invitedAt": "邀请时间", + "inviter": "邀请人", + "inviterGiftDays": "邀请人赠送天数", + "inviterId": "邀请人 ID", + "no": "否", + "orderCount": "订单数", + "searchPlaceholder": "邮箱/手机号关键词", + "status": "状态", + "title": "邀请管理", + "yes": "是" +} diff --git a/apps/admin/public/assets/locales/zh-CN/menu.json b/apps/admin/public/assets/locales/zh-CN/menu.json index c711a74..4d7048a 100644 --- a/apps/admin/public/assets/locales/zh-CN/menu.json +++ b/apps/admin/public/assets/locales/zh-CN/menu.json @@ -11,6 +11,7 @@ "Email": "邮件", "Gift": "赠送", "Group Management": "分组管理", + "Invite Management": "邀请管理", "Login": "登录", "Logs & Analytics": "日志与分析", "Maintenance": "维护", diff --git a/apps/admin/src/layout/navs.ts b/apps/admin/src/layout/navs.ts index 5c8bb28..098bfe0 100644 --- a/apps/admin/src/layout/navs.ts +++ b/apps/admin/src/layout/navs.ts @@ -98,6 +98,11 @@ export function useNavs() { url: "/dashboard/withdrawal", icon: "flat-color-icons:money-transfer", }, + { + title: t("Invite Management", "Invite Management"), + url: "/dashboard/invite-management", + icon: "flat-color-icons:add-user", + }, { title: t("Device Group", "Device Group"), url: "/dashboard/family", diff --git a/apps/admin/src/main.tsx b/apps/admin/src/main.tsx index e0d9727..6a9224a 100644 --- a/apps/admin/src/main.tsx +++ b/apps/admin/src/main.tsx @@ -35,6 +35,7 @@ initializeI18n({ "coupon", "dashboard", "document", + "invite", "log", "marketing", "menu", diff --git a/apps/admin/src/routeTree.gen.ts b/apps/admin/src/routeTree.gen.ts index 5de5f84..da5d6cb 100644 --- a/apps/admin/src/routeTree.gen.ts +++ b/apps/admin/src/routeTree.gen.ts @@ -45,6 +45,9 @@ const DashboardOrderIndexLazyRouteImport = const DashboardMarketingIndexLazyRouteImport = createFileRoute( '/dashboard/marketing/', )() +const DashboardInviteManagementIndexLazyRouteImport = createFileRoute( + '/dashboard/invite-management/', +)() const DashboardGroupIndexLazyRouteImport = createFileRoute('/dashboard/group/')() const DashboardFamilyIndexLazyRouteImport = @@ -214,6 +217,16 @@ const DashboardMarketingIndexLazyRoute = } as any).lazy(() => import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route), ) +const DashboardInviteManagementIndexLazyRoute = + DashboardInviteManagementIndexLazyRouteImport.update({ + id: '/invite-management/', + path: '/invite-management/', + getParentRoute: () => DashboardRouteLazyRoute, + } as any).lazy(() => + import('./routes/dashboard/invite-management/index.lazy').then( + (d) => d.Route, + ), + ) const DashboardGroupIndexLazyRoute = DashboardGroupIndexLazyRouteImport.update({ id: '/group/', path: '/group/', @@ -388,6 +401,7 @@ export interface FileRoutesByFullPath { '/dashboard/document': typeof DashboardDocumentIndexLazyRoute '/dashboard/family': typeof DashboardFamilyIndexLazyRoute '/dashboard/group': typeof DashboardGroupIndexLazyRoute + '/dashboard/invite-management': typeof DashboardInviteManagementIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute @@ -423,6 +437,7 @@ export interface FileRoutesByTo { '/dashboard/document': typeof DashboardDocumentIndexLazyRoute '/dashboard/family': typeof DashboardFamilyIndexLazyRoute '/dashboard/group': typeof DashboardGroupIndexLazyRoute + '/dashboard/invite-management': typeof DashboardInviteManagementIndexLazyRoute '/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute '/dashboard/order': typeof DashboardOrderIndexLazyRoute '/dashboard/payment': typeof DashboardPaymentIndexLazyRoute @@ -461,6 +476,7 @@ export interface FileRoutesById { '/dashboard/document/': typeof DashboardDocumentIndexLazyRoute '/dashboard/family/': typeof DashboardFamilyIndexLazyRoute '/dashboard/group/': typeof DashboardGroupIndexLazyRoute + '/dashboard/invite-management/': typeof DashboardInviteManagementIndexLazyRoute '/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute '/dashboard/order/': typeof DashboardOrderIndexLazyRoute '/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute @@ -500,6 +516,7 @@ export interface FileRouteTypes { | '/dashboard/document' | '/dashboard/family' | '/dashboard/group' + | '/dashboard/invite-management' | '/dashboard/marketing' | '/dashboard/order' | '/dashboard/payment' @@ -535,6 +552,7 @@ export interface FileRouteTypes { | '/dashboard/document' | '/dashboard/family' | '/dashboard/group' + | '/dashboard/invite-management' | '/dashboard/marketing' | '/dashboard/order' | '/dashboard/payment' @@ -572,6 +590,7 @@ export interface FileRouteTypes { | '/dashboard/document/' | '/dashboard/family/' | '/dashboard/group/' + | '/dashboard/invite-management/' | '/dashboard/marketing/' | '/dashboard/order/' | '/dashboard/payment/' @@ -703,6 +722,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport parentRoute: typeof DashboardRouteLazyRoute } + '/dashboard/invite-management/': { + id: '/dashboard/invite-management/' + path: '/invite-management' + fullPath: '/dashboard/invite-management' + preLoaderRoute: typeof DashboardInviteManagementIndexLazyRouteImport + parentRoute: typeof DashboardRouteLazyRoute + } '/dashboard/group/': { id: '/dashboard/group/' path: '/group' @@ -877,6 +903,7 @@ interface DashboardRouteLazyRouteChildren { DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute DashboardFamilyIndexLazyRoute: typeof DashboardFamilyIndexLazyRoute DashboardGroupIndexLazyRoute: typeof DashboardGroupIndexLazyRoute + DashboardInviteManagementIndexLazyRoute: typeof DashboardInviteManagementIndexLazyRoute DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute @@ -912,6 +939,8 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = { DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute, DashboardFamilyIndexLazyRoute: DashboardFamilyIndexLazyRoute, DashboardGroupIndexLazyRoute: DashboardGroupIndexLazyRoute, + DashboardInviteManagementIndexLazyRoute: + DashboardInviteManagementIndexLazyRoute, DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute, DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute, DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute, diff --git a/apps/admin/src/routes/dashboard/invite-management/index.lazy.tsx b/apps/admin/src/routes/dashboard/invite-management/index.lazy.tsx new file mode 100644 index 0000000..f95e84e --- /dev/null +++ b/apps/admin/src/routes/dashboard/invite-management/index.lazy.tsx @@ -0,0 +1,6 @@ +import { createLazyFileRoute } from "@tanstack/react-router"; +import InviteManagement from "@/sections/invite"; + +export const Route = createLazyFileRoute("/dashboard/invite-management/")({ + component: InviteManagement, +}); diff --git a/apps/admin/src/sections/invite/index.test.tsx b/apps/admin/src/sections/invite/index.test.tsx new file mode 100644 index 0000000..a255767 --- /dev/null +++ b/apps/admin/src/sections/invite/index.test.tsx @@ -0,0 +1,138 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen, waitFor } from "@testing-library/react"; +import { getAdminInviteList } from "@workspace/ui/services/admin/invite"; +import type { AxiosResponse } from "axios"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; +import InviteManagement from "."; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: ( + _key: string, + fallback: string, + options?: { count?: number } + ): string => { + if (typeof options?.count === "number") { + return `${options.count} days`; + } + return fallback; + }, + }), +})); + +vi.mock("@/stores/global", () => ({ + useGlobalStore: () => ({ + common: { + currency: { + currency_symbol: "$", + }, + }, + }), +})); + +vi.mock("@/utils/common", () => ({ + formatDate: (timestamp: number) => `date-${timestamp}`, +})); + +vi.mock("@workspace/ui/services/admin/invite", () => ({ + getAdminInviteList: vi.fn(), +})); + +const mockedGetAdminInviteList = vi.mocked(getAdminInviteList); + +function createInviteListResponse( + data: API.GetAdminInviteListResponse +): AxiosResponse { + return { + data: { + code: 200, + data, + }, + status: 200, + statusText: "OK", + headers: {}, + config: { + headers: {} as AxiosResponse["config"]["headers"], + }, + }; +} + +afterEach(() => { + cleanup(); + vi.clearAllMocks(); +}); + +beforeEach(() => { + localStorage.setItem("timezone", "UTC"); +}); + +describe("InviteManagement", () => { + it("renders invite rows with status, purchase state, commission, and gift days", async () => { + mockedGetAdminInviteList.mockResolvedValue( + createInviteListResponse({ + total: 1, + list: [ + { + inviter_id: 10, + inviter_identifier: "agent@example.com", + invitee_id: 123, + invitee_identifier: "user@example.com", + invitee_avatar: "", + invitee_enable: true, + invited_at: 1_716_800_000, + order_count: 3, + has_purchased: true, + inviter_commission: 1500, + inviter_gift_days: 30, + invitee_gift_days: 7, + }, + ], + }) + ); + + render(); + + await waitFor(() => + expect(mockedGetAdminInviteList).toHaveBeenCalledWith({ + page: 1, + size: 200, + search: undefined, + inviter_id: undefined, + invitee_id: undefined, + }) + ); + + expect(await screen.findByText("agent@example.com")).not.toBeNull(); + expect(screen.getByText("user@example.com")).not.toBeNull(); + expect(screen.getByText("Enabled")).not.toBeNull(); + expect(screen.getByText("Yes")).not.toBeNull(); + expect(screen.getByText("$15.00")).not.toBeNull(); + expect(screen.getByText("30 days")).not.toBeNull(); + expect(screen.getByText("7 days")).not.toBeNull(); + expect(screen.getByText("date-1716800000")).not.toBeNull(); + }); + + it("shows the empty state when the API returns no rows", async () => { + mockedGetAdminInviteList.mockResolvedValue( + createInviteListResponse({ + total: 0, + list: [], + }) + ); + + render(); + + expect(await screen.findByText("No invite records")).not.toBeNull(); + }); + + it("keeps the table empty when the request fails", async () => { + mockedGetAdminInviteList.mockRejectedValue(new Error("network failed")); + + render(); + + await waitFor(() => expect(mockedGetAdminInviteList).toHaveBeenCalled()); + expect(await screen.findByText("No invite records")).not.toBeNull(); + }); +}); diff --git a/apps/admin/src/sections/invite/index.tsx b/apps/admin/src/sections/invite/index.tsx new file mode 100644 index 0000000..b9a7736 --- /dev/null +++ b/apps/admin/src/sections/invite/index.tsx @@ -0,0 +1,196 @@ +"use client"; + +import { + Avatar, + AvatarFallback, + AvatarImage, +} from "@workspace/ui/components/avatar"; +import { Badge } from "@workspace/ui/components/badge"; +import Empty from "@workspace/ui/composed/empty"; +import { + ProTable, + type ProTableActions, +} from "@workspace/ui/composed/pro-table/pro-table"; +import { getAdminInviteList } from "@workspace/ui/services/admin/invite"; +import { useRef } from "react"; +import { useTranslation } from "react-i18next"; +import { Display } from "@/components/display"; +import { formatDate } from "@/utils/common"; + +type InviteFilters = { + search?: string; + inviter_id?: string; + invitee_id?: string; +}; + +function toOptionalNumber(value?: string) { + if (!value) return; + const parsed = Number(value); + return Number.isFinite(parsed) ? parsed : undefined; +} + +function InviteUserCell({ + id, + identifier, + avatar, +}: { + id: number; + identifier: string; + avatar?: string; +}) { + const fallback = identifier?.charAt(0)?.toUpperCase() || "U"; + + return ( +
+ + + {fallback} + +
+
#{id}
+
+ {identifier || "--"} +
+
+
+ ); +} + +function InviteIdentifierCell({ + id, + identifier, +}: { + id: number; + identifier: string; +}) { + return ( +
+
#{id}
+
+ {identifier || "--"} +
+
+ ); +} + +export default function InviteManagement() { + const { t } = useTranslation("invite"); + const ref = useRef(null); + + return ( + + action={ref} + columns={[ + { + accessorKey: "inviter_id", + header: t("inviter", "Inviter"), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "invitee_id", + header: t("invitee", "Invitee"), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "invitee_enable", + header: t("status", "Status"), + cell: ({ row }) => ( + + {row.original.invitee_enable + ? t("enabled", "Enabled") + : t("disabled", "Disabled")} + + ), + }, + { + accessorKey: "invited_at", + header: t("invitedAt", "Invited At"), + cell: ({ row }) => formatDate(row.original.invited_at), + }, + { + accessorKey: "order_count", + header: t("orderCount", "Orders"), + }, + { + accessorKey: "has_purchased", + header: t("hasPurchased", "Purchased"), + cell: ({ row }) => ( + + {row.original.has_purchased ? t("yes", "Yes") : t("no", "No")} + + ), + }, + { + accessorKey: "inviter_commission", + header: t("commission", "Commission"), + cell: ({ row }) => ( + + ), + }, + { + accessorKey: "inviter_gift_days", + header: t("inviterGiftDays", "Inviter Gift Days"), + cell: ({ row }) => + t("days", "{{count}} days", { + count: row.original.inviter_gift_days, + }), + }, + { + accessorKey: "invitee_gift_days", + header: t("inviteeGiftDays", "Invitee Gift Days"), + cell: ({ row }) => + t("days", "{{count}} days", { + count: row.original.invitee_gift_days, + }), + }, + ]} + empty={} + header={{ title: t("title", "Invite Management") }} + params={[ + { + key: "search", + placeholder: t("searchPlaceholder", "Email or phone"), + }, + { + key: "inviter_id", + placeholder: t("inviterId", "Inviter ID"), + }, + { + key: "invitee_id", + placeholder: t("inviteeId", "Invitee ID"), + }, + ]} + request={async (pagination, filters) => { + const { data } = await getAdminInviteList({ + page: pagination.page, + size: pagination.size, + search: filters.search?.trim() || undefined, + inviter_id: toOptionalNumber(filters.inviter_id), + invitee_id: toOptionalNumber(filters.invitee_id), + }); + + return { + list: + data.data?.list.map((item) => ({ + ...item, + id: `${item.inviter_id}-${item.invitee_id}-${item.invited_at}`, + })) || [], + total: data.data?.total || 0, + }; + }} + /> + ); +} diff --git a/apps/admin/src/sections/withdrawal/index.tsx b/apps/admin/src/sections/withdrawal/index.tsx index ef7be0f..779f72c 100644 --- a/apps/admin/src/sections/withdrawal/index.tsx +++ b/apps/admin/src/sections/withdrawal/index.tsx @@ -68,13 +68,21 @@ function QrCodeCell({ url }: { url?: string }) { 收款码 {zoomOpen && ( - 收款码 + 收款码 )} @@ -238,9 +246,7 @@ export default function WithdrawalPage() { { accessorKey: "qr_code_url", header: "收款码", - cell: ({ row }) => ( - - ), + cell: ({ row }) => , }, { accessorKey: "content", diff --git a/packages/ui/src/services/admin/invite.ts b/packages/ui/src/services/admin/invite.ts new file mode 100644 index 0000000..ddd8c74 --- /dev/null +++ b/packages/ui/src/services/admin/invite.ts @@ -0,0 +1,19 @@ +/* eslint-disable */ +import request from "@workspace/ui/lib/request"; + +/** Get global invite list GET /v1/admin/invite/list */ +export async function getAdminInviteList( + params: API.GetAdminInviteListParams, + options?: Record +) { + return request( + `${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/invite/list`, + { + method: "GET", + params: { + ...params, + }, + ...(options || {}), + } + ); +} diff --git a/packages/ui/src/services/admin/typings.d.ts b/packages/ui/src/services/admin/typings.d.ts index 37ee6d9..75e90a4 100644 --- a/packages/ui/src/services/admin/typings.d.ts +++ b/packages/ui/src/services/admin/typings.d.ts @@ -2742,6 +2742,35 @@ declare namespace API { list: AdminInvitedUser[]; }; + type GetAdminInviteListParams = { + page: number; + size: number; + search?: string; + inviter_id?: number; + invitee_id?: number; + }; + + type AdminInviteRelation = { + id?: string; + inviter_id: number; + inviter_identifier: string; + invitee_id: number; + invitee_identifier: string; + invitee_avatar: string; + invitee_enable: boolean; + invited_at: number; + order_count: number; + has_purchased: boolean; + inviter_commission: number; + inviter_gift_days: number; + invitee_gift_days: number; + }; + + type GetAdminInviteListResponse = { + total: number; + list: AdminInviteRelation[]; + }; + type ActivateOrderRequest = { order_no: string; }; @@ -3107,4 +3136,6 @@ declare namespace API { created_at: number; updated_at: number; }; + + type AdminWithdrawalLog = WithdrawalLog; }