diff --git a/frontend/public/openapi.json b/frontend/public/openapi.json index b6c000aa..236de233 100644 --- a/frontend/public/openapi.json +++ b/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, diff --git a/frontend/src/api/invalidationTracker.ts b/frontend/src/api/invalidationTracker.ts new file mode 100644 index 00000000..98f9e97b --- /dev/null +++ b/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; +} diff --git a/frontend/src/api/websocketBridge.ts b/frontend/src/api/websocketBridge.ts index a5c99031..b6d94224 100644 --- a/frontend/src/api/websocketBridge.ts +++ b/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 { diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts index c92b552b..69feadbe 100644 --- a/frontend/src/hooks/useClients.ts +++ b/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 bulkAssignGroupMut.mutateAsync({ emails, group }); - }, [bulkAssignGroupMut]); + return bulkAddToGroupMut.mutateAsync({ emails, group }); + }, [bulkAddToGroupMut]); + const bulkRemoveFromGroup = useCallback((emails: string[]) => { + if (!Array.isArray(emails) || emails.length === 0) return Promise.resolve(null); + return bulkRemoveFromGroupMut.mutateAsync({ emails }); + }, [bulkRemoveFromGroupMut]); const attach = useCallback((email: string, inboundIds: number[]) => { if (!email) return Promise.resolve(null as unknown as Msg); return attachMut.mutateAsync({ email, inboundIds }); @@ -472,7 +486,8 @@ export function useClients() { remove, bulkDelete, bulkAdjust, - bulkAssignGroup, + bulkAddToGroup, + bulkRemoveFromGroup, attach, bulkAttach, detach, diff --git a/frontend/src/pages/api-docs/endpoints.ts b/frontend/src/pages/api-docs/endpoints.ts index 36ae1e0b..fa00683d 100644 --- a/frontend/src/pages/api-docs/endpoints.ts +++ b/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}', }, diff --git a/frontend/src/pages/clients/BulkAssignGroupModal.tsx b/frontend/src/pages/clients/BulkAddToGroupModal.tsx similarity index 75% rename from frontend/src/pages/clients/BulkAssignGroupModal.tsx rename to frontend/src/pages/clients/BulkAddToGroupModal.tsx index b1389b50..538ad4c3 100644 --- a/frontend/src/pages/clients/BulkAssignGroupModal.tsx +++ b/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} onOpenChange(false)} onOk={submit} destroyOnHidden @@ -61,11 +59,11 @@ export default function BulkAssignGroupModal({
({ value: g }))} onChange={(v) => setValue(v ?? '')} filterOption={(input, option) => diff --git a/frontend/src/pages/clients/ClientsPage.tsx b/frontend/src/pages/clients/ClientsPage.tsx index dfb155a3..f62c6237 100644 --- a/frontend/src/pages/clients/ClientsPage.tsx +++ b/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 ( + + + + ); +} + 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 ; 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() { + + )} setBulkAdjustOpen(true), }, - { - key: 'group', - icon: , - label: t('pages.clients.group'), - onClick: () => setBulkGroupOpen(true), - }, { key: 'subLinks', icon: , @@ -1181,13 +1241,13 @@ export default function ClientsPage() { /> - { - 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 }; diff --git a/frontend/src/pages/groups/GroupAddClientsModal.tsx b/frontend/src/pages/groups/GroupAddClientsModal.tsx new file mode 100644 index 00000000..46e7d9ca --- /dev/null +++ b/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([]); + const [search, setSearch] = useState(''); + + const rows = useMemo( + () => + (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 = 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 ? {g} : , + }, + { + title: t('enable'), + dataIndex: 'enable', + key: 'enable', + width: 90, + render: (enabled: boolean) => + enabled ? ( + {t('enable')} + ) : ( + {t('pages.inbounds.attachClientsStatusDisabled')} + ), + }, + ], + [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 ( + + {messageContextHolder} + + {t('pages.groups.addToGroupDesc')} + + + + + setSearch(e.target.value)} + placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} + style={{ maxWidth: 320 }} + /> + + {t('pages.inbounds.attachClientsSelectedCount', { + selected: selectedEmails.length, + total: rows.length, + })} + + + {rows.length === 0 ? ( + + ) : ( + + size="small" + rowKey="email" + columns={columns} + dataSource={filteredRows} + pagination={false} + scroll={{ y: 320 }} + rowSelection={{ + selectedRowKeys: selectedEmails, + onChange: (keys) => setSelectedEmails(keys as string[]), + preserveSelectedRowKeys: true, + }} + /> + )} + + + ); +} diff --git a/frontend/src/pages/groups/GroupRemoveClientsModal.tsx b/frontend/src/pages/groups/GroupRemoveClientsModal.tsx new file mode 100644 index 00000000..5c899a29 --- /dev/null +++ b/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([]); + const [search, setSearch] = useState(''); + + const rows = useMemo( + () => + (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 = 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 (!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 ( + + {messageContextHolder} + + {t('pages.groups.removeFromGroupDesc')} + + + + + setSearch(e.target.value)} + placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} + style={{ maxWidth: 320 }} + /> + + {t('pages.inbounds.attachClientsSelectedCount', { + selected: selectedEmails.length, + total: rows.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/groups/GroupsPage.tsx b/frontend/src/pages/groups/GroupsPage.tsx index 21b6450a..1e1cc896 100644 --- a/frontend/src/pages/groups/GroupsPage.tsx +++ b/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([]); const [groupForAction, setGroupForAction] = useState(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: , + label: t('pages.groups.addToGroup'), + onClick: () => openAddClientsFor(row), + }, + { + key: 'removeClients', + icon: , + 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() { }} /> + + + 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; + }} + /> + + + + 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; + }} + /> + ); diff --git a/frontend/src/pages/inbounds/AssignClientsGroupModal.tsx b/frontend/src/pages/inbounds/AddClientsToGroupModal.tsx similarity index 81% rename from frontend/src/pages/inbounds/AssignClientsGroupModal.tsx rename to frontend/src/pages/inbounds/AddClientsToGroupModal.tsx index 0c4bc208..50f5eb6c 100644 --- a/frontend/src/pages/inbounds/AssignClientsGroupModal.tsx +++ b/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([]); const emails = useMemo(() => (source ? readClientEmails(source.settings) : []), [source]); @@ -41,19 +41,19 @@ export default function AssignClientsGroupModal({ }, [open]); return ( - { 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 }; }} /> diff --git a/frontend/src/pages/inbounds/InboundList.tsx b/frontend/src/pages/inbounds/InboundList.tsx index ca564299..c0168190 100644 --- a/frontend/src/pages/inbounds/InboundList.tsx +++ b/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: , 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: 'addToGroup', icon: , label: t('pages.inbounds.addClientsToGroup') }); items.push({ key: 'delAllClients', icon: , danger: true, label: t('pages.inbounds.delAllClients') }); } items.push({ key: 'delete', icon: , danger: true, label: t('delete') }); diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 35bea8b8..7b42b571 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/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() { /> - setGroupOpen(false)} - onAssigned={refresh} + onAdded={refresh} source={groupSource} /> diff --git a/web/controller/api.go b/web/controller/api.go index 572410e9..06d35441 100644 --- a/web/controller/api.go +++ b/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") diff --git a/web/controller/api_docs_test.go b/web/controller/api_docs_test.go index ad1f8d08..f356196d 100644 --- a/web/controller/api_docs_test.go +++ b/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": diff --git a/web/controller/client.go b/web/controller/client.go index bb51cc50..439afca9 100644 --- a/web/controller/client.go +++ b/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() -} diff --git a/web/controller/group.go b/web/controller/group.go new file mode 100644 index 00000000..e9075c6a --- /dev/null +++ b/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() +} diff --git a/web/service/client.go b/web/service/client.go index bddfe7de..a15d0e1b 100644 --- a/web/service/client.go +++ b/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 diff --git a/web/translation/en-US.json b/web/translation/en-US.json index 1f1ac320..1932a370 100644 --- a/web/translation/en-US.json +++ b/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", diff --git a/web/translation/fa-IR.json b/web/translation/fa-IR.json index e4e60c4f..406a8da2 100644 --- a/web/translation/fa-IR.json +++ b/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/پسورد و ترافیک مشترک) به اینباند(های) انتخابی متصل می‌شوند. روی اینباندهای فعلی هم باقی می‌مانند.",