mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-16 16:20:59 +00:00
acbf09e710
Remove viewport-capped vertical scrolling so page size controls the rendered table height and page scrolling remains responsive.
318 lines
11 KiB
TypeScript
318 lines
11 KiB
TypeScript
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<DBInboundRecord | null>(null);
|
|
const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
|
|
// 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<number | 'all'>('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: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
|
|
{ key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
|
|
...(subEnable
|
|
? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
|
|
: []),
|
|
{ key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
|
|
],
|
|
onClick: ({ key }) => onGeneralAction(key as GeneralAction),
|
|
};
|
|
|
|
return (
|
|
<Card
|
|
hoverable
|
|
title={(
|
|
<Space>
|
|
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />} aria-label={t('pages.inbounds.addInbound')}>
|
|
{!isMobile && t('pages.inbounds.addInbound')}
|
|
</Button>
|
|
<Dropdown trigger={['click']} menu={generalActionsMenu}>
|
|
<Button type="primary" icon={<MenuOutlined />} aria-label={t('pages.inbounds.generalActions')}>
|
|
{!isMobile && t('pages.inbounds.generalActions')}
|
|
</Button>
|
|
</Dropdown>
|
|
{showNodeFilter && (
|
|
<Select
|
|
value={nodeFilter}
|
|
onChange={(v) => setNodeFilter(v)}
|
|
options={nodeFilterOptions}
|
|
showSearch
|
|
popupMatchSelectWidth={false}
|
|
style={{ minWidth: isMobile ? 90 : 140 }}
|
|
aria-label={t('pages.clients.filters.nodes')}
|
|
/>
|
|
)}
|
|
<Input
|
|
value={searchKey}
|
|
onChange={(e) => setSearchKey(e.target.value)}
|
|
placeholder={t('search')}
|
|
allowClear
|
|
prefix={<SearchOutlined />}
|
|
style={{ maxWidth: isMobile ? 110 : 200 }}
|
|
aria-label={t('search')}
|
|
/>
|
|
{selectedRowKeys.length > 0 && (
|
|
<>
|
|
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
|
|
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
|
|
</Tag>
|
|
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
|
|
{!isMobile && t('delete')}
|
|
</Button>
|
|
</>
|
|
)}
|
|
</Space>
|
|
)}
|
|
>
|
|
<Space orientation="vertical" style={{ width: '100%' }}>
|
|
{isMobile ? (
|
|
<div className="inbound-cards">
|
|
{visibleInbounds.length === 0 ? (
|
|
<div className="card-empty">
|
|
<ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
|
|
<div>{t('noData')}</div>
|
|
</div>
|
|
) : (
|
|
<>
|
|
<div className="card-bulk-bar">
|
|
<Checkbox
|
|
checked={allSelected}
|
|
indeterminate={someSelected}
|
|
onChange={(e) => selectAll(e.target.checked)}
|
|
>
|
|
{t('pages.inbounds.selectAll')}
|
|
</Checkbox>
|
|
{selectedRowKeys.length > 0 && (
|
|
<span className="bulk-count">{selectedRowKeys.length}</span>
|
|
)}
|
|
</div>
|
|
{visibleInbounds.map((record) => (
|
|
<div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
|
|
<div className="card-head">
|
|
<Checkbox
|
|
checked={selectedRowKeys.includes(record.id)}
|
|
onChange={(e) => toggleSelect(record.id, e.target.checked)}
|
|
/>
|
|
<span className="card-id">#{record.id}</span>
|
|
<span className="tag-name">{record.remark}</span>
|
|
<div className="card-actions">
|
|
<Tooltip title={t('pages.inbounds.inboundInfo')}>
|
|
<InfoCircleOutlined
|
|
className="row-action-trigger"
|
|
role="button"
|
|
tabIndex={0}
|
|
aria-label={t('pages.inbounds.inboundInfo')}
|
|
onClick={() => setStatsRecord(record)}
|
|
onKeyDown={activateOnKey(() => setStatsRecord(record))}
|
|
/>
|
|
</Tooltip>
|
|
<Switch
|
|
checked={record.enable}
|
|
size="small"
|
|
onChange={(next) => onSwitchEnable(record, next)}
|
|
/>
|
|
<Dropdown
|
|
trigger={['click']}
|
|
placement="bottomRight"
|
|
menu={{
|
|
items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
|
|
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
|
|
}}
|
|
>
|
|
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
|
|
</Dropdown>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={visibleInbounds}
|
|
rowKey={(r) => r.id}
|
|
rowSelection={{
|
|
selectedRowKeys,
|
|
onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
|
|
}}
|
|
pagination={paginationFor(visibleInbounds)}
|
|
scroll={{ x: tableScrollX }}
|
|
style={{ marginTop: 10 }}
|
|
size="small"
|
|
locale={{
|
|
emptyText: (
|
|
<div className="card-empty">
|
|
<ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
<div>{t('noData')}</div>
|
|
</div>
|
|
),
|
|
}}
|
|
/>
|
|
)}
|
|
</Space>
|
|
|
|
<InboundStatsModal
|
|
open={isMobile && !!statsRecord}
|
|
record={statsRecord}
|
|
hasActiveNode={hasActiveNode}
|
|
nodesById={nodesById}
|
|
clientCount={clientCount}
|
|
inboundSpeed={inboundSpeed}
|
|
trafficDiff={trafficDiff}
|
|
expireDiff={expireDiff}
|
|
onClose={() => setStatsRecord(null)}
|
|
/>
|
|
</Card>
|
|
);
|
|
}
|