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:
EUForest
2026-03-08 23:39:30 +08:00
parent dc55d85056
commit ae31019477
55 changed files with 6249 additions and 262 deletions
@@ -0,0 +1,174 @@
"use client";
import { Button } from "@workspace/ui/components/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
DialogTrigger,
} from "@workspace/ui/components/dialog";
import { Label } from "@workspace/ui/components/label";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@workspace/ui/components/select";
import { Loader2 } from "lucide-react";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useQuery } from "@tanstack/react-query";
import { toast } from "sonner";
import { getNodeGroupList, bindNodeGroups } from "@workspace/ui/services/admin/group";
interface BindNodeGroupsDialogProps {
userGroupIds: number[];
userGroupNames: string[];
onOpenChange?: (open: boolean) => void;
onSuccess?: () => void;
}
export default function BindNodeGroupsDialog({
userGroupIds,
userGroupNames,
onOpenChange,
onSuccess,
}: BindNodeGroupsDialogProps) {
const { t } = useTranslation("group");
const [open, setOpen] = useState(false);
const [saving, setSaving] = useState(false);
const [selectedNodeGroupId, setSelectedNodeGroupId] = useState<number | undefined>();
const { data: nodeGroupsData, isLoading } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
useEffect(() => {
if (open && nodeGroupsData) {
// Load current binding when dialog opens
loadCurrentBinding();
}
}, [open]);
const loadCurrentBinding = () => {
// Get first user group's current node group binding
// For batch binding, we'll default to unbound
setSelectedNodeGroupId(undefined);
};
const handleBind = async () => {
if (selectedNodeGroupId === undefined) {
toast.error(t("selectNodeGroupRequired", "Please select a node group"));
return;
}
setSaving(true);
try {
await bindNodeGroups({
user_group_ids: userGroupIds,
node_group_id: selectedNodeGroupId === 0 ? null : selectedNodeGroupId,
} as API.BindNodeGroupsRequest);
toast.success(
t("bindSuccess", "Successfully bound {{userGroupCount}} user groups to node group").replace(
/{{userGroupCount}}/g,
String(userGroupIds.length)
)
);
setOpen(false);
onOpenChange?.(false);
onSuccess?.();
} catch (error) {
console.error("Failed to bind node group:", error);
toast.error(t("bindFailed", "Failed to bind node group"));
} finally {
setSaving(false);
}
};
const displayNames =
userGroupNames.length > 2
? `${userGroupNames.slice(0, 2).join(", ")}... (${userGroupIds.length})`
: userGroupNames.join(", ");
return (
<Dialog open={open} onOpenChange={(newOpen) => {
setOpen(newOpen);
onOpenChange?.(newOpen);
}}>
<DialogTrigger asChild>
<Button variant="outline" size="sm">
{t("bindNodeGroup", "Bind Node Group")}
</Button>
</DialogTrigger>
<DialogContent className="max-w-md">
<DialogHeader>
<DialogTitle>{t("bindNodeGroup", "Bind Node Group")}</DialogTitle>
<DialogDescription>
{t(
"bindNodeGroupDescription",
"Select a node group to bind to user groups: {{userGroups}}",
{ userGroups: displayNames }
).replace(/{{userGroups}}/g, displayNames)}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{isLoading ? (
<div className="flex justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin" />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="node-group">{t("selectNodeGroup", "Select Node Group")}</Label>
<Select
value={selectedNodeGroupId?.toString() || ""}
onValueChange={(val) => setSelectedNodeGroupId(parseInt(val) || undefined)}
>
<SelectTrigger id="node-group" className="w-full">
<SelectValue placeholder={t("selectNodeGroupPlaceholder", "Select a node group...")} />
</SelectTrigger>
<SelectContent>
<SelectItem value="0">
{t("unbound", "Unbound")}
</SelectItem>
{nodeGroupsData?.map((nodeGroup) => (
<SelectItem key={nodeGroup.id} value={String(nodeGroup.id)}>
{nodeGroup.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)}
</div>
<DialogFooter>
<Button
variant="outline"
onClick={() => {
setOpen(false);
onOpenChange?.(false);
}}
disabled={saving}
>
{t("cancel", "Cancel")}
</Button>
<Button onClick={handleBind} disabled={saving || selectedNodeGroupId === undefined}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t("confirm", "Confirm")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}