InboundList.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318
  1. import { useCallback, useMemo, useState, type Key } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import {
  4. Button,
  5. Card,
  6. Checkbox,
  7. Dropdown,
  8. Input,
  9. Select,
  10. Space,
  11. Switch,
  12. Table,
  13. Tag,
  14. Tooltip,
  15. type MenuProps,
  16. } from 'antd';
  17. import {
  18. PlusOutlined,
  19. MenuOutlined,
  20. MoreOutlined,
  21. ExportOutlined,
  22. ImportOutlined,
  23. ReloadOutlined,
  24. InfoCircleOutlined,
  25. DeleteOutlined,
  26. SearchOutlined,
  27. } from '@ant-design/icons';
  28. import { HttpUtil } from '@/utils';
  29. import { activateOnKey } from '@/utils/a11y';
  30. import { buildRowActionsMenu } from './RowActions';
  31. import { useInboundColumns } from './useInboundColumns';
  32. import InboundStatsModal from './InboundStatsModal';
  33. import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types';
  34. import './InboundList.css';
  35. export default function InboundList({
  36. dbInbounds,
  37. clientCount,
  38. lastOnlineMap: _lastOnlineMap,
  39. inboundSpeed,
  40. expireDiff,
  41. trafficDiff,
  42. pageSize,
  43. isMobile,
  44. subEnable,
  45. nodesById,
  46. hasActiveNode,
  47. onAddInbound,
  48. onGeneralAction,
  49. onRowAction,
  50. onBulkDelete,
  51. }: InboundListProps) {
  52. const { t } = useTranslation();
  53. const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
  54. const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
  55. // Node filter (#4997): 'all' shows everything, 0 is the local-panel
  56. // sentinel (inbounds without a nodeId), otherwise a node id. Session-only.
  57. const [nodeFilter, setNodeFilter] = useState<number | 'all'>('all');
  58. const [searchKey, setSearchKey] = useState('');
  59. const showNodeFilter = useMemo(
  60. () => nodesById.size > 0 || dbInbounds.some((ib) => ib.nodeId != null),
  61. [nodesById, dbInbounds],
  62. );
  63. const nodeFilterOptions = useMemo(
  64. () => [
  65. { value: 'all' as const, label: t('pages.clients.filters.nodes') },
  66. { value: 0, label: t('pages.clients.filters.localPanel') },
  67. ...Array.from(nodesById.values()).map((n) => ({ value: n.id, label: n.name || `#${n.id}` })),
  68. ],
  69. [nodesById, t],
  70. );
  71. const visibleInbounds = useMemo(() => {
  72. let list = dbInbounds;
  73. if (nodeFilter === 0) list = list.filter((ib) => ib.nodeId == null);
  74. else if (nodeFilter !== 'all') list = list.filter((ib) => ib.nodeId === nodeFilter);
  75. const q = searchKey.trim().toLowerCase();
  76. if (!q) return list;
  77. return list.filter((ib) => (
  78. (ib.remark || '').toLowerCase().includes(q)
  79. || String(ib.port).includes(q)
  80. || (ib.protocol || '').toLowerCase().includes(q)
  81. ));
  82. }, [dbInbounds, nodeFilter, searchKey]);
  83. const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
  84. const previous = dbInbound.enable;
  85. dbInbound.enable = next;
  86. try {
  87. const formData = new FormData();
  88. formData.append('enable', String(next));
  89. const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
  90. if (!msg?.success) dbInbound.enable = previous;
  91. } catch {
  92. dbInbound.enable = previous;
  93. }
  94. }, []);
  95. const hasAnyRemark = useMemo(
  96. () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
  97. [dbInbounds],
  98. );
  99. const hasAnySubSortIndex = useMemo(
  100. () => dbInbounds.some((i) => (i.subSortIndex ?? 1) > 1),
  101. [dbInbounds],
  102. );
  103. const toggleSelect = useCallback((id: number, checked: boolean) => {
  104. setSelectedRowKeys((prev) => {
  105. const next = new Set(prev);
  106. if (checked) next.add(id); else next.delete(id);
  107. return Array.from(next);
  108. });
  109. }, []);
  110. const selectAll = useCallback((checked: boolean) => {
  111. setSelectedRowKeys(checked ? visibleInbounds.map((i) => i.id) : []);
  112. }, [visibleInbounds]);
  113. const allSelected = visibleInbounds.length > 0 && selectedRowKeys.length === visibleInbounds.length;
  114. const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < visibleInbounds.length;
  115. const handleBulkDelete = useCallback(async () => {
  116. const ok = await onBulkDelete(selectedRowKeys);
  117. if (ok) setSelectedRowKeys([]);
  118. }, [onBulkDelete, selectedRowKeys]);
  119. const columns = useInboundColumns({
  120. hasAnyRemark,
  121. hasAnySubSortIndex,
  122. hasActiveNode,
  123. nodesById,
  124. clientCount,
  125. inboundSpeed,
  126. subEnable,
  127. expireDiff,
  128. trafficDiff,
  129. onRowAction,
  130. onSwitchEnable,
  131. });
  132. const tableScrollX = useMemo(
  133. () => columns.reduce((sum, c) => sum + (typeof c.width === 'number' ? c.width : 0), 0),
  134. [columns],
  135. );
  136. const paginationFor = (rows: DBInboundRecord[]) => {
  137. const size = pageSize > 0 ? pageSize : rows.length || 1;
  138. return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
  139. };
  140. const generalActionsMenu: MenuProps = {
  141. items: [
  142. { key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
  143. { key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
  144. ...(subEnable
  145. ? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
  146. : []),
  147. { key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
  148. ],
  149. onClick: ({ key }) => onGeneralAction(key as GeneralAction),
  150. };
  151. return (
  152. <Card
  153. hoverable
  154. title={(
  155. <Space>
  156. <Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />} aria-label={t('pages.inbounds.addInbound')}>
  157. {!isMobile && t('pages.inbounds.addInbound')}
  158. </Button>
  159. <Dropdown trigger={['click']} menu={generalActionsMenu}>
  160. <Button type="primary" icon={<MenuOutlined />} aria-label={t('pages.inbounds.generalActions')}>
  161. {!isMobile && t('pages.inbounds.generalActions')}
  162. </Button>
  163. </Dropdown>
  164. {showNodeFilter && (
  165. <Select
  166. value={nodeFilter}
  167. onChange={(v) => setNodeFilter(v)}
  168. options={nodeFilterOptions}
  169. showSearch
  170. popupMatchSelectWidth={false}
  171. style={{ minWidth: isMobile ? 90 : 140 }}
  172. aria-label={t('pages.clients.filters.nodes')}
  173. />
  174. )}
  175. <Input
  176. value={searchKey}
  177. onChange={(e) => setSearchKey(e.target.value)}
  178. placeholder={t('search')}
  179. allowClear
  180. prefix={<SearchOutlined />}
  181. style={{ maxWidth: isMobile ? 110 : 200 }}
  182. aria-label={t('search')}
  183. />
  184. {selectedRowKeys.length > 0 && (
  185. <>
  186. <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
  187. {t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
  188. </Tag>
  189. <Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
  190. {!isMobile && t('delete')}
  191. </Button>
  192. </>
  193. )}
  194. </Space>
  195. )}
  196. >
  197. <Space orientation="vertical" style={{ width: '100%' }}>
  198. {isMobile ? (
  199. <div className="inbound-cards">
  200. {visibleInbounds.length === 0 ? (
  201. <div className="card-empty">
  202. <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  203. <div>{t('noData')}</div>
  204. </div>
  205. ) : (
  206. <>
  207. <div className="card-bulk-bar">
  208. <Checkbox
  209. checked={allSelected}
  210. indeterminate={someSelected}
  211. onChange={(e) => selectAll(e.target.checked)}
  212. >
  213. {t('pages.inbounds.selectAll')}
  214. </Checkbox>
  215. {selectedRowKeys.length > 0 && (
  216. <span className="bulk-count">{selectedRowKeys.length}</span>
  217. )}
  218. </div>
  219. {visibleInbounds.map((record) => (
  220. <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
  221. <div className="card-head">
  222. <Checkbox
  223. checked={selectedRowKeys.includes(record.id)}
  224. onChange={(e) => toggleSelect(record.id, e.target.checked)}
  225. />
  226. <span className="card-id">#{record.id}</span>
  227. <span className="tag-name">{record.remark}</span>
  228. <div className="card-actions">
  229. <Tooltip title={t('pages.inbounds.inboundInfo')}>
  230. <InfoCircleOutlined
  231. className="row-action-trigger"
  232. role="button"
  233. tabIndex={0}
  234. aria-label={t('pages.inbounds.inboundInfo')}
  235. onClick={() => setStatsRecord(record)}
  236. onKeyDown={activateOnKey(() => setStatsRecord(record))}
  237. />
  238. </Tooltip>
  239. <Switch
  240. checked={record.enable}
  241. size="small"
  242. onChange={(next) => onSwitchEnable(record, next)}
  243. />
  244. <Dropdown
  245. trigger={['click']}
  246. placement="bottomRight"
  247. menu={{
  248. items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
  249. onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
  250. }}
  251. >
  252. <Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
  253. </Dropdown>
  254. </div>
  255. </div>
  256. </div>
  257. ))}
  258. </>
  259. )}
  260. </div>
  261. ) : (
  262. <Table
  263. columns={columns}
  264. dataSource={visibleInbounds}
  265. rowKey={(r) => r.id}
  266. rowSelection={{
  267. selectedRowKeys,
  268. onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
  269. }}
  270. pagination={paginationFor(visibleInbounds)}
  271. scroll={{ x: tableScrollX, y: 'calc(100vh - 320px)' }}
  272. virtual
  273. style={{ marginTop: 10 }}
  274. size="small"
  275. locale={{
  276. emptyText: (
  277. <div className="card-empty">
  278. <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  279. <div>{t('noData')}</div>
  280. </div>
  281. ),
  282. }}
  283. />
  284. )}
  285. </Space>
  286. <InboundStatsModal
  287. open={isMobile && !!statsRecord}
  288. record={statsRecord}
  289. hasActiveNode={hasActiveNode}
  290. nodesById={nodesById}
  291. clientCount={clientCount}
  292. inboundSpeed={inboundSpeed}
  293. trafficDiff={trafficDiff}
  294. expireDiff={expireDiff}
  295. onClose={() => setStatsRecord(null)}
  296. />
  297. </Card>
  298. );
  299. }