mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-11 13:50:59 +00:00
feat(inbounds): attach existing clients to an inbound in one click
Adds an 'Attach Existing Clients' row action on multi-user inbounds (shown even when the inbound is empty). It opens a modal listing the whole client pool with search and group filter, all attachable clients pre-selected, and attaches the selection to that inbound via the existing bulkAttach endpoint. Clients already on the inbound are shown disabled and skipped. Translations added for all 13 locales.
This commit is contained in:
@@ -39,6 +39,7 @@ const InboundFormModal = lazy(() => import('./form/InboundFormModal'));
|
||||
const InboundInfoModal = lazy(() => import('./info/InboundInfoModal'));
|
||||
const QrCodeModal = lazy(() => import('./qr/QrCodeModal'));
|
||||
const AttachClientsModal = lazy(() => import('./clients/AttachClientsModal'));
|
||||
const AttachExistingClientsModal = lazy(() => import('./clients/AttachExistingClientsModal'));
|
||||
const DetachClientsModal = lazy(() => import('./clients/DetachClientsModal'));
|
||||
const AddClientsToGroupModal = lazy(() => import('./clients/AddClientsToGroupModal'));
|
||||
|
||||
@@ -53,6 +54,7 @@ type RowAction =
|
||||
| 'resetTraffic'
|
||||
| 'delAllClients'
|
||||
| 'attachClients'
|
||||
| 'attachExisting'
|
||||
| 'detachClients'
|
||||
| 'addToGroup'
|
||||
| 'clone';
|
||||
@@ -129,6 +131,8 @@ export default function InboundsPage() {
|
||||
|
||||
const [attachOpen, setAttachOpen] = useState(false);
|
||||
const [attachSource, setAttachSource] = useState<DBInbound | null>(null);
|
||||
const [attachExistingOpen, setAttachExistingOpen] = useState(false);
|
||||
const [attachExistingTarget, setAttachExistingTarget] = useState<DBInbound | null>(null);
|
||||
const [detachOpen, setDetachOpen] = useState(false);
|
||||
const [detachSource, setDetachSource] = useState<DBInbound | null>(null);
|
||||
|
||||
@@ -523,6 +527,10 @@ export default function InboundsPage() {
|
||||
setAttachSource(target);
|
||||
setAttachOpen(true);
|
||||
break;
|
||||
case 'attachExisting':
|
||||
setAttachExistingTarget(target);
|
||||
setAttachExistingOpen(true);
|
||||
break;
|
||||
case 'detachClients':
|
||||
setDetachSource(target);
|
||||
setDetachOpen(true);
|
||||
@@ -653,6 +661,14 @@ export default function InboundsPage() {
|
||||
dbInbounds={dbInbounds}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={attachExistingOpen}>
|
||||
<AttachExistingClientsModal
|
||||
open={attachExistingOpen}
|
||||
onClose={() => setAttachExistingOpen(false)}
|
||||
onAttached={refresh}
|
||||
target={attachExistingTarget}
|
||||
/>
|
||||
</LazyMount>
|
||||
<LazyMount when={detachOpen}>
|
||||
<DetachClientsModal
|
||||
open={detachOpen}
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
import { useEffect, useMemo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Input, Modal, Select, Space, Spin, Table, Tag, Typography, message } from 'antd';
|
||||
import type { ColumnsType } from 'antd/es/table';
|
||||
|
||||
import { HttpUtil } from '@/utils';
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
|
||||
interface AttachExistingClientsModalProps {
|
||||
open: boolean;
|
||||
target: DBInbound | null;
|
||||
onClose: () => void;
|
||||
onAttached?: () => void;
|
||||
}
|
||||
|
||||
interface BulkAttachResult {
|
||||
attached?: string[];
|
||||
skipped?: string[];
|
||||
errors?: string[];
|
||||
}
|
||||
|
||||
interface ClientRow {
|
||||
email: string;
|
||||
group: string;
|
||||
enable: boolean;
|
||||
alreadyAttached: boolean;
|
||||
}
|
||||
|
||||
interface RawClient {
|
||||
email?: string;
|
||||
group?: string;
|
||||
enable?: boolean;
|
||||
inboundIds?: number[] | null;
|
||||
}
|
||||
|
||||
export default function AttachExistingClientsModal({
|
||||
open,
|
||||
target,
|
||||
onClose,
|
||||
onAttached,
|
||||
}: AttachExistingClientsModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [clientRows, setClientRows] = useState<ClientRow[]>([]);
|
||||
const [selectedEmails, setSelectedEmails] = useState<string[]>([]);
|
||||
const [search, setSearch] = useState('');
|
||||
const [groupFilter, setGroupFilter] = useState<string | undefined>(undefined);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !target) return;
|
||||
let cancelled = false;
|
||||
setLoading(true);
|
||||
setSearch('');
|
||||
setGroupFilter(undefined);
|
||||
HttpUtil.get('/panel/api/clients/list', undefined, { silent: true })
|
||||
.then((msg) => {
|
||||
if (cancelled) return;
|
||||
const list = Array.isArray(msg?.obj) ? (msg.obj as RawClient[]) : [];
|
||||
const rows: ClientRow[] = list
|
||||
.map((c) => ({
|
||||
email: (c?.email || '').trim(),
|
||||
group: (c?.group || '').trim(),
|
||||
enable: c?.enable !== false,
|
||||
alreadyAttached: Array.isArray(c?.inboundIds) && c.inboundIds.includes(target.id),
|
||||
}))
|
||||
.filter((r) => r.email);
|
||||
setClientRows(rows);
|
||||
setSelectedEmails(rows.filter((r) => !r.alreadyAttached).map((r) => r.email));
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [open, target]);
|
||||
|
||||
const groupOptions = useMemo(() => {
|
||||
const set = new Set<string>();
|
||||
for (const r of clientRows) if (r.group) set.add(r.group);
|
||||
return [...set].sort((a, b) => a.localeCompare(b)).map((g) => ({ value: g, label: g }));
|
||||
}, [clientRows]);
|
||||
|
||||
const attachableCount = useMemo(
|
||||
() => clientRows.filter((r) => !r.alreadyAttached).length,
|
||||
[clientRows],
|
||||
);
|
||||
|
||||
const filteredRows = useMemo(() => {
|
||||
const q = search.trim().toLowerCase();
|
||||
return clientRows.filter((r) => {
|
||||
if (groupFilter && r.group !== groupFilter) return false;
|
||||
if (!q) return true;
|
||||
return r.email.toLowerCase().includes(q) || r.group.toLowerCase().includes(q);
|
||||
});
|
||||
}, [clientRows, search, groupFilter]);
|
||||
|
||||
const columns: ColumnsType<ClientRow> = useMemo(
|
||||
() => [
|
||||
{
|
||||
title: t('pages.inbounds.email'),
|
||||
dataIndex: 'email',
|
||||
key: 'email',
|
||||
ellipsis: true,
|
||||
},
|
||||
{
|
||||
title: t('pages.clients.group'),
|
||||
dataIndex: 'group',
|
||||
key: 'group',
|
||||
width: 150,
|
||||
ellipsis: true,
|
||||
render: (group: string) =>
|
||||
group ? <Tag color="geekblue">{group}</Tag> : <span style={{ color: 'rgba(0,0,0,0.45)' }}>—</span>,
|
||||
},
|
||||
{
|
||||
title: t('enable'),
|
||||
key: 'status',
|
||||
width: 140,
|
||||
render: (_v, row) => {
|
||||
if (row.alreadyAttached) return <Tag color="default">{t('pages.inbounds.attachExistingStatusAttached')}</Tag>;
|
||||
return row.enable ? (
|
||||
<Tag color="success">{t('enable')}</Tag>
|
||||
) : (
|
||||
<Tag>{t('pages.inbounds.attachClientsStatusDisabled')}</Tag>
|
||||
);
|
||||
},
|
||||
},
|
||||
],
|
||||
[t],
|
||||
);
|
||||
|
||||
async function submit() {
|
||||
if (!target || selectedEmails.length === 0) return;
|
||||
setSaving(true);
|
||||
try {
|
||||
const msg = await HttpUtil.post(
|
||||
'/panel/api/clients/bulkAttach',
|
||||
{ emails: selectedEmails, inboundIds: [target.id] },
|
||||
{ headers: { 'Content-Type': 'application/json' } },
|
||||
);
|
||||
if (!msg?.success) {
|
||||
messageApi.error(msg?.msg || t('somethingWentWrong'));
|
||||
return;
|
||||
}
|
||||
const result = (msg.obj || {}) as BulkAttachResult;
|
||||
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 }));
|
||||
}
|
||||
onAttached?.();
|
||||
onClose();
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
const noClients = !loading && clientRows.length === 0;
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={open}
|
||||
onCancel={onClose}
|
||||
onOk={submit}
|
||||
okButtonProps={{ disabled: selectedEmails.length === 0, loading: saving }}
|
||||
okText={t('pages.inbounds.attachClients')}
|
||||
cancelText={t('cancel')}
|
||||
title={t('pages.inbounds.attachExistingTitle', { remark: target?.tag ?? '' })}
|
||||
width={680}
|
||||
>
|
||||
{messageContextHolder}
|
||||
<Typography.Paragraph type="secondary">
|
||||
{t('pages.inbounds.attachExistingDesc', { count: attachableCount })}
|
||||
</Typography.Paragraph>
|
||||
|
||||
{noClients ? (
|
||||
<Alert type="info" showIcon message={t('pages.inbounds.attachExistingNoClients')} />
|
||||
) : (
|
||||
<Spin spinning={loading}>
|
||||
<Space direction="vertical" size="small" style={{ width: '100%' }}>
|
||||
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
|
||||
<Space wrap>
|
||||
<Input.Search
|
||||
allowClear
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
|
||||
style={{ width: 260 }}
|
||||
/>
|
||||
{groupOptions.length > 0 && (
|
||||
<Select
|
||||
allowClear
|
||||
value={groupFilter}
|
||||
onChange={(v) => setGroupFilter(v)}
|
||||
options={groupOptions}
|
||||
placeholder={t('pages.clients.group')}
|
||||
style={{ minWidth: 160 }}
|
||||
optionFilterProp="label"
|
||||
/>
|
||||
)}
|
||||
</Space>
|
||||
<Typography.Text type="secondary">
|
||||
{t('pages.inbounds.attachClientsSelectedCount', {
|
||||
selected: selectedEmails.length,
|
||||
total: attachableCount,
|
||||
})}
|
||||
</Typography.Text>
|
||||
</Space>
|
||||
<Table<ClientRow>
|
||||
size="small"
|
||||
rowKey="email"
|
||||
columns={columns}
|
||||
dataSource={filteredRows}
|
||||
pagination={false}
|
||||
scroll={{ y: 280 }}
|
||||
rowSelection={{
|
||||
selectedRowKeys: selectedEmails,
|
||||
onChange: (keys) => setSelectedEmails(keys as string[]),
|
||||
getCheckboxProps: (row) => ({ disabled: row.alreadyAttached }),
|
||||
preserveSelectedRowKeys: true,
|
||||
}}
|
||||
/>
|
||||
</Space>
|
||||
</Spin>
|
||||
)}
|
||||
</Modal>
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,4 @@
|
||||
export { default as AttachClientsModal } from './AttachClientsModal';
|
||||
export { default as AttachExistingClientsModal } from './AttachExistingClientsModal';
|
||||
export { default as DetachClientsModal } from './DetachClientsModal';
|
||||
export { default as AddClientsToGroupModal } from './AddClientsToGroupModal';
|
||||
|
||||
@@ -49,6 +49,9 @@ export function buildRowActionsMenu({ record, subEnable, t, isMobile, hasClients
|
||||
items.push({ key: 'clipboard', icon: <CopyOutlined />, label: t('pages.inbounds.exportInbound') });
|
||||
items.push({ key: 'resetTraffic', icon: <RetweetOutlined />, label: t('pages.inbounds.resetTraffic') });
|
||||
items.push({ key: 'clone', icon: <BlockOutlined />, label: t('pages.inbounds.clone') });
|
||||
if (isInboundMultiUser(record)) {
|
||||
items.push({ key: 'attachExisting', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachExistingClients') });
|
||||
}
|
||||
if (isInboundMultiUser(record) && hasClients) {
|
||||
items.push({ key: 'attachClients', icon: <UsergroupAddOutlined />, label: t('pages.inbounds.attachClients') });
|
||||
items.push({ key: 'detachClients', icon: <UsergroupDeleteOutlined />, label: t('pages.inbounds.detachClients') });
|
||||
|
||||
Reference in New Issue
Block a user