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