Files
hi-frontend/apps/admin/src/sections/user/index.tsx
T
shanshanzhong147 71aaa08230
Build and Release / Build (push) Has been cancelled
fix(#HIF-37): 修复备注表单提交时未携带完整用户数据
备注保存前获取完整用户详情构造请求体,TC03 清空备注场景不再丢失其他字段。
拆出 RemarkForm 组件,增加保存中禁用态和失败错误提示。
2026-05-25 21:36:44 -07:00

727 lines
23 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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>
);
}