Ver código fonte

feat(clients): orphan cleanup + export/import via CodeMirror modals

Add three client-management actions to the Clients page More menu:

- Delete unattached clients: removes every client with no inbound
  attachment, cascading its traffic rows, IP log, and external links
  (POST /clients/delOrphans).
- Export clients: shows the {client, inboundIds} list in a read-only
  CodeMirror viewer with copy/download (GET /clients/export returns the
  array in the standard envelope).
- Import clients: pastes that JSON into an editable CodeMirror editor,
  mirroring Import an Inbound (POST /clients/import takes a { data }
  body). Attached clients go through the create-and-attach path; items
  with no inboundIds are restored as bare records; existing emails are
  never overwritten and are reported as skipped.

Document the new endpoints in api-docs and translate the new strings
into all supported languages.
MHSanaei 12 horas atrás
pai
commit
0b0b6250d6

+ 140 - 0
frontend/public/openapi.json

@@ -5262,6 +5262,146 @@
         }
       }
     },
+    "/panel/api/clients/delOrphans": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.",
+        "operationId": "post_panel_api_clients_delOrphans",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "deleted": 0
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/export": {
+      "get": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.",
+        "operationId": "get_panel_api_clients_export",
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": [
+                    {
+                      "client": {
+                        "email": "[email protected]",
+                        "id": "...",
+                        "totalGB": 53687091200,
+                        "expiryTime": 0,
+                        "enable": true,
+                        "subId": "..."
+                      },
+                      "inboundIds": [
+                        7,
+                        9
+                      ]
+                    }
+                  ]
+                }
+              }
+            }
+          }
+        }
+      }
+    },
+    "/panel/api/clients/import": {
+      "post": {
+        "tags": [
+          "Clients"
+        ],
+        "summary": "Import clients from a JSON body { \"data\": \"<json>\" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.",
+        "operationId": "post_panel_api_clients_import",
+        "requestBody": {
+          "required": true,
+          "content": {
+            "application/json": {
+              "schema": {
+                "type": "object"
+              },
+              "example": {
+                "data": "[{\"client\":{\"email\":\"[email protected]\",\"enable\":true},\"inboundIds\":[7]}]"
+              }
+            }
+          }
+        },
+        "responses": {
+          "200": {
+            "description": "Successful response",
+            "content": {
+              "application/json": {
+                "schema": {
+                  "type": "object",
+                  "properties": {
+                    "success": {
+                      "type": "boolean"
+                    },
+                    "msg": {
+                      "type": "string"
+                    },
+                    "obj": {}
+                  }
+                },
+                "example": {
+                  "success": true,
+                  "obj": {
+                    "created": 2,
+                    "skipped": [
+                      {
+                        "email": "[email protected]",
+                        "reason": "email already in use: [email protected]"
+                      }
+                    ]
+                  }
+                }
+              }
+            }
+          }
+        }
+      }
+    },
     "/panel/api/clients/bulkAdjust": {
       "post": {
         "tags": [

+ 28 - 0
frontend/src/hooks/useClients.ts

@@ -402,6 +402,22 @@ export function useClients() {
     onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
   });
 
+  const delOrphansMut = useMutation({
+    mutationFn: async () => {
+      const raw = await HttpUtil.post('/panel/api/clients/delOrphans');
+      return parseMsg(raw, DelDepletedResultSchema, 'clients/delOrphans');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
+  const importClientsMut = useMutation({
+    mutationFn: async (data: string): Promise<Msg<BulkCreateResult>> => {
+      const raw = await HttpUtil.post('/panel/api/clients/import', { data }, JSON_HEADERS);
+      return parseMsg(raw, BulkCreateResultSchema, 'clients/import');
+    },
+    onSuccess: (msg) => { if (msg?.success) invalidateAll(); },
+  });
+
   const create = useCallback((payload: unknown) => createMut.mutateAsync(payload), [createMut]);
   const update = useCallback((email: string, client: unknown) => {
     if (!email) return Promise.resolve(null as unknown as Msg<unknown>);
@@ -459,6 +475,15 @@ export function useClients() {
   }, [resetTrafficMut]);
   const resetAllTraffics = useCallback(() => resetAllTrafficsMut.mutateAsync(), [resetAllTrafficsMut]);
   const delDepleted = useCallback(() => delDepletedMut.mutateAsync(), [delDepletedMut]);
+  const delOrphans = useCallback(() => delOrphansMut.mutateAsync(), [delOrphansMut]);
+  const importClients = useCallback((data: string) => importClientsMut.mutateAsync(data), [importClientsMut]);
+  // Fetch the exported clients so the page can show them in a CodeMirror viewer
+  // (Copy / Download), rather than triggering an immediate browser download.
+  const exportClients = useCallback(async (): Promise<unknown[] | null> => {
+    const msg = await HttpUtil.get('/panel/api/clients/export');
+    if (!msg?.success) return null;
+    return Array.isArray(msg.obj) ? msg.obj : [];
+  }, []);
 
   const setEnable = useCallback(async (client: ClientRecord, enable: boolean) => {
     if (!client?.email) return null;
@@ -575,6 +600,9 @@ export function useClients() {
     resetTraffic,
     resetAllTraffics,
     delDepleted,
+    delOrphans,
+    exportClients,
+    importClients,
     setEnable,
     applyTrafficEvent,
     applyClientStatsEvent,

+ 19 - 0
frontend/src/pages/api-docs/endpoints.ts

@@ -607,6 +607,25 @@ export const sections: readonly Section[] = [
         summary: 'Delete every client whose traffic quota is exhausted (used >= total, when reset is disabled) or whose expiry has passed. Returns the deleted count and triggers an Xray restart when any client was on a running inbound.',
         response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
       },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/delOrphans',
+        summary: 'Delete every client that is not attached to any inbound, along with its traffic record, IP log, and external links. Useful for clearing clients left unattached after their inbounds were removed. Returns the deleted count. Cannot be undone.',
+        response: '{\n  "success": true,\n  "obj": {\n    "deleted": 0\n  }\n}',
+      },
+      {
+        method: 'GET',
+        path: '/panel/api/clients/export',
+        summary: 'Return every client as a {client, inboundIds} array — the same shape /bulkCreate and /import accept — so the payload round-trips straight back through /import. Clients with no inbound attachment are included with an empty inboundIds list. The UI shows this in a CodeMirror viewer (copy / download); programmatic callers get the array in obj.',
+        response: '{\n  "success": true,\n  "obj": [\n    {\n      "client": {\n        "email": "[email protected]",\n        "id": "...",\n        "totalGB": 53687091200,\n        "expiryTime": 0,\n        "enable": true,\n        "subId": "..."\n      },\n      "inboundIds": [7, 9]\n    }\n  ]\n}',
+      },
+      {
+        method: 'POST',
+        path: '/panel/api/clients/import',
+        summary: 'Import clients from a JSON body { "data": "<json>" }, where data is a string-encoded array produced by /export ([{client, inboundIds}]). Items with inboundIds are created and attached to those inbounds; items with an empty inboundIds list are restored as unattached client records. Existing emails are never overwritten — they are returned in skipped. Triggers a single Xray restart at the end if any target inbound was running.',
+        body: '{\n  "data": "[{\\"client\\":{\\"email\\":\\"[email protected]\\",\\"enable\\":true},\\"inboundIds\\":[7]}]"\n}',
+        response: '{\n  "success": true,\n  "obj": {\n    "created": 2,\n    "skipped": [\n      { "email": "[email protected]", "reason": "email already in use: [email protected]" }\n    ]\n  }\n}',
+      },
       {
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',

+ 143 - 1
frontend/src/pages/clients/ClientsPage.tsx

@@ -29,6 +29,8 @@ import type { ColumnsType, TableProps } from 'antd/es/table';
 import {
   ClockCircleOutlined,
   DeleteOutlined,
+  DisconnectOutlined,
+  DownloadOutlined,
   EditOutlined,
   FilterOutlined,
   InfoCircleOutlined,
@@ -42,6 +44,7 @@ import {
   SortAscendingOutlined,
   TagsOutlined,
   TeamOutlined,
+  UploadOutlined,
   UsergroupAddOutlined,
   UsergroupDeleteOutlined,
 } from '@ant-design/icons';
@@ -69,6 +72,8 @@ const SubLinksModal = lazy(() => import('./SubLinksModal'));
 const BulkAddToGroupModal = lazy(() => import('./BulkAddToGroupModal'));
 const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal'));
 const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal'));
+const TextModal = lazy(() => import('@/components/feedback/TextModal'));
+const PromptModal = lazy(() => import('@/components/feedback/PromptModal'));
 import { emptyFilters, activeFilterCount } from './filters';
 import type { ClientFilters } from './filters';
 import './ClientsPage.css';
@@ -200,7 +205,7 @@ export default function ClientsPage() {
     inbounds, onlines, loading, transitioning, fetched, fetchError, subSettings,
     tgBotEnable, expireDiff, trafficDiff, pageSize,
     create, update, remove, bulkDelete, bulkAdjust, bulkAddToGroup, bulkRemoveFromGroup, attach, setExternalLinks, bulkAttach, detach, bulkDetach,
-    resetTraffic, resetAllTraffics, delDepleted, setEnable,
+    resetTraffic, resetAllTraffics, delDepleted, delOrphans, exportClients, importClients, setEnable,
     applyTrafficEvent, applyClientStatsEvent,
     refresh,
     hydrate,
@@ -233,6 +238,17 @@ export default function ClientsPage() {
   const [bulkDetachOpen, setBulkDetachOpen] = useState(false);
   const [selectedRowKeys, setSelectedRowKeys] = useState<string[]>([]);
 
+  const [textOpen, setTextOpen] = useState(false);
+  const [textTitle, setTextTitle] = useState('');
+  const [textContent, setTextContent] = useState('');
+  const [textFileName, setTextFileName] = useState('');
+  const [promptOpen, setPromptOpen] = useState(false);
+  const [promptTitle, setPromptTitle] = useState('');
+  const [promptOkText, setPromptOkText] = useState('');
+  const [promptInitial, setPromptInitial] = useState('');
+  const [promptLoading, setPromptLoading] = useState(false);
+  const [promptHandler, setPromptHandler] = useState<((value: string) => Promise<boolean | void> | boolean | void) | null>(null);
+
   const initial = readFilterState();
   const [searchKey, setSearchKey] = useState(initial.searchKey);
   const [filters, setFilters] = useState<ClientFilters>(initial.filters);
@@ -490,6 +506,40 @@ export default function ClientsPage() {
     setQrOpen(true);
   }
 
+  const openText = useCallback((opts: { title: string; content: string; fileName?: string }) => {
+    setTextTitle(opts.title);
+    setTextContent(opts.content);
+    setTextFileName(opts.fileName || '');
+    setTextOpen(true);
+  }, []);
+
+  const openPrompt = useCallback((opts: {
+    title: string;
+    okText?: string;
+    value?: string;
+    confirm: (value: string) => Promise<boolean | void> | boolean | void;
+  }) => {
+    setPromptTitle(opts.title);
+    setPromptOkText(opts.okText || t('confirm'));
+    setPromptInitial(opts.value || '');
+    setPromptHandler(() => opts.confirm);
+    setPromptOpen(true);
+  }, [t]);
+
+  const onPromptConfirm = useCallback(async (value: string) => {
+    if (!promptHandler) {
+      setPromptOpen(false);
+      return;
+    }
+    setPromptLoading(true);
+    try {
+      const ok = await promptHandler(value);
+      if (ok !== false) setPromptOpen(false);
+    } finally {
+      setPromptLoading(false);
+    }
+  }, [promptHandler]);
+
   function onResetAllTraffics() {
     modal.confirm({
       title: t('pages.clients.resetAllTrafficsTitle'),
@@ -521,6 +571,56 @@ export default function ClientsPage() {
     });
   }
 
+  function onDeleteOrphans() {
+    modal.confirm({
+      title: t('pages.clients.delOrphansConfirmTitle'),
+      content: t('pages.clients.delOrphansConfirmContent'),
+      okText: t('delete'),
+      okType: 'danger',
+      cancelText: t('cancel'),
+      onOk: async () => {
+        const msg = await delOrphans();
+        if (msg?.success) {
+          const deleted = msg.obj?.deleted ?? 0;
+          messageApi.success(t('pages.clients.toasts.delOrphans', { count: deleted }));
+        }
+      },
+    });
+  }
+
+  async function onExportClients() {
+    const items = await exportClients();
+    if (!items) return;
+    openText({
+      title: t('pages.clients.exportClients'),
+      content: JSON.stringify(items, null, 2),
+      fileName: 'clients-export.json',
+    });
+  }
+
+  function onImportClients() {
+    openPrompt({
+      title: t('pages.clients.importClients'),
+      okText: t('pages.clients.import'),
+      value: '',
+      confirm: async (value) => {
+        const msg = await importClients(value);
+        if (!msg?.success) return false;
+        const created = msg.obj?.created ?? 0;
+        const skipped = msg.obj?.skipped ?? [];
+        if (skipped.length === 0) {
+          messageApi.success(t('pages.clients.toasts.imported', { count: created }));
+        } else {
+          const firstError = skipped[0]?.reason ?? '';
+          messageApi.warning(firstError
+            ? `${t('pages.clients.toasts.importedMixed', { ok: created, failed: skipped.length })} — ${firstError}`
+            : t('pages.clients.toasts.importedMixed', { ok: created, failed: skipped.length }));
+        }
+        return true;
+      },
+    });
+  }
+
   function onBulkUngroup() {
     const emails = [...selectedRowKeys];
     if (emails.length === 0) return;
@@ -959,12 +1059,25 @@ export default function ClientsPage() {
                                     label: t('pages.clients.bulk'),
                                     onClick: () => setBulkAddOpen(true),
                                   },
+                                  {
+                                    key: 'export',
+                                    icon: <DownloadOutlined />,
+                                    label: t('pages.clients.exportClients'),
+                                    onClick: onExportClients,
+                                  },
+                                  {
+                                    key: 'import',
+                                    icon: <UploadOutlined />,
+                                    label: t('pages.clients.importClients'),
+                                    onClick: onImportClients,
+                                  },
                                   {
                                     key: 'resetAll',
                                     icon: <RetweetOutlined />,
                                     label: t('pages.clients.resetAllTraffics'),
                                     onClick: onResetAllTraffics,
                                   },
+                                  { type: 'divider' as const },
                                   {
                                     key: 'delDepleted',
                                     icon: <RestOutlined />,
@@ -972,6 +1085,13 @@ export default function ClientsPage() {
                                     danger: true,
                                     onClick: onDelDepleted,
                                   },
+                                  {
+                                    key: 'delOrphans',
+                                    icon: <DisconnectOutlined />,
+                                    label: t('pages.clients.delOrphans'),
+                                    danger: true,
+                                    onClick: onDeleteOrphans,
+                                  },
                                 ],
                             }}
                           >
@@ -1377,6 +1497,28 @@ export default function ClientsPage() {
             nodes={nodes}
           />
         </LazyMount>
+        <LazyMount when={textOpen}>
+          <TextModal
+            open={textOpen}
+            onClose={() => setTextOpen(false)}
+            title={textTitle}
+            content={textContent}
+            fileName={textFileName}
+            json
+          />
+        </LazyMount>
+        <LazyMount when={promptOpen}>
+          <PromptModal
+            open={promptOpen}
+            onClose={() => setPromptOpen(false)}
+            title={promptTitle}
+            okText={promptOkText}
+            initialValue={promptInitial}
+            loading={promptLoading}
+            json
+            onConfirm={onPromptConfirm}
+          />
+        </LazyMount>
       </Layout>
     </ConfigProvider>
   );

+ 56 - 0
internal/web/controller/client.go

@@ -1,6 +1,7 @@
 package controller
 
 import (
+	"encoding/json"
 	"strconv"
 	"strings"
 
@@ -57,6 +58,9 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) {
 	g.POST("/:email/attach", a.attach)
 	g.POST("/:email/detach", a.detach)
 	g.POST("/:email/externalLinks", a.setExternalLinks)
+	g.GET("/export", a.export)
+	g.POST("/import", a.importClients)
+	g.POST("/delOrphans", a.delOrphans)
 	g.POST("/resetAllTraffics", a.resetAllTraffics)
 	g.POST("/delDepleted", a.delDepleted)
 	g.POST("/bulkAdjust", a.bulkAdjust)
@@ -364,6 +368,58 @@ func (a *ClientController) delDepleted(c *gin.Context) {
 	notifyClientsChanged()
 }
 
+// export returns every client as a {client, inboundIds} list in the standard
+// envelope. The frontend renders it in a read-only CodeMirror viewer (Copy /
+// Download), so this hands back data rather than streaming a file attachment.
+func (a *ClientController) export(c *gin.Context) {
+	items, err := a.clientService.ExportAll()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, items, nil)
+}
+
+type importClientsRequest struct {
+	Data string `json:"data"`
+}
+
+// importClients accepts the pasted export text as a JSON body { "data": "..." },
+// mirroring the inbound import flow. The data string is itself a JSON-encoded
+// []ClientCreatePayload, so it is unmarshalled in a second step.
+func (a *ClientController) importClients(c *gin.Context) {
+	var req importClientsRequest
+	if err := c.ShouldBindJSON(&req); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	var items []service.ClientCreatePayload
+	if err := json.Unmarshal([]byte(req.Data), &items); err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	result, needRestart, err := a.clientService.ImportClients(&a.inboundService, items)
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, result, nil)
+	if needRestart {
+		a.xrayService.SetToNeedRestart()
+	}
+	notifyClientsChanged()
+}
+
+func (a *ClientController) delOrphans(c *gin.Context) {
+	deleted, err := a.clientService.DeleteOrphans()
+	if err != nil {
+		jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err)
+		return
+	}
+	jsonObj(c, gin.H{"deleted": deleted}, nil)
+	notifyClientsChanged()
+}
+
 func (a *ClientController) resetTrafficByEmail(c *gin.Context) {
 	email := c.Param("email")
 	needRestart, err := a.clientService.ResetTrafficByEmail(&a.inboundService, email)

+ 220 - 0
internal/web/service/client_portable.go

@@ -0,0 +1,220 @@
+package service
+
+import (
+	"strings"
+	"time"
+
+	"github.com/google/uuid"
+	"github.com/mhsanaei/3x-ui/v3/internal/database"
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+	"github.com/mhsanaei/3x-ui/v3/internal/xray"
+
+	"gorm.io/gorm"
+)
+
+// ExportAll returns every client in the same {client, inboundIds} shape that
+// /add and /bulkCreate accept, so an exported file round-trips straight back
+// through Import. Clients with no inbound attachment are included with an empty
+// inboundIds list so an export taken before DeleteOrphans can restore them.
+func (s *ClientService) ExportAll() ([]ClientCreatePayload, error) {
+	db := database.GetDB()
+	var rows []model.ClientRecord
+	if err := db.Order("id ASC").Find(&rows).Error; err != nil {
+		return nil, err
+	}
+	out := make([]ClientCreatePayload, 0, len(rows))
+	if len(rows) == 0 {
+		return out, nil
+	}
+
+	ids := make([]int, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].Id)
+	}
+
+	attachments := make(map[int][]int, len(rows))
+	for _, batch := range chunkInts(ids, sqlInChunk) {
+		var links []model.ClientInbound
+		if err := db.Where("client_id IN ?", batch).Order("inbound_id ASC").Find(&links).Error; err != nil {
+			return nil, err
+		}
+		for _, l := range links {
+			attachments[l.ClientId] = append(attachments[l.ClientId], l.InboundId)
+		}
+	}
+
+	for i := range rows {
+		client := rows[i].ToClient()
+		// The per-inbound flow_override is the reliable flow for multi-inbound
+		// clients; the canonical column can be left stale by SyncInbound (#4792).
+		if flow, err := s.EffectiveFlow(db, rows[i].Id); err == nil && flow != "" {
+			client.Flow = flow
+		}
+		out = append(out, ClientCreatePayload{
+			Client:     *client,
+			InboundIds: attachments[rows[i].Id],
+		})
+	}
+	return out, nil
+}
+
+// ImportClients recreates clients from an exported list. Items that carry
+// inboundIds go through the normal BulkCreate path (added to every inbound and
+// pushed to xray); items with no inboundIds are restored as bare records so an
+// orphan-inclusive export round-trips. Existing emails are never overwritten —
+// they are reported in Skipped. The boolean reports whether xray needs a restart.
+func (s *ClientService) ImportClients(inboundSvc *InboundService, items []ClientCreatePayload) (BulkCreateResult, bool, error) {
+	result := BulkCreateResult{}
+	if len(items) == 0 {
+		return result, false, nil
+	}
+
+	attached := make([]ClientCreatePayload, 0, len(items))
+	orphans := make([]ClientCreatePayload, 0)
+	for i := range items {
+		if len(items[i].InboundIds) > 0 {
+			attached = append(attached, items[i])
+		} else {
+			orphans = append(orphans, items[i])
+		}
+	}
+
+	skip := func(email, reason string) {
+		if strings.TrimSpace(email) == "" {
+			email = "(missing email)"
+		}
+		result.Skipped = append(result.Skipped, BulkCreateReport{Email: email, Reason: reason})
+	}
+
+	needRestart := false
+	if len(attached) > 0 {
+		sub, nr, err := s.BulkCreate(inboundSvc, attached)
+		if err != nil {
+			return result, needRestart, err
+		}
+		needRestart = needRestart || nr
+		result.Created += sub.Created
+		result.Skipped = append(result.Skipped, sub.Skipped...)
+	}
+
+	db := database.GetDB()
+	for i := range orphans {
+		client := orphans[i].Client
+		email := strings.TrimSpace(client.Email)
+		if email == "" {
+			skip("", "client email is required")
+			continue
+		}
+		if verr := validateClientEmail(email); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+		if verr := validateClientSubID(client.SubID); verr != nil {
+			skip(email, verr.Error())
+			continue
+		}
+
+		// An existing record (in the DB or just created from the attached set
+		// above) always wins — import never clobbers a live client.
+		var taken int64
+		if err := db.Model(&model.ClientRecord{}).Where("email = ?", email).Count(&taken).Error; err != nil {
+			return result, needRestart, err
+		}
+		if taken > 0 {
+			skip(email, "email already in use: "+email)
+			continue
+		}
+
+		client.Email = email
+		if client.SubID == "" {
+			client.SubID = uuid.NewString()
+		}
+		if client.SubID != "" {
+			var subTaken int64
+			if err := db.Model(&model.ClientRecord{}).
+				Where("sub_id = ? AND email <> ?", client.SubID, email).
+				Count(&subTaken).Error; err != nil {
+				return result, needRestart, err
+			}
+			if subTaken > 0 {
+				skip(email, "subId already in use: "+client.SubID)
+				continue
+			}
+		}
+		if !client.Enable {
+			client.Enable = true
+		}
+		now := time.Now().UnixMilli()
+		if client.CreatedAt == 0 {
+			client.CreatedAt = now
+		}
+		client.UpdatedAt = now
+
+		if err := db.Create(client.ToRecord()).Error; err != nil {
+			skip(email, err.Error())
+			continue
+		}
+		result.Created++
+	}
+
+	return result, needRestart, nil
+}
+
+// DeleteOrphans removes every client that is not attached to any inbound,
+// together with its traffic rows, IP log, and external links. It mirrors the
+// cleanup the single-client Delete performs, batched into one transaction.
+// Returns the number of clients deleted.
+func (s *ClientService) DeleteOrphans() (int, error) {
+	db := database.GetDB()
+	sub := database.GetDB().Table("client_inbounds").Select("client_id")
+	var rows []model.ClientRecord
+	if err := db.Where("id NOT IN (?)", sub).Order("id ASC").Find(&rows).Error; err != nil {
+		return 0, err
+	}
+	if len(rows) == 0 {
+		return 0, nil
+	}
+
+	ids := make([]int, 0, len(rows))
+	emails := make([]string, 0, len(rows))
+	for i := range rows {
+		ids = append(ids, rows[i].Id)
+		if rows[i].Email != "" {
+			emails = append(emails, rows[i].Email)
+		}
+	}
+	tombstoneClientEmails(emails)
+
+	if err := runSerializedTx(func(tx *gorm.DB) error {
+		for _, batch := range chunkInts(ids, sqlInChunk) {
+			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientInbound{}).Error; e != nil {
+				return e
+			}
+			if e := tx.Where("client_id IN ?", batch).Delete(&model.ClientExternalLink{}).Error; e != nil {
+				return e
+			}
+		}
+		if len(emails) > 0 {
+			for _, batch := range chunkStrings(emails, sqlInChunk) {
+				if e := tx.Where("email IN ?", batch).Delete(&xray.ClientTraffic{}).Error; e != nil {
+					return e
+				}
+				if e := tx.Where("client_email IN ?", batch).Delete(&model.InboundClientIps{}).Error; e != nil {
+					return e
+				}
+			}
+			if e := clearGlobalTraffic(tx, emails...); e != nil {
+				return e
+			}
+		}
+		for _, batch := range chunkInts(ids, sqlInChunk) {
+			if e := tx.Where("id IN ?", batch).Delete(&model.ClientRecord{}).Error; e != nil {
+				return e
+			}
+		}
+		return nil
+	}); err != nil {
+		return 0, err
+	}
+	return len(ids), nil
+}

+ 10 - 1
internal/web/translation/ar-EG.json

@@ -828,6 +828,12 @@
       "delDepleted": "حذف المنتهية",
       "delDepletedConfirmTitle": "حذف العملاء المنتهية حصصهم؟",
       "delDepletedConfirmContent": "يُحذف كل عميل استُنفِدت حصة حركة مروره أو انتهت صلاحيته. لا يمكن التراجع.",
+      "exportClients": "تصدير العملاء",
+      "importClients": "استيراد العملاء",
+      "import": "استيراد",
+      "delOrphans": "حذف العملاء غير المرتبطين",
+      "delOrphansConfirmTitle": "حذف العملاء بلا اتصال وارد؟",
+      "delOrphansConfirmContent": "يزيل كل عميل غير مرتبط بأي اتصال وارد مع سجل حركة مروره. لا يمكن التراجع.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "تم إنشاء {ok}, وفشل {failed}",
         "bulkAdjusted": "تم تعديل {count} عميل",
         "bulkAdjustedMixed": "{ok} تم تعديلهم، {skipped} تم تخطيهم",
-        "delDepleted": "تم حذف {count} عميل منتهٍ"
+        "delDepleted": "تم حذف {count} عميل منتهٍ",
+        "delOrphans": "تم حذف {count} عميل غير مرتبط",
+        "imported": "تم استيراد {count} عميل",
+        "importedMixed": "{ok} تم استيرادهم، {failed} تم تخطيهم"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/en-US.json

@@ -828,6 +828,12 @@
       "delDepleted": "Delete depleted",
       "delDepletedConfirmTitle": "Delete depleted clients?",
       "delDepletedConfirmContent": "Removes every client whose traffic quota is exhausted or whose expiry has passed. This cannot be undone.",
+      "exportClients": "Export clients",
+      "importClients": "Import clients",
+      "import": "Import",
+      "delOrphans": "Delete unattached clients",
+      "delOrphansConfirmTitle": "Delete clients without an inbound?",
+      "delOrphansConfirmContent": "Removes every client that is not attached to any inbound, along with its traffic record. This cannot be undone.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} created, {failed} failed",
         "bulkAdjusted": "{count} clients adjusted",
         "bulkAdjustedMixed": "{ok} adjusted, {skipped} skipped",
-        "delDepleted": "{count} depleted clients deleted"
+        "delDepleted": "{count} depleted clients deleted",
+        "delOrphans": "{count} unattached clients deleted",
+        "imported": "{count} clients imported",
+        "importedMixed": "{ok} imported, {failed} skipped"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/es-ES.json

@@ -828,6 +828,12 @@
       "delDepleted": "Eliminar agotados",
       "delDepletedConfirmTitle": "¿Eliminar clientes agotados?",
       "delDepletedConfirmContent": "Elimina todos los clientes con cuota agotada o expirados. No se puede deshacer.",
+      "exportClients": "Exportar clientes",
+      "importClients": "Importar clientes",
+      "import": "Importar",
+      "delOrphans": "Eliminar clientes sin entrante",
+      "delOrphansConfirmTitle": "¿Eliminar clientes sin entrante?",
+      "delOrphansConfirmContent": "Elimina todos los clientes que no están vinculados a ningún entrante, junto con su registro de tráfico. No se puede deshacer.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} creados, {failed} fallidos",
         "bulkAdjusted": "{count} clientes ajustados",
         "bulkAdjustedMixed": "{ok} ajustados, {skipped} omitidos",
-        "delDepleted": "{count} clientes agotados eliminados"
+        "delDepleted": "{count} clientes agotados eliminados",
+        "delOrphans": "{count} clientes sin entrante eliminados",
+        "imported": "{count} clientes importados",
+        "importedMixed": "{ok} importados, {failed} omitidos"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/fa-IR.json

@@ -828,6 +828,12 @@
       "delDepleted": "حذف اتمام‌یافته‌ها",
       "delDepletedConfirmTitle": "حذف کلاینت‌های اتمام‌یافته؟",
       "delDepletedConfirmContent": "هر کلاینتی که سهمیه ترافیک‌اش تمام شده یا تاریخ انقضایش گذشته است حذف می‌شود. این عمل غیرقابل بازگشت است.",
+      "exportClients": "خروجی گرفتن از کلاینت‌ها",
+      "importClients": "ورود کلاینت‌ها",
+      "import": "ورود",
+      "delOrphans": "حذف کلاینت‌های بدون اینباند",
+      "delOrphansConfirmTitle": "حذف کلاینت‌های بدون اینباند؟",
+      "delOrphansConfirmContent": "هر کلاینتی که به هیچ اینباندی متصل نیست، همراه با رکورد ترافیک‌اش حذف می‌شود. این عمل غیرقابل بازگشت است.",
       "auth": "احراز",
       "hysteriaAuth": "احراز Hysteria",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} ساخته شد، {failed} ناموفق",
         "bulkAdjusted": "{count} کلاینت تنظیم شد",
         "bulkAdjustedMixed": "{ok} تنظیم، {skipped} رد شد",
-        "delDepleted": "{count} کلاینت اتمام‌یافته حذف شد"
+        "delDepleted": "{count} کلاینت اتمام‌یافته حذف شد",
+        "delOrphans": "{count} کلاینت بدون اینباند حذف شد",
+        "imported": "{count} کلاینت وارد شد",
+        "importedMixed": "{ok} وارد شد، {failed} رد شد"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/id-ID.json

@@ -828,6 +828,12 @@
       "delDepleted": "Hapus yang habis",
       "delDepletedConfirmTitle": "Hapus klien yang habis?",
       "delDepletedConfirmContent": "Hapus setiap klien yang kuota lalu lintasnya habis atau yang masa berlakunya telah berakhir. Tidak dapat dibatalkan.",
+      "exportClients": "Ekspor klien",
+      "importClients": "Impor klien",
+      "import": "Impor",
+      "delOrphans": "Hapus klien tanpa inbound",
+      "delOrphansConfirmTitle": "Hapus klien tanpa inbound?",
+      "delOrphansConfirmContent": "Menghapus setiap klien yang tidak terhubung ke inbound mana pun, beserta catatan lalu lintasnya. Tidak dapat dibatalkan.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} dibuat, {failed} gagal",
         "bulkAdjusted": "{count} klien disesuaikan",
         "bulkAdjustedMixed": "{ok} disesuaikan, {skipped} dilewati",
-        "delDepleted": "{count} klien habis dihapus"
+        "delDepleted": "{count} klien habis dihapus",
+        "delOrphans": "{count} klien tanpa inbound dihapus",
+        "imported": "{count} klien diimpor",
+        "importedMixed": "{ok} diimpor, {failed} dilewati"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/ja-JP.json

@@ -828,6 +828,12 @@
       "delDepleted": "使い切ったクライアントを削除",
       "delDepletedConfirmTitle": "使い切ったクライアントを削除しますか?",
       "delDepletedConfirmContent": "トラフィック上限に達したか有効期限が切れたクライアントをすべて削除します。元に戻せません。",
+      "exportClients": "クライアントをエクスポート",
+      "importClients": "クライアントをインポート",
+      "import": "インポート",
+      "delOrphans": "未アタッチのクライアントを削除",
+      "delOrphansConfirmTitle": "インバウンドのないクライアントを削除しますか?",
+      "delOrphansConfirmContent": "どのインバウンドにもアタッチされていないクライアントを、そのトラフィック記録とともにすべて削除します。元に戻せません。",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} 件作成、{failed} 件失敗",
         "bulkAdjusted": "{count} 件のクライアントを調整しました",
         "bulkAdjustedMixed": "{ok} 件調整、{skipped} 件スキップ",
-        "delDepleted": "使い切った {count} 件のクライアントを削除しました"
+        "delDepleted": "使い切った {count} 件のクライアントを削除しました",
+        "delOrphans": "未アタッチの {count} 件のクライアントを削除しました",
+        "imported": "{count} 件のクライアントをインポートしました",
+        "importedMixed": "{ok} 件インポート、{failed} 件スキップ"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/pt-BR.json

@@ -828,6 +828,12 @@
       "delDepleted": "Excluir esgotados",
       "delDepletedConfirmTitle": "Excluir clientes esgotados?",
       "delDepletedConfirmContent": "Remove todos os clientes cuja cota de tráfego foi esgotada ou cuja expiração já passou. Não é possível desfazer.",
+      "exportClients": "Exportar clientes",
+      "importClients": "Importar clientes",
+      "import": "Importar",
+      "delOrphans": "Excluir clientes sem inbound",
+      "delOrphansConfirmTitle": "Excluir clientes sem inbound?",
+      "delOrphansConfirmContent": "Remove todos os clientes que não estão vinculados a nenhum inbound, junto com seu registro de tráfego. Não é possível desfazer.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} criados, {failed} com falha",
         "bulkAdjusted": "{count} clientes ajustados",
         "bulkAdjustedMixed": "{ok} ajustados, {skipped} ignorados",
-        "delDepleted": "{count} clientes esgotados excluídos"
+        "delDepleted": "{count} clientes esgotados excluídos",
+        "delOrphans": "{count} clientes sem inbound excluídos",
+        "imported": "{count} clientes importados",
+        "importedMixed": "{ok} importados, {failed} ignorados"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/ru-RU.json

@@ -828,6 +828,12 @@
       "delDepleted": "Удалить исчерпанных",
       "delDepletedConfirmTitle": "Удалить исчерпанных клиентов?",
       "delDepletedConfirmContent": "Удаляются все клиенты, у которых исчерпана квота трафика или истёк срок. Это действие нельзя отменить.",
+      "exportClients": "Экспортировать клиентов",
+      "importClients": "Импортировать клиентов",
+      "import": "Импорт",
+      "delOrphans": "Удалить клиентов без входящего",
+      "delOrphansConfirmTitle": "Удалить клиентов без входящего?",
+      "delOrphansConfirmContent": "Удаляются все клиенты, не привязанные ни к одному входящему, вместе с их записями трафика. Это действие нельзя отменить.",
       "auth": "Авторизация",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Создано: {ok}, не удалось: {failed}",
         "bulkAdjusted": "Изменено клиентов: {count}",
         "bulkAdjustedMixed": "Изменено: {ok}, пропущено: {skipped}",
-        "delDepleted": "Удалено исчерпанных клиентов: {count}"
+        "delDepleted": "Удалено исчерпанных клиентов: {count}",
+        "delOrphans": "Удалено клиентов без входящего: {count}",
+        "imported": "Импортировано клиентов: {count}",
+        "importedMixed": "Импортировано: {ok}, пропущено: {failed}"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/tr-TR.json

@@ -828,6 +828,12 @@
       "delDepleted": "Süresi/Kotası Bitenleri Sil",
       "delDepletedConfirmTitle": "Tükenmiş Kullanıcılar Silinsin Mi?",
       "delDepletedConfirmContent": "Trafik kotası dolan veya süresi geçen tüm kullanıcılar silinir. Geri alınamaz.",
+      "exportClients": "Kullanıcıları Dışa Aktar",
+      "importClients": "Kullanıcıları İçe Aktar",
+      "import": "İçe Aktar",
+      "delOrphans": "Bağsız Kullanıcıları Sil",
+      "delOrphansConfirmTitle": "Gelen Bağlantısı Olmayan Kullanıcılar Silinsin Mi?",
+      "delOrphansConfirmContent": "Hiçbir gelen bağlantıya bağlı olmayan her kullanıcı, trafik kaydıyla birlikte silinir. Geri alınamaz.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "{ok} oluşturuldu, {failed} başarısız",
         "bulkAdjusted": "{count} kullanıcı ayarlandı",
         "bulkAdjustedMixed": "{ok} ayarlandı, {skipped} atlandı",
-        "delDepleted": "{count} tükenmiş kullanıcı silindi"
+        "delDepleted": "{count} tükenmiş kullanıcı silindi",
+        "delOrphans": "{count} bağsız kullanıcı silindi",
+        "imported": "{count} kullanıcı içe aktarıldı",
+        "importedMixed": "{ok} içe aktarıldı, {failed} atlandı"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/uk-UA.json

@@ -828,6 +828,12 @@
       "delDepleted": "Видалити вичерпаних",
       "delDepletedConfirmTitle": "Видалити вичерпаних клієнтів?",
       "delDepletedConfirmContent": "Видаляються всі клієнти, у яких вичерпана квота трафіку або сплив термін. Цю дію неможливо скасувати.",
+      "exportClients": "Експортувати клієнтів",
+      "importClients": "Імпортувати клієнтів",
+      "import": "Імпорт",
+      "delOrphans": "Видалити клієнтів без вхідного",
+      "delOrphansConfirmTitle": "Видалити клієнтів без вхідного?",
+      "delOrphansConfirmContent": "Видаляється кожен клієнт, не прив'язаний до жодного вхідного, разом із його записом трафіку. Цю дію неможливо скасувати.",
       "auth": "Авторизація",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Створено: {ok}, не вдалось: {failed}",
         "bulkAdjusted": "Змінено клієнтів: {count}",
         "bulkAdjustedMixed": "Змінено: {ok}, пропущено: {skipped}",
-        "delDepleted": "Видалено вичерпаних клієнтів: {count}"
+        "delDepleted": "Видалено вичерпаних клієнтів: {count}",
+        "delOrphans": "Видалено клієнтів без вхідного: {count}",
+        "imported": "Імпортовано клієнтів: {count}",
+        "importedMixed": "Імпортовано: {ok}, пропущено: {failed}"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/vi-VN.json

@@ -828,6 +828,12 @@
       "delDepleted": "Xóa hết hạn mức",
       "delDepletedConfirmTitle": "Xóa khách hàng hết hạn mức?",
       "delDepletedConfirmContent": "Gỡ tất cả khách hàng đã dùng hết hạn mức lưu lượng hoặc đã quá hạn. Không thể hoàn tác.",
+      "exportClients": "Xuất khách hàng",
+      "importClients": "Nhập khách hàng",
+      "import": "Nhập",
+      "delOrphans": "Xóa khách hàng không gắn inbound",
+      "delOrphansConfirmTitle": "Xóa khách hàng không thuộc inbound nào?",
+      "delOrphansConfirmContent": "Gỡ tất cả khách hàng không được gắn vào bất kỳ inbound nào, cùng với bản ghi lưu lượng của họ. Không thể hoàn tác.",
       "auth": "Auth",
       "hysteriaAuth": "Hysteria Auth",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "Đã tạo {ok}, thất bại {failed}",
         "bulkAdjusted": "Đã điều chỉnh {count} khách hàng",
         "bulkAdjustedMixed": "Đã điều chỉnh {ok}, bỏ qua {skipped}",
-        "delDepleted": "Đã xóa {count} khách hàng hết hạn mức"
+        "delDepleted": "Đã xóa {count} khách hàng hết hạn mức",
+        "delOrphans": "Đã xóa {count} khách hàng không gắn inbound",
+        "imported": "Đã nhập {count} khách hàng",
+        "importedMixed": "Đã nhập {ok}, bỏ qua {failed}"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/zh-CN.json

@@ -828,6 +828,12 @@
       "delDepleted": "删除已耗尽",
       "delDepletedConfirmTitle": "删除已耗尽的客户端?",
       "delDepletedConfirmContent": "删除所有流量配额已用尽或已过期的客户端。该操作不可撤销。",
+      "exportClients": "导出客户端",
+      "importClients": "导入客户端",
+      "import": "导入",
+      "delOrphans": "删除未关联的客户端",
+      "delOrphansConfirmTitle": "删除没有入站的客户端?",
+      "delOrphansConfirmContent": "删除所有未关联到任何入站的客户端及其流量记录。该操作不可撤销。",
       "auth": "认证",
       "hysteriaAuth": "Hysteria 认证",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "已创建 {ok} 个,失败 {failed} 个",
         "bulkAdjusted": "已调整 {count} 个客户端",
         "bulkAdjustedMixed": "已调整 {ok} 个,跳过 {skipped} 个",
-        "delDepleted": "已删除 {count} 个已耗尽的客户端"
+        "delDepleted": "已删除 {count} 个已耗尽的客户端",
+        "delOrphans": "已删除 {count} 个未关联的客户端",
+        "imported": "已导入 {count} 个客户端",
+        "importedMixed": "已导入 {ok} 个,跳过 {failed} 个"
       }
     },
     "groups": {

+ 10 - 1
internal/web/translation/zh-TW.json

@@ -828,6 +828,12 @@
       "delDepleted": "刪除已耗盡",
       "delDepletedConfirmTitle": "刪除已耗盡的客戶端?",
       "delDepletedConfirmContent": "刪除所有流量配額已用盡或已過期的客戶端。此操作無法復原。",
+      "exportClients": "匯出客戶端",
+      "importClients": "匯入客戶端",
+      "import": "匯入",
+      "delOrphans": "刪除未關聯的客戶端",
+      "delOrphansConfirmTitle": "刪除沒有入站的客戶端?",
+      "delOrphansConfirmContent": "移除所有未關聯任何入站的客戶端,連同其流量紀錄一併刪除。此操作無法復原。",
       "auth": "認證",
       "hysteriaAuth": "Hysteria 認證",
       "uuid": "UUID",
@@ -850,7 +856,10 @@
         "bulkCreatedMixed": "已建立 {ok} 個,失敗 {failed} 個",
         "bulkAdjusted": "已調整 {count} 個客戶端",
         "bulkAdjustedMixed": "已調整 {ok} 個,跳過 {skipped} 個",
-        "delDepleted": "已刪除 {count} 個已耗盡的客戶端"
+        "delDepleted": "已刪除 {count} 個已耗盡的客戶端",
+        "delOrphans": "已刪除 {count} 個未關聯的客戶端",
+        "imported": "已匯入 {count} 個客戶端",
+        "importedMixed": "已匯入 {ok} 個,跳過 {failed} 個"
       }
     },
     "groups": {