feat(group): add node group management UI
Add node group management interface with automatic user assignment and traffic-based grouping. Features: - Node group CRUD with traffic range configuration - Three grouping modes: average, subscription-based, and traffic-based - Group recalculation with preview and history tracking - Subscribe-to-group mapping management - User subscription group locking - Group calculation history with detailed reports - Multi-language support (en-US, zh-CN) - Enhanced node and subscription forms with group selection
This commit is contained in:
@@ -0,0 +1,199 @@
|
||||
"use client";
|
||||
|
||||
import { Button } from "@workspace/ui/components/button";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@workspace/ui/components/dialog";
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@workspace/ui/components/form";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from "@workspace/ui/components/select";
|
||||
import {
|
||||
getUserGroupList,
|
||||
} from "@workspace/ui/services/admin/group";
|
||||
import { zodResolver } from "@hookform/resolvers/zod";
|
||||
import { useForm } from "react-hook-form";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { z } from "zod";
|
||||
import { useQuery } from "@tanstack/react-query";
|
||||
import React, { useEffect } from "react";
|
||||
|
||||
const editUserGroupSchema = z.object({
|
||||
user_group_id: z.number().min(0),
|
||||
group_locked: z.boolean(),
|
||||
});
|
||||
|
||||
type EditUserGroupFormValues = z.infer<typeof editUserGroupSchema>;
|
||||
|
||||
interface EditUserGroupDialogProps {
|
||||
userId: number;
|
||||
userSubscribeId?: number;
|
||||
currentGroupId?: number | undefined;
|
||||
currentLocked?: boolean | undefined;
|
||||
currentGroupIds?: number[] | null | undefined;
|
||||
trigger: React.ReactNode;
|
||||
onSubmit?: (values: EditUserGroupFormValues) => Promise<boolean>;
|
||||
}
|
||||
|
||||
export default function EditUserGroupDialog({
|
||||
userId: _userId,
|
||||
userSubscribeId: _userSubscribeId,
|
||||
currentGroupId,
|
||||
currentLocked,
|
||||
currentGroupIds,
|
||||
trigger,
|
||||
onSubmit,
|
||||
}: EditUserGroupDialogProps) {
|
||||
const { t } = useTranslation("user");
|
||||
const [open, setOpen] = React.useState(false);
|
||||
|
||||
// Fetch user groups list
|
||||
const { data: groupsData } = useQuery({
|
||||
enabled: open,
|
||||
queryKey: ["getUserGroupList"],
|
||||
queryFn: async () => {
|
||||
const { data } = await getUserGroupList({
|
||||
page: 1,
|
||||
size: 100,
|
||||
});
|
||||
return data.data?.list || [];
|
||||
},
|
||||
});
|
||||
|
||||
const form = useForm<EditUserGroupFormValues>({
|
||||
resolver: zodResolver(editUserGroupSchema),
|
||||
defaultValues: {
|
||||
user_group_id: 0,
|
||||
group_locked: false,
|
||||
},
|
||||
});
|
||||
|
||||
// Reset form when dialog closes
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
form.reset();
|
||||
}
|
||||
}, [open, form]);
|
||||
|
||||
// Set form values when dialog opens
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
// Support both usage scenarios:
|
||||
// 1. User list page: currentGroupId (single number)
|
||||
// 2. Subscribe detail page: currentGroupIds (array)
|
||||
const groupId = currentGroupId || (currentGroupIds?.[0]) || 0;
|
||||
|
||||
form.reset({
|
||||
user_group_id: groupId,
|
||||
group_locked: currentLocked || false,
|
||||
});
|
||||
}
|
||||
}, [open, currentGroupId, currentGroupIds, currentLocked, form]);
|
||||
|
||||
const handleSubmit = async (values: EditUserGroupFormValues) => {
|
||||
if (onSubmit) {
|
||||
const success = await onSubmit(values);
|
||||
if (success) {
|
||||
setOpen(false);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={setOpen}>
|
||||
<DialogTrigger asChild>{trigger}</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-[425px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t("editUserGroup", "Edit User Group")}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t(
|
||||
"editUserGroupDescription",
|
||||
"Edit user group assignment and lock status"
|
||||
)}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="user_group_id"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>{t("userGroup", "User Group")}</FormLabel>
|
||||
<Select
|
||||
value={field.value > 0 ? String(field.value) : undefined}
|
||||
onValueChange={(value) => field.onChange(parseInt(value))}
|
||||
>
|
||||
<FormControl>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t("selectGroup", "Select a group")} />
|
||||
</SelectTrigger>
|
||||
</FormControl>
|
||||
<SelectContent>
|
||||
{groupsData?.map((group: API.UserGroup) => (
|
||||
<SelectItem key={group.id} value={String(group.id)}>
|
||||
{group.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="group_locked"
|
||||
render={({ field }) => (
|
||||
<FormItem className="flex flex-row items-center justify-between rounded-lg border p-3 shadow-sm">
|
||||
<div className="space-y-0.5">
|
||||
<FormLabel>{t("lockGroup", "Lock Group")}</FormLabel>
|
||||
<div className="text-[0.8rem] text-muted-foreground">
|
||||
{t(
|
||||
"lockGroupDescription",
|
||||
"Prevent automatic grouping from changing this user's group"
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<FormControl>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={field.value}
|
||||
onChange={(e) => field.onChange(e.target.checked)}
|
||||
className="h-4 w-4"
|
||||
/>
|
||||
</FormControl>
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="submit">
|
||||
{t("save", "Save")}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -2,6 +2,13 @@ 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,
|
||||
@@ -35,6 +42,10 @@ import {
|
||||
getUserList,
|
||||
updateUserBasicInfo,
|
||||
} from "@workspace/ui/services/admin/user";
|
||||
import {
|
||||
// getUserGroupList,
|
||||
previewUserNodes,
|
||||
} from "@workspace/ui/services/admin/group";
|
||||
import { useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { toast } from "sonner";
|
||||
@@ -47,6 +58,7 @@ 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";
|
||||
|
||||
export default function User() {
|
||||
const { t } = useTranslation("user");
|
||||
@@ -56,12 +68,21 @@ export default function User() {
|
||||
|
||||
const { subscribes } = useSubscribe();
|
||||
|
||||
// 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 (
|
||||
@@ -75,6 +96,7 @@ export default function User() {
|
||||
userId={row.id}
|
||||
/>,
|
||||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||||
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
||||
<ConfirmButton
|
||||
cancelText={t("cancel", "Cancel")}
|
||||
confirmText={t("confirm", "Confirm")}
|
||||
@@ -144,6 +166,7 @@ export default function User() {
|
||||
}}
|
||||
columns={[
|
||||
{
|
||||
id: "enable",
|
||||
accessorKey: "enable",
|
||||
header: t("enable", "Enable"),
|
||||
cell: ({ row }) => (
|
||||
@@ -174,10 +197,12 @@ export default function User() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "id",
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
},
|
||||
{
|
||||
id: "deleted_at",
|
||||
accessorKey: "deleted_at",
|
||||
header: t("isDeleted", "Deleted"),
|
||||
cell: ({ row }) => {
|
||||
@@ -190,6 +215,7 @@ export default function User() {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "auth_methods",
|
||||
accessorKey: "auth_methods",
|
||||
header: t("userName", "Username"),
|
||||
cell: ({ row }) => {
|
||||
@@ -208,6 +234,7 @@ export default function User() {
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "balance",
|
||||
accessorKey: "balance",
|
||||
header: t("balance", "Balance"),
|
||||
cell: ({ row }) => (
|
||||
@@ -215,6 +242,7 @@ export default function User() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "gift_amount",
|
||||
accessorKey: "gift_amount",
|
||||
header: t("giftAmount", "Gift Amount"),
|
||||
cell: ({ row }) => (
|
||||
@@ -222,6 +250,7 @@ export default function User() {
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "commission",
|
||||
accessorKey: "commission",
|
||||
header: t("commission", "Commission"),
|
||||
cell: ({ row }) => (
|
||||
@@ -229,16 +258,19 @@ export default function User() {
|
||||
),
|
||||
},
|
||||
{
|
||||
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")),
|
||||
@@ -276,10 +308,13 @@ export default function User() {
|
||||
{
|
||||
key: "subscribe_id",
|
||||
placeholder: t("subscription", "Subscription"),
|
||||
options: subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id!),
|
||||
})),
|
||||
options: [
|
||||
{ label: t("all", "All"), value: "" },
|
||||
...(subscribes?.map((item) => ({
|
||||
label: item.name!,
|
||||
value: String(item.id!),
|
||||
})) || []),
|
||||
],
|
||||
},
|
||||
{
|
||||
key: "search",
|
||||
@@ -401,3 +436,80 @@ function SubscriptionSheet({ userId }: { userId: number }) {
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
|
||||
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="text-sm font-medium text-muted-foreground">
|
||||
{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] overflow-y-auto space-y-4">
|
||||
{previewData.node_groups.map((group) => (
|
||||
<div key={group.id}>
|
||||
<h4 className="text-sm font-semibold mb-2">
|
||||
{group.name || (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 key={node.id} className="border-b">
|
||||
<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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -16,6 +16,8 @@ import { formatBytes } from "@workspace/ui/utils/formatting";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Display } from "@/components/display";
|
||||
import { formatDate } from "@/utils/common";
|
||||
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
||||
// import { getUserGroupList } from "@workspace/ui/services/admin/group";
|
||||
|
||||
export function UserSubscribeDetail({
|
||||
id,
|
||||
@@ -37,10 +39,33 @@ export function UserSubscribeDetail({
|
||||
},
|
||||
});
|
||||
|
||||
// Fetch user groups for display
|
||||
// const { data: groupsData } = useQuery({
|
||||
// enabled: id !== 0 && enabled,
|
||||
// queryKey: ["getUserGroupList"],
|
||||
// queryFn: async () => {
|
||||
// const { data } = await getUserGroupList({
|
||||
// page: 1,
|
||||
// size: 100,
|
||||
// });
|
||||
// return data.data?.list || [];
|
||||
// },
|
||||
// });
|
||||
|
||||
if (!id) return "--";
|
||||
|
||||
const usedTraffic = data ? data.upload + data.download : 0;
|
||||
const totalTraffic = data?.traffic || 0;
|
||||
const remainingTraffic = totalTraffic > 0 ? totalTraffic - usedTraffic : 0;
|
||||
|
||||
// Get user group info from data.user
|
||||
// const userGroupId = typeof data?.user?.user_group_id === 'number' ? data?.user?.user_group_id : 0;
|
||||
// const groupLocked = data?.user?.group_locked || false;
|
||||
// const groupIds = userGroupId > 0 ? [userGroupId] : [];
|
||||
|
||||
// const groupNames = userGroupId > 0
|
||||
// ? groupsData?.find((g: API.UserGroup) => g.id === userGroupId)?.name || "--"
|
||||
// : "--";
|
||||
|
||||
const subscribeContent = (
|
||||
<div className="space-y-4">
|
||||
@@ -76,6 +101,16 @@ export function UserSubscribeDetail({
|
||||
: "--"}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("remainingTraffic")}</span>
|
||||
<span>
|
||||
{data
|
||||
? totalTraffic === 0
|
||||
? t("unlimited")
|
||||
: formatBytes(remainingTraffic)
|
||||
: "--"}
|
||||
</span>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("startTime")}</span>
|
||||
<span>
|
||||
@@ -88,6 +123,35 @@ export function UserSubscribeDetail({
|
||||
{data?.expire_time ? formatDate(data.expire_time) : "--"}
|
||||
</span>
|
||||
</li>
|
||||
{/* <li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("userGroup")}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span>{groupNames || "--"}</span>
|
||||
{data?.id && (
|
||||
<EditUserGroupDialog
|
||||
userId={data?.user_id || 0}
|
||||
userSubscribeId={data?.id}
|
||||
currentGroupIds={groupIds}
|
||||
currentLocked={groupLocked}
|
||||
trigger={
|
||||
<Button variant="ghost" size="sm" className="h-6 px-2">
|
||||
{t("edit", "Edit")}
|
||||
</Button>
|
||||
}
|
||||
onSubmit={async () => {
|
||||
window.location.reload();
|
||||
return true;
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</li>
|
||||
<li className="flex items-center justify-between">
|
||||
<span className="text-muted-foreground">{t("groupLocked")}</span>
|
||||
<span>
|
||||
{groupLocked ? t("yes", "Yes") : t("no", "No")}
|
||||
</span>
|
||||
</li> */}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -143,6 +143,19 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
||||
<Display type="traffic" unlimited value={row.getValue("traffic")} />
|
||||
),
|
||||
},
|
||||
{
|
||||
id: "remaining_traffic",
|
||||
header: t("remainingTraffic", "Remaining Traffic"),
|
||||
cell: ({ row }) => {
|
||||
const upload = row.original.upload || 0;
|
||||
const download = row.original.download || 0;
|
||||
const totalTraffic = row.original.traffic || 0;
|
||||
const remainingTraffic = totalTraffic > 0 ? totalTraffic - upload - download : 0;
|
||||
return (
|
||||
<Display type="traffic" unlimited value={remainingTraffic} />
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
accessorKey: "speed_limit",
|
||||
header: t("speedLimit", "Speed Limit"),
|
||||
@@ -390,7 +403,7 @@ function RowMoreActions({
|
||||
"This action cannot be undone."
|
||||
)}
|
||||
onConfirm={async () => {
|
||||
await deleteUserSubscribe({ user_subscribe_id: row.id });
|
||||
await deleteUserSubscribe({ user_subscribe_id: String(row.id) });
|
||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||
refresh();
|
||||
}}
|
||||
|
||||
Reference in New Issue
Block a user