feat(a11y): screen-reader & keyboard accessibility across the panel (#5486) (#5652)

* feat(a11y): label list, toolbar & dashboard actions for screen readers

Phase 1 of #5486 (Android TalkBack support). Icon-only controls across
the management surfaces previously announced only their untranslated
icon name (e.g. "edit", "ellipsis") or nothing at all.

- Add aria-label to icon-only row-action and toolbar buttons across
  inbounds, clients, groups, hosts, nodes and xray
  (outbounds/routing/dns/balancers) lists, plus the dashboard cards.
- Make clickable bare icons and AntD Card actions keyboard-operable via
  role/tabIndex + Enter/Space (new activateOnKey helper); convert mobile
  dropdown triggers to buttons so they open from the keyboard.
- Fix the sidebar hamburger's mislabeled aria-label (was the dashboard
  label) and translate previously-hardcoded outbound menu labels.

New i18n keys in all 13 locales: sort, menu.openMenu,
pages.xray.outbound.moveToTop.

* feat(a11y): label modal, QR and copy/download controls for screen readers

Phase 2 of #5486. Modal and overlay controls relied on tooltips (not a
reliable accessible name) or were bare clickable icons with no keyboard
or screen-reader support.

- Add aria-label to copy/QR/download/info icon buttons in the inbound and
  client info modals, sub-links modal, QR panel, backup/log modals, and
  to the bare search/select inputs of the attach/detach client modals.
- Make click-to-copy QR codes and the IP-log refresh/clear, geofile
  reload and log refresh icons keyboard-operable (role/tabIndex +
  Enter/Space) with translated labels.
- Label the 2FA code input; drop the QrPanel download-image string
  fallback now that the key exists.

New i18n key in all 13 locales: downloadImage.

* feat(a11y): label form fields and shared form components for screen readers

Phase 3 of #5486. Form controls and shared form widgets were largely
unlabelled, and several remove controls were not keyboard-operable.

- SettingListItem now ties its title to the control via aria-labelledby,
  giving accessible names to the ~90 settings-tab inputs at once.
- InputAddon gains button semantics (role/tabIndex/Enter+Space) and an
  ariaLabel prop when used as an interactive remove control.
- Sparkline charts expose a role="img" summary of their latest values.
- Add aria-label to add/remove/regenerate icon buttons and bare
  inputs/selects across inbound, client and xray (dns/routing/balancer/
  outbound) forms; make clickable remove icons keyboard-operable; mark
  decorative help/target icons aria-hidden; label the JSON editor,
  date-time clear button, header-map remove, notification select-all and
  remark token chips.

New i18n keys in all 13 locales: regenerate, jsonEditor,
pages.xray.balancer.{costMatch,costValue,costRegexp}.

* chore(a11y): add eslint-plugin-jsx-a11y harness and fix flagged interactions

Phase 4 of #5486. Adds eslint-plugin-jsx-a11y (recommended ruleset,
scoped to .tsx) so screen-reader/keyboard regressions fail lint.

- Make the mobile node-card header a proper keyboard disclosure
  (role=button, aria-expanded, Enter/Space activation that ignores
  clicks on the nested action buttons) and drop the now-redundant
  stop-propagation click handlers the linter flagged on card-action
  wrappers in the node, client and inbound mobile cards.
- Disable jsx-a11y/no-autofocus: the autofocus on the login field and
  modal primary inputs is intentional focus management that helps
  screen-reader and keyboard users land on the right control.

make lint passes with the a11y ruleset enforced.

* feat(a11y): cover remaining deferred spots (settings tabs, sockopt, API docs)

Completes the panel sweep for #5486 by labelling the spots previously
left out of phases 1-4:

- NotifyTimeField (Telegram notifications): the mode, interval, unit and
  custom-cron inputs now carry aria-labels.
- The Sockopt toggle in transport options.
- Settings category tabs in icons-only (mobile) mode now expose the tab
  name as the icon's aria-label instead of the raw icon name.
- The Swagger API-docs view is wrapped in a labelled region landmark.

New i18n keys in all 13 locales: pages.settings.notifyTime.{interval,unit}.

* feat(a11y): label shared xray form components and remark field

Code review surfaced frontend/src/lib/xray/forms/ — shared form components
used by the host and inbound JSON forms — which the initial audit missed.

- FinalMaskForm (TCP/UDP final-mask editor): label the icon-only add and
  regenerate buttons and make all six remove icons keyboard-operable
  (role/tabIndex/Enter+Space); adds useTranslation to its sub-components.
- CustomSockoptList: the remove icon is now keyboard-operable.
- SniffingFields: aria-label on the otherwise label-less destOverride select.
- RemarkTemplateField: aria-label on the remark-variable picker button.

New i18n key in all 13 locales: pages.inbounds.sniffingDestOverride.

* feat(a11y): label client info modal and WireGuard config block

After rebasing onto the WireGuard client-config feature, re-apply the
ClientInfoModal copy/QR/IP-log aria-labels (the modal was restructured
upstream, so the original labels did not carry over) and label the new
ConfigBlock component's copy/download/QR actions. ConfigBlock's action
wrapper keeps its stop-propagation handler (a non-interactive guard for
the Collapse header) under a scoped jsx-a11y exception.

* fix(frontend): let npm install jsx-a11y under ESLint 10

eslint-plugin-jsx-a11y@6.10.2 declares a peer range that stops at ESLint 9,
but the panel is on ESLint 10, so `npm ci` aborts with ERESOLVE even though
the plugin runs fine on ESLint 10 with flat config. Add an npm override so
jsx-a11y accepts the project's ESLint version. This keeps normal peer
resolution (recharts' react-is peer still auto-installs) — no global
legacy-peer-deps and no manual react-is pin needed.

* fix(a11y): size mobile row triggers and move node expand role to chevron

Address automated review on #5652:
- add size="small" to the inbound/client/node mobile-card "more" dropdown
  triggers so they match the adjacent small Switch and the established
  desktop RowActions pattern.
- move the node card-head disclosure semantics (role/tabIndex/aria-expanded/
  keyboard) onto the chevron affordance so the expand control is no longer a
  role="button" wrapping the Switch, info button and dropdown. Mouse
  click-anywhere-to-expand is preserved on the header div.
This commit is contained in:
nima1024m
2026-06-29 12:51:29 +02:00
committed by GitHub
parent 6c71b725da
commit 71aca2018a
82 changed files with 2072 additions and 258 deletions
+9
View File
@@ -1,6 +1,7 @@
import js from '@eslint/js';
import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import globals from 'globals';
export default [
@@ -44,4 +45,12 @@ export default [
'react-hooks/refs': 'off',
},
},
{
files: ['**/*.tsx'],
plugins: { 'jsx-a11y': jsxA11y },
rules: {
...jsxA11y.flatConfigs.recommended.rules,
'jsx-a11y/no-autofocus': 'off',
},
},
];
+1368
View File
File diff suppressed because it is too large Load Diff
+4
View File
@@ -52,6 +52,7 @@
"@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0",
"jsdom": "^29.1.1",
@@ -61,6 +62,9 @@
"vitest": "^4.1.9"
},
"overrides": {
"eslint-plugin-jsx-a11y": {
"eslint": "$eslint"
},
"dompurify": "^3.4.11",
"react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0",
@@ -35,14 +35,16 @@ export default function ConfigBlock({
}
const actions = (
/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events */
<div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip>
<Tooltip title={t('download')}>
<Button
size="small"
icon={<DownloadOutlined />}
aria-label={t('download')}
onClick={() => FileManager.downloadTextFile(text, fileName)}
/>
</Tooltip>
@@ -54,7 +56,7 @@ export default function ConfigBlock({
content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
)}
@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd';
import dayjs from 'dayjs';
@@ -53,6 +54,7 @@ export default function DateTimePicker({
placeholder = '',
disabled = false,
}: DateTimePickerProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null);
@@ -100,7 +102,7 @@ export default function DateTimePicker({
<button
type="button"
className="jdp-clear"
aria-label="clear"
aria-label={t('clear')}
onMouseDown={(e) => e.preventDefault()}
onClick={(e) => {
e.stopPropagation();
@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Space } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons';
@@ -74,6 +75,7 @@ function rowsToMap(rows: HeaderRow[], mode: HeaderMapMode): Record<string, strin
}
export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
const { t } = useTranslation();
// Local state holds rows including blanks. Without it, addRow() would
// append a {name:'', value:''} that rowsToMap immediately filters out
// before reaching the form, so the new row would never reach UI. The
@@ -130,7 +132,7 @@ export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEdit
placeholder="Value"
onChange={(e) => setRow(idx, { value: e.target.value })}
/>
<Button icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
<Button aria-label={t('remove')} icon={<MinusOutlined />} onClick={() => removeRow(idx)} />
</Space.Compact>
))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>
+3 -1
View File
@@ -1,4 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorView, basicSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state';
import { json, jsonParseLinter } from '@codemirror/lang-json';
@@ -92,6 +93,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
const onChangeRef = useRef(onChange);
const valueRef = useRef(value);
const { isDark, isUltra } = useTheme();
const { t } = useTranslation();
useEffect(() => {
onChangeRef.current = onChange;
@@ -173,7 +175,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
});
}, [readOnly]);
return <div ref={hostRef} className="json-editor-host" />;
return <div ref={hostRef} className="json-editor-host" aria-label={t('jsonEditor')} />;
});
export default JsonEditor;
@@ -55,7 +55,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
title={t('pages.hosts.remarkVars.title')}
>
<Tooltip title={t('pages.hosts.remarkVars.title')}>
<Button type="text" size="small" icon={<CodeOutlined />} style={{ margin: '0 -7px' }} />
<Button type="text" size="small" icon={<CodeOutlined />} aria-label={t('pages.hosts.remarkVars.title')} style={{ margin: '0 -7px' }} />
</Tooltip>
</Popover>
}
@@ -2,6 +2,7 @@ import { Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next';
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
import { activateOnKey } from '@/utils/a11y';
interface RemarkVarPickerProps {
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */
@@ -28,7 +29,10 @@ export default function RemarkVarPicker({ onPick }: RemarkVarPickerProps) {
{REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
<Tag
role="button"
tabIndex={0}
onClick={() => onPick(v.token)}
onKeyDown={activateOnKey(() => onPick(v.token))}
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
>
{wrapToken(v.token)}
+7 -1
View File
@@ -1,4 +1,5 @@
import type { CSSProperties, ReactNode } from 'react';
import { activateOnKey } from '@/utils/a11y';
import './InputAddon.css';
interface InputAddonProps {
@@ -6,14 +7,19 @@ interface InputAddonProps {
className?: string;
style?: CSSProperties;
onClick?: () => void;
ariaLabel?: string;
}
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
export default function InputAddon({ children, className = '', style, onClick, ariaLabel }: InputAddonProps) {
return (
<span
className={`input-addon ${className}`.trim()}
style={style}
onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={onClick ? ariaLabel : undefined}
onKeyDown={onClick ? activateOnKey(onClick) : undefined}
>
{children}
</span>
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { cloneElement, isValidElement, useId, type ReactElement, type ReactNode } from 'react';
import { Col, Row } from 'antd';
import './SettingListItem.css';
@@ -18,17 +18,22 @@ export default function SettingListItem({
control,
}: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px';
const titleId = useId();
const node = control ?? children;
const labelledNode = title && isValidElement(node)
? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, { 'aria-labelledby': titleId })
: node;
return (
<div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}>
<div className="setting-list-meta">
{title && <div className="setting-list-title">{title}</div>}
{title && <div className="setting-list-title" id={titleId}>{title}</div>}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col>
<Col xs={24} lg={12}>
{control ?? children}
{labelledNode}
</Col>
</Row>
</div>
@@ -1,4 +1,5 @@
import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Tag } from 'antd';
interface Props {
@@ -10,11 +11,12 @@ interface Props {
}
function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
const { t } = useTranslation();
const ref = useRef<HTMLInputElement>(null);
useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate;
}, [indeterminate]);
return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
return <input ref={ref} type="checkbox" aria-label={t('pages.clients.selectAll')} checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
}
export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {
+11 -1
View File
@@ -181,8 +181,18 @@ export default function Sparkline({
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
const ariaSummary = useMemo(() => {
if (points.length === 0) return name1 ?? '';
const last = points[points.length - 1];
const parts: string[] = [];
parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value));
if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`);
if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`);
return parts.join(', ');
}, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
return (
<div className="sparkline-container">
<div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
{extremaPoints && (
<div className="sparkline-extrema" aria-hidden="true">
<span className="extrema-item" style={{ color: maxColor }}>
+1 -1
View File
@@ -370,7 +370,7 @@ export default function AppSidebar() {
<button
className="drawer-handle"
type="button"
aria-label={t('menu.dashboard')}
aria-label={t('menu.openMenu')}
onClick={() => setDrawerOpen(true)}
>
<MenuOutlined />
@@ -34,7 +34,12 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
{enabled && (
<>
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}>
<Select mode="multiple" className="sniffing-options" options={DEST_OPTIONS} />
<Select
mode="multiple"
className="sniffing-options"
aria-label={t('pages.inbounds.sniffingDestOverride')}
options={DEST_OPTIONS}
/>
</Form.Item>
<Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')}
@@ -3,6 +3,8 @@ import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { NamePath } from 'antd/es/form/interface';
import { activateOnKey } from '@/utils/a11y';
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
// entry is rendered as a titled group of labeled fields (system / level / opt /
// type / value) instead of one cramped inline row, so it reads like the rest of
@@ -49,7 +51,11 @@ export default function CustomSockoptList({
<DeleteOutlined
className="danger-icon"
style={{ marginInlineStart: 8 }}
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
</Divider>
<Form.Item label="System" name={[field.name, 'system']}>
@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react';
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value }));
@@ -224,6 +226,7 @@ export default function FinalMaskForm({ name, network, protocol, form, showAll =
}
function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
const { t } = useTranslation();
return (
<Form.List name={[...base, 'tcp']}>
{(fields, { add, remove }) => (
@@ -233,6 +236,7 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
/>
</Form.Item>
@@ -265,12 +269,20 @@ function TcpMaskItem({
// type change). All Form.Item `name=` use RELATIVE paths within the
// outer Form.List context.
const absolutePath = [...listPath, fieldName];
const { t } = useTranslation();
return (
<div>
<Divider style={{ margin: 0 }}>
TCP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={onRemove}
onKeyDown={activateOnKey(onRemove)}
/>
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
@@ -415,12 +427,13 @@ function FragmentRangeList({
validator?: (rule: unknown, value: unknown) => Promise<void>;
minItems?: number;
}) {
const { t } = useTranslation();
return (
<Form.List name={listName}>
{(fields, { add, remove }) => (
<>
<Form.Item label={label}>
<Button type="primary" size="small" icon={<PlusOutlined />} onClick={() => add('')} />
<Button type="primary" size="small" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
</Form.Item>
{fields.map((field, idx) => (
<Form.Item
@@ -432,7 +445,16 @@ function FragmentRangeList({
<Input
placeholder={placeholder}
addonAfter={fields.length > minItems
? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
? (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)
: null}
/>
</Form.Item>
@@ -475,6 +497,7 @@ function HeaderCustomGroups({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
{(['clients', 'servers'] as const).map((groupKey) => (
@@ -486,6 +509,7 @@ function HeaderCustomGroups({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addGroup([defaultClientServerItem()])}
/>
</Form.Item>
@@ -493,7 +517,14 @@ function HeaderCustomGroups({
<div key={group.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1}
<DeleteOutlined className="danger-icon" onClick={() => removeGroup(group.name)} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => removeGroup(group.name)}
onKeyDown={activateOnKey(() => removeGroup(group.name))}
/>
</Divider>
<Form.List name={[group.name]}>
{(items, { add: addItem, remove: removeItem }) => (
@@ -502,6 +533,7 @@ function HeaderCustomGroups({
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addItem(defaultClientServerItem())}
/>
</Form.Item>
@@ -531,6 +563,7 @@ function HeaderCustomGroups({
function UdpMasksList({
base, form, isHysteria, isWireguard, network,
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
const { t } = useTranslation();
return (
<Form.List name={[...base, 'udp']}>
{(fields, { add, remove }) => (
@@ -540,6 +573,7 @@ function UdpMasksList({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => {
const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
add({ type: def, settings: defaultUdpMaskSettings(def) });
@@ -578,6 +612,7 @@ function UdpMaskItem({
onRemove: () => void;
}) {
const absolutePath = [...listPath, fieldName];
const { t } = useTranslation();
const onTypeChange = (v: string) => {
form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
@@ -605,7 +640,14 @@ function UdpMaskItem({
<div>
<Divider style={{ margin: 0 }}>
UDP Mask {displayIndex}
<DeleteOutlined className="danger-icon" onClick={onRemove} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={onRemove}
onKeyDown={activateOnKey(onRemove)}
/>
</Divider>
<Form.Item label="Type" name={[fieldName, 'type']}>
@@ -735,6 +777,7 @@ function SalamanderUdpMaskSettings({
form: FormInstance;
absolutePath: (string | number)[];
}) {
const { t } = useTranslation();
const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
@@ -776,6 +819,7 @@ function SalamanderUdpMaskSettings({
</Form.Item>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16),
@@ -840,6 +884,7 @@ function UdpHeaderCustom({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
{(['client', 'server'] as const).map((groupKey) => (
@@ -851,6 +896,7 @@ function UdpHeaderCustom({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultUdpClientServerItem())}
/>
</Form.Item>
@@ -858,7 +904,14 @@ function UdpHeaderCustom({
<div key={item.key}>
<Divider style={{ margin: 0 }}>
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(item.name)}
onKeyDown={activateOnKey(() => remove(item.name))}
/>
</Divider>
<ItemEditor
fieldName={item.name}
@@ -883,6 +936,7 @@ function NoiseItems({
form: FormInstance;
absoluteSettingsPath: (string | number)[];
}) {
const { t } = useTranslation();
return (
<>
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
@@ -896,6 +950,7 @@ function NoiseItems({
type="primary"
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultNoiseItem())}
/>
</Form.Item>
@@ -903,7 +958,14 @@ function NoiseItems({
<div key={item.key}>
<Divider style={{ margin: 0 }}>
Noise {ni + 1}
<DeleteOutlined className="danger-icon" onClick={() => remove(item.name)} />
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(item.name)}
onKeyDown={activateOnKey(() => remove(item.name))}
/>
</Divider>
<ItemEditor
fieldName={item.name}
@@ -930,6 +992,7 @@ function ItemEditor({
delayMode?: 'number' | 'string';
onRemove?: () => void;
}) {
const { t } = useTranslation();
const onTypeChange = (v: string) => {
if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
@@ -1005,6 +1068,7 @@ function ItemEditor({
</Form.Item>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
/>
</Space.Compact>
+3 -1
View File
@@ -1,4 +1,5 @@
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css';
@@ -12,6 +13,7 @@ const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
const pageClass = useMemo(() => {
const classes = ['api-docs-page'];
@@ -27,7 +29,7 @@ export default function ApiDocsPage() {
<Layout className="content-shell">
<Layout.Content className="content-area">
<div className="docs-wrapper">
<div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI
url={openApiUrl}
docExpansion="list"
@@ -280,6 +280,7 @@ export default function ClientBulkAddModal({
style={{ flex: 1 }}
/>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
/>
+12 -10
View File
@@ -582,7 +582,7 @@ export default function ClientFormModal({
onChange={(e) => update('email', e.target.value)}
/>
{!isEdit && (
<Button icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('email', RandomUtil.randomLowerAndNum(12))} />
)}
</Space.Compact>
</Form.Item>
@@ -603,7 +603,7 @@ export default function ClientFormModal({
onChange={(v) => update('limitIp', Number(v) || 0)} />
{isEdit && (
<Tooltip title={t('pages.clients.ipLog')}>
<Button icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
<Button aria-label={t('pages.clients.ipLog')} icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</Tooltip>
@@ -717,7 +717,7 @@ export default function ClientFormModal({
</Form.Item>
<Form.Item>
<Switch checked={form.enable} onChange={(v) => update('enable', v)} />
<Switch aria-label={t('enable')} checked={form.enable} onChange={(v) => update('enable', v)} />
<span style={{ marginLeft: 8 }}>{t('enable')}</span>
</Form.Item>
</>
@@ -731,28 +731,28 @@ export default function ClientFormModal({
<Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('uuid', RandomUtil.randomUUID())} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={regeneratePassword} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regeneratePassword} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} />
<Button icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={() => update('auth', RandomUtil.randomLowerAndNum(16))} />
</Space.Compact>
</Form.Item>
@@ -790,7 +790,7 @@ export default function ClientFormModal({
update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
}}
/>
<Button icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.clients.wireguardPublicKey')}>
@@ -831,11 +831,12 @@ export default function ClientFormModal({
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
@@ -851,11 +852,12 @@ export default function ClientFormModal({
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input
value={row.value}
aria-label="https://provider.example/sub/…"
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="https://provider.example/sub/…"
/>
<Tooltip title={t('delete')}>
<Button danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
<Button aria-label={t('delete')} danger icon={<DeleteOutlined />} onClick={() => removeExternalLinkRow(row.key)} />
</Tooltip>
</div>
))}
+13 -13
View File
@@ -220,7 +220,7 @@ export default function ClientInfoModal({
<td>
<Tag className="info-large-tag">{client.subId || '-'}</Tag>
{client.subId && (
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.subId!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.subId!)} />
)}
</td>
</tr>
@@ -229,7 +229,7 @@ export default function ClientInfoModal({
<td>{t('pages.clients.uuid')}</td>
<td>
<Tag className="info-large-tag">{client.uuid}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.uuid!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.uuid!)} />
</td>
</tr>
)}
@@ -238,7 +238,7 @@ export default function ClientInfoModal({
<td>{t('password')}</td>
<td>
<Tag className="info-large-tag">{client.password}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.password!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.password!)} />
</td>
</tr>
)}
@@ -247,7 +247,7 @@ export default function ClientInfoModal({
<td>{t('pages.clients.auth')}</td>
<td>
<Tag className="info-large-tag">{client.auth}</Tag>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyValue(client.auth!)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(client.auth!)} />
</td>
</tr>
)}
@@ -295,7 +295,7 @@ export default function ClientInfoModal({
<tr>
<td>{t('pages.inbounds.IPLimitlog')}</td>
<td>
<Button size="small" icon={<EyeOutlined />} loading={ipsLoading} onClick={openIpsModal}>
<Button size="small" icon={<EyeOutlined />} aria-label={t('pages.clients.ipLog')} loading={ipsLoading} onClick={openIpsModal}>
{clientIps.length > 0 ? clientIps.length : ''}
</Button>
</td>
@@ -375,7 +375,7 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subLink)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subLink)} />
</Tooltip>
<Popover
trigger="click"
@@ -384,7 +384,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subLink} remark={`${client.email}${t('subscription.title')}`} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
</div>
@@ -403,7 +403,7 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subJsonLink)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subJsonLink)} />
</Tooltip>
<Popover
trigger="click"
@@ -412,7 +412,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
</div>
@@ -434,7 +434,7 @@ export default function ClientInfoModal({
</a>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(subClashLink)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(subClashLink)} />
</Tooltip>
<Popover
trigger="click"
@@ -443,7 +443,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
</div>
@@ -469,7 +469,7 @@ export default function ClientInfoModal({
<span className="link-row-title" title={rowTitle}>{rowTitle}</span>
<div className="link-row-actions">
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyValue(link)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyValue(link)} />
</Tooltip>
{canQr && (
<Popover
@@ -479,7 +479,7 @@ export default function ClientInfoModal({
content={<QrPanel value={link} remark={qrRemark} size={220} />}
>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} />
<Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip>
</Popover>
)}
+22 -10
View File
@@ -50,6 +50,7 @@ import {
UsergroupAddOutlined,
UsergroupDeleteOutlined,
} from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme';
import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -752,19 +753,19 @@ export default function ClientsPage() {
render: (_v, record) => (
<Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} onClick={() => onShowQr(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} onClick={() => onShowQr(record)} />
</Tooltip>
<Tooltip title={t('pages.clients.clientInfo')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} onClick={() => onShowInfo(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<InfoCircleOutlined />} aria-label={t('pages.clients.clientInfo')} onClick={() => onShowInfo(record)} />
</Tooltip>
<Tooltip title={t('pages.inbounds.resetTraffic')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} onClick={() => onResetTraffic(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<RetweetOutlined />} aria-label={t('pages.inbounds.resetTraffic')} onClick={() => onResetTraffic(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button size="small" type="text" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -1039,7 +1040,7 @@ export default function ClientsPage() {
title={
<div className="card-toolbar">
{selectedRowKeys.length === 0 ? (
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd}>
<Button type="primary" icon={<PlusOutlined />} onClick={onAdd} aria-label={t('pages.clients.addClients')}>
{!isMobile && t('pages.clients.addClients')}
</Button>
) : (
@@ -1154,7 +1155,7 @@ export default function ClientsPage() {
],
}}
>
<Button icon={<MoreOutlined />}>
<Button icon={<MoreOutlined />} aria-label={t('more')}>
{!isMobile && t('more')}
</Button>
</Dropdown>
@@ -1164,6 +1165,7 @@ export default function ClientsPage() {
icon={<DeleteOutlined />}
onClick={onBulkDelete}
style={{ marginInlineStart: 'auto' }}
aria-label={t('delete')}
>
{!isMobile && t('delete')}
</Button>
@@ -1180,6 +1182,7 @@ export default function ClientsPage() {
prefix={<SearchOutlined />}
size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }}
aria-label={t('search')}
/>
<Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button
@@ -1187,12 +1190,14 @@ export default function ClientsPage() {
size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'}
aria-label={t('filter')}
>
{!isMobile && t('filter')}
</Button>
</Badge>
<Select
value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'}
suffixIcon={<SortAscendingOutlined />}
style={{ minWidth: isMobile ? 130 : 200 }}
@@ -1365,9 +1370,16 @@ export default function ClientsPage() {
<span className="tag-name">{row.email}</span>
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</Tag>}
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<div className="card-actions">
<Tooltip title={t('pages.clients.clientInfo')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => onShowInfo(row)} />
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.clients.clientInfo')}
onClick={() => onShowInfo(row)}
onKeyDown={activateOnKey(() => onShowInfo(row))}
/>
</Tooltip>
<Switch
checked={!!row.enable}
@@ -1404,7 +1416,7 @@ export default function ClientsPage() {
],
}}
>
<MoreOutlined className="row-action-trigger" />
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>
+1 -1
View File
@@ -111,7 +111,7 @@ export default function SubLinksModal({
key: 'actions',
width: 64,
render: (_v, row) => (
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
<Button size="small" type="text" aria-label={t('copy')} icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
),
},
];
+3 -3
View File
@@ -404,10 +404,10 @@ export default function GroupsPage() {
render: (_v, row) => (
<Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
<Button aria-label={t('more')} size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
</Dropdown>
<Tooltip title={t('pages.groups.rename')}>
<Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
<Button aria-label={t('pages.groups.rename')} size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
</Tooltip>
</Space>
),
@@ -522,7 +522,7 @@ export default function GroupsPage() {
hoverable
title={
<div className="card-toolbar">
<Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
<Button aria-label={t('pages.groups.addGroup')} type="primary" icon={<PlusOutlined />} onClick={openCreate}>
{!isMobile && t('pages.groups.addGroup')}
</Button>
</div>
+4 -4
View File
@@ -84,16 +84,16 @@ export default function HostList(props: HostListProps) {
return (
<Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}>
<Button size="small" type="text" icon={<ArrowUpOutlined />} disabled={idx === 0} onClick={() => onMove(h, 'up')} />
<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 />} disabled={idx >= count - 1} onClick={() => onMove(h, 'down')} />
<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 />} onClick={() => onEdit(h)} />
<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 />} onClick={() => onDelete(h)} />
<Button size="small" type="text" danger icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(h)} />
</Tooltip>
</Space>
);
@@ -164,6 +164,7 @@ export default function AttachClientsModal({
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
aria-label={t('search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -196,6 +197,7 @@ export default function AttachClientsModal({
) : (
<Select
mode="multiple"
aria-label={t('pages.inbounds.attachClientsTargets')}
style={{ width: '100%' }}
value={targetIds}
onChange={setTargetIds}
@@ -188,6 +188,7 @@ export default function AttachExistingClientsModal({
<Space wrap>
<Input.Search
allowClear
aria-label={t('search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -196,6 +197,7 @@ export default function AttachExistingClientsModal({
{groupOptions.length > 0 && (
<Select
allowClear
aria-label={t('pages.clients.group')}
value={groupFilter}
onChange={(v) => setGroupFilter(v)}
options={groupOptions}
@@ -152,6 +152,7 @@ export default function DetachClientsModal({
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search
allowClear
aria-label={t('search')}
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -66,6 +66,7 @@ export default function FallbacksCard({
>
<Space.Compact block style={{ marginBottom: 8 }}>
<Select
aria-label={t('pages.inbounds.fallbacks.pickInbound')}
value={record.childId}
options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'}
@@ -78,18 +79,20 @@ export default function FallbacksCard({
onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/>
<Button
aria-label={t('pages.inbounds.form.moveUp')}
disabled={idx === 0}
onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined />}
/>
<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 />}
/>
<Button danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
<Button aria-label={t('delete')} danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
</Space.Compact>
<Row gutter={[8, 8]}>
<Col xs={24} sm={12}>
@@ -33,7 +33,7 @@ export default function AccountsList() {
<Form.Item name={[field.name, 'pass']} noStyle>
<Input placeholder={t('password')} />
</Form.Item>
<Button onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
@@ -27,6 +27,7 @@ export default function MtprotoFields() {
<Input readOnly style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => {
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);
@@ -33,6 +33,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />}
onClick={() => {
const method = form.getFieldValue(['settings', 'method']);
@@ -15,7 +15,7 @@ export default function TunFields() {
<Form.List name={['settings', 'gateway']}>
{(fields, { add, remove }) => (
<Form.Item label={t('pages.inbounds.info.gateway')}>
<Button size="small" onClick={() => add('')}>
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
@@ -23,7 +23,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
@@ -34,7 +34,7 @@ export default function TunFields() {
<Form.List name={['settings', 'dns']}>
{(fields, { add, remove }) => (
<Form.Item label="DNS">
<Button size="small" onClick={() => add('')}>
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
@@ -42,7 +42,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
@@ -62,7 +62,7 @@ export default function TunFields() {
</Tooltip>
}
>
<Button size="small" onClick={() => add('')}>
<Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined />
</Button>
{fields.map((field, j) => (
@@ -70,7 +70,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
</Form.Item>
<Button size="small" onClick={() => remove(field.name)}>
<Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined />
</Button>
</Space.Compact>
@@ -16,7 +16,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
<Form.Item name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={regenInboundWg} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
</Space.Compact>
</Form.Item>
<Form.Item label={t('pages.xray.wireguard.publicKey')}>
@@ -143,7 +143,7 @@ export default function RealityForm({
>
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeShortIds} />
<Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
</Space.Compact>
</Form.Item>
<Form.Item
@@ -124,6 +124,7 @@ export default function TlsForm({
<>
<Form.Item label={t('certificate')}>
<Button
aria-label={t('add')}
type="primary"
size="small"
onClick={() => add({
@@ -83,7 +83,7 @@ export default function SockoptForm({
return (
<>
<Form.Item label="Sockopt">
<Switch checked={on} onChange={toggleSockopt} />
<Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
</Form.Item>
{on && (
<>
@@ -4,6 +4,7 @@ import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { Protocols } from '@/schemas/primitives';
import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker';
@@ -336,9 +337,9 @@ export default function InboundInfoModal({
)}
</div>
<div className="ip-log-actions">
<SyncOutlined spin={refreshing} onClick={() => loadClientIps()} />
<SyncOutlined spin={refreshing} role="button" tabIndex={0} aria-label={t('refresh')} onClick={() => loadClientIps()} onKeyDown={activateOnKey(() => loadClientIps())} />
<Tooltip title={t('pages.inbounds.IPLimitlogclear')}>
<DeleteOutlined onClick={() => clearClientIps()} />
<DeleteOutlined role="button" tabIndex={0} aria-label={t('pages.inbounds.IPLimitlogclear')} onClick={() => clearClientIps()} onKeyDown={activateOnKey(() => clearClientIps())} />
</Tooltip>
</div>
</td>
@@ -394,7 +395,7 @@ export default function InboundInfoModal({
<div className="tg-row">
<Tag color="blue">{clientSettings.tgId}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(clientSettings.tgId, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(clientSettings.tgId, t)} />
</Tooltip>
</div>
</>
@@ -408,7 +409,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(link.link, t)} />
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>
@@ -424,7 +425,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{t('subscription.title')}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subLink, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(subLink, t)} />
</Tooltip>
</div>
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a>
@@ -434,7 +435,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">JSON</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(subJsonLink, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(subJsonLink, t)} />
</Tooltip>
</div>
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a>
@@ -512,7 +513,7 @@ export default function InboundInfoModal({
<dd className="value-block">
<code className="value-code">{encryptionLabel}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(encryptionLabel, t)} />
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(encryptionLabel, t)} />
</Tooltip>
</dd>
</div>
@@ -637,7 +638,7 @@ export default function InboundInfoModal({
<dd className="value-block">
<code className="value-code">{inbound.settings.secret as string}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(inbound.settings.secret as string, t)} />
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(inbound.settings.secret as string, t)} />
</Tooltip>
</dd>
</div>
@@ -688,7 +689,7 @@ export default function InboundInfoModal({
<dd className="value-block">
<code className="value-code">{links[0].link}</code>
<Tooltip title={t('copy')}>
<Button size="small" className="value-copy" icon={<CopyOutlined />} onClick={() => copyText(links[0].link, t)} />
<Button size="small" className="value-copy" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(links[0].link, t)} />
</Tooltip>
</dd>
</div>
@@ -730,7 +731,7 @@ export default function InboundInfoModal({
<span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
<Button size="small" type="text" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
</Tooltip>
<Space size={4} wrap className="share-buttons">
<Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
@@ -779,7 +780,7 @@ export default function InboundInfoModal({
<span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(`${account.user}:${account.pass}`, t)} />
</Tooltip>
</dd>
</div>
@@ -845,10 +846,10 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardConfigs[idx], t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(wireguardConfigs[idx], t)} />
</Tooltip>
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={() => downloadText(wireguardConfigs[idx], `peer-${idx + 1}.conf`)} />
</Tooltip>
</div>
<code className="link-panel-text">{wireguardConfigs[idx]}</code>
@@ -859,7 +860,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">Peer {idx + 1} link</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(wireguardLinks[idx], t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(wireguardLinks[idx], t)} />
</Tooltip>
</div>
<code className="link-panel-text">{wireguardLinks[idx]}</code>
@@ -878,7 +879,7 @@ export default function InboundInfoModal({
<div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={() => copyText(link.link, t)} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={() => copyText(link.link, t)} />
</Tooltip>
</div>
<code className="link-panel-text">{link.link}</code>
@@ -25,6 +25,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { buildRowActionsMenu } from './RowActions';
import { useInboundColumns } from './useInboundColumns';
@@ -160,11 +161,11 @@ export default function InboundList({
hoverable
title={(
<Space>
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />}>
<Button type="primary" onClick={onAddInbound} icon={<PlusOutlined />} aria-label={t('pages.inbounds.addInbound')}>
{!isMobile && t('pages.inbounds.addInbound')}
</Button>
<Dropdown trigger={['click']} menu={generalActionsMenu}>
<Button type="primary" icon={<MenuOutlined />}>
<Button type="primary" icon={<MenuOutlined />} aria-label={t('pages.inbounds.generalActions')}>
{!isMobile && t('pages.inbounds.generalActions')}
</Button>
</Dropdown>
@@ -175,6 +176,7 @@ export default function InboundList({
options={nodeFilterOptions}
popupMatchSelectWidth={false}
style={{ minWidth: isMobile ? 90 : 140 }}
aria-label={t('pages.clients.filters.nodes')}
/>
)}
{selectedRowKeys.length > 0 && (
@@ -182,7 +184,7 @@ export default function InboundList({
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
</Tag>
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}>
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
{!isMobile && t('delete')}
</Button>
</>
@@ -221,9 +223,16 @@ export default function InboundList({
/>
<span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<div className="card-actions">
<Tooltip title={t('pages.inbounds.inboundInfo')}>
<InfoCircleOutlined className="row-action-trigger" onClick={() => setStatsRecord(record)} />
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('pages.inbounds.inboundInfo')}
onClick={() => setStatsRecord(record)}
onKeyDown={activateOnKey(() => setStatsRecord(record))}
/>
</Tooltip>
<Switch
checked={record.enable}
@@ -238,7 +247,7 @@ export default function InboundList({
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }),
}}
>
<MoreOutlined className="row-action-trigger" onClick={(e) => e.preventDefault()} />
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>
@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation();
return (
<div className="action-buttons">
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onClick('edit')} />
<Dropdown
trigger={['click']}
menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction),
}}
>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
);
+14 -6
View File
@@ -4,6 +4,7 @@ import { Button, QRCode, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import './QrPanel.css';
interface QrPanelProps {
@@ -96,21 +97,29 @@ export default function QrPanel({
<div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag>
<Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} />
<Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip>
{showQr && (
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}>
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} />
<Tooltip title={t('downloadImage')}>
<Button size="small" icon={<PictureOutlined />} aria-label={t('downloadImage')} onClick={downloadImage} />
</Tooltip>
)}
{downloadName && (
<Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={download} />
<Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={download} />
</Tooltip>
)}
</div>
{showQr && (
<div ref={qrRef} className="qr-panel-canvas">
<div
ref={qrRef}
className="qr-panel-canvas"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyImage}
onKeyDown={activateOnKey(copyImage)}
>
<Tooltip title={t('copy')}>
<QRCode
className="qr-code"
@@ -120,7 +129,6 @@ export default function QrPanel({
bordered={false}
color="#000000"
bgColor="#ffffff"
onClick={copyImage}
/>
</Tooltip>
</div>
+3 -3
View File
@@ -83,7 +83,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.exportDatabasePgDesc') : t('pages.index.exportDatabaseDesc')}
</div>
</div>
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
<Button type="primary" aria-label={t('pages.index.exportDatabase')} onClick={exportDb} icon={<DownloadOutlined />} />
</div>
<div className="backup-item">
@@ -93,7 +93,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
</div>
</div>
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} />
<Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
</div>
<div className="backup-item">
@@ -103,7 +103,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.importDatabasePgDesc') : t('pages.index.importDatabaseDesc')}
</div>
</div>
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
<Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
</div>
</div>
</Modal>
@@ -200,6 +200,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
onChange={(e) => setRow(i, { file: e.target.value })}
/>
<Button
aria-label={t('delete')}
icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/>
+25 -4
View File
@@ -38,6 +38,7 @@ import {
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -211,15 +212,15 @@ export default function IndexPage() {
title={t('menu.link')}
hoverable
actions={[
<Space className="action" key="logs" onClick={() => setLogsOpen(true)}>
<Space className="action" key="logs" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={() => setLogsOpen(true)} onKeyDown={activateOnKey(() => setLogsOpen(true))}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>,
<Space className="action" key="config" onClick={openConfig}>
<Space className="action" key="config" role="button" tabIndex={0} aria-label={t('pages.index.config')} onClick={openConfig} onKeyDown={activateOnKey(openConfig)}>
<ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>}
</Space>,
<Space className="action" key="backup" onClick={() => setBackupOpen(true)}>
<Space className="action" key="backup" role="button" tabIndex={0} aria-label={t('pages.index.backupTitle')} onClick={() => setBackupOpen(true)} onKeyDown={activateOnKey(() => setBackupOpen(true))}>
<CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>,
@@ -243,7 +244,7 @@ export default function IndexPage() {
}
hoverable
actions={[
<Space className="action" key="tg" onClick={openTelegram}>
<Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
<svg
viewBox="0 0 24 24"
width="14"
@@ -259,7 +260,11 @@ export default function IndexPage() {
<Space
key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
role="button"
tabIndex={0}
aria-label={t('pages.index.updatePanel')}
onClick={openPanelVersion}
onKeyDown={activateOnKey(openPanelVersion)}
>
<CloudDownloadOutlined />
{!isMobile && (
@@ -282,7 +287,11 @@ export default function IndexPage() {
<Space
className="action"
key="sys-history"
role="button"
tabIndex={0}
aria-label={t('pages.index.systemHistoryTitle')}
onClick={() => setSysHistoryOpen(true)}
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
@@ -290,7 +299,11 @@ export default function IndexPage() {
<Space
className="action"
key="xray-metrics"
role="button"
tabIndex={0}
aria-label={t('pages.index.xrayMetricsTitle')}
onClick={() => setXrayMetricsOpen(true)}
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
>
<AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
@@ -397,12 +410,20 @@ export default function IndexPage() {
{showIp ? (
<EyeOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(false)}
onKeyDown={activateOnKey(() => setShowIp(false))}
/>
) : (
<EyeInvisibleOutlined
className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(true)}
onKeyDown={activateOnKey(() => setShowIp(true))}
/>
)}
</Tooltip>
+3 -2
View File
@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse';
import './LogModal.css';
@@ -71,7 +72,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
const titleNode = (
<>
{t('pages.index.logs')}
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
</>
);
@@ -125,7 +126,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
</Form.Item>
</Form>
@@ -4,6 +4,7 @@ import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd'
import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status';
import GeodataSection from './GeodataSection';
import './VersionModal.css';
@@ -145,7 +146,11 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
<Tooltip title={t('update')}>
<ReloadOutlined
className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('update')}
onClick={() => updateGeofile(file)}
onKeyDown={activateOnKey(() => updateGeofile(file))}
/>
</Tooltip>
</div>
+3 -2
View File
@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import './XrayLogModal.css';
@@ -132,7 +133,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
title={
<>
{t('pages.index.accessLogs')}
<SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
<SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
</>
}
>
@@ -177,7 +178,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
</Checkbox>
</Form.Item>
<Form.Item className="download-item">
<Button type="primary" onClick={download} icon={<DownloadOutlined />} />
<Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
</Form.Item>
</Form>
+6 -5
View File
@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons';
import type { Status } from '@/models/status';
import { activateOnKey } from '@/utils/a11y';
import './XrayStatusCard.css';
interface XrayStatusCardProps {
@@ -67,7 +68,7 @@ export default function XrayStatusCard({
<span>{t('pages.index.xrayStatusError')}</span>
</Col>
<Col>
<BarsOutlined className="cursor-pointer" onClick={onOpenLogs} />
<BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
</Col>
</Row>
}
@@ -90,21 +91,21 @@ export default function XrayStatusCard({
// sense when one is configured (unlike IP limit, which no longer needs it)
...(accessLogEnable
? [
<Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
<Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
<BarsOutlined />
{!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>,
]
: []),
<Space className="action" key="stop" onClick={onStopXray}>
<Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
<PoweroffOutlined />
{!isMobile && <span>{t('pages.index.stopXray')}</span>}
</Space>,
<Space className="action" key="restart" onClick={onRestartXray}>
<Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
<ReloadOutlined />
{!isMobile && <span>{t('pages.index.restartXray')}</span>}
</Space>,
<Space className="action" key="switch" onClick={onOpenVersionSwitch}>
<Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
<ToolOutlined />
{!isMobile && (
<span>
+31 -13
View File
@@ -35,6 +35,7 @@ import {
import NodeHistoryPanel from './NodeHistoryPanel';
import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isPanelUpdateAvailable } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import './NodeList.css';
interface NodeListProps {
@@ -245,18 +246,18 @@ export default function NodeList({
) : (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<ThunderboltOutlined />} aria-label={t('pages.nodes.probe')} onClick={() => onProbe(record)} />
</Tooltip>
{isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} onClick={() => onUpdateNode(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<CloudDownloadOutlined />} aria-label={t('pages.nodes.updatePanel')} onClick={() => onUpdateNode(record)} />
</Tooltip>
)}
<Tooltip title={t('edit')}>
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onEdit(record)} />
<Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
<Button type="text" size="small" danger style={{ fontSize: 16 }} icon={<DeleteOutlined />} aria-label={t('delete')} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
@@ -296,9 +297,9 @@ export default function NodeList({
{t('pages.nodes.address')}
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
)}
</Tooltip>
</span>
@@ -367,7 +368,7 @@ export default function NodeList({
<span>{record.panelVersion || '-'}</span>
{canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} onClick={() => onUpdateNode(record)}>
<Tag color="orange" style={{ margin: 0, cursor: 'pointer' }} role="button" tabIndex={0} onClick={() => onUpdateNode(record)} onKeyDown={activateOnKey(() => onUpdateNode(record))}>
{t('pages.nodes.updateAvailable')}
</Tag>
</Tooltip>
@@ -467,15 +468,32 @@ export default function NodeList({
</div>
) : (
<div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}>
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} />
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions, jsx-a11y/click-events-have-key-events -- mouse click-to-expand mirrors the keyboard-accessible chevron disclosure button */}
<div
className="card-head"
onClick={(e) => {
if (!(e.target as HTMLElement).closest('.card-actions')) toggleExpanded(record.id);
}}
>
<RightOutlined
className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`}
role="button"
tabIndex={0}
aria-expanded={expandedIds.has(record.id)}
aria-label={record.name}
onKeyDown={activateOnKey(() => toggleExpanded(record.id))}
/>
<StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}>
<div className="card-actions">
<Tooltip title={t('info')}>
<InfoCircleOutlined
className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('info')}
onClick={() => setStatsNode(record)}
onKeyDown={activateOnKey(() => setStatsNode(record))}
/>
</Tooltip>
<Switch
@@ -512,7 +530,7 @@ export default function NodeList({
],
}}
>
<MoreOutlined className="row-action-trigger" />
<Button type="text" size="small" className="row-action-trigger" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>
@@ -555,9 +573,9 @@ export default function NodeList({
</a>
<Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? (
<EyeOutlined className="ip-toggle-icon" onClick={() => setShowAddress(false)} />
<EyeOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(false)} onKeyDown={activateOnKey(() => setShowAddress(false))} />
) : (
<EyeInvisibleOutlined className="ip-toggle-icon" onClick={() => setShowAddress(true)} />
<EyeInvisibleOutlined className="ip-toggle-icon" role="button" tabIndex={0} aria-label={t('pages.index.toggleIpVisibility')} onClick={() => setShowAddress(true)} onKeyDown={activateOnKey(() => setShowAddress(true))} />
)}
</Tooltip>
</div>
@@ -115,6 +115,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
value={state.mode}
options={modeOptions}
onChange={onModeChange}
aria-label={t('pages.settings.telegramNotifyTime')}
/>
{state.mode === 'every' && (
<Space.Compact style={{ width: '100%' }}>
@@ -123,12 +124,14 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
style={{ width: '50%' }}
value={state.num}
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
aria-label={t('pages.settings.notifyTime.interval')}
/>
<Select<Unit>
style={{ width: '50%' }}
value={state.unit}
options={unitOptions}
onChange={(unit) => update({ unit })}
aria-label={t('pages.settings.notifyTime.unit')}
/>
</Space.Compact>
)}
@@ -137,6 +140,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
value={state.custom}
placeholder="0 30 8 * * *"
onChange={(e) => update({ custom: e.target.value })}
aria-label={t('pages.settings.notifyTime.custom')}
/>
)}
</Space>
+11 -4
View File
@@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { TotpCodeSchema } from '@/schemas/login';
import './TwoFactorModal.css';
@@ -108,7 +109,14 @@ export default function TwoFactorModal({
<p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
<div className="qr-wrap">
<div
className="qr-wrap"
role="button"
tabIndex={0}
aria-label={t('copy')}
onClick={copyToken}
onKeyDown={activateOnKey(copyToken)}
>
<QRCode
className="qr-code"
value={qrValue}
@@ -119,18 +127,17 @@ export default function TwoFactorModal({
bgColor="#ffffff"
errorLevel="L"
title={t('copy')}
onClick={copyToken}
/>
<span className="qr-token">{token}</span>
</div>
<Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
</>
) : (
<>
<p>{description}</p>
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
</>
)}
</Modal>
+5 -2
View File
@@ -1,4 +1,4 @@
import type { ReactNode } from 'react';
import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { Tooltip } from 'antd';
/* Builds a settings category tab label: icon + text on desktop, and on
@@ -6,7 +6,10 @@ import { Tooltip } from 'antd';
old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) {
return <Tooltip title={text}>{icon}</Tooltip>;
const labelledIcon = typeof text === 'string' && isValidElement(icon)
? cloneElement(icon as ReactElement<{ 'aria-label'?: string }>, { 'aria-label': text })
: icon;
return <Tooltip title={text}>{labelledIcon}</Tooltip>;
}
return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
@@ -224,16 +224,18 @@ export default function BalancerFormModal({
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateBaselines([...baselines, ''])}
/>
{baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input
value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s"
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))}
/>
<InputAddon onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
<InputAddon ariaLabel={t('remove')} onClick={() => updateBaselines(baselines.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
@@ -244,28 +246,32 @@ export default function BalancerFormModal({
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
/>
{costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch
checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re"
unCheckedChildren="lit"
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/>
<Input
value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern"
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/>
<InputNumber
value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight"
style={{ width: 100 }}
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))}
/>
<InputAddon onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
<InputAddon ariaLabel={t('remove')} onClick={() => updateCosts(costs.filter((_, i) => i !== idx))}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
@@ -217,7 +217,7 @@ export default function BalancersTab({
<span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
<Button aria-label={t('edit')} shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
@@ -249,7 +249,7 @@ export default function BalancersTab({
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</div>
</div>
@@ -339,7 +339,7 @@ export default function BalancersTab({
{t('pages.xray.Balancers')}
</Button>
<Tooltip title={t('pages.xray.balancerLiveRefresh')}>
<Button icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
<Button aria-label={t('pages.xray.balancerLiveRefresh')} icon={<SyncOutlined spin={liveLoading} />} onClick={refreshLive} />
</Tooltip>
</Space>
@@ -205,13 +205,13 @@ export default function DnsServerModal({
<Form.List name="domains">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
@@ -223,13 +223,13 @@ export default function DnsServerModal({
<Form.List name="expectedIPs">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
@@ -241,13 +241,13 @@ export default function DnsServerModal({
<Form.List name="unexpectedIPs">
{(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => add('')} />
<Button size="small" type="primary" icon={<PlusOutlined />} aria-label={t('add')} onClick={() => add('')} />
{fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle>
<Input />
</Form.Item>
<InputAddon onClick={() => remove(field.name)}>
<InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
+3 -1
View File
@@ -335,6 +335,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<div key={`h${idx}`} className="hosts-row">
<Input
value={row.domain}
aria-label={t('pages.xray.dns.hostsDomain')}
placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }}
onChange={(e) => {
@@ -345,6 +346,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<Select
mode="tags"
value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']}
@@ -353,7 +355,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
syncHosts(next);
}}
/>
<Button danger icon={<DeleteOutlined />} onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))} />
<Button danger aria-label={t('delete')} icon={<DeleteOutlined />} onClick={() => syncHosts(hostsList.filter((_, i) => i !== idx))} />
</div>
))}
</Space>
@@ -37,7 +37,7 @@ export function useDnsServerColumns({
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
<Button aria-label={t('more')} shape="circle" size="small" icon={<MoreOutlined />} />
</Dropdown>
</Space>
),
@@ -72,6 +72,7 @@ export function useFakednsColumns({
deleteFakedns: (idx: number) => void;
updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void;
}): ColumnsType<FakednsTableRow> {
const { t } = useTranslation();
return useMemo(
() => [
{
@@ -82,7 +83,7 @@ export function useFakednsColumns({
render: (_v, _record, index) => (
<Space size={6}>
<span className="row-index">{index + 1}</span>
<Button shape="circle" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteFakedns(index)} />
<Button aria-label={t('delete')} shape="circle" size="small" danger icon={<DeleteOutlined />} onClick={() => deleteFakedns(index)} />
</Space>
),
},
@@ -94,6 +95,7 @@ export function useFakednsColumns({
render: (_v, record, index) => (
<Input
value={record.ipPool}
aria-label={t('pages.xray.fakedns.ipPool')}
size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/>
@@ -108,6 +110,7 @@ export function useFakednsColumns({
render: (_v, record, index) => (
<InputNumber
value={record.poolSize}
aria-label={t('pages.xray.fakedns.poolSize')}
min={1}
size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
@@ -115,6 +118,6 @@ export function useFakednsColumns({
),
},
],
[deleteFakedns, updateFakednsField],
[t, deleteFakedns, updateFakednsField],
);
}
@@ -53,7 +53,7 @@ export default function OutboundCardList({
if (rows.length === 0) {
return (
<div className="card-empty">
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} aria-hidden="true" />
<div>{t('noData')}</div>
</div>
);
@@ -81,7 +81,7 @@ export default function OutboundCardList({
menu={{
items: [
...(index > 0
? [{ key: 'top', label: <VerticalAlignTopOutlined />, onClick: () => setFirst(index) }]
? [{ key: 'top', label: <><VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}</>, onClick: () => setFirst(index) }]
: []),
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') },
@@ -89,7 +89,7 @@ export default function OutboundCardList({
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
{outboundAddresses(record).length > 0 && (
@@ -118,6 +118,7 @@ export default function OutboundCardList({
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(index, testMode)}
/>
</span>
@@ -496,7 +496,7 @@ export default function OutboundsTab({
title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')}
>
<Button icon={<RetweetOutlined />} />
<Button aria-label={t('pages.inbounds.resetTraffic')} icon={<RetweetOutlined />} />
</Popconfirm>
</Space>
</Col>
@@ -657,7 +657,7 @@ export default function OutboundsTab({
<div>
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
{t('pages.xray.outboundSub.active')}
<Button size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
<Button aria-label={t('refresh')} size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
{subs.length > 0 && (
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
{t('pages.xray.outboundSub.refreshAll')}
@@ -680,8 +680,8 @@ export default function OutboundsTab({
width: 56,
render: (_: unknown, r: OutboundSub, index: number) => (
<Space size={0}>
<Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
<Button type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
<Button aria-label={t('pages.inbounds.form.moveUp')} type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
<Button aria-label={t('pages.inbounds.form.moveDown')} type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
</Space>
),
},
@@ -716,10 +716,10 @@ export default function OutboundsTab({
key: 'actions',
render: (_: unknown, r: OutboundSub) => (
<Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
<Button size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
<Button aria-label={t('edit')} size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
<Button aria-label={t('pages.xray.outboundSub.refreshNow')} size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
<Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
<Button size="small" danger icon={<DeleteOutlined />} />
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
</Popconfirm>
</Space>
),
@@ -106,6 +106,7 @@ export default function SubscriptionOutbounds({
return (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button
aria-label={t('check')}
type="primary"
shape="circle"
size={isMobile ? 'small' : undefined}
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() {
@@ -35,6 +36,7 @@ export default function DnsFields() {
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
/>
</Form.Item>
@@ -45,7 +47,11 @@ export default function DnsFields() {
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
</div>
</Form.Item>
@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
@@ -138,6 +139,7 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
type="primary"
className="ml-8"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
type: 'rand',
@@ -157,7 +159,11 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)}
</div>
@@ -198,6 +204,7 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
action: 'allow',
@@ -219,7 +226,11 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
</div>
</Form.Item>
@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInsta
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui';
import { WireguardDomainStrategy } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
@@ -17,10 +18,11 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
<Form.Item label={t('pages.inbounds.privatekey')}>
<Space.Compact block>
<Form.Item name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} />
<Input aria-label={t('pages.inbounds.privatekey')} style={{ width: 'calc(100% - 32px)' }} />
</Form.Item>
<Button
icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => {
const pair = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
@@ -61,6 +63,7 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
size="small"
type="primary"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() =>
add({
publicKey: '',
@@ -80,7 +83,11 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
{fields.length > 1 && (
<DeleteOutlined
className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/>
)}
</div>
@@ -108,10 +115,10 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
style={{ marginBottom: 4 }}
>
<Form.Item noStyle name={ipField.name}>
<Input />
<Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</Form.Item>
{ipFields.length > 1 && (
<InputAddon onClick={() => removeIp(ipIdx)}>
<InputAddon ariaLabel={t('remove')} onClick={() => removeIp(ipIdx)}>
<MinusOutlined />
</InputAddon>
)}
@@ -120,6 +127,7 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
<Button
size="small"
icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addIp('')}
/>
</>
@@ -69,24 +69,24 @@ export function useOutboundColumns({
<div className="action-cell">
<span className="row-index">{index + 1}</span>
<div className="action-buttons">
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
<Button shape="circle" size="small" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => openEdit(index)} />
<Dropdown
trigger={['click']}
menu={{
items: [
...(index > 0
? [
{ key: 'top', label: <><VerticalAlignTopOutlined /> Move to top</>, onClick: () => setFirst(index) },
{ key: 'top', label: <><VerticalAlignTopOutlined /> {t('pages.xray.outbound.moveToTop')}</>, onClick: () => setFirst(index) },
]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'reset', label: <><RetweetOutlined /> Reset traffic</>, onClick: () => onResetTraffic(rows[index].tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> Delete</>, onClick: () => confirmDelete(index) },
{ key: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(rows[index].tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
</div>
@@ -174,6 +174,7 @@ export function useOutboundColumns({
loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(index, testMode)}
/>
</Tooltip>
@@ -66,6 +66,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
<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)}
@@ -75,6 +76,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col>
<Col xs={12} sm={3}>
<InputNumber
aria-label={t('pages.xray.routeTesterPort')}
style={{ width: '100%' }}
min={0}
max={65535}
@@ -85,6 +87,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col>
<Col xs={12} sm={3}>
<Select
aria-label={t('pages.inbounds.network')}
style={{ width: '100%' }}
value={network}
onChange={setNetwork}
@@ -96,6 +99,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col>
<Col xs={12} sm={4}>
<Select
aria-label={t('pages.xray.routeTesterInbound')}
style={{ width: '100%' }}
placeholder={t('pages.xray.routeTesterInbound')}
allowClear
@@ -106,6 +110,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col>
<Col xs={12} sm={4}>
<Select
aria-label={t('pages.xray.routeTesterProtocol')}
style={{ width: '100%' }}
placeholder={t('pages.xray.routeTesterProtocol')}
allowClear
@@ -60,6 +60,7 @@ export default function RuleCardList({
<div className="rule-card-head">
<HolderOutlined
className="drag-handle"
aria-hidden="true"
onPointerDown={(ev) => onHandlePointerDown(index, ev)}
/>
<span className="rule-number">#{index + 1}</span>
@@ -68,13 +69,13 @@ export default function RuleCardList({
menu={{
items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'del', danger: true, label: <><DeleteOutlined /> {t('delete')}</>, onClick: () => confirmDelete(index) },
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
<Switch
size="small"
@@ -105,11 +106,11 @@ export default function RuleCardList({
</span>
{rule.outboundTag ? (
<Tag color="green" className="flow-tag">
<ExportOutlined /> {rule.outboundTag}
<ExportOutlined aria-hidden="true" /> {rule.outboundTag}
</Tag>
) : rule.balancerTag ? (
<Tag color="purple" className="flow-tag">
<ClusterOutlined /> {rule.balancerTag}
<ClusterOutlined aria-hidden="true" /> {rule.balancerTag}
</Tag>
) : (
<span className="criterion-empty"></span>
@@ -166,7 +166,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined />
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -176,7 +176,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined />
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -186,7 +186,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined />
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -211,7 +211,7 @@ export default function RuleFormModal({
</Form.Item>
<Form.Item label={t('pages.xray.ruleForm.attributes')}>
<Button size="small" icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
<Button size="small" aria-label={t('add')} icon={<PlusOutlined />} onClick={() => update('attrs', [...form.attrs, ['', '']])} />
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{form.attrs.map((attr, idx) => (
@@ -219,6 +219,7 @@ export default function RuleFormModal({
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input
value={attr[0]}
aria-label={t('pages.nodes.name')}
placeholder={t('pages.nodes.name')}
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
@@ -227,6 +228,7 @@ export default function RuleFormModal({
/>
<Input
value={attr[1]}
aria-label={t('pages.xray.ruleForm.value')}
placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
@@ -234,6 +236,7 @@ export default function RuleFormModal({
}}
/>
<Button
aria-label={t('remove')}
icon={<MinusOutlined />}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/>
@@ -244,7 +247,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined />
IP <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -254,7 +257,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined />
{t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -264,7 +267,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined />
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -274,7 +277,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined />
{t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -301,7 +304,7 @@ export default function RuleFormModal({
<Form.Item
label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined />
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip>
}
>
@@ -58,6 +58,7 @@ export function useRoutingColumns({
<HolderOutlined
className="drag-handle"
title={t('pages.xray.routing.dragToReorder')}
aria-hidden="true"
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/>
<span className="row-index">{index + 1}</span>
@@ -72,7 +73,7 @@ export function useRoutingColumns({
render: (_v, _r, index) => (
<div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
{!isMobile && (
<Button shape="circle" size="small" icon={<EditOutlined />} onClick={() => openEdit(index)} />
<Button shape="circle" size="small" icon={<EditOutlined />} aria-label={t('edit')} onClick={() => openEdit(index)} />
)}
<Dropdown
trigger={['click']}
@@ -81,10 +82,10 @@ export function useRoutingColumns({
...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }]
: []),
{ key: 'up', label: <ArrowUpOutlined />, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
{
key: 'down',
label: <ArrowDownOutlined />,
label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>,
disabled: index === rowsLength - 1,
onClick: () => moveDown(index),
},
@@ -92,7 +93,7 @@ export function useRoutingColumns({
],
}}
>
<Button shape="circle" size="small" icon={<MoreOutlined />} />
<Button shape="circle" size="small" icon={<MoreOutlined />} aria-label={t('more')} />
</Dropdown>
</div>
),
@@ -184,7 +185,7 @@ export function useRoutingColumns({
render: (_v, record) =>
record.outboundTag ? (
<div className="target-row">
<ExportOutlined className="target-icon" />
<ExportOutlined className="target-icon" aria-hidden="true" />
<Tag color="green">{record.outboundTag}</Tag>
</div>
) : (
@@ -200,7 +201,7 @@ export function useRoutingColumns({
render: (_v, record) =>
record.balancerTag ? (
<div className="target-row">
<ClusterOutlined className="target-icon" />
<ClusterOutlined className="target-icon" aria-hidden="true" />
<Tag color="purple">{record.balancerTag}</Tag>
</div>
) : (
+10
View File
@@ -0,0 +1,10 @@
import type { KeyboardEvent } from 'react';
export function activateOnKey(handler: () => void) {
return (event: KeyboardEvent) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
handler();
}
};
}
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "اتنسخ",
"more": "المزيد",
"download": "تحميل",
"regenerate": "إعادة التوليد",
"jsonEditor": "محرر JSON",
"downloadImage": "تنزيل الصورة",
"sort": "ترتيب",
"remark": "ملاحظة",
"enable": "مفعل",
"protocol": "بروتوكول",
@@ -118,7 +122,8 @@
"link": "إدارة",
"donate": "تبرع",
"hosts": "المضيفات",
"docs": "التوثيق"
"docs": "التوثيق",
"openMenu": "فتح القائمة"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "رأس الطلب",
"responseHeader": "رأس الرد"
}
}
},
"sniffingDestOverride": "تجاوز الوجهة"
},
"clients": {
"tabBasics": "أساسي",
@@ -1142,7 +1148,9 @@
"custom": "مخصص (crontab)",
"seconds": "ثوانٍ",
"minutes": "دقائق",
"hours": "ساعات"
"hours": "ساعات",
"interval": "الفاصل الزمني",
"unit": "الوحدة"
},
"tgNotifyBackup": "نسخة احتياطية لقاعدة البيانات",
"tgNotifyBackupDesc": "ابعت ملف النسخة الاحتياطية لقاعدة البيانات مع التقرير.",
@@ -1618,7 +1626,8 @@
"city": "المدينة",
"allCities": "كل المدن",
"privateKey": "المفتاح الخاص",
"load": "الحمل"
"load": "الحمل",
"moveToTop": "نقل إلى الأعلى"
},
"outboundSub": {
"manage": "الاشتراكات",
@@ -1717,7 +1726,10 @@
"tolerance": "التحمل",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل."
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
"costMatch": "نمط الوسم",
"costValue": "الوزن",
"costRegexp": "مطابقة تعبير نمطي"
},
"wireguard": {
"secretKey": "المفتاح السري",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Copied",
"more": "more",
"download": "Download",
"regenerate": "Regenerate",
"jsonEditor": "JSON editor",
"downloadImage": "Download Image",
"sort": "Sort",
"remark": "Remark",
"enable": "Enabled",
"protocol": "Protocol",
@@ -118,7 +122,8 @@
"logout": "Log Out",
"link": "Manage",
"donate": "Donate",
"docs": "Documentation"
"docs": "Documentation",
"openMenu": "Open menu"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Request Header",
"responseHeader": "Response Header"
}
}
},
"sniffingDestOverride": "Destination override"
},
"clients": {
"tabBasics": "Basics",
@@ -1260,7 +1266,9 @@
"custom": "Custom (crontab)",
"seconds": "Seconds",
"minutes": "Minutes",
"hours": "Hours"
"hours": "Hours",
"interval": "Interval",
"unit": "Unit"
},
"tgNotifyBackup": "Database Backup",
"tgNotifyBackupDesc": "Send a database backup file with a report.",
@@ -1734,7 +1742,8 @@
"city": "City",
"allCities": "All Cities",
"privateKey": "Private Key",
"load": "Load"
"load": "Load",
"moveToTop": "Move to top"
},
"outboundSub": {
"manage": "Subscriptions",
@@ -1833,7 +1842,10 @@
"tolerance": "Tolerance",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "It is not possible to use balancerTag and outboundTag at the same time. If used at the same time, only outboundTag will work."
"balancerDesc": "It is not possible to use balancerTag and outboundTag at the same time. If used at the same time, only outboundTag will work.",
"costMatch": "Tag pattern",
"costValue": "Weight",
"costRegexp": "Regular expression match"
},
"wireguard": {
"secretKey": "Secret Key",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Copiado",
"more": "más",
"download": "Descargar",
"regenerate": "Regenerar",
"jsonEditor": "Editor JSON",
"downloadImage": "Descargar imagen",
"sort": "Ordenar",
"remark": "Notas",
"enable": "Habilitar",
"protocol": "Protocolo",
@@ -118,7 +122,8 @@
"link": "Gestionar",
"donate": "Donar",
"hosts": "Hosts",
"docs": "Documentación"
"docs": "Documentación",
"openMenu": "Abrir menú"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Encabezado de solicitud",
"responseHeader": "Encabezado de respuesta"
}
}
},
"sniffingDestOverride": "Anulación de destino"
},
"clients": {
"tabBasics": "Básico",
@@ -1142,7 +1148,9 @@
"custom": "Personalizado (crontab)",
"seconds": "Segundos",
"minutes": "Minutos",
"hours": "Horas"
"hours": "Horas",
"interval": "Intervalo",
"unit": "Unidad"
},
"tgNotifyBackup": "Respaldo de Base de Datos",
"tgNotifyBackupDesc": "Incluir archivo de respaldo de base de datos con notificación de informe.",
@@ -1618,7 +1626,8 @@
"city": "Ciudad",
"allCities": "Todas las ciudades",
"privateKey": "Clave privada",
"load": "Carga"
"load": "Carga",
"moveToTop": "Mover al principio"
},
"outboundSub": {
"manage": "Suscripciones",
@@ -1717,7 +1726,10 @@
"tolerance": "Tolerancia",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag."
"balancerDesc": "No es posible utilizar balancerTag y outboundTag al mismo tiempo. Si se utilizan al mismo tiempo, sólo funcionará outboundTag.",
"costMatch": "Patrón de etiqueta",
"costValue": "Peso",
"costRegexp": "Coincidencia por expresión regular"
},
"wireguard": {
"secretKey": "Llave secreta",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "کپی شد",
"more": "بیشتر",
"download": "دانلود",
"regenerate": "تولید مجدد",
"jsonEditor": "ویرایشگر JSON",
"downloadImage": "دانلود تصویر",
"sort": "مرتب‌سازی",
"remark": "نام",
"enable": "فعال",
"protocol": "پروتکل",
@@ -118,7 +122,8 @@
"link": "مدیریت",
"donate": "حمایت مالی",
"hosts": "میزبان‌ها",
"docs": "مستندات"
"docs": "مستندات",
"openMenu": "باز کردن منو"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "سربرگ درخواست",
"responseHeader": "سربرگ پاسخ"
}
}
},
"sniffingDestOverride": "بازنویسی مقصد"
},
"clients": {
"tabBasics": "پایه",
@@ -1144,7 +1150,9 @@
"custom": "سفارشی (crontab)",
"seconds": "ثانیه",
"minutes": "دقیقه",
"hours": "ساعت"
"hours": "ساعت",
"interval": "بازه زمانی",
"unit": "واحد"
},
"tgNotifyBackup": "پشتیبان‌گیری از دیتابیس",
"tgNotifyBackupDesc": "فایل پشتیبان‌دیتابیس را به‌همراه گزارش ارسال می‌کند",
@@ -1618,7 +1626,8 @@
"city": "شهر",
"allCities": "همه شهرها",
"privateKey": "کلید خصوصی",
"load": "فشار سرور"
"load": "فشار سرور",
"moveToTop": "انتقال به بالا"
},
"outboundSub": {
"manage": "سابسکریپشن‌ها",
@@ -1717,7 +1726,10 @@
"tolerance": "تحمل",
"baselines": "خطوط پایه",
"costs": "هزینه‌ها",
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد."
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
"costMatch": "الگوی برچسب",
"costValue": "وزن",
"costRegexp": "تطبیق با عبارت باقاعده"
},
"wireguard": {
"secretKey": "کلید شخصی",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Tersalin",
"more": "lainnya",
"download": "Unduh",
"regenerate": "Buat Ulang",
"jsonEditor": "Editor JSON",
"downloadImage": "Unduh Gambar",
"sort": "Urutkan",
"remark": "Catatan",
"enable": "Aktifkan",
"protocol": "Protokol",
@@ -118,7 +122,8 @@
"link": "Kelola",
"donate": "Donasi",
"hosts": "Host",
"docs": "Dokumentasi"
"docs": "Dokumentasi",
"openMenu": "Buka menu"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Header Permintaan",
"responseHeader": "Header Respons"
}
}
},
"sniffingDestOverride": "Penggantian tujuan"
},
"clients": {
"tabBasics": "Dasar",
@@ -1142,7 +1148,9 @@
"custom": "Kustom (crontab)",
"seconds": "Detik",
"minutes": "Menit",
"hours": "Jam"
"hours": "Jam",
"interval": "Interval",
"unit": "Satuan"
},
"tgNotifyBackup": "Cadangan Database",
"tgNotifyBackupDesc": "Kirim berkas cadangan database dengan laporan.",
@@ -1618,7 +1626,8 @@
"city": "Kota",
"allCities": "Semua Kota",
"privateKey": "Kunci Privat",
"load": "Beban"
"load": "Beban",
"moveToTop": "Pindahkan ke atas"
},
"outboundSub": {
"manage": "Langganan",
@@ -1717,7 +1726,10 @@
"tolerance": "Toleransi",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi."
"balancerDesc": "BalancerTag dan outboundTag tidak dapat digunakan secara bersamaan. Jika digunakan secara bersamaan, hanya outboundTag yang akan berfungsi.",
"costMatch": "Pola tag",
"costValue": "Bobot",
"costRegexp": "Pencocokan ekspresi reguler"
},
"wireguard": {
"secretKey": "Kunci Rahasia",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "コピー済み",
"more": "もっと",
"download": "ダウンロード",
"regenerate": "再生成",
"jsonEditor": "JSON エディター",
"downloadImage": "画像をダウンロード",
"sort": "並べ替え",
"remark": "備考",
"enable": "有効化",
"protocol": "プロトコル",
@@ -118,7 +122,8 @@
"link": "リンク管理",
"donate": "寄付",
"hosts": "ホスト",
"docs": "ドキュメント"
"docs": "ドキュメント",
"openMenu": "メニューを開く"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "リクエストヘッダー",
"responseHeader": "レスポンスヘッダー"
}
}
},
"sniffingDestOverride": "宛先のオーバーライド"
},
"clients": {
"tabBasics": "基本",
@@ -1142,7 +1148,9 @@
"custom": "カスタム (crontab)",
"seconds": "秒",
"minutes": "分",
"hours": "時間"
"hours": "時間",
"interval": "間隔",
"unit": "単位"
},
"tgNotifyBackup": "データベースバックアップ",
"tgNotifyBackupDesc": "レポート付きのデータベースバックアップファイルを送信",
@@ -1618,7 +1626,8 @@
"city": "都市",
"allCities": "すべての都市",
"privateKey": "秘密鍵",
"load": "負荷"
"load": "負荷",
"moveToTop": "先頭に移動"
},
"outboundSub": {
"manage": "サブスクリプション",
@@ -1717,7 +1726,10 @@
"tolerance": "許容範囲",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。"
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
"costMatch": "タグパターン",
"costValue": "重み",
"costRegexp": "正規表現で一致"
},
"wireguard": {
"secretKey": "シークレットキー",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Copiado",
"more": "mais",
"download": "Baixar",
"regenerate": "Regenerar",
"jsonEditor": "Editor JSON",
"downloadImage": "Baixar imagem",
"sort": "Ordenar",
"remark": "Observação",
"enable": "Ativado",
"protocol": "Protocolo",
@@ -118,7 +122,8 @@
"link": "Gerenciar",
"donate": "Doar",
"hosts": "Hosts",
"docs": "Documentação"
"docs": "Documentação",
"openMenu": "Abrir menu"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Cabeçalho da Requisição",
"responseHeader": "Cabeçalho da Resposta"
}
}
},
"sniffingDestOverride": "Substituição de destino"
},
"clients": {
"tabBasics": "Básico",
@@ -1142,7 +1148,9 @@
"custom": "Personalizado (crontab)",
"seconds": "Segundos",
"minutes": "Minutos",
"hours": "Horas"
"hours": "Horas",
"interval": "Intervalo",
"unit": "Unidade"
},
"tgNotifyBackup": "Backup do Banco de Dados",
"tgNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.",
@@ -1618,7 +1626,8 @@
"city": "Cidade",
"allCities": "Todas as Cidades",
"privateKey": "Chave Privada",
"load": "Carga"
"load": "Carga",
"moveToTop": "Mover para o topo"
},
"outboundSub": {
"manage": "Assinaturas",
@@ -1717,7 +1726,10 @@
"tolerance": "Tolerância",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará."
"balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
"costMatch": "Padrão de tag",
"costValue": "Peso",
"costRegexp": "Correspondência por expressão regular"
},
"wireguard": {
"secretKey": "Chave Secreta",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Скопировано",
"more": "ещё",
"download": "Скачать",
"regenerate": "Сгенерировать заново",
"jsonEditor": "Редактор JSON",
"downloadImage": "Скачать изображение",
"sort": "Сортировка",
"remark": "Примечание",
"enable": "Включить",
"protocol": "Протокол",
@@ -118,7 +122,8 @@
"link": "Управление",
"donate": "Поддержать",
"hosts": "Хосты",
"docs": "Документация"
"docs": "Документация",
"openMenu": "Открыть меню"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Заголовок запроса",
"responseHeader": "Заголовок ответа"
}
}
},
"sniffingDestOverride": "Переопределение назначения"
},
"clients": {
"tabBasics": "Основные",
@@ -1142,7 +1148,9 @@
"custom": "Произвольный (crontab)",
"seconds": "Секунды",
"minutes": "Минуты",
"hours": "Часы"
"hours": "Часы",
"interval": "Интервал",
"unit": "Единица"
},
"tgNotifyBackup": "Резервное копирование базы данных",
"tgNotifyBackupDesc": "Отправлять уведомление с файлом резервной копии базы данных",
@@ -1618,7 +1626,8 @@
"city": "Город",
"allCities": "Все города",
"privateKey": "Приватный ключ",
"load": "Нагрузка"
"load": "Нагрузка",
"moveToTop": "Переместить наверх"
},
"outboundSub": {
"manage": "Подписки",
@@ -1717,7 +1726,10 @@
"tolerance": "Допуск",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag."
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
"costMatch": "Шаблон тега",
"costValue": "Вес",
"costRegexp": "Совпадение по регулярному выражению"
},
"wireguard": {
"secretKey": "Секретный ключ",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Kopyalandı",
"more": "Diğer",
"download": "İndir",
"regenerate": "Yeniden Oluştur",
"jsonEditor": "JSON Düzenleyici",
"downloadImage": "Resmi İndir",
"sort": "Sırala",
"remark": "Açıklama",
"enable": "Etkin",
"protocol": "Protokol",
@@ -118,7 +122,8 @@
"link": "Yönet",
"donate": "Bağış Yap",
"hosts": "Host'lar",
"docs": "Belgeler"
"docs": "Belgeler",
"openMenu": "Menüyü aç"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "İstek Başlığı",
"responseHeader": "Yanıt Başlığı"
}
}
},
"sniffingDestOverride": "Hedef geçersiz kılma"
},
"clients": {
"tabBasics": "Temel",
@@ -1142,7 +1148,9 @@
"custom": "Özel (crontab)",
"seconds": "Saniye",
"minutes": "Dakika",
"hours": "Saat"
"hours": "Saat",
"interval": "Aralık",
"unit": "Birim"
},
"tgNotifyBackup": "Veritabanı Yedeği",
"tgNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.",
@@ -1618,7 +1626,8 @@
"city": "Şehir",
"allCities": "Tüm Şehirler",
"privateKey": "Özel Anahtar",
"load": "Yükle"
"load": "Yükle",
"moveToTop": "En üste taşı"
},
"outboundSub": {
"manage": "Abonelikler",
@@ -1717,7 +1726,10 @@
"tolerance": "Tolerans",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur."
"balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur.",
"costMatch": "Etiket deseni",
"costValue": "Ağırlık",
"costRegexp": "Düzenli ifade eşleşmesi"
},
"wireguard": {
"secretKey": "Gizli Anahtar",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Скопійовано",
"more": "більше",
"download": "Завантажити",
"regenerate": "Згенерувати заново",
"jsonEditor": "Редактор JSON",
"downloadImage": "Завантажити зображення",
"sort": "Сортування",
"remark": "Примітка",
"enable": "Увімкнути",
"protocol": "Протокол",
@@ -118,7 +122,8 @@
"link": "Керувати",
"donate": "Підтримати",
"hosts": "Хости",
"docs": "Документація"
"docs": "Документація",
"openMenu": "Відкрити меню"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Заголовок запиту",
"responseHeader": "Заголовок відповіді"
}
}
},
"sniffingDestOverride": "Перевизначення призначення"
},
"clients": {
"tabBasics": "Основні",
@@ -1142,7 +1148,9 @@
"custom": "Власний (crontab)",
"seconds": "Секунди",
"minutes": "Хвилини",
"hours": "Години"
"hours": "Години",
"interval": "Інтервал",
"unit": "Одиниця"
},
"tgNotifyBackup": "Резервне копіювання бази даних",
"tgNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.",
@@ -1618,7 +1626,8 @@
"city": "Місто",
"allCities": "Усі міста",
"privateKey": "Приватний ключ",
"load": "Навантаження"
"load": "Навантаження",
"moveToTop": "Перемістити вгору"
},
"outboundSub": {
"manage": "Підписки",
@@ -1717,7 +1726,10 @@
"tolerance": "Допуск",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag."
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
"costMatch": "Шаблон тегу",
"costValue": "Вага",
"costRegexp": "Збіг за регулярним виразом"
},
"wireguard": {
"secretKey": "Приватний ключ",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "Đã sao chép",
"more": "thêm",
"download": "Tải xuống",
"regenerate": "Tạo lại",
"jsonEditor": "Trình chỉnh sửa JSON",
"downloadImage": "Tải hình ảnh",
"sort": "Sắp xếp",
"remark": "Ghi chú",
"enable": "Kích hoạt",
"protocol": "Giao thức",
@@ -118,7 +122,8 @@
"link": "Quản lý",
"donate": "Quyên góp",
"hosts": "Hosts",
"docs": "Tài liệu"
"docs": "Tài liệu",
"openMenu": "Mở menu"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "Header yêu cầu",
"responseHeader": "Header phản hồi"
}
}
},
"sniffingDestOverride": "Ghi đè đích"
},
"clients": {
"tabBasics": "Cơ bản",
@@ -1142,7 +1148,9 @@
"custom": "Tùy chỉnh (crontab)",
"seconds": "Giây",
"minutes": "Phút",
"hours": "Giờ"
"hours": "Giờ",
"interval": "Khoảng thời gian",
"unit": "Đơn vị"
},
"tgNotifyBackup": "Sao lưu Cơ sở dữ liệu",
"tgNotifyBackupDesc": "Bao gồm tệp sao lưu cơ sở dữ liệu với thông báo báo cáo.",
@@ -1618,7 +1626,8 @@
"city": "Thành phố",
"allCities": "Tất cả thành phố",
"privateKey": "Khóa riêng",
"load": "Tải"
"load": "Tải",
"moveToTop": "Chuyển lên đầu"
},
"outboundSub": {
"manage": "Đăng ký",
@@ -1717,7 +1726,10 @@
"tolerance": "Dung sai",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động."
"balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động.",
"costMatch": "Mẫu thẻ",
"costValue": "Trọng số",
"costRegexp": "Khớp biểu thức chính quy"
},
"wireguard": {
"secretKey": "Khoá bí mật",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "已复制",
"more": "更多",
"download": "下载",
"regenerate": "重新生成",
"jsonEditor": "JSON 编辑器",
"downloadImage": "下载图片",
"sort": "排序",
"remark": "备注",
"enable": "启用",
"protocol": "协议",
@@ -118,7 +122,8 @@
"link": "管理",
"donate": "捐赠",
"hosts": "主机",
"docs": "文档"
"docs": "文档",
"openMenu": "打开菜单"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "请求头",
"responseHeader": "响应头"
}
}
},
"sniffingDestOverride": "目标覆盖"
},
"clients": {
"tabBasics": "基本",
@@ -1142,7 +1148,9 @@
"custom": "自定义 (crontab)",
"seconds": "秒",
"minutes": "分钟",
"hours": "小时"
"hours": "小时",
"interval": "间隔",
"unit": "单位"
},
"tgNotifyBackup": "数据库备份",
"tgNotifyBackupDesc": "发送带有报告的数据库备份文件",
@@ -1618,7 +1626,8 @@
"city": "城市",
"allCities": "所有城市",
"privateKey": "私钥",
"load": "负载"
"load": "负载",
"moveToTop": "移到顶部"
},
"outboundSub": {
"manage": "订阅",
@@ -1717,7 +1726,10 @@
"tolerance": "容差",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。"
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用,则只有 outboundTag 会生效。",
"costMatch": "标签匹配模式",
"costValue": "权重",
"costRegexp": "正则表达式匹配"
},
"wireguard": {
"secretKey": "密钥",
+17 -5
View File
@@ -15,6 +15,10 @@
"copied": "已複製",
"more": "更多",
"download": "下載",
"regenerate": "重新產生",
"jsonEditor": "JSON 編輯器",
"downloadImage": "下載圖片",
"sort": "排序",
"remark": "備註",
"enable": "啟用",
"protocol": "協議",
@@ -118,7 +122,8 @@
"link": "管理",
"donate": "捐贈",
"hosts": "Hosts",
"docs": "文件"
"docs": "文件",
"openMenu": "開啟選單"
},
"pages": {
"login": {
@@ -712,7 +717,8 @@
"requestHeader": "請求頭",
"responseHeader": "響應頭"
}
}
},
"sniffingDestOverride": "目標覆寫"
},
"clients": {
"tabBasics": "基本",
@@ -1142,7 +1148,9 @@
"custom": "自訂 (crontab)",
"seconds": "秒",
"minutes": "分鐘",
"hours": "小時"
"hours": "小時",
"interval": "間隔",
"unit": "單位"
},
"tgNotifyBackup": "資料庫備份",
"tgNotifyBackupDesc": "傳送帶有報告的資料庫備份檔案",
@@ -1618,7 +1626,8 @@
"city": "城市",
"allCities": "所有城市",
"privateKey": "私密金鑰",
"load": "負載"
"load": "負載",
"moveToTop": "移到頂部"
},
"outboundSub": {
"manage": "訂閱",
@@ -1717,7 +1726,10 @@
"tolerance": "容差",
"baselines": "Baselines",
"costs": "Costs",
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。"
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用,則只有 outboundTag 會生效。",
"costMatch": "標籤比對模式",
"costValue": "權重",
"costRegexp": "正規表示式比對"
},
"wireguard": {
"secretKey": "金鑰",