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
@@ -8,6 +8,7 @@ import {
AccordionTrigger,
} from "@workspace/ui/components/accordion";
import { Button } from "@workspace/ui/components/button";
import { Card } from "@workspace/ui/components/card";
import { Checkbox } from "@workspace/ui/components/checkbox";
import {
Form,
@@ -36,7 +37,7 @@ import {
TabsTrigger,
} from "@workspace/ui/components/tabs";
import { Combobox } from "@workspace/ui/composed/combobox";
import { ArrayInput } from "@workspace/ui/composed/dynamic-Inputs";
import { ArrayInput } from "@workspace/ui/composed/dynamic-inputs";
import { JSONEditor } from "@workspace/ui/composed/editor/index";
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
import { Icon } from "@workspace/ui/composed/icon";
@@ -44,6 +45,8 @@ import {
evaluateWithPrecision,
unitConversion,
} from "@workspace/ui/utils/unit-conversions";
import { getGroupConfig, getNodeGroupList } from "@workspace/ui/services/admin/group";
import { useQuery } from "@tanstack/react-query";
import { CreditCard, Server, Settings } from "lucide-react";
import { assign, shake } from "radash";
import { useCallback, useEffect, useRef, useState } from "react";
@@ -72,6 +75,8 @@ const defaultValues = {
language: "",
node_tags: [],
nodes: [],
node_group_id: "",
node_group_ids: [],
unit_time: "Month",
deduction_ratio: 0,
purchase_with_discount: false,
@@ -117,6 +122,8 @@ export default function SubscribeForm<T extends Record<string, any>>({
language: z.string().optional(),
node_tags: z.array(z.string()).optional(),
nodes: z.array(z.number()).optional(),
node_group_id: z.string().optional(),
node_group_ids: z.optional(z.array(z.string()).default([])),
deduction_ratio: z.number().optional(),
allow_deduction: z.boolean().optional(),
reset_cycle: z.number().optional(),
@@ -234,12 +241,22 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
useEffect(() => {
form?.reset(
assign(
defaultValues,
shake(initialValues, (value) => value === null) as Record<string, any>
)
const processedValues = assign(
defaultValues,
shake(initialValues, (value) => value === null) as Record<string, any>
);
// Convert node_group_id from number to string (including 0)
if (initialValues?.node_group_id !== undefined) {
processedValues.node_group_id = String(initialValues.node_group_id);
}
// Convert node_group_ids from number[] to string[]
if (initialValues?.node_group_ids && Array.isArray(initialValues.node_group_ids)) {
processedValues.node_group_ids = (initialValues.node_group_ids as any[]).map((id) => String(id));
}
form?.reset(processedValues);
const discount = form.getValues("discount") || [];
if (discount.length > 0) {
debouncedCalculateDiscount(discount, "discount");
@@ -256,15 +273,56 @@ export default function SubscribeForm<T extends Record<string, any>>({
);
async function handleSubmit(data: { [x: string]: any }) {
// Don't process node_group_id - submit as-is
const bool = await onSubmit(data as T);
if (bool) setOpen(false);
}
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags } = useNode();
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags, getNodesWithoutGroups, nodes } = useNode();
const tagGroups = getAllAvailableTags();
// Fetch node groups
const { data: nodeGroupsData } = useQuery({
queryKey: ["nodeGroups"],
queryFn: async () => {
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
return data.data?.list || [];
},
});
// Fetch group config to check if group feature is enabled
const { data: groupConfigData } = useQuery({
queryKey: ["groupConfig"],
queryFn: async () => {
const { data } = await getGroupConfig();
return data.data;
},
});
const isGroupEnabled = groupConfigData?.enabled || false;
const unit_time = form.watch("unit_time");
const node_group_id = form.watch("node_group_id");
const node_group_ids = form.watch("node_group_ids");
// Watch node_group_id and automatically include it in node_group_ids
useEffect(() => {
if (node_group_id) {
const currentGroupIds = form.getValues("node_group_ids") || [];
if (!currentGroupIds.includes(node_group_id)) {
form.setValue("node_group_ids", [...currentGroupIds, node_group_id]);
}
}
}, [node_group_id, form]);
// If node_group_id is empty or 0, automatically set it to the first item in node_group_ids
useEffect(() => {
if ((!node_group_id || node_group_id === "0") && node_group_ids && node_group_ids.length > 0) {
form.setValue("node_group_id", node_group_ids[0]);
}
}, [node_group_ids, node_group_id, form]);
return (
<Sheet onOpenChange={setOpen} open={open}>
@@ -932,80 +990,83 @@ export default function SubscribeForm<T extends Record<string, any>>({
<TabsContent className="space-y-4" value="servers">
<div className="space-y-6">
<FormField
control={form.control}
name="node_tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroup")}</FormLabel>
<FormControl>
<Accordion
className="w-full"
collapsible
type="single"
>
{tagGroups.map((tag) => {
const value = field.value || [];
const tagId = tag;
const nodesWithTag = getNodesByTag(tag);
{/* Show node_tags field only when group feature is disabled */}
{!isGroupEnabled && (
<FormField
control={form.control}
name="node_tags"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroup")}</FormLabel>
<FormControl>
<Accordion
className="w-full"
collapsible
type="single"
>
{tagGroups.map((tag) => {
const value = field.value || [];
const tagId = tag;
const nodesWithTag = getNodesByTag(tag);
return (
<AccordionItem key={tag} value={String(tag)}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(tagId as any)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
...value,
tagId,
] as any)
: form.setValue(
field.name,
value.filter(
(v: any) => v !== tagId
return (
<AccordionItem key={tag} value={String(tag)}>
<AccordionTrigger>
<div className="flex items-center gap-2">
<Checkbox
checked={value.includes(tagId as any)}
onCheckedChange={(checked) =>
checked
? form.setValue(field.name, [
...value,
tagId,
] as any)
: form.setValue(
field.name,
value.filter(
(v: any) => v !== tagId
)
)
)
}
/>
<Label>
{tag}
<span className="ml-2 text-muted-foreground text-xs">
({nodesWithTag.length})
</span>
</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1">
{getNodesByTag(tag).map((node) => (
<li
className="flex items-center justify-between gap-3"
key={node.id}
>
<span className="flex-1">
{node.name}
}
/>
<Label>
{tag}
<span className="ml-2 text-muted-foreground text-xs">
({nodesWithTag.length})
</span>
<span className="flex-1">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right">
{node.protocol}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</Label>
</div>
</AccordionTrigger>
<AccordionContent>
<ul className="space-y-1">
{getNodesByTag(tag).map((node) => (
<li
className="flex items-center justify-between gap-3"
key={node.id}
>
<span className="flex-1">
{node.name}
</span>
<span className="flex-1">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right">
{node.protocol}
</span>
</li>
))}
</ul>
</AccordionContent>
</AccordionItem>
);
})}
</Accordion>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
)}
<FormField
control={form.control}
@@ -1015,7 +1076,9 @@ export default function SubscribeForm<T extends Record<string, any>>({
<FormLabel>{t("form.node")}</FormLabel>
<FormControl>
<div className="flex flex-col gap-2">
{getNodesWithoutTags().map((item) => {
{/* When group feature is enabled, show nodes without groups */}
{/* When group feature is disabled, show nodes without tags */}
{(isGroupEnabled ? getNodesWithoutGroups() : getNodesWithoutTags()).map((item: API.Node) => {
const value = field.value || [];
return (
@@ -1056,10 +1119,281 @@ export default function SubscribeForm<T extends Record<string, any>>({
})}
</div>
</FormControl>
<FormDescription>
{isGroupEnabled
? t("form.nodesWithoutGroupsDescription", "Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)")
: t("form.nodesDescription", "Select nodes for this subscription")
}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
{/* Show node_group_ids field only when group feature is enabled */}
{isGroupEnabled && (
<>
{/* When no default node group is set, show simple node group selection */}
{!node_group_id ? (
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.nodeGroups", "Node Groups")}</FormLabel>
<FormControl>
<div className="space-y-4">
{nodeGroupsData?.map((g) => {
// Filter nodes that belong to this group
const nodesInGroup = (nodes || []).filter((node) => {
const nodeGroupIds = (node as any).node_group_ids || [];
return nodeGroupIds.includes(g.id);
});
return (
<div key={g.id} className="border rounded-lg p-4">
<div className="flex items-center space-x-2 mb-3">
<Checkbox
id={`subscribe-node-group-${g.id}`}
checked={field.value?.includes(String(g.id))}
onCheckedChange={(checked) => {
const currentValue = field.value || [];
const currentDefaultGroupId = form.getValues("node_group_id");
if (checked) {
const newValue = [...currentValue, String(g.id)];
form.setValue(field.name, newValue);
// If no default node group is set, set this one as default
if (!currentDefaultGroupId) {
form.setValue("node_group_id", String(g.id));
}
} else {
form.setValue(
field.name,
currentValue.filter((v: string) => v !== String(g.id))
);
}
}}
/>
<Label
htmlFor={`subscribe-node-group-${g.id}`}
className="cursor-pointer font-medium"
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length} {t("form.nodes", "nodes")})
</span>
</Label>
</div>
{/* Show nodes in this group */}
{nodesInGroup.length > 0 && (
<div className="ml-6 mt-3">
<div className="text-xs text-muted-foreground mb-2">
{t("form.nodesInGroup", "Nodes in this group:")}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded border p-2 text-sm bg-muted/30"
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</FormControl>
<FormDescription>
{t(
"form.nodeGroupsFirstSelectionDescription",
"Select node groups for this product. The first selected group will be set as the default node group."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
) : (
<>
{/* Default Node Group Selection - shown when default is set */}
<FormField
control={form.control}
name="node_group_id"
render={({ field }) => {
// Find the selected node group
const selectedNodeGroup = nodeGroupsData?.find((g) => String(g.id) === field.value);
// Filter nodes that belong to this group
const nodesInGroup = selectedNodeGroup ? (nodes || []).filter((node) => {
const nodeGroupIds = (node as any).node_group_ids || [];
return nodeGroupIds.includes(selectedNodeGroup.id);
}) : [];
return (
<FormItem>
<FormLabel>{t("form.defaultNodeGroup", "Default Node Group")}</FormLabel>
<Card className="p-4">
<FormControl>
<Combobox
placeholder={t("form.selectDefaultNodeGroup", "Select a default node group...")}
value={field.value}
onChange={(value) => {
form.setValue(field.name, value || "");
}}
options={[
{ label: t("form.noDefaultNodeGroup", "No Default Node Group"), value: "" },
...(nodeGroupsData?.map((g) => ({
label: g.name,
value: String(g.id),
})) || []),
]}
/>
</FormControl>
<FormDescription className="mt-2">
{t(
"form.defaultNodeGroupDescription",
"The default node group for this product."
)}
</FormDescription>
{/* Show nodes in the selected default node group */}
{nodesInGroup.length > 0 && (
<>
<div className="text-xs text-muted-foreground mb-2 mt-3">
{t("form.nodesInGroup", "Nodes in this group:")}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded border p-2 text-sm bg-muted/30"
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</>
)}
</Card>
<FormMessage />
</FormItem>
);
}}
/>
{/* Backup Node Groups Selection - filter out default node group */}
<FormField
control={form.control}
name="node_group_ids"
render={({ field }) => (
<FormItem>
<FormLabel>{t("form.backupNodeGroups", "Backup Node Groups")}</FormLabel>
<FormControl>
<div className="space-y-4">
{nodeGroupsData
?.filter((g) => String(g.id) !== node_group_id)
?.map((g) => {
// Filter nodes that belong to this group
const nodesInGroup = (nodes || []).filter((node) => {
const nodeGroupIds = (node as any).node_group_ids || [];
return nodeGroupIds.includes(g.id);
});
return (
<div key={g.id} className="border rounded-lg p-4">
<div className="flex items-center space-x-2 mb-3">
<Checkbox
id={`subscribe-backup-node-group-${g.id}`}
checked={field.value?.includes(String(g.id))}
onCheckedChange={(checked) => {
const currentValue = field.value || [];
if (checked) {
form.setValue(field.name, [...currentValue, String(g.id)]);
} else {
form.setValue(
field.name,
currentValue.filter((v: string) => v !== String(g.id))
);
}
}}
/>
<Label
htmlFor={`subscribe-backup-node-group-${g.id}`}
className="cursor-pointer font-medium"
>
{g.name}
<span className="ml-2 text-muted-foreground text-sm">
({nodesInGroup.length} {t("form.nodes", "nodes")})
</span>
</Label>
</div>
{/* Show nodes in this group */}
{nodesInGroup.length > 0 && (
<div className="ml-6 mt-3">
<div className="text-xs text-muted-foreground mb-2">
{t("form.nodesInGroup", "Nodes in this group:")}
</div>
<div className="grid grid-cols-1 gap-2">
{nodesInGroup.map((node) => (
<div
key={node.id}
className="flex items-center justify-between rounded border p-2 text-sm bg-muted/30"
>
<span className="flex-1 font-medium">
{node.name}
</span>
<span className="flex-1 text-muted-foreground">
{node.address}:{node.port}
</span>
<span className="flex-1 text-right text-muted-foreground">
{node.protocol}
</span>
</div>
))}
</div>
</div>
)}
</div>
);
})}
</div>
</FormControl>
<FormDescription>
{t(
"form.backupNodeGroupsDescription",
"Select additional backup node groups."
)}
</FormDescription>
<FormMessage />
</FormItem>
)}
/>
</>
)}
</>
)}
</div>
</TabsContent>
</Tabs>