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:
parent
dc55d85056
commit
ae31019477
@ -40,6 +40,8 @@
|
|||||||
"40005": "You do not have access permission, please contact the administrator if you have any questions.",
|
"40005": "You do not have access permission, please contact the administrator if you have any questions.",
|
||||||
"50001": "Corresponding coupon information not found, please check and try again.",
|
"50001": "Corresponding coupon information not found, please check and try again.",
|
||||||
"50002": "The coupon has been used, cannot be used again.",
|
"50002": "The coupon has been used, cannot be used again.",
|
||||||
|
"50003": "This coupon code is not supported by the current purchase plan.",
|
||||||
|
"50004": "Coupon has insufficient remaining uses.",
|
||||||
"60001": "Subscription has expired, please renew before using.",
|
"60001": "Subscription has expired, please renew before using.",
|
||||||
"60002": "Unable to use the subscription at the moment, please try again later.",
|
"60002": "Unable to use the subscription at the moment, please try again later.",
|
||||||
"60003": "An existing subscription is detected. Please cancel it before proceeding.",
|
"60003": "An existing subscription is detected. Please cancel it before proceeding.",
|
||||||
|
|||||||
186
apps/admin/public/assets/locales/en-US/group.json
Normal file
186
apps/admin/public/assets/locales/en-US/group.json
Normal file
@ -0,0 +1,186 @@
|
|||||||
|
{
|
||||||
|
"actions": "Actions",
|
||||||
|
"autoTrigger": "Auto",
|
||||||
|
"averageMode": "Average Grouping",
|
||||||
|
"cancel": "Cancel",
|
||||||
|
"completed": "Completed",
|
||||||
|
"confirm": "Confirm",
|
||||||
|
"confirmDelete": "Confirm Delete",
|
||||||
|
"config": "Config",
|
||||||
|
"create": "Create",
|
||||||
|
"created": "Created successfully",
|
||||||
|
"createdAt": "Created At",
|
||||||
|
"createNodeGroup": "Create Node Group",
|
||||||
|
"createUserGroup": "Create User Group",
|
||||||
|
"delete": "Delete",
|
||||||
|
"deleted": "Deleted successfully",
|
||||||
|
"deleteNodeGroupConfirm": "This will delete the node group. Nodes in this group will be reassigned.",
|
||||||
|
"deleteUserGroupConfirm": "This will delete the user group. Users in this group will be reassigned to the default group.",
|
||||||
|
"description": "Description",
|
||||||
|
"descriptionPlaceholder": "Enter description",
|
||||||
|
"edit": "Edit",
|
||||||
|
"editNodeGroup": "Edit Node Group",
|
||||||
|
"editUserGroup": "Edit User Group",
|
||||||
|
"editUserGroupDescription": "Edit user group assignment and lock status",
|
||||||
|
"selectGroup": "Select a group",
|
||||||
|
"endTime": "End Time",
|
||||||
|
"errorMessage": "Error Message",
|
||||||
|
"export": "Export",
|
||||||
|
"failed": "Failed",
|
||||||
|
"failedCount": "Failed",
|
||||||
|
"groupConfig": "Group Configuration",
|
||||||
|
"groupConfigDescription": "Manage node groups and automatically assign node groups to user subscriptions",
|
||||||
|
"groupDetails": "Group Details",
|
||||||
|
"groupEnabled": "Group Management Enabled",
|
||||||
|
"groupEnabledDescription": "Enable group management to control user access to nodes",
|
||||||
|
"groupHistory": "Group Calculation History",
|
||||||
|
"groupHistoryDescription": "View group recalculation history and results",
|
||||||
|
"groupHistoryDetail": "Group Calculation Detail",
|
||||||
|
"groupId": "Group ID",
|
||||||
|
"groupIdPlaceholder": "Enter unique group ID",
|
||||||
|
"groupMode": "Group Mode",
|
||||||
|
"groupModeDescription": "Select the grouping algorithm for assigning users to groups",
|
||||||
|
"groupName": "Group Name",
|
||||||
|
"groupNamePlaceholder": "Enter group name",
|
||||||
|
"groupRecalculation": "Group Recalculation",
|
||||||
|
"groupRecalculationDescription": "Manually trigger node group reassignment for all active user subscriptions based on current configuration",
|
||||||
|
"history": "History",
|
||||||
|
"historyId": "History ID",
|
||||||
|
"id": "ID",
|
||||||
|
"idPrefix": "#",
|
||||||
|
"idle": "Idle",
|
||||||
|
"separator": "/",
|
||||||
|
"loading": "Loading...",
|
||||||
|
"loadFailed": "Failed to load configuration",
|
||||||
|
"locked": "Locked",
|
||||||
|
"manualTrigger": "Manual",
|
||||||
|
"name": "Name",
|
||||||
|
"namePlaceholder": "Enter name",
|
||||||
|
"nodeCount": "Node Count",
|
||||||
|
"nodeGroup": "Node Group",
|
||||||
|
"nodeGroupFormDescription": "Configure node group settings",
|
||||||
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodeGroupsDescription": "Manage node groups for user access control",
|
||||||
|
"noDetails": "No details available",
|
||||||
|
"operator": "Operator",
|
||||||
|
"progress": "Progress",
|
||||||
|
"recalculate": "Recalculate",
|
||||||
|
"recalculateAll": "Reassign Node Groups",
|
||||||
|
"recalculationCompleted": "Recalculation completed successfully",
|
||||||
|
"recalculationFailed": "Recalculation failed. Please try again.",
|
||||||
|
"recalculationStarted": "Recalculation started",
|
||||||
|
"recalculationWarning": "Recalculation will reassign node groups for all active user subscriptions based on current configuration. This operation cannot be undone.",
|
||||||
|
"running": "Running",
|
||||||
|
"save": "Save",
|
||||||
|
"scheduleTrigger": "Schedule",
|
||||||
|
"sort": "Sort",
|
||||||
|
"sortOrder": "Sort Order",
|
||||||
|
"startTime": "Start Time",
|
||||||
|
"subscribeMode": "Subscribe-based Grouping",
|
||||||
|
"successCount": "Success",
|
||||||
|
"title": "Group Management",
|
||||||
|
"totalUsers": "Total Users",
|
||||||
|
"totalNodes": "Total Nodes",
|
||||||
|
"totalGroups": "Total Groups",
|
||||||
|
"trafficMode": "Traffic-based Grouping",
|
||||||
|
"triggerType": "Trigger Type",
|
||||||
|
"userGroup": "User Group",
|
||||||
|
"userGroups": "User Groups",
|
||||||
|
"userGroupsDescription": "Manage user groups for node access control",
|
||||||
|
"updated": "Updated successfully",
|
||||||
|
"updateFailed": "Update failed",
|
||||||
|
"userCount": "User Count",
|
||||||
|
"viewDetail": "View Detail",
|
||||||
|
"warning": "Warning",
|
||||||
|
"yes": "Yes",
|
||||||
|
"no": "No",
|
||||||
|
"saving": "Saving...",
|
||||||
|
"enableGrouping": "Enable Grouping",
|
||||||
|
"enableGroupingDescription": "When enabled, user subscriptions will be automatically assigned node groups based on the distribution mode",
|
||||||
|
"groupingMode": "Grouping Mode",
|
||||||
|
"averageModeConfig": "Average Mode Configuration",
|
||||||
|
"subscribeModeConfig": "Subscribe Mode Configuration",
|
||||||
|
"trafficModeConfig": "Traffic Mode Configuration",
|
||||||
|
"averageModeDescription": "Randomly assign available node groups to active user subscriptions",
|
||||||
|
"subscribeModeDescription": "Set default node group for user groups based on subscription plans",
|
||||||
|
"trafficModeDescription": "Assign node groups to user subscriptions based on traffic usage",
|
||||||
|
"defaultUserGroupId": "Default User Group ID",
|
||||||
|
"defaultUserGroupDescription": "New users will be assigned to this group",
|
||||||
|
"defaultUserGroupForExpiredDescription": "Users with expired subscriptions will be assigned to this group",
|
||||||
|
"autoCreateGroup": "Auto Create Group",
|
||||||
|
"autoCreateGroupDescription": "Automatically create a new user group when a new subscription plan is added",
|
||||||
|
"lockGroup": "Lock Group",
|
||||||
|
"lockGroupDescription": "Prevent automatic recalculation from changing this user's group",
|
||||||
|
"trafficRangesComingSoon": "Traffic ranges configuration coming soon...",
|
||||||
|
"currentStatus": "Current Status",
|
||||||
|
"trafficRangesConfig": "Traffic Ranges Configuration",
|
||||||
|
"trafficRangesDescription": "Configure traffic ranges for grouping users. Traffic is calculated based on user's billing cycle.",
|
||||||
|
"minTrafficGB": "Min Traffic (GB)",
|
||||||
|
"maxTrafficGB": "Max Traffic (GB)",
|
||||||
|
"addRange": "Add Range",
|
||||||
|
"remove": "Remove",
|
||||||
|
"note": "Note",
|
||||||
|
"trafficRangesNote": "Ranges must not overlap and must cover all values without gaps. Users with traffic >= the upper limit of the last range will be assigned to the last group.",
|
||||||
|
"defaultUserGroup": "Default User Group",
|
||||||
|
"defaultUserGroupForTrafficDescription": "Users with traffic exceeding all defined ranges will be assigned to this group",
|
||||||
|
"rangeError": "Range Error",
|
||||||
|
"overlapError": "Overlap Error",
|
||||||
|
"gapError": "Gap Error",
|
||||||
|
"groupByTraffic": "Group by Traffic",
|
||||||
|
"resetGroups": "Reset All Groups",
|
||||||
|
"resetGroupsTitle": "Reset All Groups",
|
||||||
|
"resetGroupsDescription": "This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone.",
|
||||||
|
"resetSuccess": "All groups have been reset successfully",
|
||||||
|
"resetFailed": "Failed to reset groups",
|
||||||
|
"saved": "Configuration saved successfully",
|
||||||
|
"saveFailed": "Failed to save configuration",
|
||||||
|
"autoCalculated": "Auto-calculated",
|
||||||
|
"userGroupCountAutoCalculated": "Auto-calculated from actual user groups",
|
||||||
|
"userGroupCount": "User Group Count",
|
||||||
|
"nodeGroupCountAutoCalculated": "Auto-calculated from actual node groups",
|
||||||
|
"nodeGroupCount": "Node Group Count",
|
||||||
|
"arrow": " → ",
|
||||||
|
"availableNodeGroups": "Available Node Groups",
|
||||||
|
"currentGroupingResult": "Current Grouping Result",
|
||||||
|
"calculationInfo": "Calculation Information",
|
||||||
|
"groupingDetailsStatistics": "Grouping Details Statistics",
|
||||||
|
"successFailedCount": "Success/Failed",
|
||||||
|
"latestGroupingCalculation": "Latest grouping calculation details",
|
||||||
|
"userList": "User List",
|
||||||
|
"email": "Email",
|
||||||
|
"noUsers": "No users found",
|
||||||
|
"showing": "Showing",
|
||||||
|
"to": "to",
|
||||||
|
"of": "of",
|
||||||
|
"previous": "Previous",
|
||||||
|
"next": "Next",
|
||||||
|
"result": "Result",
|
||||||
|
"bindNodeGroup": "Bind Node Group",
|
||||||
|
"bindNodeGroupDescription": "Select a node group to bind to user groups: {{userGroups}}",
|
||||||
|
"selectNodeGroup": "Select Node Group",
|
||||||
|
"selectNodeGroupPlaceholder": "Select a node group...",
|
||||||
|
"selectNodeGroupRequired": "Please select a node group",
|
||||||
|
"unbound": "Unbound",
|
||||||
|
"bindSuccess": "Successfully bound {{userGroupCount}} user group(s) to node group",
|
||||||
|
"bindFailed": "Failed to bind node group",
|
||||||
|
"groupMapping": "Group Mapping",
|
||||||
|
"forCalculation": "For Calculation",
|
||||||
|
"trafficRange": "Traffic Range (GB)",
|
||||||
|
"configSaved": "Configuration saved successfully",
|
||||||
|
"subscribeGroupMappingTitle": "Subscribe-Node Group Mapping",
|
||||||
|
"subscribeName": "Subscribe Plan",
|
||||||
|
"userGroupName": "User Group",
|
||||||
|
"nodeGroupName": "Node Group",
|
||||||
|
"notMapped": "Not Mapped",
|
||||||
|
"noMappingData": "No mapping data available",
|
||||||
|
"forCalculationDescription": "Whether this node group participates in grouping calculation",
|
||||||
|
"trafficRangeGB": "Traffic Range (GB)",
|
||||||
|
"trafficRangeDescription": "Users with traffic >= Min and < Max will be assigned to this node group",
|
||||||
|
"minCannotExceedMax": "Minimum traffic cannot exceed maximum traffic",
|
||||||
|
"rangeOverlap": "Range overlaps with node group \"{{name}}\"",
|
||||||
|
"nodeGroupNotFound": "Node group not found",
|
||||||
|
"validationFailed": "Validation failed",
|
||||||
|
"totalNodeGroups": "Total Node Groups",
|
||||||
|
"invalidRange": "Minimum traffic must be less than maximum traffic",
|
||||||
|
"rangeConflict": "Traffic range conflicts with node group \"{{name}}\" (range: {{min}} - {{max}} GB)"
|
||||||
|
}
|
||||||
@ -10,6 +10,7 @@
|
|||||||
"Document Management": "Document Management",
|
"Document Management": "Document Management",
|
||||||
"Email": "Email",
|
"Email": "Email",
|
||||||
"Gift": "Gift",
|
"Gift": "Gift",
|
||||||
|
"Group Management": "Group Management",
|
||||||
"Login": "Login",
|
"Login": "Login",
|
||||||
"Logs & Analytics": "Logs & Analytics",
|
"Logs & Analytics": "Logs & Analytics",
|
||||||
"Maintenance": "Maintenance",
|
"Maintenance": "Maintenance",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"address": "Address",
|
"address": "Address",
|
||||||
|
"all": "All",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmDeleteDesc": "This action cannot be undone.",
|
"confirmDeleteDesc": "This action cannot be undone.",
|
||||||
@ -17,15 +18,21 @@
|
|||||||
"enabled_off": "Disabled",
|
"enabled_off": "Disabled",
|
||||||
"enabled_on": "Enabled",
|
"enabled_on": "Enabled",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"nodeGroup": "Node Group",
|
||||||
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodeGroup_description": "Assign this node to multiple groups for user access control.",
|
||||||
"pageTitle": "Nodes",
|
"pageTitle": "Nodes",
|
||||||
"port": "Port",
|
"port": "Port",
|
||||||
"protocol": "Protocol",
|
"protocol": "Protocol",
|
||||||
|
"public": "Public",
|
||||||
|
"selectNodeGroup": "Select node group…",
|
||||||
"select_protocol": "Select protocol…",
|
"select_protocol": "Select protocol…",
|
||||||
"select_server": "Select server…",
|
"select_server": "Select server…",
|
||||||
"server": "Server",
|
"server": "Server",
|
||||||
"sorted_success": "Sorted successfully",
|
"sorted_success": "Sorted successfully",
|
||||||
"tags": "Tags",
|
"tags": "Tags",
|
||||||
"tags_description": "Permission grouping tag (incl. plan binding and delivery policies).",
|
"tags_description": "Permission grouping tag (incl. plan binding and delivery policies).",
|
||||||
|
"tags_groupMode_description": "Optional tags for display and filtering (node group name will be used as tag if empty).",
|
||||||
"tags_placeholder": "Use Enter or comma (,) to add multiple tags",
|
"tags_placeholder": "Use Enter or comma (,) to add multiple tags",
|
||||||
"updated": "Updated"
|
"updated": "Updated"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"all": "All",
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmDelete": "Are you sure you want to delete?",
|
"confirmDelete": "Are you sure you want to delete?",
|
||||||
@ -7,13 +8,16 @@
|
|||||||
"create": "Create",
|
"create": "Create",
|
||||||
"createSubscribe": "Create Subscription",
|
"createSubscribe": "Create Subscription",
|
||||||
"createSuccess": "Create Successful",
|
"createSuccess": "Create Successful",
|
||||||
|
"currentUserGroup": "Current User Group",
|
||||||
|
"defaultNodeGroup": "Default Node Group",
|
||||||
"delete": "Delete",
|
"delete": "Delete",
|
||||||
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodes": "nodes",
|
||||||
"deleteSuccess": "Delete Successful",
|
"deleteSuccess": "Delete Successful",
|
||||||
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
|
"deleteWarning": "Data cannot be recovered after deletion. Please proceed with caution.",
|
||||||
"deviceLimit": "IP Limit",
|
"deviceLimit": "IP Limit",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"editSubscribe": "Edit Subscription",
|
"editSubscribe": "Edit Subscription",
|
||||||
"sortSuccess": "Sort completed successfully",
|
|
||||||
"form": {
|
"form": {
|
||||||
"annualReset": "Annual Reset",
|
"annualReset": "Annual Reset",
|
||||||
"basic": "Basic",
|
"basic": "Basic",
|
||||||
@ -30,7 +34,6 @@
|
|||||||
"discountPercent": "Discount Percentage",
|
"discountPercent": "Discount Percentage",
|
||||||
"Hour": "Hour",
|
"Hour": "Hour",
|
||||||
"inventory": "Subscription Limit",
|
"inventory": "Subscription Limit",
|
||||||
"unlimitedInventory": "Unlimited (enter -1)",
|
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
"languageDescription": "Leave empty for default without language restriction",
|
"languageDescription": "Leave empty for default without language restriction",
|
||||||
"languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN",
|
"languagePlaceholder": "Language identifier for the subscription, e.g., en-US, zh-CN",
|
||||||
@ -40,7 +43,19 @@
|
|||||||
"name": "Name",
|
"name": "Name",
|
||||||
"node": "Node",
|
"node": "Node",
|
||||||
"nodeGroup": "Node Group",
|
"nodeGroup": "Node Group",
|
||||||
"nodes": "Nodes",
|
"nodeGroups": "Node Groups",
|
||||||
|
"nodeGroupsDescription": "Assign this product to multiple node groups. Users will get nodes from these groups.",
|
||||||
|
"nodeGroupsFirstSelectionDescription": "Select node groups for this product. The first selected group will be set as the default node group.",
|
||||||
|
"defaultNodeGroup": "Default Node Group",
|
||||||
|
"defaultNodeGroupDescription": "Select the default node group for this product. This will be automatically included in the backup node groups.",
|
||||||
|
"selectDefaultNodeGroup": "Select a default node group...",
|
||||||
|
"noDefaultNodeGroup": "No Default Node Group",
|
||||||
|
"backupNodeGroups": "Backup Node Groups",
|
||||||
|
"backupNodeGroupsDescription": "Select additional backup node groups. The default node group is automatically included.",
|
||||||
|
"nodes": "Linked Nodes",
|
||||||
|
"nodesDescription": "Select nodes for this subscription",
|
||||||
|
"nodesInGroup": "Nodes in this group:",
|
||||||
|
"nodesWithoutGroupsDescription": "Nodes without group assignment will be shown here (nodes that belong to groups are managed in the Node Groups section above)",
|
||||||
"noLimit": "No Limit",
|
"noLimit": "No Limit",
|
||||||
"NoLimit": "No Limit",
|
"NoLimit": "No Limit",
|
||||||
"noReset": "No Reset",
|
"noReset": "No Reset",
|
||||||
@ -61,16 +76,42 @@
|
|||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
"unitPrice": "Unit Price",
|
"unitPrice": "Unit Price",
|
||||||
"unitTime": "Unit Time",
|
"unitTime": "Unit Time",
|
||||||
|
"unlimitedInventory": "Unlimited (enter -1)",
|
||||||
"Year": "Year"
|
"Year": "Year"
|
||||||
},
|
},
|
||||||
|
"groupMapping": "Group Mapping",
|
||||||
|
"groupMappingTitle": "Group Mapping",
|
||||||
|
"groupMappingUpdateFailed": "Failed to update group mapping",
|
||||||
|
"groupMappingUpdateSuccess": "Group mapping updated successfully",
|
||||||
|
"migrateUsers": "Migrate Users",
|
||||||
|
"migrateUsersTitle": "Migrate Users",
|
||||||
|
"migrateUsersDescription": "Migrate all users from the current user group to another group",
|
||||||
|
"migrateUsersWarning": "This will migrate {count} users from \"{group}\" to the target group. This action cannot be undone.",
|
||||||
|
"migrateUsersSuccess": "Successfully migrated {count} users to the target group",
|
||||||
|
"migrateUsersFailed": "Failed to migrate users",
|
||||||
|
"targetUserGroup": "Target User Group",
|
||||||
|
"selectTargetGroup": "Select a target group...",
|
||||||
|
"selectTargetGroupFirst": "Please select a target group first",
|
||||||
|
"cannotMigrateToSameGroup": "Cannot migrate to the same group",
|
||||||
|
"noSourceGroup": "No source group available",
|
||||||
|
"selectedGroup": "Selected Group",
|
||||||
|
"userCount": "User Count",
|
||||||
|
"migrating": "Migrating...",
|
||||||
"inventory": "Subscription Limit",
|
"inventory": "Subscription Limit",
|
||||||
"language": "Language",
|
"language": "Language",
|
||||||
|
"loading": "Loading...",
|
||||||
"name": "Name",
|
"name": "Name",
|
||||||
|
"noMapping": "No mapping set",
|
||||||
|
"noNodes": "No nodes in this group",
|
||||||
"quota": "Purchase Limit/Time",
|
"quota": "Purchase Limit/Time",
|
||||||
"replacement": "Reset Price/Time",
|
"replacement": "Reset Price/Time",
|
||||||
|
"save": "Save",
|
||||||
|
"selectGroupPlaceholder": "Select a group...",
|
||||||
|
"selectUserGroup": "Select User Group",
|
||||||
"sell": "Sell",
|
"sell": "Sell",
|
||||||
"show": "Display",
|
"show": "Display",
|
||||||
"sold": "Subscription Count",
|
"sold": "Subscription Count",
|
||||||
|
"sortSuccess": "Sort completed successfully",
|
||||||
"traffic": "Traffic",
|
"traffic": "Traffic",
|
||||||
"unitPrice": "Unit Price",
|
"unitPrice": "Unit Price",
|
||||||
"updateSuccess": "Update Successful"
|
"updateSuccess": "Update Successful"
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"active": "Active",
|
|
||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"code": "Redemption Code",
|
"code": "Redemption Code",
|
||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
@ -13,7 +12,6 @@
|
|||||||
"duration": "Redemption Duration",
|
"duration": "Redemption Duration",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
"editRedemptionCode": "Edit Redemption Code",
|
"editRedemptionCode": "Edit Redemption Code",
|
||||||
"exhausted": "Exhausted",
|
|
||||||
"form": {
|
"form": {
|
||||||
"batchCount": "Batch Count",
|
"batchCount": "Batch Count",
|
||||||
"batchCountPlaceholder": "Batch Count",
|
"batchCountPlaceholder": "Batch Count",
|
||||||
@ -26,16 +24,12 @@
|
|||||||
"halfYear": "Half Year",
|
"halfYear": "Half Year",
|
||||||
"month": "Month",
|
"month": "Month",
|
||||||
"quarter": "Quarter",
|
"quarter": "Quarter",
|
||||||
"quantityRequired": "Quantity is required",
|
|
||||||
"selectPlan": "Select Redemption Plan",
|
"selectPlan": "Select Redemption Plan",
|
||||||
"selectUnitTime": "Select Redemption Duration Unit",
|
"selectUnitTime": "Select Redemption Duration Unit",
|
||||||
"subscribePlan": "Redemption Plan",
|
"subscribePlan": "Redemption Plan",
|
||||||
"subscribePlanRequired": "Subscribe plan is required",
|
|
||||||
"totalCount": "Available Uses",
|
"totalCount": "Available Uses",
|
||||||
"totalCountPlaceholder": "Available Uses",
|
"totalCountPlaceholder": "Available Uses",
|
||||||
"totalCountRequired": "Total count is required",
|
|
||||||
"unitTime": "Redemption Duration Unit",
|
"unitTime": "Redemption Duration Unit",
|
||||||
"unitTimeRequired": "Unit time is required",
|
|
||||||
"year": "Year"
|
"year": "Year"
|
||||||
},
|
},
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@ -50,8 +44,8 @@
|
|||||||
"status": "Status",
|
"status": "Status",
|
||||||
"subscribeId": "Subscribe ID",
|
"subscribeId": "Subscribe ID",
|
||||||
"subscribePlan": "Redemption Plan",
|
"subscribePlan": "Redemption Plan",
|
||||||
"totalCount": "Available Uses",
|
|
||||||
"total": "Total",
|
"total": "Total",
|
||||||
|
"totalCount": "Available Uses",
|
||||||
"unitTime": "Redemption Duration Unit",
|
"unitTime": "Redemption Duration Unit",
|
||||||
"updateSuccess": "Update Success",
|
"updateSuccess": "Update Success",
|
||||||
"usedCount": "Used",
|
"usedCount": "Used",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"description": "Configure currency units, symbols, and exchange rate API settings",
|
"description": "Configure currency units, symbols, and exchange rate API settings",
|
||||||
"title": "Currency Configuration"
|
"title": "Currency Configuration"
|
||||||
},
|
},
|
||||||
|
"groupSettings": "Group Settings",
|
||||||
"invite": {
|
"invite": {
|
||||||
"description": "Configure user invitation and referral reward settings",
|
"description": "Configure user invitation and referral reward settings",
|
||||||
"forcedInvite": "Require Invitation to Register",
|
"forcedInvite": "Require Invitation to Register",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"systemReboot": "System Reboot",
|
"systemReboot": "System Reboot",
|
||||||
"systemServices": "System Services",
|
"systemServices": "System Services",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
|
"updateDescription": "Are you sure you want to update?",
|
||||||
"updateFailed": "Update failed",
|
"updateFailed": "Update failed",
|
||||||
"updateServerDescription": "Are you sure you want to update the server version from {{current}} to {{latest}}?",
|
"updateServerDescription": "Are you sure you want to update the server version from {{current}} to {{latest}}?",
|
||||||
"updateSuccess": "Update completed successfully",
|
"updateSuccess": "Update completed successfully",
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
"serverRequired": "Please select a server"
|
"serverRequired": "Please select a server"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
|
"quantityRequired": "Quantity is required",
|
||||||
|
"subscribePlanRequired": "Subscribe plan is required",
|
||||||
|
"totalCountRequired": "Total count is required",
|
||||||
|
"unitTimeRequired": "Unit time is required",
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "Client name is required",
|
"nameRequired": "Client name is required",
|
||||||
"userAgentRequiredSuffix": "is required"
|
"userAgentRequiredSuffix": "is required"
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
"accountEnable": "Account Enable",
|
"accountEnable": "Account Enable",
|
||||||
"add": "Add",
|
"add": "Add",
|
||||||
"administrator": "Administrator",
|
"administrator": "Administrator",
|
||||||
|
"all": "All",
|
||||||
"areaCodePlaceholder": "Area code",
|
"areaCodePlaceholder": "Area code",
|
||||||
"authMethodsTitle": "Auth Methods",
|
"authMethodsTitle": "Auth Methods",
|
||||||
"avatar": "Avatar",
|
"avatar": "Avatar",
|
||||||
@ -17,6 +18,9 @@
|
|||||||
"confirm": "Confirm",
|
"confirm": "Confirm",
|
||||||
"confirmDelete": "Confirm Delete",
|
"confirmDelete": "Confirm Delete",
|
||||||
"confirmOffline": "Confirm Offline",
|
"confirmOffline": "Confirm Offline",
|
||||||
|
"confirmResetToken": "Confirm Reset Subscription Address",
|
||||||
|
"confirmResumeSubscribe": "Confirm Resume Subscription",
|
||||||
|
"confirmStopSubscribe": "Confirm Stop Subscription",
|
||||||
"copySubscription": "Copy Subscription",
|
"copySubscription": "Copy Subscription",
|
||||||
"copySuccess": "Copied successfully",
|
"copySuccess": "Copied successfully",
|
||||||
"create": "Create",
|
"create": "Create",
|
||||||
@ -29,12 +33,14 @@
|
|||||||
"deleteDescription": "This action cannot be undone.",
|
"deleteDescription": "This action cannot be undone.",
|
||||||
"deleteSubscriptionDescription": "This action cannot be undone.",
|
"deleteSubscriptionDescription": "This action cannot be undone.",
|
||||||
"deleteSuccess": "Deleted successfully",
|
"deleteSuccess": "Deleted successfully",
|
||||||
"isDeleted": "Status",
|
|
||||||
"deviceLimit": "Device Limit",
|
"deviceLimit": "Device Limit",
|
||||||
"download": "Download",
|
"download": "Download",
|
||||||
"downloadTraffic": "Download Traffic",
|
"downloadTraffic": "Download Traffic",
|
||||||
"edit": "Edit",
|
"edit": "Edit",
|
||||||
|
"editGroup": "Edit Group",
|
||||||
"editSubscription": "Edit Subscription",
|
"editSubscription": "Edit Subscription",
|
||||||
|
"editUserGroup": "Edit User Group",
|
||||||
|
"editUserGroupDescription": "Edit user group assignment and lock status",
|
||||||
"enable": "Enable",
|
"enable": "Enable",
|
||||||
"expiredAt": "Expired At",
|
"expiredAt": "Expired At",
|
||||||
"expireTime": "expireTime",
|
"expireTime": "expireTime",
|
||||||
@ -44,6 +50,7 @@
|
|||||||
"invalidEmailFormat": "Invalid email format",
|
"invalidEmailFormat": "Invalid email format",
|
||||||
"inviteCode": "Invite Code",
|
"inviteCode": "Invite Code",
|
||||||
"inviteCodePlaceholder": "Enter invite code",
|
"inviteCodePlaceholder": "Enter invite code",
|
||||||
|
"isDeleted": "Status",
|
||||||
"kickOfflineConfirm": "kickOfflineConfirm",
|
"kickOfflineConfirm": "kickOfflineConfirm",
|
||||||
"kickOfflineSuccess": "Device kicked offline",
|
"kickOfflineSuccess": "Device kicked offline",
|
||||||
"lastSeen": "Last Seen",
|
"lastSeen": "Last Seen",
|
||||||
@ -73,37 +80,29 @@
|
|||||||
"referrerUserId": "Referrer User ID",
|
"referrerUserId": "Referrer User ID",
|
||||||
"remove": "Remove",
|
"remove": "Remove",
|
||||||
"resetLogs": "Reset Logs",
|
"resetLogs": "Reset Logs",
|
||||||
"resetTraffic": "Reset Traffic",
|
|
||||||
"toggleStatus": "Toggle Status",
|
|
||||||
"resetSubscriptionToken": "Reset Token",
|
|
||||||
"resetSubscriptionTokenDescription": "This will reset the subscription token. Old links will become invalid.",
|
|
||||||
"resetSubscriptionTraffic": "Reset Traffic",
|
|
||||||
"resetSubscriptionTrafficDescription": "This will reset the subscription traffic counters.",
|
|
||||||
"toggleSubscriptionStatus": "Toggle Status",
|
|
||||||
"toggleSubscriptionStatusDescription": "This will toggle the subscription status.",
|
|
||||||
"resetTime": "Reset Time",
|
"resetTime": "Reset Time",
|
||||||
"resetToken": "Reset Subscription Address",
|
"resetToken": "Reset Subscription Address",
|
||||||
|
"saving": "Saving...",
|
||||||
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
|
"resetTokenDescription": "This will reset the subscription address and regenerate a new token.",
|
||||||
"resetTokenSuccess": "Subscription address reset successfully",
|
"resetTokenSuccess": "Subscription address reset successfully",
|
||||||
"confirmResetToken": "Confirm Reset Subscription Address",
|
"resumeSubscribe": "Resume Subscription",
|
||||||
|
"selectGroup": "Select a group",
|
||||||
|
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
|
||||||
|
"resumeSubscribeSuccess": "Subscription resumed successfully",
|
||||||
|
"save": "Save",
|
||||||
|
"shortCode": "Short Code",
|
||||||
|
"speedLimit": "Speed Limit",
|
||||||
|
"startTime": "startTime",
|
||||||
|
"status": "Status",
|
||||||
|
"statusActive": "Active",
|
||||||
|
"statusDeducted": "Deducted",
|
||||||
|
"statusExpired": "Expired",
|
||||||
|
"statusFinished": "Finished",
|
||||||
|
"statusPending": "Pending",
|
||||||
|
"statusStopped": "Stopped",
|
||||||
"stopSubscribe": "Stop Subscription",
|
"stopSubscribe": "Stop Subscription",
|
||||||
"stopSubscribeDescription": "This will stop the subscription temporarily. User will not be able to use it.",
|
"stopSubscribeDescription": "This will stop the subscription temporarily. User will not be able to use it.",
|
||||||
"stopSubscribeSuccess": "Subscription stopped successfully",
|
"stopSubscribeSuccess": "Subscription stopped successfully",
|
||||||
"confirmStopSubscribe": "Confirm Stop Subscription",
|
|
||||||
"resumeSubscribe": "Resume Subscription",
|
|
||||||
"resumeSubscribeDescription": "This will resume the subscription and allow the user to use it.",
|
|
||||||
"resumeSubscribeSuccess": "Subscription resumed successfully",
|
|
||||||
"confirmResumeSubscribe": "Confirm Resume Subscription",
|
|
||||||
"status": "Status",
|
|
||||||
"statusPending": "Pending",
|
|
||||||
"statusActive": "Active",
|
|
||||||
"statusFinished": "Finished",
|
|
||||||
"statusExpired": "Expired",
|
|
||||||
"statusDeducted": "Deducted",
|
|
||||||
"statusStopped": "Stopped",
|
|
||||||
"save": "Save",
|
|
||||||
"speedLimit": "Speed Limit",
|
|
||||||
"startTime": "startTime",
|
|
||||||
"subscription": "Subscription",
|
"subscription": "Subscription",
|
||||||
"subscriptionId": "subscriptionId",
|
"subscriptionId": "subscriptionId",
|
||||||
"subscriptionInfo": "subscriptionInfo",
|
"subscriptionInfo": "subscriptionInfo",
|
||||||
@ -119,11 +118,13 @@
|
|||||||
"trafficDetails": "Traffic Details",
|
"trafficDetails": "Traffic Details",
|
||||||
"trafficLimit": "Traffic Limit",
|
"trafficLimit": "Traffic Limit",
|
||||||
"trafficStats": "Traffic Stats",
|
"trafficStats": "Traffic Stats",
|
||||||
"trafficUsage": "trafficUsage",
|
"trafficUsage": "Traffic Usage",
|
||||||
|
"remainingTraffic": "Remaining Traffic",
|
||||||
"unlimited": "unlimited",
|
"unlimited": "unlimited",
|
||||||
"unverified": "Unverified",
|
"unverified": "Unverified",
|
||||||
"update": "Update",
|
"update": "Update",
|
||||||
"updateSuccess": "Updated successfully",
|
"updateSuccess": "Updated successfully",
|
||||||
|
"groupUpdated": "Group updated successfully",
|
||||||
"upload": "Upload",
|
"upload": "Upload",
|
||||||
"uploadTraffic": "Upload Traffic",
|
"uploadTraffic": "Upload Traffic",
|
||||||
"userAgent": "User Agent",
|
"userAgent": "User Agent",
|
||||||
@ -134,5 +135,17 @@
|
|||||||
"userList": "User List",
|
"userList": "User List",
|
||||||
"userName": "Username",
|
"userName": "Username",
|
||||||
"userProfile": "User Profile",
|
"userProfile": "User Profile",
|
||||||
"verified": "Verified"
|
"userGroup": "User Group",
|
||||||
|
"verified": "Verified",
|
||||||
|
"locked": "Locked",
|
||||||
|
"lockGroup": "Lock Group",
|
||||||
|
"lockGroupDescription": "Prevent automatic grouping from changing this user's group",
|
||||||
|
"groupLocked": "Group Locked",
|
||||||
|
"previewNodes": "Preview Nodes",
|
||||||
|
"availableNodes": "Available Nodes",
|
||||||
|
"name": "Name",
|
||||||
|
"address": "Address",
|
||||||
|
"noNodesAvailable": "No nodes available",
|
||||||
|
"nodeGroup": "Node Group",
|
||||||
|
"publicNodes": "Public Nodes"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,6 +40,8 @@
|
|||||||
"40005": "您没有访问权限,如有疑问请联系管理员。",
|
"40005": "您没有访问权限,如有疑问请联系管理员。",
|
||||||
"50001": "找不到对应的优惠券信息,请检查后重试。",
|
"50001": "找不到对应的优惠券信息,请检查后重试。",
|
||||||
"50002": "该优惠券已被使用,无法再次使用。",
|
"50002": "该优惠券已被使用,无法再次使用。",
|
||||||
|
"50003": "",
|
||||||
|
"50004": "",
|
||||||
"60001": "订阅已过期,请续费后使用。",
|
"60001": "订阅已过期,请续费后使用。",
|
||||||
"60002": "暂时无法使用该订阅,请稍后再试。",
|
"60002": "暂时无法使用该订阅,请稍后再试。",
|
||||||
"60003": "检测到现有订阅,请先取消后再继续。",
|
"60003": "检测到现有订阅,请先取消后再继续。",
|
||||||
|
|||||||
187
apps/admin/public/assets/locales/zh-CN/group.json
Normal file
187
apps/admin/public/assets/locales/zh-CN/group.json
Normal file
@ -0,0 +1,187 @@
|
|||||||
|
{
|
||||||
|
"actions": "操作",
|
||||||
|
"autoTrigger": "自动",
|
||||||
|
"averageMode": "平均分组",
|
||||||
|
"cancel": "取消",
|
||||||
|
"completed": "已完成",
|
||||||
|
"confirm": "确认",
|
||||||
|
"confirmDelete": "确认删除",
|
||||||
|
"config": "配置",
|
||||||
|
"create": "创建",
|
||||||
|
"created": "创建成功",
|
||||||
|
"createdAt": "创建时间",
|
||||||
|
"createNodeGroup": "创建节点组",
|
||||||
|
"createUserGroup": "创建用户组",
|
||||||
|
"delete": "删除",
|
||||||
|
"deleted": "删除成功",
|
||||||
|
"deleteNodeGroupConfirm": "此操作将删除节点组。该组中的节点将被重新分配。",
|
||||||
|
"deleteUserGroupConfirm": "此操作将删除用户组。该组中的用户将被重新分配到默认组。",
|
||||||
|
"description": "描述",
|
||||||
|
"descriptionPlaceholder": "输入描述",
|
||||||
|
"edit": "编辑",
|
||||||
|
"editNodeGroup": "编辑节点组",
|
||||||
|
"editUserGroup": "编辑用户组",
|
||||||
|
"editUserGroupDescription": "编辑用户组分配和锁定状态",
|
||||||
|
"selectGroup": "选择一个组",
|
||||||
|
"endTime": "结束时间",
|
||||||
|
"errorMessage": "错误信息",
|
||||||
|
"export": "导出",
|
||||||
|
"failed": "失败",
|
||||||
|
"failedCount": "失败",
|
||||||
|
"groupConfig": "分组配置",
|
||||||
|
"groupConfigDescription": "管理节点组并自动为用户订阅分配节点组",
|
||||||
|
"groupDetails": "分组详情",
|
||||||
|
"groupEnabled": "启用分组管理",
|
||||||
|
"groupEnabledDescription": "启用分组管理以控制用户对节点的访问",
|
||||||
|
"groupHistory": "分组计算历史",
|
||||||
|
"groupHistoryDescription": "查看分组重算历史和结果",
|
||||||
|
"groupHistoryDetail": "分组计算详情",
|
||||||
|
"groupId": "分组ID",
|
||||||
|
"groupIdPlaceholder": "输入唯一的分组ID",
|
||||||
|
"groupMode": "分组模式",
|
||||||
|
"groupModeDescription": "选择分组算法以将用户分配到组",
|
||||||
|
"groupName": "分组名称",
|
||||||
|
"groupNamePlaceholder": "输入分组名称",
|
||||||
|
"groupRecalculation": "分组重新计算",
|
||||||
|
"groupRecalculationDescription": "根据当前配置手动触发所有有效用户订阅的节点组重新分配",
|
||||||
|
"history": "历史记录",
|
||||||
|
"historyId": "历史ID",
|
||||||
|
"id": "ID",
|
||||||
|
"idPrefix": "#",
|
||||||
|
"idle": "空闲",
|
||||||
|
"separator": ",",
|
||||||
|
"loading": "加载中...",
|
||||||
|
"loadFailed": "加载配置失败",
|
||||||
|
"locked": "已锁定",
|
||||||
|
"manualTrigger": "手动",
|
||||||
|
"name": "名称",
|
||||||
|
"namePlaceholder": "输入名称",
|
||||||
|
"nodeCount": "节点数",
|
||||||
|
"nodeGroup": "节点组",
|
||||||
|
"nodeGroupFormDescription": "配置节点组设置",
|
||||||
|
"nodeGroups": "节点组",
|
||||||
|
"nodeGroupsDescription": "管理节点组以控制用户访问权限",
|
||||||
|
"noDetails": "暂无详情",
|
||||||
|
"operator": "操作人",
|
||||||
|
"progress": "进度",
|
||||||
|
"recalculate": "重新计算",
|
||||||
|
"recalculateAll": "重新分配节点组",
|
||||||
|
"recalculationCompleted": "重新计算成功完成",
|
||||||
|
"recalculationFailed": "重新计算失败,请重试。",
|
||||||
|
"recalculationStarted": "重新计算已启动",
|
||||||
|
"recalculationWarning": "重新计算将根据当前配置重新分配所有有效用户订阅的节点组。此操作无法撤消。",
|
||||||
|
"running": "运行中",
|
||||||
|
"save": "保存",
|
||||||
|
"scheduleTrigger": "定时",
|
||||||
|
"sort": "排序",
|
||||||
|
"sortOrder": "排序顺序",
|
||||||
|
"startTime": "开始时间",
|
||||||
|
"subscribeMode": "套餐分组",
|
||||||
|
"successCount": "成功",
|
||||||
|
"title": "分组管理",
|
||||||
|
"totalUsers": "总用户数",
|
||||||
|
"totalNodes": "总节点数",
|
||||||
|
"totalGroups": "总分组数",
|
||||||
|
"trafficMode": "流量分组",
|
||||||
|
"triggerType": "触发类型",
|
||||||
|
"userGroup": "用户组",
|
||||||
|
"userGroups": "用户组",
|
||||||
|
"userGroupsDescription": "管理用户组以控制节点访问权限",
|
||||||
|
"updated": "更新成功",
|
||||||
|
"updateFailed": "更新失败",
|
||||||
|
"userCount": "用户数",
|
||||||
|
"viewDetail": "查看详情",
|
||||||
|
"warning": "警告",
|
||||||
|
"yes": "是",
|
||||||
|
"no": "否",
|
||||||
|
"saving": "保存中...",
|
||||||
|
"enableGrouping": "启用分组",
|
||||||
|
"enableGroupingDescription": "启用后,用户订阅将根据分配模式自动分配节点组",
|
||||||
|
"groupingMode": "分组方式",
|
||||||
|
"averageModeConfig": "平均模式配置",
|
||||||
|
"subscribeModeConfig": "订阅模式配置",
|
||||||
|
"trafficModeConfig": "流量模式配置",
|
||||||
|
"averageModeDescription": "为有效用户订阅随机分配可用节点组",
|
||||||
|
"subscribeModeDescription": "根据订阅套餐设置用户组的默认节点组",
|
||||||
|
"trafficModeDescription": "根据用户订阅的流量使用情况分配节点组",
|
||||||
|
"defaultUserGroupId": "默认用户组ID",
|
||||||
|
"defaultUserGroupDescription": "新用户将被分配到此组",
|
||||||
|
"defaultUserGroupForExpiredDescription": "订阅过期的用户将被分配到此组",
|
||||||
|
"autoCreateGroup": "自动创建组",
|
||||||
|
"autoCreateGroupDescription": "添加新订阅计划时自动创建新的用户组",
|
||||||
|
"lockGroup": "锁定分组",
|
||||||
|
"lockGroupDescription": "防止自动重新计算更改此用户的分组",
|
||||||
|
"trafficRangesComingSoon": "流量区间配置即将推出...",
|
||||||
|
"currentStatus": "当前状态",
|
||||||
|
"trafficRangesConfig": "流量区间配置",
|
||||||
|
"trafficRangesDescription": "配置用户流量分组区间。流量根据用户计费周期计算。",
|
||||||
|
"minTrafficGB": "最小流量 (GB)",
|
||||||
|
"maxTrafficGB": "最大流量 (GB)",
|
||||||
|
"addRange": "添加区间",
|
||||||
|
"remove": "移除",
|
||||||
|
"note": "注意",
|
||||||
|
"trafficRangesNote": "区间不能重叠且必须覆盖所有值而不留空档。流量大于最后一个区间上限的用户将被分配到最后一个组。",
|
||||||
|
"defaultUserGroup": "默认用户组",
|
||||||
|
"defaultUserGroupForTrafficDescription": "超出所有定义区间的用户将被分配到此组",
|
||||||
|
"rangeError": "区间错误",
|
||||||
|
"overlapError": "区间重叠错误",
|
||||||
|
"gapError": "存在空档错误",
|
||||||
|
"groupByTraffic": "按流量分组",
|
||||||
|
"resetGroups": "重置所有分组",
|
||||||
|
"resetGroupsTitle": "重置所有分组",
|
||||||
|
"resetGroupsDescription": "此操作将删除所有节点组和用户组,将所有用户的组ID重置为0,清空所有商品的节点组ID,清空所有节点的节点组ID。此操作无法撤消。",
|
||||||
|
"resetSuccess": "所有分组已成功重置",
|
||||||
|
"resetFailed": "重置分组失败",
|
||||||
|
"saved": "配置保存成功",
|
||||||
|
"saveFailed": "保存配置失败",
|
||||||
|
"autoCalculated": "自动统计",
|
||||||
|
"userGroupCountAutoCalculated": "自动统计实际用户组数量",
|
||||||
|
"userGroupCount": "用户组数",
|
||||||
|
"nodeGroupCountAutoCalculated": "自动统计实际节点组数量",
|
||||||
|
"nodeGroupCount": "节点组数",
|
||||||
|
"arrow": " → ",
|
||||||
|
"availableNodeGroups": "可用节点组",
|
||||||
|
"currentGroupingResult": "当前分组结果",
|
||||||
|
"calculationInfo": "计算信息",
|
||||||
|
"groupingDetailsStatistics": "分组详情统计",
|
||||||
|
"successFailedCount": "成功/失败",
|
||||||
|
"latestGroupingCalculation": "最新分组计算详情",
|
||||||
|
"userList": "用户列表",
|
||||||
|
"email": "邮箱",
|
||||||
|
"noUsers": "未找到用户",
|
||||||
|
"showing": "显示",
|
||||||
|
"to": "至",
|
||||||
|
"of": "共",
|
||||||
|
"previous": "上一页",
|
||||||
|
"next": "下一页",
|
||||||
|
"result": "结果",
|
||||||
|
"bindNodeGroup": "绑定节点组",
|
||||||
|
"bindNodeGroupDescription": "选择一个节点组绑定到以下用户组:{{userGroups}}",
|
||||||
|
"selectNodeGroup": "选择节点组",
|
||||||
|
"selectNodeGroupPlaceholder": "请选择节点组...",
|
||||||
|
"selectNodeGroupRequired": "请选择一个节点组",
|
||||||
|
"unbound": "未绑定",
|
||||||
|
"bindSuccess": "成功将 {{userGroupCount}} 个用户组绑定到节点组",
|
||||||
|
"bindFailed": "绑定节点组失败",
|
||||||
|
"groupMapping": "分组对应关系",
|
||||||
|
"forCalculation": "参与计算",
|
||||||
|
"trafficRange": "流量区间 (GB)",
|
||||||
|
"configSaved": "配置保存成功",
|
||||||
|
"subscribeGroupMappingTitle": "套餐-节点组对应关系",
|
||||||
|
"subscribeName": "订阅计划",
|
||||||
|
"userGroupName": "用户组",
|
||||||
|
"nodeGroupName": "节点组",
|
||||||
|
"notMapped": "未映射",
|
||||||
|
"noMappingData": "暂无映射数据",
|
||||||
|
"forCalculationDescription": "此节点组是否参与分组计算",
|
||||||
|
"trafficRangeGB": "流量区间 (GB)",
|
||||||
|
"trafficRangeDescription": "流量大于等于最小值且小于最大值的用户将被分配到此节点组",
|
||||||
|
"minCannotExceedMax": "最小流量不能超过最大流量",
|
||||||
|
"rangeOverlap": "区间与节点组 \"{{name}}\" 重叠",
|
||||||
|
"nodeGroupNotFound": "未找到节点组",
|
||||||
|
"validationFailed": "验证失败",
|
||||||
|
"totalNodeGroups": "总节点组数",
|
||||||
|
"invalidRange": "最小流量必须小于最大流量",
|
||||||
|
"rangeConflict": "流量区间与节点组 \"{{name}}\" 冲突(区间:{{min}} - {{max}} GB)"
|
||||||
|
}
|
||||||
|
|
||||||
@ -10,6 +10,7 @@
|
|||||||
"Document Management": "文档管理",
|
"Document Management": "文档管理",
|
||||||
"Email": "邮件",
|
"Email": "邮件",
|
||||||
"Gift": "赠送",
|
"Gift": "赠送",
|
||||||
|
"Group Management": "分组管理",
|
||||||
"Login": "登录",
|
"Login": "登录",
|
||||||
"Logs & Analytics": "日志与分析",
|
"Logs & Analytics": "日志与分析",
|
||||||
"Maintenance": "维护",
|
"Maintenance": "维护",
|
||||||
|
|||||||
@ -1,5 +1,6 @@
|
|||||||
{
|
{
|
||||||
"address": "地址",
|
"address": "地址",
|
||||||
|
"all": "全部",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmDeleteDesc": "此操作无法撤销。",
|
"confirmDeleteDesc": "此操作无法撤销。",
|
||||||
@ -17,15 +18,21 @@
|
|||||||
"enabled_off": "已禁用",
|
"enabled_off": "已禁用",
|
||||||
"enabled_on": "已启用",
|
"enabled_on": "已启用",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
|
"nodeGroup": "节点分组",
|
||||||
|
"nodeGroups": "节点分组",
|
||||||
|
"nodeGroup_description": "将此节点分配到多个分组以控制用户访问。",
|
||||||
"pageTitle": "节点",
|
"pageTitle": "节点",
|
||||||
"port": "端口",
|
"port": "端口",
|
||||||
"protocol": "协议",
|
"protocol": "协议",
|
||||||
|
"public": "公共",
|
||||||
|
"selectNodeGroup": "选择节点分组…",
|
||||||
"select_protocol": "选择协议…",
|
"select_protocol": "选择协议…",
|
||||||
"select_server": "选择服务器…",
|
"select_server": "选择服务器…",
|
||||||
"server": "服务器",
|
"server": "服务器",
|
||||||
"sorted_success": "排序成功",
|
"sorted_success": "排序成功",
|
||||||
"tags": "标签",
|
"tags": "标签",
|
||||||
"tags_description": "权限分组标签(包含计划绑定和投递策略)。",
|
"tags_description": "权限分组标签(包含计划绑定和投递策略)。",
|
||||||
|
"tags_groupMode_description": "可选标签,用于显示和过滤(如果为空,节点组名称将作为标签使用)。",
|
||||||
"tags_placeholder": "使用回车或逗号 (,) 添加多个标签",
|
"tags_placeholder": "使用回车或逗号 (,) 添加多个标签",
|
||||||
"updated": "已更新"
|
"updated": "已更新"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,5 @@
|
|||||||
{
|
{
|
||||||
|
"all": "全部",
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmDelete": "确定要删除吗?",
|
"confirmDelete": "确定要删除吗?",
|
||||||
@ -7,13 +8,16 @@
|
|||||||
"create": "创建",
|
"create": "创建",
|
||||||
"createSubscribe": "创建订阅",
|
"createSubscribe": "创建订阅",
|
||||||
"createSuccess": "创建成功",
|
"createSuccess": "创建成功",
|
||||||
|
"currentUserGroup": "当前用户分组",
|
||||||
|
"defaultNodeGroup": "默认节点组",
|
||||||
"delete": "删除",
|
"delete": "删除",
|
||||||
|
"nodeGroups": "节点组",
|
||||||
|
"nodes": "个节点",
|
||||||
"deleteSuccess": "删除成功",
|
"deleteSuccess": "删除成功",
|
||||||
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
"deleteWarning": "删除后数据无法恢复,请谨慎操作。",
|
||||||
"deviceLimit": "IP限制",
|
"deviceLimit": "IP限制",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"editSubscribe": "编辑订阅",
|
"editSubscribe": "编辑订阅",
|
||||||
"sortSuccess": "排序成功",
|
|
||||||
"form": {
|
"form": {
|
||||||
"annualReset": "年度重置",
|
"annualReset": "年度重置",
|
||||||
"basic": "基本",
|
"basic": "基本",
|
||||||
@ -30,7 +34,6 @@
|
|||||||
"discountPercent": "折扣百分比",
|
"discountPercent": "折扣百分比",
|
||||||
"Hour": "小时",
|
"Hour": "小时",
|
||||||
"inventory": "订阅库存",
|
"inventory": "订阅库存",
|
||||||
"unlimitedInventory": "无限制(输入 -1)",
|
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
"languageDescription": "留空为默认无语言限制",
|
"languageDescription": "留空为默认无语言限制",
|
||||||
"languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN",
|
"languagePlaceholder": "订阅的语言标识符,例如 en-US、zh-CN",
|
||||||
@ -40,7 +43,19 @@
|
|||||||
"name": "名称",
|
"name": "名称",
|
||||||
"node": "节点",
|
"node": "节点",
|
||||||
"nodeGroup": "节点组",
|
"nodeGroup": "节点组",
|
||||||
"nodes": "节点",
|
"nodeGroups": "节点组",
|
||||||
|
"nodeGroupsDescription": "将此商品分配到多个节点分组。用户将可以从这些分组获取节点。",
|
||||||
|
"nodeGroupsFirstSelectionDescription": "为此商品选择节点组。第一个选中的组将被设置为默认节点组。",
|
||||||
|
"defaultNodeGroup": "默认节点组",
|
||||||
|
"defaultNodeGroupDescription": "为此商品选择默认节点组。将自动包含在备用节点组中。",
|
||||||
|
"selectDefaultNodeGroup": "选择默认节点组...",
|
||||||
|
"noDefaultNodeGroup": "无默认节点组",
|
||||||
|
"backupNodeGroups": "备用节点组",
|
||||||
|
"backupNodeGroupsDescription": "选择其他备用节点组。默认节点组会自动包含。",
|
||||||
|
"nodes": "关联节点",
|
||||||
|
"nodesDescription": "选择此订阅的节点",
|
||||||
|
"nodesInGroup": "分组中的节点:",
|
||||||
|
"nodesWithoutGroupsDescription": "未分配到分组的节点将在此处显示(属于分组的节点在上方的节点组部分管理)",
|
||||||
"noLimit": "无限制",
|
"noLimit": "无限制",
|
||||||
"NoLimit": "无限制",
|
"NoLimit": "无限制",
|
||||||
"noReset": "不重置",
|
"noReset": "不重置",
|
||||||
@ -61,16 +76,42 @@
|
|||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
"unitPrice": "单价",
|
"unitPrice": "单价",
|
||||||
"unitTime": "时间单位",
|
"unitTime": "时间单位",
|
||||||
|
"unlimitedInventory": "无限制(输入 -1)",
|
||||||
"Year": "年"
|
"Year": "年"
|
||||||
},
|
},
|
||||||
|
"groupMapping": "分组映射",
|
||||||
|
"groupMappingTitle": "分组映射",
|
||||||
|
"groupMappingUpdateFailed": "更新分组映射失败",
|
||||||
|
"groupMappingUpdateSuccess": "分组映射更新成功",
|
||||||
|
"migrateUsers": "迁移用户",
|
||||||
|
"migrateUsersTitle": "迁移用户",
|
||||||
|
"migrateUsersDescription": "将当前用户组的所有用户迁移到另一个用户组",
|
||||||
|
"migrateUsersWarning": "这将把 {count} 个用户从 \"{group}\" 迁移到目标用户组。此操作无法撤销。",
|
||||||
|
"migrateUsersSuccess": "成功将 {count} 个用户迁移到目标用户组",
|
||||||
|
"migrateUsersFailed": "迁移用户失败",
|
||||||
|
"targetUserGroup": "目标用户组",
|
||||||
|
"selectTargetGroup": "选择目标用户组...",
|
||||||
|
"selectTargetGroupFirst": "请先选择目标用户组",
|
||||||
|
"cannotMigrateToSameGroup": "无法迁移到相同的用户组",
|
||||||
|
"noSourceGroup": "没有可用的源用户组",
|
||||||
|
"selectedGroup": "已选择的分组",
|
||||||
|
"userCount": "用户数量",
|
||||||
|
"migrating": "迁移中...",
|
||||||
"inventory": "订阅库存",
|
"inventory": "订阅库存",
|
||||||
"language": "语言",
|
"language": "语言",
|
||||||
|
"loading": "加载中...",
|
||||||
"name": "名称",
|
"name": "名称",
|
||||||
|
"noMapping": "未设置映射",
|
||||||
|
"noNodes": "该分组下没有节点",
|
||||||
"quota": "购买限制/次",
|
"quota": "购买限制/次",
|
||||||
"replacement": "重置价格/次",
|
"replacement": "重置价格/次",
|
||||||
|
"save": "保存",
|
||||||
|
"selectGroupPlaceholder": "选择分组...",
|
||||||
|
"selectUserGroup": "选择用户分组",
|
||||||
"sell": "销售",
|
"sell": "销售",
|
||||||
"show": "显示",
|
"show": "显示",
|
||||||
"sold": "订阅数量",
|
"sold": "订阅数量",
|
||||||
|
"sortSuccess": "排序成功",
|
||||||
"traffic": "流量",
|
"traffic": "流量",
|
||||||
"unitPrice": "单价",
|
"unitPrice": "单价",
|
||||||
"updateSuccess": "更新成功"
|
"updateSuccess": "更新成功"
|
||||||
|
|||||||
@ -1,5 +1,4 @@
|
|||||||
{
|
{
|
||||||
"active": "有效",
|
|
||||||
"cancel": "取消",
|
"cancel": "取消",
|
||||||
"code": "兑换码",
|
"code": "兑换码",
|
||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
@ -13,7 +12,6 @@
|
|||||||
"duration": "兑换可用时长",
|
"duration": "兑换可用时长",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
"editRedemptionCode": "编辑兑换码",
|
"editRedemptionCode": "编辑兑换码",
|
||||||
"exhausted": "已用尽",
|
|
||||||
"form": {
|
"form": {
|
||||||
"batchCount": "批次数量",
|
"batchCount": "批次数量",
|
||||||
"batchCountPlaceholder": "批次数量",
|
"batchCountPlaceholder": "批次数量",
|
||||||
@ -26,16 +24,12 @@
|
|||||||
"halfYear": "半年",
|
"halfYear": "半年",
|
||||||
"month": "月",
|
"month": "月",
|
||||||
"quarter": "季度",
|
"quarter": "季度",
|
||||||
"quantityRequired": "数量为必填项",
|
|
||||||
"selectPlan": "选择兑换套餐",
|
"selectPlan": "选择兑换套餐",
|
||||||
"selectUnitTime": "选择兑换时长单位",
|
"selectUnitTime": "选择兑换时长单位",
|
||||||
"subscribePlan": "兑换套餐",
|
"subscribePlan": "兑换套餐",
|
||||||
"subscribePlanRequired": "兑换套餐为必填项",
|
|
||||||
"totalCount": "兑换码可用次数",
|
"totalCount": "兑换码可用次数",
|
||||||
"totalCountPlaceholder": "兑换码可用次数",
|
"totalCountPlaceholder": "兑换码可用次数",
|
||||||
"totalCountRequired": "兑换码可用次数为必填项",
|
|
||||||
"unitTime": "兑换时长单位",
|
"unitTime": "兑换时长单位",
|
||||||
"unitTimeRequired": "兑换时长单位为必填项",
|
|
||||||
"year": "年"
|
"year": "年"
|
||||||
},
|
},
|
||||||
"id": "ID",
|
"id": "ID",
|
||||||
@ -50,8 +44,8 @@
|
|||||||
"status": "状态",
|
"status": "状态",
|
||||||
"subscribeId": "套餐ID",
|
"subscribeId": "套餐ID",
|
||||||
"subscribePlan": "兑换套餐",
|
"subscribePlan": "兑换套餐",
|
||||||
"totalCount": "兑换码可用次数",
|
|
||||||
"total": "总计",
|
"total": "总计",
|
||||||
|
"totalCount": "兑换码可用次数",
|
||||||
"unitTime": "兑换时长单位",
|
"unitTime": "兑换时长单位",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
"usedCount": "已使用数量",
|
"usedCount": "已使用数量",
|
||||||
|
|||||||
@ -19,6 +19,7 @@
|
|||||||
"description": "配置货币单位、符号和汇率 API 设置",
|
"description": "配置货币单位、符号和汇率 API 设置",
|
||||||
"title": "货币配置"
|
"title": "货币配置"
|
||||||
},
|
},
|
||||||
|
"groupSettings": "",
|
||||||
"invite": {
|
"invite": {
|
||||||
"description": "配置用户邀请和推荐奖励设置",
|
"description": "配置用户邀请和推荐奖励设置",
|
||||||
"forcedInvite": "强制邀请注册",
|
"forcedInvite": "强制邀请注册",
|
||||||
|
|||||||
@ -12,6 +12,7 @@
|
|||||||
"systemReboot": "系统重启",
|
"systemReboot": "系统重启",
|
||||||
"systemServices": "系统服务",
|
"systemServices": "系统服务",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
|
"updateDescription": "",
|
||||||
"updateFailed": "更新失败",
|
"updateFailed": "更新失败",
|
||||||
"updateServerDescription": "确定要将服务器版本从 {{current}} 更新到 {{latest}} 吗?",
|
"updateServerDescription": "确定要将服务器版本从 {{current}} 更新到 {{latest}} 吗?",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
|
|||||||
@ -7,6 +7,10 @@
|
|||||||
"serverRequired": "请选择服务器"
|
"serverRequired": "请选择服务器"
|
||||||
},
|
},
|
||||||
"form": {
|
"form": {
|
||||||
|
"quantityRequired": "",
|
||||||
|
"subscribePlanRequired": "",
|
||||||
|
"totalCountRequired": "",
|
||||||
|
"unitTimeRequired": "",
|
||||||
"validation": {
|
"validation": {
|
||||||
"nameRequired": "客户端名称必填",
|
"nameRequired": "客户端名称必填",
|
||||||
"userAgentRequiredSuffix": "是必填项"
|
"userAgentRequiredSuffix": "是必填项"
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
"accountEnable": "账户启用",
|
"accountEnable": "账户启用",
|
||||||
"add": "添加",
|
"add": "添加",
|
||||||
"administrator": "管理员",
|
"administrator": "管理员",
|
||||||
|
"all": "全部",
|
||||||
"areaCodePlaceholder": "区号",
|
"areaCodePlaceholder": "区号",
|
||||||
"authMethodsTitle": "认证方式",
|
"authMethodsTitle": "认证方式",
|
||||||
"avatar": "头像",
|
"avatar": "头像",
|
||||||
@ -17,6 +18,9 @@
|
|||||||
"confirm": "确认",
|
"confirm": "确认",
|
||||||
"confirmDelete": "确认删除",
|
"confirmDelete": "确认删除",
|
||||||
"confirmOffline": "确认下线",
|
"confirmOffline": "确认下线",
|
||||||
|
"confirmResetToken": "确认重置订阅地址",
|
||||||
|
"confirmResumeSubscribe": "确认恢复订阅",
|
||||||
|
"confirmStopSubscribe": "确认暂停订阅",
|
||||||
"copySubscription": "复制订阅",
|
"copySubscription": "复制订阅",
|
||||||
"copySuccess": "复制成功",
|
"copySuccess": "复制成功",
|
||||||
"create": "创建",
|
"create": "创建",
|
||||||
@ -29,12 +33,14 @@
|
|||||||
"deleteDescription": "此操作无法撤销。",
|
"deleteDescription": "此操作无法撤销。",
|
||||||
"deleteSubscriptionDescription": "此操作无法撤销。",
|
"deleteSubscriptionDescription": "此操作无法撤销。",
|
||||||
"deleteSuccess": "删除成功",
|
"deleteSuccess": "删除成功",
|
||||||
"isDeleted": "状态",
|
|
||||||
"deviceLimit": "IP限制",
|
"deviceLimit": "IP限制",
|
||||||
"download": "下载",
|
"download": "下载",
|
||||||
"downloadTraffic": "下载流量",
|
"downloadTraffic": "下载流量",
|
||||||
"edit": "编辑",
|
"edit": "编辑",
|
||||||
|
"editGroup": "编辑分组",
|
||||||
"editSubscription": "编辑订阅",
|
"editSubscription": "编辑订阅",
|
||||||
|
"editUserGroup": "编辑用户组",
|
||||||
|
"editUserGroupDescription": "编辑用户组分配和锁定状态",
|
||||||
"enable": "启用",
|
"enable": "启用",
|
||||||
"expiredAt": "过期时间",
|
"expiredAt": "过期时间",
|
||||||
"expireTime": "过期时间",
|
"expireTime": "过期时间",
|
||||||
@ -44,6 +50,7 @@
|
|||||||
"invalidEmailFormat": "邮箱格式无效",
|
"invalidEmailFormat": "邮箱格式无效",
|
||||||
"inviteCode": "邀请码",
|
"inviteCode": "邀请码",
|
||||||
"inviteCodePlaceholder": "输入邀请码",
|
"inviteCodePlaceholder": "输入邀请码",
|
||||||
|
"isDeleted": "状态",
|
||||||
"kickOfflineConfirm": "确认踢下线",
|
"kickOfflineConfirm": "确认踢下线",
|
||||||
"kickOfflineSuccess": "设备已踢下线",
|
"kickOfflineSuccess": "设备已踢下线",
|
||||||
"lastSeen": "最后上线",
|
"lastSeen": "最后上线",
|
||||||
@ -73,37 +80,29 @@
|
|||||||
"referrerUserId": "推荐人用户 ID",
|
"referrerUserId": "推荐人用户 ID",
|
||||||
"remove": "移除",
|
"remove": "移除",
|
||||||
"resetLogs": "重置日志",
|
"resetLogs": "重置日志",
|
||||||
"resetTraffic": "重置流量",
|
|
||||||
"toggleStatus": "切换状态",
|
|
||||||
"resetSubscriptionToken": "重置令牌",
|
|
||||||
"resetSubscriptionTokenDescription": "将重置订阅令牌,旧订阅链接会失效。",
|
|
||||||
"resetSubscriptionTraffic": "重置流量",
|
|
||||||
"resetSubscriptionTrafficDescription": "将重置该订阅的流量统计。",
|
|
||||||
"toggleSubscriptionStatus": "切换状态",
|
|
||||||
"toggleSubscriptionStatusDescription": "将切换该订阅的启用/停用状态。",
|
|
||||||
"resetTime": "重置时间",
|
"resetTime": "重置时间",
|
||||||
"resetToken": "重置订阅地址",
|
"resetToken": "重置订阅地址",
|
||||||
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
|
"resetTokenDescription": "这将重置订阅地址并重新生成新的令牌。",
|
||||||
|
"saving": "保存中...",
|
||||||
"resetTokenSuccess": "订阅地址重置成功",
|
"resetTokenSuccess": "订阅地址重置成功",
|
||||||
"confirmResetToken": "确认重置订阅地址",
|
"resumeSubscribe": "恢复订阅",
|
||||||
|
"selectGroup": "选择一个组",
|
||||||
|
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
|
||||||
|
"resumeSubscribeSuccess": "订阅已恢复",
|
||||||
|
"save": "保存",
|
||||||
|
"shortCode": "短码",
|
||||||
|
"speedLimit": "速度限制",
|
||||||
|
"startTime": "开始时间",
|
||||||
|
"status": "状态",
|
||||||
|
"statusActive": "活跃",
|
||||||
|
"statusDeducted": "已扣除",
|
||||||
|
"statusExpired": "已过期",
|
||||||
|
"statusFinished": "已完成",
|
||||||
|
"statusPending": "待处理",
|
||||||
|
"statusStopped": "已停止",
|
||||||
"stopSubscribe": "暂停订阅",
|
"stopSubscribe": "暂停订阅",
|
||||||
"stopSubscribeDescription": "这将暂时停止订阅。用户将无法使用。",
|
"stopSubscribeDescription": "这将暂时停止订阅。用户将无法使用。",
|
||||||
"stopSubscribeSuccess": "订阅已暂停",
|
"stopSubscribeSuccess": "订阅已暂停",
|
||||||
"confirmStopSubscribe": "确认暂停订阅",
|
|
||||||
"resumeSubscribe": "恢复订阅",
|
|
||||||
"resumeSubscribeDescription": "这将恢复订阅,允许用户继续使用。",
|
|
||||||
"resumeSubscribeSuccess": "订阅已恢复",
|
|
||||||
"confirmResumeSubscribe": "确认恢复订阅",
|
|
||||||
"status": "状态",
|
|
||||||
"statusPending": "待处理",
|
|
||||||
"statusActive": "活跃",
|
|
||||||
"statusFinished": "已完成",
|
|
||||||
"statusExpired": "已过期",
|
|
||||||
"statusDeducted": "已扣除",
|
|
||||||
"statusStopped": "已停止",
|
|
||||||
"save": "保存",
|
|
||||||
"speedLimit": "速度限制",
|
|
||||||
"startTime": "开始时间",
|
|
||||||
"subscription": "订阅",
|
"subscription": "订阅",
|
||||||
"subscriptionId": "订阅 ID",
|
"subscriptionId": "订阅 ID",
|
||||||
"subscriptionInfo": "订阅信息",
|
"subscriptionInfo": "订阅信息",
|
||||||
@ -120,10 +119,12 @@
|
|||||||
"trafficLimit": "流量限制",
|
"trafficLimit": "流量限制",
|
||||||
"trafficStats": "流量统计",
|
"trafficStats": "流量统计",
|
||||||
"trafficUsage": "流量使用",
|
"trafficUsage": "流量使用",
|
||||||
|
"remainingTraffic": "剩余流量",
|
||||||
"unlimited": "无限制",
|
"unlimited": "无限制",
|
||||||
"unverified": "未验证",
|
"unverified": "未验证",
|
||||||
"update": "更新",
|
"update": "更新",
|
||||||
"updateSuccess": "更新成功",
|
"updateSuccess": "更新成功",
|
||||||
|
"groupUpdated": "分组更新成功",
|
||||||
"upload": "上传",
|
"upload": "上传",
|
||||||
"uploadTraffic": "上传流量",
|
"uploadTraffic": "上传流量",
|
||||||
"userAgent": "用户代理",
|
"userAgent": "用户代理",
|
||||||
@ -134,5 +135,17 @@
|
|||||||
"userList": "用户列表",
|
"userList": "用户列表",
|
||||||
"userName": "用户名",
|
"userName": "用户名",
|
||||||
"userProfile": "用户资料",
|
"userProfile": "用户资料",
|
||||||
"verified": "已验证"
|
"userGroup": "用户分组",
|
||||||
|
"verified": "已验证",
|
||||||
|
"locked": "锁定",
|
||||||
|
"lockGroup": "锁定分组",
|
||||||
|
"lockGroupDescription": "防止自动分组更改此用户的分组",
|
||||||
|
"groupLocked": "分组已锁定",
|
||||||
|
"previewNodes": "预览节点",
|
||||||
|
"availableNodes": "可用节点",
|
||||||
|
"name": "名称",
|
||||||
|
"address": "地址",
|
||||||
|
"noNodesAvailable": "无可用节点",
|
||||||
|
"nodeGroup": "节点组",
|
||||||
|
"publicNodes": "公共节点"
|
||||||
}
|
}
|
||||||
|
|||||||
@ -34,6 +34,11 @@ export function useNavs() {
|
|||||||
url: "/dashboard/nodes",
|
url: "/dashboard/nodes",
|
||||||
icon: "flat-color-icons:mind-map",
|
icon: "flat-color-icons:mind-map",
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
title: t("Group Management", "Group Management"),
|
||||||
|
url: "/dashboard/group",
|
||||||
|
icon: "flat-color-icons:department",
|
||||||
|
},
|
||||||
{
|
{
|
||||||
title: t("Subscribe Config", "Subscribe Config"),
|
title: t("Subscribe Config", "Subscribe Config"),
|
||||||
url: "/dashboard/subscribe",
|
url: "/dashboard/subscribe",
|
||||||
|
|||||||
@ -39,6 +39,8 @@ const DashboardOrderIndexLazyRouteImport =
|
|||||||
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
|
const DashboardMarketingIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/marketing/',
|
'/dashboard/marketing/',
|
||||||
)()
|
)()
|
||||||
|
const DashboardGroupIndexLazyRouteImport =
|
||||||
|
createFileRoute('/dashboard/group/')()
|
||||||
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
|
const DashboardDocumentIndexLazyRouteImport = createFileRoute(
|
||||||
'/dashboard/document/',
|
'/dashboard/document/',
|
||||||
)()
|
)()
|
||||||
@ -189,6 +191,13 @@ const DashboardMarketingIndexLazyRoute =
|
|||||||
} as any).lazy(() =>
|
} as any).lazy(() =>
|
||||||
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
|
import('./routes/dashboard/marketing/index.lazy').then((d) => d.Route),
|
||||||
)
|
)
|
||||||
|
const DashboardGroupIndexLazyRoute = DashboardGroupIndexLazyRouteImport.update({
|
||||||
|
id: '/group/',
|
||||||
|
path: '/group/',
|
||||||
|
getParentRoute: () => DashboardRouteLazyRoute,
|
||||||
|
} as any).lazy(() =>
|
||||||
|
import('./routes/dashboard/group/index.lazy').then((d) => d.Route),
|
||||||
|
)
|
||||||
const DashboardDocumentIndexLazyRoute =
|
const DashboardDocumentIndexLazyRoute =
|
||||||
DashboardDocumentIndexLazyRouteImport.update({
|
DashboardDocumentIndexLazyRouteImport.update({
|
||||||
id: '/document/',
|
id: '/document/',
|
||||||
@ -345,6 +354,7 @@ export interface FileRoutesByFullPath {
|
|||||||
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
|
||||||
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -377,6 +387,7 @@ export interface FileRoutesByTo {
|
|||||||
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/group': typeof DashboardGroupIndexLazyRoute
|
||||||
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment': typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -411,6 +422,7 @@ export interface FileRoutesById {
|
|||||||
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
|
'/dashboard/auth-control/': typeof DashboardAuthControlIndexLazyRoute
|
||||||
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
|
'/dashboard/coupon/': typeof DashboardCouponIndexLazyRoute
|
||||||
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
|
'/dashboard/document/': typeof DashboardDocumentIndexLazyRoute
|
||||||
|
'/dashboard/group/': typeof DashboardGroupIndexLazyRoute
|
||||||
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
|
'/dashboard/marketing/': typeof DashboardMarketingIndexLazyRoute
|
||||||
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
|
'/dashboard/order/': typeof DashboardOrderIndexLazyRoute
|
||||||
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
|
'/dashboard/payment/': typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -446,6 +458,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control'
|
| '/dashboard/auth-control'
|
||||||
| '/dashboard/coupon'
|
| '/dashboard/coupon'
|
||||||
| '/dashboard/document'
|
| '/dashboard/document'
|
||||||
|
| '/dashboard/group'
|
||||||
| '/dashboard/marketing'
|
| '/dashboard/marketing'
|
||||||
| '/dashboard/order'
|
| '/dashboard/order'
|
||||||
| '/dashboard/payment'
|
| '/dashboard/payment'
|
||||||
@ -478,6 +491,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control'
|
| '/dashboard/auth-control'
|
||||||
| '/dashboard/coupon'
|
| '/dashboard/coupon'
|
||||||
| '/dashboard/document'
|
| '/dashboard/document'
|
||||||
|
| '/dashboard/group'
|
||||||
| '/dashboard/marketing'
|
| '/dashboard/marketing'
|
||||||
| '/dashboard/order'
|
| '/dashboard/order'
|
||||||
| '/dashboard/payment'
|
| '/dashboard/payment'
|
||||||
@ -511,6 +525,7 @@ export interface FileRouteTypes {
|
|||||||
| '/dashboard/auth-control/'
|
| '/dashboard/auth-control/'
|
||||||
| '/dashboard/coupon/'
|
| '/dashboard/coupon/'
|
||||||
| '/dashboard/document/'
|
| '/dashboard/document/'
|
||||||
|
| '/dashboard/group/'
|
||||||
| '/dashboard/marketing/'
|
| '/dashboard/marketing/'
|
||||||
| '/dashboard/order/'
|
| '/dashboard/order/'
|
||||||
| '/dashboard/payment/'
|
| '/dashboard/payment/'
|
||||||
@ -627,6 +642,13 @@ declare module '@tanstack/react-router' {
|
|||||||
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
|
preLoaderRoute: typeof DashboardMarketingIndexLazyRouteImport
|
||||||
parentRoute: typeof DashboardRouteLazyRoute
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
}
|
}
|
||||||
|
'/dashboard/group/': {
|
||||||
|
id: '/dashboard/group/'
|
||||||
|
path: '/group'
|
||||||
|
fullPath: '/dashboard/group'
|
||||||
|
preLoaderRoute: typeof DashboardGroupIndexLazyRouteImport
|
||||||
|
parentRoute: typeof DashboardRouteLazyRoute
|
||||||
|
}
|
||||||
'/dashboard/document/': {
|
'/dashboard/document/': {
|
||||||
id: '/dashboard/document/'
|
id: '/dashboard/document/'
|
||||||
path: '/document'
|
path: '/document'
|
||||||
@ -770,6 +792,7 @@ interface DashboardRouteLazyRouteChildren {
|
|||||||
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
|
DashboardAuthControlIndexLazyRoute: typeof DashboardAuthControlIndexLazyRoute
|
||||||
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
|
DashboardCouponIndexLazyRoute: typeof DashboardCouponIndexLazyRoute
|
||||||
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
|
DashboardDocumentIndexLazyRoute: typeof DashboardDocumentIndexLazyRoute
|
||||||
|
DashboardGroupIndexLazyRoute: typeof DashboardGroupIndexLazyRoute
|
||||||
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
|
DashboardMarketingIndexLazyRoute: typeof DashboardMarketingIndexLazyRoute
|
||||||
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
|
DashboardOrderIndexLazyRoute: typeof DashboardOrderIndexLazyRoute
|
||||||
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
|
DashboardPaymentIndexLazyRoute: typeof DashboardPaymentIndexLazyRoute
|
||||||
@ -802,6 +825,7 @@ const DashboardRouteLazyRouteChildren: DashboardRouteLazyRouteChildren = {
|
|||||||
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
|
DashboardAuthControlIndexLazyRoute: DashboardAuthControlIndexLazyRoute,
|
||||||
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
|
DashboardCouponIndexLazyRoute: DashboardCouponIndexLazyRoute,
|
||||||
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
|
DashboardDocumentIndexLazyRoute: DashboardDocumentIndexLazyRoute,
|
||||||
|
DashboardGroupIndexLazyRoute: DashboardGroupIndexLazyRoute,
|
||||||
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
|
DashboardMarketingIndexLazyRoute: DashboardMarketingIndexLazyRoute,
|
||||||
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
|
DashboardOrderIndexLazyRoute: DashboardOrderIndexLazyRoute,
|
||||||
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
|
DashboardPaymentIndexLazyRoute: DashboardPaymentIndexLazyRoute,
|
||||||
|
|||||||
6
apps/admin/src/routes/dashboard/group/index.lazy.tsx
Normal file
6
apps/admin/src/routes/dashboard/group/index.lazy.tsx
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
import { createLazyFileRoute } from "@tanstack/react-router";
|
||||||
|
import Group from "@/sections/group";
|
||||||
|
|
||||||
|
export const Route = createLazyFileRoute("/dashboard/group/")({
|
||||||
|
component: Group,
|
||||||
|
});
|
||||||
264
apps/admin/src/sections/group/average-mode-tab.tsx
Normal file
264
apps/admin/src/sections/group/average-mode-tab.tsx
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState, useRef } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
getNodeGroupList,
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
|
||||||
|
export default function AverageModeTab() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
|
||||||
|
const [averageConfig, setAverageConfig] = useState({
|
||||||
|
node_group_count: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const hasLoadedConfig = useRef(true);
|
||||||
|
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
if (data.data?.config?.average_config) {
|
||||||
|
setAverageConfig(data.data.config.average_config as any);
|
||||||
|
}
|
||||||
|
hasLoadedConfig.current = true;
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load group config:", error);
|
||||||
|
toast.error(t("loadFailed", "Failed to load configuration"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (nodeGroupsData) {
|
||||||
|
const nodeGroupCount = nodeGroupsData?.length || 0;
|
||||||
|
|
||||||
|
if (averageConfig.node_group_count !== nodeGroupCount) {
|
||||||
|
setAverageConfig({
|
||||||
|
...averageConfig,
|
||||||
|
node_group_count: nodeGroupCount,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [nodeGroupsData]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status?.state === "running") {
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status?.state]);
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalculating(true);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode: "average" });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Configuration Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("averageModeConfig", "Average Mode Configuration")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"averageModeDescription",
|
||||||
|
"Randomly assign node groups to user subscriptions based on subscribe configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
<div className="grid grid-cols-1 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node_group_count">
|
||||||
|
{t("availableNodeGroups", "Available Node Groups")}
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="node_group_count"
|
||||||
|
type="number"
|
||||||
|
min={1}
|
||||||
|
value={averageConfig.node_group_count}
|
||||||
|
readOnly
|
||||||
|
className="bg-muted"
|
||||||
|
/>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
{t("nodeGroupCountAutoCalculated", "Auto-calculated from actual node groups")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recalculation Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("recalculationCompleted", "Recalculation completed successfully")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{t("recalculationFailed", "Recalculation failed. Please try again.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleRecalculate}
|
||||||
|
disabled={recalculating || status?.state === "running"}
|
||||||
|
>
|
||||||
|
{recalculating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculateAll", "Recalculate All Users")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
174
apps/admin/src/sections/group/bind-node-groups-dialog.tsx
Normal file
174
apps/admin/src/sections/group/bind-node-groups-dialog.tsx
Normal file
@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
375
apps/admin/src/sections/group/current-group-results.tsx
Normal file
375
apps/admin/src/sections/group/current-group-results.tsx
Normal file
@ -0,0 +1,375 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { getGroupHistory, getGroupHistoryDetail, getNodeGroupList } from "@workspace/ui/services/admin/group";
|
||||||
|
|
||||||
|
export default function CurrentGroupResults() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [latestResult, setLatestResult] = useState<any>(null);
|
||||||
|
const [latestDetails, setLatestDetails] = useState<any[]>([]);
|
||||||
|
const [detailsLoading, setDetailsLoading] = useState(false);
|
||||||
|
|
||||||
|
// User list dialog state
|
||||||
|
const [userListOpen, setUserListOpen] = useState(false);
|
||||||
|
const [selectedNodeGroupName, setSelectedNodeGroupName] = useState<string>("");
|
||||||
|
const [userList, setUserList] = useState<any[]>([]);
|
||||||
|
const [userListLoading, setUserListLoading] = useState(false);
|
||||||
|
const [userListTotal, setUserListTotal] = useState(0);
|
||||||
|
|
||||||
|
// Fetch node groups
|
||||||
|
const { data: nodeGroups } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const loadData = async () => {
|
||||||
|
try {
|
||||||
|
// Load latest result
|
||||||
|
const { data: historyData } = await getGroupHistory({
|
||||||
|
page: 1,
|
||||||
|
size: 1,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (historyData.data?.list && historyData.data.list.length > 0) {
|
||||||
|
const latest = historyData.data.list[0];
|
||||||
|
if (!latest) return;
|
||||||
|
setLatestResult(latest);
|
||||||
|
|
||||||
|
// Fetch details
|
||||||
|
setDetailsLoading(true);
|
||||||
|
try {
|
||||||
|
const { data: detailData } = await getGroupHistoryDetail({
|
||||||
|
id: latest.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detailData.data?.config_snapshot?.group_details) {
|
||||||
|
setLatestDetails(detailData.data.config_snapshot.group_details);
|
||||||
|
} else {
|
||||||
|
setLatestDetails([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch latest result details:", error);
|
||||||
|
setLatestDetails([]);
|
||||||
|
} finally {
|
||||||
|
setDetailsLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load data:", error);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
loadData();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleShowUserList = async (nodeGroupId: number, nodeGroupName: string) => {
|
||||||
|
setSelectedNodeGroupName(nodeGroupName);
|
||||||
|
setUserListOpen(true);
|
||||||
|
setUserListLoading(true);
|
||||||
|
|
||||||
|
// 从历史详情记录中获取用户数据
|
||||||
|
const detail = latestDetails.find((d: any) => {
|
||||||
|
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
|
||||||
|
return detailNodeGroupId === nodeGroupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detail) {
|
||||||
|
const userDataJSON = detail.UserData || detail.user_data;
|
||||||
|
if (userDataJSON) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(userDataJSON);
|
||||||
|
setUserList(userData);
|
||||||
|
setUserListTotal(userData.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse user data:", error);
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
setUserListLoading(false);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (loading) {
|
||||||
|
return (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("currentGroupingResult", "Current Grouping Result")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Latest Result Card */}
|
||||||
|
{!latestResult ? (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("currentGroupingResult", "Current Grouping Result")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("noDetails", "No details available")}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("currentGroupingResult", "Current Grouping Result")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("latestGroupingCalculation", "Latest grouping calculation details")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Calculation Info */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">{t("calculationInfo", "Calculation Information")}</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("groupMode", "Group Mode")}</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(latestResult.GroupMode || latestResult.group_mode) === "average"
|
||||||
|
? t("averageMode", "Average Mode")
|
||||||
|
: (latestResult.GroupMode || latestResult.group_mode) === "subscribe"
|
||||||
|
? t("subscribeMode", "Subscribe Mode")
|
||||||
|
: t("trafficMode", "Traffic Mode")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("state", "State")}</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(latestResult.State || latestResult.state) === "completed"
|
||||||
|
? t("completed", "Completed")
|
||||||
|
: (latestResult.State || latestResult.state) === "running"
|
||||||
|
? t("running", "Running")
|
||||||
|
: (latestResult.State || latestResult.state) === "failed"
|
||||||
|
? t("failed", "Failed")
|
||||||
|
: t("idle", "Idle")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("triggerType", "Trigger Type")}</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{(latestResult.TriggerType || latestResult.trigger_type) === "manual"
|
||||||
|
? t("manualTrigger", "Manual")
|
||||||
|
: (latestResult.TriggerType || latestResult.trigger_type) === "auto"
|
||||||
|
? t("autoTrigger", "Auto")
|
||||||
|
: t("scheduleTrigger", "Schedule")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("successFailedCount", "Success/Failed")}</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{latestResult.SuccessCount || latestResult.success_count || 0} / {latestResult.FailedCount || latestResult.failed_count || 0}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("startTime", "Start Time")}</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{latestResult.StartTime || latestResult.start_time
|
||||||
|
? new Date((latestResult.StartTime || latestResult.start_time) * 1000).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("endTime", "End Time")}</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{latestResult.EndTime || latestResult.end_time
|
||||||
|
? new Date((latestResult.EndTime || latestResult.end_time) * 1000).toLocaleString()
|
||||||
|
: "-"}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Grouping Details */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h3 className="text-sm font-medium">{t("groupingDetailsStatistics", "Grouping Details Statistics")}</h3>
|
||||||
|
<div className="grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{latestDetails.reduce((sum: number, d: any) => sum + (d.UserCount || d.user_count || 0), 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("totalUsers", "Total Users")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{latestDetails.reduce((sum: number, d: any) => sum + (d.NodeCount || d.node_count || 0), 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("totalNodes", "Total Nodes")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">{latestDetails.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("totalNodeGroups", "Total Node Groups")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{detailsLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : latestDetails.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* Details Table */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="border-b px-4 py-2 text-left">
|
||||||
|
{t("nodeGroup", "Node Group")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("userCount", "User Count")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("nodeCount", "Node Count")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{latestDetails.map((detail: any, index: number) => {
|
||||||
|
const nodeGroupId = detail.NodeGroupId || detail.node_group_id;
|
||||||
|
const nodeGroup = nodeGroups?.find((ng) => ng.id === nodeGroupId);
|
||||||
|
const nodeGroupName = nodeGroup?.name || `${t("idPrefix", "#")}${nodeGroupId}`;
|
||||||
|
const userCount = detail.UserCount || detail.user_count || 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{nodeGroupName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroupId}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
className={`font-semibold hover:underline ${
|
||||||
|
userCount === 0 ? 'text-muted-foreground cursor-not-allowed' : 'cursor-pointer'
|
||||||
|
}`}
|
||||||
|
onClick={() => handleShowUserList(nodeGroupId, nodeGroupName)}
|
||||||
|
disabled={userCount === 0}
|
||||||
|
>
|
||||||
|
{userCount}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
{detail.NodeCount || detail.node_count || 0}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("noDetails", "No details available")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* User List Dialog */}
|
||||||
|
<Dialog open={userListOpen} onOpenChange={setUserListOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedNodeGroupName} - {t("userList", "User List")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("totalUsers", "Total Users")}: {userListTotal}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{userListLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : userList.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("id", "ID")}</TableHead>
|
||||||
|
<TableHead>{t("email", "Email")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{userList.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.email || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("noUsers", "No users found")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
264
apps/admin/src/sections/group/group-config.tsx
Normal file
264
apps/admin/src/sections/group/group-config.tsx
Normal file
@ -0,0 +1,264 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
getGroupConfig,
|
||||||
|
updateGroupConfig,
|
||||||
|
resetGroups,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import {
|
||||||
|
AlertDialog,
|
||||||
|
AlertDialogAction,
|
||||||
|
AlertDialogCancel,
|
||||||
|
AlertDialogContent,
|
||||||
|
AlertDialogDescription,
|
||||||
|
AlertDialogFooter,
|
||||||
|
AlertDialogHeader,
|
||||||
|
AlertDialogTitle,
|
||||||
|
AlertDialogTrigger,
|
||||||
|
} from "@workspace/ui/components/alert-dialog";
|
||||||
|
|
||||||
|
export default function GroupConfig() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [resetting, setResetting] = useState(false);
|
||||||
|
const [showResetDialog, setShowResetDialog] = useState(false);
|
||||||
|
const [config, setConfig] = useState<{
|
||||||
|
enabled: boolean;
|
||||||
|
mode: "average" | "subscribe" | "traffic";
|
||||||
|
}>({
|
||||||
|
enabled: false,
|
||||||
|
mode: "average",
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadConfig = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getGroupConfig();
|
||||||
|
if (data.data) {
|
||||||
|
setConfig({
|
||||||
|
enabled: data.data.enabled || false,
|
||||||
|
mode: (data.data.mode || "average") as "average" | "subscribe" | "traffic",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load group config:", error);
|
||||||
|
toast.error(t("loadFailed", "Failed to load configuration"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadConfig();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const handleUpdateEnabled = async (enabled: boolean) => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
enabled,
|
||||||
|
mode: config.mode,
|
||||||
|
};
|
||||||
|
await updateGroupConfig(payload);
|
||||||
|
setConfig({ ...config, enabled });
|
||||||
|
toast.success(t("saved", "Configuration saved successfully"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update group config:", error);
|
||||||
|
toast.error(t("saveFailed", "Failed to save configuration"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdateMode = async (mode: "average" | "subscribe" | "traffic") => {
|
||||||
|
setSaving(true);
|
||||||
|
try {
|
||||||
|
const payload: any = {
|
||||||
|
enabled: config.enabled,
|
||||||
|
mode,
|
||||||
|
};
|
||||||
|
await updateGroupConfig(payload);
|
||||||
|
setConfig({ ...config, mode });
|
||||||
|
toast.success(t("saved", "Configuration saved successfully"));
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update group config:", error);
|
||||||
|
toast.error(t("saveFailed", "Failed to save configuration"));
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleResetGroups = async () => {
|
||||||
|
setResetting(true);
|
||||||
|
try {
|
||||||
|
await resetGroups({ confirm: true });
|
||||||
|
toast.success(t("resetSuccess", "All groups have been reset successfully"));
|
||||||
|
setShowResetDialog(false);
|
||||||
|
// Reload config after reset
|
||||||
|
await loadConfig();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to reset groups:", error);
|
||||||
|
toast.error(t("resetFailed", "Failed to reset groups"));
|
||||||
|
} finally {
|
||||||
|
setResetting(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupConfig", "Group Configuration")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupConfigDescription",
|
||||||
|
"Configure user group and node group settings"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Enable/Disable */}
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<label htmlFor="enabled" className="font-medium">
|
||||||
|
{t("enableGrouping", "Enable Grouping")}
|
||||||
|
</label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"enableGroupingDescription",
|
||||||
|
"When enabled, users will only see nodes from their assigned group"
|
||||||
|
)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<input
|
||||||
|
id="enabled"
|
||||||
|
type="checkbox"
|
||||||
|
checked={config.enabled}
|
||||||
|
onChange={(e) => handleUpdateEnabled(e.target.checked)}
|
||||||
|
disabled={saving}
|
||||||
|
className="h-4 w-4"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Mode Selection */}
|
||||||
|
{config.enabled && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<label className="font-medium">
|
||||||
|
{t("groupingMode", "Grouping Mode")}
|
||||||
|
</label>
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateMode("average")}
|
||||||
|
disabled={saving}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
config.mode === "average"
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
} ${saving ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("averageMode", "Average Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"averageModeDescription",
|
||||||
|
"Distribute users evenly across groups"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateMode("subscribe")}
|
||||||
|
disabled={saving}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
config.mode === "subscribe"
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
} ${saving ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("subscribeMode", "Subscribe Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"subscribeModeDescription",
|
||||||
|
"Group users by their subscription plan"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleUpdateMode("traffic")}
|
||||||
|
disabled={saving}
|
||||||
|
className={`rounded-lg border p-4 text-left transition-colors ${
|
||||||
|
config.mode === "traffic"
|
||||||
|
? "border-primary bg-primary/10"
|
||||||
|
: "border-border hover:bg-muted"
|
||||||
|
} ${saving ? "opacity-50 cursor-not-allowed" : ""}`}
|
||||||
|
>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("trafficMode", "Traffic Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t(
|
||||||
|
"trafficModeDescription",
|
||||||
|
"Group users by their traffic usage"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Reset Button */}
|
||||||
|
<div className="flex justify-end pt-4 border-t">
|
||||||
|
<AlertDialog open={showResetDialog} onOpenChange={setShowResetDialog}>
|
||||||
|
<AlertDialogTrigger asChild>
|
||||||
|
<Button variant="destructive">
|
||||||
|
{t("resetGroups", "Reset All Groups")}
|
||||||
|
</Button>
|
||||||
|
</AlertDialogTrigger>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>
|
||||||
|
{t("resetGroupsTitle", "Reset All Groups")}
|
||||||
|
</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>
|
||||||
|
{t(
|
||||||
|
"resetGroupsDescription",
|
||||||
|
"This action will delete all node groups and user groups, reset all users' group ID to 0, clear all products' node group IDs, and clear all nodes' node group IDs. This action cannot be undone."
|
||||||
|
)}
|
||||||
|
</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</AlertDialogCancel>
|
||||||
|
<AlertDialogAction
|
||||||
|
onClick={handleResetGroups}
|
||||||
|
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||||
|
>
|
||||||
|
{resetting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||||
|
{t("confirm", "Confirm")}
|
||||||
|
</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
499
apps/admin/src/sections/group/group-history.tsx
Normal file
499
apps/admin/src/sections/group/group-history.tsx
Normal file
@ -0,0 +1,499 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableHead,
|
||||||
|
TableHeader,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
getGroupHistory,
|
||||||
|
getGroupHistoryDetail,
|
||||||
|
getNodeGroupList,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { formatDate } from "@/utils/common";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
|
||||||
|
export default function GroupHistory() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
const [detailOpen, setDetailOpen] = useState(false);
|
||||||
|
const [detailLoading, setDetailLoading] = useState(false);
|
||||||
|
const [selectedHistory, setSelectedHistory] = useState<API.GroupHistory | null>(null);
|
||||||
|
const [details, setDetails] = useState<any[]>([]);
|
||||||
|
const [nodeGroupMap, setNodeGroupMap] = useState<Map<number, string>>(new Map());
|
||||||
|
|
||||||
|
// User list dialog state
|
||||||
|
const [userListOpen, setUserListOpen] = useState(false);
|
||||||
|
const [selectedNodeGroupName, setSelectedNodeGroupName] = useState<string>("");
|
||||||
|
const [userList, setUserList] = useState<any[]>([]);
|
||||||
|
const [userListTotal, setUserListTotal] = useState(0);
|
||||||
|
|
||||||
|
// Fetch all node groups
|
||||||
|
const { data: nodeGroups } = useQuery({
|
||||||
|
queryKey: ["getNodeGroupListForDetail"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: 1,
|
||||||
|
size: 100,
|
||||||
|
});
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Build ID to name maps when groups are loaded
|
||||||
|
if (nodeGroups) {
|
||||||
|
const newNodeGroupMap = new Map<number, string>();
|
||||||
|
nodeGroups.forEach((ng: API.NodeGroup) => {
|
||||||
|
newNodeGroupMap.set(ng.id, ng.name);
|
||||||
|
});
|
||||||
|
if (newNodeGroupMap.size !== nodeGroupMap.size) {
|
||||||
|
setNodeGroupMap(newNodeGroupMap);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const getModeLabel = (mode: string) => {
|
||||||
|
switch (mode) {
|
||||||
|
case "average":
|
||||||
|
return t("averageMode", "Average");
|
||||||
|
case "subscribe":
|
||||||
|
return t("subscribeMode", "Subscribe");
|
||||||
|
case "traffic":
|
||||||
|
return t("trafficMode", "Traffic");
|
||||||
|
default:
|
||||||
|
return mode;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getTriggerTypeLabel = (type: string) => {
|
||||||
|
switch (type) {
|
||||||
|
case "manual":
|
||||||
|
return t("manualTrigger", "Manual");
|
||||||
|
case "auto":
|
||||||
|
return t("autoTrigger", "Auto");
|
||||||
|
case "schedule":
|
||||||
|
return t("scheduleTrigger", "Schedule");
|
||||||
|
default:
|
||||||
|
return type;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleViewDetail = async (record: API.GroupHistory) => {
|
||||||
|
setSelectedHistory(record);
|
||||||
|
setDetailOpen(true);
|
||||||
|
setDetailLoading(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getGroupHistoryDetail({
|
||||||
|
id: record.id,
|
||||||
|
});
|
||||||
|
|
||||||
|
console.log("Group history detail response:", data);
|
||||||
|
|
||||||
|
// 从返回的数据中获取详情列表
|
||||||
|
// data.data.config_snapshot.group_details 包含分组详情
|
||||||
|
if (data.data?.config_snapshot?.group_details) {
|
||||||
|
setDetails(data.data.config_snapshot.group_details);
|
||||||
|
} else {
|
||||||
|
console.warn("No group_details found in response:", data);
|
||||||
|
setDetails([]);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch history details:", error);
|
||||||
|
setDetails([]);
|
||||||
|
} finally {
|
||||||
|
setDetailLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleShowUserList = async (nodeGroupId: number, nodeGroupName: string) => {
|
||||||
|
setSelectedNodeGroupName(nodeGroupName);
|
||||||
|
setUserListOpen(true);
|
||||||
|
|
||||||
|
// 从历史详情记录中获取用户数据
|
||||||
|
const detail = details.find((d: any) => {
|
||||||
|
const detailNodeGroupId = d.NodeGroupId || d.node_group_id;
|
||||||
|
return detailNodeGroupId === nodeGroupId;
|
||||||
|
});
|
||||||
|
|
||||||
|
if (detail) {
|
||||||
|
const userDataJSON = detail.UserData || detail.user_data;
|
||||||
|
if (userDataJSON) {
|
||||||
|
try {
|
||||||
|
const userData = JSON.parse(userDataJSON);
|
||||||
|
setUserList(userData);
|
||||||
|
setUserListTotal(userData.length);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to parse user data:", error);
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
setUserList([]);
|
||||||
|
setUserListTotal(0);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupHistory", "Group Calculation History")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("groupHistoryDescription", "View group recalculation history and results")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProTable<API.GroupHistory, API.GetGroupHistoryRequest>
|
||||||
|
action={ref}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getGroupHistory({
|
||||||
|
page: params.page || 1,
|
||||||
|
size: params.size || 10,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "id",
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("id", "ID"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("idPrefix", "#")}{row.getValue("id")}
|
||||||
|
</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "group_mode",
|
||||||
|
accessorKey: "group_mode",
|
||||||
|
header: t("groupMode", "Group Mode"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<Badge variant="outline">
|
||||||
|
{getModeLabel(row.getValue("group_mode"))}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "trigger_type",
|
||||||
|
accessorKey: "trigger_type",
|
||||||
|
header: t("triggerType", "Trigger Type"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{getTriggerTypeLabel(row.getValue("trigger_type"))}
|
||||||
|
</Badge>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "total_users",
|
||||||
|
accessorKey: "total_users",
|
||||||
|
header: t("totalUsers", "Total Users"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<span className="font-semibold">{row.getValue("total_users")}</span>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "result",
|
||||||
|
accessorKey: "error_log",
|
||||||
|
header: t("result", "Result"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const record = row.original;
|
||||||
|
return (
|
||||||
|
<div className="space-y-1">
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("successCount", "Success")}: {record.success_count}
|
||||||
|
{" "}{t("separator", "/")}{" "}
|
||||||
|
{t("failedCount", "Failed")}: {record.failed_count}
|
||||||
|
</div>
|
||||||
|
{record.error_log && (
|
||||||
|
<Badge variant="destructive">
|
||||||
|
{t("failed", "Failed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
{!record.error_log && record.failed_count === 0 && (
|
||||||
|
<Badge variant="default">
|
||||||
|
{t("completed", "Completed")}
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "created_at",
|
||||||
|
accessorKey: "created_at",
|
||||||
|
header: t("createdAt", "Created At"),
|
||||||
|
cell: ({ row }: { row: any }) => formatDate(row.getValue("created_at")),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
actions={{
|
||||||
|
render: (row: any) => [
|
||||||
|
<Button
|
||||||
|
key="detail"
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => handleViewDetail(row)}
|
||||||
|
>
|
||||||
|
{t("viewDetail", "View Detail")}
|
||||||
|
</Button>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
header={{
|
||||||
|
title: t("groupHistory", "Group Calculation History"),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Detail Dialog */}
|
||||||
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{t("groupHistoryDetail", "Group Calculation Detail")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("historyId", "History ID")}: {selectedHistory?.id}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{selectedHistory && (
|
||||||
|
<>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("groupMode", "Group Mode")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{getModeLabel(selectedHistory.group_mode)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("triggerType", "Trigger Type")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{getTriggerTypeLabel(selectedHistory.trigger_type)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("totalUsers", "Total Users")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">{selectedHistory.total_users}</div>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("result", "Result")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("successCount", "Success")}: {selectedHistory.success_count}
|
||||||
|
{" "}{t("separator", "/")}{" "}
|
||||||
|
{t("failedCount", "Failed")}: {selectedHistory.failed_count}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{selectedHistory.start_time && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("startTime", "Start Time")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatDate(selectedHistory.start_time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{selectedHistory.end_time && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("endTime", "End Time")}
|
||||||
|
</div>
|
||||||
|
<div className="font-medium">
|
||||||
|
{formatDate(selectedHistory.end_time)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{selectedHistory.error_log && (
|
||||||
|
<div>
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("errorMessage", "Error Message")}
|
||||||
|
</div>
|
||||||
|
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
{selectedHistory.error_log}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<div className="mb-2 text-sm font-medium">
|
||||||
|
{t("groupDetails", "Group Details")}
|
||||||
|
</div>
|
||||||
|
{detailLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : details.length > 0 ? (
|
||||||
|
<>
|
||||||
|
{/* 统计信息 */}
|
||||||
|
<div className="mb-4 grid grid-cols-3 gap-4 rounded-lg bg-muted/50 p-4">
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{details.reduce((sum: number, d: any) => sum + (d.UserCount || d.user_count || 0), 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("totalUsers", "Total Users")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
{details.reduce((sum: number, d: any) => sum + (d.NodeCount || d.node_count || 0), 0)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("totalNodes", "Total Nodes")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="text-center">
|
||||||
|
<div className="text-2xl font-bold">{details.length}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">
|
||||||
|
{t("totalNodeGroups", "Total Node Groups")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* 详情表格 */}
|
||||||
|
<div className="rounded-md border">
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-muted">
|
||||||
|
<tr>
|
||||||
|
<th className="border-b px-4 py-2 text-left">
|
||||||
|
{t("nodeGroup", "Node Group")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("userCount", "User Count")}
|
||||||
|
</th>
|
||||||
|
<th className="border-b px-4 py-2 text-right">
|
||||||
|
{t("nodeCount", "Node Count")}
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{details.map((detail: any, index: number) => {
|
||||||
|
const nodeGroupId = detail.NodeGroupId || detail.node_group_id;
|
||||||
|
const nodeGroupName = nodeGroupMap.get(nodeGroupId) || `${t("idPrefix", "#")}${nodeGroupId}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={index}>
|
||||||
|
<td className="border-b px-4 py-2">
|
||||||
|
<div>
|
||||||
|
<div className="font-medium">{nodeGroupName}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroupId}</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
className="font-semibold hover:underline cursor-pointer disabled:opacity-50 disabled:cursor-not-allowed"
|
||||||
|
onClick={() => handleShowUserList(nodeGroupId, nodeGroupName)}
|
||||||
|
disabled={(detail.UserCount || detail.user_count || 0) === 0}
|
||||||
|
>
|
||||||
|
{detail.UserCount || detail.user_count || 0}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
<td className="border-b px-4 py-2 text-right">
|
||||||
|
{detail.NodeCount || detail.node_count || 0}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("noDetails", "No details available")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
|
||||||
|
{/* User List Dialog */}
|
||||||
|
<Dialog open={userListOpen} onOpenChange={setUserListOpen}>
|
||||||
|
<DialogContent className="sm:max-w-[700px] max-h-[80vh] overflow-y-auto">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
{selectedNodeGroupName} - {t("userList", "User List")}
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("totalUsers", "Total Users")}: {userListTotal}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{userList.length > 0 ? (
|
||||||
|
<Table>
|
||||||
|
<TableHeader>
|
||||||
|
<TableRow>
|
||||||
|
<TableHead>{t("id", "ID")}</TableHead>
|
||||||
|
<TableHead>{t("email", "Email")}</TableHead>
|
||||||
|
</TableRow>
|
||||||
|
</TableHeader>
|
||||||
|
<TableBody>
|
||||||
|
{userList.map((user) => (
|
||||||
|
<TableRow key={user.id}>
|
||||||
|
<TableCell className="font-medium">{user.id}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
{user.email || "-"}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
) : (
|
||||||
|
<div className="text-center py-8 text-sm text-muted-foreground">
|
||||||
|
{t("noUsers", "No users found")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
apps/admin/src/sections/group/group-recalculate.tsx
Normal file
228
apps/admin/src/sections/group/group-recalculate.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import {
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
|
||||||
|
export default function GroupRecalculate() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState<string | null>(null);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
|
||||||
|
// Poll status every 2 seconds when recalculating
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status?.state === "running") {
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status?.state]);
|
||||||
|
|
||||||
|
const handleRecalculate = async (mode: "average" | "subscribe" | "traffic") => {
|
||||||
|
setRecalculating(mode);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("recalculationCompleted", "Recalculation completed successfully")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{t("recalculationFailed", "Recalculation failed. Please try again.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Buttons */}
|
||||||
|
<div className="space-y-4">
|
||||||
|
<div className="grid grid-cols-3 gap-4">
|
||||||
|
{/* Average Mode Recalculate */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("averageMode", "Average Mode")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRecalculate("average")}
|
||||||
|
disabled={recalculating === "average" || status?.state === "running"}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{recalculating === "average" && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculate", "Recalculate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Subscribe Mode Recalculate */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("subscribeMode", "Subscribe Mode")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRecalculate("subscribe")}
|
||||||
|
disabled={recalculating === "subscribe" || status?.state === "running"}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{recalculating === "subscribe" && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculate", "Recalculate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Traffic Mode Recalculate */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="font-medium">
|
||||||
|
{t("trafficMode", "Traffic Mode")}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
onClick={() => handleRecalculate("traffic")}
|
||||||
|
disabled={recalculating === "traffic" || status?.state === "running"}
|
||||||
|
className="w-full"
|
||||||
|
variant="outline"
|
||||||
|
>
|
||||||
|
{recalculating === "traffic" && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculate", "Recalculate")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
85
apps/admin/src/sections/group/index.tsx
Normal file
85
apps/admin/src/sections/group/index.tsx
Normal file
@ -0,0 +1,85 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@workspace/ui/components/tabs";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
// import UserGroups from "./user-groups";
|
||||||
|
import NodeGroups from "./node-groups";
|
||||||
|
import GroupHistory from "./group-history";
|
||||||
|
import GroupConfig from "./group-config";
|
||||||
|
import AverageModeTab from "./average-mode-tab";
|
||||||
|
import SubscribeModeTab from "./subscribe-mode-tab";
|
||||||
|
import TrafficModeTab from "./traffic-mode-tab";
|
||||||
|
import CurrentGroupResults from "./current-group-results";
|
||||||
|
|
||||||
|
export default function Group() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<h2 className="font-semibold text-lg">
|
||||||
|
{t("title", "Group Management")}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<Tabs defaultValue="config">
|
||||||
|
<TabsList className="flex flex-wrap gap-2">
|
||||||
|
<TabsTrigger value="config">
|
||||||
|
{t("config", "Config")}
|
||||||
|
</TabsTrigger>
|
||||||
|
{/* <TabsTrigger value="user">
|
||||||
|
{t("userGroups", "User Groups")}
|
||||||
|
</TabsTrigger> */}
|
||||||
|
<TabsTrigger value="node">
|
||||||
|
{t("nodeGroups", "Node Groups")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="average">
|
||||||
|
{t("averageMode", "Average Mode")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="subscribe">
|
||||||
|
{t("subscribeMode", "Subscribe Mode")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="traffic">
|
||||||
|
{t("trafficMode", "Traffic Mode")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="results">
|
||||||
|
{t("currentGroupingResult", "Current Grouping Result")}
|
||||||
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="history">
|
||||||
|
{t("history", "History")}
|
||||||
|
</TabsTrigger>
|
||||||
|
</TabsList>
|
||||||
|
|
||||||
|
<TabsContent value="config" className="mt-4">
|
||||||
|
<GroupConfig />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
{/* <TabsContent value="user" className="mt-4">
|
||||||
|
<UserGroups />
|
||||||
|
</TabsContent> */}
|
||||||
|
|
||||||
|
<TabsContent value="node" className="mt-4">
|
||||||
|
<NodeGroups />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="average" className="mt-4">
|
||||||
|
<AverageModeTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="subscribe" className="mt-4">
|
||||||
|
<SubscribeModeTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="traffic" className="mt-4">
|
||||||
|
<TrafficModeTab />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="results" className="mt-4">
|
||||||
|
<CurrentGroupResults />
|
||||||
|
</TabsContent>
|
||||||
|
|
||||||
|
<TabsContent value="history" className="mt-4">
|
||||||
|
<GroupHistory />
|
||||||
|
</TabsContent>
|
||||||
|
</Tabs>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
300
apps/admin/src/sections/group/node-group-form.tsx
Normal file
300
apps/admin/src/sections/group/node-group-form.tsx
Normal file
@ -0,0 +1,300 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import { Textarea } from "@workspace/ui/components/textarea";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import { AlertCircle, Loader2 } from "lucide-react";
|
||||||
|
import { forwardRef, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface NodeGroupFormProps {
|
||||||
|
initialValues?: Partial<API.NodeGroup>;
|
||||||
|
allNodeGroups?: API.NodeGroup[];
|
||||||
|
currentGroupId?: number;
|
||||||
|
loading?: boolean;
|
||||||
|
onSubmit: (values: Record<string, unknown>) => Promise<boolean>;
|
||||||
|
title: string;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const NodeGroupForm = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
NodeGroupFormProps
|
||||||
|
>(({ initialValues, allNodeGroups = [], currentGroupId, loading, onSubmit, title, trigger }, ref) => {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
const [conflictError, setConflictError] = useState<string>("");
|
||||||
|
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
for_calculation: true,
|
||||||
|
min_traffic_gb: 0,
|
||||||
|
max_traffic_gb: 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
setConflictError(""); // 重置冲突错误
|
||||||
|
if (initialValues) {
|
||||||
|
setValues({
|
||||||
|
name: initialValues.name || "",
|
||||||
|
description: initialValues.description || "",
|
||||||
|
sort: initialValues.sort ?? 0,
|
||||||
|
for_calculation: initialValues.for_calculation ?? true,
|
||||||
|
min_traffic_gb: initialValues.min_traffic_gb ?? 0,
|
||||||
|
max_traffic_gb: initialValues.max_traffic_gb ?? 0,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setValues({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
for_calculation: true,
|
||||||
|
min_traffic_gb: 0,
|
||||||
|
max_traffic_gb: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialValues, open]);
|
||||||
|
|
||||||
|
// 检测流量区间冲突
|
||||||
|
const checkTrafficRangeConflict = (minTraffic: number, maxTraffic: number): string => {
|
||||||
|
// 如果 min=0 且 max=0,表示不参与流量分组,跳过所有验证
|
||||||
|
if (minTraffic === 0 && maxTraffic === 0) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证区间有效性:min 必须 < max(除非 max=0 表示无上限)
|
||||||
|
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
|
||||||
|
return t("invalidRange", "Min traffic must be less than max traffic");
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理 max=0 的情况,表示无上限,使用一个很大的数代替
|
||||||
|
const actualMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
|
||||||
|
|
||||||
|
// 检查与其他节点组的冲突
|
||||||
|
for (const group of allNodeGroups) {
|
||||||
|
// 跳过当前编辑的节点组
|
||||||
|
if (currentGroupId && group.id === currentGroupId) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 跳过没有设置流量区间的节点组(min=0 且 max=0 表示未配置)
|
||||||
|
const existingMin = group.min_traffic_gb ?? 0;
|
||||||
|
const existingMax = group.max_traffic_gb ?? 0;
|
||||||
|
if (existingMin === 0 && existingMax === 0) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 处理现有节点组 max=0 的情况
|
||||||
|
const actualExistingMax = existingMax === 0 ? Number.MAX_VALUE : existingMax;
|
||||||
|
|
||||||
|
// 检测区间重叠
|
||||||
|
// 两个区间 [min1, max1] 和 [min2, max2] 重叠的条件:
|
||||||
|
// max1 > min2 && max2 > min1
|
||||||
|
const hasOverlap = actualMax > existingMin && actualExistingMax > minTraffic;
|
||||||
|
|
||||||
|
if (hasOverlap) {
|
||||||
|
return t("rangeConflict", {
|
||||||
|
name: group.name,
|
||||||
|
min: existingMin.toString(),
|
||||||
|
max: existingMax === 0 ? "∞" : existingMax.toString(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return "";
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
// 检测流量区间冲突
|
||||||
|
const conflict = checkTrafficRangeConflict(values.min_traffic_gb, values.max_traffic_gb);
|
||||||
|
if (conflict) {
|
||||||
|
setConflictError(conflict);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setSubmitting(true);
|
||||||
|
const success = await onSubmit(values);
|
||||||
|
setSubmitting(false);
|
||||||
|
if (success) {
|
||||||
|
setOpen(false);
|
||||||
|
setConflictError("");
|
||||||
|
setValues({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
for_calculation: true,
|
||||||
|
min_traffic_gb: 0,
|
||||||
|
max_traffic_gb: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild ref={ref}>
|
||||||
|
{trigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("nodeGroupFormDescription", "Configure node group settings")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
{t("name", "Name")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("namePlaceholder", "Enter name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">
|
||||||
|
{t("description", "Description")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={values.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("descriptionPlaceholder", "Enter description")}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort">{t("sort", "Sort Order")}</Label>
|
||||||
|
<Input
|
||||||
|
id="sort"
|
||||||
|
type="number"
|
||||||
|
value={values.sort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, sort: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="space-y-0.5">
|
||||||
|
<Label htmlFor="for_calculation">
|
||||||
|
{t("forCalculation", "For Calculation")}
|
||||||
|
</Label>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("forCalculationDescription", "Whether this node group participates in grouping calculation")}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Switch
|
||||||
|
id="for_calculation"
|
||||||
|
checked={values.for_calculation}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setValues({ ...values, for_calculation: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<Label>{t("trafficRangeGB", "Traffic Range (GB)")}</Label>
|
||||||
|
</div>
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
{t("trafficRangeDescription", "Users with traffic >= Min and < Max will be assigned to this node group")}
|
||||||
|
</p>
|
||||||
|
<div className="grid grid-cols-2 gap-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="min_traffic_gb">{t("minTrafficGB", "Min Traffic (GB)")}</Label>
|
||||||
|
<Input
|
||||||
|
id="min_traffic_gb"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={values.min_traffic_gb}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = parseFloat(e.target.value) || 0;
|
||||||
|
setValues({ ...values, min_traffic_gb: newValue });
|
||||||
|
// 实时检测冲突
|
||||||
|
const conflict = checkTrafficRangeConflict(newValue, values.max_traffic_gb);
|
||||||
|
setConflictError(conflict);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="max_traffic_gb">{t("maxTrafficGB", "Max Traffic (GB)")}</Label>
|
||||||
|
<Input
|
||||||
|
id="max_traffic_gb"
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
value={values.max_traffic_gb}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = parseFloat(e.target.value) || 0;
|
||||||
|
setValues({ ...values, max_traffic_gb: newValue });
|
||||||
|
// 实时检测冲突
|
||||||
|
const conflict = checkTrafficRangeConflict(values.min_traffic_gb, newValue);
|
||||||
|
setConflictError(conflict);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* 显示冲突错误 */}
|
||||||
|
{conflictError && (
|
||||||
|
<div className="flex items-center gap-2 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
|
||||||
|
<AlertCircle className="h-4 w-4 flex-shrink-0" />
|
||||||
|
<span>{conflictError}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-md border px-4 py-2 text-sm"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || loading || !!conflictError}
|
||||||
|
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t("save", "Save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
NodeGroupForm.displayName = "NodeGroupForm";
|
||||||
|
|
||||||
|
export default NodeGroupForm;
|
||||||
224
apps/admin/src/sections/group/node-groups.tsx
Normal file
224
apps/admin/src/sections/group/node-groups.tsx
Normal file
@ -0,0 +1,224 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
createNodeGroup,
|
||||||
|
deleteNodeGroup,
|
||||||
|
getNodeGroupList,
|
||||||
|
updateNodeGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import NodeGroupForm from "./node-group-form";
|
||||||
|
|
||||||
|
export default function NodeGroups() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [allNodeGroups, setAllNodeGroups] = useState<API.NodeGroup[]>([]);
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
|
// 获取所有节点组数据(用于冲突检测)
|
||||||
|
useEffect(() => {
|
||||||
|
const fetchAllNodeGroups = async () => {
|
||||||
|
try {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to fetch node groups:", error);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
fetchAllNodeGroups();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("nodeGroups", "Node Groups")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("nodeGroupsDescription", "Manage node groups for user access control")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProTable<API.NodeGroup, API.GetNodeGroupListRequest>
|
||||||
|
action={ref}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getNodeGroupList({
|
||||||
|
page: params.page || 1,
|
||||||
|
size: params.size || 10,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "id",
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("id", "ID"),
|
||||||
|
cell: ({ row }: { row: any }) => <span className="text-muted-foreground">#{row.getValue("id")}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("name", "Name"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "description",
|
||||||
|
accessorKey: "description",
|
||||||
|
header: t("description", "Description"),
|
||||||
|
cell: ({ row }: { row: any }) => row.getValue("description") || "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "for_calculation",
|
||||||
|
accessorKey: "for_calculation",
|
||||||
|
header: t("forCalculation", "For Calculation"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const value = row.getValue("for_calculation");
|
||||||
|
return value ? (
|
||||||
|
<Badge variant="default">{t("yes", "Yes")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{t("no", "No")}</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "traffic_range",
|
||||||
|
accessorKey: "traffic_range",
|
||||||
|
header: t("trafficRange", "Traffic Range (GB)"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const min = row.original.min_traffic_gb;
|
||||||
|
const max = row.original.max_traffic_gb;
|
||||||
|
if (min !== undefined && max !== undefined) {
|
||||||
|
return `${min} - ${max}`;
|
||||||
|
}
|
||||||
|
if (min !== undefined) {
|
||||||
|
return `≥ ${min}`;
|
||||||
|
}
|
||||||
|
if (max !== undefined) {
|
||||||
|
return `≤ ${max}`;
|
||||||
|
}
|
||||||
|
return "--";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sort",
|
||||||
|
accessorKey: "sort",
|
||||||
|
header: t("sort", "Sort"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
actions={{
|
||||||
|
render: (row: any) => [
|
||||||
|
<NodeGroupForm
|
||||||
|
key={`edit-${row.id}`}
|
||||||
|
initialValues={row}
|
||||||
|
allNodeGroups={allNodeGroups}
|
||||||
|
currentGroupId={row.id}
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateNodeGroup({
|
||||||
|
id: row.id,
|
||||||
|
...values,
|
||||||
|
} as API.UpdateNodeGroupRequest);
|
||||||
|
toast.success(t("updated", "Updated successfully"));
|
||||||
|
// 刷新节点组列表
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("editNodeGroup", "Edit Node Group")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{t("edit", "Edit")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
<ConfirmButton
|
||||||
|
key="delete"
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteNodeGroupConfirm",
|
||||||
|
"This will delete the node group. Nodes in this group will be reassigned."
|
||||||
|
)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteNodeGroup({ id: row.id });
|
||||||
|
toast.success(t("deleted", "Deleted successfully"));
|
||||||
|
// 刷新节点组列表
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Confirm Delete")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
{t("delete", "Delete")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
header={{
|
||||||
|
title: t("nodeGroups", "Node Groups"),
|
||||||
|
toolbar: (
|
||||||
|
<NodeGroupForm
|
||||||
|
key="create"
|
||||||
|
initialValues={undefined}
|
||||||
|
allNodeGroups={allNodeGroups}
|
||||||
|
currentGroupId={undefined}
|
||||||
|
loading={loading}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createNodeGroup(values as API.CreateNodeGroupRequest);
|
||||||
|
toast.success(t("created", "Created successfully"));
|
||||||
|
// 刷新节点组列表
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
setAllNodeGroups(data.data?.list || []);
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("createNodeGroup", "Create Node Group")}
|
||||||
|
trigger={
|
||||||
|
<Button>
|
||||||
|
{t("create", "Create")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
260
apps/admin/src/sections/group/subscribe-mode-tab.tsx
Normal file
260
apps/admin/src/sections/group/subscribe-mode-tab.tsx
Normal file
@ -0,0 +1,260 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import {
|
||||||
|
Table,
|
||||||
|
TableBody,
|
||||||
|
TableCell,
|
||||||
|
TableRow,
|
||||||
|
} from "@workspace/ui/components/table";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
getSubscribeGroupMapping,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
|
||||||
|
interface SubscribeGroupMapping {
|
||||||
|
subscribe_name: string;
|
||||||
|
node_group_name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function SubscribeModeTab() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Fetch subscribe group mapping
|
||||||
|
const { data: mappingData, isLoading: mappingLoading } = useQuery({
|
||||||
|
queryKey: ["subscribeGroupMapping"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getSubscribeGroupMapping();
|
||||||
|
return (data.data?.list || []) as SubscribeGroupMapping[];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadStatus();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
if (status?.state === "running") {
|
||||||
|
loadStatus();
|
||||||
|
}
|
||||||
|
}, 2000);
|
||||||
|
return () => clearInterval(interval);
|
||||||
|
}, [status?.state]);
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalculating(true);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode: "subscribe" });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Configuration Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("subscribeModeConfig", "Subscribe Mode Configuration")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("subscribeModeDescription", "Group users by their purchased subscription plan")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Subscribe Group Mapping Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("subscribeGroupMappingTitle", "套餐-节点组对应关系")}</CardTitle>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
{mappingLoading ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Table>
|
||||||
|
<TableBody>
|
||||||
|
{mappingData && mappingData.length > 0 ? (
|
||||||
|
mappingData
|
||||||
|
.filter(
|
||||||
|
(item: SubscribeGroupMapping) => item.subscribe_name && item.node_group_name
|
||||||
|
)
|
||||||
|
.map((item: SubscribeGroupMapping, index: number) => (
|
||||||
|
<TableRow key={index}>
|
||||||
|
<TableCell>
|
||||||
|
<span className="font-medium">{item.subscribe_name}</span>
|
||||||
|
<span className="mx-2 text-muted-foreground">
|
||||||
|
{t("arrow", "→")}
|
||||||
|
</span>
|
||||||
|
<Badge variant="outline">{item.node_group_name}</Badge>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
))
|
||||||
|
) : (
|
||||||
|
<TableRow>
|
||||||
|
<TableCell className="text-center text-muted-foreground">
|
||||||
|
{t("noMappingData", "No mapping data available")}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recalculation Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("recalculationCompleted", "Recalculation completed successfully")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{t("recalculationFailed", "Recalculation failed. Please try again.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleRecalculate}
|
||||||
|
disabled={recalculating || status?.state === "running"}
|
||||||
|
>
|
||||||
|
{recalculating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculateAll", "Recalculate All Users")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
228
apps/admin/src/sections/group/traffic-mode-tab.tsx
Normal file
228
apps/admin/src/sections/group/traffic-mode-tab.tsx
Normal file
@ -0,0 +1,228 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import {
|
||||||
|
getNodeGroupList,
|
||||||
|
updateNodeGroup,
|
||||||
|
getRecalculationStatus,
|
||||||
|
recalculateGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import TrafficRangeConfig from "./traffic-ranges-config";
|
||||||
|
|
||||||
|
export default function TrafficModeTab() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [recalculating, setRecalculating] = useState(false);
|
||||||
|
const [loadingStatus, setLoadingStatus] = useState(false);
|
||||||
|
|
||||||
|
const [status, setStatus] = useState<{
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
} | null>(null);
|
||||||
|
|
||||||
|
// Fetch node groups
|
||||||
|
const { data: nodeGroupsData, isLoading: isLoadingNodeGroups, refetch: refetchNodeGroups } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const loadStatus = async () => {
|
||||||
|
setLoadingStatus(true);
|
||||||
|
try {
|
||||||
|
const { data } = await getRecalculationStatus();
|
||||||
|
if (data.data) {
|
||||||
|
setStatus(data.data);
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to load recalculation status:", error);
|
||||||
|
} finally {
|
||||||
|
setLoadingStatus(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrafficUpdate = async (nodeGroupId: number, fields: { min_traffic_gb?: number; max_traffic_gb?: number }) => {
|
||||||
|
try {
|
||||||
|
await updateNodeGroup({
|
||||||
|
id: nodeGroupId,
|
||||||
|
...fields,
|
||||||
|
});
|
||||||
|
toast.success(t("configSaved", "Configuration saved successfully"));
|
||||||
|
// Refetch node groups to get updated data
|
||||||
|
refetchNodeGroups();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to update node group:", error);
|
||||||
|
toast.error(t("saveFailed", "Failed to save configuration"));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleRecalculate = async () => {
|
||||||
|
setRecalculating(true);
|
||||||
|
try {
|
||||||
|
await recalculateGroup({ mode: "traffic" });
|
||||||
|
toast.success(t("recalculationStarted", "Recalculation started"));
|
||||||
|
loadStatus();
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to start recalculation:", error);
|
||||||
|
toast.error(t("recalculationFailed", "Failed to start recalculation"));
|
||||||
|
} finally {
|
||||||
|
setRecalculating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateLabel = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return t("running", "Running");
|
||||||
|
case "completed":
|
||||||
|
return t("completed", "Completed");
|
||||||
|
case "failed":
|
||||||
|
return t("failed", "Failed");
|
||||||
|
default:
|
||||||
|
return t("idle", "Idle");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const getStateVariant = (state: string) => {
|
||||||
|
switch (state) {
|
||||||
|
case "running":
|
||||||
|
return "default";
|
||||||
|
case "completed":
|
||||||
|
return "secondary";
|
||||||
|
case "failed":
|
||||||
|
return "destructive";
|
||||||
|
default:
|
||||||
|
return "outline";
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{/* Node Groups Traffic Configuration Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("trafficModeConfig", "Traffic Mode Configuration")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"trafficModeDescription",
|
||||||
|
"Configure traffic ranges for node groups. Users will be assigned to node groups based on their traffic usage."
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{isLoadingNodeGroups ? (
|
||||||
|
<div className="flex items-center justify-center py-8">
|
||||||
|
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||||
|
<span className="ml-2 text-sm text-muted-foreground">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<TrafficRangeConfig
|
||||||
|
nodeGroups={nodeGroupsData || []}
|
||||||
|
onTrafficUpdate={handleTrafficUpdate}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
{/* Recalculation Card */}
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("groupRecalculation", "Group Recalculation")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t(
|
||||||
|
"groupRecalculationDescription",
|
||||||
|
"Manually trigger a full recalculation of all user groups based on current configuration"
|
||||||
|
)}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent className="space-y-4">
|
||||||
|
{/* Current Status */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<span className="text-sm font-medium">
|
||||||
|
{t("currentStatus", "Current Status")}
|
||||||
|
</span>
|
||||||
|
{loadingStatus ? (
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
) : status ? (
|
||||||
|
<Badge variant={getStateVariant(status.state) as any}>
|
||||||
|
{getStateLabel(status.state)}
|
||||||
|
</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{status?.state === "running" && (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="flex items-center justify-between text-sm">
|
||||||
|
<span>{t("progress", "Progress")}</span>
|
||||||
|
<span>
|
||||||
|
{status.progress} / {status.total || 0}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="h-2 overflow-hidden rounded-full bg-muted">
|
||||||
|
<div
|
||||||
|
className="h-full bg-primary transition-all"
|
||||||
|
style={{
|
||||||
|
width: `${status.total > 0 ? (status.progress / status.total) * 100 : 0}%`,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "completed" && (
|
||||||
|
<div className="text-sm text-muted-foreground">
|
||||||
|
{t("recalculationCompleted", "Recalculation completed successfully")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{status?.state === "failed" && (
|
||||||
|
<div className="text-sm text-destructive">
|
||||||
|
{t("recalculationFailed", "Recalculation failed. Please try again.")}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Recalculate Button */}
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<Button
|
||||||
|
onClick={handleRecalculate}
|
||||||
|
disabled={recalculating || status?.state === "running"}
|
||||||
|
>
|
||||||
|
{recalculating && (
|
||||||
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||||
|
)}
|
||||||
|
{t("recalculateAll", "Recalculate All Users")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning */}
|
||||||
|
<div className="rounded-md bg-yellow-50 p-4 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<strong>{t("warning", "Warning")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"recalculationWarning",
|
||||||
|
"Recalculation will reassign all users to new groups based on current configuration. This operation cannot be undone."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
247
apps/admin/src/sections/group/traffic-ranges-config.tsx
Normal file
247
apps/admin/src/sections/group/traffic-ranges-config.tsx
Normal file
@ -0,0 +1,247 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
|
||||||
|
interface NodeGroup {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface TrafficRangeConfigProps {
|
||||||
|
nodeGroups: NodeGroup[];
|
||||||
|
onTrafficUpdate: (nodeGroupId: number, fields: { min_traffic_gb?: number; max_traffic_gb?: number }) => Promise<void>;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface UpdatingNode {
|
||||||
|
nodeGroupId: number;
|
||||||
|
field: 'min_traffic_gb' | 'max_traffic_gb';
|
||||||
|
}
|
||||||
|
|
||||||
|
interface NodeGroupTempValues {
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TrafficRangeConfig({ nodeGroups, onTrafficUpdate }: TrafficRangeConfigProps) {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [updatingNodes, setUpdatingNodes] = useState<UpdatingNode[]>([]);
|
||||||
|
// 使用对象存储每个节点组的临时值
|
||||||
|
const [temporaryValues, setTemporaryValues] = useState<Record<number, NodeGroupTempValues>>({});
|
||||||
|
|
||||||
|
// Get the display value (temporary or actual)
|
||||||
|
const getDisplayValue = (nodeGroupId: number, field: 'min_traffic_gb' | 'max_traffic_gb'): number => {
|
||||||
|
const temp = temporaryValues[nodeGroupId];
|
||||||
|
if (temp && temp[field] !== undefined) {
|
||||||
|
return temp[field]!;
|
||||||
|
}
|
||||||
|
const nodeGroup = nodeGroups.find(ng => ng.id === nodeGroupId);
|
||||||
|
return field === 'min_traffic_gb' ? (nodeGroup?.min_traffic_gb ?? 0) : (nodeGroup?.max_traffic_gb ?? 0);
|
||||||
|
};
|
||||||
|
|
||||||
|
// Validate traffic ranges: no overlaps
|
||||||
|
const validateTrafficRange = (
|
||||||
|
nodeGroupId: number,
|
||||||
|
minTraffic: number,
|
||||||
|
maxTraffic: number
|
||||||
|
): { valid: boolean; error?: string } => {
|
||||||
|
// 如果 min=0 且 max=0,表示不参与流量分组,跳过验证
|
||||||
|
if (minTraffic === 0 && maxTraffic === 0) {
|
||||||
|
return { valid: true };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if min >= max (both > 0)
|
||||||
|
if (minTraffic > 0 && maxTraffic > 0 && minTraffic >= maxTraffic) {
|
||||||
|
return { valid: false, error: t("minCannotExceedMax", "Minimum traffic cannot exceed maximum traffic") };
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check for overlaps with other node groups
|
||||||
|
const otherGroups = nodeGroups
|
||||||
|
.filter(ng => ng.id !== nodeGroupId)
|
||||||
|
.map(ng => {
|
||||||
|
const temp = temporaryValues[ng.id];
|
||||||
|
return {
|
||||||
|
id: ng.id,
|
||||||
|
name: ng.name,
|
||||||
|
min: temp?.min_traffic_gb !== undefined ? temp.min_traffic_gb : (ng.min_traffic_gb ?? 0),
|
||||||
|
max: temp?.max_traffic_gb !== undefined ? temp.max_traffic_gb : (ng.max_traffic_gb ?? 0),
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter(ng => !(ng.min === 0 && ng.max === 0)) // 跳过未配置流量区间的组
|
||||||
|
.sort((a, b) => a.min - b.min);
|
||||||
|
|
||||||
|
for (const other of otherGroups) {
|
||||||
|
// Handle max=0 as no limit (infinity)
|
||||||
|
const otherMax = other.max === 0 ? Number.MAX_VALUE : other.max;
|
||||||
|
const currentMax = maxTraffic === 0 ? Number.MAX_VALUE : maxTraffic;
|
||||||
|
|
||||||
|
// Check for overlap: two ranges [min1, max1] and [min2, max2] overlap if:
|
||||||
|
// max1 > min2 && max2 > min1
|
||||||
|
if (currentMax > other.min && otherMax > minTraffic) {
|
||||||
|
return {
|
||||||
|
valid: false,
|
||||||
|
error: t("rangeOverlap", "Range overlaps with node group \"{{name}}\"", { name: other.name })
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return { valid: true };
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleTrafficBlur = async (nodeGroupId: number) => {
|
||||||
|
const nodeGroup = nodeGroups.find(ng => ng.id === nodeGroupId);
|
||||||
|
if (!nodeGroup) return;
|
||||||
|
|
||||||
|
const tempValues = temporaryValues[nodeGroupId];
|
||||||
|
if (!tempValues) return;
|
||||||
|
|
||||||
|
// 获取当前的临时值或实际值
|
||||||
|
const currentMin = tempValues.min_traffic_gb !== undefined
|
||||||
|
? tempValues.min_traffic_gb
|
||||||
|
: (nodeGroup.min_traffic_gb ?? 0);
|
||||||
|
const currentMax = tempValues.max_traffic_gb !== undefined
|
||||||
|
? tempValues.max_traffic_gb
|
||||||
|
: (nodeGroup.max_traffic_gb ?? 0);
|
||||||
|
|
||||||
|
// 只要有一个字段被修改了就保存
|
||||||
|
const hasMinChange = tempValues.min_traffic_gb !== undefined;
|
||||||
|
const hasMaxChange = tempValues.max_traffic_gb !== undefined;
|
||||||
|
if (!hasMinChange && !hasMaxChange) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 验证
|
||||||
|
const validation = validateTrafficRange(nodeGroupId, currentMin, currentMax);
|
||||||
|
if (!validation.valid) {
|
||||||
|
toast.error(validation.error || t("validationFailed", "Validation failed"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 检查值是否真的改变了
|
||||||
|
const originalMin = nodeGroup.min_traffic_gb ?? 0;
|
||||||
|
const originalMax = nodeGroup.max_traffic_gb ?? 0;
|
||||||
|
if (currentMin === originalMin && currentMax === originalMax) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 标记为更新中(只标记被修改的字段)
|
||||||
|
if (hasMinChange) {
|
||||||
|
setUpdatingNodes(prev => [...prev, { nodeGroupId, field: 'min_traffic_gb' }]);
|
||||||
|
}
|
||||||
|
if (hasMaxChange) {
|
||||||
|
setUpdatingNodes(prev => [...prev, { nodeGroupId, field: 'max_traffic_gb' }]);
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
// 一次性传递两个字段
|
||||||
|
const fieldsToUpdate: { min_traffic_gb?: number; max_traffic_gb?: number } = {};
|
||||||
|
if (currentMin !== originalMin) {
|
||||||
|
fieldsToUpdate.min_traffic_gb = currentMin;
|
||||||
|
}
|
||||||
|
if (currentMax !== originalMax) {
|
||||||
|
fieldsToUpdate.max_traffic_gb = currentMax;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Object.keys(fieldsToUpdate).length > 0) {
|
||||||
|
await onTrafficUpdate(nodeGroupId, fieldsToUpdate);
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
// 移除更新状态
|
||||||
|
setUpdatingNodes(prev => prev.filter(u => !(u.nodeGroupId === nodeGroupId)));
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const isUpdating = (nodeGroupId: number) => {
|
||||||
|
return updatingNodes.some(u => u.nodeGroupId === nodeGroupId);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<div className="space-y-2">
|
||||||
|
<div className="grid grid-cols-12 gap-2 text-sm font-medium text-muted-foreground">
|
||||||
|
<div className="col-span-6">{t("nodeGroup", "Node Group")}</div>
|
||||||
|
<div className="col-span-3">{t("minTrafficGB", "Min (GB)")}</div>
|
||||||
|
<div className="col-span-3">{t("maxTrafficGB", "Max (GB)")}</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{nodeGroups.map((nodeGroup) => (
|
||||||
|
<div key={nodeGroup.id} className="grid grid-cols-12 gap-2 items-center">
|
||||||
|
<div className="col-span-6">
|
||||||
|
<div className="font-medium">{nodeGroup.name}</div>
|
||||||
|
<div className="text-xs text-muted-foreground">{t("id", "ID")}: {nodeGroup.id}</div>
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 relative">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder="0"
|
||||||
|
value={getDisplayValue(nodeGroup.id, "min_traffic_gb")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = parseFloat(e.target.value) || 0;
|
||||||
|
// 更新临时状态
|
||||||
|
setTemporaryValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[nodeGroup.id]: {
|
||||||
|
...prev[nodeGroup.id],
|
||||||
|
min_traffic_gb: newValue,
|
||||||
|
max_traffic_gb: prev[nodeGroup.id]?.max_traffic_gb,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||||
|
disabled={isUpdating(nodeGroup.id)}
|
||||||
|
/>
|
||||||
|
{isUpdating(nodeGroup.id) && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="col-span-3 relative">
|
||||||
|
<Input
|
||||||
|
type="number"
|
||||||
|
min={0}
|
||||||
|
step={1}
|
||||||
|
placeholder="0"
|
||||||
|
value={getDisplayValue(nodeGroup.id, "max_traffic_gb")}
|
||||||
|
onChange={(e) => {
|
||||||
|
const newValue = parseFloat(e.target.value) || 0;
|
||||||
|
// 更新临时状态
|
||||||
|
setTemporaryValues(prev => ({
|
||||||
|
...prev,
|
||||||
|
[nodeGroup.id]: {
|
||||||
|
...prev[nodeGroup.id],
|
||||||
|
min_traffic_gb: prev[nodeGroup.id]?.min_traffic_gb,
|
||||||
|
max_traffic_gb: newValue,
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
onBlur={() => handleTrafficBlur(nodeGroup.id)}
|
||||||
|
disabled={isUpdating(nodeGroup.id)}
|
||||||
|
/>
|
||||||
|
{isUpdating(nodeGroup.id) && (
|
||||||
|
<div className="absolute right-2 top-1/2 -translate-y-1/2">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md bg-muted p-4 text-sm text-muted-foreground">
|
||||||
|
<strong>{t("note", "Note")}:</strong>{" "}
|
||||||
|
{t(
|
||||||
|
"trafficRangesNote",
|
||||||
|
"Set traffic ranges for each node group. Users will be assigned to node groups based on their traffic usage. Leave both values as 0 to not use this node group for traffic-based assignment."
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
210
apps/admin/src/sections/group/user-group-form.tsx
Normal file
210
apps/admin/src/sections/group/user-group-form.tsx
Normal file
@ -0,0 +1,210 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import { Input } from "@workspace/ui/components/input";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import { Textarea } from "@workspace/ui/components/textarea";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@workspace/ui/components/select";
|
||||||
|
import { Switch } from "@workspace/ui/components/switch";
|
||||||
|
import { Loader2 } from "lucide-react";
|
||||||
|
import { forwardRef, useEffect, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
interface UserGroupFormProps {
|
||||||
|
initialValues?: Partial<API.UserGroup>;
|
||||||
|
loading?: boolean;
|
||||||
|
nodeGroups?: API.NodeGroup[];
|
||||||
|
onSubmit: (values: Record<string, unknown>) => Promise<boolean>;
|
||||||
|
title: string;
|
||||||
|
trigger: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const UserGroupForm = forwardRef<
|
||||||
|
HTMLButtonElement,
|
||||||
|
UserGroupFormProps
|
||||||
|
>(({ initialValues, loading, nodeGroups = [], onSubmit, title, trigger }, ref) => {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [values, setValues] = useState({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
node_group_id: null as number | null,
|
||||||
|
for_calculation: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (open) {
|
||||||
|
if (initialValues) {
|
||||||
|
setValues({
|
||||||
|
name: initialValues.name || "",
|
||||||
|
description: initialValues.description || "",
|
||||||
|
sort: initialValues.sort ?? 0,
|
||||||
|
node_group_id: initialValues.node_group_id || null,
|
||||||
|
for_calculation: initialValues.for_calculation ?? true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
setValues({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
node_group_id: null,
|
||||||
|
for_calculation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [initialValues, open]);
|
||||||
|
|
||||||
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
|
e.preventDefault();
|
||||||
|
setSubmitting(true);
|
||||||
|
const success = await onSubmit(values);
|
||||||
|
setSubmitting(false);
|
||||||
|
if (success) {
|
||||||
|
setOpen(false);
|
||||||
|
setValues({
|
||||||
|
name: "",
|
||||||
|
description: "",
|
||||||
|
sort: 0,
|
||||||
|
node_group_id: null,
|
||||||
|
for_calculation: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={setOpen}>
|
||||||
|
<DialogTrigger asChild ref={ref}>
|
||||||
|
{trigger}
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="sm:max-w-[500px]">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{title}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t("userGroupFormDescription", "Configure user group settings")}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
<form onSubmit={handleSubmit} className="space-y-4">
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="name">
|
||||||
|
{t("name", "Name")} *
|
||||||
|
</Label>
|
||||||
|
<Input
|
||||||
|
id="name"
|
||||||
|
value={values.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, name: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("namePlaceholder", "Enter name")}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between space-x-2">
|
||||||
|
<Label htmlFor="for_calculation">{t("forCalculation", "For Calculation")}</Label>
|
||||||
|
<Switch
|
||||||
|
id="for_calculation"
|
||||||
|
checked={values.for_calculation}
|
||||||
|
onCheckedChange={(checked) =>
|
||||||
|
setValues({ ...values, for_calculation: checked })
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="description">
|
||||||
|
{t("description", "Description")}
|
||||||
|
</Label>
|
||||||
|
<Textarea
|
||||||
|
id="description"
|
||||||
|
value={values.description}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, description: e.target.value })
|
||||||
|
}
|
||||||
|
placeholder={t("descriptionPlaceholder", "Enter description")}
|
||||||
|
rows={3}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="node_group_id">{t("nodeGroup", "Node Group")}</Label>
|
||||||
|
<Select
|
||||||
|
value={values.node_group_id ? String(values.node_group_id) : "0"}
|
||||||
|
onValueChange={(val) =>
|
||||||
|
setValues({
|
||||||
|
...values,
|
||||||
|
node_group_id: val === "0" ? null : parseInt(val),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="node_group_id" className="w-full">
|
||||||
|
<SelectValue placeholder={t("selectNodeGroupPlaceholder", "Select a node group...")} />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
<SelectItem value="0">
|
||||||
|
{t("unbound", "Unbound")}
|
||||||
|
</SelectItem>
|
||||||
|
{nodeGroups.map((nodeGroup) => (
|
||||||
|
<SelectItem key={nodeGroup.id} value={String(nodeGroup.id)}>
|
||||||
|
{nodeGroup.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="sort">{t("sort", "Sort Order")}</Label>
|
||||||
|
<Input
|
||||||
|
id="sort"
|
||||||
|
type="number"
|
||||||
|
value={values.sort}
|
||||||
|
onChange={(e) =>
|
||||||
|
setValues({ ...values, sort: parseInt(e.target.value) || 0 })
|
||||||
|
}
|
||||||
|
min={0}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-md border px-4 py-2 text-sm"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={submitting || loading}
|
||||||
|
className="flex items-center gap-2 rounded-md bg-primary px-4 py-2 text-sm text-primary-foreground disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{submitting && <Loader2 className="h-4 w-4 animate-spin" />}
|
||||||
|
{t("save", "Save")}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
UserGroupForm.displayName = "UserGroupForm";
|
||||||
|
|
||||||
|
export default UserGroupForm;
|
||||||
200
apps/admin/src/sections/group/user-groups.tsx
Normal file
200
apps/admin/src/sections/group/user-groups.tsx
Normal file
@ -0,0 +1,200 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import {
|
||||||
|
Card,
|
||||||
|
CardContent,
|
||||||
|
CardDescription,
|
||||||
|
CardHeader,
|
||||||
|
CardTitle,
|
||||||
|
} from "@workspace/ui/components/card";
|
||||||
|
import { ConfirmButton } from "@workspace/ui/composed/confirm-button";
|
||||||
|
import {
|
||||||
|
ProTable,
|
||||||
|
type ProTableActions,
|
||||||
|
} from "@workspace/ui/composed/pro-table/pro-table";
|
||||||
|
import {
|
||||||
|
createUserGroup,
|
||||||
|
deleteUserGroup,
|
||||||
|
getUserGroupList,
|
||||||
|
getNodeGroupList,
|
||||||
|
updateUserGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
import { useRef, useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import UserGroupForm from "./user-group-form";
|
||||||
|
|
||||||
|
export default function UserGroups() {
|
||||||
|
const { t } = useTranslation("group");
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const ref = useRef<ProTableActions>(null);
|
||||||
|
|
||||||
|
const { data: nodeGroupsData } = useQuery({
|
||||||
|
queryKey: ["nodeGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getNodeGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card>
|
||||||
|
<CardHeader>
|
||||||
|
<CardTitle>{t("userGroups", "User Groups")}</CardTitle>
|
||||||
|
<CardDescription>
|
||||||
|
{t("userGroupsDescription", "Manage user groups for node access control")}
|
||||||
|
</CardDescription>
|
||||||
|
</CardHeader>
|
||||||
|
<CardContent>
|
||||||
|
<ProTable<API.UserGroup, API.GetUserGroupListRequest>
|
||||||
|
action={ref}
|
||||||
|
request={async (params) => {
|
||||||
|
const { data } = await getUserGroupList({
|
||||||
|
page: params.page || 1,
|
||||||
|
size: params.size || 10,
|
||||||
|
});
|
||||||
|
return {
|
||||||
|
list: data.data?.list || [],
|
||||||
|
total: data.data?.total || 0,
|
||||||
|
};
|
||||||
|
}}
|
||||||
|
columns={[
|
||||||
|
{
|
||||||
|
id: "id",
|
||||||
|
accessorKey: "id",
|
||||||
|
header: t("id", "ID"),
|
||||||
|
cell: ({ row }: { row: any }) => <span className="text-muted-foreground">#{row.getValue("id")}</span>,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("name", "Name"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "for_calculation",
|
||||||
|
accessorKey: "for_calculation",
|
||||||
|
header: t("forCalculation", "For Calculation"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const forCalculation = row.getValue("for_calculation");
|
||||||
|
return forCalculation ? (
|
||||||
|
<Badge variant="default">{t("yes", "Yes")}</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="secondary">{t("no", "No")}</Badge>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "description",
|
||||||
|
accessorKey: "description",
|
||||||
|
header: t("description", "Description"),
|
||||||
|
cell: ({ row }: { row: any }) => row.getValue("description") || "--",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "node_group_id",
|
||||||
|
accessorKey: "node_group_id",
|
||||||
|
header: t("nodeGroup", "Node Group"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const nodeGroupId = row.getValue("node_group_id");
|
||||||
|
const group = nodeGroupsData?.find((g) => g.id === nodeGroupId);
|
||||||
|
return group?.name || "--";
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "sort",
|
||||||
|
accessorKey: "sort",
|
||||||
|
header: t("sort", "Sort"),
|
||||||
|
},
|
||||||
|
]}
|
||||||
|
actions={{
|
||||||
|
render: (row: any) => [
|
||||||
|
<UserGroupForm
|
||||||
|
key={`edit-${row.id}`}
|
||||||
|
initialValues={row}
|
||||||
|
loading={loading}
|
||||||
|
nodeGroups={nodeGroupsData || []}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await updateUserGroup({
|
||||||
|
id: row.id,
|
||||||
|
...values,
|
||||||
|
} as unknown as API.UpdateUserGroupRequest);
|
||||||
|
toast.success(t("updated", "Updated successfully"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("editUserGroup", "Edit User Group")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="outline" size="sm">
|
||||||
|
{t("edit", "Edit")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
<ConfirmButton
|
||||||
|
key="delete"
|
||||||
|
cancelText={t("cancel", "Cancel")}
|
||||||
|
confirmText={t("confirm", "Confirm")}
|
||||||
|
description={t(
|
||||||
|
"deleteUserGroupConfirm",
|
||||||
|
"This will delete the user group. Users in this group will be reassigned to the default group."
|
||||||
|
)}
|
||||||
|
onConfirm={async () => {
|
||||||
|
await deleteUserGroup({ id: row.id });
|
||||||
|
toast.success(t("deleted", "Deleted successfully"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
}}
|
||||||
|
title={t("confirmDelete", "Confirm Delete")}
|
||||||
|
trigger={
|
||||||
|
<Button variant="destructive" size="sm">
|
||||||
|
{t("delete", "Delete")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>,
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
header={{
|
||||||
|
title: t("userGroups", "User Groups"),
|
||||||
|
toolbar: (
|
||||||
|
<UserGroupForm
|
||||||
|
key="create"
|
||||||
|
initialValues={undefined}
|
||||||
|
loading={loading}
|
||||||
|
nodeGroups={nodeGroupsData || []}
|
||||||
|
onSubmit={async (values) => {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
await createUserGroup(values as API.CreateUserGroupRequest);
|
||||||
|
toast.success(t("created", "Created successfully"));
|
||||||
|
ref.current?.refresh();
|
||||||
|
setLoading(false);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
setLoading(false);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
title={t("createUserGroup", "Create User Group")}
|
||||||
|
trigger={
|
||||||
|
<Button>
|
||||||
|
{t("create", "Create")}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -25,14 +25,12 @@ export default function ResetSubscribeLogPage() {
|
|||||||
|
|
||||||
const initialFilters = {
|
const initialFilters = {
|
||||||
date: sp.date || today,
|
date: sp.date || today,
|
||||||
user_subscribe_id: sp.user_subscribe_id
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
? Number(sp.user_subscribe_id)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ProTable<
|
<ProTable<
|
||||||
API.ResetSubscribeLog,
|
API.ResetSubscribeLog,
|
||||||
{ date?: string; user_subscribe_id?: number }
|
{ date?: string; user_subscribe_id?: string }
|
||||||
>
|
>
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
@ -83,7 +81,7 @@ export default function ResetSubscribeLogPage() {
|
|||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
date: (filter as any)?.date,
|
date: (filter as any)?.date,
|
||||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
user_subscribe_id: (filter as any)?.user_subscribe_id ? Number((filter as any)?.user_subscribe_id) : undefined,
|
||||||
});
|
});
|
||||||
const list = (data?.data?.list || []) as any[];
|
const list = (data?.data?.list || []) as any[];
|
||||||
const total = Number(data?.data?.total || list.length);
|
const total = Number(data?.data?.total || list.length);
|
||||||
|
|||||||
@ -17,14 +17,12 @@ export default function SubscribeTrafficLogPage() {
|
|||||||
const initialFilters = {
|
const initialFilters = {
|
||||||
date: sp.date || today,
|
date: sp.date || today,
|
||||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||||
user_subscribe_id: sp.user_subscribe_id
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
? Number(sp.user_subscribe_id)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ProTable<
|
<ProTable<
|
||||||
API.UserSubscribeTrafficLog,
|
API.UserSubscribeTrafficLog,
|
||||||
{ date?: string; user_id?: number; user_subscribe_id?: number }
|
{ date?: string; user_id?: number; user_subscribe_id?: string }
|
||||||
>
|
>
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
@ -95,7 +93,7 @@ export default function SubscribeTrafficLogPage() {
|
|||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
date: (filter as any)?.date,
|
date: (filter as any)?.date,
|
||||||
user_id: (filter as any)?.user_id,
|
user_id: (filter as any)?.user_id,
|
||||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
user_subscribe_id: (filter as any)?.user_subscribe_id ? Number((filter as any)?.user_subscribe_id) : undefined,
|
||||||
});
|
});
|
||||||
const list =
|
const list =
|
||||||
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
|
((data?.data?.list || []) as API.UserSubscribeTrafficLog[]) || [];
|
||||||
|
|||||||
@ -23,12 +23,10 @@ export default function SubscribeLogPage() {
|
|||||||
const initialFilters = {
|
const initialFilters = {
|
||||||
date: sp.date || today,
|
date: sp.date || today,
|
||||||
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
user_id: sp.user_id ? Number(sp.user_id) : undefined,
|
||||||
user_subscribe_id: sp.user_subscribe_id
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
? Number(sp.user_subscribe_id)
|
|
||||||
: undefined,
|
|
||||||
};
|
};
|
||||||
return (
|
return (
|
||||||
<ProTable<API.SubscribeLog, { date?: string; user_id?: number }>
|
<ProTable<API.SubscribeLog, { date?: string; user_id?: number; user_subscribe_id?: string }>
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
accessorKey: "user",
|
accessorKey: "user",
|
||||||
@ -96,7 +94,7 @@ export default function SubscribeLogPage() {
|
|||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
date: (filter as any)?.date,
|
date: (filter as any)?.date,
|
||||||
user_id: (filter as any)?.user_id,
|
user_id: (filter as any)?.user_id,
|
||||||
user_subscribe_id: (filter as any)?.user_subscribe_id,
|
user_subscribe_id: (filter as any)?.user_subscribe_id ? Number((filter as any)?.user_subscribe_id) : undefined,
|
||||||
});
|
});
|
||||||
const list = (data?.data?.list || []) as any[];
|
const list = (data?.data?.list || []) as any[];
|
||||||
const total = Number(data?.data?.total || list.length);
|
const total = Number(data?.data?.total || list.length);
|
||||||
|
|||||||
@ -16,7 +16,9 @@ import {
|
|||||||
toggleNodeStatus,
|
toggleNodeStatus,
|
||||||
updateNode,
|
updateNode,
|
||||||
} from "@workspace/ui/services/admin/server";
|
} from "@workspace/ui/services/admin/server";
|
||||||
import { useRef, useState } from "react";
|
import { getGroupConfig, getNodeGroupList } from "@workspace/ui/services/admin/group";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useMemo, useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
import { useNode } from "@/stores/node";
|
import { useNode } from "@/stores/node";
|
||||||
@ -32,13 +34,132 @@ export default function Nodes() {
|
|||||||
const { getServerName, getServerAddress, getProtocolPort } = useServer();
|
const { getServerName, getServerAddress, getProtocolPort } = useServer();
|
||||||
const { fetchNodes, fetchTags } = useNode();
|
const { fetchNodes, fetchTags } = useNode();
|
||||||
|
|
||||||
|
// Fetch node groups for display
|
||||||
|
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;
|
||||||
|
|
||||||
|
// Dynamic columns based on group feature status
|
||||||
|
const columns = useMemo(() => {
|
||||||
|
const baseColumns = [
|
||||||
|
{
|
||||||
|
id: "enabled",
|
||||||
|
header: t("enabled", "Enabled"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<Switch
|
||||||
|
checked={row.original.enabled}
|
||||||
|
onCheckedChange={async (v) => {
|
||||||
|
await toggleNodeStatus({ id: row.original.id, enable: v });
|
||||||
|
toast.success(
|
||||||
|
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
|
||||||
|
);
|
||||||
|
ref.current?.refresh();
|
||||||
|
fetchNodes();
|
||||||
|
fetchTags();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "name",
|
||||||
|
accessorKey: "name",
|
||||||
|
header: t("name", "Name"),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "address_port",
|
||||||
|
header: `${t("address", "Address")}:${t("port", "Port")}`,
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
`${row.original.address || "—"}:${row.original.port || "—"}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "server_id",
|
||||||
|
header: t("server", "Server"),
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "protocol",
|
||||||
|
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
|
||||||
|
cell: ({ row }: { row: any }) =>
|
||||||
|
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "tags",
|
||||||
|
header: t("tags", "Tags"),
|
||||||
|
cell: ({ row }: { row: any }) => (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{(row.original.tags || []).length === 0
|
||||||
|
? "—"
|
||||||
|
: row.original.tags.map((tg: string) => (
|
||||||
|
<Badge key={tg} variant="outline">
|
||||||
|
{tg}
|
||||||
|
</Badge>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// Add Node Groups column when group feature is enabled
|
||||||
|
if (isGroupEnabled) {
|
||||||
|
baseColumns.push({
|
||||||
|
id: "node_group_ids",
|
||||||
|
header: t("nodeGroups", "Node Groups"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const groupIds = row.original.node_group_ids as number[] || [];
|
||||||
|
|
||||||
|
// Public node indicator (when node_group_ids is empty)
|
||||||
|
if (groupIds.length === 0) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Badge variant="secondary" className="text-xs">
|
||||||
|
{t("public", "Public")}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-wrap gap-1">
|
||||||
|
{groupIds.map((groupId) => {
|
||||||
|
const group = nodeGroupsData?.find((g) => g.id === groupId);
|
||||||
|
return (
|
||||||
|
<Badge key={groupId} variant="outline">
|
||||||
|
{group?.name || String(groupId)}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return baseColumns;
|
||||||
|
}, [isGroupEnabled, nodeGroupsData, t, getServerName, getServerAddress, getProtocolPort]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.Node, { search: string }>
|
<ProTable<API.Node, { search: string; node_group_id?: number }>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
<NodeForm
|
<NodeForm
|
||||||
initialValues={row}
|
initialValues={row as any}
|
||||||
key="edit"
|
key="edit"
|
||||||
loading={loading}
|
loading={loading}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
@ -47,6 +168,7 @@ export default function Nodes() {
|
|||||||
const body: API.UpdateNodeRequest = {
|
const body: API.UpdateNodeRequest = {
|
||||||
...row,
|
...row,
|
||||||
...values,
|
...values,
|
||||||
|
node_group_ids: values.node_group_ids?.map((id: string | number) => Number(id)) || [],
|
||||||
} as any;
|
} as any;
|
||||||
await updateNode(body);
|
await updateNode(body);
|
||||||
toast.success(t("updated", "Updated"));
|
toast.success(t("updated", "Updated"));
|
||||||
@ -135,62 +257,7 @@ export default function Nodes() {
|
|||||||
];
|
];
|
||||||
},
|
},
|
||||||
}}
|
}}
|
||||||
columns={[
|
columns={columns}
|
||||||
{
|
|
||||||
id: "enabled",
|
|
||||||
header: t("enabled", "Enabled"),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<Switch
|
|
||||||
checked={row.original.enabled}
|
|
||||||
onCheckedChange={async (v) => {
|
|
||||||
await toggleNodeStatus({ id: row.original.id, enable: v });
|
|
||||||
toast.success(
|
|
||||||
v ? t("enabled_on", "Enabled") : t("enabled_off", "Disabled")
|
|
||||||
);
|
|
||||||
ref.current?.refresh();
|
|
||||||
fetchNodes();
|
|
||||||
fetchTags();
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
{ accessorKey: "name", header: t("name", "Name") },
|
|
||||||
|
|
||||||
{
|
|
||||||
id: "address_port",
|
|
||||||
header: `${t("address", "Address")}:${t("port", "Port")}`,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
`${row.original.address || "—"}:${row.original.port || "—"}`,
|
|
||||||
},
|
|
||||||
|
|
||||||
{
|
|
||||||
id: "server_id",
|
|
||||||
header: t("server", "Server"),
|
|
||||||
cell: ({ row }) =>
|
|
||||||
`${getServerName(row.original.server_id)}:${getServerAddress(row.original.server_id)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "protocol",
|
|
||||||
header: ` ${t("protocol", "Protocol")}:${t("port", "Port")}`,
|
|
||||||
cell: ({ row }) =>
|
|
||||||
`${row.original.protocol}:${getProtocolPort(row.original.server_id, row.original.protocol)}`,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
accessorKey: "tags",
|
|
||||||
header: t("tags", "Tags"),
|
|
||||||
cell: ({ row }) => (
|
|
||||||
<div className="flex flex-wrap gap-1">
|
|
||||||
{(row.original.tags || []).length === 0
|
|
||||||
? "—"
|
|
||||||
: row.original.tags.map((tg) => (
|
|
||||||
<Badge key={tg} variant="outline">
|
|
||||||
{tg}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
),
|
|
||||||
},
|
|
||||||
]}
|
|
||||||
header={{
|
header={{
|
||||||
title: t("pageTitle", "Nodes"),
|
title: t("pageTitle", "Nodes"),
|
||||||
toolbar: (
|
toolbar: (
|
||||||
@ -199,15 +266,18 @@ export default function Nodes() {
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const body: API.CreateNodeRequest = {
|
const body: any = {
|
||||||
name: values.name,
|
name: values.name,
|
||||||
server_id: Number(values.server_id!),
|
server_id: Number(values.server_id!),
|
||||||
protocol: values.protocol,
|
protocol: values.protocol,
|
||||||
address: values.address,
|
address: values.address,
|
||||||
port: Number(values.port!),
|
port: Number(values.port!),
|
||||||
tags: values.tags || [],
|
tags: values.tags || [],
|
||||||
enabled: false,
|
|
||||||
};
|
};
|
||||||
|
// Add node_group_ids if it exists
|
||||||
|
if (values.node_group_ids) {
|
||||||
|
body.node_group_ids = values.node_group_ids.map((id: string | number) => Number(id));
|
||||||
|
}
|
||||||
await createNode(body);
|
await createNode(body);
|
||||||
toast.success(t("created", "Created"));
|
toast.success(t("created", "Created"));
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
@ -259,13 +329,35 @@ export default function Nodes() {
|
|||||||
}
|
}
|
||||||
return updatedItems;
|
return updatedItems;
|
||||||
}}
|
}}
|
||||||
params={[{ key: "search" }]}
|
params={[
|
||||||
|
{
|
||||||
|
key: "search",
|
||||||
|
},
|
||||||
|
...(isGroupEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "node_group_id",
|
||||||
|
placeholder: t("nodeGroups", "Node Groups"),
|
||||||
|
options: [
|
||||||
|
{ label: t("all", "All"), value: "" },
|
||||||
|
...(nodeGroupsData?.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
})) || []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
|
]}
|
||||||
request={async (pagination, filter) => {
|
request={async (pagination, filter) => {
|
||||||
const { data } = await filterNodeList({
|
const filters = {
|
||||||
page: pagination.page,
|
page: pagination.page,
|
||||||
size: pagination.size,
|
size: pagination.size,
|
||||||
search: filter?.search || undefined,
|
search: filter?.search || undefined,
|
||||||
});
|
node_group_id: filter?.node_group_id ? Number(filter.node_group_id) : undefined,
|
||||||
|
};
|
||||||
|
|
||||||
|
const { data } = await filterNodeList(filters);
|
||||||
const list = (data?.data?.list || []) as API.Node[];
|
const list = (data?.data?.list || []) as API.Node[];
|
||||||
const total = Number(data?.data?.total || list.length);
|
const total = Number(data?.data?.total || list.length);
|
||||||
return { list, total };
|
return { list, total };
|
||||||
|
|||||||
@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { zodResolver } from "@hookform/resolvers/zod";
|
import { zodResolver } from "@hookform/resolvers/zod";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
FormControl,
|
FormControl,
|
||||||
@ -11,6 +12,7 @@ import {
|
|||||||
FormLabel,
|
FormLabel,
|
||||||
FormMessage,
|
FormMessage,
|
||||||
} from "@workspace/ui/components/form";
|
} from "@workspace/ui/components/form";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
import { ScrollArea } from "@workspace/ui/components/scroll-area";
|
||||||
import {
|
import {
|
||||||
Sheet,
|
Sheet,
|
||||||
@ -23,6 +25,8 @@ import {
|
|||||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
import { Combobox } from "@workspace/ui/composed/combobox";
|
||||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
import TagInput from "@workspace/ui/composed/tag-input";
|
import TagInput from "@workspace/ui/composed/tag-input";
|
||||||
|
import { getGroupConfig, getNodeGroupList } from "@workspace/ui/services/admin/group";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import type { TFunction } from "i18next";
|
import type { TFunction } from "i18next";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { useForm } from "react-hook-form";
|
import { useForm } from "react-hook-form";
|
||||||
@ -54,7 +58,7 @@ const buildSchema = (t: TFunction) =>
|
|||||||
server_id: z
|
server_id: z
|
||||||
.number({ message: t("errors.serverRequired", "Please select a server") })
|
.number({ message: t("errors.serverRequired", "Please select a server") })
|
||||||
.int()
|
.int()
|
||||||
.gt(0, t("errors.serverRequired", "Please select a server"))
|
.positive(t("errors.serverRequired", "Please select a server"))
|
||||||
.optional(),
|
.optional(),
|
||||||
protocol: z
|
protocol: z
|
||||||
.string()
|
.string()
|
||||||
@ -71,6 +75,7 @@ const buildSchema = (t: TFunction) =>
|
|||||||
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
|
.min(1, t("errors.portRange", "Port must be between 1 and 65535"))
|
||||||
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
|
.max(65_535, t("errors.portRange", "Port must be between 1 and 65535")),
|
||||||
tags: z.array(z.string()),
|
tags: z.array(z.string()),
|
||||||
|
node_group_ids: z.optional(z.array(z.string()).default([])),
|
||||||
});
|
});
|
||||||
|
|
||||||
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
|
export type NodeFormValues = z.infer<ReturnType<typeof buildSchema>>;
|
||||||
@ -112,8 +117,10 @@ export default function NodeForm(props: {
|
|||||||
address: "",
|
address: "",
|
||||||
port: 0,
|
port: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
|
node_group_ids: [],
|
||||||
...initialValues,
|
...initialValues,
|
||||||
},
|
},
|
||||||
|
mode: "onSubmit", // Only validate on form submission
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverId = form.watch("server_id");
|
const serverId = form.watch("server_id");
|
||||||
@ -125,17 +132,54 @@ export default function NodeForm(props: {
|
|||||||
|
|
||||||
const availableProtocols = getAvailableProtocols(serverId);
|
const availableProtocols = getAvailableProtocols(serverId);
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (initialValues) {
|
if (initialValues) {
|
||||||
form.reset({
|
const resetValues: NodeFormValues = {
|
||||||
name: "",
|
name: "",
|
||||||
server_id: undefined,
|
server_id: undefined,
|
||||||
protocol: "",
|
protocol: "",
|
||||||
address: "",
|
address: "",
|
||||||
port: 0,
|
port: 0,
|
||||||
tags: [],
|
tags: [],
|
||||||
...initialValues,
|
node_group_ids: [],
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Copy only the values we need from initialValues
|
||||||
|
if (initialValues.name) resetValues.name = initialValues.name;
|
||||||
|
if (initialValues.server_id) resetValues.server_id = initialValues.server_id;
|
||||||
|
if (initialValues.protocol) resetValues.protocol = initialValues.protocol;
|
||||||
|
if (initialValues.address) resetValues.address = initialValues.address;
|
||||||
|
if (initialValues.port) resetValues.port = initialValues.port;
|
||||||
|
if (initialValues.tags) resetValues.tags = initialValues.tags;
|
||||||
|
|
||||||
|
// Convert node_group_ids from number[] to string[], ensure it's always an array
|
||||||
|
if (initialValues.node_group_ids && Array.isArray(initialValues.node_group_ids)) {
|
||||||
|
resetValues.node_group_ids = initialValues.node_group_ids.map((id: string | number) => String(id));
|
||||||
|
} else {
|
||||||
|
resetValues.node_group_ids = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
form.reset(resetValues);
|
||||||
}
|
}
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [initialValues]);
|
}, [initialValues]);
|
||||||
@ -360,6 +404,7 @@ export default function NodeForm(props: {
|
|||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Tags field - always shown */}
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
name="tags"
|
name="tags"
|
||||||
@ -378,15 +423,77 @@ export default function NodeForm(props: {
|
|||||||
/>
|
/>
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription>
|
||||||
{t(
|
{isGroupEnabled
|
||||||
"tags_description",
|
? t(
|
||||||
"Permission grouping tag (incl. plan binding and delivery policies)."
|
"tags_groupMode_description",
|
||||||
)}
|
"Optional tags for display and filtering (node group will be used as tag if empty)."
|
||||||
|
)
|
||||||
|
: t(
|
||||||
|
"tags_description",
|
||||||
|
"Permission grouping tag (incl. plan binding and delivery policies)."
|
||||||
|
)}
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
<FormMessage />
|
<FormMessage />
|
||||||
</FormItem>
|
</FormItem>
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
{/* Show Node Group field only when group feature is enabled */}
|
||||||
|
{isGroupEnabled && (
|
||||||
|
<FormField
|
||||||
|
control={form.control}
|
||||||
|
name="node_group_ids"
|
||||||
|
render={({ field }) => (
|
||||||
|
<FormItem>
|
||||||
|
<FormLabel>{t("nodeGroup", "Node Group")}</FormLabel>
|
||||||
|
<FormControl>
|
||||||
|
<div className="grid grid-cols-2 gap-2">
|
||||||
|
{nodeGroupsData?.map((g) => (
|
||||||
|
<div
|
||||||
|
key={g.id}
|
||||||
|
className="flex items-center space-x-2"
|
||||||
|
>
|
||||||
|
<Checkbox
|
||||||
|
id={`node-group-${g.id}`}
|
||||||
|
checked={field.value?.includes(String(g.id)) || false}
|
||||||
|
onCheckedChange={(checked) => {
|
||||||
|
// Ensure field.value is always an array
|
||||||
|
const currentValue = Array.isArray(field.value) ? field.value : [];
|
||||||
|
if (checked) {
|
||||||
|
const newValue = [...currentValue, String(g.id)];
|
||||||
|
form.setValue(field.name, newValue, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
const newValue = currentValue.filter((v: string) => v !== String(g.id));
|
||||||
|
form.setValue(field.name, newValue, {
|
||||||
|
shouldValidate: true,
|
||||||
|
shouldDirty: true,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Label
|
||||||
|
htmlFor={`node-group-${g.id}`}
|
||||||
|
className="cursor-pointer"
|
||||||
|
>
|
||||||
|
{g.name}
|
||||||
|
</Label>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</FormControl>
|
||||||
|
<FormDescription>
|
||||||
|
{t(
|
||||||
|
"nodeGroup_description",
|
||||||
|
"Assign this node to multiple groups for user access control."
|
||||||
|
)}
|
||||||
|
</FormDescription>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</form>
|
</form>
|
||||||
</Form>
|
</Form>
|
||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
|
|||||||
215
apps/admin/src/sections/product/migrate-users-dialog.tsx
Normal file
215
apps/admin/src/sections/product/migrate-users-dialog.tsx
Normal file
@ -0,0 +1,215 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from "@workspace/ui/components/select";
|
||||||
|
import { Label } from "@workspace/ui/components/label";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
import { toast } from "sonner";
|
||||||
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
|
import { AlertTriangle } from "lucide-react";
|
||||||
|
import {
|
||||||
|
getSubscribeMapping,
|
||||||
|
getUserGroupList,
|
||||||
|
migrateUsersToGroup,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
|
|
||||||
|
interface MigrateUsersDialogProps {
|
||||||
|
subscribeId: number;
|
||||||
|
subscribeName: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function MigrateUsersDialog({
|
||||||
|
subscribeId,
|
||||||
|
subscribeName,
|
||||||
|
}: MigrateUsersDialogProps) {
|
||||||
|
const { t } = useTranslation("product");
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
const [selectedGroupId, setSelectedGroupId] = useState<number>(0);
|
||||||
|
const [migrating, setMigrating] = useState(false);
|
||||||
|
|
||||||
|
// Fetch subscribe mapping to get current user group
|
||||||
|
const { data: mappingsData, isLoading: mappingsLoading } = useQuery({
|
||||||
|
enabled: open,
|
||||||
|
queryKey: ["subscribeMapping"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getSubscribeMapping({ page: 1, size: 1000 });
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fetch user groups for the dropdown
|
||||||
|
const { data: userGroupsData } = useQuery({
|
||||||
|
enabled: open,
|
||||||
|
queryKey: ["userGroups"],
|
||||||
|
queryFn: async () => {
|
||||||
|
const { data } = await getUserGroupList({ page: 1, size: 1000 });
|
||||||
|
return data.data?.list || [];
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the current mapping for this subscribe
|
||||||
|
const currentMapping = mappingsData?.list?.find(
|
||||||
|
(m) => m.subscribe_id === subscribeId
|
||||||
|
);
|
||||||
|
|
||||||
|
const currentGroupId = currentMapping?.user_group_id;
|
||||||
|
const currentUserGroup = currentGroupId
|
||||||
|
? userGroupsData?.find((g) => g.id === currentGroupId)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
|
const handleMigrate = async () => {
|
||||||
|
if (!selectedGroupId) {
|
||||||
|
toast.error(t("selectTargetGroupFirst", "Please select a target group first"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (selectedGroupId === currentGroupId) {
|
||||||
|
toast.error(t("cannotMigrateToSameGroup", "Cannot migrate to the same group"));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setMigrating(true);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await migrateUsersToGroup({
|
||||||
|
from_user_group_id: currentGroupId!,
|
||||||
|
to_user_group_id: selectedGroupId,
|
||||||
|
include_locked: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
toast.success(t("migrateUsersSuccess", "Successfully migrated users to the target group"));
|
||||||
|
|
||||||
|
setOpen(false);
|
||||||
|
} catch (error) {
|
||||||
|
console.error("Failed to migrate users:", error);
|
||||||
|
toast.error(t("migrateUsersFailed", "Failed to migrate users"));
|
||||||
|
} finally {
|
||||||
|
setMigrating(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const availableGroups = userGroupsData?.filter(
|
||||||
|
(g) => !currentGroupId || g.id !== currentGroupId
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog onOpenChange={setOpen} open={open}>
|
||||||
|
<DialogTrigger asChild>
|
||||||
|
<Button variant="outline">{t("migrateUsers", "Migrate Users")}</Button>
|
||||||
|
</DialogTrigger>
|
||||||
|
<DialogContent className="max-w-md">
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>{t("migrateUsersTitle", "Migrate Users")} - {subscribeName}</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
{t(
|
||||||
|
"migrateUsersDescription",
|
||||||
|
"Migrate all users from the current user group to another group"
|
||||||
|
)}
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
{mappingsLoading ? (
|
||||||
|
<div className="py-8 text-center text-muted-foreground">
|
||||||
|
{t("loading", "Loading...")}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4 py-4">
|
||||||
|
{/* Current User Group Info */}
|
||||||
|
<div className="rounded-lg border p-4 space-y-2">
|
||||||
|
<Label>{t("currentUserGroup", "Current User Group")}:</Label>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{currentUserGroup ? (
|
||||||
|
<>
|
||||||
|
<Badge variant="outline">{currentUserGroup.name}</Badge>
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-muted-foreground">
|
||||||
|
{t("noMapping", "No mapping set")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Warning Message */}
|
||||||
|
{currentUserGroup && (
|
||||||
|
<div className="flex items-start gap-2 rounded-md bg-yellow-50 p-3 text-sm text-yellow-800 dark:bg-yellow-900/20 dark:text-yellow-400">
|
||||||
|
<AlertTriangle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||||
|
<p>
|
||||||
|
{t("migrateUsersWarning", "This will migrate users from \"{group}\" to the target group. This action cannot be undone.")
|
||||||
|
.replace("{group}", currentUserGroup.name || "")
|
||||||
|
}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Target User Group Selection */}
|
||||||
|
<div className="space-y-2">
|
||||||
|
<Label htmlFor="target-group">{t("targetUserGroup", "Target User Group")}:</Label>
|
||||||
|
<Select
|
||||||
|
value={selectedGroupId?.toString() || ""}
|
||||||
|
onValueChange={(val) => setSelectedGroupId(Number(val))}
|
||||||
|
disabled={!currentGroupId}
|
||||||
|
>
|
||||||
|
<SelectTrigger id="target-group">
|
||||||
|
<SelectValue
|
||||||
|
placeholder={
|
||||||
|
currentGroupId
|
||||||
|
? t("selectTargetGroup", "Select a target group...")
|
||||||
|
: t("noSourceGroup", "No source group available")
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{availableGroups?.map((group) => (
|
||||||
|
<SelectItem key={group.id} value={String(group.id)}>
|
||||||
|
{group.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Selected Target Group Info */}
|
||||||
|
{selectedGroupId && (
|
||||||
|
<div className="rounded-md bg-muted p-3">
|
||||||
|
<span className="text-sm font-medium">{t("selectedGroup", "Selected Group")}: </span>
|
||||||
|
<Badge variant="secondary">
|
||||||
|
{availableGroups?.find((g) => g.id === selectedGroupId)?.name}
|
||||||
|
</Badge>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="outline" onClick={() => setOpen(false)}>
|
||||||
|
{t("cancel", "Cancel")}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
onClick={handleMigrate}
|
||||||
|
disabled={!selectedGroupId || !currentGroupId || migrating}
|
||||||
|
>
|
||||||
|
{migrating ? t("migrating", "Migrating...") : t("confirm", "Confirm")}
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -8,6 +8,7 @@ import {
|
|||||||
AccordionTrigger,
|
AccordionTrigger,
|
||||||
} from "@workspace/ui/components/accordion";
|
} from "@workspace/ui/components/accordion";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import { Card } from "@workspace/ui/components/card";
|
||||||
import { Checkbox } from "@workspace/ui/components/checkbox";
|
import { Checkbox } from "@workspace/ui/components/checkbox";
|
||||||
import {
|
import {
|
||||||
Form,
|
Form,
|
||||||
@ -36,7 +37,7 @@ import {
|
|||||||
TabsTrigger,
|
TabsTrigger,
|
||||||
} from "@workspace/ui/components/tabs";
|
} from "@workspace/ui/components/tabs";
|
||||||
import { Combobox } from "@workspace/ui/composed/combobox";
|
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 { JSONEditor } from "@workspace/ui/composed/editor/index";
|
||||||
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
import { EnhancedInput } from "@workspace/ui/composed/enhanced-input";
|
||||||
import { Icon } from "@workspace/ui/composed/icon";
|
import { Icon } from "@workspace/ui/composed/icon";
|
||||||
@ -44,6 +45,8 @@ import {
|
|||||||
evaluateWithPrecision,
|
evaluateWithPrecision,
|
||||||
unitConversion,
|
unitConversion,
|
||||||
} from "@workspace/ui/utils/unit-conversions";
|
} 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 { CreditCard, Server, Settings } from "lucide-react";
|
||||||
import { assign, shake } from "radash";
|
import { assign, shake } from "radash";
|
||||||
import { useCallback, useEffect, useRef, useState } from "react";
|
import { useCallback, useEffect, useRef, useState } from "react";
|
||||||
@ -72,6 +75,8 @@ const defaultValues = {
|
|||||||
language: "",
|
language: "",
|
||||||
node_tags: [],
|
node_tags: [],
|
||||||
nodes: [],
|
nodes: [],
|
||||||
|
node_group_id: "",
|
||||||
|
node_group_ids: [],
|
||||||
unit_time: "Month",
|
unit_time: "Month",
|
||||||
deduction_ratio: 0,
|
deduction_ratio: 0,
|
||||||
purchase_with_discount: false,
|
purchase_with_discount: false,
|
||||||
@ -117,6 +122,8 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
language: z.string().optional(),
|
language: z.string().optional(),
|
||||||
node_tags: z.array(z.string()).optional(),
|
node_tags: z.array(z.string()).optional(),
|
||||||
nodes: z.array(z.number()).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(),
|
deduction_ratio: z.number().optional(),
|
||||||
allow_deduction: z.boolean().optional(),
|
allow_deduction: z.boolean().optional(),
|
||||||
reset_cycle: z.number().optional(),
|
reset_cycle: z.number().optional(),
|
||||||
@ -234,12 +241,22 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
form?.reset(
|
const processedValues = assign(
|
||||||
assign(
|
defaultValues,
|
||||||
defaultValues,
|
shake(initialValues, (value) => value === null) as Record<string, any>
|
||||||
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") || [];
|
const discount = form.getValues("discount") || [];
|
||||||
if (discount.length > 0) {
|
if (discount.length > 0) {
|
||||||
debouncedCalculateDiscount(discount, "discount");
|
debouncedCalculateDiscount(discount, "discount");
|
||||||
@ -256,15 +273,56 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
);
|
);
|
||||||
|
|
||||||
async function handleSubmit(data: { [x: 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);
|
const bool = await onSubmit(data as T);
|
||||||
if (bool) setOpen(false);
|
if (bool) setOpen(false);
|
||||||
}
|
}
|
||||||
|
|
||||||
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags } = useNode();
|
const { getAllAvailableTags, getNodesByTag, getNodesWithoutTags, getNodesWithoutGroups, nodes } = useNode();
|
||||||
|
|
||||||
const tagGroups = getAllAvailableTags();
|
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 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 (
|
return (
|
||||||
<Sheet onOpenChange={setOpen} open={open}>
|
<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">
|
<TabsContent className="space-y-4" value="servers">
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<FormField
|
{/* Show node_tags field only when group feature is disabled */}
|
||||||
control={form.control}
|
{!isGroupEnabled && (
|
||||||
name="node_tags"
|
<FormField
|
||||||
render={({ field }) => (
|
control={form.control}
|
||||||
<FormItem>
|
name="node_tags"
|
||||||
<FormLabel>{t("form.nodeGroup")}</FormLabel>
|
render={({ field }) => (
|
||||||
<FormControl>
|
<FormItem>
|
||||||
<Accordion
|
<FormLabel>{t("form.nodeGroup")}</FormLabel>
|
||||||
className="w-full"
|
<FormControl>
|
||||||
collapsible
|
<Accordion
|
||||||
type="single"
|
className="w-full"
|
||||||
>
|
collapsible
|
||||||
{tagGroups.map((tag) => {
|
type="single"
|
||||||
const value = field.value || [];
|
>
|
||||||
const tagId = tag;
|
{tagGroups.map((tag) => {
|
||||||
const nodesWithTag = getNodesByTag(tag);
|
const value = field.value || [];
|
||||||
|
const tagId = tag;
|
||||||
|
const nodesWithTag = getNodesByTag(tag);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<AccordionItem key={tag} value={String(tag)}>
|
<AccordionItem key={tag} value={String(tag)}>
|
||||||
<AccordionTrigger>
|
<AccordionTrigger>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={value.includes(tagId as any)}
|
checked={value.includes(tagId as any)}
|
||||||
onCheckedChange={(checked) =>
|
onCheckedChange={(checked) =>
|
||||||
checked
|
checked
|
||||||
? form.setValue(field.name, [
|
? form.setValue(field.name, [
|
||||||
...value,
|
...value,
|
||||||
tagId,
|
tagId,
|
||||||
] as any)
|
] as any)
|
||||||
: form.setValue(
|
: form.setValue(
|
||||||
field.name,
|
field.name,
|
||||||
value.filter(
|
value.filter(
|
||||||
(v: any) => v !== tagId
|
(v: any) => v !== tagId
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
}
|
/>
|
||||||
/>
|
<Label>
|
||||||
<Label>
|
{tag}
|
||||||
{tag}
|
<span className="ml-2 text-muted-foreground text-xs">
|
||||||
<span className="ml-2 text-muted-foreground text-xs">
|
({nodesWithTag.length})
|
||||||
({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}
|
|
||||||
</span>
|
</span>
|
||||||
<span className="flex-1">
|
</Label>
|
||||||
{node.address}:{node.port}
|
</div>
|
||||||
</span>
|
</AccordionTrigger>
|
||||||
<span className="flex-1 text-right">
|
<AccordionContent>
|
||||||
{node.protocol}
|
<ul className="space-y-1">
|
||||||
</span>
|
{getNodesByTag(tag).map((node) => (
|
||||||
</li>
|
<li
|
||||||
))}
|
className="flex items-center justify-between gap-3"
|
||||||
</ul>
|
key={node.id}
|
||||||
</AccordionContent>
|
>
|
||||||
</AccordionItem>
|
<span className="flex-1">
|
||||||
);
|
{node.name}
|
||||||
})}
|
</span>
|
||||||
</Accordion>
|
<span className="flex-1">
|
||||||
</FormControl>
|
{node.address}:{node.port}
|
||||||
<FormMessage />
|
</span>
|
||||||
</FormItem>
|
<span className="flex-1 text-right">
|
||||||
)}
|
{node.protocol}
|
||||||
/>
|
</span>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</AccordionContent>
|
||||||
|
</AccordionItem>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</Accordion>
|
||||||
|
</FormControl>
|
||||||
|
<FormMessage />
|
||||||
|
</FormItem>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
<FormField
|
<FormField
|
||||||
control={form.control}
|
control={form.control}
|
||||||
@ -1015,7 +1076,9 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
<FormLabel>{t("form.node")}</FormLabel>
|
<FormLabel>{t("form.node")}</FormLabel>
|
||||||
<FormControl>
|
<FormControl>
|
||||||
<div className="flex flex-col gap-2">
|
<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 || [];
|
const value = field.value || [];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -1056,10 +1119,281 @@ export default function SubscribeForm<T extends Record<string, any>>({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</FormControl>
|
</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 />
|
<FormMessage />
|
||||||
</FormItem>
|
</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>
|
</div>
|
||||||
</TabsContent>
|
</TabsContent>
|
||||||
</Tabs>
|
</Tabs>
|
||||||
|
|||||||
@ -16,6 +16,8 @@ import {
|
|||||||
subscribeSort,
|
subscribeSort,
|
||||||
updateSubscribe,
|
updateSubscribe,
|
||||||
} from "@workspace/ui/services/admin/subscribe";
|
} from "@workspace/ui/services/admin/subscribe";
|
||||||
|
import { getNodeGroupList } from "@workspace/ui/services/admin/group";
|
||||||
|
import { useQuery } from "@tanstack/react-query";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
import { toast } from "sonner";
|
||||||
@ -28,8 +30,29 @@ export default function SubscribeTable() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const ref = useRef<ProTableActions>(null);
|
const ref = useRef<ProTableActions>(null);
|
||||||
const { fetchSubscribes } = useSubscribe();
|
const { fetchSubscribes } = useSubscribe();
|
||||||
|
|
||||||
|
// Fetch node groups for filtering
|
||||||
|
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 (await import("@workspace/ui/services/admin/group")).getGroupConfig();
|
||||||
|
return data.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const isGroupEnabled = groupConfigData?.enabled || false;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ProTable<API.SubscribeItem, { group_id: number; query: string }>
|
<ProTable<API.SubscribeItem, { group_id: number; query: string; node_group_id?: number }>
|
||||||
action={ref}
|
action={ref}
|
||||||
actions={{
|
actions={{
|
||||||
render: (row) => [
|
render: (row) => [
|
||||||
@ -40,10 +63,16 @@ export default function SubscribeTable() {
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await updateSubscribe({
|
const updateBody: any = {
|
||||||
...row,
|
...row,
|
||||||
...values,
|
...values,
|
||||||
} as API.UpdateSubscribeRequest);
|
};
|
||||||
|
// Add node_group_ids if it exists in values
|
||||||
|
const vals = values as any;
|
||||||
|
if (vals.node_group_ids) {
|
||||||
|
updateBody.node_group_ids = vals.node_group_ids.map((id: string | number) => Number(id));
|
||||||
|
}
|
||||||
|
await updateSubscribe(updateBody as API.UpdateSubscribeRequest);
|
||||||
toast.success(t("updateSuccess"));
|
toast.success(t("updateSuccess"));
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
fetchSubscribes();
|
fetchSubscribes();
|
||||||
@ -243,6 +272,26 @@ export default function SubscribeTable() {
|
|||||||
<Badge variant="outline">{row.getValue("sold")}</Badge>
|
<Badge variant="outline">{row.getValue("sold")}</Badge>
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
|
...(isGroupEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
id: "node_group",
|
||||||
|
header: t("defaultNodeGroup", "Default Node Group"),
|
||||||
|
cell: ({ row }: { row: any }) => {
|
||||||
|
const nodeGroupId = row.original.node_group_id;
|
||||||
|
const nodeGroup = nodeGroupsData?.find((g) => g.id === nodeGroupId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div>
|
||||||
|
{nodeGroup ? (
|
||||||
|
<Badge variant="outline">{nodeGroup.name}</Badge>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
header={{
|
header={{
|
||||||
toolbar: (
|
toolbar: (
|
||||||
@ -251,11 +300,17 @@ export default function SubscribeTable() {
|
|||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
await createSubscribe({
|
const createBody: any = {
|
||||||
...values,
|
...values,
|
||||||
show: false,
|
show: false,
|
||||||
sell: false,
|
sell: false,
|
||||||
});
|
};
|
||||||
|
// Add node_group_ids if it exists in values
|
||||||
|
const vals = values as any;
|
||||||
|
if (vals.node_group_ids) {
|
||||||
|
createBody.node_group_ids = vals.node_group_ids.map((id: string | number) => Number(id));
|
||||||
|
}
|
||||||
|
await createSubscribe(createBody);
|
||||||
toast.success(t("createSuccess"));
|
toast.success(t("createSuccess"));
|
||||||
ref.current?.refresh();
|
ref.current?.refresh();
|
||||||
fetchSubscribes();
|
fetchSubscribes();
|
||||||
@ -312,12 +367,30 @@ export default function SubscribeTable() {
|
|||||||
{
|
{
|
||||||
key: "search",
|
key: "search",
|
||||||
},
|
},
|
||||||
|
...(isGroupEnabled
|
||||||
|
? [
|
||||||
|
{
|
||||||
|
key: "node_group_id",
|
||||||
|
placeholder: t("nodeGroups", "Node Groups"),
|
||||||
|
options: [
|
||||||
|
{ label: t("all", "All"), value: "" },
|
||||||
|
...(nodeGroupsData?.map((item) => ({
|
||||||
|
label: item.name,
|
||||||
|
value: String(item.id),
|
||||||
|
})) || []),
|
||||||
|
],
|
||||||
|
},
|
||||||
|
]
|
||||||
|
: []),
|
||||||
]}
|
]}
|
||||||
request={async (pagination, filters) => {
|
request={async (pagination, filters) => {
|
||||||
const { data } = await getSubscribeList({
|
const params = {
|
||||||
...pagination,
|
...pagination,
|
||||||
...filters,
|
...filters,
|
||||||
});
|
node_group_id: filters?.node_group_id ? Number(filters.node_group_id) : undefined,
|
||||||
|
} as any;
|
||||||
|
|
||||||
|
const { data } = await getSubscribeList(params);
|
||||||
return {
|
return {
|
||||||
list: data.data?.list || [],
|
list: data.data?.list || [],
|
||||||
total: data.data?.total || 0,
|
total: data.data?.total || 0,
|
||||||
|
|||||||
199
apps/admin/src/sections/user/edit-user-group-dialog.tsx
Normal file
199
apps/admin/src/sections/user/edit-user-group-dialog.tsx
Normal file
@ -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 { Link, useSearch } from "@tanstack/react-router";
|
||||||
import { Badge } from "@workspace/ui/components/badge";
|
import { Badge } from "@workspace/ui/components/badge";
|
||||||
import { Button } from "@workspace/ui/components/button";
|
import { Button } from "@workspace/ui/components/button";
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
DialogTrigger,
|
||||||
|
} from "@workspace/ui/components/dialog";
|
||||||
import {
|
import {
|
||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
@ -35,6 +42,10 @@ import {
|
|||||||
getUserList,
|
getUserList,
|
||||||
updateUserBasicInfo,
|
updateUserBasicInfo,
|
||||||
} from "@workspace/ui/services/admin/user";
|
} from "@workspace/ui/services/admin/user";
|
||||||
|
import {
|
||||||
|
// getUserGroupList,
|
||||||
|
previewUserNodes,
|
||||||
|
} from "@workspace/ui/services/admin/group";
|
||||||
import { useRef, useState } from "react";
|
import { useRef, useState } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { toast } from "sonner";
|
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 { BasicInfoForm } from "./user-profile/basic-info-form";
|
||||||
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
import { NotifySettingsForm } from "./user-profile/notify-settings-form";
|
||||||
import UserSubscription from "./user-subscription";
|
import UserSubscription from "./user-subscription";
|
||||||
|
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
||||||
|
|
||||||
export default function User() {
|
export default function User() {
|
||||||
const { t } = useTranslation("user");
|
const { t } = useTranslation("user");
|
||||||
@ -56,12 +68,21 @@ export default function User() {
|
|||||||
|
|
||||||
const { subscribes } = useSubscribe();
|
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 = {
|
const initialFilters = {
|
||||||
search: sp.search || undefined,
|
search: sp.search || undefined,
|
||||||
user_id: sp.user_id || undefined,
|
user_id: sp.user_id || undefined,
|
||||||
subscribe_id: sp.subscribe_id || undefined,
|
subscribe_id: sp.subscribe_id || undefined,
|
||||||
user_subscribe_id: sp.user_subscribe_id || undefined,
|
user_subscribe_id: sp.user_subscribe_id || undefined,
|
||||||
short_code: sp.short_code || undefined,
|
short_code: sp.short_code || undefined,
|
||||||
|
// user_group_id: sp.user_group_id || undefined,
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@ -75,6 +96,7 @@ export default function User() {
|
|||||||
userId={row.id}
|
userId={row.id}
|
||||||
/>,
|
/>,
|
||||||
<SubscriptionSheet key="subscription" userId={row.id} />,
|
<SubscriptionSheet key="subscription" userId={row.id} />,
|
||||||
|
<PreviewNodesDialog key="preview-nodes" userId={row.id} />,
|
||||||
<ConfirmButton
|
<ConfirmButton
|
||||||
cancelText={t("cancel", "Cancel")}
|
cancelText={t("cancel", "Cancel")}
|
||||||
confirmText={t("confirm", "Confirm")}
|
confirmText={t("confirm", "Confirm")}
|
||||||
@ -144,6 +166,7 @@ export default function User() {
|
|||||||
}}
|
}}
|
||||||
columns={[
|
columns={[
|
||||||
{
|
{
|
||||||
|
id: "enable",
|
||||||
accessorKey: "enable",
|
accessorKey: "enable",
|
||||||
header: t("enable", "Enable"),
|
header: t("enable", "Enable"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -174,10 +197,12 @@ export default function User() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "id",
|
||||||
accessorKey: "id",
|
accessorKey: "id",
|
||||||
header: "ID",
|
header: "ID",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "deleted_at",
|
||||||
accessorKey: "deleted_at",
|
accessorKey: "deleted_at",
|
||||||
header: t("isDeleted", "Deleted"),
|
header: t("isDeleted", "Deleted"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@ -190,6 +215,7 @@ export default function User() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "auth_methods",
|
||||||
accessorKey: "auth_methods",
|
accessorKey: "auth_methods",
|
||||||
header: t("userName", "Username"),
|
header: t("userName", "Username"),
|
||||||
cell: ({ row }) => {
|
cell: ({ row }) => {
|
||||||
@ -208,6 +234,7 @@ export default function User() {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "balance",
|
||||||
accessorKey: "balance",
|
accessorKey: "balance",
|
||||||
header: t("balance", "Balance"),
|
header: t("balance", "Balance"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -215,6 +242,7 @@ export default function User() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "gift_amount",
|
||||||
accessorKey: "gift_amount",
|
accessorKey: "gift_amount",
|
||||||
header: t("giftAmount", "Gift Amount"),
|
header: t("giftAmount", "Gift Amount"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -222,6 +250,7 @@ export default function User() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "commission",
|
||||||
accessorKey: "commission",
|
accessorKey: "commission",
|
||||||
header: t("commission", "Commission"),
|
header: t("commission", "Commission"),
|
||||||
cell: ({ row }) => (
|
cell: ({ row }) => (
|
||||||
@ -229,16 +258,19 @@ export default function User() {
|
|||||||
),
|
),
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "refer_code",
|
||||||
accessorKey: "refer_code",
|
accessorKey: "refer_code",
|
||||||
header: t("inviteCode", "Invite Code"),
|
header: t("inviteCode", "Invite Code"),
|
||||||
cell: ({ row }) => row.getValue("refer_code") || "--",
|
cell: ({ row }) => row.getValue("refer_code") || "--",
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "referer_id",
|
||||||
accessorKey: "referer_id",
|
accessorKey: "referer_id",
|
||||||
header: t("referer", "Referer"),
|
header: t("referer", "Referer"),
|
||||||
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
cell: ({ row }) => <UserDetail id={row.original.referer_id} />,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
id: "created_at",
|
||||||
accessorKey: "created_at",
|
accessorKey: "created_at",
|
||||||
header: t("createdAt", "Created At"),
|
header: t("createdAt", "Created At"),
|
||||||
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
cell: ({ row }) => formatDate(row.getValue("created_at")),
|
||||||
@ -276,10 +308,13 @@ export default function User() {
|
|||||||
{
|
{
|
||||||
key: "subscribe_id",
|
key: "subscribe_id",
|
||||||
placeholder: t("subscription", "Subscription"),
|
placeholder: t("subscription", "Subscription"),
|
||||||
options: subscribes?.map((item) => ({
|
options: [
|
||||||
label: item.name!,
|
{ label: t("all", "All"), value: "" },
|
||||||
value: String(item.id!),
|
...(subscribes?.map((item) => ({
|
||||||
})),
|
label: item.name!,
|
||||||
|
value: String(item.id!),
|
||||||
|
})) || []),
|
||||||
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
key: "search",
|
key: "search",
|
||||||
@ -401,3 +436,80 @@ function SubscriptionSheet({ userId }: { userId: number }) {
|
|||||||
</Sheet>
|
</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 { useTranslation } from "react-i18next";
|
||||||
import { Display } from "@/components/display";
|
import { Display } from "@/components/display";
|
||||||
import { formatDate } from "@/utils/common";
|
import { formatDate } from "@/utils/common";
|
||||||
|
// import EditUserGroupDialog from "./edit-user-group-dialog";
|
||||||
|
// import { getUserGroupList } from "@workspace/ui/services/admin/group";
|
||||||
|
|
||||||
export function UserSubscribeDetail({
|
export function UserSubscribeDetail({
|
||||||
id,
|
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 "--";
|
if (!id) return "--";
|
||||||
|
|
||||||
const usedTraffic = data ? data.upload + data.download : 0;
|
const usedTraffic = data ? data.upload + data.download : 0;
|
||||||
const totalTraffic = data?.traffic || 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 = (
|
const subscribeContent = (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
@ -76,6 +101,16 @@ export function UserSubscribeDetail({
|
|||||||
: "--"}
|
: "--"}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</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">
|
<li className="flex items-center justify-between">
|
||||||
<span className="text-muted-foreground">{t("startTime")}</span>
|
<span className="text-muted-foreground">{t("startTime")}</span>
|
||||||
<span>
|
<span>
|
||||||
@ -88,6 +123,35 @@ export function UserSubscribeDetail({
|
|||||||
{data?.expire_time ? formatDate(data.expire_time) : "--"}
|
{data?.expire_time ? formatDate(data.expire_time) : "--"}
|
||||||
</span>
|
</span>
|
||||||
</li>
|
</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>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@ -143,6 +143,19 @@ export default function UserSubscription({ userId }: { userId: number }) {
|
|||||||
<Display type="traffic" unlimited value={row.getValue("traffic")} />
|
<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",
|
accessorKey: "speed_limit",
|
||||||
header: t("speedLimit", "Speed Limit"),
|
header: t("speedLimit", "Speed Limit"),
|
||||||
@ -390,7 +403,7 @@ function RowMoreActions({
|
|||||||
"This action cannot be undone."
|
"This action cannot be undone."
|
||||||
)}
|
)}
|
||||||
onConfirm={async () => {
|
onConfirm={async () => {
|
||||||
await deleteUserSubscribe({ user_subscribe_id: row.id });
|
await deleteUserSubscribe({ user_subscribe_id: String(row.id) });
|
||||||
toast.success(t("deleteSuccess", "Deleted successfully"));
|
toast.success(t("deleteSuccess", "Deleted successfully"));
|
||||||
refresh();
|
refresh();
|
||||||
}}
|
}}
|
||||||
|
|||||||
@ -25,6 +25,7 @@ interface NodeState {
|
|||||||
isServerReferencedByNodes: (serverId: number) => boolean;
|
isServerReferencedByNodes: (serverId: number) => boolean;
|
||||||
getNodesByTag: (tag: string) => API.Node[];
|
getNodesByTag: (tag: string) => API.Node[];
|
||||||
getNodesWithoutTags: () => API.Node[];
|
getNodesWithoutTags: () => API.Node[];
|
||||||
|
getNodesWithoutGroups: () => API.Node[];
|
||||||
getNodeTags: () => string[];
|
getNodeTags: () => string[];
|
||||||
getAllAvailableTags: () => string[];
|
getAllAvailableTags: () => string[];
|
||||||
}
|
}
|
||||||
@ -92,6 +93,12 @@ export const useNodeStore = create<NodeState>((set, get) => ({
|
|||||||
getNodesWithoutTags: () =>
|
getNodesWithoutTags: () =>
|
||||||
get().nodes.filter((node) => (node.tags || []).length === 0),
|
get().nodes.filter((node) => (node.tags || []).length === 0),
|
||||||
|
|
||||||
|
getNodesWithoutGroups: () =>
|
||||||
|
get().nodes.filter((node) => {
|
||||||
|
const groupIds = (node as any).node_group_ids;
|
||||||
|
return !groupIds || groupIds.length === 0;
|
||||||
|
}),
|
||||||
|
|
||||||
getNodeTags: () =>
|
getNodeTags: () =>
|
||||||
Array.from(
|
Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
@ -135,6 +142,7 @@ export const useNode = () => {
|
|||||||
isServerReferencedByNodes: store.isServerReferencedByNodes,
|
isServerReferencedByNodes: store.isServerReferencedByNodes,
|
||||||
getNodesByTag: store.getNodesByTag,
|
getNodesByTag: store.getNodesByTag,
|
||||||
getNodesWithoutTags: store.getNodesWithoutTags,
|
getNodesWithoutTags: store.getNodesWithoutTags,
|
||||||
|
getNodesWithoutGroups: store.getNodesWithoutGroups,
|
||||||
getNodeTags: store.getNodeTags,
|
getNodeTags: store.getNodeTags,
|
||||||
getAllAvailableTags: store.getAllAvailableTags,
|
getAllAvailableTags: store.getAllAvailableTags,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -24,8 +24,20 @@ export function differenceInDays(date1: Date, date2: Date): number {
|
|||||||
|
|
||||||
export function formatDate(date?: Date | number, showTime = true) {
|
export function formatDate(date?: Date | number, showTime = true) {
|
||||||
if (!date) return;
|
if (!date) return;
|
||||||
|
|
||||||
|
// 如果是数字(Unix时间戳),需要判断是秒级还是毫秒级
|
||||||
|
// Unix时间戳(秒级):10位数字,如 1771936457
|
||||||
|
// JavaScript时间戳(毫秒级):13位数字
|
||||||
|
let dateValue = date;
|
||||||
|
if (typeof date === "number") {
|
||||||
|
// 如果小于 10000000000(100亿),认为是秒级时间戳,需要乘以1000
|
||||||
|
if (date < 10000000000) {
|
||||||
|
dateValue = date * 1000;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const timeZone = localStorage.getItem("timezone") || "UTC";
|
const timeZone = localStorage.getItem("timezone") || "UTC";
|
||||||
return intlFormat(date, {
|
return intlFormat(dateValue, {
|
||||||
year: "numeric",
|
year: "numeric",
|
||||||
month: "numeric",
|
month: "numeric",
|
||||||
day: "numeric",
|
day: "numeric",
|
||||||
|
|||||||
@ -53,17 +53,18 @@ export function DatePicker({
|
|||||||
)}
|
)}
|
||||||
variant="outline"
|
variant="outline"
|
||||||
>
|
>
|
||||||
{value ? intlFormat(value) : <span>{placeholder}</span>}
|
<span className="truncate">{value ? intlFormat(value) : <span>{placeholder}</span>}</span>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{value && (
|
{value && (
|
||||||
<button
|
<span
|
||||||
className="flex items-center"
|
className="flex items-center cursor-pointer"
|
||||||
onClick={handleClear}
|
onClick={handleClear}
|
||||||
onMouseDown={handleClear}
|
onMouseDown={handleClear}
|
||||||
type="button"
|
role="button"
|
||||||
|
tabIndex={0}
|
||||||
>
|
>
|
||||||
<X className="size-4 opacity-50 hover:opacity-100" />
|
<X className="size-4 opacity-50 hover:opacity-100" />
|
||||||
</button>
|
</span>
|
||||||
)}
|
)}
|
||||||
<CalendarIcon className="size-4" />
|
<CalendarIcon className="size-4" />
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
394
packages/ui/src/services/admin/group.ts
Normal file
394
packages/ui/src/services/admin/group.ts
Normal file
@ -0,0 +1,394 @@
|
|||||||
|
// @ts-nocheck
|
||||||
|
/* eslint-disable */
|
||||||
|
import request from "@workspace/ui/lib/request";
|
||||||
|
|
||||||
|
/** Get user group list GET /v1/admin/group/user/list */
|
||||||
|
export async function getUserGroupList(
|
||||||
|
params: API.GetUserGroupListRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetUserGroupListResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/user/list`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create user group POST /v1/admin/group/user */
|
||||||
|
export async function createUserGroup(
|
||||||
|
body: API.CreateUserGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/user`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update user group PUT /v1/admin/group/user */
|
||||||
|
export async function updateUserGroup(
|
||||||
|
body: API.UpdateUserGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/user`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete user group DELETE /v1/admin/group/user */
|
||||||
|
export async function deleteUserGroup(
|
||||||
|
body: API.DeleteUserGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/user`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get node group list GET /v1/admin/group/node/list */
|
||||||
|
export async function getNodeGroupList(
|
||||||
|
params: API.GetNodeGroupListRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetNodeGroupListResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/node/list`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Create node group POST /v1/admin/group/node */
|
||||||
|
export async function createNodeGroup(
|
||||||
|
body: API.CreateNodeGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/node`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update node group PUT /v1/admin/group/node */
|
||||||
|
export async function updateNodeGroup(
|
||||||
|
body: API.UpdateNodeGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/node`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Delete node group DELETE /v1/admin/group/node */
|
||||||
|
export async function deleteNodeGroup(
|
||||||
|
body: API.DeleteNodeGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/node`,
|
||||||
|
{
|
||||||
|
method: "DELETE",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get subscribe mapping list GET /v1/admin/group/subscribe/mapping */
|
||||||
|
export async function getSubscribeMapping(
|
||||||
|
params: API.GetSubscribeMappingRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetSubscribeMappingResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/subscribe/mapping`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update subscribe mapping PUT /v1/admin/group/subscribe/mapping */
|
||||||
|
export async function updateSubscribeMapping(
|
||||||
|
body: API.UpdateSubscribeMappingRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/subscribe/mapping`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get subscribe group mapping GET /v1/admin/group/subscribe/mapping */
|
||||||
|
export async function getSubscribeGroupMapping(
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetSubscribeGroupMappingResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/subscribe/mapping`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get group config GET /v1/admin/group/config */
|
||||||
|
export async function getGroupConfig(
|
||||||
|
params?: API.GetGroupConfigRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetGroupConfigResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/config`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: params || {},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update group config PUT /v1/admin/group/config */
|
||||||
|
export async function updateGroupConfig(
|
||||||
|
body: API.UpdateGroupConfigRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/config`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get recalculation status GET /v1/admin/group/recalculation/status */
|
||||||
|
export async function getRecalculationStatus(options?: { [key: string]: any }) {
|
||||||
|
return request<API.Response & { data?: API.RecalculationState }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/recalculation/status`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Recalculate groups POST /v1/admin/group/recalculate */
|
||||||
|
export async function recalculateGroup(
|
||||||
|
body: API.RecalculateGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/recalculate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get group history GET /v1/admin/group/history */
|
||||||
|
export async function getGroupHistory(
|
||||||
|
params: API.GetGroupHistoryRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetGroupHistoryResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/history`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
...params,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Get group history detail GET /v1/admin/group/history/detail */
|
||||||
|
export async function getGroupHistoryDetail(
|
||||||
|
params: { id: number },
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.GetGroupHistoryDetailResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/history/detail`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
id: params.id,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Export group result GET /v1/admin/group/export */
|
||||||
|
export async function exportGroupResult(
|
||||||
|
params?: API.ExportGroupResultRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<Blob>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/export`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: params || {},
|
||||||
|
responseType: 'blob',
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Preview user nodes GET /v1/admin/group/preview */
|
||||||
|
export async function previewUserNodes(
|
||||||
|
params: API.PreviewUserNodesRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.PreviewUserNodesResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/preview`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
params: {
|
||||||
|
user_id: params.user_id,
|
||||||
|
},
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Migrate users to another group POST /v1/admin/group/migrate */
|
||||||
|
export async function migrateUsersToGroup(
|
||||||
|
body: API.MigrateUsersRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: API.MigrateUsersResponse }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/migrate`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Update user user group PUT /v1/admin/user/user_group */
|
||||||
|
export async function updateUserUserGroup(
|
||||||
|
body: API.UpdateUserUserGroupRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/user/user_group`,
|
||||||
|
{
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Reset all groups POST /v1/admin/group/reset */
|
||||||
|
export async function resetGroups(
|
||||||
|
body: API.ResetGroupsRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/reset`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Bind node group to user groups POST /v1/admin/group/user/bind-node-groups */
|
||||||
|
export async function bindNodeGroups(
|
||||||
|
body: API.BindNodeGroupsRequest,
|
||||||
|
options?: { [key: string]: any }
|
||||||
|
) {
|
||||||
|
return request<API.Response & { data?: any }>(
|
||||||
|
`${import.meta.env.VITE_API_PREFIX || ""}/v1/admin/group/user/bind-node-groups`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
data: body,
|
||||||
|
...(options || {}),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
@ -9,6 +9,7 @@ import * as authMethod from "./authMethod";
|
|||||||
import * as console from "./console";
|
import * as console from "./console";
|
||||||
import * as coupon from "./coupon";
|
import * as coupon from "./coupon";
|
||||||
import * as document from "./document";
|
import * as document from "./document";
|
||||||
|
import * as group from "./group";
|
||||||
import * as log from "./log";
|
import * as log from "./log";
|
||||||
import * as marketing from "./marketing";
|
import * as marketing from "./marketing";
|
||||||
import * as order from "./order";
|
import * as order from "./order";
|
||||||
@ -27,6 +28,7 @@ export default {
|
|||||||
console,
|
console,
|
||||||
coupon,
|
coupon,
|
||||||
document,
|
document,
|
||||||
|
group,
|
||||||
log,
|
log,
|
||||||
marketing,
|
marketing,
|
||||||
order,
|
order,
|
||||||
|
|||||||
283
packages/ui/src/services/admin/typings.d.ts
vendored
283
packages/ui/src/services/admin/typings.d.ts
vendored
@ -103,6 +103,7 @@ declare namespace API {
|
|||||||
longitude: string;
|
longitude: string;
|
||||||
created_at: number;
|
created_at: number;
|
||||||
download: number;
|
download: number;
|
||||||
|
port: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AuthConfig = {
|
type AuthConfig = {
|
||||||
@ -534,7 +535,7 @@ declare namespace API {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type DeleteUserSubscribeRequest = {
|
type DeleteUserSubscribeRequest = {
|
||||||
user_subscribe_id: number;
|
user_subscribe_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type DeviceAuthticateConfig = {
|
type DeviceAuthticateConfig = {
|
||||||
@ -702,12 +703,14 @@ declare namespace API {
|
|||||||
page: number;
|
page: number;
|
||||||
size: number;
|
size: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
node_group_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterNodeListRequest = {
|
type FilterNodeListRequest = {
|
||||||
page: number;
|
page: number;
|
||||||
size: number;
|
size: number;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
node_group_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type FilterNodeListResponse = {
|
type FilterNodeListResponse = {
|
||||||
@ -1147,6 +1150,7 @@ declare namespace API {
|
|||||||
size: number;
|
size: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
node_group_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetSubscribeListRequest = {
|
type GetSubscribeListRequest = {
|
||||||
@ -1154,6 +1158,7 @@ declare namespace API {
|
|||||||
size: number;
|
size: number;
|
||||||
language?: string;
|
language?: string;
|
||||||
search?: string;
|
search?: string;
|
||||||
|
node_group_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetSubscribeListResponse = {
|
type GetSubscribeListResponse = {
|
||||||
@ -1210,6 +1215,7 @@ declare namespace API {
|
|||||||
unscoped?: boolean;
|
unscoped?: boolean;
|
||||||
subscribe_id?: number;
|
subscribe_id?: number;
|
||||||
user_subscribe_id?: number;
|
user_subscribe_id?: number;
|
||||||
|
user_group_id?: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
type GetUserListRequest = {
|
type GetUserListRequest = {
|
||||||
@ -1440,6 +1446,8 @@ declare namespace API {
|
|||||||
protocol: string;
|
protocol: string;
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
sort?: number;
|
sort?: number;
|
||||||
|
node_group_id?: number;
|
||||||
|
node_group_ids?: number[];
|
||||||
created_at: number;
|
created_at: number;
|
||||||
updated_at: number;
|
updated_at: number;
|
||||||
};
|
};
|
||||||
@ -1921,11 +1929,11 @@ declare namespace API {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ResetUserSubscribeTokenRequest = {
|
type ResetUserSubscribeTokenRequest = {
|
||||||
user_subscribe_id: number;
|
user_subscribe_id: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ResetUserSubscribeTrafficRequest = {
|
type ResetUserSubscribeTrafficRequest = {
|
||||||
user_subscribe_id: number;
|
user_subscribe_id: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
type Response = {
|
type Response = {
|
||||||
@ -2106,6 +2114,8 @@ declare namespace API {
|
|||||||
quota: number;
|
quota: number;
|
||||||
nodes: number[];
|
nodes: number[];
|
||||||
node_tags: string[];
|
node_tags: string[];
|
||||||
|
node_group_ids?: number[];
|
||||||
|
node_group_id?: number;
|
||||||
show: boolean;
|
show: boolean;
|
||||||
sell: boolean;
|
sell: boolean;
|
||||||
sort: number;
|
sort: number;
|
||||||
@ -2171,6 +2181,8 @@ declare namespace API {
|
|||||||
quota?: number;
|
quota?: number;
|
||||||
nodes?: number[];
|
nodes?: number[];
|
||||||
node_tags?: string[];
|
node_tags?: string[];
|
||||||
|
node_group_ids?: number[];
|
||||||
|
node_group_id?: number;
|
||||||
show?: boolean;
|
show?: boolean;
|
||||||
sell?: boolean;
|
sell?: boolean;
|
||||||
sort?: number;
|
sort?: number;
|
||||||
@ -2244,7 +2256,7 @@ declare namespace API {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type ToggleUserSubscribeStatusRequest = {
|
type ToggleUserSubscribeStatusRequest = {
|
||||||
user_subscribe_id: number;
|
user_subscribe_id: any;
|
||||||
};
|
};
|
||||||
|
|
||||||
type TosConfig = {
|
type TosConfig = {
|
||||||
@ -2473,7 +2485,7 @@ declare namespace API {
|
|||||||
};
|
};
|
||||||
|
|
||||||
type UpdateUserSubscribeRequest = {
|
type UpdateUserSubscribeRequest = {
|
||||||
user_subscribe_id: number;
|
user_subscribe_id: string;
|
||||||
subscribe_id: number;
|
subscribe_id: number;
|
||||||
traffic: number;
|
traffic: number;
|
||||||
expired_at: number;
|
expired_at: number;
|
||||||
@ -2498,6 +2510,8 @@ declare namespace API {
|
|||||||
enable_login_notify: boolean;
|
enable_login_notify: boolean;
|
||||||
enable_subscribe_notify: boolean;
|
enable_subscribe_notify: boolean;
|
||||||
enable_trade_notify: boolean;
|
enable_trade_notify: boolean;
|
||||||
|
user_group_id: string;
|
||||||
|
group_locked: boolean;
|
||||||
auth_methods: UserAuthMethod[];
|
auth_methods: UserAuthMethod[];
|
||||||
user_devices: UserDevice[];
|
user_devices: UserDevice[];
|
||||||
rules: string[];
|
rules: string[];
|
||||||
@ -2659,4 +2673,263 @@ declare namespace API {
|
|||||||
security: string;
|
security: string;
|
||||||
security_config: SecurityConfig;
|
security_config: SecurityConfig;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Group Management Types =====
|
||||||
|
|
||||||
|
type UserGroup = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sort: number;
|
||||||
|
node_group_id?: number | null;
|
||||||
|
for_calculation?: boolean;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type NodeGroup = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
description: string;
|
||||||
|
sort: number;
|
||||||
|
for_calculation: boolean;
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type Subscribe = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
unit_price: number;
|
||||||
|
unit_time: number;
|
||||||
|
show?: boolean;
|
||||||
|
sell?: boolean;
|
||||||
|
sort: number;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscribeGroupMapping = {
|
||||||
|
subscribe_id: number;
|
||||||
|
user_group_id: string;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type SubscribeGroupMappingInfo = {
|
||||||
|
id: number;
|
||||||
|
subscribe_id: number;
|
||||||
|
user_group_id: number;
|
||||||
|
subscribe?: Subscribe;
|
||||||
|
user_group?: UserGroup;
|
||||||
|
created_at: number;
|
||||||
|
updated_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupHistory = {
|
||||||
|
id: number;
|
||||||
|
group_mode: string;
|
||||||
|
trigger_type: string;
|
||||||
|
total_users: number;
|
||||||
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
start_time?: number;
|
||||||
|
end_time?: number;
|
||||||
|
operator?: string;
|
||||||
|
error_log?: string;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupHistoryDetailItem = {
|
||||||
|
id: number;
|
||||||
|
history_id: number;
|
||||||
|
user_group_id: string;
|
||||||
|
node_group_id: string;
|
||||||
|
user_count: number;
|
||||||
|
node_count: number;
|
||||||
|
user_data?: string;
|
||||||
|
created_at: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GroupHistoryDetail = {
|
||||||
|
id: number;
|
||||||
|
group_mode: string;
|
||||||
|
trigger_type: string;
|
||||||
|
total_users: number;
|
||||||
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
start_time?: number;
|
||||||
|
end_time?: number;
|
||||||
|
operator?: string;
|
||||||
|
error_log?: string;
|
||||||
|
created_at: number;
|
||||||
|
config_snapshot?: {
|
||||||
|
group_details?: GroupHistoryDetailItem[];
|
||||||
|
config?: Record<string, unknown>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecalculationState = {
|
||||||
|
state: string;
|
||||||
|
progress: number;
|
||||||
|
total: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// ===== Group Request/Response Types =====
|
||||||
|
|
||||||
|
type GetUserGroupListRequest = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
group_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetUserGroupListResponse = {
|
||||||
|
total: number;
|
||||||
|
list: UserGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateUserGroupRequest = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sort?: number;
|
||||||
|
node_group_id?: number | null;
|
||||||
|
for_calculation?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateUserGroupRequest = {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
sort?: number;
|
||||||
|
node_group_id?: number | null;
|
||||||
|
for_calculation?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteUserGroupRequest = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BindNodeGroupsRequest = {
|
||||||
|
user_group_ids: number[];
|
||||||
|
node_group_id?: number | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetNodeGroupListRequest = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
group_id?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetNodeGroupListResponse = {
|
||||||
|
total: number;
|
||||||
|
list: NodeGroup[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type CreateNodeGroupRequest = {
|
||||||
|
name: string;
|
||||||
|
description?: string;
|
||||||
|
sort?: number;
|
||||||
|
for_calculation?: boolean;
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateNodeGroupRequest = {
|
||||||
|
id: number;
|
||||||
|
name?: string;
|
||||||
|
description?: string;
|
||||||
|
sort?: number;
|
||||||
|
for_calculation?: boolean;
|
||||||
|
min_traffic_gb?: number;
|
||||||
|
max_traffic_gb?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type DeleteNodeGroupRequest = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetSubscribeMappingRequest = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
subscribe_id?: number;
|
||||||
|
user_group_id?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetSubscribeMappingResponse = {
|
||||||
|
total: number;
|
||||||
|
list: SubscribeGroupMappingInfo[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateSubscribeMappingRequest = {
|
||||||
|
subscribe_id: number;
|
||||||
|
user_group_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetGroupConfigResponse = {
|
||||||
|
enabled: boolean;
|
||||||
|
mode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UpdateGroupConfigRequest = {
|
||||||
|
enabled?: boolean;
|
||||||
|
mode?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RecalculateGroupRequest = {
|
||||||
|
mode: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetGroupHistoryRequest = {
|
||||||
|
page: number;
|
||||||
|
size: number;
|
||||||
|
group_mode?: string;
|
||||||
|
trigger_type?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetGroupHistoryResponse = {
|
||||||
|
total: number;
|
||||||
|
list: GroupHistory[];
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetGroupHistoryDetailRequest = {
|
||||||
|
id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GetGroupHistoryDetailResponse = GroupHistoryDetail;
|
||||||
|
|
||||||
|
type ExportGroupResultRequest = {
|
||||||
|
history_id?: number;
|
||||||
|
};
|
||||||
|
type MigrateUsersRequest = {
|
||||||
|
from_user_group_id: number;
|
||||||
|
to_user_group_id: number;
|
||||||
|
include_locked?: boolean;
|
||||||
|
};
|
||||||
|
type MigrateUsersResponse = {
|
||||||
|
success_count: number;
|
||||||
|
failed_count: number;
|
||||||
|
};
|
||||||
|
type ResetGroupsRequest = {
|
||||||
|
confirm: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewUserNodesRequest = {
|
||||||
|
user_id: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PreviewUserNodesResponse = {
|
||||||
|
user_id: number;
|
||||||
|
node_groups: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
nodes: Array<{
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
address: string;
|
||||||
|
port: number;
|
||||||
|
}>;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user