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:
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user