Pārlūkot izejas kodu

refactor(clients): coherent group management — rename, split, extract

This bundles a set of group-related improvements that built up across
one session and only make sense together.

Terminology / API surface:
- Rename "assign group" → "add to group" everywhere: i18n keys,
  callback names (bulkAddToGroup), component + file names
  (BulkAddToGroupModal, AddClientsToGroupModal), Go controller/struct
  names (bulkAddToGroup, AddToGroup), OpenAPI summaries. Nothing keeps
  the word "assign" anymore.
- Move group routes under /panel/api/clients/groups/* (was
  /bulkAssignGroup at the clients root).
- Split add and remove into two endpoints: /groups/bulkAdd now rejects
  empty group; new /groups/bulkRemove clears the label for the given
  emails. The old "submit empty to clear" UX is gone — Ungroup is its
  own action.

UI affordances on Clients page:
- Promote Group + Ungroup to visible bar buttons next to Attach +
  Detach. Group reuses BulkAddToGroupModal; Ungroup pops a danger
  confirm and calls bulkRemoveFromGroup.
- Custom UngroupIcon (TagsOutlined with a diagonal strike) for the
  Ungroup button so the pairing reads at a glance.
- Hide the Group column when no clients have a group label yet —
  removes a column of em-dashes on fresh installs.

UI on Groups page:
- New per-row Add clients… / Remove clients… actions backed by
  GroupAddClientsModal and GroupRemoveClientsModal: rich client picker
  (email / comment / current group / enable) with search and
  preserveSelectedRowKeys, mirroring the inbounds Attach modal UX.

Controller split:
- Move all /groups/* routes, handlers, and request bodies out of
  web/controller/client.go into a dedicated web/controller/group.go
  (GroupController with leaner clientService + xrayService
  dependencies). URLs are byte-identical because the new controller
  registers on the same parent gin.RouterGroup; api_docs_test.go gets
  a group.go → /panel/api/clients basePath entry so its route
  extraction keeps working.

Invalidation dedup:
- Removing a client from a group on the Groups page used to refetch
  /clients/groups and /clients/onlines three times: once from the
  mutation's onSuccess, once from a redundant invalidate() in the
  page's onSubmit, once from the WebSocket invalidate broadcast that
  the backend fires after every mutation. The manual invalidate() is
  gone, and a small invalidationTracker module lets websocketBridge
  skip WS-driven invalidates that arrive within 1.5s of a local
  invalidate — bringing the refetch count down to one. The WS path
  still works for changes made by another tab or user.
MHSanaei 12 stundas atpakaļ
vecāks
revīzija
530e338c66

+ 56 - 4
frontend/public/openapi.json

@@ -2852,13 +2852,13 @@
         }
       }
     },
-    "/panel/api/clients/bulkAssignGroup": {
+    "/panel/api/clients/groups/bulkAdd": {
       "post": {
         "tags": [
           "Clients"
         ],
-        "summary": "Assign the given group label to many clients in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound's settings JSON in a single transaction. Pass an empty group to clear the label. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group.",
-        "operationId": "post_panel_api_clients_bulkAssignGroup",
+        "summary": "Add many clients to a group in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound's settings JSON in a single transaction. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group. To clear the group label, use /groups/bulkRemove instead.",
+        "operationId": "post_panel_api_clients_groups_bulkAdd",
         "requestBody": {
           "required": true,
           "content": {
@@ -2905,6 +2905,58 @@
         }
       }
     },
+    "/panel/api/clients/groups/bulkRemove": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Clear the group label on many clients in one call. Inverse of /groups/bulkAdd. Clients themselves are kept — only the group label is cleared from clients.group_name and from each owning inbound's settings JSON. Groups become empty if all their members are removed.",
+        "operationId": "post_panel_api_clients_groups_bulkRemove",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "emails": [
+                  "alice",
+                  "bob"
+                ]
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "affected": 2
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/bulkAttach": {
       "post": {
         "tags": [
@@ -3178,7 +3230,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is assigned to it. Errors if a group with the same name already exists.",
+        "summary": "Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is added to it. Errors if a group with the same name already exists.",
         "operationId": "post_panel_api_clients_groups_create",
         "requestBody": {
           "required": true,

+ 9 - 0
frontend/src/api/invalidationTracker.ts

@@ -0,0 +1,9 @@
+let lastLocalInvalidateAt = 0;
+
+export function markLocalInvalidate(): void {
+  lastLocalInvalidateAt = Date.now();
+}
+
+export function isRecentLocalInvalidate(windowMs = 1500): boolean {
+  return Date.now() - lastLocalInvalidateAt < windowMs;
+}

+ 2 - 0
frontend/src/api/websocketBridge.ts

@@ -3,6 +3,7 @@ import { useQueryClient } from '@tanstack/react-query';
 
 import { WebSocketClient } from '@/api/websocket';
 import { keys } from '@/api/queryKeys';
+import { isRecentLocalInvalidate } from '@/api/invalidationTracker';
 
 type Handler = (payload: unknown) => void;
 
@@ -35,6 +36,7 @@ export function useWebSocketBridge() {
       if (invalidateTimer != null) clearTimeout(invalidateTimer);
       invalidateTimer = window.setTimeout(() => {
         invalidateTimer = null;
+        if (isRecentLocalInvalidate()) return;
         if (p.type === 'inbounds') {
           queryClient.invalidateQueries({ queryKey: ['inbounds'] });
         } else {

+ 25 - 10
frontend/src/hooks/useClients.ts

@@ -4,6 +4,7 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from '@tansta
 import { HttpUtil, Msg } from '@/utils';
 import { parseMsg } from '@/utils/zodValidate';
 import { keys } from '@/api/queryKeys';
+import { markLocalInvalidate } from '@/api/invalidationTracker';
 import {
   ClientHydrateSchema,
   ClientPageResponseSchema,
@@ -213,10 +214,13 @@ export function useClients() {
   // Inbounds page and any open edit modal pick up the new shape without
   // a manual reload.
   const invalidateAll = useCallback(
-    () => Promise.all([
-      queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
-      queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
-    ]),
+    () => {
+      markLocalInvalidate();
+      return Promise.all([
+        queryClient.invalidateQueries({ queryKey: keys.clients.root() }),
+        queryClient.invalidateQueries({ queryKey: keys.inbounds.root() }),
+      ]);
+    },
     [queryClient],
   );
 
@@ -238,9 +242,15 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
-  const bulkAssignGroupMut = useMutation({
+  const bulkAddToGroupMut = useMutation({
     mutationFn: (body: { emails: string[]; group: string }) =>
-      HttpUtil.post('/panel/api/clients/bulkAssignGroup', body, JSON_HEADERS),
+      HttpUtil.post('/panel/api/clients/groups/bulkAdd', body, JSON_HEADERS),
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
+  const bulkRemoveFromGroupMut = useMutation({
+    mutationFn: (body: { emails: string[] }) =>
+      HttpUtil.post('/panel/api/clients/groups/bulkRemove', body, JSON_HEADERS),
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
@@ -352,10 +362,14 @@ export function useClients() {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
     return bulkAdjustMut.mutateAsync({ emails, addDays, addBytes });
   }, [bulkAdjustMut]);
-  const bulkAssignGroup = useCallback((emails: string[], group: string) => {
+  const bulkAddToGroup = useCallback((emails: string[], group: string) => {
+    if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
+    return bulkAddToGroupMut.mutateAsync({ emails, group });
+  }, [bulkAddToGroupMut]);
+  const bulkRemoveFromGroup = useCallback((emails: string[]) => {
     if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null);
-    return bulkAssignGroupMut.mutateAsync({ emails, group });
-  }, [bulkAssignGroupMut]);
+    return bulkRemoveFromGroupMut.mutateAsync({ emails });
+  }, [bulkRemoveFromGroupMut]);
   const attach = useCallback((email: string, inboundIds: number[]) => {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
     return attachMut.mutateAsync({ email, inboundIds });
@@ -472,7 +486,8 @@ export function useClients() {
     remove,
     bulkDelete,
     bulkAdjust,
-    bulkAssignGroup,
+    bulkAddToGroup,
+    bulkRemoveFromGroup,
     attach,
     bulkAttach,
     detach,

+ 10 - 3
frontend/src/pages/api-docs/endpoints.ts

@@ -546,11 +546,18 @@ export const sections: readonly Section[] = [
       },
       {
         method: 'POST',
-        path: '/panel/api/clients/bulkAssignGroup',
-        summary: 'Assign the given group label to many clients in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound\'s settings JSON in a single transaction. Pass an empty group to clear the label. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group.',
+        path: '/panel/api/clients/groups/bulkAdd',
+        summary: 'Add many clients to a group in one call. Updates clients.group_name and patches the matching client entry inside every owning inbound\'s settings JSON in a single transaction. If the group name does not yet exist (in client_groups or as a derived label), it is auto-created as a persistent group. To clear the group label, use /groups/bulkRemove instead.',
         body: '{\n  "emails": ["alice", "bob"],\n  "group": "customer-a"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "affected": 2\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/groups/bulkRemove',
+        summary: 'Clear the group label on many clients in one call. Inverse of /groups/bulkAdd. Clients themselves are kept — only the group label is cleared from clients.group_name and from each owning inbound\'s settings JSON. Groups become empty if all their members are removed.',
+        body: '{\n  "emails": ["alice", "bob"]\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "affected": 2\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/bulkAttach',
@@ -598,7 +605,7 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/groups/create',
-        summary: 'Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is assigned to it. Errors if a group with the same name already exists.',
+        summary: 'Create a new empty (placeholder) group. The group becomes selectable in client forms and the filter drawer even before any client is added to it. Errors if a group with the same name already exists.',
         body: '{\n  "name": "customer-a"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "name": "customer-a"\n  }\n}',
       },

+ 10 - 12
frontend/src/pages/clients/BulkAssignGroupModal.tsx → frontend/src/pages/clients/BulkAddToGroupModal.tsx

@@ -2,7 +2,7 @@ import { useEffect, useState } from 'react';
 import { useTranslation } from 'react-i18next';
 import { AutoComplete, Form, Modal, message } from 'antd';
 
-interface BulkAssignGroupModalProps {
+interface BulkAddToGroupModalProps {
   open: boolean;
   count: number;
   groups: string[];
@@ -10,13 +10,13 @@ interface BulkAssignGroupModalProps {
   onSubmit: (group: string) => Promise<{ affected?: number } | null>;
 }
 
-export default function BulkAssignGroupModal({
+export default function BulkAddToGroupModal({
   open,
   count,
   groups,
   onOpenChange,
   onSubmit,
-}: BulkAssignGroupModalProps) {
+}: BulkAddToGroupModalProps) {
   const { t } = useTranslation();
   const [messageApi, messageContextHolder] = message.useMessage();
   const [value, setValue] = useState('');
@@ -28,16 +28,13 @@ export default function BulkAssignGroupModal({
 
   async function submit() {
     const next = value.trim();
+    if (!next) return;
     setSubmitting(true);
     try {
       const result = await onSubmit(next);
       if (result) {
         const affected = result.affected ?? 0;
-        if (next === '') {
-          messageApi.success(t('pages.clients.assignGroupClearedToast', { count: affected }));
-        } else {
-          messageApi.success(t('pages.clients.assignGroupAssignedToast', { count: affected, group: next }));
-        }
+        messageApi.success(t('pages.clients.addToGroupSuccessToast', { count: affected, group: next }));
         onOpenChange(false);
       }
     } finally {
@@ -50,10 +47,11 @@ export default function BulkAssignGroupModal({
       {messageContextHolder}
       <Modal
         open={open}
-        title={t('pages.clients.assignGroupTitle', { count })}
-        okText={t('save')}
+        title={t('pages.clients.addToGroupTitle', { count })}
+        okText={t('add')}
         cancelText={t('cancel')}
         confirmLoading={submitting}
+        okButtonProps={{ disabled: !value.trim() }}
         onCancel={() => onOpenChange(false)}
         onOk={submit}
         destroyOnHidden
@@ -61,11 +59,11 @@ export default function BulkAssignGroupModal({
         <Form layout="vertical">
           <Form.Item
             label={t('pages.clients.group')}
-            tooltip={t('pages.clients.assignGroupTooltip')}
+            tooltip={t('pages.clients.addToGroupTooltip')}
           >
             <AutoComplete
               value={value}
-              placeholder={t('pages.clients.assignGroupPlaceholder')}
+              placeholder={t('pages.clients.addToGroupPlaceholder')}
               options={groups.map((g) => ({ value: g }))}
               onChange={(v) => setValue(v ?? '')}
               filterOption={(input, option) =>

+ 71 - 11
frontend/src/pages/clients/ClientsPage.tsx

@@ -62,7 +62,7 @@ const ClientBulkAddModal = lazy(() => import('./ClientBulkAddModal'));
 const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal'));
 const FilterDrawer = lazy(() => import('./FilterDrawer'));
 const SubLinksModal = lazy(() => import('./SubLinksModal'));
-const BulkAssignGroupModal = lazy(() => import('./BulkAssignGroupModal'));
+const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
 const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
 import { emptyFilters, activeFilterCount } from './filters';
@@ -71,6 +71,45 @@ import './ClientsPage.css';
 
 const FILTER_STATE_KEY = 'clientsFilterState';
 
+function UngroupIcon() {
+  return (
+    <span
+      style={{
+        position: 'relative',
+        display: 'inline-flex',
+        alignItems: 'center',
+        justifyContent: 'center',
+        width: '1em',
+        height: '1em',
+      }}
+    >
+      <TagsOutlined />
+      <span
+        aria-hidden="true"
+        style={{
+          position: 'absolute',
+          inset: 0,
+          display: 'flex',
+          alignItems: 'center',
+          justifyContent: 'center',
+          pointerEvents: 'none',
+        }}
+      >
+        <span
+          style={{
+            display: 'block',
+            width: '125%',
+            height: '1.5px',
+            background: 'currentColor',
+            transform: 'rotate(-45deg)',
+            borderRadius: '1px',
+          }}
+        />
+      </span>
+    </span>
+  );
+}
+
 type Bucket = 'active' | 'deactive' | 'depleted' | 'expiring';
 
 interface PersistedFilterState {
@@ -152,7 +191,7 @@ export default function ClientsPage() {
     setQuery,
     inbounds, onlines, loading, fetched, subSettings,
     ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize,
-    create, update, remove, bulkDelete, bulkAdjust, bulkAssignGroup, attach, bulkAttach, detach, bulkDetach,
+    create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, bulkAttach, detach, bulkDetach,
     resetTraffic, resetAllTraffics, delDepleted, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     hydrate,
@@ -461,6 +500,26 @@ export default function ClientsPage() {
     });
   }
 
+  function onBulkUngroup() {
+    const emails = [...selectedRowKeys];
+    if (emails.length === 0) return;
+    modal.confirm({
+      title: t('pages.clients.ungroupConfirmTitle', { count: emails.length }),
+      content: t('pages.clients.ungroupConfirmContent'),
+      okText: t('confirm'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await bulkRemoveFromGroup(emails);
+        if (msg?.success) {
+          setSelectedRowKeys([]);
+          const affected = (msg.obj as { affected?: number } | undefined)?.affected ?? emails.length;
+          messageApi.success(t('pages.clients.ungroupSuccessToast', { count: affected }));
+        }
+      },
+    });
+  }
+
   function onBulkDelete() {
     const emails = [...selectedRowKeys];
     if (emails.length === 0) return;
@@ -586,6 +645,7 @@ export default function ClientsPage() {
       title: t('pages.clients.group'),
       key: 'group',
       width: 130,
+      hidden: allGroups.length === 0,
       render: (_v, record) => {
         if (!record.group) return <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>;
         const isActive = filters.groups.includes(record.group);
@@ -670,7 +730,7 @@ export default function ClientsPage() {
       ),
     },
     // eslint-disable-next-line react-hooks/exhaustive-deps
-  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters]);
+  ], [t, togglingEmail, clientBucket, isOnline, inboundsById, filters, allGroups]);
 
   const tablePagination = {
     current: currentPage,
@@ -803,6 +863,12 @@ export default function ClientsPage() {
                               <Button danger icon={<UsergroupDeleteOutlined />} onClick={() => setBulkDetachOpen(true)}>
                                 {!isMobile && t('pages.clients.detach')}
                               </Button>
+                              <Button icon={<TagsOutlined />} onClick={() => setBulkGroupOpen(true)}>
+                                {!isMobile && t('pages.clients.addToGroup')}
+                              </Button>
+                              <Button danger icon={<UngroupIcon />} onClick={onBulkUngroup}>
+                                {!isMobile && t('pages.clients.ungroup')}
+                              </Button>
                             </>
                           )}
                           <Dropdown
@@ -817,12 +883,6 @@ export default function ClientsPage() {
                                       label: t('pages.clients.adjust'),
                                       onClick: () => setBulkAdjustOpen(true),
                                     },
-                                    {
-                                      key: 'group',
-                                      icon: <TagsOutlined />,
-                                      label: t('pages.clients.group'),
-                                      onClick: () => setBulkGroupOpen(true),
-                                    },
                                     {
                                       key: 'subLinks',
                                       icon: <LinkOutlined />,
@@ -1181,13 +1241,13 @@ export default function ClientsPage() {
           />
         </LazyMount>
         <LazyMount when={bulkGroupOpen}>
-          <BulkAssignGroupModal
+          <BulkAddToGroupModal
             open={bulkGroupOpen}
             count={selectedRowKeys.length}
             groups={allGroups}
             onOpenChange={setBulkGroupOpen}
             onSubmit={async (group) => {
-              const msg = await bulkAssignGroup([...selectedRowKeys], group);
+              const msg = await bulkAddToGroup([...selectedRowKeys], group);
               if (msg?.success) {
                 setSelectedRowKeys([]);
                 return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };

+ 161 - 0
frontend/src/pages/groups/GroupAddClientsModal.tsx

@@ -0,0 +1,161 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Alert, Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import type { ClientRecord } from '@/hooks/useClients';
+
+interface GroupAddClientsModalProps {
+  open: boolean;
+  groupName: string | null;
+  candidates: ClientRecord[];
+  onClose: () => void;
+  onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
+}
+
+interface ClientRow {
+  email: string;
+  comment: string;
+  enable: boolean;
+  currentGroup: string;
+}
+
+export default function GroupAddClientsModal({
+  open,
+  groupName,
+  candidates,
+  onClose,
+  onSubmit,
+}: GroupAddClientsModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [saving, setSaving] = useState(false);
+  const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
+  const [search, setSearch] = useState('');
+
+  const rows = useMemo<ClientRow[]>(
+    () =>
+      (candidates || [])
+        .map((c) => ({
+          email: (c.email || '').trim(),
+          comment: (c.comment || '').trim(),
+          enable: c.enable !== false,
+          currentGroup: (c.group || '').trim(),
+        }))
+        .filter((r) => r.email),
+    [candidates],
+  );
+
+  useEffect(() => {
+    if (!open) return;
+    setSelectedEmails([]);
+    setSearch('');
+  }, [open, rows]);
+
+  const filteredRows = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    if (!q) return rows;
+    return rows.filter(
+      (r) =>
+        r.email.toLowerCase().includes(q) ||
+        r.comment.toLowerCase().includes(q) ||
+        r.currentGroup.toLowerCase().includes(q),
+    );
+  }, [rows, search]);
+
+  const columns: ColumnsType<ClientRow> = useMemo(
+    () => [
+      { title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
+      { title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
+      {
+        title: t('pages.clients.group'),
+        dataIndex: 'currentGroup',
+        key: 'currentGroup',
+        width: 140,
+        ellipsis: true,
+        render: (g: string) =>
+          g ? <Tag color="geekblue">{g}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>,
+      },
+      {
+        title: t('enable'),
+        dataIndex: 'enable',
+        key: 'enable',
+        width: 90,
+        render: (enabled: boolean) =>
+          enabled ? (
+            <Tag color="success">{t('enable')}</Tag>
+          ) : (
+            <Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
+          ),
+      },
+    ],
+    [t],
+  );
+
+  async function submit() {
+    if (!groupName || selectedEmails.length === 0) return;
+    setSaving(true);
+    try {
+      const result = await onSubmit(selectedEmails);
+      if (!result) return;
+      const affected = result.affected ?? selectedEmails.length;
+      messageApi.success(t('pages.groups.addToGroupResult', { count: affected, name: groupName }));
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  return (
+    <Modal
+      open={open}
+      onCancel={onClose}
+      onOk={submit}
+      okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
+      okText={t('add')}
+      cancelText={t('cancel')}
+      title={t('pages.groups.addToGroupTitle', { name: groupName ?? '' })}
+      width={720}
+    >
+      {messageContextHolder}
+      <Typography.Paragraph type="secondary">
+        {t('pages.groups.addToGroupDesc')}
+      </Typography.Paragraph>
+
+      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+        <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
+          <Input.Search
+            allowClear
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
+            style={{ maxWidth: 320 }}
+          />
+          <Typography.Text type="secondary">
+            {t('pages.inbounds.attachClientsSelectedCount', {
+              selected: selectedEmails.length,
+              total: rows.length,
+            })}
+          </Typography.Text>
+        </Space>
+        {rows.length === 0 ? (
+          <Alert type="info" showIcon message={t('pages.groups.addToGroupEmpty')} />
+        ) : (
+          <Table<ClientRow>
+            size="small"
+            rowKey="email"
+            columns={columns}
+            dataSource={filteredRows}
+            pagination={false}
+            scroll={{ y: 320 }}
+            rowSelection={{
+              selectedRowKeys: selectedEmails,
+              onChange: (keys) => setSelectedEmails(keys as string[]),
+              preserveSelectedRowKeys: true,
+            }}
+          />
+        )}
+      </Space>
+    </Modal>
+  );
+}

+ 145 - 0
frontend/src/pages/groups/GroupRemoveClientsModal.tsx

@@ -0,0 +1,145 @@
+import { useEffect, useMemo, useState } from 'react';
+import { useTranslation } from 'react-i18next';
+import { Input, Modal, Space, Table, Tag, Typography, message } from 'antd';
+import type { ColumnsType } from 'antd/es/table';
+
+import type { ClientRecord } from '@/hooks/useClients';
+
+interface GroupRemoveClientsModalProps {
+  open: boolean;
+  groupName: string | null;
+  members: ClientRecord[];
+  onClose: () => void;
+  onSubmit: (emails: string[]) => Promise<{ affected?: number } | null>;
+}
+
+interface ClientRow {
+  email: string;
+  comment: string;
+  enable: boolean;
+}
+
+export default function GroupRemoveClientsModal({
+  open,
+  groupName,
+  members,
+  onClose,
+  onSubmit,
+}: GroupRemoveClientsModalProps) {
+  const { t } = useTranslation();
+  const [messageApi, messageContextHolder] = message.useMessage();
+  const [saving, setSaving] = useState(false);
+  const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
+  const [search, setSearch] = useState('');
+
+  const rows = useMemo<ClientRow[]>(
+    () =>
+      (members || [])
+        .map((c) => ({
+          email: (c.email || '').trim(),
+          comment: (c.comment || '').trim(),
+          enable: c.enable !== false,
+        }))
+        .filter((r) => r.email),
+    [members],
+  );
+
+  useEffect(() => {
+    if (!open) return;
+    setSelectedEmails([]);
+    setSearch('');
+  }, [open, rows]);
+
+  const filteredRows = useMemo(() => {
+    const q = search.trim().toLowerCase();
+    if (!q) return rows;
+    return rows.filter(
+      (r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q),
+    );
+  }, [rows, search]);
+
+  const columns: ColumnsType<ClientRow> = useMemo(
+    () => [
+      { title: t('pages.inbounds.email'), dataIndex: 'email', key: 'email', ellipsis: true },
+      { title: t('comment'), dataIndex: 'comment', key: 'comment', ellipsis: true },
+      {
+        title: t('enable'),
+        dataIndex: 'enable',
+        key: 'enable',
+        width: 90,
+        render: (enabled: boolean) =>
+          enabled ? (
+            <Tag color="success">{t('enable')}</Tag>
+          ) : (
+            <Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
+          ),
+      },
+    ],
+    [t],
+  );
+
+  async function submit() {
+    if (!groupName || selectedEmails.length === 0) return;
+    setSaving(true);
+    try {
+      const result = await onSubmit(selectedEmails);
+      if (!result) return;
+      const affected = result.affected ?? selectedEmails.length;
+      messageApi.success(
+        t('pages.groups.removeFromGroupResult', { count: affected, name: groupName }),
+      );
+      onClose();
+    } finally {
+      setSaving(false);
+    }
+  }
+
+  return (
+    <Modal
+      open={open}
+      onCancel={onClose}
+      onOk={submit}
+      okButtonProps={{ danger: true, disabled: selectedEmails.length === 0, loading: saving }}
+      okText={t('remove')}
+      cancelText={t('cancel')}
+      title={t('pages.groups.removeFromGroupTitle', { name: groupName ?? '' })}
+      width={680}
+    >
+      {messageContextHolder}
+      <Typography.Paragraph type="secondary">
+        {t('pages.groups.removeFromGroupDesc')}
+      </Typography.Paragraph>
+
+      <Space direction="vertical" size="small" style={{ width: '100%' }}>
+        <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
+          <Input.Search
+            allowClear
+            value={search}
+            onChange={(e) => setSearch(e.target.value)}
+            placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
+            style={{ maxWidth: 320 }}
+          />
+          <Typography.Text type="secondary">
+            {t('pages.inbounds.attachClientsSelectedCount', {
+              selected: selectedEmails.length,
+              total: rows.length,
+            })}
+          </Typography.Text>
+        </Space>
+        <Table<ClientRow>
+          size="small"
+          rowKey="email"
+          columns={columns}
+          dataSource={filteredRows}
+          pagination={false}
+          scroll={{ y: 280 }}
+          rowSelection={{
+            selectedRowKeys: selectedEmails,
+            onChange: (keys) => setSelectedEmails(keys as string[]),
+            preserveSelectedRowKeys: true,
+          }}
+        />
+      </Space>
+    </Modal>
+  );
+}

+ 67 - 1
frontend/src/pages/groups/GroupsPage.tsx

@@ -30,6 +30,8 @@ import {
   RetweetOutlined,
   TagsOutlined,
   TeamOutlined,
+  UsergroupAddOutlined,
+  UsergroupDeleteOutlined,
 } from '@ant-design/icons';
 import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
 
@@ -47,6 +49,8 @@ import { parseMsg } from '@/utils/zodValidate';
 
 const SubLinksModal = lazy(() => import('../clients/SubLinksModal'));
 const ClientBulkAdjustModal = lazy(() => import('../clients/ClientBulkAdjustModal'));
+const GroupAddClientsModal = lazy(() => import('./GroupAddClientsModal'));
+const GroupRemoveClientsModal = lazy(() => import('./GroupRemoveClientsModal'));
 
 const JSON_HEADERS = { headers: { 'Content-Type': 'application/json' } } as const;
 
@@ -77,7 +81,7 @@ export default function GroupsPage() {
   useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
   const queryClient = useQueryClient();
 
-  const { clients, subSettings, bulkAdjust, bulkDelete } = useClients();
+  const { clients, subSettings, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, bulkDelete } = useClients();
 
   const groupsQuery = useQuery({
     queryKey: keys.clients.groups(),
@@ -124,6 +128,8 @@ export default function GroupsPage() {
 
   const [subLinksOpen, setSubLinksOpen] = useState(false);
   const [adjustOpen, setAdjustOpen] = useState(false);
+  const [addClientsOpen, setAddClientsOpen] = useState(false);
+  const [removeClientsOpen, setRemoveClientsOpen] = useState(false);
   const [groupEmails, setGroupEmails] = useState<string[]>([]);
   const [groupForAction, setGroupForAction] = useState<GroupSummary | null>(null);
 
@@ -228,6 +234,20 @@ export default function GroupsPage() {
     setAdjustOpen(true);
   }
 
+  function openAddClientsFor(g: GroupSummary) {
+    setGroupForAction(g);
+    setAddClientsOpen(true);
+  }
+
+  function openRemoveClientsFor(g: GroupSummary) {
+    if (!g.clientCount) {
+      messageApi.info(t('pages.groups.emptyForAction'));
+      return;
+    }
+    setGroupForAction(g);
+    setRemoveClientsOpen(true);
+  }
+
   function onDeleteClients(g: GroupSummary) {
     if (!g.clientCount) {
       messageApi.info(t('pages.groups.emptyForAction'));
@@ -306,6 +326,20 @@ export default function GroupsPage() {
         disabled: !row.clientCount,
         onClick: () => onResetTraffic(row),
       },
+      {
+        key: 'addClients',
+        icon: <UsergroupAddOutlined />,
+        label: t('pages.groups.addToGroup'),
+        onClick: () => openAddClientsFor(row),
+      },
+      {
+        key: 'removeClients',
+        icon: <UsergroupDeleteOutlined />,
+        label: t('pages.groups.removeFromGroup'),
+        danger: true,
+        disabled: !row.clientCount,
+        onClick: () => openRemoveClientsFor(row),
+      },
       { type: 'divider' },
       {
         key: 'rename',
@@ -522,6 +556,38 @@ export default function GroupsPage() {
             }}
           />
         </LazyMount>
+
+        <LazyMount when={addClientsOpen}>
+          <GroupAddClientsModal
+            open={addClientsOpen}
+            groupName={groupForAction?.name ?? null}
+            candidates={clients.filter((c) => c.group !== groupForAction?.name)}
+            onClose={() => setAddClientsOpen(false)}
+            onSubmit={async (emails) => {
+              const msg = await bulkAddToGroup(emails, groupForAction?.name ?? '');
+              if (msg?.success) {
+                return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
+              }
+              return null;
+            }}
+          />
+        </LazyMount>
+
+        <LazyMount when={removeClientsOpen}>
+          <GroupRemoveClientsModal
+            open={removeClientsOpen}
+            groupName={groupForAction?.name ?? null}
+            members={clients.filter((c) => c.group === groupForAction?.name)}
+            onClose={() => setRemoveClientsOpen(false)}
+            onSubmit={async (emails) => {
+              const msg = await bulkRemoveFromGroup(emails);
+              if (msg?.success) {
+                return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
+              }
+              return null;
+            }}
+          />
+        </LazyMount>
       </Layout>
     </ConfigProvider>
   );

+ 9 - 9
frontend/src/pages/inbounds/AssignClientsGroupModal.tsx → frontend/src/pages/inbounds/AddClientsToGroupModal.tsx

@@ -3,13 +3,13 @@ import { lazy, useEffect, useMemo, useState } from 'react';
 import { HttpUtil } from '@/utils';
 import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound';
 
-const BulkAssignGroupModal = lazy(() => import('@/pages/clients/BulkAssignGroupModal'));
+const BulkAddToGroupModal = lazy(() => import('@/pages/clients/BulkAddToGroupModal'));
 
-interface AssignClientsGroupModalProps {
+interface AddClientsToGroupModalProps {
   open: boolean;
   source: DBInbound | null;
   onClose: () => void;
-  onAssigned?: () => void;
+  onAdded?: () => void;
 }
 
 function readClientEmails(settings: unknown): string[] {
@@ -18,12 +18,12 @@ function readClientEmails(settings: unknown): string[] {
   return clients.map((c) => (c?.email || '').trim()).filter(Boolean);
 }
 
-export default function AssignClientsGroupModal({
+export default function AddClientsToGroupModal({
   open,
   source,
   onClose,
-  onAssigned,
-}: AssignClientsGroupModalProps) {
+  onAdded,
+}: AddClientsToGroupModalProps) {
   const [groups, setGroups] = useState<string[]>([]);
 
   const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]);
@@ -41,19 +41,19 @@ export default function AssignClientsGroupModal({
   }, [open]);
 
   return (
-    <BulkAssignGroupModal
+    <BulkAddToGroupModal
       open={open}
       count={emails.length}
       groups={groups}
       onOpenChange={(o) => { if (!o) onClose(); }}
       onSubmit={async (group) => {
         const msg = await HttpUtil.post(
-          '/panel/api/clients/bulkAssignGroup',
+          '/panel/api/clients/groups/bulkAdd',
           { emails, group },
           { headers: { 'Content-Type': 'application/json' } },
         );
         if (!msg?.success) return null;
-        onAssigned?.();
+        onAdded?.();
         return (msg.obj as { affected?: number } | undefined) ?? { affected: 0 };
       }}
     />

+ 1 - 1
frontend/src/pages/inbounds/InboundList.tsx

@@ -263,7 +263,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r
   if (isInboundMultiUser(record) && hasClients) {
     items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
     items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
-    items.push({ key: 'assignGroup', icon: <TagsOutlined />, label: t('pages.inbounds.assignClientsGroup') });
+    items.push({ key: 'addToGroup', icon: <TagsOutlined />, label: t('pages.inbounds.addClientsToGroup') });
     items.push({ key: 'delAllClients', icon: <UsergroupDeleteOutlined />, danger: true, label: t('pages.inbounds.delAllClients') });
   }
   items.push({ key: 'delete', icon: <DeleteOutlined />, danger: true, label: t('delete') });

+ 6 - 6
frontend/src/pages/inbounds/InboundsPage.tsx

@@ -40,7 +40,7 @@ const InboundInfoModal = lazy(() => import('./InboundInfoModal'));
 const QrCodeModal = lazy(() => import('./QrCodeModal'));
 const AttachClientsModal = lazy(() => import('./AttachClientsModal'));
 const DetachClientsModal = lazy(() => import('./DetachClientsModal'));
-const AssignClientsGroupModal = lazy(() => import('./AssignClientsGroupModal'));
+const AddClientsToGroupModal = lazy(() => import('./AddClientsToGroupModal'));
 
 type RowAction =
   | 'edit'
@@ -54,7 +54,7 @@ type RowAction =
   | 'delAllClients'
   | 'attachClients'
   | 'detachClients'
-  | 'assignGroup'
+  | 'addToGroup'
   | 'clone';
 
 type GeneralAction = 'import' | 'export' | 'subs' | 'resetInbounds';
@@ -452,7 +452,7 @@ export default function InboundsPage() {
     // Actions that touch per-client secrets (uuid, password, flow, ...) need
     // the full payload that the slim list view does not ship. Hydrate first
     // and then operate on the rehydrated record.
-    const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'assignGroup'];
+    const hydratingKeys: RowAction[] = ['edit', 'showInfo', 'qrcode', 'export', 'subs', 'clipboard', 'clone', 'attachClients', 'addToGroup'];
     let target = dbInbound;
     if (hydratingKeys.includes(key)) {
       const hydrated = await hydrateInbound(dbInbound.id);
@@ -497,7 +497,7 @@ export default function InboundsPage() {
         setDetachSource(target);
         setDetachOpen(true);
         break;
-      case 'assignGroup':
+      case 'addToGroup':
         setGroupSource(target);
         setGroupOpen(true);
         break;
@@ -631,10 +631,10 @@ export default function InboundsPage() {
           />
         </LazyMount>
         <LazyMount when={groupOpen}>
-          <AssignClientsGroupModal
+          <AddClientsToGroupModal
             open={groupOpen}
             onClose={() => setGroupOpen(false)}
-            onAssigned={refresh}
+            onAdded={refresh}
             source={groupSource}
           />
         </LazyMount>

+ 1 - 0
web/controller/api.go

@@ -67,6 +67,7 @@ func (a *APIController) initRouter(g *gin.RouterGroup, customGeo *service.Custom
 
 	clients := api.Group("/clients")
 	NewClientController(clients)
+	NewGroupController(clients)
 
 	// Server API
 	server := api.Group("/server")

+ 2 - 0
web/controller/api_docs_test.go

@@ -89,6 +89,8 @@ func TestAPIRoutesDocumented(t *testing.T) {
 			basePath = "/panel/api/inbounds"
 		case "client.go":
 			basePath = "/panel/api/clients"
+		case "group.go":
+			basePath = "/panel/api/clients"
 		case "server.go":
 			basePath = "/panel/api/server"
 		case "node.go":

+ 1 - 107
web/controller/client.go

@@ -47,22 +47,15 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/bulkAdjust", a.bulkAdjust)
 	g.POST("/bulkDel", a.bulkDelete)
 	g.POST("/bulkCreate", a.bulkCreate)
-	g.POST("/bulkAssignGroup", a.bulkAssignGroup)
 	g.POST("/bulkAttach", a.bulkAttach)
 	g.POST("/bulkDetach", a.bulkDetach)
+	g.POST("/bulkResetTraffic", a.bulkResetTraffic)
 	g.POST("/resetTraffic/:email", a.resetTrafficByEmail)
 	g.POST("/updateTraffic/:email", a.updateTrafficByEmail)
 	g.POST("/ips/:email", a.getIps)
 	g.POST("/clearIps/:email", a.clearIps)
 	g.POST("/onlines", a.onlines)
 	g.POST("/lastOnline", a.lastOnline)
-
-	g.GET("/groups", a.listGroups)
-	g.GET("/groups/:name/emails", a.groupEmails)
-	g.POST("/groups/create", a.createGroup)
-	g.POST("/groups/rename", a.renameGroup)
-	g.POST("/groups/delete", a.deleteGroup)
-	g.POST("/bulkResetTraffic", a.bulkResetTraffic)
 }
 
 func (a *ClientController) list(c *gin.Context) {
@@ -220,27 +213,6 @@ type bulkDeleteRequest struct {
 	KeepTraffic bool     `json:"keepTraffic"`
 }
 
-type bulkAssignGroupRequest struct {
-	Emails []string `json:"emails"`
-	Group  string   `json:"group"`
-}
-
-func (a *ClientController) bulkAssignGroup(c *gin.Context) {
-	var req bulkAssignGroupRequest
-	if err := c.ShouldBindJSON(&req); err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	affected, err := a.clientService.AssignGroup(req.Emails, req.Group)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonObj(c, gin.H{"affected": affected}, nil)
-	a.xrayService.SetToNeedRestart()
-	notifyClientsChanged()
-}
-
 type bulkAttachRequest struct {
 	Emails     []string `json:"emails"`
 	InboundIds []int    `json:"inboundIds"`
@@ -471,25 +443,6 @@ func (a *ClientController) detach(c *gin.Context) {
 	notifyClientsChanged()
 }
 
-func (a *ClientController) listGroups(c *gin.Context) {
-	rows, err := a.clientService.ListGroups()
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonObj(c, rows, nil)
-}
-
-func (a *ClientController) groupEmails(c *gin.Context) {
-	name := c.Param("name")
-	emails, err := a.clientService.EmailsByGroup(name)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonObj(c, emails, nil)
-}
-
 type bulkResetRequest struct {
 	Emails []string `json:"emails"`
 }
@@ -509,62 +462,3 @@ func (a *ClientController) bulkResetTraffic(c *gin.Context) {
 	a.xrayService.SetToNeedRestart()
 	notifyClientsChanged()
 }
-
-type groupCreateBody struct {
-	Name string `json:"name"`
-}
-
-func (a *ClientController) createGroup(c *gin.Context) {
-	var body groupCreateBody
-	if err := c.ShouldBindJSON(&body); err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	if err := a.clientService.CreateGroup(body.Name); err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	jsonObj(c, gin.H{"name": body.Name}, nil)
-	notifyClientsChanged()
-}
-
-type groupRenameBody struct {
-	OldName string `json:"oldName"`
-	NewName string `json:"newName"`
-}
-
-func (a *ClientController) renameGroup(c *gin.Context) {
-	var body groupRenameBody
-	if err := c.ShouldBindJSON(&body); err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	a.xrayService.SetToNeedRestart()
-	jsonObj(c, gin.H{"affected": affected}, nil)
-	notifyClientsChanged()
-}
-
-type groupDeleteBody struct {
-	Name string `json:"name"`
-}
-
-func (a *ClientController) deleteGroup(c *gin.Context) {
-	var body groupDeleteBody
-	if err := c.ShouldBindJSON(&body); err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	affected, err := a.clientService.DeleteGroup(body.Name)
-	if err != nil {
-		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
-		return
-	}
-	a.xrayService.SetToNeedRestart()
-	jsonObj(c, gin.H{"affected": affected}, nil)
-	notifyClientsChanged()
-}

+ 154 - 0
web/controller/group.go

@@ -0,0 +1,154 @@
+package controller
+
+import (
+	"strings"
+
+	"github.com/mhsanaei/3x-ui/v3/util/common"
+	"github.com/mhsanaei/3x-ui/v3/web/service"
+
+	"github.com/gin-gonic/gin"
+)
+
+type GroupController struct {
+	clientService service.ClientService
+	xrayService   service.XrayService
+}
+
+func NewGroupController(g *gin.RouterGroup) *GroupController {
+	a := &GroupController{}
+	a.initRouter(g)
+	return a
+}
+
+func (a *GroupController) initRouter(g *gin.RouterGroup) {
+	g.GET("/groups", a.list)
+	g.GET("/groups/:name/emails", a.emails)
+	g.POST("/groups/create", a.create)
+	g.POST("/groups/rename", a.rename)
+	g.POST("/groups/delete", a.delete)
+	g.POST("/groups/bulkAdd", a.bulkAdd)
+	g.POST("/groups/bulkRemove", a.bulkRemove)
+}
+
+func (a *GroupController) list(c *gin.Context) {
+	rows, err := a.clientService.ListGroups()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, rows, nil)
+}
+
+func (a *GroupController) emails(c *gin.Context) {
+	name := c.Param("name")
+	emails, err := a.clientService.EmailsByGroup(name)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, emails, nil)
+}
+
+type groupCreateBody struct {
+	Name string `json:"name"`
+}
+
+func (a *GroupController) create(c *gin.Context) {
+	var body groupCreateBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if err := a.clientService.CreateGroup(body.Name); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"name": body.Name}, nil)
+	notifyClientsChanged()
+}
+
+type groupRenameBody struct {
+	OldName string `json:"oldName"`
+	NewName string `json:"newName"`
+}
+
+func (a *GroupController) rename(c *gin.Context) {
+	var body groupRenameBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.RenameGroup(body.OldName, body.NewName)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	a.xrayService.SetToNeedRestart()
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	notifyClientsChanged()
+}
+
+type groupDeleteBody struct {
+	Name string `json:"name"`
+}
+
+func (a *GroupController) delete(c *gin.Context) {
+	var body groupDeleteBody
+	if err := c.ShouldBindJSON(&body); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.DeleteGroup(body.Name)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	a.xrayService.SetToNeedRestart()
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	notifyClientsChanged()
+}
+
+type bulkAddToGroupRequest struct {
+	Emails []string `json:"emails"`
+	Group  string   `json:"group"`
+}
+
+func (a *GroupController) bulkAdd(c *gin.Context) {
+	var req bulkAddToGroupRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	if strings.TrimSpace(req.Group) == "" {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), common.NewError("group name is required"))
+		return
+	}
+	affected, err := a.clientService.AddToGroup(req.Emails, req.Group)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	a.xrayService.SetToNeedRestart()
+	notifyClientsChanged()
+}
+
+type bulkRemoveFromGroupRequest struct {
+	Emails []string `json:"emails"`
+}
+
+func (a *GroupController) bulkRemove(c *gin.Context) {
+	var req bulkRemoveFromGroupRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	affected, err := a.clientService.RemoveFromGroup(req.Emails)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"affected": affected}, nil)
+	a.xrayService.SetToNeedRestart()
+	notifyClientsChanged()
+}

+ 5 - 1
web/service/client.go

@@ -1402,7 +1402,11 @@ func (s *ClientService) DeleteGroup(name string) (int, error) {
 	return s.replaceGroupValue(name, "")
 }
 
-func (s *ClientService) AssignGroup(emails []string, group string) (int, error) {
+func (s *ClientService) RemoveFromGroup(emails []string) (int, error) {
+	return s.AddToGroup(emails, "")
+}
+
+func (s *ClientService) AddToGroup(emails []string, group string) (int, error) {
 	group = strings.TrimSpace(group)
 	if len(emails) == 0 {
 		return 0, nil

+ 22 - 8
web/translation/en-US.json

@@ -8,6 +8,8 @@
   "save": "Save",
   "logout": "Log Out",
   "create": "Create",
+  "add": "Add",
+  "remove": "Remove",
   "update": "Update",
   "copy": "Copy",
   "copied": "Copied",
@@ -299,7 +301,7 @@
       "delAllClientsConfirmTitle": "Delete all {count} clients from \"{remark}\"?",
       "delAllClientsConfirmContent": "This removes every client from this inbound and drops their traffic records. The inbound itself is kept. This cannot be undone.",
       "attachClients": "Attach Clients To…",
-      "assignClientsGroup": "Assign Clients To Group…",
+      "addClientsToGroup": "Add Clients To Group…",
       "attachClientsTitle": "Attach clients from \"{remark}\"",
       "attachClientsDesc": "Attaches the same {count} clients (same UUID/password and shared traffic) to the selected inbound(s). They stay on this inbound too.",
       "attachClientsTargets": "Target inbounds",
@@ -536,12 +538,15 @@
       "deleteSelected": "Delete ({count})",
       "adjustSelected": "Adjust ({count})",
       "subLinksSelected": "Sub links ({count})",
-      "assignGroupSelected": "Group ({count})",
-      "assignGroupTitle": "Assign group to {count} client(s)",
-      "assignGroupTooltip": "Pick an existing group or type a new name. Leave blank to clear the group on the selected clients.",
-      "assignGroupPlaceholder": "Group name (leave blank to clear)",
-      "assignGroupAssignedToast": "Assigned {count} client(s) to {group}",
-      "assignGroupClearedToast": "Cleared group from {count} client(s)",
+      "addToGroupTitle": "Add {count} client(s) to a group",
+      "addToGroupTooltip": "Pick an existing group or type a new name. Use the Ungroup action to remove clients from their current group.",
+      "addToGroupPlaceholder": "Group name",
+      "addToGroupSuccessToast": "Added {count} client(s) to {group}",
+      "ungroupSuccessToast": "Cleared group from {count} client(s)",
+      "ungroup": "Ungroup",
+      "ungroupConfirmTitle": "Remove {count} client(s) from their group?",
+      "ungroupConfirmContent": "Clears the group label on each selected client. Clients themselves are kept (use Delete to remove them entirely).",
+      "addToGroup": "Add to group",
       "attach": "Attach",
       "adjust": "Adjust",
       "subLinks": "Sub links",
@@ -629,7 +634,16 @@
       "deleteClientsConfirmTitle": "Delete all clients in {name}?",
       "deleteClientsConfirmContent": "This permanently removes {count} client(s) along with their traffic records. The group label is cleared too. This cannot be undone.",
       "deleteClientsSuccess": "Deleted {count} client(s).",
-      "deleteClientsMixed": "{ok} deleted, {failed} skipped"
+      "deleteClientsMixed": "{ok} deleted, {failed} skipped",
+      "addToGroup": "Add clients…",
+      "addToGroupTitle": "Add clients to group \"{name}\"",
+      "addToGroupDesc": "Select clients to add to this group. They keep their existing inbound attachments; only the group label changes. Clients already in this group are not listed.",
+      "addToGroupEmpty": "No other clients available to add.",
+      "addToGroupResult": "Added {count} client(s) to {name}.",
+      "removeFromGroup": "Remove clients…",
+      "removeFromGroupTitle": "Remove clients from group \"{name}\"",
+      "removeFromGroupDesc": "Select members to remove from this group. Clients themselves are kept (use \"Delete clients in group\" to remove them entirely).",
+      "removeFromGroupResult": "Removed {count} client(s) from {name}."
     },
     "nodes": {
       "title": "Nodes",

+ 7 - 1
web/translation/fa-IR.json

@@ -8,6 +8,8 @@
   "save": "ذخیره",
   "logout": "خروج",
   "create": "ایجاد",
+  "add": "افزودن",
+  "remove": "حذف",
   "update": "به‌روزرسانی",
   "copy": "کپی",
   "copied": "کپی شد",
@@ -294,7 +296,7 @@
       "delAllClientsConfirmTitle": "حذف هر {count} کلاینت اینباند «{remark}»؟",
       "delAllClientsConfirmContent": "تمام کلاینت‌های این اینباند به همراه رکوردهای ترافیک‌شان حذف می‌شوند. خود اینباند باقی می‌ماند. این عمل غیرقابل بازگشت است.",
       "attachClients": "اتصال کلاینت‌ها به…",
-      "assignClientsGroup": "افزودن کلاینت‌ها به گروه…",
+      "addClientsToGroup": "افزودن کلاینت‌ها به گروه…",
       "attachClientsTitle": "اتصال کلاینت‌های «{remark}»",
       "attachClientsDesc": "همان {count} کلاینت (با همان UUID/پسورد و ترافیک مشترک) را به اینباند(های) انتخاب‌شده هم متصل می‌کند. روی این اینباند هم باقی می‌مانند.",
       "attachClientsTargets": "اینباندهای مقصد",
@@ -517,6 +519,10 @@
       "adjust": "تنظیم",
       "subLinks": "لینک‌های ساب",
       "selectedCount": "{count} انتخاب‌شده",
+      "ungroup": "حذف گروه",
+      "ungroupConfirmTitle": "{count} کلاینت از گروهشان حذف شود؟",
+      "ungroupConfirmContent": "برچسب گروه از هر کلاینت انتخاب‌شده پاک می‌شود. خود کلاینت‌ها حفظ می‌شوند (برای حذف کامل، از Delete استفاده کنید).",
+      "addToGroup": "افزودن به گروه",
       "attachSelected": "اتصال ({count})",
       "attachToInboundsTitle": "اتصال {count} کلاینت به اینباند(ها)",
       "attachToInboundsDesc": "{count} کلاینت انتخاب‌شده (با همان UUID/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل می‌شوند. روی اینباندهای فعلی هم باقی می‌مانند.",