From 511ce4ff65c63a102a42bbccecfc14f900c851a8 Mon Sep 17 00:00:00 2001 From: shanshanzhong Date: Tue, 23 Jun 2026 08:49:48 -0700 Subject: [PATCH] Merge branch 'internal' into main --- .../assets/locales/en-US/dashboard.json | 1 + .../public/assets/locales/en-US/user.json | 4 + .../assets/locales/zh-CN/dashboard.json | 1 + .../public/assets/locales/zh-CN/user.json | 4 + .../dashboard/components/statistics.tsx | 4 +- apps/admin/src/sections/user/index.tsx | 283 ++++++++---------- apps/admin/src/sections/user/user-detail.tsx | 16 +- .../sections/user/user-identifier.test.tsx | 101 +++++++ .../src/sections/user/user-identifier.tsx | 66 ++++ .../sections/user/user-search-bar.test.tsx | 92 ++++++ .../src/sections/user/user-search-bar.tsx | 102 +++++++ .../assets/locales/en-US/subscribe.json | 1 + .../assets/locales/zh-CN/subscribe.json | 1 + apps/user/src/sections/subscribe/billing.tsx | 9 + .../src/sections/subscribe/promo-info.tsx | 4 +- packages/ui/src/services/user/typings.d.ts | 4 +- 16 files changed, 515 insertions(+), 178 deletions(-) create mode 100644 apps/admin/src/sections/user/user-identifier.test.tsx create mode 100644 apps/admin/src/sections/user/user-identifier.tsx create mode 100644 apps/admin/src/sections/user/user-search-bar.test.tsx create mode 100644 apps/admin/src/sections/user/user-search-bar.tsx diff --git a/apps/admin/public/assets/locales/en-US/dashboard.json b/apps/admin/public/assets/locales/en-US/dashboard.json index 8256029..2e80d11 100644 --- a/apps/admin/public/assets/locales/en-US/dashboard.json +++ b/apps/admin/public/assets/locales/en-US/dashboard.json @@ -29,5 +29,6 @@ "users": "Users", "userTitle": "User Statistics", "userTraffic": "User Traffic", + "withdrawalManagement": "Withdrawal Management", "yesterday": "Yesterday" } diff --git a/apps/admin/public/assets/locales/en-US/user.json b/apps/admin/public/assets/locales/en-US/user.json index 7e3f9da..4ba0f85 100644 --- a/apps/admin/public/assets/locales/en-US/user.json +++ b/apps/admin/public/assets/locales/en-US/user.json @@ -100,10 +100,12 @@ "manager": "Administrator", "memberCount": "Member Count", "more": "More", + "neverLoggedIn": "Never logged in", "normal": "Normal", "next": "Next", "noInvitedUsers": "No invited users yet", "notifySettingsTitle": "Notify Settings", + "inactive": "Inactive", "offline": "Offline", "online": "Online", "onlineDevices": "Online Devices", @@ -189,6 +191,7 @@ "groupUpdated": "Group updated successfully", "upload": "Upload", "uploadTraffic": "Upload Traffic", + "userStatus": "User Status", "userAgent": "User Agent", "userEmail": "Email", "userEmailPlaceholder": "Enter email", @@ -198,6 +201,7 @@ "userName": "Username", "userProfile": "User Profile", "userGroup": "User Group", + "active": "Active", "verified": "Verified", "viewDeviceGroup": "View Device Group", "viewOwner": "View Owner", diff --git a/apps/admin/public/assets/locales/zh-CN/dashboard.json b/apps/admin/public/assets/locales/zh-CN/dashboard.json index 7e5c4e0..bfbbce7 100644 --- a/apps/admin/public/assets/locales/zh-CN/dashboard.json +++ b/apps/admin/public/assets/locales/zh-CN/dashboard.json @@ -29,5 +29,6 @@ "users": "用户", "userTitle": "用户统计", "userTraffic": "用户流量", + "withdrawalManagement": "提现管理", "yesterday": "昨日" } diff --git a/apps/admin/public/assets/locales/zh-CN/user.json b/apps/admin/public/assets/locales/zh-CN/user.json index 33c0e64..612e003 100644 --- a/apps/admin/public/assets/locales/zh-CN/user.json +++ b/apps/admin/public/assets/locales/zh-CN/user.json @@ -101,10 +101,12 @@ "manager": "管理员", "memberCount": "成员数量", "more": "更多", + "neverLoggedIn": "从未登录", "normal": "正常", "next": "下一页", "noInvitedUsers": "暂无邀请用户", "notifySettingsTitle": "通知设置", + "inactive": "未活跃", "offline": "离线", "online": "在线", "onlineDevices": "在线设备", @@ -190,6 +192,7 @@ "groupUpdated": "分组更新成功", "upload": "上传", "uploadTraffic": "上传流量", + "userStatus": "用户状态", "userAgent": "用户代理", "userEmail": "邮箱", "userEmailPlaceholder": "输入邮箱", @@ -199,6 +202,7 @@ "userName": "用户名", "userProfile": "用户资料", "userGroup": "用户分组", + "active": "活跃", "verified": "已验证", "viewDeviceGroup": "查看设备组", "viewOwner": "查看所有者", 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 + ? 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 ( -
- - {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} - /> - - {searchValue && ( - - )} - - )} -
- ); -} - -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 ( - - - - + 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 ( + {searchValue && ( + + )} + + )} + + ); +} diff --git a/apps/user/public/assets/locales/en-US/subscribe.json b/apps/user/public/assets/locales/en-US/subscribe.json index 77526c0..812832f 100644 --- a/apps/user/public/assets/locales/en-US/subscribe.json +++ b/apps/user/public/assets/locales/en-US/subscribe.json @@ -9,6 +9,7 @@ "originalPrice": "Original Price (Monthly)", "price": "Price", "productDiscount": "Product Discount", + "promoDiscount": "Promo Discount", "total": "Total" }, "buy": "Buy", diff --git a/apps/user/public/assets/locales/zh-CN/subscribe.json b/apps/user/public/assets/locales/zh-CN/subscribe.json index ece8ba7..d0375bb 100644 --- a/apps/user/public/assets/locales/zh-CN/subscribe.json +++ b/apps/user/public/assets/locales/zh-CN/subscribe.json @@ -9,6 +9,7 @@ "originalPrice": "原价(按月)", "price": "价格", "productDiscount": "商品折扣", + "promoDiscount": "促销优惠", "total": "总价" }, "buy": "购买", diff --git a/apps/user/src/sections/subscribe/billing.tsx b/apps/user/src/sections/subscribe/billing.tsx index c24db2e..ec016a4 100644 --- a/apps/user/src/sections/subscribe/billing.tsx +++ b/apps/user/src/sections/subscribe/billing.tsx @@ -10,6 +10,7 @@ interface SubscribeBillingProps { unit_price: number; unit_time: string; subscribe_discount: number; + promo_discount: number; show_original_price?: boolean; } >; @@ -66,6 +67,14 @@ export function SubscribeBilling({ order }: Readonly) { +
  • + + {t("billing.promoDiscount", "Promo Discount")} + + + + +
  • {t("billing.couponDiscount", "Coupon Discount")} diff --git a/apps/user/src/sections/subscribe/promo-info.tsx b/apps/user/src/sections/subscribe/promo-info.tsx index d6f439e..55f88c0 100644 --- a/apps/user/src/sections/subscribe/promo-info.tsx +++ b/apps/user/src/sections/subscribe/promo-info.tsx @@ -18,10 +18,10 @@ export function PromoInfo({ discount }: Readonly) { {promo.rule_name || t("promo.available", "Promo Available")} - {promo.end_time && ( + {promo.expires_at && ( {t("promo.validUntil", "Valid until")}:{" "} - {new Date(promo.end_time * 1000).toLocaleString()} + {new Date(promo.expires_at * 1000).toLocaleString()} )} diff --git a/packages/ui/src/services/user/typings.d.ts b/packages/ui/src/services/user/typings.d.ts index cfcc498..9a5e1be 100644 --- a/packages/ui/src/services/user/typings.d.ts +++ b/packages/ui/src/services/user/typings.d.ts @@ -548,6 +548,7 @@ declare namespace API { price: number; amount: number; discount: number; + promo_discount: number; gift_amount: number; coupon: string; coupon_discount: number; @@ -1024,8 +1025,9 @@ declare namespace API { type SubscribeDiscountPromo = { rule_name?: string; + rule_type?: string; promo_price: number; - end_time?: number; + expires_at?: number; }; type SubscribeGroup = {