From 72b68cce22b9f3f90c8f0e0551f8fabf8ec40ed3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Thu, 28 May 2026 11:08:52 +0200 Subject: [PATCH] feat(clients): selective bulk attach + new bulk detach Inbounds page: - AttachClientsModal now shows a per-client selection table (email, comment, enabled tag) with search and a live "selected of total" counter; all clients are pre-selected so the old "attach all" workflow stays a single OK click. - New DetachClientsModal on the inbound row menu lets you pick which clients to remove from that inbound (records are kept so they can be re-attached later; for full removal use Delete). Clients page: - New "Attach (N)" bulk-action button + BulkAttachInboundsModal that attaches selected clients to one or more multi-user inbounds. - New "Detach (N)" bulk-action button + BulkDetachInboundsModal that removes selected clients from chosen inbounds; (email, inbound) pairs where the client isn't attached are silently skipped. Backend adds POST /panel/api/clients/bulkDetach, wrapping the existing Detach service for each email and reporting per-email detached/skipped/errors. ClientRecord rows are kept on detach to match the single-client endpoint; bulkDel remains the path for full removal. --- frontend/public/openapi.json | 61 ++++++ frontend/src/hooks/useClients.ts | 32 +++ frontend/src/pages/api-docs/endpoints.ts | 11 ++ .../pages/clients/BulkAttachInboundsModal.tsx | 98 ++++++++++ .../pages/clients/BulkDetachInboundsModal.tsx | 98 ++++++++++ frontend/src/pages/clients/ClientsPage.tsx | 45 ++++- .../src/pages/inbounds/AttachClientsModal.tsx | 124 ++++++++++-- .../src/pages/inbounds/DetachClientsModal.tsx | 183 ++++++++++++++++++ frontend/src/pages/inbounds/InboundList.tsx | 1 + frontend/src/pages/inbounds/InboundsPage.tsx | 16 ++ frontend/src/schemas/client.ts | 14 ++ web/controller/client.go | 24 +++ web/service/client.go | 69 +++++++ web/translation/en-US.json | 23 +++ web/translation/fa-IR.json | 23 +++ 15 files changed, 809 insertions(+), 13 deletions(-) create mode 100644 frontend/src/pages/clients/BulkAttachInboundsModal.tsx create mode 100644 frontend/src/pages/clients/BulkDetachInboundsModal.tsx create mode 100644 frontend/src/pages/inbounds/DetachClientsModal.tsx diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index 02c736e9..b6c000aa 100644 --- a/frontend/public/openapi.json +++ b/frontend/public/openapi.json @@ -2968,6 +2968,67 @@ } } }, + "/panel/api/clients/bulkDetach": { + "post": { + "tags": [ + "Clients" + ], + "summary": "Mirror of bulkAttach: detach many existing clients from many inbounds in one call. For each email, intersects the client's current inbounds with the requested set and detaches from those only; (email, inbound) pairs where the client is not currently attached are silently no-ops. Emails not attached to any of the requested inbounds are reported under skipped. Client records are kept even if they become orphaned — use bulkDel for full removal. Returns per-email detached/skipped/errors lists and triggers a single Xray restart if any target inbound was running.", + "operationId": "post_panel_api_clients_bulkDetach", + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "type": "object" + }, + "example": { + "emails": [ + "alice", + "bob" + ], + "inboundIds": [ + 7, + 9 + ] + } + } + } + }, + "responses": { + "200": { + "description": "Successful response", + "content": { + "application/json": { + "schema": { + "type": "object", + "properties": { + "success": { + "type": "boolean" + }, + "msg": { + "type": "string" + }, + "obj": {} + } + }, + "example": { + "success": true, + "obj": { + "detached": [ + "alice", + "bob" + ], + "skipped": [], + "errors": [] + } + } + } + } + } + } + } + }, "/panel/api/clients/bulkResetTraffic": { "post": { "tags": [ diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index 73535c6b..c92b552b 100644 --- a/frontend/src/hooks/useClients.ts +++ b/frontend/src/hooks/useClients.ts @@ -10,8 +10,10 @@ import { InboundOptionsSchema, OnlinesSchema, BulkAdjustResultSchema, + BulkAttachResultSchema, BulkCreateResultSchema, BulkDeleteResultSchema, + BulkDetachResultSchema, DelDepletedResultSchema, type ClientHydrate, type ClientRecord, @@ -20,8 +22,10 @@ import { type ClientPageResponse, type InboundOption, type BulkAdjustResult, + type BulkAttachResult, type BulkCreateResult, type BulkDeleteResult, + type BulkDetachResult, } from '@/schemas/client'; import { DefaultsPayloadSchema } from '@/schemas/defaults'; @@ -286,12 +290,28 @@ export function useClients() { onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); + const bulkAttachMut = useMutation({ + mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise> => { + const raw = await HttpUtil.post('/panel/api/clients/bulkAttach', payload, JSON_HEADERS); + return parseMsg(raw, BulkAttachResultSchema, 'clients/bulkAttach'); + }, + onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, + }); + const detachMut = useMutation({ mutationFn: ({ email, inboundIds }: { email: string; inboundIds: number[] }) => HttpUtil.post(`/panel/api/clients/${encodeURIComponent(email)}/detach`, { inboundIds }, JSON_HEADERS), onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, }); + const bulkDetachMut = useMutation({ + mutationFn: async (payload: { emails: string[]; inboundIds: number[] }): Promise> => { + const raw = await HttpUtil.post('/panel/api/clients/bulkDetach', payload, JSON_HEADERS); + return parseMsg(raw, BulkDetachResultSchema, 'clients/bulkDetach'); + }, + onSuccess: (msg) => { if (msg?.success) invalidateAll(); }, + }); + const resetTrafficMut = useMutation({ mutationFn: (email: string) => HttpUtil.post(`/panel/api/clients/resetTraffic/${encodeURIComponent(email)}`), @@ -340,10 +360,20 @@ export function useClients() { if (!email) return Promise.resolve(null as unknown as Msg); return attachMut.mutateAsync({ email, inboundIds }); }, [attachMut]); + const bulkAttach = useCallback((emails: string[], inboundIds: number[]) => { + if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg); + if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg); + return bulkAttachMut.mutateAsync({ emails, inboundIds }); + }, [bulkAttachMut]); const detach = useCallback((email: string, inboundIds: number[]) => { if (!email) return Promise.resolve(null as unknown as Msg); return detachMut.mutateAsync({ email, inboundIds }); }, [detachMut]); + const bulkDetach = useCallback((emails: string[], inboundIds: number[]) => { + if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null as unknown as Msg); + if (!Array.isArray(inboundIds) || inboundIds.length === 0) return Promise.resolve(null as unknown as Msg); + return bulkDetachMut.mutateAsync({ emails, inboundIds }); + }, [bulkDetachMut]); const resetTraffic = useCallback((client: ClientRecord) => { if (!client?.email) return Promise.resolve(null as unknown as Msg); return resetTrafficMut.mutateAsync(client.email); @@ -444,7 +474,9 @@ export function useClients() { bulkAdjust, bulkAssignGroup, attach, + bulkAttach, detach, + bulkDetach, resetTraffic, resetAllTraffics, delDepleted, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index f6a31a71..36ae1e0b 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/frontend/src/pages/api-docs/endpoints.ts @@ -562,6 +562,17 @@ export const sections: readonly Section[] = [ body: '{\n "emails": ["alice", "bob"],\n "inboundIds": [7, 9]\n}', response: '{\n "success": true,\n "obj": {\n "attached": ["alice", "bob"],\n "skipped": ["bob"],\n "errors": []\n }\n}', }, + { + method: 'POST', + path: '/panel/api/clients/bulkDetach', + summary: 'Mirror of bulkAttach: detach many existing clients from many inbounds in one call. For each email, intersects the client\'s current inbounds with the requested set and detaches from those only; (email, inbound) pairs where the client is not currently attached are silently no-ops. Emails not attached to any of the requested inbounds are reported under skipped. Client records are kept even if they become orphaned — use bulkDel for full removal. Returns per-email detached/skipped/errors lists and triggers a single Xray restart if any target inbound was running.', + params: [ + { name: 'emails', in: 'body (json)', type: 'array', desc: 'Emails of existing clients to detach.' }, + { name: 'inboundIds', in: 'body (json)', type: 'integer[]', desc: 'Inbound IDs to detach the clients from.' }, + ], + body: '{\n "emails": ["alice", "bob"],\n "inboundIds": [7, 9]\n}', + response: '{\n "success": true,\n "obj": {\n "detached": ["alice", "bob"],\n "skipped": [],\n "errors": []\n }\n}', + }, { method: 'POST', path: '/panel/api/clients/bulkResetTraffic', diff --git a/frontend/src/pages/clients/BulkAttachInboundsModal.tsx b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx new file mode 100644 index 00000000..7087e80e --- /dev/null +++ b/frontend/src/pages/clients/BulkAttachInboundsModal.tsx @@ -0,0 +1,98 @@ +import { useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { Alert, Modal, Select, Typography, message } from 'antd'; + +import type { InboundOption } from '@/hooks/useClients'; +import type { BulkAttachResult } from '@/schemas/client'; + +const MULTI_USER_PROTOCOLS = new Set(['vmess', 'vless', 'trojan', 'hysteria', 'shadowsocks']); + +interface BulkAttachInboundsModalProps { + open: boolean; + count: number; + inbounds: InboundOption[]; + onOpenChange: (open: boolean) => void; + onSubmit: (inboundIds: number[]) => Promise; +} + +export default function BulkAttachInboundsModal({ + open, + count, + inbounds, + onOpenChange, + onSubmit, +}: BulkAttachInboundsModalProps) { + const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); + const [targetIds, setTargetIds] = useState([]); + const [submitting, setSubmitting] = useState(false); + + useEffect(() => { + if (open) setTargetIds([]); + }, [open]); + + const targetOptions = useMemo(() => { + return (inbounds || []) + .filter((ib) => MULTI_USER_PROTOCOLS.has((ib.protocol || '').toLowerCase())) + .map((ib) => ({ + value: ib.id, + label: `${ib.remark ?? ''} (${ib.protocol ?? ''}@${ib.port ?? ''})`, + })); + }, [inbounds]); + + async function submit() { + if (targetIds.length === 0 || count === 0) return; + setSubmitting(true); + try { + const result = await onSubmit(targetIds); + if (!result) return; + const attached = result.attached?.length ?? 0; + const skipped = result.skipped?.length ?? 0; + const errors = result.errors?.length ?? 0; + if (errors > 0) { + messageApi.warning( + t('pages.inbounds.attachClientsResultMixed', { attached, skipped, errors }), + ); + } else { + messageApi.success(t('pages.inbounds.attachClientsResult', { attached, skipped })); + } + onOpenChange(false); + } finally { + setSubmitting(false); + } + } + + return ( + <> + {messageContextHolder} + onOpenChange(false)} + onOk={submit} + destroyOnHidden + > + + {t('pages.clients.attachToInboundsDesc', { count })} + + {targetOptions.length === 0 ? ( + + ) : ( + + )} + + + ); +} diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index fbc8200e..42e21bef 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/frontend/src/pages/clients/ClientsPage.tsx @@ -42,6 +42,7 @@ import { TagsOutlined, TeamOutlined, UsergroupAddOutlined, + UsergroupDeleteOutlined, } from '@ant-design/icons'; import { useTheme } from '@/hooks/useTheme'; @@ -62,6 +63,8 @@ const ClientBulkAdjustModal = lazy(() => import('./ClientBulkAdjustModal')); const FilterDrawer = lazy(() => import('./FilterDrawer')); const SubLinksModal = lazy(() => import('./SubLinksModal')); const BulkAssignGroupModal = lazy(() => import('./BulkAssignGroupModal')); +const BulkAttachInboundsModal = lazy(() => import('./BulkAttachInboundsModal')); +const BulkDetachInboundsModal = lazy(() => import('./BulkDetachInboundsModal')); import { emptyFilters, activeFilterCount } from './filters'; import type { ClientFilters } from './filters'; import './ClientsPage.css'; @@ -149,7 +152,7 @@ export default function ClientsPage() { setQuery, inbounds, onlines, loading, fetched, subSettings, ipLimitEnable, tgBotEnable, expireDiff, trafficDiff, pageSize, - create, update, remove, bulkDelete, bulkAdjust, bulkAssignGroup, attach, detach, + create, update, remove, bulkDelete, bulkAdjust, bulkAssignGroup, attach, bulkAttach, detach, bulkDetach, resetTraffic, resetAllTraffics, delDepleted, setEnable, applyTrafficEvent, applyClientStatsEvent, hydrate, @@ -173,6 +176,8 @@ export default function ClientsPage() { const [bulkAdjustOpen, setBulkAdjustOpen] = useState(false); const [subLinksOpen, setSubLinksOpen] = useState(false); const [bulkGroupOpen, setBulkGroupOpen] = useState(false); + const [bulkAttachOpen, setBulkAttachOpen] = useState(false); + const [bulkDetachOpen, setBulkDetachOpen] = useState(false); const [selectedRowKeys, setSelectedRowKeys] = useState([]); const initial = readFilterState(); @@ -789,6 +794,12 @@ export default function ClientsPage() { + + @@ -1157,6 +1168,38 @@ export default function ClientsPage() { }} /> + + { + const msg = await bulkAttach([...selectedRowKeys], inboundIds); + if (msg?.success) { + setSelectedRowKeys([]); + return msg.obj ?? { attached: [], skipped: [], errors: [] }; + } + return null; + }} + /> + + + { + const msg = await bulkDetach([...selectedRowKeys], inboundIds); + if (msg?.success) { + setSelectedRowKeys([]); + return msg.obj ?? { detached: [], skipped: [], errors: [] }; + } + return null; + }} + /> + }; +interface ClientRow { + email: string; + comment: string; + enable: boolean; +} + +function readClientRows(settings: unknown): ClientRow[] { + const parsed = coerceInboundJsonField(settings) as { + clients?: Array<{ email?: string; comment?: string; enable?: boolean }>; + }; const clients = Array.isArray(parsed?.clients) ? parsed.clients : []; - return clients.map((c) => (c?.email || '').trim()).filter(Boolean); + return clients + .map((c) => ({ + email: (c?.email || '').trim(), + comment: (c?.comment || '').trim(), + enable: c?.enable !== false, + })) + .filter((r) => r.email); } export default function AttachClientsModal({ @@ -37,12 +52,18 @@ export default function AttachClientsModal({ const [messageApi, messageContextHolder] = message.useMessage(); const [targetIds, setTargetIds] = useState([]); const [saving, setSaving] = useState(false); + const [clientRows, setClientRows] = useState([]); + const [selectedEmails, setSelectedEmails] = useState([]); + const [search, setSearch] = useState(''); useEffect(() => { - if (open) setTargetIds([]); - }, [open]); - - const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]); + if (!open) return; + const rows = source ? readClientRows(source.settings) : []; + setClientRows(rows); + setSelectedEmails(rows.map((r) => r.email)); + setTargetIds([]); + setSearch(''); + }, [open, source]); const targetOptions = useMemo(() => { if (!source) return []; @@ -51,11 +72,53 @@ export default function AttachClientsModal({ .map((ib) => ({ value: ib.id, label: `${ib.remark} (${ib.protocol}@${ib.port})` })); }, [dbInbounds, source]); + const filteredRows = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return clientRows; + return clientRows.filter( + (r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q), + ); + }, [clientRows, search]); + + const columns: ColumnsType = 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 ? ( + {t('enable')} + ) : ( + {t('pages.inbounds.attachClientsStatusDisabled')} + ), + }, + ], + [t], + ); + async function submit() { - if (!source || targetIds.length === 0 || emails.length === 0) return; + if (!source || targetIds.length === 0 || selectedEmails.length === 0) return; setSaving(true); try { - const msg = await HttpUtil.post('/panel/api/clients/bulkAttach', { emails, inboundIds: targetIds }, { headers: { 'Content-Type': 'application/json' } }); + const msg = await HttpUtil.post( + '/panel/api/clients/bulkAttach', + { emails: selectedEmails, inboundIds: targetIds }, + { headers: { 'Content-Type': 'application/json' } }, + ); if (!msg?.success) { messageApi.error(msg?.msg || t('somethingWentWrong')); return; @@ -81,15 +144,52 @@ export default function AttachClientsModal({ open={open} onCancel={onClose} onOk={submit} - okButtonProps={{ disabled: targetIds.length === 0 || emails.length === 0, loading: saving }} + okButtonProps={{ + disabled: targetIds.length === 0 || selectedEmails.length === 0, + loading: saving, + }} okText={t('pages.inbounds.attachClients')} cancelText={t('cancel')} title={t('pages.inbounds.attachClientsTitle', { remark: source?.remark ?? '' })} + width={680} > {messageContextHolder} - {t('pages.inbounds.attachClientsDesc', { count: emails.length })} + {t('pages.inbounds.attachClientsDesc', { count: clientRows.length })} + + + {t('pages.inbounds.attachClientsSelectLabel')} + + setSearch(e.target.value)} + placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} + style={{ maxWidth: 320 }} + /> + + {t('pages.inbounds.attachClientsSelectedCount', { + selected: selectedEmails.length, + total: clientRows.length, + })} + + + + size="small" + rowKey="email" + columns={columns} + dataSource={filteredRows} + pagination={false} + scroll={{ y: 280 }} + rowSelection={{ + selectedRowKeys: selectedEmails, + onChange: (keys) => setSelectedEmails(keys as string[]), + preserveSelectedRowKeys: true, + }} + /> + + {targetOptions.length === 0 ? ( ) : ( diff --git a/frontend/src/pages/inbounds/DetachClientsModal.tsx b/frontend/src/pages/inbounds/DetachClientsModal.tsx new file mode 100644 index 00000000..b02f21d1 --- /dev/null +++ b/frontend/src/pages/inbounds/DetachClientsModal.tsx @@ -0,0 +1,183 @@ +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 { HttpUtil } from '@/utils'; +import { coerceInboundJsonField, type DBInbound } from '@/models/dbinbound'; + +interface DetachClientsModalProps { + open: boolean; + source: DBInbound | null; + onClose: () => void; + onDetached?: () => void; +} + +interface BulkDetachResult { + detached?: string[]; + skipped?: string[]; + errors?: string[]; +} + +interface ClientRow { + email: string; + comment: string; + enable: boolean; +} + +function readClientRows(settings: unknown): ClientRow[] { + const parsed = coerceInboundJsonField(settings) as { + clients?: Array<{ email?: string; comment?: string; enable?: boolean }>; + }; + const clients = Array.isArray(parsed?.clients) ? parsed.clients : []; + return clients + .map((c) => ({ + email: (c?.email || '').trim(), + comment: (c?.comment || '').trim(), + enable: c?.enable !== false, + })) + .filter((r) => r.email); +} + +export default function DetachClientsModal({ + open, + source, + onClose, + onDetached, +}: DetachClientsModalProps) { + const { t } = useTranslation(); + const [messageApi, messageContextHolder] = message.useMessage(); + const [saving, setSaving] = useState(false); + const [clientRows, setClientRows] = useState([]); + const [selectedEmails, setSelectedEmails] = useState([]); + const [search, setSearch] = useState(''); + + useEffect(() => { + if (!open) return; + const rows = source ? readClientRows(source.settings) : []; + setClientRows(rows); + setSelectedEmails([]); + setSearch(''); + }, [open, source]); + + const filteredRows = useMemo(() => { + const q = search.trim().toLowerCase(); + if (!q) return clientRows; + return clientRows.filter( + (r) => r.email.toLowerCase().includes(q) || r.comment.toLowerCase().includes(q), + ); + }, [clientRows, search]); + + const columns: ColumnsType = 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 ? ( + {t('enable')} + ) : ( + {t('pages.inbounds.attachClientsStatusDisabled')} + ), + }, + ], + [t], + ); + + async function submit() { + if (!source || selectedEmails.length === 0) return; + setSaving(true); + try { + const msg = await HttpUtil.post( + '/panel/api/clients/bulkDetach', + { emails: selectedEmails, inboundIds: [source.id] }, + { headers: { 'Content-Type': 'application/json' } }, + ); + if (!msg?.success) { + messageApi.error(msg?.msg || t('somethingWentWrong')); + return; + } + const result = (msg.obj || {}) as BulkDetachResult; + const detached = result.detached?.length ?? 0; + const skipped = result.skipped?.length ?? 0; + const errors = result.errors?.length ?? 0; + if (errors > 0) { + messageApi.warning(t('pages.inbounds.detachClientsResultMixed', { detached, skipped, errors })); + } else { + messageApi.success(t('pages.inbounds.detachClientsResult', { detached, skipped })); + } + onDetached?.(); + onClose(); + } finally { + setSaving(false); + } + } + + return ( + + {messageContextHolder} + + {t('pages.inbounds.detachClientsDesc', { count: clientRows.length })} + + + + {t('pages.inbounds.detachClientsSelectLabel')} + + setSearch(e.target.value)} + placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} + style={{ maxWidth: 320 }} + /> + + {t('pages.inbounds.attachClientsSelectedCount', { + selected: selectedEmails.length, + total: clientRows.length, + })} + + + + size="small" + rowKey="email" + columns={columns} + dataSource={filteredRows} + pagination={false} + scroll={{ y: 280 }} + rowSelection={{ + selectedRowKeys: selectedEmails, + onChange: (keys) => setSelectedEmails(keys as string[]), + preserveSelectedRowKeys: true, + }} + /> + + + ); +} diff --git a/frontend/src/pages/inbounds/InboundList.tsx b/frontend/src/pages/inbounds/InboundList.tsx index a16fbde4..ca564299 100644 --- a/frontend/src/pages/inbounds/InboundList.tsx +++ b/frontend/src/pages/inbounds/InboundList.tsx @@ -262,6 +262,7 @@ function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients }: { r items.push({ key: 'clone', icon: , label: t('pages.inbounds.clone') }); if (isInboundMultiUser(record) && hasClients) { items.push({ key: 'attachClients', icon: , label: t('pages.inbounds.attachClients') }); + items.push({ key: 'detachClients', icon: , label: t('pages.inbounds.detachClients') }); items.push({ key: 'assignGroup', icon: , label: t('pages.inbounds.assignClientsGroup') }); items.push({ key: 'delAllClients', icon: , danger: true, label: t('pages.inbounds.delAllClients') }); } diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 71171d34..35bea8b8 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -39,6 +39,7 @@ const InboundFormModal = lazy(() => import('./InboundFormModal')); 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')); type RowAction = @@ -52,6 +53,7 @@ type RowAction = | 'resetTraffic' | 'delAllClients' | 'attachClients' + | 'detachClients' | 'assignGroup' | 'clone'; @@ -127,6 +129,8 @@ export default function InboundsPage() { const [attachOpen, setAttachOpen] = useState(false); const [attachSource, setAttachSource] = useState(null); + const [detachOpen, setDetachOpen] = useState(false); + const [detachSource, setDetachSource] = useState(null); const [groupOpen, setGroupOpen] = useState(false); const [groupSource, setGroupSource] = useState(null); @@ -489,6 +493,10 @@ export default function InboundsPage() { setAttachSource(target); setAttachOpen(true); break; + case 'detachClients': + setDetachSource(target); + setDetachOpen(true); + break; case 'assignGroup': setGroupSource(target); setGroupOpen(true); @@ -614,6 +622,14 @@ export default function InboundsPage() { dbInbounds={dbInbounds} /> + + setDetachOpen(false)} + onDetached={refresh} + source={detachSource} + /> + v ?? []), + skipped: z.array(z.string()).nullable().transform((v) => v ?? []), + errors: z.array(z.string()).nullable().transform((v) => v ?? []), +}); + +export const BulkDetachResultSchema = z.object({ + detached: z.array(z.string()).nullable().transform((v) => v ?? []), + skipped: z.array(z.string()).nullable().transform((v) => v ?? []), + errors: z.array(z.string()).nullable().transform((v) => v ?? []), +}); + export const OnlinesSchema = nullableStringArray; export const GroupSummarySchema = z.object({ @@ -167,6 +179,8 @@ export type ClientHydrate = z.infer; export type BulkAdjustResult = z.infer; export type BulkDeleteResult = z.infer; export type BulkCreateResult = z.infer; +export type BulkAttachResult = z.infer; +export type BulkDetachResult = z.infer; export type ClientBulkAddFormValues = z.infer; export type ClientBulkAdjustFormValues = z.infer; export type ClientFormValues = z.infer; diff --git a/web/controller/client.go b/web/controller/client.go index 3123de05..bb51cc50 100644 --- a/web/controller/client.go +++ b/web/controller/client.go @@ -49,6 +49,7 @@ func (a *ClientController) initRouter(g *gin.RouterGroup) { g.POST("/bulkCreate", a.bulkCreate) g.POST("/bulkAssignGroup", a.bulkAssignGroup) g.POST("/bulkAttach", a.bulkAttach) + g.POST("/bulkDetach", a.bulkDetach) g.POST("/resetTraffic/:email", a.resetTrafficByEmail) g.POST("/updateTraffic/:email", a.updateTrafficByEmail) g.POST("/ips/:email", a.getIps) @@ -263,6 +264,29 @@ func (a *ClientController) bulkAttach(c *gin.Context) { notifyClientsChanged() } +type bulkDetachRequest struct { + Emails []string `json:"emails"` + InboundIds []int `json:"inboundIds"` +} + +func (a *ClientController) bulkDetach(c *gin.Context) { + var req bulkDetachRequest + if err := c.ShouldBindJSON(&req); err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + result, needRestart, err := a.clientService.BulkDetach(&a.inboundService, req.Emails, req.InboundIds) + if err != nil { + jsonMsg(c, I18nWeb(c, "somethingWentWrong"), err) + return + } + jsonObj(c, result, nil) + if needRestart { + a.xrayService.SetToNeedRestart() + } + notifyClientsChanged() +} + func (a *ClientController) bulkDelete(c *gin.Context) { var req bulkDeleteRequest if err := c.ShouldBindJSON(&req); err != nil { diff --git a/web/service/client.go b/web/service/client.go index 26a8bb04..bddfe7de 100644 --- a/web/service/client.go +++ b/web/service/client.go @@ -884,6 +884,75 @@ func (s *ClientService) BulkAttach(inboundSvc *InboundService, emails []string, return result, needRestart, nil } +// BulkDetachResult reports the outcome of a bulk detach across target inbounds. +type BulkDetachResult struct { + Detached []string `json:"detached"` + Skipped []string `json:"skipped"` + Errors []string `json:"errors"` +} + +// BulkDetach detaches the given existing clients (by email) from each target inbound. +// (email, inbound) pairs where the client is not currently attached are silently skipped +// at the inbound level; emails that aren't attached to any of the requested inbounds +// are reported under skipped. ClientRecord rows are kept even when they become orphaned +// (matches single-client detach semantics); callers should use bulkDelete for full removal. +func (s *ClientService) BulkDetach(inboundSvc *InboundService, emails []string, inboundIds []int) (*BulkDetachResult, bool, error) { + result := &BulkDetachResult{} + if len(emails) == 0 || len(inboundIds) == 0 { + return result, false, nil + } + + requested := make(map[int]struct{}, len(inboundIds)) + for _, id := range inboundIds { + requested[id] = struct{}{} + } + + needRestart := false + seenEmail := make(map[string]struct{}, len(emails)) + for _, email := range emails { + if email == "" { + continue + } + key := strings.ToLower(email) + if _, ok := seenEmail[key]; ok { + continue + } + seenEmail[key] = struct{}{} + + rec, err := s.GetRecordByEmail(nil, email) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err)) + continue + } + currentIds, err := s.GetInboundIdsForRecord(rec.Id) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", email, err)) + continue + } + intersection := make([]int, 0, len(currentIds)) + for _, id := range currentIds { + if _, ok := requested[id]; ok { + intersection = append(intersection, id) + } + } + if len(intersection) == 0 { + result.Skipped = append(result.Skipped, rec.Email) + continue + } + nr, err := s.Detach(inboundSvc, rec.Id, intersection) + if err != nil { + result.Errors = append(result.Errors, fmt.Sprintf("%s: %v", rec.Email, err)) + continue + } + if nr { + needRestart = true + } + result.Detached = append(result.Detached, rec.Email) + } + + return result, needRestart, nil +} + func (s *ClientService) DetachByEmailMany(inboundSvc *InboundService, email string, inboundIds []int) (bool, error) { if email == "" { return false, common.NewError("client email is required") diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 6830f867..db2db99e 100644 --- a/web/translation/en-US.json +++ b/web/translation/en-US.json @@ -306,6 +306,16 @@ "attachClientsNoTargets": "No other compatible inbounds available to attach to.", "attachClientsResult": "Attached {attached}, skipped {skipped}.", "attachClientsResultMixed": "Attached {attached}, skipped {skipped}, errors {errors}.", + "attachClientsSelectLabel": "Clients to attach", + "attachClientsSearchPlaceholder": "Search email or comment", + "attachClientsStatusDisabled": "Disabled", + "attachClientsSelectedCount": "{selected} of {total} selected", + "detachClients": "Detach Clients", + "detachClientsTitle": "Detach clients of \"{remark}\"", + "detachClientsDesc": "Removes the selected client(s) from this inbound only. Client records themselves are kept (use Delete to remove fully). Source has {count} clients in total.", + "detachClientsResult": "Detached {detached}, skipped {skipped}.", + "detachClientsResultMixed": "Detached {detached}, skipped {skipped}, errors {errors}.", + "detachClientsSelectLabel": "Clients to detach", "exportLinksTitle": "Export inbound links", "exportSubsTitle": "Export subscription links", "exportAllLinksTitle": "Export all inbound links", @@ -532,6 +542,19 @@ "assignGroupPlaceholder": "Group name (leave blank to clear)", "assignGroupAssignedToast": "Assigned {count} client(s) to {group}", "assignGroupClearedToast": "Cleared group from {count} client(s)", + "attachSelected": "Attach ({count})", + "attachToInboundsTitle": "Attach {count} client(s) to inbound(s)", + "attachToInboundsDesc": "Attaches the selected {count} client(s) (same UUID/password and shared traffic) to the chosen inbound(s). They keep their existing attachments too.", + "attachToInboundsTargets": "Target inbounds", + "attachToInboundsNoTargets": "No multi-user inbounds available to attach to.", + "detachSelected": "Detach ({count})", + "detach": "Detach", + "detachFromInboundsTitle": "Detach {count} client(s) from inbound(s)", + "detachFromInboundsDesc": "Removes the selected {count} client(s) from the chosen inbound(s). Pairs where the client wasn't attached are silently skipped. Client records are kept (use Delete to remove fully).", + "detachFromInboundsTargets": "Inbounds to detach from", + "detachFromInboundsNoTargets": "No multi-user inbounds available.", + "detachFromInboundsResult": "Detached {detached}, skipped {skipped}.", + "detachFromInboundsResultMixed": "Detached {detached}, skipped {skipped}, errors {errors}.", "subLinksTitle": "Sub links ({count})", "subLinkColumn": "Subscription URL", "subJsonLinkColumn": "Subscription JSON URL", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index 37d7c781..23779bab 100644 --- a/web/translation/fa-IR.json +++ b/web/translation/fa-IR.json @@ -301,6 +301,16 @@ "attachClientsNoTargets": "اینباند سازگار دیگری برای اتصال وجود ندارد.", "attachClientsResult": "{attached} متصل شد، {skipped} رد شد.", "attachClientsResultMixed": "{attached} متصل شد، {skipped} رد شد، {errors} خطا.", + "attachClientsSelectLabel": "کلاینت‌های قابل اتصال", + "attachClientsSearchPlaceholder": "جستجوی ایمیل یا توضیح", + "attachClientsStatusDisabled": "غیرفعال", + "attachClientsSelectedCount": "{selected} از {total} انتخاب‌شده", + "detachClients": "جداسازی کلاینت‌ها", + "detachClientsTitle": "جداسازی کلاینت‌های «{remark}»", + "detachClientsDesc": "کلاینت‌های انتخاب‌شده فقط از همین اینباند جدا می‌شوند. خود رکورد کلاینت‌ها حفظ می‌شود (برای حذف کامل از Delete استفاده کنید). این اینباند مجموعاً {count} کلاینت دارد.", + "detachClientsResult": "{detached} جدا شد، {skipped} رد شد.", + "detachClientsResultMixed": "{detached} جدا شد، {skipped} رد شد، {errors} خطا.", + "detachClientsSelectLabel": "کلاینت‌های قابل جداسازی", "exportLinksTitle": "خروجی لینک‌های اینباند", "exportSubsTitle": "خروجی لینک‌های ساب", "exportAllLinksTitle": "خروجی لینک‌های همه اینباندها", @@ -503,6 +513,19 @@ "deleteConfirmContent": "این کلاینت از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.", "deleteSelected": "حذف ({count})", "adjustSelected": "تنظیم ({count})", + "attachSelected": "اتصال ({count})", + "attachToInboundsTitle": "اتصال {count} کلاینت به اینباند(ها)", + "attachToInboundsDesc": "{count} کلاینت انتخاب‌شده (با همان UUID/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل می‌شوند. روی اینباندهای فعلی هم باقی می‌مانند.", + "attachToInboundsTargets": "اینباندهای مقصد", + "attachToInboundsNoTargets": "اینباند سازگار برای اتصال وجود ندارد.", + "detachSelected": "جداسازی ({count})", + "detach": "جداسازی", + "detachFromInboundsTitle": "جداسازی {count} کلاینت از اینباند(ها)", + "detachFromInboundsDesc": "{count} کلاینت انتخاب‌شده از اینباند(های) انتخابی جدا می‌شوند. زوج‌هایی که کلاینت در آن‌ها متصل نیست بی‌صدا رد می‌شوند. خود رکورد کلاینت‌ها حفظ می‌شود (برای حذف کامل از Delete استفاده کنید).", + "detachFromInboundsTargets": "اینباندهای مبدأ", + "detachFromInboundsNoTargets": "اینباند سازگار وجود ندارد.", + "detachFromInboundsResult": "{detached} جدا شد، {skipped} رد شد.", + "detachFromInboundsResultMixed": "{detached} جدا شد، {skipped} رد شد، {errors} خطا.", "bulkDeleteConfirmTitle": "حذف {count} کلاینت؟", "bulkDeleteConfirmContent": "هر کلاینت انتخاب‌شده از تمام اینباندهای متصل حذف و سابقه ترافیک آن پاک می‌شود. این عمل غیرقابل بازگشت است.", "bulkAdjustTitle": "تنظیم {count} کلاینت",