diff --git a/frontend/src/pages/inbounds/InboundsPage.tsx b/frontend/src/pages/inbounds/InboundsPage.tsx index 459c6b92..0f3580ac 100644 --- a/frontend/src/pages/inbounds/InboundsPage.tsx +++ b/frontend/src/pages/inbounds/InboundsPage.tsx @@ -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(null); + const [attachExistingOpen, setAttachExistingOpen] = useState(false); + const [attachExistingTarget, setAttachExistingTarget] = useState(null); const [detachOpen, setDetachOpen] = useState(false); const [detachSource, setDetachSource] = useState(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} /> + + setAttachExistingOpen(false)} + onAttached={refresh} + target={attachExistingTarget} + /> + 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([]); + const [selectedEmails, setSelectedEmails] = useState([]); + const [search, setSearch] = useState(''); + const [groupFilter, setGroupFilter] = useState(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(); + 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 = 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 ? {group} : , + }, + { + title: t('enable'), + key: 'status', + width: 140, + render: (_v, row) => { + if (row.alreadyAttached) return {t('pages.inbounds.attachExistingStatusAttached')}; + return row.enable ? ( + {t('enable')} + ) : ( + {t('pages.inbounds.attachClientsStatusDisabled')} + ); + }, + }, + ], + [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 ( + + {messageContextHolder} + + {t('pages.inbounds.attachExistingDesc', { count: attachableCount })} + + + {noClients ? ( + + ) : ( + + + + + setSearch(e.target.value)} + placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} + style={{ width: 260 }} + /> + {groupOptions.length > 0 && ( +