diff --git a/apps/admin/src/sections/dashboard/components/statistics.tsx b/apps/admin/src/sections/dashboard/components/statistics.tsx index db3b069..1009c41 100644 --- a/apps/admin/src/sections/dashboard/components/statistics.tsx +++ b/apps/admin/src/sections/dashboard/components/statistics.tsx @@ -241,11 +241,11 @@ export default function Statistics() { iconBg: "bg-green-100 dark:bg-green-900/30", }, { - title: t("pendingTickets", "Pending Tickets"), + title: t("withdrawalManagement", "提现管理"), value: TicketTotal || 0, subtitle: t("pending", "Pending"), icon: "uil:clipboard-notes", - href: "/dashboard/ticket", + href: "/dashboard/withdrawal", color: "text-red-600 dark:text-red-400", iconBg: "bg-red-100 dark:bg-red-900/30", }, diff --git a/apps/admin/src/sections/user/index.tsx b/apps/admin/src/sections/user/index.tsx index 74b9998..2117b19 100644 --- a/apps/admin/src/sections/user/index.tsx +++ b/apps/admin/src/sections/user/index.tsx @@ -1,34 +1,25 @@ import { useQuery } from "@tanstack/react-query"; import { Link, useSearch } from "@tanstack/react-router"; -import { Badge } from "@workspace/ui/components/badge"; import { Button } from "@workspace/ui/components/button"; import { Dialog, DialogContent, DialogHeader, DialogTitle, - DialogTrigger, } from "@workspace/ui/components/dialog"; import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from "@workspace/ui/components/dropdown-menu"; -import { Input } from "@workspace/ui/components/input"; import { Popover, PopoverContent, PopoverTrigger, } from "@workspace/ui/components/popover"; import { ScrollArea } from "@workspace/ui/components/scroll-area"; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from "@workspace/ui/components/select"; import { Sheet, SheetContent, @@ -43,7 +34,12 @@ import { TabsList, TabsTrigger, } from "@workspace/ui/components/tabs"; -import { Combobox } from "@workspace/ui/composed/combobox"; +import { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "@workspace/ui/components/tooltip"; // import { ConfirmButton } from "@workspace/ui/composed/confirm-button"; import { ProTable, @@ -60,9 +56,8 @@ import { getUserList, updateUserBasicInfo, } from "@workspace/ui/services/admin/user"; -import { parseDeviceType } from "@workspace/ui/utils/device"; import { FilePenLine } from "lucide-react"; -import { useCallback, useRef, useState } from "react"; +import { useCallback, useEffect, useRef, useState } from "react"; import { useTranslation } from "react-i18next"; import { toast } from "sonner"; import { Display } from "@/components/display"; @@ -73,18 +68,16 @@ import { RemarkForm } from "./remark-form"; import { buildUserBasicInfoPayload } from "./user-basic-info-payload"; import { UserDetail } from "./user-detail"; import UserForm from "./user-form"; +import { UserIdentifier } from "./user-identifier"; import { UserInviteStatsSheet } from "./user-invite-stats-sheet"; import { AuthMethodsForm } from "./user-profile/auth-methods-form"; import { BasicInfoForm } from "./user-profile/basic-info-form"; import { NotifySettingsForm } from "./user-profile/notify-settings-form"; +import { UserSearchBar } from "./user-search-bar"; import UserSubscription from "./user-subscription"; // import EditUserGroupDialog from "./edit-user-group-dialog"; -type UserDeviceWithDeviceNo = API.UserDevice & { - device_no?: string; -}; - export default function User() { const { t } = useTranslation("user"); const [loading, setLoading] = useState(false); @@ -98,6 +91,14 @@ export default function User() { value: sp.search || sp.user_id || "", }); + useEffect(() => { + searchRef.current = { + type: sp.user_id ? "user_id" : "email", + value: sp.search || sp.user_id || "", + }; + ref.current?.refresh(); + }, [sp.search, sp.user_id]); + const handleSearch = useCallback((type: string, value: string) => { searchRef.current = { type, value }; ref.current?.refresh(); @@ -151,7 +152,6 @@ export default function User() { onChanged={() => ref.current?.refresh()} userId={row.id} />, - , /* + + {t("orderList", "Order List")} @@ -262,62 +264,43 @@ export default function User() { id: "auth_methods", accessorKey: "auth_methods", header: "设备码/邮箱", - cell: ({ row }) => { - const method = row.original.auth_methods?.[0]; - const identifier = method?.auth_identifier || ""; - const isDevice = method?.auth_type === "device"; - const firstDevice = row.original.user_devices?.[0] as - | UserDeviceWithDeviceNo - | undefined; - const deviceNo = firstDevice?.device_no; - const deviceType = parseDeviceType(firstDevice?.user_agent || ""); - const display = isDevice ? deviceNo || identifier : identifier; - return ( - - {/* - {method?.auth_type} - */} - {deviceType && ( - - {deviceType} - - )} - {display} - - - - {row.original?.remark ? `(${row.original.remark})` : ""} - - - - - { - const { data } = await getUserDetail({ - id: row.original.id, - }); - const user = data.data; + cell: ({ row }) => ( + + + + + + {row.original?.remark ? `(${row.original.remark})` : ""} + + + + + { + const { data } = await getUserDetail({ + id: row.original.id, + }); + const user = data.data; - if (!user) { - throw new Error("User detail not found"); - } + if (!user) { + throw new Error("User detail not found"); + } - await updateUserBasicInfo( - buildUserBasicInfoPayload(user, remark) - ); - toast.success(t("updateSuccess")); - ref.current?.refresh(); - }} - /> - - - - ); - }, + await updateUserBasicInfo( + buildUserBasicInfoPayload(user, remark) + ); + toast.success(t("updateSuccess")); + ref.current?.refresh(); + }} + /> + + + + ), }, /* { id: "balance", @@ -355,6 +338,48 @@ export default function User() { header: t("referer", "Referer"), cell: ({ row }) => , }, + { + id: "last_login_time", + accessorKey: "last_login_time", + header: t("userStatus", "用户状态"), + cell: ({ row }) => { + const lastLoginTime = ( + row.original as API.User & { + last_login_time?: number; + } + ).last_login_time; + const isOnline = Boolean(lastLoginTime); + const dotClassName = isOnline ? "bg-green-500" : "bg-gray-400"; + const statusLabel = isOnline + ? t("active", "Active") + : t("inactive", "Inactive"); + + return ( + + + + + + + {isOnline + ? `${statusLabel}: ${formatDate(lastLoginTime)}` + : statusLabel} + + + + + {isOnline + ? formatDate(lastLoginTime) + : t("neverLoggedIn", "Never logged in")} + + + + ); + }, + }, { id: "created_at", accessorKey: "created_at", @@ -523,6 +548,25 @@ function InviteStatsMenuItem({ userId }: { userId: number }) { ); } +function PreviewNodesMenuItem({ userId }: { userId: number }) { + const { t } = useTranslation("user"); + const [open, setOpen] = useState(false); + + return ( + <> + { + e.preventDefault(); + setOpen(true); + }} + > + {t("previewNodes", "Preview Nodes")} + + + > + ); +} + function DeviceGroupSheet({ userId, onChanged, @@ -551,92 +595,16 @@ function DeviceGroupSheet({ ); } -function UserSearchBar({ - initialType, - initialValue, - onSearch, - subscribes, +function PreviewNodesDialog({ + userId, + open, + onOpenChange, }: { - initialType: string; - initialValue: string; - onSearch: (type: string, value: string) => void; - subscribes?: API.SubscribeItem[]; + userId: number; + open: boolean; + onOpenChange: (open: boolean) => void; }) { const { t } = useTranslation("user"); - const [searchType, setSearchType] = useState(initialType); - const [searchValue, setSearchValue] = useState(initialValue); - - return ( - - { - setSearchType(v); - setSearchValue(""); - }} - value={searchType} - > - - - - - 设备码/邮箱 - {t("userId", "User ID")} - - {t("subscription", "Subscription")} - - - - {searchType === "subscribe_id" ? ( - { - setSearchValue(value); - onSearch("subscribe_id", value); - }} - options={subscribes?.map((item) => ({ - label: item.name!, - value: String(item.id!), - }))} - placeholder={t("subscription", "Subscription")} - value={searchValue} - /> - ) : ( - <> - setSearchValue(e.target.value)} - onKeyDown={(e) => - e.key === "Enter" && onSearch(searchType, searchValue) - } - placeholder={t("searchInputPlaceholder", "Enter search term")} - value={searchValue} - /> - onSearch(searchType, searchValue)} - variant="default" - > - {t("search", "Search")} - - {searchValue && ( - { - setSearchValue(""); - onSearch(searchType, ""); - }} - variant="outline" - > - {t("resetSearch", "Reset")} - - )} - > - )} - - ); -} - -function PreviewNodesDialog({ userId }: { userId: number }) { - const { t } = useTranslation("user"); - const [open, setOpen] = useState(false); const { data: previewData, isLoading } = useQuery({ enabled: open, queryKey: ["previewUserNodes", userId], @@ -647,10 +615,7 @@ function PreviewNodesDialog({ userId }: { userId: number }) { }); return ( - - - {t("previewNodes", "Preview Nodes")} - + diff --git a/apps/admin/src/sections/user/user-detail.tsx b/apps/admin/src/sections/user/user-detail.tsx index 7f530ca..03baeb3 100644 --- a/apps/admin/src/sections/user/user-detail.tsx +++ b/apps/admin/src/sections/user/user-detail.tsx @@ -16,6 +16,7 @@ import { formatBytes } from "@workspace/ui/utils/formatting"; import { useTranslation } from "react-i18next"; import { Display } from "@/components/display"; import { formatDate } from "@/utils/common"; +import { UserIdentifier } from "./user-identifier"; // import EditUserGroupDialog from "./edit-user-group-dialog"; // import { getUserGroupList } from "@workspace/ui/services/admin/group"; @@ -229,26 +230,13 @@ export function UserDetail({ id }: { id: number }) { if (!id) return "--"; - const firstMethod = data?.auth_methods[0]; - const isDevice = firstMethod?.auth_type === "device"; - const deviceNo = (data as any)?.user_devices?.[0]?.device_no; - const rawIdentifier = firstMethod?.auth_identifier || ""; - const identifier = isDevice ? deviceNo || rawIdentifier : rawIdentifier; - return ( {data ? ( - <> - {firstMethod && ( - - {firstMethod.auth_type} - - )} - {identifier} - > + ) : ( t("loading", "Loading...") )} diff --git a/apps/admin/src/sections/user/user-identifier.test.tsx b/apps/admin/src/sections/user/user-identifier.test.tsx new file mode 100644 index 0000000..3a12444 --- /dev/null +++ b/apps/admin/src/sections/user/user-identifier.test.tsx @@ -0,0 +1,101 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it } from "vitest"; +import { getUserIdentifierParts, UserIdentifier } from "./user-identifier"; + +afterEach(() => { + cleanup(); +}); + +type DeviceWithOptionalNo = API.UserDevice & { device_no?: string }; + +describe("getUserIdentifierParts", () => { + it("uses the device number and D label for device-auth users", () => { + expect( + getUserIdentifierParts({ + auth_methods: [ + { + auth_type: "device", + auth_identifier: "raw-device-id", + verified: true, + }, + ], + user_devices: [ + { + id: 1, + ip: "127.0.0.1", + identifier: "device-id", + user_agent: + "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X)", + online: true, + enabled: true, + created_at: 0, + updated_at: 0, + device_no: "DVC-001", + } satisfies DeviceWithOptionalNo, + ], + }) + ).toMatchObject({ + authLabel: "(D)", + identifier: "DVC-001", + isDevice: true, + }); + }); + + it("falls back to the raw identifier and E label for email-auth users", () => { + expect( + getUserIdentifierParts({ + auth_methods: [ + { + auth_type: "email", + auth_identifier: "user@example.com", + verified: true, + }, + ], + user_devices: [], + }) + ).toMatchObject({ + authLabel: "(E)", + identifier: "user@example.com", + isDevice: false, + }); + }); +}); + +describe("UserIdentifier", () => { + it("renders the compact auth label and visible identifier", () => { + render( + + ); + + expect(screen.getByText("(E)")).not.toBeNull(); + expect(screen.getByText("referrer@example.com")).not.toBeNull(); + }); + + it("renders an empty fallback when no identifier is available", () => { + render( + + ); + + expect(screen.getByText("(E)")).not.toBeNull(); + expect(screen.getByText("--")).not.toBeNull(); + }); +}); diff --git a/apps/admin/src/sections/user/user-identifier.tsx b/apps/admin/src/sections/user/user-identifier.tsx new file mode 100644 index 0000000..b7ea34e --- /dev/null +++ b/apps/admin/src/sections/user/user-identifier.tsx @@ -0,0 +1,66 @@ +"use client"; + +import { Badge } from "@workspace/ui/components/badge"; +import { parseDeviceType } from "@workspace/ui/utils/device"; + +type UserDeviceWithDeviceNo = API.UserDevice & { + device_no?: string; +}; + +type UserIdentifierUser = { + auth_methods?: API.UserAuthMethod[]; + user_devices?: UserDeviceWithDeviceNo[]; +}; + +type UserIdentifierParts = { + authLabel: "(D)" | "(E)"; + deviceBadge?: string; + identifier: string; + isDevice: boolean; +}; + +export function getUserIdentifierParts( + user?: UserIdentifierUser +): UserIdentifierParts { + const method = user?.auth_methods?.[0]; + const firstDevice = user?.user_devices?.[0] as + | UserDeviceWithDeviceNo + | undefined; + const isDevice = method?.auth_type === "device"; + const rawIdentifier = method?.auth_identifier || ""; + const identifier = isDevice + ? firstDevice?.device_no || rawIdentifier + : rawIdentifier; + + return { + authLabel: isDevice ? "(D)" : "(E)", + deviceBadge: parseDeviceType(firstDevice?.user_agent || ""), + identifier, + isDevice, + }; +} + +export function UserIdentifier({ + className, + user, +}: { + className?: string; + user?: UserIdentifierUser; +}) { + const { authLabel, deviceBadge, identifier, isDevice } = + getUserIdentifierParts(user); + + return ( + + {deviceBadge ? ( + + {deviceBadge} + + ) : null} + {authLabel} + + {identifier || "--"} + + + ); +} diff --git a/apps/admin/src/sections/user/user-search-bar.test.tsx b/apps/admin/src/sections/user/user-search-bar.test.tsx new file mode 100644 index 0000000..5a9d575 --- /dev/null +++ b/apps/admin/src/sections/user/user-search-bar.test.tsx @@ -0,0 +1,92 @@ +/** + * @vitest-environment jsdom + */ +import { cleanup, fireEvent, render, screen } from "@testing-library/react"; +import { afterEach, describe, expect, it, vi } from "vitest"; +import { UserSearchBar } from "./user-search-bar"; + +vi.mock("react-i18next", () => ({ + useTranslation: () => ({ + t: (_key: string, fallback: string): string => fallback, + }), +})); + +afterEach(() => { + cleanup(); +}); + +describe("UserSearchBar", () => { + it("keeps the controls usable on a narrow mobile viewport", () => { + Object.defineProperty(window, "innerWidth", { + configurable: true, + value: 390, + }); + + render( + + ); + + expect(screen.getByRole("combobox")).not.toBeNull(); + expect(screen.getByRole("button", { name: "Search" })).not.toBeNull(); + }); + + it("keeps the typed happy-path value and triggers search", () => { + const onSearch = vi.fn(); + + render( + + ); + + fireEvent.change(screen.getByPlaceholderText("Enter search term"), { + target: { value: "user@example.com" }, + }); + fireEvent.click(screen.getByRole("button", { name: "Search" })); + + expect(onSearch).toHaveBeenCalledWith("email", "user@example.com"); + expect( + (screen.getByPlaceholderText("Enter search term") as HTMLInputElement) + .value + ).toBe("user@example.com"); + }); + + it("syncs to external search changes for precise referrer navigation", () => { + const onSearch = vi.fn(); + const { rerender } = render( + + ); + + expect( + (screen.getByPlaceholderText("Enter search term") as HTMLInputElement) + .value + ).toBe("old@example.com"); + + rerender( + + ); + + expect( + (screen.getByPlaceholderText("Enter search term") as HTMLInputElement) + .value + ).toBe("1024"); + }); +}); diff --git a/apps/admin/src/sections/user/user-search-bar.tsx b/apps/admin/src/sections/user/user-search-bar.tsx new file mode 100644 index 0000000..ee4a9ef --- /dev/null +++ b/apps/admin/src/sections/user/user-search-bar.tsx @@ -0,0 +1,102 @@ +"use client"; + +import { Button } from "@workspace/ui/components/button"; +import { Input } from "@workspace/ui/components/input"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@workspace/ui/components/select"; +import { Combobox } from "@workspace/ui/composed/combobox"; +import { useEffect, useState } from "react"; +import { useTranslation } from "react-i18next"; + +export function UserSearchBar({ + initialType, + initialValue, + onSearch, + subscribes, +}: { + initialType: string; + initialValue: string; + onSearch: (type: string, value: string) => void; + subscribes?: API.SubscribeItem[]; +}) { + const { t } = useTranslation("user"); + const [searchType, setSearchType] = useState(initialType); + const [searchValue, setSearchValue] = useState(initialValue); + + useEffect(() => { + setSearchType(initialType); + setSearchValue(initialValue); + }, [initialType, initialValue]); + + return ( + + { + setSearchType(v); + setSearchValue(""); + }} + value={searchType} + > + + + + + 设备码/邮箱 + {t("userId", "User ID")} + + {t("subscription", "Subscription")} + + + + {searchType === "subscribe_id" ? ( + { + setSearchValue(value); + onSearch("subscribe_id", value); + }} + options={subscribes?.map((item) => ({ + label: item.name!, + value: String(item.id!), + }))} + placeholder={t("subscription", "Subscription")} + value={searchValue} + /> + ) : ( + <> + setSearchValue(e.target.value)} + onKeyDown={(e) => + e.key === "Enter" && onSearch(searchType, searchValue) + } + placeholder={t("searchInputPlaceholder", "Enter search term")} + value={searchValue} + /> + onSearch(searchType, searchValue)} + variant="default" + > + {t("search", "Search")} + + {searchValue && ( + { + setSearchValue(""); + onSearch(searchType, ""); + }} + variant="outline" + > + {t("resetSearch", "Reset")} + + )} + > + )} + + ); +}