71aaa08230
Build and Release / Build (push) Has been cancelled
备注保存前获取完整用户详情构造请求体,TC03 清空备注场景不再丢失其他字段。 拆出 RemarkForm 组件,增加保存中禁用态和失败错误提示。
727 lines
23 KiB
TypeScript
727 lines
23 KiB
TypeScript
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,
|
||
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,
|
||
SheetHeader,
|
||
SheetTitle,
|
||
SheetTrigger,
|
||
} from "@workspace/ui/components/sheet";
|
||
import { Switch } from "@workspace/ui/components/switch";
|
||
import {
|
||
Tabs,
|
||
TabsContent,
|
||
TabsList,
|
||
TabsTrigger,
|
||
} from "@workspace/ui/components/tabs";
|
||
import { Combobox } from "@workspace/ui/composed/combobox";
|
||
// import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||
import {
|
||
ProTable,
|
||
type ProTableActions,
|
||
} from "@workspace/ui/composed/pro-table/pro-table";
|
||
import {
|
||
// getUserGroupList,
|
||
previewUserNodes,
|
||
} from "@workspace/ui/services/admin/group";
|
||
import {
|
||
createUser,
|
||
// deleteUser,
|
||
getUserDetail,
|
||
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 { useTranslation } from "react-i18next";
|
||
import { toast } from "sonner";
|
||
import { Display } from "@/components/display";
|
||
import { useSubscribe } from "@/stores/subscribe";
|
||
import { formatDate } from "@/utils/common";
|
||
import FamilyManagement from "./family";
|
||
import { RemarkForm } from "./remark-form";
|
||
import { buildUserBasicInfoPayload } from "./user-basic-info-payload";
|
||
import { UserDetail } from "./user-detail";
|
||
import UserForm from "./user-form";
|
||
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 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);
|
||
const ref = useRef<ProTableActions>(null);
|
||
const sp = useSearch({ strict: false }) as Record<string, string | undefined>;
|
||
|
||
const { subscribes } = useSubscribe();
|
||
|
||
const searchRef = useRef({
|
||
type: sp.user_id ? "user_id" : "email",
|
||
value: sp.search || sp.user_id || "",
|
||
});
|
||
|
||
const handleSearch = useCallback((type: string, value: string) => {
|
||
searchRef.current = { type, value };
|
||
ref.current?.refresh();
|
||
}, []);
|
||
|
||
const buildSearchParams = () => {
|
||
const { type, value } = searchRef.current;
|
||
if (!value) return {};
|
||
switch (type) {
|
||
case "user_id":
|
||
return { user_id: Number(value) };
|
||
case "device":
|
||
return { search: value };
|
||
case "subscribe_id":
|
||
return { subscribe_id: Number(value) };
|
||
default:
|
||
return { search: value };
|
||
}
|
||
};
|
||
|
||
// const { data: userGroupsData } = useQuery({
|
||
// queryKey: ["userGroups"],
|
||
// queryFn: async () => {
|
||
// const { data } = await getUserGroupList({ page: 1, size: 1000 });
|
||
// return data.data?.list || [];
|
||
// },
|
||
// });
|
||
|
||
const initialFilters = {
|
||
search: sp.search || undefined,
|
||
user_id: sp.user_id || undefined,
|
||
subscribe_id: sp.subscribe_id || undefined,
|
||
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||
short_code: sp.short_code || undefined,
|
||
// user_group_id: sp.user_group_id || undefined,
|
||
};
|
||
|
||
return (
|
||
<ProTable<API.User, API.GetUserListParams>
|
||
action={ref}
|
||
actions={{
|
||
render: (row) => [
|
||
<ProfileSheet
|
||
key="profile"
|
||
onUpdated={() => ref.current?.refresh()}
|
||
userId={row.id}
|
||
/>,
|
||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||
<DeviceGroupSheet
|
||
key="device-group"
|
||
onChanged={() => ref.current?.refresh()}
|
||
userId={row.id}
|
||
/>,
|
||
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
||
/* <ConfirmButton
|
||
cancelText={t("cancel", "Cancel")}
|
||
confirmText={t("confirm", "Confirm")}
|
||
description={t(
|
||
"deleteDescription",
|
||
"This action cannot be undone."
|
||
)}
|
||
key="edit"
|
||
onConfirm={async () => {
|
||
await deleteUser({ id: row.id });
|
||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||
ref.current?.refresh();
|
||
}}
|
||
title={t("confirmDelete", "Confirm Delete")}
|
||
trigger={
|
||
<Button variant="destructive">{t("delete", "Delete")}</Button>
|
||
}
|
||
/>,*/
|
||
<DropdownMenu key="more" modal={false}>
|
||
<DropdownMenuTrigger asChild>
|
||
<Button variant="outline">{t("more", "More")}</Button>
|
||
</DropdownMenuTrigger>
|
||
<DropdownMenuContent align="end">
|
||
<InviteStatsMenuItem userId={row.id} />
|
||
<DropdownMenuItem asChild>
|
||
<Link search={{ user_id: row.id }} to="/dashboard/order">
|
||
{t("orderList", "Order List")}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link search={{ user_id: row.id }} to="/dashboard/log/login">
|
||
{t("loginLogs", "Login Logs")}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link search={{ user_id: row.id }} to="/dashboard/log/balance">
|
||
{t("balanceLogs", "Balance Logs")}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link
|
||
search={{ user_id: row.id }}
|
||
to="/dashboard/log/commission"
|
||
>
|
||
{t("commissionLogs", "Commission Logs")}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
<DropdownMenuItem asChild>
|
||
<Link search={{ user_id: row.id }} to="/dashboard/log/gift">
|
||
{t("giftLogs", "Gift Logs")}
|
||
</Link>
|
||
</DropdownMenuItem>
|
||
</DropdownMenuContent>
|
||
</DropdownMenu>,
|
||
],
|
||
}}
|
||
columns={[
|
||
{
|
||
id: "enable",
|
||
accessorKey: "enable",
|
||
header: t("enable", "Enable"),
|
||
cell: ({ row }) => (
|
||
<Switch
|
||
defaultChecked={row.getValue("enable")}
|
||
onCheckedChange={async (checked) => {
|
||
const {
|
||
auth_methods: _auth_methods,
|
||
user_devices: _user_devices,
|
||
enable_balance_notify: _enable_balance_notify,
|
||
enable_login_notify: _enable_login_notify,
|
||
enable_subscribe_notify: _enable_subscribe_notify,
|
||
enable_trade_notify: _enable_trade_notify,
|
||
updated_at: _updated_at,
|
||
created_at: _created_at,
|
||
id,
|
||
...rest
|
||
} = row.original;
|
||
await updateUserBasicInfo({
|
||
user_id: id,
|
||
...rest,
|
||
enable: checked,
|
||
} as unknown as API.UpdateUserBasiceInfoRequest);
|
||
toast.success(t("updateSuccess", "Updated successfully"));
|
||
ref.current?.refresh();
|
||
}}
|
||
/>
|
||
),
|
||
},
|
||
{
|
||
id: "id",
|
||
accessorKey: "id",
|
||
header: "ID",
|
||
},
|
||
/*{
|
||
id: "deleted_at",
|
||
accessorKey: "deleted_at",
|
||
header: t("isDeleted", "Deleted"),
|
||
cell: ({ row }) => {
|
||
const deletedAt = row.getValue("deleted_at") as number | undefined;
|
||
return deletedAt ? (
|
||
<Badge variant="destructive">{t("deleted", "Deleted")}</Badge>
|
||
) : (
|
||
<Badge variant="outline">{t("normal", "Normal")}</Badge>
|
||
);
|
||
},
|
||
},*/
|
||
{
|
||
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 (
|
||
<div className="flex items-center">
|
||
{/* <Badge
|
||
className="mr-1 uppercase"
|
||
title={method?.verified ? t("verified", "Verified") : ""}
|
||
>
|
||
{method?.auth_type}
|
||
</Badge>*/}
|
||
{deviceType && (
|
||
<Badge className="mr-1" variant="secondary">
|
||
{deviceType}
|
||
</Badge>
|
||
)}
|
||
<span title={isDevice ? display : undefined}>{display}</span>
|
||
<Popover>
|
||
<PopoverTrigger>
|
||
<div className={"flex items-center"}>
|
||
{row.original?.remark ? `(${row.original.remark})` : ""}
|
||
<FilePenLine className={"ml-2 text-primary"} size={14} />
|
||
</div>
|
||
</PopoverTrigger>
|
||
<PopoverContent className={"w-64"}>
|
||
<RemarkForm
|
||
initialRemark={row.original.remark}
|
||
onSave={async (remark) => {
|
||
const { data } = await getUserDetail({
|
||
id: row.original.id,
|
||
});
|
||
const user = data.data;
|
||
|
||
if (!user) {
|
||
throw new Error("User detail not found");
|
||
}
|
||
|
||
await updateUserBasicInfo(
|
||
buildUserBasicInfoPayload(user, remark)
|
||
);
|
||
toast.success(t("updateSuccess"));
|
||
ref.current?.refresh();
|
||
}}
|
||
/>
|
||
</PopoverContent>
|
||
</Popover>
|
||
</div>
|
||
);
|
||
},
|
||
},
|
||
/* {
|
||
id: "balance",
|
||
accessorKey: "balance",
|
||
header: t("balance", "Balance"),
|
||
cell: ({ row }) => (
|
||
<Display type="currency" value={row.getValue("balance")} />
|
||
),
|
||
},
|
||
{
|
||
id: "gift_amount",
|
||
accessorKey: "gift_amount",
|
||
header: t("giftAmount", "Gift Amount"),
|
||
cell: ({ row }) => (
|
||
<Display type="currency" value={row.getValue("gift_amount")} />
|
||
),
|
||
},*/
|
||
{
|
||
id: "commission",
|
||
accessorKey: "commission",
|
||
header: t("commission", "Commission"),
|
||
cell: ({ row }) => (
|
||
<Display type="currency" value={row.getValue("commission")} />
|
||
),
|
||
},
|
||
{
|
||
id: "refer_code",
|
||
accessorKey: "refer_code",
|
||
header: t("inviteCode", "Invite Code"),
|
||
cell: ({ row }) => row.getValue("refer_code") || "--",
|
||
},
|
||
{
|
||
id: "referer_id",
|
||
accessorKey: "referer_id",
|
||
header: t("referer", "Referer"),
|
||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||
},
|
||
{
|
||
id: "created_at",
|
||
accessorKey: "created_at",
|
||
header: t("createdAt", "Created At"),
|
||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||
},
|
||
]}
|
||
header={{
|
||
title: (
|
||
<UserSearchBar
|
||
initialType={searchRef.current.type}
|
||
initialValue={searchRef.current.value}
|
||
onSearch={handleSearch}
|
||
subscribes={subscribes}
|
||
/>
|
||
),
|
||
toolbar: (
|
||
<UserForm<API.CreateUserRequest>
|
||
key="create"
|
||
loading={loading}
|
||
onSubmit={async (values) => {
|
||
setLoading(true);
|
||
try {
|
||
await createUser(values);
|
||
toast.success(t("createSuccess", "Created successfully"));
|
||
ref.current?.refresh();
|
||
setLoading(false);
|
||
|
||
return true;
|
||
} catch {
|
||
setLoading(false);
|
||
|
||
return false;
|
||
}
|
||
}}
|
||
title={t("createUser", "Create User")}
|
||
trigger={t("create", "Create")}
|
||
/>
|
||
),
|
||
}}
|
||
initialFilters={initialFilters}
|
||
key={initialFilters.user_id}
|
||
request={async (pagination) => {
|
||
const { data } = await getUserList({
|
||
...pagination,
|
||
...buildSearchParams(),
|
||
});
|
||
return {
|
||
list: data.data?.list || [],
|
||
total: data.data?.total || 0,
|
||
};
|
||
}}
|
||
/>
|
||
);
|
||
}
|
||
|
||
function ProfileSheet({
|
||
userId,
|
||
onUpdated,
|
||
}: {
|
||
userId: number;
|
||
onUpdated?: () => void;
|
||
}) {
|
||
const { t } = useTranslation("user");
|
||
const [open, setOpen] = useState(false);
|
||
const { data: user, refetch } = useQuery({
|
||
enabled: open,
|
||
queryKey: ["user", userId],
|
||
queryFn: async () => {
|
||
const { data } = await getUserDetail({ id: userId });
|
||
return data.data as API.User;
|
||
},
|
||
});
|
||
|
||
const refetchAll = async () => {
|
||
await refetch();
|
||
onUpdated?.();
|
||
return Promise.resolve();
|
||
};
|
||
return (
|
||
<Sheet onOpenChange={setOpen} open={open}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="default">{t("edit", "Edit")}</Button>
|
||
</SheetTrigger>
|
||
<SheetContent
|
||
className="w-[700px] max-w-full md:max-w-screen-lg"
|
||
side="right"
|
||
>
|
||
<SheetHeader>
|
||
<SheetTitle>
|
||
{t("userProfile", "User Profile")} · ID: {userId}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
{user && (
|
||
<ScrollArea className="h-[calc(100dvh-140px)] p-2">
|
||
<Tabs defaultValue="basic">
|
||
<TabsList className="mb-3">
|
||
<TabsTrigger value="basic">
|
||
{t("basicInfoTitle", "Basic Info")}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="notify">
|
||
{t("notifySettingsTitle", "Notify Settings")}
|
||
</TabsTrigger>
|
||
<TabsTrigger value="auth">
|
||
{t("authMethodsTitle", "Auth Methods")}
|
||
</TabsTrigger>
|
||
</TabsList>
|
||
<TabsContent className="mt-0" value="basic">
|
||
<BasicInfoForm refetch={refetchAll} user={user} />
|
||
</TabsContent>
|
||
<TabsContent className="mt-0" value="notify">
|
||
<NotifySettingsForm refetch={refetchAll} user={user} />
|
||
</TabsContent>
|
||
<TabsContent className="mt-0" value="auth">
|
||
<AuthMethodsForm refetch={refetchAll} user={user} />
|
||
</TabsContent>
|
||
</Tabs>
|
||
</ScrollArea>
|
||
)}
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function SubscriptionSheet({ userId }: { userId: number }) {
|
||
const { t } = useTranslation("user");
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<Sheet onOpenChange={setOpen} open={open}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="secondary">{t("subscription", "Subscription")}</Button>
|
||
</SheetTrigger>
|
||
<SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
|
||
<SheetHeader>
|
||
<SheetTitle>
|
||
{t("subscriptionList", "Subscription List")} · ID: {userId}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
<div className="mt-2 px-4">
|
||
<UserSubscription userId={userId} />
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
function InviteStatsMenuItem({ userId }: { userId: number }) {
|
||
const { t } = useTranslation("user");
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<>
|
||
<DropdownMenuItem
|
||
onSelect={(e) => {
|
||
e.preventDefault();
|
||
setOpen(true);
|
||
}}
|
||
>
|
||
{t("inviteStats", "Invite Statistics")}
|
||
</DropdownMenuItem>
|
||
<UserInviteStatsSheet
|
||
onOpenChange={setOpen}
|
||
open={open}
|
||
userId={userId}
|
||
/>
|
||
</>
|
||
);
|
||
}
|
||
|
||
function DeviceGroupSheet({
|
||
userId,
|
||
onChanged,
|
||
}: {
|
||
userId: number;
|
||
onChanged?: () => void;
|
||
}) {
|
||
const { t } = useTranslation("user");
|
||
const [open, setOpen] = useState(false);
|
||
return (
|
||
<Sheet onOpenChange={setOpen} open={open}>
|
||
<SheetTrigger asChild>
|
||
<Button variant="outline">{t("deviceGroup", "Device Group")}</Button>
|
||
</SheetTrigger>
|
||
<SheetContent className="w-[1000px] max-w-full md:max-w-7xl" side="right">
|
||
<SheetHeader>
|
||
<SheetTitle>
|
||
{t("deviceGroup", "Device Group")} · ID: {userId}
|
||
</SheetTitle>
|
||
</SheetHeader>
|
||
<div className="mt-2 px-4">
|
||
<FamilyManagement initialUserId={userId} onChanged={onChanged} />
|
||
</div>
|
||
</SheetContent>
|
||
</Sheet>
|
||
);
|
||
}
|
||
|
||
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);
|
||
|
||
return (
|
||
<div className="flex items-center gap-2">
|
||
<Select
|
||
onValueChange={(v) => {
|
||
setSearchType(v);
|
||
setSearchValue("");
|
||
}}
|
||
value={searchType}
|
||
>
|
||
<SelectTrigger className="w-48">
|
||
<SelectValue />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="email">设备码/邮箱</SelectItem>
|
||
<SelectItem value="user_id">{t("userId", "User ID")}</SelectItem>
|
||
<SelectItem value="subscribe_id">
|
||
{t("subscription", "Subscription")}
|
||
</SelectItem>
|
||
</SelectContent>
|
||
</Select>
|
||
{searchType === "subscribe_id" ? (
|
||
<Combobox
|
||
className="w-48"
|
||
onChange={(value) => {
|
||
setSearchValue(value);
|
||
onSearch("subscribe_id", value);
|
||
}}
|
||
options={subscribes?.map((item) => ({
|
||
label: item.name!,
|
||
value: String(item.id!),
|
||
}))}
|
||
placeholder={t("subscription", "Subscription")}
|
||
value={searchValue}
|
||
/>
|
||
) : (
|
||
<>
|
||
<Input
|
||
className="w-48"
|
||
onChange={(e) => setSearchValue(e.target.value)}
|
||
onKeyDown={(e) =>
|
||
e.key === "Enter" && onSearch(searchType, searchValue)
|
||
}
|
||
placeholder={t("searchInputPlaceholder", "Enter search term")}
|
||
value={searchValue}
|
||
/>
|
||
<Button
|
||
onClick={() => onSearch(searchType, searchValue)}
|
||
variant="default"
|
||
>
|
||
{t("search", "Search")}
|
||
</Button>
|
||
{searchValue && (
|
||
<Button
|
||
onClick={() => {
|
||
setSearchValue("");
|
||
onSearch(searchType, "");
|
||
}}
|
||
variant="outline"
|
||
>
|
||
{t("resetSearch", "Reset")}
|
||
</Button>
|
||
)}
|
||
</>
|
||
)}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function PreviewNodesDialog({ userId }: { userId: number }) {
|
||
const { t } = useTranslation("user");
|
||
const [open, setOpen] = useState(false);
|
||
const { data: previewData, isLoading } = useQuery({
|
||
enabled: open,
|
||
queryKey: ["previewUserNodes", userId],
|
||
queryFn: async () => {
|
||
const { data } = await previewUserNodes({ user_id: userId });
|
||
return data.data;
|
||
},
|
||
});
|
||
|
||
return (
|
||
<Dialog onOpenChange={setOpen} open={open}>
|
||
<DialogTrigger asChild>
|
||
<Button variant="outline">{t("previewNodes", "Preview Nodes")}</Button>
|
||
</DialogTrigger>
|
||
<DialogContent className="max-w-2xl">
|
||
<DialogHeader>
|
||
<DialogTitle>
|
||
{t("previewNodes", "Preview Nodes")} · ID: {userId}
|
||
</DialogTitle>
|
||
</DialogHeader>
|
||
{isLoading ? (
|
||
<div className="py-8 text-center text-muted-foreground">
|
||
{t("loading", "Loading...")}
|
||
</div>
|
||
) : previewData ? (
|
||
<div className="space-y-4">
|
||
<div>
|
||
<span className="font-medium text-muted-foreground text-sm">
|
||
{t("availableNodes", "Available Nodes")}:
|
||
</span>{" "}
|
||
{previewData.node_groups?.reduce(
|
||
(sum, group) => sum + (group.nodes?.length || 0),
|
||
0
|
||
) || 0}
|
||
</div>
|
||
{previewData.node_groups && previewData.node_groups.length > 0 ? (
|
||
<div className="max-h-[400px] space-y-4 overflow-y-auto">
|
||
{previewData.node_groups.map((group) => (
|
||
<div key={group.id}>
|
||
<h4 className="mb-2 font-semibold text-sm">
|
||
{group.name ||
|
||
(group.id === -1
|
||
? t("subscriptionNodes", "Subscription Nodes")
|
||
: group.id === 0
|
||
? t("publicNodes", "Public Nodes")
|
||
: `${t("nodeGroup", "Node Group")} ${group.id}`)}
|
||
</h4>
|
||
{group.nodes && group.nodes.length > 0 ? (
|
||
<table className="w-full text-sm">
|
||
<thead>
|
||
<tr className="border-b">
|
||
<th className="p-2 text-left font-medium">ID</th>
|
||
<th className="p-2 text-left font-medium">
|
||
{t("name", "Name")}
|
||
</th>
|
||
<th className="p-2 text-left font-medium">
|
||
{t("address", "Address")}
|
||
</th>
|
||
</tr>
|
||
</thead>
|
||
<tbody>
|
||
{group.nodes.map((node) => (
|
||
<tr className="border-b" key={node.id}>
|
||
<td className="p-2">{node.id}</td>
|
||
<td className="p-2">{node.name}</td>
|
||
<td className="p-2">
|
||
{node.address}:{node.port}
|
||
</td>
|
||
</tr>
|
||
))}
|
||
</tbody>
|
||
</table>
|
||
) : null}
|
||
</div>
|
||
))}
|
||
</div>
|
||
) : (
|
||
<div className="py-4 text-center text-muted-foreground">
|
||
{t("noNodesAvailable", "No nodes available")}
|
||
</div>
|
||
)}
|
||
</div>
|
||
) : null}
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
}
|