InboundList.tsx 8.1 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  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. Space,
  9. Switch,
  10. Table,
  11. Tag,
  12. Tooltip,
  13. type MenuProps,
  14. } from 'antd';
  15. import {
  16. PlusOutlined,
  17. MenuOutlined,
  18. MoreOutlined,
  19. ExportOutlined,
  20. ImportOutlined,
  21. ReloadOutlined,
  22. InfoCircleOutlined,
  23. DeleteOutlined,
  24. } from '@ant-design/icons';
  25. import { HttpUtil } from '@/utils';
  26. import { buildRowActionsMenu } from './RowActions';
  27. import { useInboundColumns } from './useInboundColumns';
  28. import InboundStatsModal from './InboundStatsModal';
  29. import type { DBInboundRecord, GeneralAction, InboundListProps, RowAction } from './types';
  30. import './InboundList.css';
  31. export default function InboundList({
  32. dbInbounds,
  33. clientCount,
  34. lastOnlineMap: _lastOnlineMap,
  35. expireDiff,
  36. trafficDiff,
  37. pageSize,
  38. isMobile,
  39. subEnable,
  40. nodesById,
  41. hasActiveNode,
  42. onAddInbound,
  43. onGeneralAction,
  44. onRowAction,
  45. onBulkDelete,
  46. }: InboundListProps) {
  47. const { t } = useTranslation();
  48. const [statsRecord, setStatsRecord] = useState<DBInboundRecord | null>(null);
  49. const [selectedRowKeys, setSelectedRowKeys] = useState<number[]>([]);
  50. const onSwitchEnable = useCallback(async (dbInbound: DBInboundRecord, next: boolean) => {
  51. const previous = dbInbound.enable;
  52. dbInbound.enable = next;
  53. try {
  54. const formData = new FormData();
  55. formData.append('enable', String(next));
  56. const msg = await HttpUtil.post(`/panel/api/inbounds/setEnable/${dbInbound.id}`, formData);
  57. if (!msg?.success) dbInbound.enable = previous;
  58. } catch {
  59. dbInbound.enable = previous;
  60. }
  61. }, []);
  62. const hasAnyRemark = useMemo(
  63. () => dbInbounds.some((i) => typeof i.remark === 'string' && i.remark.trim() !== ''),
  64. [dbInbounds],
  65. );
  66. const toggleSelect = useCallback((id: number, checked: boolean) => {
  67. setSelectedRowKeys((prev) => {
  68. const next = new Set(prev);
  69. if (checked) next.add(id); else next.delete(id);
  70. return Array.from(next);
  71. });
  72. }, []);
  73. const selectAll = useCallback((checked: boolean) => {
  74. setSelectedRowKeys(checked ? dbInbounds.map((i) => i.id) : []);
  75. }, [dbInbounds]);
  76. const allSelected = dbInbounds.length > 0 && selectedRowKeys.length === dbInbounds.length;
  77. const someSelected = selectedRowKeys.length > 0 && selectedRowKeys.length < dbInbounds.length;
  78. const handleBulkDelete = useCallback(async () => {
  79. const ok = await onBulkDelete(selectedRowKeys);
  80. if (ok) setSelectedRowKeys([]);
  81. }, [onBulkDelete, selectedRowKeys]);
  82. const columns = useInboundColumns({
  83. hasAnyRemark,
  84. hasActiveNode,
  85. nodesById,
  86. clientCount,
  87. subEnable,
  88. expireDiff,
  89. trafficDiff,
  90. onRowAction,
  91. onSwitchEnable,
  92. });
  93. const paginationFor = (rows: DBInboundRecord[]) => {
  94. const size = pageSize > 0 ? pageSize : rows.length || 1;
  95. return { pageSize: size, showSizeChanger: false, hideOnSinglePage: true };
  96. };
  97. const generalActionsMenu: MenuProps = {
  98. items: [
  99. { key: 'import', icon: <ImportOutlined />, label: t('pages.inbounds.importInbound') },
  100. { key: 'export', icon: <ExportOutlined />, label: t('pages.inbounds.export') },
  101. ...(subEnable
  102. ? [{ key: 'subs', icon: <ExportOutlined />, label: `${t('pages.inbounds.export')} — ${t('pages.settings.subSettings')}` }]
  103. : []),
  104. { key: 'resetInbounds', icon: <ReloadOutlined />, label: t('pages.inbounds.resetAllTraffic') },
  105. ],
  106. onClick: ({ key }) => onGeneralAction(key as GeneralAction),
  107. };
  108. return (
  109. <Card
  110. hoverable
  111. title={(
  112. <Space>
  113. <Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
  114. {!isMobile && t('pages.inbounds.addInbound')}
  115. </Button>
  116. <Dropdown trigger={['click']} menu={generalActionsMenu}>
  117. <Button type="primary" icon={<MenuOutlined />}>
  118. {!isMobile && t('pages.inbounds.generalActions')}
  119. </Button>
  120. </Dropdown>
  121. {selectedRowKeys.length > 0 && (
  122. <>
  123. <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
  124. {t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
  125. </Tag>
  126. <Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
  127. {!isMobile && t('delete')}
  128. </Button>
  129. </>
  130. )}
  131. </Space>
  132. )}
  133. >
  134. <Space orientation="vertical" style={{ width: '100%' }}>
  135. {isMobile ? (
  136. <div className="inbound-cards">
  137. {dbInbounds.length === 0 ? (
  138. <div className="card-empty">
  139. <ImportOutlined style={{ fontSize: 28, opacity: 0.5 }} />
  140. <div>{t('noData')}</div>
  141. </div>
  142. ) : (
  143. <>
  144. <div className="card-bulk-bar">
  145. <Checkbox
  146. checked={allSelected}
  147. indeterminate={someSelected}
  148. onChange={(e) => selectAll(e.target.checked)}
  149. >
  150. {t('pages.inbounds.selectAll')}
  151. </Checkbox>
  152. {selectedRowKeys.length > 0 && (
  153. <span className="bulk-count">{selectedRowKeys.length}</span>
  154. )}
  155. </div>
  156. {dbInbounds.map((record) => (
  157. <div key={record.id} className={`inbound-card${selectedRowKeys.includes(record.id) ? ' is-selected' : ''}`}>
  158. <div className="card-head">
  159. <Checkbox
  160. checked={selectedRowKeys.includes(record.id)}
  161. onChange={(e) => toggleSelect(record.id, e.target.checked)}
  162. />
  163. <span className="card-id">#{record.id}</span>
  164. <span className="tag-name">{record.remark}</span>
  165. <div className="card-actions" onClick={(e) => e.stopPropagation()}>
  166. <Tooltip title={t('pages.inbounds.inboundInfo')}>
  167. <InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
  168. </Tooltip>
  169. <Switch
  170. checked={record.enable}
  171. size="small"
  172. onChange={(next) => onSwitchEnable(record, next)}
  173. />
  174. <Dropdown
  175. trigger={['click']}
  176. placement="bottomRight"
  177. menu={{
  178. items: buildRowActionsMenu({ record, subEnable, t, isMobile: true, hasClients: (clientCount[record.id]?.clients || 0) > 0 }),
  179. onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
  180. }}
  181. >
  182. <MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
  183. </Dropdown>
  184. </div>
  185. </div>
  186. </div>
  187. ))}
  188. </>
  189. )}
  190. </div>
  191. ) : (
  192. <Table
  193. columns={columns}
  194. dataSource={dbInbounds}
  195. rowKey={(r) => r.id}
  196. rowSelection={{
  197. selectedRowKeys,
  198. onChange: (keys: Key[]) => setSelectedRowKeys(keys as number[]),
  199. }}
  200. pagination={paginationFor(dbInbounds)}
  201. scroll={{ x: 1000 }}
  202. style={{ marginTop: 10 }}
  203. size="small"
  204. locale={{
  205. emptyText: (
  206. <div className="card-empty">
  207. <ImportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  208. <div>{t('noData')}</div>
  209. </div>
  210. ),
  211. }}
  212. />
  213. )}
  214. </Space>
  215. <InboundStatsModal
  216. open={isMobile && !!statsRecord}
  217. record={statsRecord}
  218. hasActiveNode={hasActiveNode}
  219. nodesById={nodesById}
  220. clientCount={clientCount}
  221. trafficDiff={trafficDiff}
  222. expireDiff={expireDiff}
  223. onClose={() => setStatsRecord(null)}
  224. />
  225. </Card>
  226. );
  227. }