Files
3x-ui-agent/frontend/src/pages/inbounds/list/InboundList.tsx
T
Muse 051a74be80 feat(inbounds): forwarding chip on list rows
Rows whose stored ForwardingSpec is enabled get a volcano chip after
the protocol tags: '转发 → <target remark>' for managed landings
(resolved against the full row set), '转发 → <address>:<port>' for
manual endpoints, '转发 → SK5' when the target row is gone. Text is
ellipsised at 180px with a tooltip so narrow rows never overflow.
Data rides the slim list's existing forwarding field — no API change.
2026-10-21 21:01:45 +00:00

376 lines
12 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 inboundsById = useMemo(
() => new Map(dbInbounds.map((ib) => [ib.id, ib] as const)),
[dbInbounds],
);
const columns = useInboundColumns({
hasAnyRemark,
hasAnySubSortIndex,
hasActiveNode,
nodesById,
inboundsById,
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>
);
}