mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-03 03:35:04 +03:00
e2184777a0
* fix(frontend): make the panel usable with screen readers * fix(frontend): keep mobile tab names and the inbound expiry hint Review of #6907 found two gaps in the screen-reader pass: - On phone-width screens catTabLabel renders only the icon and names the tab by putting aria-label on it, but callers now pass that icon with aria-hidden for the desktop label, so every Settings, Xray and Host category tab lost its name under TalkBack. catTabLabel now clears aria-hidden on the icon it uses as the name. - DateTimePicker forwarded id and aria-label but not aria-describedby, so the inbound expiry hint ("leave blank to never expire") was never attached. It now forwards it on both the Gregorian and Jalali input.
166 lines
5.4 KiB
TypeScript
166 lines
5.4 KiB
TypeScript
import { useMemo, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import { Alert, Button, Col, Input, InputNumber, Row, Select, Space, Tag } from 'antd';
|
|
import { AimOutlined } from '@ant-design/icons';
|
|
|
|
import { HttpUtil } from '@/utils';
|
|
import { useInboundOptions } from '@/api/queries/useInboundOptions';
|
|
import { buildRemarkByTag, formatInboundTag } from './helpers';
|
|
|
|
interface RouteTesterProps {
|
|
inboundTags: string[];
|
|
isMobile: boolean;
|
|
}
|
|
|
|
// Mirror of the /xray/routeTest response (RoutingService.TestRoute).
|
|
interface RouteTestResult {
|
|
matched: boolean;
|
|
outboundTag: string;
|
|
groupTags?: string[];
|
|
}
|
|
|
|
const PROTOCOL_OPTIONS = ['http', 'tls', 'quic', 'bittorrent'].map((p) => ({ label: p, value: p }));
|
|
|
|
export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps) {
|
|
const { t } = useTranslation();
|
|
const { data: inboundOptions } = useInboundOptions();
|
|
const remarkByTag = useMemo(() => buildRemarkByTag(inboundOptions || []), [inboundOptions]);
|
|
const [dest, setDest] = useState('');
|
|
const [port, setPort] = useState<number | null>(443);
|
|
const [network, setNetwork] = useState('tcp');
|
|
const [inboundTag, setInboundTag] = useState<string | undefined>(undefined);
|
|
const [protocol, setProtocol] = useState<string | undefined>(undefined);
|
|
const [testing, setTesting] = useState(false);
|
|
const [result, setResult] = useState<RouteTestResult | null>(null);
|
|
|
|
async function run() {
|
|
const value = dest.trim();
|
|
if (!value) return;
|
|
// Domains never contain ':' and a pure dotted-quad is an IPv4 address;
|
|
// everything else is treated as a domain.
|
|
const isIp = /^(\d{1,3}\.){3}\d{1,3}$/.test(value) || value.includes(':');
|
|
setTesting(true);
|
|
setResult(null);
|
|
try {
|
|
const msg = await HttpUtil.post('/panel/api/xray/routeTest', {
|
|
domain: isIp ? '' : value,
|
|
ip: isIp ? value : '',
|
|
port: port ?? 0,
|
|
network,
|
|
inboundTag: inboundTag || '',
|
|
protocol: protocol || '',
|
|
});
|
|
if (msg?.success && msg.obj && typeof msg.obj === 'object') {
|
|
setResult(msg.obj as RouteTestResult);
|
|
}
|
|
} finally {
|
|
setTesting(false);
|
|
}
|
|
}
|
|
|
|
const fieldSpan = isMobile ? 24 : undefined;
|
|
|
|
return (
|
|
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Alert type="info" showIcon title={t('pages.xray.routeTesterDesc')} />
|
|
<Row gutter={[8, 8]} align="bottom">
|
|
<Col xs={fieldSpan} sm={7}>
|
|
<Input
|
|
aria-label={t('pages.xray.routeTesterDest')}
|
|
placeholder={t('pages.xray.routeTesterDest')}
|
|
value={dest}
|
|
onChange={(e) => setDest(e.target.value)}
|
|
onPressEnter={run}
|
|
allowClear
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={3}>
|
|
<InputNumber
|
|
aria-label={t('pages.xray.routeTesterPort')}
|
|
style={{ width: '100%' }}
|
|
min={0}
|
|
max={65535}
|
|
placeholder={t('pages.xray.routeTesterPort')}
|
|
value={port}
|
|
onChange={(v) => setPort(v)}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={3}>
|
|
<Select
|
|
aria-label={t('pages.inbounds.network')}
|
|
style={{ width: '100%' }}
|
|
value={network}
|
|
onChange={setNetwork}
|
|
options={[
|
|
{ label: 'TCP', value: 'tcp' },
|
|
{ label: 'UDP', value: 'udp' },
|
|
]}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={4}>
|
|
<Select
|
|
aria-label={t('pages.xray.routeTesterInbound')}
|
|
style={{ width: '100%' }}
|
|
placeholder={t('pages.xray.routeTesterInbound')}
|
|
allowClear
|
|
value={inboundTag}
|
|
onChange={setInboundTag}
|
|
options={inboundTags
|
|
.filter(Boolean)
|
|
.map((tag) => ({ label: formatInboundTag(tag, remarkByTag), value: tag }))}
|
|
/>
|
|
</Col>
|
|
<Col xs={12} sm={4}>
|
|
<Select
|
|
aria-label={t('pages.xray.routeTesterProtocol')}
|
|
style={{ width: '100%' }}
|
|
placeholder={t('pages.xray.routeTesterProtocol')}
|
|
allowClear
|
|
value={protocol}
|
|
onChange={setProtocol}
|
|
options={PROTOCOL_OPTIONS}
|
|
/>
|
|
</Col>
|
|
<Col xs={fieldSpan} sm={3}>
|
|
<Button
|
|
type="primary"
|
|
icon={<AimOutlined aria-hidden />}
|
|
loading={testing}
|
|
disabled={!dest.trim()}
|
|
onClick={run}
|
|
block
|
|
>
|
|
{t('pages.xray.routeTesterTest')}
|
|
</Button>
|
|
</Col>
|
|
</Row>
|
|
|
|
{result &&
|
|
(result.matched ? (
|
|
<Alert
|
|
type="success"
|
|
showIcon
|
|
title={
|
|
<Space wrap>
|
|
<span>{t('pages.xray.routeTesterMatchedOutbound')}:</span>
|
|
<Tag color="blue">{result.outboundTag || '—'}</Tag>
|
|
{(result.groupTags || []).length > 0 && (
|
|
<>
|
|
<span>{t('pages.xray.routeTesterViaBalancer')}:</span>
|
|
{(result.groupTags || []).map((tag) => (
|
|
<Tag key={tag} color="orange">
|
|
{tag}
|
|
</Tag>
|
|
))}
|
|
</>
|
|
)}
|
|
</Space>
|
|
}
|
|
/>
|
|
) : (
|
|
<Alert type="warning" showIcon title={t('pages.xray.routeTesterDefaultOutbound')} />
|
|
))}
|
|
</Space>
|
|
);
|
|
}
|