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

View File

@@ -1,6 +1,7 @@
import js from '@eslint/js'; import js from '@eslint/js';
import tseslint from 'typescript-eslint'; import tseslint from 'typescript-eslint';
import reactHooks from 'eslint-plugin-react-hooks'; import reactHooks from 'eslint-plugin-react-hooks';
import jsxA11y from 'eslint-plugin-jsx-a11y';
import globals from 'globals'; import globals from 'globals';
export default [ export default [
@@ -44,4 +45,12 @@ export default [
'react-hooks/refs': 'off', 'react-hooks/refs': 'off',
}, },
}, },
{
files: ['**/*.tsx'],
plugins: { 'jsx-a11y': jsxA11y },
rules: {
...jsxA11y.flatConfigs.recommended.rules,
'jsx-a11y/no-autofocus': 'off',
},
},
]; ];

File diff suppressed because it is too large Load Diff

View File

@@ -52,6 +52,7 @@
"@vitejs/plugin-react": "^6.0.3", "@vitejs/plugin-react": "^6.0.3",
"@vitest/coverage-v8": "^4.1.9", "@vitest/coverage-v8": "^4.1.9",
"eslint": "^10.5.0", "eslint": "^10.5.0",
"eslint-plugin-jsx-a11y": "^6.10.2",
"eslint-plugin-react-hooks": "^7.1.1", "eslint-plugin-react-hooks": "^7.1.1",
"globals": "^17.7.0", "globals": "^17.7.0",
"jsdom": "^29.1.1", "jsdom": "^29.1.1",
@@ -61,6 +62,9 @@
"vitest": "^4.1.9" "vitest": "^4.1.9"
}, },
"overrides": { "overrides": {
"eslint-plugin-jsx-a11y": {
"eslint": "$eslint"
},
"dompurify": "^3.4.11", "dompurify": "^3.4.11",
"react-copy-to-clipboard": "^5.1.1", "react-copy-to-clipboard": "^5.1.1",
"react-inspector": "^9.0.0", "react-inspector": "^9.0.0",

View File

@@ -35,14 +35,16 @@ export default function ConfigBlock({
} }
const actions = ( 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()}> <div className="config-block-actions" onClick={(e: MouseEvent) => e.stopPropagation()}>
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} /> <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip> </Tooltip>
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button <Button
size="small" size="small"
icon={<DownloadOutlined />} icon={<DownloadOutlined />}
aria-label={t('download')}
onClick={() => FileManager.downloadTextFile(text, fileName)} onClick={() => FileManager.downloadTextFile(text, fileName)}
/> />
</Tooltip> </Tooltip>
@@ -54,7 +56,7 @@ export default function ConfigBlock({
content={<QrPanel value={text} remark={qrRemark || label} size={220} />} content={<QrPanel value={text} remark={qrRemark || label} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} /> <Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip> </Tooltip>
</Popover> </Popover>
)} )}

View File

@@ -1,4 +1,5 @@
import { useEffect, useMemo, useRef, useState } from 'react'; import { useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { CloseCircleFilled } from '@ant-design/icons'; import { CloseCircleFilled } from '@ant-design/icons';
import { DatePicker } from 'antd'; import { DatePicker } from 'antd';
import dayjs from 'dayjs'; import dayjs from 'dayjs';
@@ -53,6 +54,7 @@ export default function DateTimePicker({
placeholder = '', placeholder = '',
disabled = false, disabled = false,
}: DateTimePickerProps) { }: DateTimePickerProps) {
const { t } = useTranslation();
const { datepicker } = useDatepicker(); const { datepicker } = useDatepicker();
const { isDark, isUltra } = useTheme(); const { isDark, isUltra } = useTheme();
const jalaliRef = useRef<HTMLDivElement>(null); const jalaliRef = useRef<HTMLDivElement>(null);
@@ -100,7 +102,7 @@ export default function DateTimePicker({
<button <button
type="button" type="button"
className="jdp-clear" className="jdp-clear"
aria-label="clear" aria-label={t('clear')}
onMouseDown={(e) => e.preventDefault()} onMouseDown={(e) => e.preventDefault()}
onClick={(e) => { onClick={(e) => {
e.stopPropagation(); e.stopPropagation();

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react'; import { useEffect, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Button, Input, Space } from 'antd'; import { Button, Input, Space } from 'antd';
import { MinusOutlined, PlusOutlined } from '@ant-design/icons'; 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) { export default function HeaderMapEditor({ mode, value, onChange }: HeaderMapEditorProps) {
const { t } = useTranslation();
// Local state holds rows including blanks. Without it, addRow() would // Local state holds rows including blanks. Without it, addRow() would
// append a {name:'', value:''} that rowsToMap immediately filters out // append a {name:'', value:''} that rowsToMap immediately filters out
// before reaching the form, so the new row would never reach UI. The // 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" placeholder="Value"
onChange={(e) => setRow(idx, { value: e.target.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> </Space.Compact>
))} ))}
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}> <Button size="small" type="primary" icon={<PlusOutlined />} onClick={addRow}>

View File

@@ -1,4 +1,5 @@
import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react'; import { forwardRef, useEffect, useImperativeHandle, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { EditorView, basicSetup } from 'codemirror'; import { EditorView, basicSetup } from 'codemirror';
import { EditorState, Compartment } from '@codemirror/state'; import { EditorState, Compartment } from '@codemirror/state';
import { json, jsonParseLinter } from '@codemirror/lang-json'; import { json, jsonParseLinter } from '@codemirror/lang-json';
@@ -92,6 +93,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
const onChangeRef = useRef(onChange); const onChangeRef = useRef(onChange);
const valueRef = useRef(value); const valueRef = useRef(value);
const { isDark, isUltra } = useTheme(); const { isDark, isUltra } = useTheme();
const { t } = useTranslation();
useEffect(() => { useEffect(() => {
onChangeRef.current = onChange; onChangeRef.current = onChange;
@@ -173,7 +175,7 @@ const JsonEditor = forwardRef<JsonEditorHandle, JsonEditorProps>(function JsonEd
}); });
}, [readOnly]); }, [readOnly]);
return <div ref={hostRef} className="json-editor-host" />; return <div ref={hostRef} className="json-editor-host" aria-label={t('jsonEditor')} />;
}); });
export default JsonEditor; export default JsonEditor;

View File

@@ -55,7 +55,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
title={t('pages.hosts.remarkVars.title')} title={t('pages.hosts.remarkVars.title')}
> >
<Tooltip 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> </Tooltip>
</Popover> </Popover>
} }

View File

@@ -2,6 +2,7 @@ import { Tag, Tooltip, Typography } from 'antd';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables'; import { REMARK_VARIABLES, REMARK_VAR_GROUPS, wrapToken } from '@/lib/remark/remarkVariables';
import { activateOnKey } from '@/utils/a11y';
interface RemarkVarPickerProps { interface RemarkVarPickerProps {
/** Called with the bare token (e.g. "EMAIL") when a chip is clicked. */ /** 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) => ( {REMARK_VARIABLES.filter((v) => v.group === group).map((v) => (
<Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}> <Tooltip key={v.token} title={t(`pages.hosts.remarkVars.desc${v.token}`)}>
<Tag <Tag
role="button"
tabIndex={0}
onClick={() => onPick(v.token)} onClick={() => onPick(v.token)}
onKeyDown={activateOnKey(() => onPick(v.token))}
style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }} style={{ cursor: 'pointer', margin: 0, fontFamily: 'monospace' }}
> >
{wrapToken(v.token)} {wrapToken(v.token)}

View File

@@ -1,4 +1,5 @@
import type { CSSProperties, ReactNode } from 'react'; import type { CSSProperties, ReactNode } from 'react';
import { activateOnKey } from '@/utils/a11y';
import './InputAddon.css'; import './InputAddon.css';
interface InputAddonProps { interface InputAddonProps {
@@ -6,14 +7,19 @@ interface InputAddonProps {
className?: string; className?: string;
style?: CSSProperties; style?: CSSProperties;
onClick?: () => void; onClick?: () => void;
ariaLabel?: string;
} }
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) { export default function InputAddon({ children, className = '', style, onClick, ariaLabel }: InputAddonProps) {
return ( return (
<span <span
className={`input-addon ${className}`.trim()} className={`input-addon ${className}`.trim()}
style={style} style={style}
onClick={onClick} onClick={onClick}
role={onClick ? 'button' : undefined}
tabIndex={onClick ? 0 : undefined}
aria-label={onClick ? ariaLabel : undefined}
onKeyDown={onClick ? activateOnKey(onClick) : undefined}
> >
{children} {children}
</span> </span>

View File

@@ -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 { Col, Row } from 'antd';
import './SettingListItem.css'; import './SettingListItem.css';
@@ -18,17 +18,22 @@ export default function SettingListItem({
control, control,
}: SettingListItemProps) { }: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px'; 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 ( return (
<div className="setting-list-item" style={{ padding }}> <div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}> <Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<div className="setting-list-meta"> <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>} {description && <div className="setting-list-description">{description}</div>}
</div> </div>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
{control ?? children} {labelledNode}
</Col> </Col>
</Row> </Row>
</div> </div>

View File

@@ -1,4 +1,5 @@
import { useRef, useEffect } from 'react'; import { useRef, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { Tag } from 'antd'; import { Tag } from 'antd';
interface Props { interface Props {
@@ -10,11 +11,12 @@ interface Props {
} }
function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) { function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
const { t } = useTranslation();
const ref = useRef<HTMLInputElement>(null); const ref = useRef<HTMLInputElement>(null);
useEffect(() => { useEffect(() => {
if (ref.current) ref.current.indeterminate = indeterminate; if (ref.current) ref.current.indeterminate = 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) { export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {

View File

@@ -181,8 +181,18 @@ export default function Sparkline({
const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR; const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
const maxColor = extrema?.maxColor ?? DEFAULT_MAX_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 ( return (
<div className="sparkline-container"> <div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
{extremaPoints && ( {extremaPoints && (
<div className="sparkline-extrema" aria-hidden="true"> <div className="sparkline-extrema" aria-hidden="true">
<span className="extrema-item" style={{ color: maxColor }}> <span className="extrema-item" style={{ color: maxColor }}>

View File

@@ -370,7 +370,7 @@ export default function AppSidebar() {
<button <button
className="drawer-handle" className="drawer-handle"
type="button" type="button"
aria-label={t('menu.dashboard')} aria-label={t('menu.openMenu')}
onClick={() => setDrawerOpen(true)} onClick={() => setDrawerOpen(true)}
> >
<MenuOutlined /> <MenuOutlined />

View File

@@ -34,7 +34,12 @@ export default function SniffingFields({ name, form, enableLabel }: SniffingFiel
{enabled && ( {enabled && (
<> <>
<Form.Item name={[...name, 'destOverride']} wrapperCol={{ md: { span: 14, offset: 8 } }}> <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>
<Form.Item <Form.Item
label={t('pages.inbounds.sniffingMetadataOnly')} label={t('pages.inbounds.sniffingMetadataOnly')}

View File

@@ -3,6 +3,8 @@ import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import type { NamePath } from 'antd/es/form/interface'; import type { NamePath } from 'antd/es/form/interface';
import { activateOnKey } from '@/utils/a11y';
// Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each // Editor for sockopt.customSockopt — a list of raw setsockopt() options. Each
// entry is rendered as a titled group of labeled fields (system / level / opt / // 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 // type / value) instead of one cramped inline row, so it reads like the rest of
@@ -49,7 +51,11 @@ export default function CustomSockoptList({
<DeleteOutlined <DeleteOutlined
className="danger-icon" className="danger-icon"
style={{ marginInlineStart: 8 }} style={{ marginInlineStart: 8 }}
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)} onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/> />
</Divider> </Divider>
<Form.Item label="System" name={[field.name, 'system']}> <Form.Item label="System" name={[field.name, 'system']}>

View File

@@ -1,10 +1,12 @@
import { useEffect, useRef } from 'react'; import { useEffect, useRef } from 'react';
import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd'; import { AutoComplete, Button, Divider, Form, Input, InputNumber, Select, Space, Switch } from 'antd';
import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { useTranslation } from 'react-i18next';
import type { FormInstance } from 'antd/es/form'; import type { FormInstance } from 'antd/es/form';
import type { NamePath } from 'antd/es/form/interface'; import type { NamePath } from 'antd/es/form/interface';
import { RandomUtil } from '@/utils'; import { RandomUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives'; import { OutboundProtocols, UTLS_FINGERPRINT } from '@/schemas/primitives';
const UTLS_FINGERPRINT_OPTIONS = Object.values(UTLS_FINGERPRINT).map((value) => ({ value, label: value })); 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 }) { function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormInstance }) {
const { t } = useTranslation();
return ( return (
<Form.List name={[...base, 'tcp']}> <Form.List name={[...base, 'tcp']}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
@@ -233,6 +236,7 @@ function TcpMasksList({ base, form }: { base: (string | number)[]; form: FormIns
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })} onClick={() => add({ type: 'fragment', settings: defaultTcpMaskSettings('fragment') })}
/> />
</Form.Item> </Form.Item>
@@ -265,12 +269,20 @@ function TcpMaskItem({
// type change). All Form.Item `name=` use RELATIVE paths within the // type change). All Form.Item `name=` use RELATIVE paths within the
// outer Form.List context. // outer Form.List context.
const absolutePath = [...listPath, fieldName]; const absolutePath = [...listPath, fieldName];
const { t } = useTranslation();
return ( return (
<div> <div>
<Divider style={{ margin: 0 }}> <Divider style={{ margin: 0 }}>
TCP Mask {displayIndex} 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> </Divider>
<Form.Item label="Type" name={[fieldName, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
@@ -415,12 +427,13 @@ function FragmentRangeList({
validator?: (rule: unknown, value: unknown) => Promise<void>; validator?: (rule: unknown, value: unknown) => Promise<void>;
minItems?: number; minItems?: number;
}) { }) {
const { t } = useTranslation();
return ( return (
<Form.List name={listName}> <Form.List name={listName}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<> <>
<Form.Item label={label}> <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> </Form.Item>
{fields.map((field, idx) => ( {fields.map((field, idx) => (
<Form.Item <Form.Item
@@ -432,7 +445,16 @@ function FragmentRangeList({
<Input <Input
placeholder={placeholder} placeholder={placeholder}
addonAfter={fields.length > minItems 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} : null}
/> />
</Form.Item> </Form.Item>
@@ -475,6 +497,7 @@ function HeaderCustomGroups({
form: FormInstance; form: FormInstance;
absoluteSettingsPath: (string | number)[]; absoluteSettingsPath: (string | number)[];
}) { }) {
const { t } = useTranslation();
return ( return (
<> <>
{(['clients', 'servers'] as const).map((groupKey) => ( {(['clients', 'servers'] as const).map((groupKey) => (
@@ -486,6 +509,7 @@ function HeaderCustomGroups({
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addGroup([defaultClientServerItem()])} onClick={() => addGroup([defaultClientServerItem()])}
/> />
</Form.Item> </Form.Item>
@@ -493,7 +517,14 @@ function HeaderCustomGroups({
<div key={group.key}> <div key={group.key}>
<Divider style={{ margin: 0 }}> <Divider style={{ margin: 0 }}>
{groupKey === 'clients' ? 'Clients' : 'Servers'} Group {gi + 1} {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> </Divider>
<Form.List name={[group.name]}> <Form.List name={[group.name]}>
{(items, { add: addItem, remove: removeItem }) => ( {(items, { add: addItem, remove: removeItem }) => (
@@ -502,6 +533,7 @@ function HeaderCustomGroups({
<Button <Button
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addItem(defaultClientServerItem())} onClick={() => addItem(defaultClientServerItem())}
/> />
</Form.Item> </Form.Item>
@@ -531,6 +563,7 @@ function HeaderCustomGroups({
function UdpMasksList({ function UdpMasksList({
base, form, isHysteria, isWireguard, network, base, form, isHysteria, isWireguard, network,
}: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) { }: { base: (string | number)[]; form: FormInstance; isHysteria: boolean; isWireguard: boolean; network: string }) {
const { t } = useTranslation();
return ( return (
<Form.List name={[...base, 'udp']}> <Form.List name={[...base, 'udp']}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
@@ -540,6 +573,7 @@ function UdpMasksList({
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => { onClick={() => {
const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy'; const def = isHysteria || isWireguard ? 'salamander' : 'mkcp-legacy';
add({ type: def, settings: defaultUdpMaskSettings(def) }); add({ type: def, settings: defaultUdpMaskSettings(def) });
@@ -578,6 +612,7 @@ function UdpMaskItem({
onRemove: () => void; onRemove: () => void;
}) { }) {
const absolutePath = [...listPath, fieldName]; const absolutePath = [...listPath, fieldName];
const { t } = useTranslation();
const onTypeChange = (v: string) => { const onTypeChange = (v: string) => {
form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v)); form.setFieldValue([...absolutePath, 'settings'], defaultUdpMaskSettings(v));
@@ -605,7 +640,14 @@ function UdpMaskItem({
<div> <div>
<Divider style={{ margin: 0 }}> <Divider style={{ margin: 0 }}>
UDP Mask {displayIndex} 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> </Divider>
<Form.Item label="Type" name={[fieldName, 'type']}> <Form.Item label="Type" name={[fieldName, 'type']}>
@@ -735,6 +777,7 @@ function SalamanderUdpMaskSettings({
form: FormInstance; form: FormInstance;
absolutePath: (string | number)[]; absolutePath: (string | number)[];
}) { }) {
const { t } = useTranslation();
const packetSizePath = [...absolutePath, 'settings', 'packetSize']; const packetSizePath = [...absolutePath, 'settings', 'packetSize'];
const packetSize = Form.useWatch(packetSizePath, { form, preserve: true }); const packetSize = Form.useWatch(packetSizePath, { form, preserve: true });
const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander'; const mode = typeof packetSize === 'string' && packetSize.trim() !== '' ? 'gecko' : 'salamander';
@@ -776,6 +819,7 @@ function SalamanderUdpMaskSettings({
</Form.Item> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue( onClick={() => form.setFieldValue(
[...absolutePath, 'settings', 'password'], [...absolutePath, 'settings', 'password'],
RandomUtil.randomLowerAndNum(16), RandomUtil.randomLowerAndNum(16),
@@ -840,6 +884,7 @@ function UdpHeaderCustom({
form: FormInstance; form: FormInstance;
absoluteSettingsPath: (string | number)[]; absoluteSettingsPath: (string | number)[];
}) { }) {
const { t } = useTranslation();
return ( return (
<> <>
{(['client', 'server'] as const).map((groupKey) => ( {(['client', 'server'] as const).map((groupKey) => (
@@ -851,6 +896,7 @@ function UdpHeaderCustom({
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultUdpClientServerItem())} onClick={() => add(defaultUdpClientServerItem())}
/> />
</Form.Item> </Form.Item>
@@ -858,7 +904,14 @@ function UdpHeaderCustom({
<div key={item.key}> <div key={item.key}>
<Divider style={{ margin: 0 }}> <Divider style={{ margin: 0 }}>
{groupKey === 'client' ? 'Client' : 'Server'} {ci + 1} {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> </Divider>
<ItemEditor <ItemEditor
fieldName={item.name} fieldName={item.name}
@@ -883,6 +936,7 @@ function NoiseItems({
form: FormInstance; form: FormInstance;
absoluteSettingsPath: (string | number)[]; absoluteSettingsPath: (string | number)[];
}) { }) {
const { t } = useTranslation();
return ( return (
<> <>
<Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}> <Form.Item label="Reset" name={[udpFieldName, 'settings', 'reset']}>
@@ -896,6 +950,7 @@ function NoiseItems({
type="primary" type="primary"
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add(defaultNoiseItem())} onClick={() => add(defaultNoiseItem())}
/> />
</Form.Item> </Form.Item>
@@ -903,7 +958,14 @@ function NoiseItems({
<div key={item.key}> <div key={item.key}>
<Divider style={{ margin: 0 }}> <Divider style={{ margin: 0 }}>
Noise {ni + 1} 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> </Divider>
<ItemEditor <ItemEditor
fieldName={item.name} fieldName={item.name}
@@ -930,6 +992,7 @@ function ItemEditor({
delayMode?: 'number' | 'string'; delayMode?: 'number' | 'string';
onRemove?: () => void; onRemove?: () => void;
}) { }) {
const { t } = useTranslation();
const onTypeChange = (v: string) => { const onTypeChange = (v: string) => {
if (v === 'base64') { if (v === 'base64') {
form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64()); form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64());
@@ -1005,6 +1068,7 @@ function ItemEditor({
</Form.Item> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())} onClick={() => form.setFieldValue([...absoluteItemPath, 'packet'], RandomUtil.randomBase64())}
/> />
</Space.Compact> </Space.Compact>

View File

@@ -1,4 +1,5 @@
import { useMemo } from 'react'; import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { ConfigProvider, Layout } from 'antd'; import { ConfigProvider, Layout } from 'antd';
import SwaggerUI from 'swagger-ui-react'; import SwaggerUI from 'swagger-ui-react';
import 'swagger-ui-react/swagger-ui.css'; import 'swagger-ui-react/swagger-ui.css';
@@ -12,6 +13,7 @@ const openApiUrl = `${basePath}panel/api/openapi.json`;
export default function ApiDocsPage() { export default function ApiDocsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { t } = useTranslation();
const pageClass = useMemo(() => { const pageClass = useMemo(() => {
const classes = ['api-docs-page']; const classes = ['api-docs-page'];
@@ -27,7 +29,7 @@ export default function ApiDocsPage() {
<Layout className="content-shell"> <Layout className="content-shell">
<Layout.Content className="content-area"> <Layout.Content className="content-area">
<div className="docs-wrapper"> <div className="docs-wrapper" role="region" aria-label={t('menu.apiDocs')}>
<SwaggerUI <SwaggerUI
url={openApiUrl} url={openApiUrl}
docExpansion="list" docExpansion="list"

View File

@@ -280,6 +280,7 @@ export default function ClientBulkAddModal({
style={{ flex: 1 }} style={{ flex: 1 }}
/> />
<Button <Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))} onClick={() => update('subId', RandomUtil.randomLowerAndNum(16))}
/> />

View File

@@ -582,7 +582,7 @@ export default function ClientFormModal({
onChange={(e) => update('email', e.target.value)} onChange={(e) => update('email', e.target.value)}
/> />
{!isEdit && ( {!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> </Space.Compact>
</Form.Item> </Form.Item>
@@ -603,7 +603,7 @@ export default function ClientFormModal({
onChange={(v) => update('limitIp', Number(v) || 0)} /> onChange={(v) => update('limitIp', Number(v) || 0)} />
{isEdit && ( {isEdit && (
<Tooltip title={t('pages.clients.ipLog')}> <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 : ''} {clientIps.length > 0 ? clientIps.length : ''}
</Button> </Button>
</Tooltip> </Tooltip>
@@ -717,7 +717,7 @@ export default function ClientFormModal({
</Form.Item> </Form.Item>
<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> <span style={{ marginLeft: 8 }}>{t('enable')}</span>
</Form.Item> </Form.Item>
</> </>
@@ -731,28 +731,28 @@ export default function ClientFormModal({
<Form.Item label={t('pages.clients.uuid')}> <Form.Item label={t('pages.clients.uuid')}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input value={form.uuid} style={{ flex: 1 }} onChange={(e) => update('uuid', e.target.value)} /> <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> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.clients.password')}> <Form.Item label={t('pages.clients.password')}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input value={form.password} style={{ flex: 1 }} onChange={(e) => update('password', e.target.value)} /> <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> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.clients.subId')}> <Form.Item label={t('pages.clients.subId')}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input value={form.subId} style={{ flex: 1 }} onChange={(e) => update('subId', e.target.value)} /> <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> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.clients.hysteriaAuth')}> <Form.Item label={t('pages.clients.hysteriaAuth')}>
<Space.Compact style={{ display: 'flex' }}> <Space.Compact style={{ display: 'flex' }}>
<Input value={form.auth} style={{ flex: 1 }} onChange={(e) => update('auth', e.target.value)} /> <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> </Space.Compact>
</Form.Item> </Form.Item>
@@ -790,7 +790,7 @@ export default function ClientFormModal({
update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : ''); update('wgPublicKey', priv ? Wireguard.generateKeypair(priv).publicKey : '');
}} }}
/> />
<Button icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} /> <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenerateWireguardKeys} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.clients.wireguardPublicKey')}> <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 }}> <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input <Input
value={row.value} value={row.value}
aria-label="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)} onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://" placeholder="vless:// · vmess:// · trojan:// · ss:// · hysteria2:// · wireguard://"
/> />
<Tooltip title={t('delete')}> <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> </Tooltip>
</div> </div>
))} ))}
@@ -851,11 +852,12 @@ export default function ClientFormModal({
<div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}> <div key={row.key} style={{ display: 'flex', gap: 8, marginBottom: 8 }}>
<Input <Input
value={row.value} value={row.value}
aria-label="https://provider.example/sub/…"
onChange={(e) => updateExternalLinkRow(row.key, e.target.value)} onChange={(e) => updateExternalLinkRow(row.key, e.target.value)}
placeholder="https://provider.example/sub/…" placeholder="https://provider.example/sub/…"
/> />
<Tooltip title={t('delete')}> <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> </Tooltip>
</div> </div>
))} ))}

View File

@@ -220,7 +220,7 @@ export default function ClientInfoModal({
<td> <td>
<Tag className="info-large-tag">{client.subId || '-'}</Tag> <Tag className="info-large-tag">{client.subId || '-'}</Tag>
{client.subId && ( {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> </td>
</tr> </tr>
@@ -229,7 +229,7 @@ export default function ClientInfoModal({
<td>{t('pages.clients.uuid')}</td> <td>{t('pages.clients.uuid')}</td>
<td> <td>
<Tag className="info-large-tag">{client.uuid}</Tag> <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> </td>
</tr> </tr>
)} )}
@@ -238,7 +238,7 @@ export default function ClientInfoModal({
<td>{t('password')}</td> <td>{t('password')}</td>
<td> <td>
<Tag className="info-large-tag">{client.password}</Tag> <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> </td>
</tr> </tr>
)} )}
@@ -247,7 +247,7 @@ export default function ClientInfoModal({
<td>{t('pages.clients.auth')}</td> <td>{t('pages.clients.auth')}</td>
<td> <td>
<Tag className="info-large-tag">{client.auth}</Tag> <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> </td>
</tr> </tr>
)} )}
@@ -295,7 +295,7 @@ export default function ClientInfoModal({
<tr> <tr>
<td>{t('pages.inbounds.IPLimitlog')}</td> <td>{t('pages.inbounds.IPLimitlog')}</td>
<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 : ''} {clientIps.length > 0 ? clientIps.length : ''}
</Button> </Button>
</td> </td>
@@ -375,7 +375,7 @@ export default function ClientInfoModal({
</a> </a>
<div className="link-row-actions"> <div className="link-row-actions">
<Tooltip title={t('copy')}> <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> </Tooltip>
<Popover <Popover
trigger="click" trigger="click"
@@ -384,7 +384,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subLink} remark={`${client.email}${t('subscription.title')}`} size={220} />} content={<QrPanel value={subLink} remark={`${client.email}${t('subscription.title')}`} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} /> <Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip> </Tooltip>
</Popover> </Popover>
</div> </div>
@@ -403,7 +403,7 @@ export default function ClientInfoModal({
</a> </a>
<div className="link-row-actions"> <div className="link-row-actions">
<Tooltip title={t('copy')}> <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> </Tooltip>
<Popover <Popover
trigger="click" trigger="click"
@@ -412,7 +412,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />} content={<QrPanel value={subJsonLink} remark={`${client.email} — JSON`} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} /> <Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip> </Tooltip>
</Popover> </Popover>
</div> </div>
@@ -434,7 +434,7 @@ export default function ClientInfoModal({
</a> </a>
<div className="link-row-actions"> <div className="link-row-actions">
<Tooltip title={t('copy')}> <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> </Tooltip>
<Popover <Popover
trigger="click" trigger="click"
@@ -443,7 +443,7 @@ export default function ClientInfoModal({
content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />} content={<QrPanel value={subClashLink} remark={`${client.email} — Clash / Mihomo`} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} /> <Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip> </Tooltip>
</Popover> </Popover>
</div> </div>
@@ -469,7 +469,7 @@ export default function ClientInfoModal({
<span className="link-row-title" title={rowTitle}>{rowTitle}</span> <span className="link-row-title" title={rowTitle}>{rowTitle}</span>
<div className="link-row-actions"> <div className="link-row-actions">
<Tooltip title={t('copy')}> <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> </Tooltip>
{canQr && ( {canQr && (
<Popover <Popover
@@ -479,7 +479,7 @@ export default function ClientInfoModal({
content={<QrPanel value={link} remark={qrRemark} size={220} />} content={<QrPanel value={link} remark={qrRemark} size={220} />}
> >
<Tooltip title={t('pages.clients.qrCode')}> <Tooltip title={t('pages.clients.qrCode')}>
<Button size="small" icon={<QrcodeOutlined />} /> <Button size="small" icon={<QrcodeOutlined />} aria-label={t('pages.clients.qrCode')} />
</Tooltip> </Tooltip>
</Popover> </Popover>
)} )}

View File

@@ -50,6 +50,7 @@ import {
UsergroupAddOutlined, UsergroupAddOutlined,
UsergroupDeleteOutlined, UsergroupDeleteOutlined,
} from '@ant-design/icons'; } from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { formatInboundLabel } from '@/lib/inbounds/label'; import { formatInboundLabel } from '@/lib/inbounds/label';
@@ -752,19 +753,19 @@ export default function ClientsPage() {
render: (_v, record) => ( render: (_v, record) => (
<Space size={4}> <Space size={4}>
<Tooltip title={t('pages.clients.qrCode')}> <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>
<Tooltip title={t('pages.clients.clientInfo')}> <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>
<Tooltip title={t('pages.inbounds.resetTraffic')}> <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>
<Tooltip title={t('edit')}> <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>
<Tooltip title={t('delete')}> <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> </Tooltip>
</Space> </Space>
), ),
@@ -1039,7 +1040,7 @@ export default function ClientsPage() {
title={ title={
<div className="card-toolbar"> <div className="card-toolbar">
{selectedRowKeys.length === 0 ? ( {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')} {!isMobile && t('pages.clients.addClients')}
</Button> </Button>
) : ( ) : (
@@ -1154,7 +1155,7 @@ export default function ClientsPage() {
], ],
}} }}
> >
<Button icon={<MoreOutlined />}> <Button icon={<MoreOutlined />} aria-label={t('more')}>
{!isMobile && t('more')} {!isMobile && t('more')}
</Button> </Button>
</Dropdown> </Dropdown>
@@ -1164,6 +1165,7 @@ export default function ClientsPage() {
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={onBulkDelete} onClick={onBulkDelete}
style={{ marginInlineStart: 'auto' }} style={{ marginInlineStart: 'auto' }}
aria-label={t('delete')}
> >
{!isMobile && t('delete')} {!isMobile && t('delete')}
</Button> </Button>
@@ -1180,6 +1182,7 @@ export default function ClientsPage() {
prefix={<SearchOutlined />} prefix={<SearchOutlined />}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
style={{ maxWidth: 320 }} style={{ maxWidth: 320 }}
aria-label={t('search')}
/> />
<Badge count={activeCount} size="small" offset={[-4, 4]}> <Badge count={activeCount} size="small" offset={[-4, 4]}>
<Button <Button
@@ -1187,12 +1190,14 @@ export default function ClientsPage() {
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
onClick={() => setFilterDrawerOpen(true)} onClick={() => setFilterDrawerOpen(true)}
type={activeCount > 0 ? 'primary' : 'default'} type={activeCount > 0 ? 'primary' : 'default'}
aria-label={t('filter')}
> >
{!isMobile && t('filter')} {!isMobile && t('filter')}
</Button> </Button>
</Badge> </Badge>
<Select <Select
value={sortValueFor(sortColumn, sortOrder)} value={sortValueFor(sortColumn, sortOrder)}
aria-label={t('sort')}
size={isMobile ? 'small' : 'middle'} size={isMobile ? 'small' : 'middle'}
suffixIcon={<SortAscendingOutlined />} suffixIcon={<SortAscendingOutlined />}
style={{ minWidth: isMobile ? 130 : 200 }} style={{ minWidth: isMobile ? 130 : 200 }}
@@ -1365,9 +1370,16 @@ export default function ClientsPage() {
<span className="tag-name">{row.email}</span> <span className="tag-name">{row.email}</span>
{bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>} {bucket === 'depleted' && <Tag color="red" className="status-tag">{t('depleted')}</Tag>}
{bucket === 'expiring' && <Tag color="orange" className="status-tag">{t('depletingSoon')}</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')}> <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> </Tooltip>
<Switch <Switch
checked={!!row.enable} 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> </Dropdown>
</div> </div>
</div> </div>

View File

@@ -111,7 +111,7 @@ export default function SubLinksModal({
key: 'actions', key: 'actions',
width: 64, width: 64,
render: (_v, row) => ( 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'))} />
), ),
}, },
]; ];

View File

@@ -404,10 +404,10 @@ export default function GroupsPage() {
render: (_v, row) => ( render: (_v, row) => (
<Space size={4}> <Space size={4}>
<Dropdown trigger={['click']} menu={{ items: rowActions(row) }}> <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> </Dropdown>
<Tooltip title={t('pages.groups.rename')}> <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> </Tooltip>
</Space> </Space>
), ),
@@ -522,7 +522,7 @@ export default function GroupsPage() {
hoverable hoverable
title={ title={
<div className="card-toolbar"> <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')} {!isMobile && t('pages.groups.addGroup')}
</Button> </Button>
</div> </div>

View File

@@ -84,16 +84,16 @@ export default function HostList(props: HostListProps) {
return ( return (
<Space size={2}> <Space size={2}>
<Tooltip title={t('pages.hosts.moveUp')}> <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>
<Tooltip title={t('pages.hosts.moveDown')}> <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>
<Tooltip title={t('edit')}> <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>
<Tooltip title={t('delete')}> <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> </Tooltip>
</Space> </Space>
); );

View File

@@ -164,6 +164,7 @@ export default function AttachClientsModal({
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap> <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search <Input.Search
allowClear allowClear
aria-label={t('search')}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -196,6 +197,7 @@ export default function AttachClientsModal({
) : ( ) : (
<Select <Select
mode="multiple" mode="multiple"
aria-label={t('pages.inbounds.attachClientsTargets')}
style={{ width: '100%' }} style={{ width: '100%' }}
value={targetIds} value={targetIds}
onChange={setTargetIds} onChange={setTargetIds}

View File

@@ -188,6 +188,7 @@ export default function AttachExistingClientsModal({
<Space wrap> <Space wrap>
<Input.Search <Input.Search
allowClear allowClear
aria-label={t('search')}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}
@@ -196,6 +197,7 @@ export default function AttachExistingClientsModal({
{groupOptions.length > 0 && ( {groupOptions.length > 0 && (
<Select <Select
allowClear allowClear
aria-label={t('pages.clients.group')}
value={groupFilter} value={groupFilter}
onChange={(v) => setGroupFilter(v)} onChange={(v) => setGroupFilter(v)}
options={groupOptions} options={groupOptions}

View File

@@ -152,6 +152,7 @@ export default function DetachClientsModal({
<Space style={{ width: '100%', justifyContent: 'space-between' }} wrap> <Space style={{ width: '100%', justifyContent: 'space-between' }} wrap>
<Input.Search <Input.Search
allowClear allowClear
aria-label={t('search')}
value={search} value={search}
onChange={(e) => setSearch(e.target.value)} onChange={(e) => setSearch(e.target.value)}
placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')} placeholder={t('pages.inbounds.attachClientsSearchPlaceholder')}

View File

@@ -66,6 +66,7 @@ export default function FallbacksCard({
> >
<Space.Compact block style={{ marginBottom: 8 }}> <Space.Compact block style={{ marginBottom: 8 }}>
<Select <Select
aria-label={t('pages.inbounds.fallbacks.pickInbound')}
value={record.childId} value={record.childId}
options={fallbackChildOptions} options={fallbackChildOptions}
placeholder={t('pages.inbounds.fallbacks.pickInbound') || 'Pick an inbound'} 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 })} onChange={(v) => updateFallback(record.rowKey, { childId: v ?? null })}
/> />
<Button <Button
aria-label={t('pages.inbounds.form.moveUp')}
disabled={idx === 0} disabled={idx === 0}
onClick={() => moveFallback(idx, -1)} onClick={() => moveFallback(idx, -1)}
title={t('pages.inbounds.form.moveUp')} title={t('pages.inbounds.form.moveUp')}
icon={<ArrowUpOutlined />} icon={<ArrowUpOutlined />}
/> />
<Button <Button
aria-label={t('pages.inbounds.form.moveDown')}
disabled={idx === fallbacks.length - 1} disabled={idx === fallbacks.length - 1}
onClick={() => moveFallback(idx, 1)} onClick={() => moveFallback(idx, 1)}
title={t('pages.inbounds.form.moveDown')} title={t('pages.inbounds.form.moveDown')}
icon={<ArrowDownOutlined />} icon={<ArrowDownOutlined />}
/> />
<Button danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} /> <Button aria-label={t('delete')} danger onClick={() => removeFallback(idx)} icon={<DeleteOutlined />} />
</Space.Compact> </Space.Compact>
<Row gutter={[8, 8]}> <Row gutter={[8, 8]}>
<Col xs={24} sm={12}> <Col xs={24} sm={12}>

View File

@@ -33,7 +33,7 @@ export default function AccountsList() {
<Form.Item name={[field.name, 'pass']} noStyle> <Form.Item name={[field.name, 'pass']} noStyle>
<Input placeholder={t('password')} /> <Input placeholder={t('password')} />
</Form.Item> </Form.Item>
<Button onClick={() => remove(field.name)}> <Button aria-label={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</Button> </Button>
</Space.Compact> </Space.Compact>

View File

@@ -27,6 +27,7 @@ export default function MtprotoFields() {
<Input readOnly style={{ width: 'calc(100% - 32px)' }} /> <Input readOnly style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </Form.Item>
<Button <Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => { onClick={() => {
const domain = form.getFieldValue(['settings', 'fakeTlsDomain']); const domain = form.getFieldValue(['settings', 'fakeTlsDomain']);

View File

@@ -33,6 +33,7 @@ export default function ShadowsocksFields({ form, isSSWith2022 }: ShadowsocksFie
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </Form.Item>
<Button <Button
aria-label={t('regenerate')}
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
onClick={() => { onClick={() => {
const method = form.getFieldValue(['settings', 'method']); const method = form.getFieldValue(['settings', 'method']);

View File

@@ -15,7 +15,7 @@ export default function TunFields() {
<Form.List name={['settings', 'gateway']}> <Form.List name={['settings', 'gateway']}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<Form.Item label={t('pages.inbounds.info.gateway')}> <Form.Item label={t('pages.inbounds.info.gateway')}>
<Button size="small" onClick={() => add('')}> <Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{fields.map((field, j) => ( {fields.map((field, j) => (
@@ -23,7 +23,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle> <Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} /> <Input placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} />
</Form.Item> </Form.Item>
<Button size="small" onClick={() => remove(field.name)}> <Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</Button> </Button>
</Space.Compact> </Space.Compact>
@@ -34,7 +34,7 @@ export default function TunFields() {
<Form.List name={['settings', 'dns']}> <Form.List name={['settings', 'dns']}>
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<Form.Item label="DNS"> <Form.Item label="DNS">
<Button size="small" onClick={() => add('')}> <Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{fields.map((field, j) => ( {fields.map((field, j) => (
@@ -42,7 +42,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle> <Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} /> <Input placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} />
</Form.Item> </Form.Item>
<Button size="small" onClick={() => remove(field.name)}> <Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</Button> </Button>
</Space.Compact> </Space.Compact>
@@ -62,7 +62,7 @@ export default function TunFields() {
</Tooltip> </Tooltip>
} }
> >
<Button size="small" onClick={() => add('')}> <Button aria-label={t('add')} size="small" onClick={() => add('')}>
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{fields.map((field, j) => ( {fields.map((field, j) => (
@@ -70,7 +70,7 @@ export default function TunFields() {
<Form.Item name={field.name} noStyle> <Form.Item name={field.name} noStyle>
<Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} /> <Input placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} />
</Form.Item> </Form.Item>
<Button size="small" onClick={() => remove(field.name)}> <Button aria-label={t('remove')} size="small" onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</Button> </Button>
</Space.Compact> </Space.Compact>

View File

@@ -16,7 +16,7 @@ export default function WireguardFields({ wgPubKey, regenInboundWg }: WireguardF
<Form.Item name={['settings', 'secretKey']} noStyle> <Form.Item name={['settings', 'secretKey']} noStyle>
<Input style={{ width: 'calc(100% - 32px)' }} /> <Input style={{ width: 'calc(100% - 32px)' }} />
</Form.Item> </Form.Item>
<Button icon={<ReloadOutlined />} onClick={regenInboundWg} /> <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={regenInboundWg} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.wireguard.publicKey')}> <Form.Item label={t('pages.xray.wireguard.publicKey')}>

View File

@@ -143,7 +143,7 @@ export default function RealityForm({
> >
<Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} /> <Select mode="tags" tokenSeparators={[',']} style={{ flex: 1 }} />
</Form.Item> </Form.Item>
<Button icon={<ReloadOutlined />} onClick={randomizeShortIds} /> <Button aria-label={t('regenerate')} icon={<ReloadOutlined />} onClick={randomizeShortIds} />
</Space.Compact> </Space.Compact>
</Form.Item> </Form.Item>
<Form.Item <Form.Item

View File

@@ -124,6 +124,7 @@ export default function TlsForm({
<> <>
<Form.Item label={t('certificate')}> <Form.Item label={t('certificate')}>
<Button <Button
aria-label={t('add')}
type="primary" type="primary"
size="small" size="small"
onClick={() => add({ onClick={() => add({

View File

@@ -83,7 +83,7 @@ export default function SockoptForm({
return ( return (
<> <>
<Form.Item label="Sockopt"> <Form.Item label="Sockopt">
<Switch checked={on} onChange={toggleSockopt} /> <Switch checked={on} onChange={toggleSockopt} aria-label="Sockopt" />
</Form.Item> </Form.Item>
{on && ( {on && (
<> <>

View File

@@ -4,6 +4,7 @@ import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, Wireguard } from '@/utils'; import { HttpUtil, IntlUtil, SizeFormatter, ColorUtils, Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { Protocols } from '@/schemas/primitives'; import { Protocols } from '@/schemas/primitives';
import { InfinityIcon } from '@/components/ui'; import { InfinityIcon } from '@/components/ui';
import { useDatepicker } from '@/hooks/useDatepicker'; import { useDatepicker } from '@/hooks/useDatepicker';
@@ -336,9 +337,9 @@ export default function InboundInfoModal({
)} )}
</div> </div>
<div className="ip-log-actions"> <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')}> <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> </Tooltip>
</div> </div>
</td> </td>
@@ -394,7 +395,7 @@ export default function InboundInfoModal({
<div className="tg-row"> <div className="tg-row">
<Tag color="blue">{clientSettings.tgId}</Tag> <Tag color="blue">{clientSettings.tgId}</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</div> </div>
</> </>
@@ -408,7 +409,7 @@ export default function InboundInfoModal({
<div className="link-panel-header"> <div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag> <Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</div> </div>
<code className="link-panel-text">{link.link}</code> <code className="link-panel-text">{link.link}</code>
@@ -424,7 +425,7 @@ export default function InboundInfoModal({
<div className="link-panel-header"> <div className="link-panel-header">
<Tag color="green">{t('subscription.title')}</Tag> <Tag color="green">{t('subscription.title')}</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</div> </div>
<a href={subLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subLink}</a> <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"> <div className="link-panel-header">
<Tag color="green">JSON</Tag> <Tag color="green">JSON</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</div> </div>
<a href={subJsonLink} target="_blank" rel="noopener noreferrer" className="link-panel-anchor">{subJsonLink}</a> <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"> <dd className="value-block">
<code className="value-code">{encryptionLabel}</code> <code className="value-code">{encryptionLabel}</code>
<Tooltip title={t('copy')}> <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> </Tooltip>
</dd> </dd>
</div> </div>
@@ -637,7 +638,7 @@ export default function InboundInfoModal({
<dd className="value-block"> <dd className="value-block">
<code className="value-code">{inbound.settings.secret as string}</code> <code className="value-code">{inbound.settings.secret as string}</code>
<Tooltip title={t('copy')}> <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> </Tooltip>
</dd> </dd>
</div> </div>
@@ -688,7 +689,7 @@ export default function InboundInfoModal({
<dd className="value-block"> <dd className="value-block">
<code className="value-code">{links[0].link}</code> <code className="value-code">{links[0].link}</code>
<Tooltip title={t('copy')}> <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> </Tooltip>
</dd> </dd>
</div> </div>
@@ -730,7 +731,7 @@ export default function InboundInfoModal({
<span className="account-sep">:</span> <span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag> <Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
<Space size={4} wrap className="share-buttons"> <Space size={4} wrap className="share-buttons">
<Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}> <Tooltip title={`socks5://${account.user}:${account.pass}@${dbInbound.address}:${dbInbound.port}`}>
@@ -779,7 +780,7 @@ export default function InboundInfoModal({
<span className="account-sep">:</span> <span className="account-sep">:</span>
<Tag className="value-tag">{account.pass}</Tag> <Tag className="value-tag">{account.pass}</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</dd> </dd>
</div> </div>
@@ -845,10 +846,10 @@ export default function InboundInfoModal({
<div className="link-panel-header"> <div className="link-panel-header">
<Tag color="green">{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}</Tag> <Tag color="green">{t('pages.inbounds.info.peerNumberConfig', { n: idx + 1 })}</Tag>
<Tooltip title={t('copy')}> <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>
<Tooltip title={t('download')}> <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> </Tooltip>
</div> </div>
<code className="link-panel-text">{wireguardConfigs[idx]}</code> <code className="link-panel-text">{wireguardConfigs[idx]}</code>
@@ -859,7 +860,7 @@ export default function InboundInfoModal({
<div className="link-panel-header"> <div className="link-panel-header">
<Tag color="green">Peer {idx + 1} link</Tag> <Tag color="green">Peer {idx + 1} link</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</div> </div>
<code className="link-panel-text">{wireguardLinks[idx]}</code> <code className="link-panel-text">{wireguardLinks[idx]}</code>
@@ -878,7 +879,7 @@ export default function InboundInfoModal({
<div className="link-panel-header"> <div className="link-panel-header">
<Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag> <Tag color="green">{link.remark || `Link ${idx + 1}`}</Tag>
<Tooltip title={t('copy')}> <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> </Tooltip>
</div> </div>
<code className="link-panel-text">{link.link}</code> <code className="link-panel-text">{link.link}</code>

View File

@@ -25,6 +25,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { buildRowActionsMenu } from './RowActions'; import { buildRowActionsMenu } from './RowActions';
import { useInboundColumns } from './useInboundColumns'; import { useInboundColumns } from './useInboundColumns';
@@ -160,11 +161,11 @@ export default function InboundList({
hoverable hoverable
title={( title={(
<Space> <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')} {!isMobile && t('pages.inbounds.addInbound')}
</Button> </Button>
<Dropdown trigger={['click']} menu={generalActionsMenu}> <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')} {!isMobile && t('pages.inbounds.generalActions')}
</Button> </Button>
</Dropdown> </Dropdown>
@@ -175,6 +176,7 @@ export default function InboundList({
options={nodeFilterOptions} options={nodeFilterOptions}
popupMatchSelectWidth={false} popupMatchSelectWidth={false}
style={{ minWidth: isMobile ? 90 : 140 }} style={{ minWidth: isMobile ? 90 : 140 }}
aria-label={t('pages.clients.filters.nodes')}
/> />
)} )}
{selectedRowKeys.length > 0 && ( {selectedRowKeys.length > 0 && (
@@ -182,7 +184,7 @@ export default function InboundList({
<Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}> <Tag color="blue" closable onClose={() => setSelectedRowKeys([])} style={{ marginInlineEnd: 0 }}>
{t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })} {t('pages.inbounds.selectedCount', { count: selectedRowKeys.length })}
</Tag> </Tag>
<Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete}> <Button danger icon={<DeleteOutlined />} onClick={handleBulkDelete} aria-label={t('delete')}>
{!isMobile && t('delete')} {!isMobile && t('delete')}
</Button> </Button>
</> </>
@@ -221,9 +223,16 @@ export default function InboundList({
/> />
<span className="card-id">#{record.id}</span> <span className="card-id">#{record.id}</span>
<span className="tag-name">{record.remark}</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')}> <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> </Tooltip>
<Switch <Switch
checked={record.enable} checked={record.enable}
@@ -238,7 +247,7 @@ export default function InboundList({
onClick: ({ key }) => onRowAction({ key: key as RowAction, dbInbound: record }), 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> </Dropdown>
</div> </div>
</div> </div>

View File

@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
const { t } = useTranslation(); const { t } = useTranslation();
return ( return (
<div className="action-buttons"> <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 <Dropdown
trigger={['click']} trigger={['click']}
menu={{ menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
onClick: ({ key }) => onClick(key as RowAction), 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> </Dropdown>
</div> </div>
); );

View File

@@ -4,6 +4,7 @@ import { Button, QRCode, Tag, Tooltip, message } from 'antd';
import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons'; import { CopyOutlined, DownloadOutlined, PictureOutlined } from '@ant-design/icons';
import { ClipboardManager, FileManager } from '@/utils'; import { ClipboardManager, FileManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import './QrPanel.css'; import './QrPanel.css';
interface QrPanelProps { interface QrPanelProps {
@@ -96,21 +97,29 @@ export default function QrPanel({
<div className="qr-panel-header"> <div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag> <Tag color="green" className="qr-remark">{remark}</Tag>
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>
<Button size="small" icon={<CopyOutlined />} onClick={copy} /> <Button size="small" icon={<CopyOutlined />} aria-label={t('copy')} onClick={copy} />
</Tooltip> </Tooltip>
{showQr && ( {showQr && (
<Tooltip title={t('downloadImage') !== 'downloadImage' ? t('downloadImage') : 'Download Image'}> <Tooltip title={t('downloadImage')}>
<Button size="small" icon={<PictureOutlined />} onClick={downloadImage} /> <Button size="small" icon={<PictureOutlined />} aria-label={t('downloadImage')} onClick={downloadImage} />
</Tooltip> </Tooltip>
)} )}
{downloadName && ( {downloadName && (
<Tooltip title={t('download')}> <Tooltip title={t('download')}>
<Button size="small" icon={<DownloadOutlined />} onClick={download} /> <Button size="small" icon={<DownloadOutlined />} aria-label={t('download')} onClick={download} />
</Tooltip> </Tooltip>
)} )}
</div> </div>
{showQr && ( {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')}> <Tooltip title={t('copy')}>
<QRCode <QRCode
className="qr-code" className="qr-code"
@@ -120,7 +129,6 @@ export default function QrPanel({
bordered={false} bordered={false}
color="#000000" color="#000000"
bgColor="#ffffff" bgColor="#ffffff"
onClick={copyImage}
/> />
</Tooltip> </Tooltip>
</div> </div>

View File

@@ -83,7 +83,7 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
{isPostgres ? t('pages.index.exportDatabasePgDesc') : t('pages.index.exportDatabaseDesc')} {isPostgres ? t('pages.index.exportDatabasePgDesc') : t('pages.index.exportDatabaseDesc')}
</div> </div>
</div> </div>
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} /> <Button type="primary" aria-label={t('pages.index.exportDatabase')} onClick={exportDb} icon={<DownloadOutlined />} />
</div> </div>
<div className="backup-item"> <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')} {isPostgres ? t('pages.index.migrationDownloadPgDesc') : t('pages.index.migrationDownloadDesc')}
</div> </div>
</div> </div>
<Button type="primary" onClick={exportMigration} icon={<DownloadOutlined />} /> <Button type="primary" aria-label={t('pages.index.migrationDownload')} onClick={exportMigration} icon={<DownloadOutlined />} />
</div> </div>
<div className="backup-item"> <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')} {isPostgres ? t('pages.index.importDatabasePgDesc') : t('pages.index.importDatabaseDesc')}
</div> </div>
</div> </div>
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} /> <Button type="primary" aria-label={t('pages.index.importDatabase')} onClick={importDb} icon={<UploadOutlined />} />
</div> </div>
</div> </div>
</Modal> </Modal>

View File

@@ -200,6 +200,7 @@ export default function GeodataSection({ active, onBusy, onClose }: GeodataSecti
onChange={(e) => setRow(i, { file: e.target.value })} onChange={(e) => setRow(i, { file: e.target.value })}
/> />
<Button <Button
aria-label={t('delete')}
icon={<DeleteOutlined />} icon={<DeleteOutlined />}
onClick={() => setRows((p) => p.filter((_, j) => j !== i))} onClick={() => setRows((p) => p.filter((_, j) => j !== i))}
/> />

View File

@@ -38,6 +38,7 @@ import {
import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils'; import { HttpUtil, SizeFormatter, TimeFormatter, ClipboardManager, FileManager } from '@/utils';
import { formatPanelVersion } from '@/lib/panel-version'; import { formatPanelVersion } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useStatusQuery } from '@/api/queries/useStatusQuery'; import { useStatusQuery } from '@/api/queries/useStatusQuery';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
@@ -211,15 +212,15 @@ export default function IndexPage() {
title={t('menu.link')} title={t('menu.link')}
hoverable hoverable
actions={[ 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 /> <BarsOutlined />
{!isMobile && <span>{t('pages.index.logs')}</span>} {!isMobile && <span>{t('pages.index.logs')}</span>}
</Space>, </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 /> <ControlOutlined />
{!isMobile && <span>{t('pages.index.config')}</span>} {!isMobile && <span>{t('pages.index.config')}</span>}
</Space>, </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 /> <CloudServerOutlined />
{!isMobile && <span>{t('pages.index.backupTitle')}</span>} {!isMobile && <span>{t('pages.index.backupTitle')}</span>}
</Space>, </Space>,
@@ -243,7 +244,7 @@ export default function IndexPage() {
} }
hoverable hoverable
actions={[ 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 <svg
viewBox="0 0 24 24" viewBox="0 0 24 24"
width="14" width="14"
@@ -259,7 +260,11 @@ export default function IndexPage() {
<Space <Space
key="panel-version" key="panel-version"
className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`} className={`action ${panelUpdateInfo.updateAvailable ? 'action-update' : ''}`}
role="button"
tabIndex={0}
aria-label={t('pages.index.updatePanel')}
onClick={openPanelVersion} onClick={openPanelVersion}
onKeyDown={activateOnKey(openPanelVersion)}
> >
<CloudDownloadOutlined /> <CloudDownloadOutlined />
{!isMobile && ( {!isMobile && (
@@ -282,7 +287,11 @@ export default function IndexPage() {
<Space <Space
className="action" className="action"
key="sys-history" key="sys-history"
role="button"
tabIndex={0}
aria-label={t('pages.index.systemHistoryTitle')}
onClick={() => setSysHistoryOpen(true)} onClick={() => setSysHistoryOpen(true)}
onKeyDown={activateOnKey(() => setSysHistoryOpen(true))}
> >
<AreaChartOutlined /> <AreaChartOutlined />
{!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>} {!isMobile && <span>{t('pages.index.systemHistoryTitle')}</span>}
@@ -290,7 +299,11 @@ export default function IndexPage() {
<Space <Space
className="action" className="action"
key="xray-metrics" key="xray-metrics"
role="button"
tabIndex={0}
aria-label={t('pages.index.xrayMetricsTitle')}
onClick={() => setXrayMetricsOpen(true)} onClick={() => setXrayMetricsOpen(true)}
onKeyDown={activateOnKey(() => setXrayMetricsOpen(true))}
> >
<AreaChartOutlined /> <AreaChartOutlined />
{!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>} {!isMobile && <span>{t('pages.index.xrayMetricsTitle')}</span>}
@@ -397,12 +410,20 @@ export default function IndexPage() {
{showIp ? ( {showIp ? (
<EyeOutlined <EyeOutlined
className="ip-toggle-icon" className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(false)} onClick={() => setShowIp(false)}
onKeyDown={activateOnKey(() => setShowIp(false))}
/> />
) : ( ) : (
<EyeInvisibleOutlined <EyeInvisibleOutlined
className="ip-toggle-icon" className="ip-toggle-icon"
role="button"
tabIndex={0}
aria-label={t('pages.index.toggleIpVisibility')}
onClick={() => setShowIp(true)} onClick={() => setShowIp(true)}
onKeyDown={activateOnKey(() => setShowIp(true))}
/> />
)} )}
</Tooltip> </Tooltip>

View File

@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Modal, Select, Space } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons'; import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils'; import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { parseLogLine } from './logParse'; import { parseLogLine } from './logParse';
import './LogModal.css'; import './LogModal.css';
@@ -71,7 +72,7 @@ export default function LogModal({ open, onClose }: LogModalProps) {
const titleNode = ( const titleNode = (
<> <>
{t('pages.index.logs')} {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> </Checkbox>
</Form.Item> </Form.Item>
<Form.Item className="download-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.Item>
</Form> </Form>

View File

@@ -4,6 +4,7 @@ import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd'
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import type { Status } from '@/models/status'; import type { Status } from '@/models/status';
import GeodataSection from './GeodataSection'; import GeodataSection from './GeodataSection';
import './VersionModal.css'; import './VersionModal.css';
@@ -145,7 +146,11 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
<Tooltip title={t('update')}> <Tooltip title={t('update')}>
<ReloadOutlined <ReloadOutlined
className="reload-icon" className="reload-icon"
role="button"
tabIndex={0}
aria-label={t('update')}
onClick={() => updateGeofile(file)} onClick={() => updateGeofile(file)}
onKeyDown={activateOnKey(() => updateGeofile(file))}
/> />
</Tooltip> </Tooltip>
</div> </div>

View File

@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons'; import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils'; import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { useDatepicker } from '@/hooks/useDatepicker'; import { useDatepicker } from '@/hooks/useDatepicker';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import './XrayLogModal.css'; import './XrayLogModal.css';
@@ -132,7 +133,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
title={ title={
<> <>
{t('pages.index.accessLogs')} {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> </Checkbox>
</Form.Item> </Form.Item>
<Form.Item className="download-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.Item>
</Form> </Form>

View File

@@ -9,6 +9,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import type { Status } from '@/models/status'; import type { Status } from '@/models/status';
import { activateOnKey } from '@/utils/a11y';
import './XrayStatusCard.css'; import './XrayStatusCard.css';
interface XrayStatusCardProps { interface XrayStatusCardProps {
@@ -67,7 +68,7 @@ export default function XrayStatusCard({
<span>{t('pages.index.xrayStatusError')}</span> <span>{t('pages.index.xrayStatusError')}</span>
</Col> </Col>
<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> </Col>
</Row> </Row>
} }
@@ -90,21 +91,21 @@ export default function XrayStatusCard({
// sense when one is configured (unlike IP limit, which no longer needs it) // sense when one is configured (unlike IP limit, which no longer needs it)
...(accessLogEnable ...(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 /> <BarsOutlined />
{!isMobile && <span>{t('pages.index.accessLogs')}</span>} {!isMobile && <span>{t('pages.index.accessLogs')}</span>}
</Space>, </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 /> <PoweroffOutlined />
{!isMobile && <span>{t('pages.index.stopXray')}</span>} {!isMobile && <span>{t('pages.index.stopXray')}</span>}
</Space>, </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 /> <ReloadOutlined />
{!isMobile && <span>{t('pages.index.restartXray')}</span>} {!isMobile && <span>{t('pages.index.restartXray')}</span>}
</Space>, </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 /> <ToolOutlined />
{!isMobile && ( {!isMobile && (
<span> <span>

View File

@@ -35,6 +35,7 @@ import {
import NodeHistoryPanel from './NodeHistoryPanel'; import NodeHistoryPanel from './NodeHistoryPanel';
import type { NodeRecord } from '@/api/queries/useNodesQuery'; import type { NodeRecord } from '@/api/queries/useNodesQuery';
import { isPanelUpdateAvailable } from '@/lib/panel-version'; import { isPanelUpdateAvailable } from '@/lib/panel-version';
import { activateOnKey } from '@/utils/a11y';
import './NodeList.css'; import './NodeList.css';
interface NodeListProps { interface NodeListProps {
@@ -245,18 +246,18 @@ export default function NodeList({
) : ( ) : (
<Space> <Space>
<Tooltip title={t('pages.nodes.probe')}> <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> </Tooltip>
{isUpdateEligible(record) && ( {isUpdateEligible(record) && (
<Tooltip title={t('pages.nodes.updatePanel')}> <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>
)} )}
<Tooltip title={t('edit')}> <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>
<Tooltip title={t('delete')}> <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> </Tooltip>
</Space> </Space>
), ),
@@ -296,9 +297,9 @@ export default function NodeList({
{t('pages.nodes.address')} {t('pages.nodes.address')}
<Tooltip title={t('pages.index.toggleIpVisibility')}> <Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? ( {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> </Tooltip>
</span> </span>
@@ -367,7 +368,7 @@ export default function NodeList({
<span>{record.panelVersion || '-'}</span> <span>{record.panelVersion || '-'}</span>
{canUpdate && ( {canUpdate && (
<Tooltip title={`${t('pages.nodes.updateAvailable')}: ${latestVersion}`}> <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')} {t('pages.nodes.updateAvailable')}
</Tag> </Tag>
</Tooltip> </Tooltip>
@@ -467,15 +468,32 @@ export default function NodeList({
</div> </div>
) : ( ) : (
<div key={record.id} className="node-card"> <div key={record.id} className="node-card">
<div className="card-head" onClick={() => toggleExpanded(record.id)}> {/* 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 */}
<RightOutlined className={`card-expand${expandedIds.has(record.id) ? ' is-expanded' : ''}`} /> <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} /> <StatusDot status={record.status} xrayState={record.xrayState} />
<span className="node-name">{record.name}</span> <span className="node-name">{record.name}</span>
<div className="card-actions" onClick={(e) => e.stopPropagation()}> <div className="card-actions">
<Tooltip title={t('info')}> <Tooltip title={t('info')}>
<InfoCircleOutlined <InfoCircleOutlined
className="row-action-trigger" className="row-action-trigger"
role="button"
tabIndex={0}
aria-label={t('info')}
onClick={() => setStatsNode(record)} onClick={() => setStatsNode(record)}
onKeyDown={activateOnKey(() => setStatsNode(record))}
/> />
</Tooltip> </Tooltip>
<Switch <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> </Dropdown>
</div> </div>
</div> </div>
@@ -555,9 +573,9 @@ export default function NodeList({
</a> </a>
<Tooltip title={t('pages.index.toggleIpVisibility')}> <Tooltip title={t('pages.index.toggleIpVisibility')}>
{showAddress ? ( {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> </Tooltip>
</div> </div>

View File

@@ -115,6 +115,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
value={state.mode} value={state.mode}
options={modeOptions} options={modeOptions}
onChange={onModeChange} onChange={onModeChange}
aria-label={t('pages.settings.telegramNotifyTime')}
/> />
{state.mode === 'every' && ( {state.mode === 'every' && (
<Space.Compact style={{ width: '100%' }}> <Space.Compact style={{ width: '100%' }}>
@@ -123,12 +124,14 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
style={{ width: '50%' }} style={{ width: '50%' }}
value={state.num} value={state.num}
onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })} onChange={(v) => update({ num: Math.max(1, Number(v) || 1) })}
aria-label={t('pages.settings.notifyTime.interval')}
/> />
<Select<Unit> <Select<Unit>
style={{ width: '50%' }} style={{ width: '50%' }}
value={state.unit} value={state.unit}
options={unitOptions} options={unitOptions}
onChange={(unit) => update({ unit })} onChange={(unit) => update({ unit })}
aria-label={t('pages.settings.notifyTime.unit')}
/> />
</Space.Compact> </Space.Compact>
)} )}
@@ -137,6 +140,7 @@ function NotifyTimeField({ value, onChange }: { value: string; onChange: (v: str
value={state.custom} value={state.custom}
placeholder="0 30 8 * * *" placeholder="0 30 8 * * *"
onChange={(e) => update({ custom: e.target.value })} onChange={(e) => update({ custom: e.target.value })}
aria-label={t('pages.settings.notifyTime.custom')}
/> />
)} )}
</Space> </Space>

View File

@@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
import * as OTPAuth from 'otpauth'; import * as OTPAuth from 'otpauth';
import { ClipboardManager } from '@/utils'; import { ClipboardManager } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { TotpCodeSchema } from '@/schemas/login'; import { TotpCodeSchema } from '@/schemas/login';
import './TwoFactorModal.css'; import './TwoFactorModal.css';
@@ -108,7 +109,14 @@ export default function TwoFactorModal({
<p>{t('pages.settings.security.twoFactorModalSteps')}</p> <p>{t('pages.settings.security.twoFactorModalSteps')}</p>
<Divider /> <Divider />
<p>{t('pages.settings.security.twoFactorModalFirstStep')}</p> <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 <QRCode
className="qr-code" className="qr-code"
value={qrValue} value={qrValue}
@@ -119,18 +127,17 @@ export default function TwoFactorModal({
bgColor="#ffffff" bgColor="#ffffff"
errorLevel="L" errorLevel="L"
title={t('copy')} title={t('copy')}
onClick={copyToken}
/> />
<span className="qr-token">{token}</span> <span className="qr-token">{token}</span>
</div> </div>
<Divider /> <Divider />
<p>{t('pages.settings.security.twoFactorModalSecondStep')}</p> <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> <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> </Modal>

View File

@@ -1,4 +1,4 @@
import type { ReactNode } from 'react'; import { cloneElement, isValidElement, type ReactElement, type ReactNode } from 'react';
import { Tooltip } from 'antd'; import { Tooltip } from 'antd';
/* Builds a settings category tab label: icon + text on desktop, and on /* 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. */ old top tab bar's icons-only behaviour. */
export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode { export function catTabLabel(icon: ReactNode, text: ReactNode, iconsOnly: boolean): ReactNode {
if (iconsOnly) { 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 ( return (
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}> <span style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>

View File

@@ -224,16 +224,18 @@ export default function BalancerFormModal({
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateBaselines([...baselines, ''])} onClick={() => updateBaselines([...baselines, ''])}
/> />
{baselines.map((b, idx) => ( {baselines.map((b, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}> <Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Input <Input
value={b} value={b}
aria-label={t('pages.xray.balancer.baselines')}
placeholder="e.g. 1s" placeholder="e.g. 1s"
onChange={(e) => updateBaselines(baselines.map((x, i) => (i === idx ? e.target.value : x)))} 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 /> <MinusOutlined />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
@@ -244,28 +246,32 @@ export default function BalancerFormModal({
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])} onClick={() => updateCosts([...costs, { regexp: false, match: '', value: 1 }])}
/> />
{costs.map((c, idx) => ( {costs.map((c, idx) => (
<Space.Compact key={idx} block style={{ marginTop: 4 }}> <Space.Compact key={idx} block style={{ marginTop: 4 }}>
<Switch <Switch
checked={c.regexp} checked={c.regexp}
aria-label={t('pages.xray.balancer.costRegexp')}
checkedChildren="re" checkedChildren="re"
unCheckedChildren="lit" unCheckedChildren="lit"
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))} onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, regexp: v } : x)))}
/> />
<Input <Input
value={c.match} value={c.match}
aria-label={t('pages.xray.balancer.costMatch')}
placeholder="tag pattern" placeholder="tag pattern"
onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))} onChange={(e) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, match: e.target.value } : x)))}
/> />
<InputNumber <InputNumber
value={c.value} value={c.value}
aria-label={t('pages.xray.balancer.costValue')}
placeholder="weight" placeholder="weight"
style={{ width: 100 }} style={{ width: 100 }}
onChange={(v) => updateCosts(costs.map((x, i) => (i === idx ? { ...x, value: typeof v === 'number' ? v : 0 } : x)))} 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 /> <MinusOutlined />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>

View File

@@ -217,7 +217,7 @@ export default function BalancersTab({
<span className="row-index">{index + 1}</span> <span className="row-index">{index + 1}</span>
<div className={!isMobile ? 'action-buttons' : ''}> <div className={!isMobile ? 'action-buttons' : ''}>
{!isMobile && ( {!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 <Dropdown
trigger={['click']} 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> </Dropdown>
</div> </div>
</div> </div>
@@ -339,7 +339,7 @@ export default function BalancersTab({
{t('pages.xray.Balancers')} {t('pages.xray.Balancers')}
</Button> </Button>
<Tooltip title={t('pages.xray.balancerLiveRefresh')}> <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> </Tooltip>
</Space> </Space>

View File

@@ -205,13 +205,13 @@ export default function DnsServerModal({
<Form.List name="domains"> <Form.List name="domains">
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.domains')}> <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) => ( {fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}> <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle> <Form.Item name={field.name} noStyle>
<Input /> <Input />
</Form.Item> </Form.Item>
<InputAddon onClick={() => remove(field.name)}> <InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
@@ -223,13 +223,13 @@ export default function DnsServerModal({
<Form.List name="expectedIPs"> <Form.List name="expectedIPs">
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.expectIPs')}> <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) => ( {fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}> <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle> <Form.Item name={field.name} noStyle>
<Input /> <Input />
</Form.Item> </Form.Item>
<InputAddon onClick={() => remove(field.name)}> <InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>
@@ -241,13 +241,13 @@ export default function DnsServerModal({
<Form.List name="unexpectedIPs"> <Form.List name="unexpectedIPs">
{(fields, { add, remove }) => ( {(fields, { add, remove }) => (
<Form.Item label={t('pages.xray.dns.unexpectIPs')}> <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) => ( {fields.map((field) => (
<Space.Compact key={field.key} block style={{ marginTop: 4 }}> <Space.Compact key={field.key} block style={{ marginTop: 4 }}>
<Form.Item name={field.name} noStyle> <Form.Item name={field.name} noStyle>
<Input /> <Input />
</Form.Item> </Form.Item>
<InputAddon onClick={() => remove(field.name)}> <InputAddon ariaLabel={t('remove')} onClick={() => remove(field.name)}>
<MinusOutlined /> <MinusOutlined />
</InputAddon> </InputAddon>
</Space.Compact> </Space.Compact>

View File

@@ -335,6 +335,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<div key={`h${idx}`} className="hosts-row"> <div key={`h${idx}`} className="hosts-row">
<Input <Input
value={row.domain} value={row.domain}
aria-label={t('pages.xray.dns.hostsDomain')}
placeholder={t('pages.xray.dns.hostsDomain')} placeholder={t('pages.xray.dns.hostsDomain')}
style={{ flex: '1 1 220px' }} style={{ flex: '1 1 220px' }}
onChange={(e) => { onChange={(e) => {
@@ -345,6 +346,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
<Select <Select
mode="tags" mode="tags"
value={row.values} value={row.values}
aria-label={t('pages.xray.dns.hostsValues')}
placeholder={t('pages.xray.dns.hostsValues')} placeholder={t('pages.xray.dns.hostsValues')}
style={{ flex: '2 1 320px' }} style={{ flex: '2 1 320px' }}
tokenSeparators={[',', ' ']} tokenSeparators={[',', ' ']}
@@ -353,7 +355,7 @@ export default function DnsTab({ templateSettings, setTemplateSettings }: DnsTab
syncHosts(next); 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> </div>
))} ))}
</Space> </Space>

View File

@@ -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> </Dropdown>
</Space> </Space>
), ),
@@ -72,6 +72,7 @@ export function useFakednsColumns({
deleteFakedns: (idx: number) => void; deleteFakedns: (idx: number) => void;
updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void; updateFakednsField: (idx: number, field: 'ipPool' | 'poolSize', value: string | number) => void;
}): ColumnsType<FakednsTableRow> { }): ColumnsType<FakednsTableRow> {
const { t } = useTranslation();
return useMemo( return useMemo(
() => [ () => [
{ {
@@ -82,7 +83,7 @@ export function useFakednsColumns({
render: (_v, _record, index) => ( render: (_v, _record, index) => (
<Space size={6}> <Space size={6}>
<span className="row-index">{index + 1}</span> <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> </Space>
), ),
}, },
@@ -94,6 +95,7 @@ export function useFakednsColumns({
render: (_v, record, index) => ( render: (_v, record, index) => (
<Input <Input
value={record.ipPool} value={record.ipPool}
aria-label={t('pages.xray.fakedns.ipPool')}
size="small" size="small"
onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)} onChange={(e) => updateFakednsField(index, 'ipPool', e.target.value)}
/> />
@@ -108,6 +110,7 @@ export function useFakednsColumns({
render: (_v, record, index) => ( render: (_v, record, index) => (
<InputNumber <InputNumber
value={record.poolSize} value={record.poolSize}
aria-label={t('pages.xray.fakedns.poolSize')}
min={1} min={1}
size="small" size="small"
onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)} onChange={(v) => updateFakednsField(index, 'poolSize', Number(v) || 0)}
@@ -115,6 +118,6 @@ export function useFakednsColumns({
), ),
}, },
], ],
[deleteFakedns, updateFakednsField], [t, deleteFakedns, updateFakednsField],
); );
} }

View File

@@ -53,7 +53,7 @@ export default function OutboundCardList({
if (rows.length === 0) { if (rows.length === 0) {
return ( return (
<div className="card-empty"> <div className="card-empty">
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} /> <ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} aria-hidden="true" />
<div>{t('noData')}</div> <div>{t('noData')}</div>
</div> </div>
); );
@@ -81,7 +81,7 @@ export default function OutboundCardList({
menu={{ menu={{
items: [ items: [
...(index > 0 ...(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: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) },
{ key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(record.tag || '') }, { 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> </Dropdown>
</div> </div>
{outboundAddresses(record).length > 0 && ( {outboundAddresses(record).length > 0 && (
@@ -118,6 +118,7 @@ export default function OutboundCardList({
loading={isTesting(outboundTestStates, index)} loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)} disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(index, testMode)} onClick={() => onTest(index, testMode)}
/> />
</span> </span>

View File

@@ -496,7 +496,7 @@ export default function OutboundsTab({
title={t('pages.inbounds.resetAllTrafficContent')} title={t('pages.inbounds.resetAllTrafficContent')}
onConfirm={() => onResetTraffic('-alltags-')} onConfirm={() => onResetTraffic('-alltags-')}
> >
<Button icon={<RetweetOutlined />} /> <Button aria-label={t('pages.inbounds.resetTraffic')} icon={<RetweetOutlined />} />
</Popconfirm> </Popconfirm>
</Space> </Space>
</Col> </Col>
@@ -657,7 +657,7 @@ export default function OutboundsTab({
<div> <div>
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}> <div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
{t('pages.xray.outboundSub.active')} {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 && ( {subs.length > 0 && (
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}> <Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
{t('pages.xray.outboundSub.refreshAll')} {t('pages.xray.outboundSub.refreshAll')}
@@ -680,8 +680,8 @@ export default function OutboundsTab({
width: 56, width: 56,
render: (_: unknown, r: OutboundSub, index: number) => ( render: (_: unknown, r: OutboundSub, index: number) => (
<Space size={0}> <Space size={0}>
<Button type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} /> <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 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.moveDown')} type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
</Space> </Space>
), ),
}, },
@@ -716,10 +716,10 @@ export default function OutboundsTab({
key: 'actions', key: 'actions',
render: (_: unknown, r: OutboundSub) => ( render: (_: unknown, r: OutboundSub) => (
<Space> <Space>
<Button size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} /> <Button aria-label={t('edit')} 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('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)}> <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> </Popconfirm>
</Space> </Space>
), ),

View File

@@ -106,6 +106,7 @@ export default function SubscriptionOutbounds({
return ( return (
<Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}> <Tooltip title={`${t('check')} (${(isUdpOutbound(record) ? 'http' : testMode).toUpperCase()})`}>
<Button <Button
aria-label={t('check')}
type="primary" type="primary"
shape="circle" shape="circle"
size={isMobile ? 'small' : undefined} size={isMobile ? 'small' : undefined}

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { Button, Form, Input, InputNumber, Select } from 'antd'; import { Button, Form, Input, InputNumber, Select } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { DNSRuleActions } from '@/schemas/primitives'; import { DNSRuleActions } from '@/schemas/primitives';
export default function DnsFields() { export default function DnsFields() {
@@ -35,6 +36,7 @@ export default function DnsFields() {
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })} onClick={() => add({ action: 'direct', qType: '', domain: '', rCode: 0 })}
/> />
</Form.Item> </Form.Item>
@@ -45,7 +47,11 @@ export default function DnsFields() {
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span> <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined <DeleteOutlined
className="danger-icon" className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)} onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/> />
</div> </div>
</Form.Item> </Form.Item>

View File

@@ -2,6 +2,7 @@ import { useTranslation } from 'react-i18next';
import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd'; import { AutoComplete, Button, Form, Input, InputNumber, Select, Switch, type FormInstance } from 'antd';
import { DeleteOutlined, PlusOutlined } from '@ant-design/icons'; import { DeleteOutlined, PlusOutlined } from '@ant-design/icons';
import { activateOnKey } from '@/utils/a11y';
import { OutboundDomainStrategies } from '@/schemas/primitives'; import { OutboundDomainStrategies } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form'; import type { OutboundFormValues } from '@/schemas/forms/outbound-form';
@@ -138,6 +139,7 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
type="primary" type="primary"
className="ml-8" className="ml-8"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => onClick={() =>
add({ add({
type: 'rand', type: 'rand',
@@ -157,7 +159,11 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
{fields.length > 1 && ( {fields.length > 1 && (
<DeleteOutlined <DeleteOutlined
className="danger-icon" className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)} onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/> />
)} )}
</div> </div>
@@ -198,6 +204,7 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => onClick={() =>
add({ add({
action: 'allow', action: 'allow',
@@ -219,7 +226,11 @@ export default function FreedomFields({ form }: { form: FormInstance<OutboundFor
<span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span> <span>{t('pages.xray.outboundForm.ruleN', { n: index + 1 })}</span>
<DeleteOutlined <DeleteOutlined
className="danger-icon" className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)} onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/> />
</div> </div>
</Form.Item> </Form.Item>

View File

@@ -3,6 +3,7 @@ import { Button, Form, Input, InputNumber, Select, Space, Switch, type FormInsta
import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons'; import { DeleteOutlined, MinusOutlined, PlusOutlined, ReloadOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils'; import { Wireguard } from '@/utils';
import { activateOnKey } from '@/utils/a11y';
import { InputAddon } from '@/components/ui'; import { InputAddon } from '@/components/ui';
import { WireguardDomainStrategy } from '@/schemas/primitives'; import { WireguardDomainStrategy } from '@/schemas/primitives';
import type { OutboundFormValues } from '@/schemas/forms/outbound-form'; 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')}> <Form.Item label={t('pages.inbounds.privatekey')}>
<Space.Compact block> <Space.Compact block>
<Form.Item name={['settings', 'secretKey']} noStyle> <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> </Form.Item>
<Button <Button
icon={<ReloadOutlined />} icon={<ReloadOutlined />}
aria-label={t('regenerate')}
onClick={() => { onClick={() => {
const pair = Wireguard.generateKeypair(); const pair = Wireguard.generateKeypair();
form.setFieldValue(['settings', 'secretKey'], pair.privateKey); form.setFieldValue(['settings', 'secretKey'], pair.privateKey);
@@ -61,6 +63,7 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
size="small" size="small"
type="primary" type="primary"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => onClick={() =>
add({ add({
publicKey: '', publicKey: '',
@@ -80,7 +83,11 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
{fields.length > 1 && ( {fields.length > 1 && (
<DeleteOutlined <DeleteOutlined
className="danger-icon" className="danger-icon"
role="button"
tabIndex={0}
aria-label={t('remove')}
onClick={() => remove(field.name)} onClick={() => remove(field.name)}
onKeyDown={activateOnKey(() => remove(field.name))}
/> />
)} )}
</div> </div>
@@ -108,10 +115,10 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
style={{ marginBottom: 4 }} style={{ marginBottom: 4 }}
> >
<Form.Item noStyle name={ipField.name}> <Form.Item noStyle name={ipField.name}>
<Input /> <Input aria-label={t('pages.xray.wireguard.allowedIPs')} />
</Form.Item> </Form.Item>
{ipFields.length > 1 && ( {ipFields.length > 1 && (
<InputAddon onClick={() => removeIp(ipIdx)}> <InputAddon ariaLabel={t('remove')} onClick={() => removeIp(ipIdx)}>
<MinusOutlined /> <MinusOutlined />
</InputAddon> </InputAddon>
)} )}
@@ -120,6 +127,7 @@ export default function WireguardFields({ form }: { form: FormInstance<OutboundF
<Button <Button
size="small" size="small"
icon={<PlusOutlined />} icon={<PlusOutlined />}
aria-label={t('add')}
onClick={() => addIp('')} onClick={() => addIp('')}
/> />
</> </>

View File

@@ -69,24 +69,24 @@ export function useOutboundColumns({
<div className="action-cell"> <div className="action-cell">
<span className="row-index">{index + 1}</span> <span className="row-index">{index + 1}</span>
<div className="action-buttons"> <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 <Dropdown
trigger={['click']} trigger={['click']}
menu={{ menu={{
items: [ items: [
...(index > 0 ...(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: 'up', label: <><ArrowUpOutlined /> {t('pages.inbounds.form.moveUp')}</>, disabled: index === 0, onClick: () => moveUp(index) },
{ key: 'down', label: <ArrowDownOutlined />, disabled: index === rows.length - 1, onClick: () => moveDown(index) }, { key: 'down', label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>, disabled: index === rows.length - 1, onClick: () => moveDown(index) },
{ key: 'reset', label: <><RetweetOutlined /> Reset traffic</>, onClick: () => onResetTraffic(rows[index].tag || '') }, { key: 'reset', label: <><RetweetOutlined /> {t('pages.inbounds.resetTraffic')}</>, onClick: () => onResetTraffic(rows[index].tag || '') },
{ key: 'del', danger: true, label: <><DeleteOutlined /> Delete</>, onClick: () => confirmDelete(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> </Dropdown>
</div> </div>
</div> </div>
@@ -174,6 +174,7 @@ export function useOutboundColumns({
loading={isTesting(outboundTestStates, index)} loading={isTesting(outboundTestStates, index)}
disabled={isUntestable(record) || isTesting(outboundTestStates, index)} disabled={isUntestable(record) || isTesting(outboundTestStates, index)}
icon={<ThunderboltOutlined />} icon={<ThunderboltOutlined />}
aria-label={t('check')}
onClick={() => onTest(index, testMode)} onClick={() => onTest(index, testMode)}
/> />
</Tooltip> </Tooltip>

View File

@@ -66,6 +66,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
<Row gutter={[8, 8]} align="bottom"> <Row gutter={[8, 8]} align="bottom">
<Col xs={fieldSpan} sm={7}> <Col xs={fieldSpan} sm={7}>
<Input <Input
aria-label={t('pages.xray.routeTesterDest')}
placeholder={t('pages.xray.routeTesterDest')} placeholder={t('pages.xray.routeTesterDest')}
value={dest} value={dest}
onChange={(e) => setDest(e.target.value)} onChange={(e) => setDest(e.target.value)}
@@ -75,6 +76,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col> </Col>
<Col xs={12} sm={3}> <Col xs={12} sm={3}>
<InputNumber <InputNumber
aria-label={t('pages.xray.routeTesterPort')}
style={{ width: '100%' }} style={{ width: '100%' }}
min={0} min={0}
max={65535} max={65535}
@@ -85,6 +87,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col> </Col>
<Col xs={12} sm={3}> <Col xs={12} sm={3}>
<Select <Select
aria-label={t('pages.inbounds.network')}
style={{ width: '100%' }} style={{ width: '100%' }}
value={network} value={network}
onChange={setNetwork} onChange={setNetwork}
@@ -96,6 +99,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col> </Col>
<Col xs={12} sm={4}> <Col xs={12} sm={4}>
<Select <Select
aria-label={t('pages.xray.routeTesterInbound')}
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={t('pages.xray.routeTesterInbound')} placeholder={t('pages.xray.routeTesterInbound')}
allowClear allowClear
@@ -106,6 +110,7 @@ export default function RouteTester({ inboundTags, isMobile }: RouteTesterProps)
</Col> </Col>
<Col xs={12} sm={4}> <Col xs={12} sm={4}>
<Select <Select
aria-label={t('pages.xray.routeTesterProtocol')}
style={{ width: '100%' }} style={{ width: '100%' }}
placeholder={t('pages.xray.routeTesterProtocol')} placeholder={t('pages.xray.routeTesterProtocol')}
allowClear allowClear

View File

@@ -60,6 +60,7 @@ export default function RuleCardList({
<div className="rule-card-head"> <div className="rule-card-head">
<HolderOutlined <HolderOutlined
className="drag-handle" className="drag-handle"
aria-hidden="true"
onPointerDown={(ev) => onHandlePointerDown(index, ev)} onPointerDown={(ev) => onHandlePointerDown(index, ev)}
/> />
<span className="rule-number">#{index + 1}</span> <span className="rule-number">#{index + 1}</span>
@@ -68,13 +69,13 @@ export default function RuleCardList({
menu={{ menu={{
items: [ items: [
{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }, { 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 />, disabled: index === rows.length - 1, onClick: () => moveDown(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) }, { 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> </Dropdown>
<Switch <Switch
size="small" size="small"
@@ -105,11 +106,11 @@ export default function RuleCardList({
</span> </span>
{rule.outboundTag ? ( {rule.outboundTag ? (
<Tag color="green" className="flow-tag"> <Tag color="green" className="flow-tag">
<ExportOutlined /> {rule.outboundTag} <ExportOutlined aria-hidden="true" /> {rule.outboundTag}
</Tag> </Tag>
) : rule.balancerTag ? ( ) : rule.balancerTag ? (
<Tag color="purple" className="flow-tag"> <Tag color="purple" className="flow-tag">
<ClusterOutlined /> {rule.balancerTag} <ClusterOutlined aria-hidden="true" /> {rule.balancerTag}
</Tag> </Tag>
) : ( ) : (
<span className="criterion-empty"></span> <span className="criterion-empty"></span>

View File

@@ -166,7 +166,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined /> {t('pages.xray.ruleForm.sourceIps')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -176,7 +176,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined /> {t('pages.xray.ruleForm.sourcePort')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -186,7 +186,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined /> {t('pages.xray.ruleForm.vlessRoute')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -211,7 +211,7 @@ export default function RuleFormModal({
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.ruleForm.attributes')}> <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>
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{form.attrs.map((attr, idx) => ( {form.attrs.map((attr, idx) => (
@@ -219,6 +219,7 @@ export default function RuleFormModal({
<InputAddon>{`${idx + 1}`}</InputAddon> <InputAddon>{`${idx + 1}`}</InputAddon>
<Input <Input
value={attr[0]} value={attr[0]}
aria-label={t('pages.nodes.name')}
placeholder={t('pages.nodes.name')} placeholder={t('pages.nodes.name')}
onChange={(e) => { onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a)); 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 <Input
value={attr[1]} value={attr[1]}
aria-label={t('pages.xray.ruleForm.value')}
placeholder={t('pages.xray.ruleForm.value')} placeholder={t('pages.xray.ruleForm.value')}
onChange={(e) => { onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a)); 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 <Button
aria-label={t('remove')}
icon={<MinusOutlined />} icon={<MinusOutlined />}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))} onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/> />
@@ -244,7 +247,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
IP <QuestionCircleOutlined /> IP <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -254,7 +257,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
{t('domainName')} <QuestionCircleOutlined /> {t('domainName')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -264,7 +267,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.xray.ruleForm.user')} <QuestionCircleOutlined /> {t('pages.xray.ruleForm.user')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -274,7 +277,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.rules.useComma')}> <Tooltip title={t('pages.xray.rules.useComma')}>
{t('pages.inbounds.port')} <QuestionCircleOutlined /> {t('pages.inbounds.port')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >
@@ -301,7 +304,7 @@ export default function RuleFormModal({
<Form.Item <Form.Item
label={ label={
<Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}> <Tooltip title={t('pages.xray.ruleForm.balancerTagTooltip')}>
{t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined /> {t('pages.xray.ruleForm.balancerTag')} <QuestionCircleOutlined aria-hidden="true" />
</Tooltip> </Tooltip>
} }
> >

View File

@@ -58,6 +58,7 @@ export function useRoutingColumns({
<HolderOutlined <HolderOutlined
className="drag-handle" className="drag-handle"
title={t('pages.xray.routing.dragToReorder')} title={t('pages.xray.routing.dragToReorder')}
aria-hidden="true"
onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)} onPointerDown={(ev: React.PointerEvent) => onHandlePointerDown(index, ev)}
/> />
<span className="row-index">{index + 1}</span> <span className="row-index">{index + 1}</span>
@@ -72,7 +73,7 @@ export function useRoutingColumns({
render: (_v, _r, index) => ( render: (_v, _r, index) => (
<div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}> <div className={!isMobile ? 'action-buttons' : ''} style={{ justifyContent: 'center', margin: 0 }}>
{!isMobile && ( {!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 <Dropdown
trigger={['click']} trigger={['click']}
@@ -81,10 +82,10 @@ export function useRoutingColumns({
...(isMobile ...(isMobile
? [{ key: 'edit', label: <><EditOutlined /> {t('edit')}</>, onClick: () => openEdit(index) }] ? [{ 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', key: 'down',
label: <ArrowDownOutlined />, label: <><ArrowDownOutlined /> {t('pages.inbounds.form.moveDown')}</>,
disabled: index === rowsLength - 1, disabled: index === rowsLength - 1,
onClick: () => moveDown(index), 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> </Dropdown>
</div> </div>
), ),
@@ -184,7 +185,7 @@ export function useRoutingColumns({
render: (_v, record) => render: (_v, record) =>
record.outboundTag ? ( record.outboundTag ? (
<div className="target-row"> <div className="target-row">
<ExportOutlined className="target-icon" /> <ExportOutlined className="target-icon" aria-hidden="true" />
<Tag color="green">{record.outboundTag}</Tag> <Tag color="green">{record.outboundTag}</Tag>
</div> </div>
) : ( ) : (
@@ -200,7 +201,7 @@ export function useRoutingColumns({
render: (_v, record) => render: (_v, record) =>
record.balancerTag ? ( record.balancerTag ? (
<div className="target-row"> <div className="target-row">
<ClusterOutlined className="target-icon" /> <ClusterOutlined className="target-icon" aria-hidden="true" />
<Tag color="purple">{record.balancerTag}</Tag> <Tag color="purple">{record.balancerTag}</Tag>
</div> </div>
) : ( ) : (

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();
}
};
}

View File

@@ -15,6 +15,10 @@
"copied": "اتنسخ", "copied": "اتنسخ",
"more": "المزيد", "more": "المزيد",
"download": "تحميل", "download": "تحميل",
"regenerate": "إعادة التوليد",
"jsonEditor": "محرر JSON",
"downloadImage": "تنزيل الصورة",
"sort": "ترتيب",
"remark": "ملاحظة", "remark": "ملاحظة",
"enable": "مفعل", "enable": "مفعل",
"protocol": "بروتوكول", "protocol": "بروتوكول",
@@ -118,7 +122,8 @@
"link": "إدارة", "link": "إدارة",
"donate": "تبرع", "donate": "تبرع",
"hosts": "المضيفات", "hosts": "المضيفات",
"docs": "التوثيق" "docs": "التوثيق",
"openMenu": "فتح القائمة"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "رأس الطلب", "requestHeader": "رأس الطلب",
"responseHeader": "رأس الرد" "responseHeader": "رأس الرد"
} }
} },
"sniffingDestOverride": "تجاوز الوجهة"
}, },
"clients": { "clients": {
"tabBasics": "أساسي", "tabBasics": "أساسي",
@@ -1142,7 +1148,9 @@
"custom": "مخصص (crontab)", "custom": "مخصص (crontab)",
"seconds": "ثوانٍ", "seconds": "ثوانٍ",
"minutes": "دقائق", "minutes": "دقائق",
"hours": "ساعات" "hours": "ساعات",
"interval": "الفاصل الزمني",
"unit": "الوحدة"
}, },
"tgNotifyBackup": "نسخة احتياطية لقاعدة البيانات", "tgNotifyBackup": "نسخة احتياطية لقاعدة البيانات",
"tgNotifyBackupDesc": "ابعت ملف النسخة الاحتياطية لقاعدة البيانات مع التقرير.", "tgNotifyBackupDesc": "ابعت ملف النسخة الاحتياطية لقاعدة البيانات مع التقرير.",
@@ -1618,7 +1626,8 @@
"city": "المدينة", "city": "المدينة",
"allCities": "كل المدن", "allCities": "كل المدن",
"privateKey": "المفتاح الخاص", "privateKey": "المفتاح الخاص",
"load": "الحمل" "load": "الحمل",
"moveToTop": "نقل إلى الأعلى"
}, },
"outboundSub": { "outboundSub": {
"manage": "الاشتراكات", "manage": "الاشتراكات",
@@ -1717,7 +1726,10 @@
"tolerance": "التحمل", "tolerance": "التحمل",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل." "balancerDesc": "ماينفعش تستخدم balancerTag و outboundTag مع بعض. لو اتستخدموا مع بعض، outboundTag هو اللي هيشتغل.",
"costMatch": "نمط الوسم",
"costValue": "الوزن",
"costRegexp": "مطابقة تعبير نمطي"
}, },
"wireguard": { "wireguard": {
"secretKey": "المفتاح السري", "secretKey": "المفتاح السري",

View File

@@ -15,6 +15,10 @@
"copied": "Copied", "copied": "Copied",
"more": "more", "more": "more",
"download": "Download", "download": "Download",
"regenerate": "Regenerate",
"jsonEditor": "JSON editor",
"downloadImage": "Download Image",
"sort": "Sort",
"remark": "Remark", "remark": "Remark",
"enable": "Enabled", "enable": "Enabled",
"protocol": "Protocol", "protocol": "Protocol",
@@ -118,7 +122,8 @@
"logout": "Log Out", "logout": "Log Out",
"link": "Manage", "link": "Manage",
"donate": "Donate", "donate": "Donate",
"docs": "Documentation" "docs": "Documentation",
"openMenu": "Open menu"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Request Header", "requestHeader": "Request Header",
"responseHeader": "Response Header" "responseHeader": "Response Header"
} }
} },
"sniffingDestOverride": "Destination override"
}, },
"clients": { "clients": {
"tabBasics": "Basics", "tabBasics": "Basics",
@@ -1260,7 +1266,9 @@
"custom": "Custom (crontab)", "custom": "Custom (crontab)",
"seconds": "Seconds", "seconds": "Seconds",
"minutes": "Minutes", "minutes": "Minutes",
"hours": "Hours" "hours": "Hours",
"interval": "Interval",
"unit": "Unit"
}, },
"tgNotifyBackup": "Database Backup", "tgNotifyBackup": "Database Backup",
"tgNotifyBackupDesc": "Send a database backup file with a report.", "tgNotifyBackupDesc": "Send a database backup file with a report.",
@@ -1734,7 +1742,8 @@
"city": "City", "city": "City",
"allCities": "All Cities", "allCities": "All Cities",
"privateKey": "Private Key", "privateKey": "Private Key",
"load": "Load" "load": "Load",
"moveToTop": "Move to top"
}, },
"outboundSub": { "outboundSub": {
"manage": "Subscriptions", "manage": "Subscriptions",
@@ -1833,7 +1842,10 @@
"tolerance": "Tolerance", "tolerance": "Tolerance",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "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": { "wireguard": {
"secretKey": "Secret Key", "secretKey": "Secret Key",

View File

@@ -15,6 +15,10 @@
"copied": "Copiado", "copied": "Copiado",
"more": "más", "more": "más",
"download": "Descargar", "download": "Descargar",
"regenerate": "Regenerar",
"jsonEditor": "Editor JSON",
"downloadImage": "Descargar imagen",
"sort": "Ordenar",
"remark": "Notas", "remark": "Notas",
"enable": "Habilitar", "enable": "Habilitar",
"protocol": "Protocolo", "protocol": "Protocolo",
@@ -118,7 +122,8 @@
"link": "Gestionar", "link": "Gestionar",
"donate": "Donar", "donate": "Donar",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "Documentación" "docs": "Documentación",
"openMenu": "Abrir menú"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Encabezado de solicitud", "requestHeader": "Encabezado de solicitud",
"responseHeader": "Encabezado de respuesta" "responseHeader": "Encabezado de respuesta"
} }
} },
"sniffingDestOverride": "Anulación de destino"
}, },
"clients": { "clients": {
"tabBasics": "Básico", "tabBasics": "Básico",
@@ -1142,7 +1148,9 @@
"custom": "Personalizado (crontab)", "custom": "Personalizado (crontab)",
"seconds": "Segundos", "seconds": "Segundos",
"minutes": "Minutos", "minutes": "Minutos",
"hours": "Horas" "hours": "Horas",
"interval": "Intervalo",
"unit": "Unidad"
}, },
"tgNotifyBackup": "Respaldo de Base de Datos", "tgNotifyBackup": "Respaldo de Base de Datos",
"tgNotifyBackupDesc": "Incluir archivo de respaldo de base de datos con notificación de informe.", "tgNotifyBackupDesc": "Incluir archivo de respaldo de base de datos con notificación de informe.",
@@ -1618,7 +1626,8 @@
"city": "Ciudad", "city": "Ciudad",
"allCities": "Todas las ciudades", "allCities": "Todas las ciudades",
"privateKey": "Clave privada", "privateKey": "Clave privada",
"load": "Carga" "load": "Carga",
"moveToTop": "Mover al principio"
}, },
"outboundSub": { "outboundSub": {
"manage": "Suscripciones", "manage": "Suscripciones",
@@ -1717,7 +1726,10 @@
"tolerance": "Tolerancia", "tolerance": "Tolerancia",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "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": { "wireguard": {
"secretKey": "Llave secreta", "secretKey": "Llave secreta",

View File

@@ -15,6 +15,10 @@
"copied": "کپی شد", "copied": "کپی شد",
"more": "بیشتر", "more": "بیشتر",
"download": "دانلود", "download": "دانلود",
"regenerate": "تولید مجدد",
"jsonEditor": "ویرایشگر JSON",
"downloadImage": "دانلود تصویر",
"sort": "مرتب‌سازی",
"remark": "نام", "remark": "نام",
"enable": "فعال", "enable": "فعال",
"protocol": "پروتکل", "protocol": "پروتکل",
@@ -118,7 +122,8 @@
"link": "مدیریت", "link": "مدیریت",
"donate": "حمایت مالی", "donate": "حمایت مالی",
"hosts": "میزبان‌ها", "hosts": "میزبان‌ها",
"docs": "مستندات" "docs": "مستندات",
"openMenu": "باز کردن منو"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "سربرگ درخواست", "requestHeader": "سربرگ درخواست",
"responseHeader": "سربرگ پاسخ" "responseHeader": "سربرگ پاسخ"
} }
} },
"sniffingDestOverride": "بازنویسی مقصد"
}, },
"clients": { "clients": {
"tabBasics": "پایه", "tabBasics": "پایه",
@@ -1144,7 +1150,9 @@
"custom": "سفارشی (crontab)", "custom": "سفارشی (crontab)",
"seconds": "ثانیه", "seconds": "ثانیه",
"minutes": "دقیقه", "minutes": "دقیقه",
"hours": "ساعت" "hours": "ساعت",
"interval": "بازه زمانی",
"unit": "واحد"
}, },
"tgNotifyBackup": "پشتیبان‌گیری از دیتابیس", "tgNotifyBackup": "پشتیبان‌گیری از دیتابیس",
"tgNotifyBackupDesc": "فایل پشتیبان‌دیتابیس را به‌همراه گزارش ارسال می‌کند", "tgNotifyBackupDesc": "فایل پشتیبان‌دیتابیس را به‌همراه گزارش ارسال می‌کند",
@@ -1618,7 +1626,8 @@
"city": "شهر", "city": "شهر",
"allCities": "همه شهرها", "allCities": "همه شهرها",
"privateKey": "کلید خصوصی", "privateKey": "کلید خصوصی",
"load": "فشار سرور" "load": "فشار سرور",
"moveToTop": "انتقال به بالا"
}, },
"outboundSub": { "outboundSub": {
"manage": "سابسکریپشن‌ها", "manage": "سابسکریپشن‌ها",
@@ -1717,7 +1726,10 @@
"tolerance": "تحمل", "tolerance": "تحمل",
"baselines": "خطوط پایه", "baselines": "خطوط پایه",
"costs": "هزینه‌ها", "costs": "هزینه‌ها",
"balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد." "balancerDesc": "امکان استفاده همزمان balancerTag و outboundTag باهم وجود ندارد. درصورت استفاده همزمان فقط outboundTag عمل خواهد کرد.",
"costMatch": "الگوی برچسب",
"costValue": "وزن",
"costRegexp": "تطبیق با عبارت باقاعده"
}, },
"wireguard": { "wireguard": {
"secretKey": "کلید شخصی", "secretKey": "کلید شخصی",

View File

@@ -15,6 +15,10 @@
"copied": "Tersalin", "copied": "Tersalin",
"more": "lainnya", "more": "lainnya",
"download": "Unduh", "download": "Unduh",
"regenerate": "Buat Ulang",
"jsonEditor": "Editor JSON",
"downloadImage": "Unduh Gambar",
"sort": "Urutkan",
"remark": "Catatan", "remark": "Catatan",
"enable": "Aktifkan", "enable": "Aktifkan",
"protocol": "Protokol", "protocol": "Protokol",
@@ -118,7 +122,8 @@
"link": "Kelola", "link": "Kelola",
"donate": "Donasi", "donate": "Donasi",
"hosts": "Host", "hosts": "Host",
"docs": "Dokumentasi" "docs": "Dokumentasi",
"openMenu": "Buka menu"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Header Permintaan", "requestHeader": "Header Permintaan",
"responseHeader": "Header Respons" "responseHeader": "Header Respons"
} }
} },
"sniffingDestOverride": "Penggantian tujuan"
}, },
"clients": { "clients": {
"tabBasics": "Dasar", "tabBasics": "Dasar",
@@ -1142,7 +1148,9 @@
"custom": "Kustom (crontab)", "custom": "Kustom (crontab)",
"seconds": "Detik", "seconds": "Detik",
"minutes": "Menit", "minutes": "Menit",
"hours": "Jam" "hours": "Jam",
"interval": "Interval",
"unit": "Satuan"
}, },
"tgNotifyBackup": "Cadangan Database", "tgNotifyBackup": "Cadangan Database",
"tgNotifyBackupDesc": "Kirim berkas cadangan database dengan laporan.", "tgNotifyBackupDesc": "Kirim berkas cadangan database dengan laporan.",
@@ -1618,7 +1626,8 @@
"city": "Kota", "city": "Kota",
"allCities": "Semua Kota", "allCities": "Semua Kota",
"privateKey": "Kunci Privat", "privateKey": "Kunci Privat",
"load": "Beban" "load": "Beban",
"moveToTop": "Pindahkan ke atas"
}, },
"outboundSub": { "outboundSub": {
"manage": "Langganan", "manage": "Langganan",
@@ -1717,7 +1726,10 @@
"tolerance": "Toleransi", "tolerance": "Toleransi",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "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": { "wireguard": {
"secretKey": "Kunci Rahasia", "secretKey": "Kunci Rahasia",

View File

@@ -15,6 +15,10 @@
"copied": "コピー済み", "copied": "コピー済み",
"more": "もっと", "more": "もっと",
"download": "ダウンロード", "download": "ダウンロード",
"regenerate": "再生成",
"jsonEditor": "JSON エディター",
"downloadImage": "画像をダウンロード",
"sort": "並べ替え",
"remark": "備考", "remark": "備考",
"enable": "有効化", "enable": "有効化",
"protocol": "プロトコル", "protocol": "プロトコル",
@@ -118,7 +122,8 @@
"link": "リンク管理", "link": "リンク管理",
"donate": "寄付", "donate": "寄付",
"hosts": "ホスト", "hosts": "ホスト",
"docs": "ドキュメント" "docs": "ドキュメント",
"openMenu": "メニューを開く"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "リクエストヘッダー", "requestHeader": "リクエストヘッダー",
"responseHeader": "レスポンスヘッダー" "responseHeader": "レスポンスヘッダー"
} }
} },
"sniffingDestOverride": "宛先のオーバーライド"
}, },
"clients": { "clients": {
"tabBasics": "基本", "tabBasics": "基本",
@@ -1142,7 +1148,9 @@
"custom": "カスタム (crontab)", "custom": "カスタム (crontab)",
"seconds": "秒", "seconds": "秒",
"minutes": "分", "minutes": "分",
"hours": "時間" "hours": "時間",
"interval": "間隔",
"unit": "単位"
}, },
"tgNotifyBackup": "データベースバックアップ", "tgNotifyBackup": "データベースバックアップ",
"tgNotifyBackupDesc": "レポート付きのデータベースバックアップファイルを送信", "tgNotifyBackupDesc": "レポート付きのデータベースバックアップファイルを送信",
@@ -1618,7 +1626,8 @@
"city": "都市", "city": "都市",
"allCities": "すべての都市", "allCities": "すべての都市",
"privateKey": "秘密鍵", "privateKey": "秘密鍵",
"load": "負荷" "load": "負荷",
"moveToTop": "先頭に移動"
}, },
"outboundSub": { "outboundSub": {
"manage": "サブスクリプション", "manage": "サブスクリプション",
@@ -1717,7 +1726,10 @@
"tolerance": "許容範囲", "tolerance": "許容範囲",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。" "balancerDesc": "balancerTagとoutboundTagは同時に使用できません。同時に使用された場合、outboundTagのみが有効になります。",
"costMatch": "タグパターン",
"costValue": "重み",
"costRegexp": "正規表現で一致"
}, },
"wireguard": { "wireguard": {
"secretKey": "シークレットキー", "secretKey": "シークレットキー",

View File

@@ -15,6 +15,10 @@
"copied": "Copiado", "copied": "Copiado",
"more": "mais", "more": "mais",
"download": "Baixar", "download": "Baixar",
"regenerate": "Regenerar",
"jsonEditor": "Editor JSON",
"downloadImage": "Baixar imagem",
"sort": "Ordenar",
"remark": "Observação", "remark": "Observação",
"enable": "Ativado", "enable": "Ativado",
"protocol": "Protocolo", "protocol": "Protocolo",
@@ -118,7 +122,8 @@
"link": "Gerenciar", "link": "Gerenciar",
"donate": "Doar", "donate": "Doar",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "Documentação" "docs": "Documentação",
"openMenu": "Abrir menu"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Cabeçalho da Requisição", "requestHeader": "Cabeçalho da Requisição",
"responseHeader": "Cabeçalho da Resposta" "responseHeader": "Cabeçalho da Resposta"
} }
} },
"sniffingDestOverride": "Substituição de destino"
}, },
"clients": { "clients": {
"tabBasics": "Básico", "tabBasics": "Básico",
@@ -1142,7 +1148,9 @@
"custom": "Personalizado (crontab)", "custom": "Personalizado (crontab)",
"seconds": "Segundos", "seconds": "Segundos",
"minutes": "Minutos", "minutes": "Minutos",
"hours": "Horas" "hours": "Horas",
"interval": "Intervalo",
"unit": "Unidade"
}, },
"tgNotifyBackup": "Backup do Banco de Dados", "tgNotifyBackup": "Backup do Banco de Dados",
"tgNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.", "tgNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.",
@@ -1618,7 +1626,8 @@
"city": "Cidade", "city": "Cidade",
"allCities": "Todas as Cidades", "allCities": "Todas as Cidades",
"privateKey": "Chave Privada", "privateKey": "Chave Privada",
"load": "Carga" "load": "Carga",
"moveToTop": "Mover para o topo"
}, },
"outboundSub": { "outboundSub": {
"manage": "Assinaturas", "manage": "Assinaturas",
@@ -1717,7 +1726,10 @@
"tolerance": "Tolerância", "tolerance": "Tolerância",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "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": { "wireguard": {
"secretKey": "Chave Secreta", "secretKey": "Chave Secreta",

View File

@@ -15,6 +15,10 @@
"copied": "Скопировано", "copied": "Скопировано",
"more": "ещё", "more": "ещё",
"download": "Скачать", "download": "Скачать",
"regenerate": "Сгенерировать заново",
"jsonEditor": "Редактор JSON",
"downloadImage": "Скачать изображение",
"sort": "Сортировка",
"remark": "Примечание", "remark": "Примечание",
"enable": "Включить", "enable": "Включить",
"protocol": "Протокол", "protocol": "Протокол",
@@ -118,7 +122,8 @@
"link": "Управление", "link": "Управление",
"donate": "Поддержать", "donate": "Поддержать",
"hosts": "Хосты", "hosts": "Хосты",
"docs": "Документация" "docs": "Документация",
"openMenu": "Открыть меню"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Заголовок запроса", "requestHeader": "Заголовок запроса",
"responseHeader": "Заголовок ответа" "responseHeader": "Заголовок ответа"
} }
} },
"sniffingDestOverride": "Переопределение назначения"
}, },
"clients": { "clients": {
"tabBasics": "Основные", "tabBasics": "Основные",
@@ -1142,7 +1148,9 @@
"custom": "Произвольный (crontab)", "custom": "Произвольный (crontab)",
"seconds": "Секунды", "seconds": "Секунды",
"minutes": "Минуты", "minutes": "Минуты",
"hours": "Часы" "hours": "Часы",
"interval": "Интервал",
"unit": "Единица"
}, },
"tgNotifyBackup": "Резервное копирование базы данных", "tgNotifyBackup": "Резервное копирование базы данных",
"tgNotifyBackupDesc": "Отправлять уведомление с файлом резервной копии базы данных", "tgNotifyBackupDesc": "Отправлять уведомление с файлом резервной копии базы данных",
@@ -1618,7 +1626,8 @@
"city": "Город", "city": "Город",
"allCities": "Все города", "allCities": "Все города",
"privateKey": "Приватный ключ", "privateKey": "Приватный ключ",
"load": "Нагрузка" "load": "Нагрузка",
"moveToTop": "Переместить наверх"
}, },
"outboundSub": { "outboundSub": {
"manage": "Подписки", "manage": "Подписки",
@@ -1717,7 +1726,10 @@
"tolerance": "Допуск", "tolerance": "Допуск",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag." "balancerDesc": "Невозможно одновременно использовать balancerTag и outboundTag. При одновременном использовании будет работать только outboundTag.",
"costMatch": "Шаблон тега",
"costValue": "Вес",
"costRegexp": "Совпадение по регулярному выражению"
}, },
"wireguard": { "wireguard": {
"secretKey": "Секретный ключ", "secretKey": "Секретный ключ",

View File

@@ -15,6 +15,10 @@
"copied": "Kopyalandı", "copied": "Kopyalandı",
"more": "Diğer", "more": "Diğer",
"download": "İndir", "download": "İndir",
"regenerate": "Yeniden Oluştur",
"jsonEditor": "JSON Düzenleyici",
"downloadImage": "Resmi İndir",
"sort": "Sırala",
"remark": "Açıklama", "remark": "Açıklama",
"enable": "Etkin", "enable": "Etkin",
"protocol": "Protokol", "protocol": "Protokol",
@@ -118,7 +122,8 @@
"link": "Yönet", "link": "Yönet",
"donate": "Bağış Yap", "donate": "Bağış Yap",
"hosts": "Host'lar", "hosts": "Host'lar",
"docs": "Belgeler" "docs": "Belgeler",
"openMenu": "Menüyü aç"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "İstek Başlığı", "requestHeader": "İstek Başlığı",
"responseHeader": "Yanıt Başlığı" "responseHeader": "Yanıt Başlığı"
} }
} },
"sniffingDestOverride": "Hedef geçersiz kılma"
}, },
"clients": { "clients": {
"tabBasics": "Temel", "tabBasics": "Temel",
@@ -1142,7 +1148,9 @@
"custom": "Özel (crontab)", "custom": "Özel (crontab)",
"seconds": "Saniye", "seconds": "Saniye",
"minutes": "Dakika", "minutes": "Dakika",
"hours": "Saat" "hours": "Saat",
"interval": "Aralık",
"unit": "Birim"
}, },
"tgNotifyBackup": "Veritabanı Yedeği", "tgNotifyBackup": "Veritabanı Yedeği",
"tgNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.", "tgNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.",
@@ -1618,7 +1626,8 @@
"city": "Şehir", "city": "Şehir",
"allCities": "Tüm Şehirler", "allCities": "Tüm Şehirler",
"privateKey": "Özel Anahtar", "privateKey": "Özel Anahtar",
"load": "Yükle" "load": "Yükle",
"moveToTop": "En üste taşı"
}, },
"outboundSub": { "outboundSub": {
"manage": "Abonelikler", "manage": "Abonelikler",
@@ -1717,7 +1726,10 @@
"tolerance": "Tolerans", "tolerance": "Tolerans",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "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": { "wireguard": {
"secretKey": "Gizli Anahtar", "secretKey": "Gizli Anahtar",

View File

@@ -15,6 +15,10 @@
"copied": "Скопійовано", "copied": "Скопійовано",
"more": "більше", "more": "більше",
"download": "Завантажити", "download": "Завантажити",
"regenerate": "Згенерувати заново",
"jsonEditor": "Редактор JSON",
"downloadImage": "Завантажити зображення",
"sort": "Сортування",
"remark": "Примітка", "remark": "Примітка",
"enable": "Увімкнути", "enable": "Увімкнути",
"protocol": "Протокол", "protocol": "Протокол",
@@ -118,7 +122,8 @@
"link": "Керувати", "link": "Керувати",
"donate": "Підтримати", "donate": "Підтримати",
"hosts": "Хости", "hosts": "Хости",
"docs": "Документація" "docs": "Документація",
"openMenu": "Відкрити меню"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Заголовок запиту", "requestHeader": "Заголовок запиту",
"responseHeader": "Заголовок відповіді" "responseHeader": "Заголовок відповіді"
} }
} },
"sniffingDestOverride": "Перевизначення призначення"
}, },
"clients": { "clients": {
"tabBasics": "Основні", "tabBasics": "Основні",
@@ -1142,7 +1148,9 @@
"custom": "Власний (crontab)", "custom": "Власний (crontab)",
"seconds": "Секунди", "seconds": "Секунди",
"minutes": "Хвилини", "minutes": "Хвилини",
"hours": "Години" "hours": "Години",
"interval": "Інтервал",
"unit": "Одиниця"
}, },
"tgNotifyBackup": "Резервне копіювання бази даних", "tgNotifyBackup": "Резервне копіювання бази даних",
"tgNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.", "tgNotifyBackupDesc": "Надіслати файл резервної копії бази даних зі звітом.",
@@ -1618,7 +1626,8 @@
"city": "Місто", "city": "Місто",
"allCities": "Усі міста", "allCities": "Усі міста",
"privateKey": "Приватний ключ", "privateKey": "Приватний ключ",
"load": "Навантаження" "load": "Навантаження",
"moveToTop": "Перемістити вгору"
}, },
"outboundSub": { "outboundSub": {
"manage": "Підписки", "manage": "Підписки",
@@ -1717,7 +1726,10 @@
"tolerance": "Допуск", "tolerance": "Допуск",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag." "balancerDesc": "Неможливо використовувати balancerTag і outboundTag одночасно. Якщо використовувати одночасно, працюватиме лише outboundTag.",
"costMatch": "Шаблон тегу",
"costValue": "Вага",
"costRegexp": "Збіг за регулярним виразом"
}, },
"wireguard": { "wireguard": {
"secretKey": "Приватний ключ", "secretKey": "Приватний ключ",

View File

@@ -15,6 +15,10 @@
"copied": "Đã sao chép", "copied": "Đã sao chép",
"more": "thêm", "more": "thêm",
"download": "Tải xuống", "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ú", "remark": "Ghi chú",
"enable": "Kích hoạt", "enable": "Kích hoạt",
"protocol": "Giao thức", "protocol": "Giao thức",
@@ -118,7 +122,8 @@
"link": "Quản lý", "link": "Quản lý",
"donate": "Quyên góp", "donate": "Quyên góp",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "Tài liệu" "docs": "Tài liệu",
"openMenu": "Mở menu"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "Header yêu cầu", "requestHeader": "Header yêu cầu",
"responseHeader": "Header phản hồi" "responseHeader": "Header phản hồi"
} }
} },
"sniffingDestOverride": "Ghi đè đích"
}, },
"clients": { "clients": {
"tabBasics": "Cơ bản", "tabBasics": "Cơ bản",
@@ -1142,7 +1148,9 @@
"custom": "Tùy chỉnh (crontab)", "custom": "Tùy chỉnh (crontab)",
"seconds": "Giây", "seconds": "Giây",
"minutes": "Phút", "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", "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.", "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ố", "city": "Thành phố",
"allCities": "Tất cả thành phố", "allCities": "Tất cả thành phố",
"privateKey": "Khóa riêng", "privateKey": "Khóa riêng",
"load": "Tải" "load": "Tải",
"moveToTop": "Chuyển lên đầu"
}, },
"outboundSub": { "outboundSub": {
"manage": "Đăng ký", "manage": "Đăng ký",
@@ -1717,7 +1726,10 @@
"tolerance": "Dung sai", "tolerance": "Dung sai",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "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": { "wireguard": {
"secretKey": "Khoá bí mật", "secretKey": "Khoá bí mật",

View File

@@ -15,6 +15,10 @@
"copied": "已复制", "copied": "已复制",
"more": "更多", "more": "更多",
"download": "下载", "download": "下载",
"regenerate": "重新生成",
"jsonEditor": "JSON 编辑器",
"downloadImage": "下载图片",
"sort": "排序",
"remark": "备注", "remark": "备注",
"enable": "启用", "enable": "启用",
"protocol": "协议", "protocol": "协议",
@@ -118,7 +122,8 @@
"link": "管理", "link": "管理",
"donate": "捐赠", "donate": "捐赠",
"hosts": "主机", "hosts": "主机",
"docs": "文档" "docs": "文档",
"openMenu": "打开菜单"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "请求头", "requestHeader": "请求头",
"responseHeader": "响应头" "responseHeader": "响应头"
} }
} },
"sniffingDestOverride": "目标覆盖"
}, },
"clients": { "clients": {
"tabBasics": "基本", "tabBasics": "基本",
@@ -1142,7 +1148,9 @@
"custom": "自定义 (crontab)", "custom": "自定义 (crontab)",
"seconds": "秒", "seconds": "秒",
"minutes": "分钟", "minutes": "分钟",
"hours": "小时" "hours": "小时",
"interval": "间隔",
"unit": "单位"
}, },
"tgNotifyBackup": "数据库备份", "tgNotifyBackup": "数据库备份",
"tgNotifyBackupDesc": "发送带有报告的数据库备份文件", "tgNotifyBackupDesc": "发送带有报告的数据库备份文件",
@@ -1618,7 +1626,8 @@
"city": "城市", "city": "城市",
"allCities": "所有城市", "allCities": "所有城市",
"privateKey": "私钥", "privateKey": "私钥",
"load": "负载" "load": "负载",
"moveToTop": "移到顶部"
}, },
"outboundSub": { "outboundSub": {
"manage": "订阅", "manage": "订阅",
@@ -1717,7 +1726,10 @@
"tolerance": "容差", "tolerance": "容差",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用则只有 outboundTag 会生效。" "balancerDesc": "无法同时使用 balancerTag 和 outboundTag。如果同时使用则只有 outboundTag 会生效。",
"costMatch": "标签匹配模式",
"costValue": "权重",
"costRegexp": "正则表达式匹配"
}, },
"wireguard": { "wireguard": {
"secretKey": "密钥", "secretKey": "密钥",

View File

@@ -15,6 +15,10 @@
"copied": "已複製", "copied": "已複製",
"more": "更多", "more": "更多",
"download": "下載", "download": "下載",
"regenerate": "重新產生",
"jsonEditor": "JSON 編輯器",
"downloadImage": "下載圖片",
"sort": "排序",
"remark": "備註", "remark": "備註",
"enable": "啟用", "enable": "啟用",
"protocol": "協議", "protocol": "協議",
@@ -118,7 +122,8 @@
"link": "管理", "link": "管理",
"donate": "捐贈", "donate": "捐贈",
"hosts": "Hosts", "hosts": "Hosts",
"docs": "文件" "docs": "文件",
"openMenu": "開啟選單"
}, },
"pages": { "pages": {
"login": { "login": {
@@ -712,7 +717,8 @@
"requestHeader": "請求頭", "requestHeader": "請求頭",
"responseHeader": "響應頭" "responseHeader": "響應頭"
} }
} },
"sniffingDestOverride": "目標覆寫"
}, },
"clients": { "clients": {
"tabBasics": "基本", "tabBasics": "基本",
@@ -1142,7 +1148,9 @@
"custom": "自訂 (crontab)", "custom": "自訂 (crontab)",
"seconds": "秒", "seconds": "秒",
"minutes": "分鐘", "minutes": "分鐘",
"hours": "小時" "hours": "小時",
"interval": "間隔",
"unit": "單位"
}, },
"tgNotifyBackup": "資料庫備份", "tgNotifyBackup": "資料庫備份",
"tgNotifyBackupDesc": "傳送帶有報告的資料庫備份檔案", "tgNotifyBackupDesc": "傳送帶有報告的資料庫備份檔案",
@@ -1618,7 +1626,8 @@
"city": "城市", "city": "城市",
"allCities": "所有城市", "allCities": "所有城市",
"privateKey": "私密金鑰", "privateKey": "私密金鑰",
"load": "負載" "load": "負載",
"moveToTop": "移到頂部"
}, },
"outboundSub": { "outboundSub": {
"manage": "訂閱", "manage": "訂閱",
@@ -1717,7 +1726,10 @@
"tolerance": "容差", "tolerance": "容差",
"baselines": "Baselines", "baselines": "Baselines",
"costs": "Costs", "costs": "Costs",
"balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用則只有 outboundTag 會生效。" "balancerDesc": "無法同時使用 balancerTag 和 outboundTag。如果同時使用則只有 outboundTag 會生效。",
"costMatch": "標籤比對模式",
"costValue": "權重",
"costRegexp": "正規表示式比對"
}, },
"wireguard": { "wireguard": {
"secretKey": "金鑰", "secretKey": "金鑰",