diff --git a/frontend/src/pages/xray/outbounds/OutboundCardList.tsx b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx new file mode 100644 index 00000000..e438dbbb --- /dev/null +++ b/frontend/src/pages/xray/outbounds/OutboundCardList.tsx @@ -0,0 +1,127 @@ +import { useTranslation } from 'react-i18next'; +import { Button, Dropdown, Tag, Tooltip } from 'antd'; +import { + RetweetOutlined, + MoreOutlined, + EditOutlined, + DeleteOutlined, + VerticalAlignTopOutlined, + ThunderboltOutlined, + CheckCircleFilled, + CloseCircleFilled, + LoadingOutlined, +} from '@ant-design/icons'; + +import { SizeFormatter } from '@/utils'; +import { OutboundProtocols as Protocols } from '@/schemas/primitives'; +import type { OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; + +import type { OutboundRow } from './outbounds-tab-types'; +import { + isTesting, + isUntestable, + outboundAddresses, + showSecurity, + testResult, + trafficFor, +} from './outbounds-tab-helpers'; + +interface OutboundCardListProps { + rows: OutboundRow[]; + testMode: 'tcp' | 'http'; + outboundsTraffic: OutboundTrafficRow[]; + outboundTestStates: Record; + setFirst: (idx: number) => void; + openEdit: (idx: number) => void; + onResetTraffic: (tag: string) => void; + confirmDelete: (idx: number) => void; + onTest: (index: number, mode: string) => void; +} + +export default function OutboundCardList({ + rows, + testMode, + outboundsTraffic, + outboundTestStates, + setFirst, + openEdit, + onResetTraffic, + confirmDelete, + onTest, +}: OutboundCardListProps) { + const { t } = useTranslation(); + if (rows.length === 0) { + return
; + } + return ( + <> + {rows.map((record, index) => ( +
+
+
+ {index + 1} + + {record.tag} + + {record.protocol} + {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && ( + <> + {record.streamSettings?.network} + {showSecurity(record.streamSettings?.security) && {record.streamSettings?.security}} + + )} +
+ 0 + ? [{ key: 'top', label: , onClick: () => setFirst(index) }] + : []), + { key: 'edit', label: <> {t('edit')}, onClick: () => openEdit(index) }, + { key: 'reset', label: <> {t('pages.inbounds.resetTraffic')}, onClick: () => onResetTraffic(record.tag || '') }, + { key: 'del', danger: true, label: <> {t('delete')}, onClick: () => confirmDelete(index) }, + ], + }} + > +
+ {outboundAddresses(record).length > 0 && ( +
+ {outboundAddresses(record).map((addr) => ( + + {addr} + + ))} +
+ )} +
+ ↑ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).up)} + + ↓ {SizeFormatter.sizeFormat(trafficFor(outboundsTraffic, record).down)} + + {testResult(outboundTestStates, index) ? ( + + {testResult(outboundTestStates, index)!.success ? : } + {testResult(outboundTestStates, index)!.success ? {testResult(outboundTestStates, index)!.delay} ms : failed} + + ) : isTesting(outboundTestStates, index) ? ( + + ) : null} +
+
+ ))} + + ); +} diff --git a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx index bfa77601..a68a65e9 100644 --- a/frontend/src/pages/xray/outbounds/OutboundsTab.tsx +++ b/frontend/src/pages/xray/outbounds/OutboundsTab.tsx @@ -3,15 +3,12 @@ import { useTranslation } from 'react-i18next'; import { Button, Col, - Dropdown, Modal, Popconfirm, - Popover, Radio, Row, Space, Table, - Tag, Tooltip, } from 'antd'; import { @@ -19,27 +16,17 @@ import { CloudOutlined, ApiOutlined, RetweetOutlined, - MoreOutlined, - EditOutlined, - DeleteOutlined, - VerticalAlignTopOutlined, - ThunderboltOutlined, - CheckCircleFilled, - CloseCircleFilled, - LoadingOutlined, - ArrowUpOutlined, - ArrowDownOutlined, PlayCircleOutlined, } from '@ant-design/icons'; -import type { ColumnsType } from 'antd/es/table'; -import { SizeFormatter } from '@/utils'; -import { OutboundProtocols as Protocols } from '@/schemas/primitives'; import OutboundFormModal from './OutboundFormModal'; -import { isUdpOutbound } from '@/hooks/useXraySetting'; import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting'; import './OutboundsTab.css'; +import type { OutboundRow } from './outbounds-tab-types'; +import { useOutboundColumns } from './useOutboundColumns'; +import OutboundCardList from './OutboundCardList'; + interface OutboundsTabProps { templateSettings: XraySettingsValue | null; setTemplateSettings: SetTemplate; @@ -55,59 +42,6 @@ interface OutboundsTabProps { onShowNord: () => void; } -interface OutboundRow { - key: number; - tag?: string; - protocol?: string; - streamSettings?: { network?: string; security?: string }; - settings?: Record; -} - -function outboundAddresses(o: OutboundRow): string[] { - const settings = o.settings as Record | undefined; - switch (o.protocol) { - case Protocols.VMess: { - const serverObj = settings?.vnext as Array<{ address: string; port: number }> | undefined; - return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : []; - } - case Protocols.VLESS: - return [`${settings?.address || ''}:${settings?.port || ''}`]; - case Protocols.HTTP: - case Protocols.Socks: - case Protocols.Shadowsocks: - case Protocols.Trojan: { - const serverObj = settings?.servers as Array<{ address: string; port: number }> | undefined; - return serverObj ? serverObj.map((s) => `${s.address}:${s.port}`) : []; - } - case Protocols.DNS: { - const addr = (settings?.rewriteAddress as string) || (settings?.address as string) || ''; - const port = (settings?.rewritePort as string | number) || (settings?.port as string | number) || ''; - return addr || port ? [`${addr}:${port}`] : []; - } - case Protocols.Wireguard: - return (((settings?.peers as Array<{ endpoint?: string }>) || []).map((p) => p.endpoint || '').filter(Boolean)); - default: - return []; - } -} - -function isUntestable(o: OutboundRow, mode: string): boolean { - if (!o) return true; - if (o.protocol === Protocols.Blackhole || o.protocol === Protocols.Loopback || o.tag === 'blocked') return true; - if (mode === 'tcp' && (o.protocol === Protocols.Freedom || o.protocol === Protocols.DNS)) return true; - return false; -} - -function showSecurity(security?: string): boolean { - return security === 'tls' || security === 'reality'; -} - -function hasBreakdown(r: { endpoints?: unknown[]; error?: string } | null | undefined): boolean { - if (!r) return false; - if (r.endpoints?.length) return true; - return !!r.error; -} - export default function OutboundsTab({ templateSettings, setTemplateSettings, @@ -213,171 +147,19 @@ export default function OutboundsTab({ }); } - function trafficFor(o: OutboundRow): { up: number; down: number } { - const tr = outboundsTraffic.find((x) => x.tag === o.tag); - return { up: tr?.up || 0, down: tr?.down || 0 }; - } - function isTesting(idx: number): boolean { - return !!outboundTestStates?.[idx]?.testing; - } - function testResult(idx: number) { - return outboundTestStates?.[idx]?.result || null; - } - - const columns: ColumnsType = useMemo( - () => [ - { - title: '#', - key: 'action', - align: 'center', - width: 100, - render: (_v, _record, index) => ( -
- {index + 1} -
-
-
- ), - }, - { - title: t('pages.xray.outbound.tag'), - key: 'identity', - align: 'left', - render: (_v, record) => ( -
- - {record.tag} - -
- {record.protocol} - {[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol as never) && ( - <> - {record.streamSettings?.network} - {showSecurity(record.streamSettings?.security) && {record.streamSettings?.security}} - - )} -
-
- ), - }, - { - title: t('pages.inbounds.address'), - key: 'address', - align: 'left', - render: (_v, record) => { - const addrs = outboundAddresses(record); - return ( -
- {addrs.length === 0 ? ( - - ) : ( - addrs.map((addr) => ( - - {addr} - - )) - )} -
- ); - }, - }, - { - title: t('pages.inbounds.traffic'), - key: 'traffic', - align: 'left', - width: 200, - render: (_v, record) => { - const tr = trafficFor(record); - return ( - <> - ↑ {SizeFormatter.sizeFormat(tr.up)} - - ↓ {SizeFormatter.sizeFormat(tr.down)} - - ); - }, - }, - { - title: t('pages.nodes.latency'), - key: 'testResult', - align: 'left', - width: 140, - render: (_v, _record, index) => { - const r = testResult(index); - if (!r) return isTesting(index) ? : ; - return ( - -
- {r.success ? {r.delay} ms : {r.error || 'failed'}} - {r.mode && {String(r.mode).toUpperCase()}} -
- {hasBreakdown(r) && ( - <> - {(r.endpoints || []).map((ep) => ( -
- - {ep.address} - {ep.success ? `${ep.delay} ms` : ep.error || 'failed'} -
- ))} - - )} - - } - > - - {r.success ? : } - {r.success ? {r.delay} ms : failed} - -
- ); - }, - }, - { - title: t('check'), - key: 'test', - align: 'center', - width: 80, - render: (_v, record, index) => ( - -