mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-12 16:15:02 +03:00
fix(frontend): make the panel usable with screen readers (#6907)
* 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.
This commit is contained in:
@@ -32,7 +32,13 @@ export default function FallbacksCard({
|
||||
|
||||
const addButtons = (
|
||||
<Space size={8} wrap>
|
||||
<Button type="primary" ghost size="small" icon={<PlusOutlined />} onClick={addFallback}>
|
||||
<Button
|
||||
type="primary"
|
||||
ghost
|
||||
size="small"
|
||||
icon={<PlusOutlined aria-hidden />}
|
||||
onClick={addFallback}
|
||||
>
|
||||
{t('pages.inbounds.fallbacks.add') || 'Add fallback'}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -90,25 +96,26 @@ export default function FallbacksCard({
|
||||
disabled={idx === 0}
|
||||
onClick={() => moveFallback(idx, -1)}
|
||||
title={t('pages.inbounds.form.moveUp')}
|
||||
icon={<ArrowUpOutlined />}
|
||||
icon={<ArrowUpOutlined aria-hidden />}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('pages.inbounds.form.moveDown')}
|
||||
disabled={idx === fallbacks.length - 1}
|
||||
onClick={() => moveFallback(idx, 1)}
|
||||
title={t('pages.inbounds.form.moveDown')}
|
||||
icon={<ArrowDownOutlined />}
|
||||
icon={<ArrowDownOutlined aria-hidden />}
|
||||
/>
|
||||
<Button
|
||||
aria-label={t('delete')}
|
||||
danger
|
||||
onClick={() => removeFallback(idx)}
|
||||
icon={<DeleteOutlined />}
|
||||
icon={<DeleteOutlined aria-hidden />}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<Row gutter={[8, 8]}>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
aria-label="SNI"
|
||||
prefix="SNI"
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.name}
|
||||
@@ -117,6 +124,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
aria-label="ALPN"
|
||||
prefix="ALPN"
|
||||
placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
|
||||
value={record.alpn}
|
||||
@@ -125,6 +133,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
aria-label="Path"
|
||||
prefix="Path"
|
||||
placeholder="/"
|
||||
value={record.path}
|
||||
@@ -133,6 +142,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<Input
|
||||
aria-label="Dest"
|
||||
prefix="Dest"
|
||||
placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
|
||||
value={record.dest}
|
||||
@@ -141,6 +151,7 @@ export default function FallbacksCard({
|
||||
</Col>
|
||||
<Col xs={24} sm={12}>
|
||||
<InputNumber
|
||||
aria-label="xver"
|
||||
prefix="xver"
|
||||
min={0}
|
||||
max={2}
|
||||
|
||||
@@ -1,20 +1,8 @@
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useEffect, useRef, useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { QuestionCircleOutlined } from '@ant-design/icons';
|
||||
|
||||
import dayjs from 'dayjs';
|
||||
import {
|
||||
Alert,
|
||||
Form,
|
||||
Input,
|
||||
InputNumber,
|
||||
Modal,
|
||||
Radio,
|
||||
Select,
|
||||
Switch,
|
||||
Tabs,
|
||||
Tooltip,
|
||||
message,
|
||||
} from 'antd';
|
||||
import { Alert, Form, Input, InputNumber, Modal, Radio, Select, Switch, Tabs, message } from 'antd';
|
||||
import { Controller, FormProvider, useForm, useWatch } from 'react-hook-form';
|
||||
|
||||
import { HttpUtil, NumberFormatter, RandomUtil, SizeFormatter, Wireguard } from '@/utils';
|
||||
@@ -99,16 +87,6 @@ import SniffingTab from './SniffingTab';
|
||||
import type { DBInbound } from '@/models/dbinbound';
|
||||
import type { NodeRecord } from '@/api/queries/useNodesQuery';
|
||||
|
||||
/* Render a field label with a hover tooltip icon instead of an `extra` help line below. */
|
||||
const labelWithHint = (label: string, hint: string) => (
|
||||
<span>
|
||||
{label}
|
||||
<Tooltip title={hint}>
|
||||
<QuestionCircleOutlined style={{ marginInlineStart: 4, color: 'rgba(128,128,128,0.65)' }} />
|
||||
</Tooltip>
|
||||
</span>
|
||||
);
|
||||
|
||||
const PROTOCOL_OPTIONS = Object.values(Protocols).map((p) => ({ value: p, label: p }));
|
||||
const SHARE_ADDR_STRATEGIES = ['node', 'listen', 'custom'] as const;
|
||||
const SHARE_ADDR_HOSTNAME_RE =
|
||||
@@ -240,6 +218,7 @@ export default function InboundFormModal({
|
||||
availableNodesFetched = true,
|
||||
}: InboundFormModalProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const [modal, modalContextHolder] = Modal.useModal();
|
||||
const methods = useForm<InboundFormValues>({ defaultValues: buildAddModeValues() });
|
||||
@@ -668,7 +647,8 @@ export default function InboundFormModal({
|
||||
|
||||
<FormField
|
||||
name="listen"
|
||||
label={labelWithHint(t('pages.inbounds.address'), t('pages.inbounds.form.listenHelp'))}
|
||||
label={t('pages.inbounds.address')}
|
||||
tooltip={t('pages.inbounds.form.listenHelp')}
|
||||
>
|
||||
<Input placeholder={t('pages.inbounds.monitorDesc')} />
|
||||
</FormField>
|
||||
@@ -677,10 +657,8 @@ export default function InboundFormModal({
|
||||
<>
|
||||
<FormField
|
||||
name="shareAddrStrategy"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.shareAddrStrategy'),
|
||||
t('pages.inbounds.form.shareAddrStrategyHelp'),
|
||||
)}
|
||||
label={t('pages.inbounds.form.shareAddrStrategy')}
|
||||
tooltip={t('pages.inbounds.form.shareAddrStrategyHelp')}
|
||||
>
|
||||
<Select
|
||||
options={SHARE_ADDR_STRATEGIES.filter(
|
||||
@@ -695,10 +673,8 @@ export default function InboundFormModal({
|
||||
{shareAddrStrategy === 'custom' && (
|
||||
<FormField
|
||||
name="shareAddr"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.shareAddr'),
|
||||
t('pages.inbounds.form.shareAddrHelp'),
|
||||
)}
|
||||
label={t('pages.inbounds.form.shareAddr')}
|
||||
tooltip={t('pages.inbounds.form.shareAddrHelp')}
|
||||
rules={{
|
||||
validate: (value) =>
|
||||
isValidShareAddrInput(String(value ?? '')) ||
|
||||
@@ -713,10 +689,8 @@ export default function InboundFormModal({
|
||||
|
||||
<FormField
|
||||
name="subSortIndex"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.subSortIndex'),
|
||||
t('pages.inbounds.form.subSortIndexHelp'),
|
||||
)}
|
||||
label={t('pages.inbounds.form.subSortIndex')}
|
||||
tooltip={t('pages.inbounds.form.subSortIndexHelp')}
|
||||
>
|
||||
<InputNumber />
|
||||
</FormField>
|
||||
@@ -724,10 +698,8 @@ export default function InboundFormModal({
|
||||
<FormField
|
||||
name="excludeFromSub"
|
||||
valueProp="checked"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.excludeFromSub'),
|
||||
t('pages.inbounds.form.excludeFromSubHelp'),
|
||||
)}
|
||||
label={t('pages.inbounds.form.excludeFromSub')}
|
||||
tooltip={t('pages.inbounds.form.excludeFromSubHelp')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
@@ -736,10 +708,8 @@ export default function InboundFormModal({
|
||||
<FormField
|
||||
name="disableFlow"
|
||||
valueProp="checked"
|
||||
label={labelWithHint(
|
||||
t('pages.inbounds.form.disableFlow'),
|
||||
t('pages.inbounds.form.disableFlowHelp'),
|
||||
)}
|
||||
label={t('pages.inbounds.form.disableFlow')}
|
||||
tooltip={t('pages.inbounds.form.disableFlowHelp')}
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
@@ -754,13 +724,13 @@ export default function InboundFormModal({
|
||||
</FormField>
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.meansNoLimit')}>
|
||||
{t('pages.inbounds.totalFlow')}
|
||||
</Tooltip>
|
||||
}
|
||||
htmlFor={`${uid}-totalFlow`}
|
||||
label={t('pages.inbounds.totalFlow')}
|
||||
tooltip={t('pages.inbounds.meansNoLimit')}
|
||||
>
|
||||
<InputNumber
|
||||
id={`${uid}-totalFlow`}
|
||||
aria-describedby={`${uid}-totalFlow-hint`}
|
||||
value={wTotal ? Math.round((wTotal / SizeFormatter.ONE_GB) * 100) / 100 : 0}
|
||||
min={0}
|
||||
step={1}
|
||||
@@ -769,6 +739,9 @@ export default function InboundFormModal({
|
||||
setV('total', bytes);
|
||||
}}
|
||||
/>
|
||||
<span id={`${uid}-totalFlow-hint`} hidden>
|
||||
{t('pages.inbounds.meansNoLimit')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
<FormField name="trafficReset" label={t('pages.inbounds.periodicTrafficResetTitle')}>
|
||||
@@ -791,16 +764,19 @@ export default function InboundFormModal({
|
||||
)}
|
||||
|
||||
<Form.Item
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.leaveBlankToNeverExpire')}>
|
||||
{t('pages.inbounds.expireDate')}
|
||||
</Tooltip>
|
||||
}
|
||||
htmlFor={`${uid}-expiryTime`}
|
||||
label={t('pages.inbounds.expireDate')}
|
||||
tooltip={t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
>
|
||||
<DateTimePicker
|
||||
id={`${uid}-expiryTime`}
|
||||
aria-describedby={`${uid}-expiryTime-hint`}
|
||||
value={wExpiry > 0 ? dayjs(wExpiry) : null}
|
||||
onChange={(d) => setV('expiryTime', d ? d.valueOf() : 0)}
|
||||
/>
|
||||
<span id={`${uid}-expiryTime-hint`} hidden>
|
||||
{t('pages.inbounds.leaveBlankToNeverExpire')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
</>
|
||||
);
|
||||
@@ -936,8 +912,9 @@ export default function InboundFormModal({
|
||||
const streamTab = (
|
||||
<>
|
||||
{hasSelectableTransport && (
|
||||
<Form.Item label={t('transmission')}>
|
||||
<Form.Item htmlFor={`${uid}-network`} label={t('transmission')}>
|
||||
<Select
|
||||
id={`${uid}-network`}
|
||||
style={{ width: '75%' }}
|
||||
value={network}
|
||||
onChange={onNetworkChange}
|
||||
|
||||
@@ -23,7 +23,7 @@ export default function AccountsList() {
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusOutlined /> {t('add')}
|
||||
<PlusOutlined aria-hidden /> {t('add')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.length > 0 && (
|
||||
@@ -32,13 +32,13 @@ export default function AccountsList() {
|
||||
<Space.Compact key={field.id} className="mb-8" block>
|
||||
<InputAddon>{String(idx + 1)}</InputAddon>
|
||||
<FormField name={['settings', 'accounts', idx, 'user']} noStyle>
|
||||
<Input placeholder={t('username')} />
|
||||
<Input aria-label={`${t('username')} ${idx + 1}`} placeholder={t('username')} />
|
||||
</FormField>
|
||||
<FormField name={['settings', 'accounts', idx, 'pass']} noStyle>
|
||||
<Input placeholder={t('password')} />
|
||||
<Input aria-label={`${t('password')} ${idx + 1}`} placeholder={t('password')} />
|
||||
</FormField>
|
||||
<Button aria-label={t('remove')} onClick={() => remove(idx)}>
|
||||
<MinusOutlined />
|
||||
<MinusOutlined aria-hidden />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
@@ -16,22 +17,23 @@ export default function AmneziawgFields({
|
||||
regenInboundAwgObfuscation,
|
||||
}: AmneziawgFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.amneziawg.privateKey')}>
|
||||
<Form.Item htmlFor={`${uid}-privateKey`} label={t('pages.xray.amneziawg.privateKey')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['settings', 'server', 'privateKey']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
<Input id={`${uid}-privateKey`} style={{ width: 'calc(100% - 32px)' }} />
|
||||
</FormField>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
icon={<ReloadOutlined aria-hidden />}
|
||||
onClick={regenInboundAwg}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.amneziawg.publicKey')}>
|
||||
<Input value={awgPubKey} disabled />
|
||||
<Form.Item htmlFor={`${uid}-publicKey`} label={t('pages.xray.amneziawg.publicKey')}>
|
||||
<Input id={`${uid}-publicKey`} value={awgPubKey} disabled />
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name={['settings', 'server', 'subnetIp']}
|
||||
@@ -89,7 +91,7 @@ export default function AmneziawgFields({
|
||||
<Input placeholder="eth0" />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.xray.amneziawg.obfuscation')}>
|
||||
<Button icon={<ReloadOutlined />} onClick={regenInboundAwgObfuscation}>
|
||||
<Button icon={<ReloadOutlined aria-hidden />} onClick={regenInboundAwgObfuscation}>
|
||||
{t('pages.xray.amneziawg.regenerateObfuscation')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, InputNumber, Select, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
@@ -9,6 +10,7 @@ const MASQ_PATH = ['streamSettings', 'hysteriaSettings', 'masquerade'];
|
||||
|
||||
export default function HysteriaFields() {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { control, setValue } = useFormContext();
|
||||
const masq = useWatch({ control, name: 'streamSettings.hysteriaSettings.masquerade' }) as
|
||||
| { type?: string }
|
||||
@@ -32,8 +34,9 @@ export default function HysteriaFields() {
|
||||
<InputNumber min={2} max={600} style={{ width: '100%' }} />
|
||||
</FormField>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.form.masquerade')}>
|
||||
<Form.Item htmlFor={`${uid}-masquerade`} label={t('pages.inbounds.form.masquerade')}>
|
||||
<Switch
|
||||
id={`${uid}-masquerade`}
|
||||
checked={!!masq}
|
||||
onChange={(checked) =>
|
||||
setValue(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, Select, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
@@ -13,6 +14,7 @@ interface ShadowsocksFieldsProps {
|
||||
|
||||
export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { getValues, setValue } = useFormContext();
|
||||
return (
|
||||
<>
|
||||
@@ -26,14 +28,14 @@ export default function ShadowsocksFields({ isSSWith2022 }: ShadowsocksFieldsPro
|
||||
<Select options={SSInboundMethodSchema.options.map((m) => ({ value: m, label: m }))} />
|
||||
</FormField>
|
||||
{isSSWith2022 && (
|
||||
<Form.Item label={t('password')}>
|
||||
<Form.Item htmlFor={`${uid}-password`} label={t('password')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['settings', 'password']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
<Input id={`${uid}-password`} style={{ width: 'calc(100% - 32px)' }} />
|
||||
</FormField>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
icon={<ReloadOutlined aria-hidden />}
|
||||
onClick={() => {
|
||||
const method = getValues('settings.method');
|
||||
setValue(
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
AutoComplete,
|
||||
@@ -21,6 +21,7 @@ import { HttpUtil } from '@/utils';
|
||||
|
||||
export default function TuicFields() {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const [messageApi, messageContextHolder] = message.useMessage();
|
||||
const { control, setValue } = useFormContext();
|
||||
const [loadingPanelCert, setLoadingPanelCert] = useState(false);
|
||||
@@ -147,8 +148,9 @@ export default function TuicFields() {
|
||||
return (
|
||||
<>
|
||||
{messageContextHolder}
|
||||
<Form.Item label={t('pages.inbounds.publicKey')}>
|
||||
<Form.Item htmlFor={`${uid}-certificate`} label={t('pages.inbounds.publicKey')}>
|
||||
<AutoComplete
|
||||
id={`${uid}-certificate`}
|
||||
value={certificate}
|
||||
options={certOptions}
|
||||
onChange={(v) => setValue('settings.server.certificate', v)}
|
||||
@@ -156,8 +158,9 @@ export default function TuicFields() {
|
||||
/>
|
||||
</Form.Item>
|
||||
|
||||
<Form.Item label={t('pages.inbounds.privatekey')}>
|
||||
<Form.Item htmlFor={`${uid}-privateKey`} label={t('pages.inbounds.privatekey')}>
|
||||
<AutoComplete
|
||||
id={`${uid}-privateKey`}
|
||||
value={privateKey}
|
||||
options={keyOptions}
|
||||
onChange={(v) => setValue('settings.server.private_key', v)}
|
||||
@@ -169,13 +172,13 @@ export default function TuicFields() {
|
||||
<Space wrap style={{ display: 'flex', flexWrap: 'wrap', width: '100%' }}>
|
||||
<Button
|
||||
type="primary"
|
||||
icon={<CloudDownloadOutlined />}
|
||||
icon={<CloudDownloadOutlined aria-hidden />}
|
||||
loading={loadingPanelCert}
|
||||
onClick={setCertFromPanel}
|
||||
>
|
||||
{t('pages.inbounds.setDefaultCert')}
|
||||
</Button>
|
||||
<Button icon={<SyncOutlined />} onClick={autofillFromSni}>
|
||||
<Button icon={<SyncOutlined aria-hidden />} onClick={autofillFromSni}>
|
||||
{t('pages.inbounds.form.autoFill')}
|
||||
</Button>
|
||||
<Button
|
||||
@@ -217,12 +220,21 @@ export default function TuicFields() {
|
||||
|
||||
<Divider titlePlacement="start">{t('pages.xray.tuic.profileOptions')}</Divider>
|
||||
|
||||
<Form.Item label={t('pages.xray.tuic.sni')} tooltip={t('pages.xray.tuic.sniHint')}>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-sni`}
|
||||
label={t('pages.xray.tuic.sni')}
|
||||
tooltip={t('pages.xray.tuic.sniHint')}
|
||||
>
|
||||
<Input
|
||||
id={`${uid}-sni`}
|
||||
aria-describedby={`${uid}-sni-hint`}
|
||||
value={sni}
|
||||
placeholder="example.com"
|
||||
onChange={(e) => handleSniChange(e.target.value)}
|
||||
/>
|
||||
<span id={`${uid}-sni-hint`} hidden>
|
||||
{t('pages.xray.tuic.sniHint')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
|
||||
<FormField
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import type { ReactNode } from 'react';
|
||||
import { useId, type ReactNode } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch, Tooltip } from 'antd';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
|
||||
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
|
||||
import { useFieldArray, useFormContext } from 'react-hook-form';
|
||||
|
||||
@@ -9,28 +9,39 @@ import { FormField } from '@/components/form/rhf';
|
||||
interface StringListProps {
|
||||
name: string[];
|
||||
label: ReactNode;
|
||||
tooltip?: string;
|
||||
placeholder: (index: number) => string;
|
||||
}
|
||||
|
||||
function StringList({ name, label, placeholder }: StringListProps) {
|
||||
function StringList({ name, label, tooltip, placeholder }: StringListProps) {
|
||||
const { t } = useTranslation();
|
||||
const labelId = useId();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({ control, name: name.join('.') });
|
||||
return (
|
||||
<Form.Item label={label}>
|
||||
<Form.Item label={<span id={labelId}>{label}</span>} tooltip={tooltip}>
|
||||
<Button aria-label={t('add')} size="small" onClick={() => append('')}>
|
||||
<PlusOutlined />
|
||||
<PlusOutlined aria-hidden />
|
||||
</Button>
|
||||
{fields.map((field, j) => (
|
||||
<Space.Compact key={field.id} block className="mt-4">
|
||||
<FormField name={[...name, j]} noStyle>
|
||||
<Input placeholder={placeholder(j)} />
|
||||
<Input
|
||||
aria-labelledby={labelId}
|
||||
aria-describedby={tooltip ? `${labelId}-hint` : undefined}
|
||||
placeholder={placeholder(j)}
|
||||
/>
|
||||
</FormField>
|
||||
<Button aria-label={t('remove')} size="small" onClick={() => remove(j)}>
|
||||
<MinusOutlined />
|
||||
<MinusOutlined aria-hidden />
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
))}
|
||||
{tooltip && (
|
||||
<span id={`${labelId}-hint`} hidden>
|
||||
{tooltip}
|
||||
</span>
|
||||
)}
|
||||
</Form.Item>
|
||||
);
|
||||
}
|
||||
@@ -73,20 +84,14 @@ export default function TunFields() {
|
||||
</FormField>
|
||||
<StringList
|
||||
name={['settings', 'autoSystemRoutingTable']}
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.form.autoSystemRoutesTooltip')}>
|
||||
{t('pages.inbounds.info.autoSystemRoutes')}
|
||||
</Tooltip>
|
||||
}
|
||||
label={t('pages.inbounds.info.autoSystemRoutes')}
|
||||
tooltip={t('pages.inbounds.form.autoSystemRoutesTooltip')}
|
||||
placeholder={(j) => (j === 0 ? '0.0.0.0/0' : '::/0')}
|
||||
/>
|
||||
<FormField
|
||||
name={['settings', 'autoOutboundsInterface']}
|
||||
label={
|
||||
<Tooltip title={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}>
|
||||
{t('pages.inbounds.form.autoOutboundsInterface')}
|
||||
</Tooltip>
|
||||
}
|
||||
label={t('pages.inbounds.form.autoOutboundsInterface')}
|
||||
tooltip={t('pages.inbounds.form.autoOutboundsInterfaceTooltip')}
|
||||
>
|
||||
<Input placeholder="auto" />
|
||||
</FormField>
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Select, Space, Typography } from 'antd';
|
||||
import { Controller, useFormContext } from 'react-hook-form';
|
||||
@@ -26,6 +26,7 @@ export default function VlessFields({
|
||||
clearVlessEnc,
|
||||
}: VlessFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { control } = useFormContext();
|
||||
const [authKind, setAuthKind] = useState<VlessAuthKind>(vlessAuthKind ?? 'x25519');
|
||||
|
||||
@@ -47,9 +48,10 @@ export default function VlessFields({
|
||||
<FormField name={['settings', 'encryption']} label={t('pages.inbounds.encryption')}>
|
||||
<Input />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.vlessAuthGenerate')}>
|
||||
<Form.Item htmlFor={`${uid}-authKind`} label={t('pages.inbounds.vlessAuthGenerate')}>
|
||||
<Space size={8} wrap>
|
||||
<Select
|
||||
id={`${uid}-authKind`}
|
||||
value={authKind}
|
||||
onChange={(v) => setAuthKind(v)}
|
||||
options={authOptions}
|
||||
@@ -80,6 +82,7 @@ export default function VlessFields({
|
||||
defaultValue={def}
|
||||
render={({ field }) => (
|
||||
<InputNumber
|
||||
aria-label={`${t('pages.inbounds.form.visionTestseed')} ${i + 1}`}
|
||||
min={1}
|
||||
style={{ width: '25%' }}
|
||||
value={field.value as number}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Space, Switch } from 'antd';
|
||||
import { ReloadOutlined } from '@ant-design/icons';
|
||||
@@ -11,18 +12,23 @@ interface WireguardFieldsProps {
|
||||
|
||||
export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardFieldsProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
return (
|
||||
<>
|
||||
<Form.Item label={t('pages.xray.wireguard.secretKey')}>
|
||||
<Form.Item htmlFor={`${uid}-secretKey`} label={t('pages.xray.wireguard.secretKey')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['settings', 'secretKey']} noStyle>
|
||||
<Input style={{ width: 'calc(100% - 32px)' }} />
|
||||
<Input id={`${uid}-secretKey`} style={{ width: 'calc(100% - 32px)' }} />
|
||||
</FormField>
|
||||
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined aria-hidden />}
|
||||
onClick={regenInboundWg}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
|
||||
<Input value={wgPubKey} disabled />
|
||||
<Form.Item htmlFor={`${uid}-publicKey`} label={t('pages.xray.wireguard.publicKey')}>
|
||||
<Input id={`${uid}-publicKey`} value={wgPubKey} disabled />
|
||||
</Form.Item>
|
||||
<FormField name={['settings', 'subnetIp']} label={t('pages.xray.wireguard.subnetIp')}>
|
||||
<Input placeholder="10.0.0.0" />
|
||||
|
||||
@@ -199,6 +199,7 @@ export default function RealityTargetScannerModal({
|
||||
{t('pages.inbounds.form.scanModalDesc')}
|
||||
</Typography.Paragraph>
|
||||
<Input.Search
|
||||
aria-label={t('pages.inbounds.form.scanDiscoverPlaceholder')}
|
||||
allowClear
|
||||
enterButton={t('pages.inbounds.form.scan')}
|
||||
loading={loading}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useState } from 'react';
|
||||
import { useState, useId } from 'react';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
@@ -59,6 +59,7 @@ export default function RealityForm({
|
||||
clearMldsa65,
|
||||
}: RealityFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { getFieldState, trigger } = useFormContext();
|
||||
const [scannerOpen, setScannerOpen] = useState(false);
|
||||
const mldsa65Seed = useWatch({ name: 'streamSettings.realitySettings.mldsa65Seed' });
|
||||
@@ -113,6 +114,7 @@ export default function RealityForm({
|
||||
<Select options={Object.values(UTLS_FINGERPRINT).map((fp) => ({ value: fp, label: fp }))} />
|
||||
</FormField>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-target`}
|
||||
label={t('pages.inbounds.form.target')}
|
||||
tooltip={t('pages.inbounds.form.realityTargetHint')}
|
||||
>
|
||||
@@ -127,19 +129,27 @@ export default function RealityForm({
|
||||
},
|
||||
}}
|
||||
>
|
||||
<Input style={{ flex: 1 }} placeholder="example.com:443" />
|
||||
<Input
|
||||
id={`${uid}-target`}
|
||||
aria-describedby={`${uid}-target-hint`}
|
||||
style={{ flex: 1 }}
|
||||
placeholder="example.com:443"
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
icon={<RadarChartOutlined />}
|
||||
icon={<RadarChartOutlined aria-hidden />}
|
||||
loading={scanning}
|
||||
onClick={() => scanRealityTarget()}
|
||||
>
|
||||
{t('pages.inbounds.form.scan')}
|
||||
</Button>
|
||||
<Button icon={<SearchOutlined />} onClick={() => setScannerOpen(true)}>
|
||||
<Button icon={<SearchOutlined aria-hidden />} onClick={() => setScannerOpen(true)}>
|
||||
{t('pages.inbounds.form.findTargets')}
|
||||
</Button>
|
||||
</Space.Compact>
|
||||
<span id={`${uid}-target-hint`} hidden>
|
||||
{t('pages.inbounds.form.realityTargetHint')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
{scanResult && (
|
||||
<Form.Item label=" " colon={false}>
|
||||
@@ -235,32 +245,45 @@ export default function RealityForm({
|
||||
>
|
||||
<Input placeholder="x.y.z" />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.shortIds')}>
|
||||
<Form.Item htmlFor={`${uid}-shortIds`} label={t('pages.inbounds.form.shortIds')}>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<FormField name={['streamSettings', 'realitySettings', 'shortIds']} noStyle>
|
||||
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
|
||||
<Select
|
||||
id={`${uid}-shortIds`}
|
||||
mode="tags"
|
||||
tokenSeparators={[',']}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
icon={<ReloadOutlined aria-hidden />}
|
||||
onClick={randomizeShortIds}
|
||||
/>
|
||||
</Space.Compact>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-spiderX`}
|
||||
label={t('pages.inbounds.form.spiderX')}
|
||||
tooltip={t('pages.inbounds.form.spiderXHint')}
|
||||
>
|
||||
<Space.Compact block style={{ display: 'flex' }}>
|
||||
<FormField name={['streamSettings', 'realitySettings', 'settings', 'spiderX']} noStyle>
|
||||
<Input style={{ flex: 1 }} />
|
||||
<Input
|
||||
id={`${uid}-spiderX`}
|
||||
aria-describedby={`${uid}-spiderX-hint`}
|
||||
style={{ flex: 1 }}
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
aria-label={t('regenerate')}
|
||||
icon={<ReloadOutlined />}
|
||||
icon={<ReloadOutlined aria-hidden />}
|
||||
onClick={randomizeSpiderX}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<span id={`${uid}-spiderX-hint`} hidden>
|
||||
{t('pages.inbounds.form.spiderXHint')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name={['streamSettings', 'realitySettings', 'settings', 'publicKey']}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Button, Form, Input, InputNumber, Radio, Select, Space, Switch } from 'antd';
|
||||
import {
|
||||
@@ -77,7 +78,7 @@ function CertRow({
|
||||
{total > 1 && (
|
||||
<Form.Item label=" ">
|
||||
<Button size="small" danger onClick={onRemove}>
|
||||
<MinusOutlined /> {t('remove')}
|
||||
<MinusOutlined aria-hidden /> {t('remove')}
|
||||
</Button>
|
||||
</Form.Item>
|
||||
)}
|
||||
@@ -161,16 +162,20 @@ function CertRow({
|
||||
|
||||
function EchSockoptSection() {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { control, setValue } = useFormContext();
|
||||
const echSockopt = useWatch({ control, name: 'streamSettings.tlsSettings.echSockopt' });
|
||||
const on = !!echSockopt;
|
||||
return (
|
||||
<>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-echSockopt`}
|
||||
label={t('pages.inbounds.form.echSockopt')}
|
||||
tooltip={t('pages.inbounds.form.echSockoptTip')}
|
||||
>
|
||||
<Switch
|
||||
id={`${uid}-echSockopt`}
|
||||
aria-describedby={`${uid}-echSockopt-hint`}
|
||||
checked={on}
|
||||
onChange={(v) =>
|
||||
setValue(
|
||||
@@ -179,6 +184,9 @@ function EchSockoptSection() {
|
||||
)
|
||||
}
|
||||
/>
|
||||
<span id={`${uid}-echSockopt-hint`} hidden>
|
||||
{t('pages.inbounds.form.echSockoptTip')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
{on && (
|
||||
<>
|
||||
@@ -226,6 +234,7 @@ export default function TlsForm({
|
||||
clearEchCert,
|
||||
}: TlsFormProps) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { control } = useFormContext();
|
||||
const { fields, append, remove } = useFieldArray({
|
||||
control,
|
||||
@@ -242,16 +251,18 @@ export default function TlsForm({
|
||||
>
|
||||
<CipherSuitesSelect placeholder={t('pages.inbounds.form.autoOption')} />
|
||||
</FormField>
|
||||
<Form.Item label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Form.Item htmlFor={`${uid}-minVersion`} label={t('pages.inbounds.form.minMaxVersion')}>
|
||||
<Space.Compact block>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'minVersion']} noStyle>
|
||||
<Select
|
||||
id={`${uid}-minVersion`}
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
</FormField>
|
||||
<FormField name={['streamSettings', 'tlsSettings', 'maxVersion']} noStyle>
|
||||
<Select
|
||||
aria-label={t('pages.inbounds.form.maxVersion')}
|
||||
style={{ width: '50%' }}
|
||||
options={Object.values(TLS_VERSION_OPTION).map((v) => ({ value: v, label: v }))}
|
||||
/>
|
||||
@@ -330,7 +341,7 @@ export default function TlsForm({
|
||||
})
|
||||
}
|
||||
>
|
||||
<PlusOutlined />
|
||||
<PlusOutlined aria-hidden />
|
||||
</Button>
|
||||
</Form.Item>
|
||||
{fields.map((field, idx) => (
|
||||
@@ -375,6 +386,7 @@ export default function TlsForm({
|
||||
</Space>
|
||||
</Form.Item>
|
||||
<Form.Item
|
||||
htmlFor={`${uid}-pinnedPeerCertSha256`}
|
||||
label={t('pages.inbounds.form.pinnedPeerCertSha256')}
|
||||
tooltip={t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
|
||||
>
|
||||
@@ -384,6 +396,8 @@ export default function TlsForm({
|
||||
noStyle
|
||||
>
|
||||
<Select
|
||||
id={`${uid}-pinnedPeerCertSha256`}
|
||||
aria-describedby={`${uid}-pinnedPeerCertSha256-hint`}
|
||||
mode="tags"
|
||||
tokenSeparators={[',', ' ']}
|
||||
placeholder={t('pages.inbounds.form.pinnedPeerCertSha256Placeholder')}
|
||||
@@ -391,18 +405,23 @@ export default function TlsForm({
|
||||
/>
|
||||
</FormField>
|
||||
<Button
|
||||
icon={<FileProtectOutlined />}
|
||||
icon={<FileProtectOutlined aria-hidden />}
|
||||
onClick={pinFromCert}
|
||||
loading={saving}
|
||||
aria-label={t('pages.inbounds.form.pinFromCert')}
|
||||
title={t('pages.inbounds.form.pinFromCert')}
|
||||
/>
|
||||
<Button
|
||||
icon={<CloudDownloadOutlined />}
|
||||
icon={<CloudDownloadOutlined aria-hidden />}
|
||||
onClick={() => pinFromRemote()}
|
||||
loading={saving}
|
||||
aria-label={t('pages.inbounds.form.pinFromRemote')}
|
||||
title={t('pages.inbounds.form.pinFromRemote')}
|
||||
/>
|
||||
</Space.Compact>
|
||||
<span id={`${uid}-pinnedPeerCertSha256-hint`} hidden>
|
||||
{t('pages.inbounds.form.pinnedPeerCertSha256Tip')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
<FormField
|
||||
name={['streamSettings', 'tlsSettings', 'settings', 'verifyPeerCertByName']}
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Form, Input, Switch } from 'antd';
|
||||
import { useFormContext, useWatch } from 'react-hook-form';
|
||||
@@ -7,6 +8,7 @@ import { FormField } from '@/components/form/rhf';
|
||||
|
||||
export default function RawForm() {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { control, setValue } = useFormContext();
|
||||
const headerType = (useWatch({
|
||||
control,
|
||||
@@ -21,8 +23,9 @@ export default function RawForm() {
|
||||
>
|
||||
<Switch />
|
||||
</FormField>
|
||||
<Form.Item label={`HTTP ${t('camouflage')}`}>
|
||||
<Form.Item htmlFor={`${uid}-httpCamouflage`} label={`HTTP ${t('camouflage')}`}>
|
||||
<Switch
|
||||
id={`${uid}-httpCamouflage`}
|
||||
checked={headerType === 'http'}
|
||||
onChange={(v) => {
|
||||
setValue(
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { useId } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Alert, Form, InputNumber, Segmented, Select, Switch } from 'antd';
|
||||
import { Controller, useFormContext, useWatch } from 'react-hook-form';
|
||||
@@ -28,6 +29,7 @@ export default function SockoptForm({
|
||||
network: string;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const uid = useId();
|
||||
const { control, getValues, setValue } = useFormContext();
|
||||
const sock = useWatch({ control, name: 'streamSettings.sockopt' });
|
||||
const on = !!sock && typeof sock === 'object' && Object.keys(sock).length > 0;
|
||||
@@ -101,6 +103,8 @@ export default function SockoptForm({
|
||||
tooltip={t('pages.inbounds.form.realClientIpHint')}
|
||||
>
|
||||
<Segmented
|
||||
aria-label={t('pages.inbounds.form.realClientIp')}
|
||||
aria-describedby={`${uid}-real-client-ip-hint`}
|
||||
value={presetValue}
|
||||
onChange={(v) => applyRealClientIpPreset(v as RealClientIpPreset)}
|
||||
options={[
|
||||
@@ -112,6 +116,9 @@ export default function SockoptForm({
|
||||
{ value: 'proxy', label: t('pages.inbounds.form.realClientIpPresetProxyProtocol') },
|
||||
]}
|
||||
/>
|
||||
<span id={`${uid}-real-client-ip-hint`} hidden>
|
||||
{t('pages.inbounds.form.realClientIpHint')}
|
||||
</span>
|
||||
</Form.Item>
|
||||
{trustedMismatch && (
|
||||
<Alert
|
||||
|
||||
Reference in New Issue
Block a user