| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251 |
- import { useMemo } from 'react';
- import { useTranslation } from 'react-i18next';
- import { Button, Card, Popover, Space, Switch, Table, Tag, Tooltip } from 'antd';
- import type { ColumnsType } from 'antd/es/table';
- import {
- ArrowDownOutlined,
- ArrowUpOutlined,
- DeleteOutlined,
- EditOutlined,
- GlobalOutlined,
- PlusOutlined,
- } from '@ant-design/icons';
- import type { HostRecord } from '@/api/queries/useHostsQuery';
- import type { InboundOption } from '@/schemas/client';
- import './HostList.css';
- interface HostListProps {
- hosts: HostRecord[];
- inboundOptions: InboundOption[];
- loading?: boolean;
- isMobile?: boolean;
- selectedGroupIds: string[];
- onSelectionChange: (groupIds: string[]) => void;
- onAdd: () => void;
- onEdit: (host: HostRecord) => void;
- onDelete: (host: HostRecord) => void;
- onToggleEnable: (host: HostRecord, next: boolean) => void;
- onMove: (host: HostRecord, dir: 'up' | 'down') => void;
- onBulkEnable: (enable: boolean) => void;
- onBulkDelete: () => void;
- }
- const INBOUND_PROTOCOL_COLORS: Record<string, string> = {
- vless: 'blue',
- vmess: 'geekblue',
- trojan: 'volcano',
- shadowsocks: 'magenta',
- hysteria: 'cyan',
- hysteria2: 'green',
- wireguard: 'gold',
- http: 'purple',
- mixed: 'lime',
- tunnel: 'orange',
- };
- export function sortHosts(hosts: HostRecord[]): HostRecord[] {
- return [...hosts].sort((a, b) => {
- const sa = a.sortOrder ?? 0;
- const sb = b.sortOrder ?? 0;
- if (sa !== sb) return sa - sb;
- return (a.remark || '').localeCompare(b.remark || '');
- });
- }
- export default function HostList(props: HostListProps) {
- const { t } = useTranslation();
- const {
- hosts, inboundOptions, loading, isMobile, selectedGroupIds, onSelectionChange,
- onAdd, onEdit, onDelete, onToggleEnable, onMove, onBulkEnable, onBulkDelete,
- } = props;
- const inboundsMap = useMemo(() => {
- const map = new Map<number, InboundOption>();
- for (const ib of inboundOptions) map.set(ib.id, ib);
- return map;
- }, [inboundOptions]);
- const sorted = useMemo(() => sortHosts(hosts), [hosts]);
- const columns: ColumnsType<HostRecord> = [
- {
- title: t('pages.hosts.fields.actions'),
- key: 'actions',
- width: 168,
- render: (_, h, idx) => {
- const count = sorted.length;
- return (
- <Space size={2}>
- <Tooltip title={t('pages.hosts.moveUp')}>
- <Button size="small" type="text" icon={<ArrowUpOutlined />} aria-label={t('pages.hosts.moveUp')} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
- </Tooltip>
- <Tooltip title={t('pages.hosts.moveDown')}>
- <Button size="small" type="text" icon={<ArrowDownOutlined />} aria-label={t('pages.hosts.moveDown')} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
- </Tooltip>
- <Tooltip title={t('edit')}>
- <Button size="small" type="text" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(h)} />
- </Tooltip>
- <Tooltip title={t('delete')}>
- <Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(h)} />
- </Tooltip>
- </Space>
- );
- },
- },
- {
- title: t('pages.hosts.fields.enable'),
- key: 'enable',
- width: 90,
- render: (_, h) => (
- <Switch size="small" checked={!h.isDisabled} onChange={(next) => onToggleEnable(h, next)} />
- ),
- },
- {
- title: t('pages.hosts.fields.remark'),
- dataIndex: 'remark',
- key: 'remark',
- render: (_, h) => (
- <div className="host-remark-cell">
- <span className="host-remark">{h.remark}</span>
- {h.serverDescription ? <span className="host-desc">{h.serverDescription}</span> : null}
- </div>
- ),
- },
- {
- title: t('pages.hosts.fields.endpoint'),
- key: 'endpoint',
- render: (_, h) => {
- const addrs = h.hosts?.filter(a => a.trim() !== '') || [];
- if (addrs.length === 0) return <Tag color="orange">{t('pages.hosts.fields.inheritAddress') || 'inherits'}</Tag>;
- const visible = addrs.slice(0, 1);
- const overflow = addrs.slice(1);
- return (
- <>
- {visible.map((addr) => <Tag key={addr}>{addr}</Tag>)}
- {overflow.length > 0 && (
- <Popover
- trigger="click"
- placement="bottomRight"
- content={
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
- {overflow.map((addr) => <Tag key={addr}>{addr}</Tag>)}
- </div>
- }
- >
- <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
- +{overflow.length}
- </Tag>
- </Popover>
- )}
- </>
- );
- },
- },
- {
- title: t('pages.hosts.fields.inbound'),
- key: 'inbound',
- render: (_, h) => {
- const ids = h.inboundIds || [];
- if (ids.length === 0) return <span className="host-muted">—</span>;
- const visible = ids.slice(0, 1);
- const overflow = ids.slice(1);
- const chip = (id: number) => {
- const ib = inboundsMap.get(id);
- const label = ib ? (ib.remark || ib.tag || `#${id}`) : `#${id}`;
- const proto = (ib?.protocol || '').toLowerCase();
- const color = INBOUND_PROTOCOL_COLORS[proto] ?? 'default';
- return (
- <Tooltip key={id} title={label}>
- <Tag color={color} style={{ margin: 2 }}>{label}</Tag>
- </Tooltip>
- );
- };
- return (
- <>
- {visible.map(chip)}
- {overflow.length > 0 && (
- <Popover
- trigger="click"
- placement="bottomRight"
- content={
- <div style={{ display: 'flex', flexDirection: 'column', gap: 4, maxWidth: 280, maxHeight: 280, overflowY: 'auto' }}>
- {overflow.map(chip)}
- </div>
- }
- >
- <Tag color="default" style={{ margin: 2, cursor: 'pointer' }}>
- +{overflow.length}
- </Tag>
- </Popover>
- )}
- </>
- );
- },
- },
- {
- title: t('pages.hosts.fields.security'),
- dataIndex: 'security',
- key: 'security',
- render: (security: string) => <Tag>{security || 'same'}</Tag>,
- },
- {
- title: t('pages.hosts.fields.tags'),
- key: 'tags',
- render: (_, h) => (h.tags && h.tags.length > 0
- ? <Space size={[0, 4]} wrap>{h.tags.map((tag) => <Tag key={tag} color="blue">{tag}</Tag>)}</Space>
- : <span className="host-muted">—</span>),
- },
- ];
- const toolbar = (
- <div className="card-toolbar">
- {selectedGroupIds.length === 0 ? (
- <Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
- {!isMobile && t('pages.hosts.addHost')}
- </Button>
- ) : (
- <>
- <Tag
- color="blue"
- closable
- onClose={() => onSelectionChange([])}
- style={{ marginInlineEnd: 0, padding: '4px 8px', fontSize: 13 }}
- >
- {t('pages.hosts.selectedCount', { count: selectedGroupIds.length })}
- </Tag>
- <Button onClick={() => onBulkEnable(true)}>{t('pages.hosts.bulkEnable')}</Button>
- <Button onClick={() => onBulkEnable(false)}>{t('pages.hosts.bulkDisable')}</Button>
- <Button danger icon={<DeleteOutlined />} onClick={onBulkDelete}>{t('pages.hosts.bulkDelete')}</Button>
- </>
- )}
- </div>
- );
- return (
- <Card size="small" hoverable title={toolbar} className="hosts-card">
- <Table<HostRecord>
- rowKey="groupId"
- size="small"
- loading={loading}
- columns={columns}
- dataSource={sorted}
- pagination={false}
- scroll={{ x: 'max-content', y: 'calc(100vh - 280px)' }}
- virtual
- rowSelection={{
- selectedRowKeys: selectedGroupIds,
- onChange: (keys) => onSelectionChange(keys as string[]),
- }}
- locale={{
- emptyText: (
- <div className="card-empty">
- <GlobalOutlined style={{ fontSize: 32, marginBottom: 8 }} />
- <div>{t('noData')}</div>
- </div>
- ),
- }}
- />
- </Card>
- );
- }
|