import { useCallback, useMemo, useState, type Key } from 'react'; import { useTranslation } from 'react-i18next'; import { Button, Card, Checkbox, Dropdown, Input, Select, Space, Switch, Table, Tag, Tooltip, type MenuProps, } from 'antd'; import { PlusOutlined, MenuOutlined, MoreOutlined, ExportOutlined, ImportOutlined, ReloadOutlined, InfoCircleOutlined, DeleteOutlined, SearchOutlined, } from '@ant-design/icons'; import { HttpUtil } from '@/utils'; import { activateOnKey } from '@/utils/a11y'; import { buildRowActionsMenu } from './RowActions'; import { useInboundColumns } from './useInboundColumns'; import InboundStatsModal from './InboundStatsModal'; import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types'; import './InboundList.css'; export default function InboundList({ dbInbounds, clientCount, lastOnlineMap: _lastOnlineMap, inboundSpeed, expireDiff, trafficDiff, pageSize, isMobile, subEnable, nodesById, hasActiveNode, onAddInbound, onGeneralAction, onRowAction, onBulkDelete, }: InboundListProps) { const { t } = useTranslation(); const [statsRecord, setStatsRecord] = useState(null); const [selectedRowKeys, setSelectedRowKeys] = useState([]); // Node filter (#4997): 'all' shows everything, 0 is the local-panel // sentinel (inbounds without a nodeId), otherwise a node id. Session-only. const [nodeFilter, setNodeFilter] = useState('all'); const [searchKey, setSearchKey] = useState(''); const showNodeFilter = useMemo( () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null), [nodesById, dbInbounds], ); const nodeFilterOptions = useMemo( () => [ { value: 'all' as const, label: t('pages.clients.filters.nodes') }, { value: 0, label: t('pages.clients.filters.localPanel') }, ...Array.from(nodesById.values()).map((n) => ({ value: n.id, label: n.name || `#${n.id}` })), ], [nodesById, t], ); const visibleInbounds = useMemo(() => { let list = dbInbounds; if (nodeFilter === 0) list = list.filter((ib) => ib.nodeId == null); else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter); const q = searchKey.trim().toLowerCase(); if (!q) return list; return list.filter((ib) => ( (ib.remark || '').toLowerCase().includes(q) || String(ib.port).includes(q) || (ib.protocol || '').toLowerCase().includes(q) )); }, [dbInbounds, nodeFilter, searchKey]); const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => { const previous = dbInbound.enable; dbInbound.enable = next; try { const formData = new FormData(); formData.append('enable', String(next)); const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData); if (!msg?.success) dbInbound.enable = previous; } catch { dbInbound.enable = previous; } }, []); const hasAnyRemark = useMemo( () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''), [dbInbounds], ); const hasAnySubSortIndex = useMemo( () => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1), [dbInbounds], ); const toggleSelect = useCallback((id: number, checked: boolean) => { setSelectedRowKeys((prev) => { const next = new Set(prev); if (checked) next.add(id); else next.delete(id); return Array.from(next); }); }, []); const selectAll = useCallback((checked: boolean) => { setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []); }, [visibleInbounds]); const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length; const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length; const handleBulkDelete = useCallback(async () => { const ok = await onBulkDelete(selectedRowKeys); if (ok) setSelectedRowKeys([]); }, [onBulkDelete, selectedRowKeys]); const columns = useInboundColumns({ hasAnyRemark, hasAnySubSortIndex, hasActiveNode, nodesById, clientCount, inboundSpeed, subEnable, expireDiff, trafficDiff, onRowAction, onSwitchEnable, }); const tableScrollX = useMemo( () => columns.reduce((sum, c) => sum + (typeof c.width === 'number' ? c.width : 0), 0), [columns], ); const paginationFor = (rows: DBInboundRecord[]) => { const size = pageSize > 0 ? pageSize : rows.length || 1; return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true }; }; const generalActionsMenu: MenuProps = { items: [ { key: 'import', icon: , label: t('pages.inbounds.importInbound') }, { key: 'export', icon: , label: t('pages.inbounds.export') }, ...(subEnable ? [{ key: 'subs', icon: , label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }] : []), { key: 'resetInbounds', icon: , label: t('pages.inbounds.resetAllTraffic') }, ], onClick: ({ key }) => onGeneralAction(key as GeneralAction), }; return ( {showNodeFilter && ( setSearchKey(e.target.value)} placeholder={t('search')} allowClear prefix={} style={{ maxWidth: isMobile ? 110 : 200 }} aria-label={t('search')} /> {selectedRowKeys.length > 0 && ( <> setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}> {t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })} )} )} > {isMobile ? (
{visibleInbounds.length === 0 ? (
{t('noData')}
) : ( <>
selectAll(e.target.checked)} > {t('pages.inbounds.selectAll')} {selectedRowKeys.length > 0 && ( {selectedRowKeys.length} )}
{visibleInbounds.map((record) => (
toggleSelect(record.id, e.target.checked)} /> #{record.id} {record.remark}
setStatsRecord(record)} onKeyDown={activateOnKey(() => setStatsRecord(record))} /> onSwitchEnable(record, next)} /> 0 }), onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }), }} >
))} )}
) : ( r.id} rowSelection={{ selectedRowKeys, onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]), }} pagination={paginationFor(visibleInbounds)} scroll={{ x: tableScrollX, y: 'calc(100vh - 320px)' }} virtual style={{ marginTop: 10 }} size="small" locale={{ emptyText: (
{t('noData')}
), }} /> )} setStatsRecord(null)} /> ); }