HostList.tsx 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251
  1. import { useMemo } from 'react';
  2. import { useTranslation } from 'react-i18next';
  3. import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
  4. import type { ColumnsType } from 'antd/es/table';
  5. import {
  6. ArrowDownOutlined,
  7. ArrowUpOutlined,
  8. DeleteOutlined,
  9. EditOutlined,
  10. GlobalOutlined,
  11. PlusOutlined,
  12. } from '@ant-design/icons';
  13. import type { HostRecord } from '@/api/queries/useHostsQuery';
  14. import type { InboundOption } from '@/schemas/client';
  15. import './HostList.css';
  16. interface HostListProps {
  17. hosts: HostRecord[];
  18. inboundOptions: InboundOption[];
  19. loading?: boolean;
  20. isMobile?: boolean;
  21. selectedGroupIds: string[];
  22. onSelectionChange: (groupIds: string[]) => void;
  23. onAdd: () => void;
  24. onEdit: (host: HostRecord) => void;
  25. onDelete: (host: HostRecord) => void;
  26. onToggleEnable: (host: HostRecord, next: boolean) => void;
  27. onMove: (host: HostRecord, dir: 'up' | 'down') => void;
  28. onBulkEnable: (enable: boolean) => void;
  29. onBulkDelete: () => void;
  30. }
  31. const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
  32. vless: 'blue',
  33. vmess: 'geekblue',
  34. trojan: 'volcano',
  35. shadowsocks: 'magenta',
  36. hysteria: 'cyan',
  37. hysteria2: 'green',
  38. wireguard: 'gold',
  39. http: 'purple',
  40. mixed: 'lime',
  41. tunnel: 'orange',
  42. };
  43. export function sortHosts(hosts: HostRecord[]): HostRecord[] {
  44. return [...hosts].sort((a, b) => {
  45. const sa = a.sortOrder ?? 0;
  46. const sb = b.sortOrder ?? 0;
  47. if (sa !== sb) return sa - sb;
  48. return (a.remark || '').localeCompare(b.remark || '');
  49. });
  50. }
  51. export default function HostList(props: HostListProps) {
  52. const { t } = useTranslation();
  53. const {
  54. hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange,
  55. onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
  56. } = props;
  57. const inboundsMap = useMemo(() => {
  58. const map = new Map<number, InboundOption>();
  59. for (const ib of inboundOptions) map.set(ib.id, ib);
  60. return map;
  61. }, [inboundOptions]);
  62. const sorted = useMemo(() => sortHosts(hosts), [hosts]);
  63. const columns: ColumnsType<HostRecord> = [
  64. {
  65. title: t('pages.hosts.fields.actions'),
  66. key: 'actions',
  67. width: 168,
  68. render: (_, h, idx) => {
  69. const count = sorted.length;
  70. return (
  71. <Space size={2}>
  72. <Tooltip title={t('pages.hosts.moveUp')}>
  73. <Button size="small" type="text" icon={<ArrowUpOutlined />} aria-label={t('pages.hosts.moveUp')} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
  74. </Tooltip>
  75. <Tooltip title={t('pages.hosts.moveDown')}>
  76. <Button size="small" type="text" icon={<ArrowDownOutlined />} aria-label={t('pages.hosts.moveDown')} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
  77. </Tooltip>
  78. <Tooltip title={t('edit')}>
  79. <Button size="small" type="text" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(h)} />
  80. </Tooltip>
  81. <Tooltip title={t('delete')}>
  82. <Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(h)} />
  83. </Tooltip>
  84. </Space>
  85. );
  86. },
  87. },
  88. {
  89. title: t('pages.hosts.fields.enable'),
  90. key: 'enable',
  91. width: 90,
  92. render: (_, h) => (
  93. <Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} />
  94. ),
  95. },
  96. {
  97. title: t('pages.hosts.fields.remark'),
  98. dataIndex: 'remark',
  99. key: 'remark',
  100. render: (_, h) => (
  101. <div className="host-remark-cell">
  102. <span className="host-remark">{h.remark}</span>
  103. {h.serverDescription ? <span className="host-desc">{h.serverDescription}</span> : null}
  104. </div>
  105. ),
  106. },
  107. {
  108. title: t('pages.hosts.fields.endpoint'),
  109. key: 'endpoint',
  110. render: (_, h) => {
  111. const addrs = h.hosts?.filter(a => a.trim() !== '') || [];
  112. if (addrs.length === 0) return <Tag color="orange">{t('pages.hosts.fields.inheritAddress') || 'inherits'}</Tag>;
  113. const visible = addrs.slice(0, 1);
  114. const overflow = addrs.slice(1);
  115. return (
  116. <>
  117. {visible.map((addr) => <Tag key={addr}>{addr}</Tag>)}
  118. {overflow.length > 0 && (
  119. <Popover
  120. trigger="click"
  121. placement="bottomRight"
  122. content={
  123. <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
  124. {overflow.map((addr) => <Tag key={addr}>{addr}</Tag>)}
  125. </div>
  126. }
  127. >
  128. <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
  129. +{overflow.length}
  130. </Tag>
  131. </Popover>
  132. )}
  133. </>
  134. );
  135. },
  136. },
  137. {
  138. title: t('pages.hosts.fields.inbound'),
  139. key: 'inbound',
  140. render: (_, h) => {
  141. const ids = h.inboundIds || [];
  142. if (ids.length === 0) return <span className="host-muted">—</span>;
  143. const visible = ids.slice(0, 1);
  144. const overflow = ids.slice(1);
  145. const chip = (id: number) => {
  146. const ib = inboundsMap.get(id);
  147. const label = ib ? (ib.remark || ib.tag || `#${id}`) : `#${id}`;
  148. const proto = (ib?.protocol || '').toLowerCase();
  149. const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
  150. return (
  151. <Tooltip key={id} title={label}>
  152. <Tag color={color} style={{ margin: 2 }}>{label}</Tag>
  153. </Tooltip>
  154. );
  155. };
  156. return (
  157. <>
  158. {visible.map(chip)}
  159. {overflow.length > 0 && (
  160. <Popover
  161. trigger="click"
  162. placement="bottomRight"
  163. content={
  164. <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
  165. {overflow.map(chip)}
  166. </div>
  167. }
  168. >
  169. <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
  170. +{overflow.length}
  171. </Tag>
  172. </Popover>
  173. )}
  174. </>
  175. );
  176. },
  177. },
  178. {
  179. title: t('pages.hosts.fields.security'),
  180. dataIndex: 'security',
  181. key: 'security',
  182. render: (security: string) => <Tag>{security || 'same'}</Tag>,
  183. },
  184. {
  185. title: t('pages.hosts.fields.tags'),
  186. key: 'tags',
  187. render: (_, h) => (h.tags && h.tags.length > 0
  188. ? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
  189. : <span className="host-muted">—</span>),
  190. },
  191. ];
  192. const toolbar = (
  193. <div className="card-toolbar">
  194. {selectedGroupIds.length === 0 ? (
  195. <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
  196. {!isMobile && t('pages.hosts.addHost')}
  197. </Button>
  198. ) : (
  199. <>
  200. <Tag
  201. color="blue"
  202. closable
  203. onClose={() => onSelectionChange([])}
  204. style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
  205. >
  206. {t('pages.hosts.selectedCount', { count: selectedGroupIds.length })}
  207. </Tag>
  208. <Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
  209. <Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
  210. <Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
  211. </>
  212. )}
  213. </div>
  214. );
  215. return (
  216. <Card size="small" hoverable title={toolbar} className="hosts-card">
  217. <Table<HostRecord>
  218. rowKey="groupId"
  219. size="small"
  220. loading={loading}
  221. columns={columns}
  222. dataSource={sorted}
  223. pagination={false}
  224. scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }}
  225. virtual
  226. rowSelection={{
  227. selectedRowKeys: selectedGroupIds,
  228. onChange: (keys) => onSelectionChange(keys as string[]),
  229. }}
  230. locale={{
  231. emptyText: (
  232. <div className="card-empty">
  233. <GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} />
  234. <div>{t('noData')}</div>
  235. </div>
  236. ),
  237. }}
  238. />
  239. </Card>
  240. );
  241. }