Просмотр исходного кода

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

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

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

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

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

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

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

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

New i18n key in all 13 locales: downloadImage.

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

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

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

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

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

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

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

make lint passes with the a11y ruleset enforced.

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

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

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

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

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

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

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

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

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

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

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

[email protected] 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.
nima1024m 19 часов назад
Родитель
Сommit
71aca2018a
82 измененных файлов с 1542 добавлено и 265 удалено
  1. 9 0
      frontend/eslint.config.js
  2. 838 7
      frontend/package-lock.json
  3. 4 0
      frontend/package.json
  4. 4 2
      frontend/src/components/clients/ConfigBlock.tsx
  5. 3 1
      frontend/src/components/form/DateTimePicker.tsx
  6. 3 1
      frontend/src/components/form/HeaderMapEditor.tsx
  7. 3 1
      frontend/src/components/form/JsonEditor.tsx
  8. 1 1
      frontend/src/components/form/RemarkTemplateField.tsx
  9. 4 0
      frontend/src/components/form/RemarkVarPicker.tsx
  10. 7 1
      frontend/src/components/ui/InputAddon.tsx
  11. 8 3
      frontend/src/components/ui/SettingListItem.tsx
  12. 3 1
      frontend/src/components/ui/notifications/NotificationHeader.tsx
  13. 11 1
      frontend/src/components/viz/Sparkline.tsx
  14. 1 1
      frontend/src/layouts/AppSidebar.tsx
  15. 6 1
      frontend/src/lib/xray/forms/SniffingFields.tsx
  16. 6 0
      frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx
  17. 71 7
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  18. 3 1
      frontend/src/pages/api-docs/ApiDocsPage.tsx
  19. 1 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  20. 12 10
      frontend/src/pages/clients/ClientFormModal.tsx
  21. 13 13
      frontend/src/pages/clients/ClientInfoModal.tsx
  22. 22 10
      frontend/src/pages/clients/ClientsPage.tsx
  23. 1 1
      frontend/src/pages/clients/SubLinksModal.tsx
  24. 3 3
      frontend/src/pages/groups/GroupsPage.tsx
  25. 4 4
      frontend/src/pages/hosts/HostList.tsx
  26. 2 0
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  27. 2 0
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  28. 1 0
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  29. 4 1
      frontend/src/pages/inbounds/form/FallbacksCard.tsx
  30. 1 1
      frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
  31. 1 0
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  32. 1 0
      frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
  33. 6 6
      frontend/src/pages/inbounds/form/protocols/tun.tsx
  34. 1 1
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  35. 1 1
      frontend/src/pages/inbounds/form/security/reality.tsx
  36. 1 0
      frontend/src/pages/inbounds/form/security/tls.tsx
  37. 1 1
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  38. 16 15
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  39. 15 6
      frontend/src/pages/inbounds/list/InboundList.tsx
  40. 2 2
      frontend/src/pages/inbounds/list/RowActions.tsx
  41. 14 6
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  42. 3 3
      frontend/src/pages/index/BackupModal.tsx
  43. 1 0
      frontend/src/pages/index/GeodataSection.tsx
  44. 25 4
      frontend/src/pages/index/IndexPage.tsx
  45. 3 2
      frontend/src/pages/index/LogModal.tsx
  46. 5 0
      frontend/src/pages/index/VersionModal.tsx
  47. 3 2
      frontend/src/pages/index/XrayLogModal.tsx
  48. 6 5
      frontend/src/pages/index/XrayStatusCard.tsx
  49. 31 13
      frontend/src/pages/nodes/NodeList.tsx
  50. 4 0
      frontend/src/pages/settings/TelegramTab.tsx
  51. 11 4
      frontend/src/pages/settings/TwoFactorModal.tsx
  52. 5 2
      frontend/src/pages/settings/catTabLabel.tsx
  53. 8 2
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  54. 3 3
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  55. 6 6
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  56. 3 1
      frontend/src/pages/xray/dns/DnsTab.tsx
  57. 6 3
      frontend/src/pages/xray/dns/useDnsColumns.tsx
  58. 4 3
      frontend/src/pages/xray/outbounds/OutboundCardList.tsx
  59. 7 7
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  60. 1 0
      frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx
  61. 6 0
      frontend/src/pages/xray/outbounds/protocols/dns.tsx
  62. 11 0
      frontend/src/pages/xray/outbounds/protocols/freedom.tsx
  63. 11 3
      frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
  64. 8 7
      frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
  65. 5 0
      frontend/src/pages/xray/routing/RouteTester.tsx
  66. 6 5
      frontend/src/pages/xray/routing/RuleCardList.tsx
  67. 12 9
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  68. 7 6
      frontend/src/pages/xray/routing/useRoutingColumns.tsx
  69. 10 0
      frontend/src/utils/a11y.ts
  70. 17 5
      internal/web/translation/ar-EG.json
  71. 17 5
      internal/web/translation/en-US.json
  72. 17 5
      internal/web/translation/es-ES.json
  73. 17 5
      internal/web/translation/fa-IR.json
  74. 17 5
      internal/web/translation/id-ID.json
  75. 17 5
      internal/web/translation/ja-JP.json
  76. 17 5
      internal/web/translation/pt-BR.json
  77. 17 5
      internal/web/translation/ru-RU.json
  78. 17 5
      internal/web/translation/tr-TR.json
  79. 17 5
      internal/web/translation/uk-UA.json
  80. 17 5
      internal/web/translation/vi-VN.json
  81. 17 5
      internal/web/translation/zh-CN.json
  82. 17 5
      internal/web/translation/zh-TW.json

+ 9 - 0
frontend/eslint.config.js

@@ -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',
+    },
+  },
 ];

Разница между файлами не показана из-за своего большого размера
+ 838 - 7
frontend/package-lock.json


+ 4 - 0
frontend/package.json

@@ -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",

+ 4 - 2
frontend/src/components/clients/ConfigBlock.tsx

@@ -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>
       )}

+ 3 - 1
frontend/src/components/form/DateTimePicker.tsx

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

+ 3 - 1
frontend/src/components/form/HeaderMapEditor.tsx

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

+ 3 - 1
frontend/src/components/form/JsonEditor.tsx

@@ -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;

+ 1 - 1
frontend/src/components/form/RemarkTemplateField.tsx

@@ -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>
         }

+ 4 - 0
frontend/src/components/form/RemarkVarPicker.tsx

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

+ 7 - 1
frontend/src/components/ui/InputAddon.tsx

@@ -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>

+ 8 - 3
frontend/src/components/ui/SettingListItem.tsx

@@ -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>

+ 3 - 1
frontend/src/components/ui/notifications/NotificationHeader.tsx

@@ -1,4 +1,5 @@
 import { useRef, useEffect } from 'react';
+import { useTranslation } from 'react-i18next';
 import { Tag } from 'antd';
 
 interface Props {
@@ -10,11 +11,12 @@ interface Props {
 }
 
 function MasterCheckbox({ checked, indeterminate, onChange }: { checked: boolean; indeterminate: boolean; onChange: () => void }) {
+  const { t } = useTranslation();
   const ref = useRef<HTMLInputElement>(null);
   useEffect(() => {
     if (ref.current) ref.current.indeterminate = indeterminate;
   }, [indeterminate]);
-  return <input ref={ref} type="checkbox" checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
+  return <input ref={ref} type="checkbox" aria-label={t('pages.clients.selectAll')} checked={checked} onChange={onChange} style={{ cursor: 'pointer' }} />;
 }
 
 export function NotificationHeader({ count, total, allSelected, indeterminate, onToggleAll }: Props) {

+ 11 - 1
frontend/src/components/viz/Sparkline.tsx

@@ -181,8 +181,18 @@ export default function Sparkline({
   const minColor = extrema?.minColor ?? DEFAULT_MIN_COLOR;
   const maxColor = extrema?.maxColor ?? DEFAULT_MAX_COLOR;
 
+  const ariaSummary = useMemo(() => {
+    if (points.length === 0) return name1 ?? '';
+    const last = points[points.length - 1];
+    const parts: string[] = [];
+    parts.push(name1 ? `${name1}: ${yFormatter(last.value)}` : yFormatter(last.value));
+    if (hasSeries2 && name2) parts.push(`${name2}: ${yFormatter(last.value2)}`);
+    if (hasSeries3 && name3) parts.push(`${name3}: ${yFormatter(last.value3)}`);
+    return parts.join(', ');
+  }, [points, name1, name2, name3, hasSeries2, hasSeries3, yFormatter]);
+
   return (
-    <div className="sparkline-container">
+    <div className="sparkline-container" role={ariaSummary ? 'img' : undefined} aria-label={ariaSummary || undefined}>
       {extremaPoints && (
         <div className="sparkline-extrema" aria-hidden="true">
           <span className="extrema-item" style={{ color: maxColor }}>

+ 1 - 1
frontend/src/layouts/AppSidebar.tsx

@@ -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 />

+ 6 - 1
frontend/src/lib/xray/forms/SniffingFields.tsx

@@ -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')}

+ 6 - 0
frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx

@@ -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']}>

+ 71 - 7
frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx

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

+ 3 - 1
frontend/src/pages/api-docs/ApiDocsPage.tsx

@@ -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"

+ 1 - 0
frontend/src/pages/clients/ClientBulkAddModal.tsx

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

+ 12 - 10
frontend/src/pages/clients/ClientFormModal.tsx

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

+ 13 - 13
frontend/src/pages/clients/ClientInfoModal.tsx

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

+ 22 - 10
frontend/src/pages/clients/ClientsPage.tsx

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

+ 1 - 1
frontend/src/pages/clients/SubLinksModal.tsx

@@ -111,7 +111,7 @@ export default function SubLinksModal({
       key: 'actions',
       width: 64,
       render: (_v, row) => (
-        <Button size="small" type="text" icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
+        <Button size="small" type="text" aria-label={t('copy')} icon={<CopyOutlined />} onClick={() => copy(row.link, t('copied'))} />
       ),
     },
   ];

+ 3 - 3
frontend/src/pages/groups/GroupsPage.tsx

@@ -404,10 +404,10 @@ export default function GroupsPage() {
       render: (_v, row) => (
         <Space size={4}>
           <Dropdown trigger={['click']} menu={{ items: rowActions(row) }}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
+            <Button aria-label={t('more')} size="small" type="text" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
           </Dropdown>
           <Tooltip title={t('pages.groups.rename')}>
-            <Button size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
+            <Button aria-label={t('pages.groups.rename')} size="small" type="text" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => openRename(row)} />
           </Tooltip>
         </Space>
       ),
@@ -522,7 +522,7 @@ export default function GroupsPage() {
                       hoverable
                       title={
                         <div className="card-toolbar">
-                          <Button type="primary" icon={<PlusOutlined />} onClick={openCreate}>
+                          <Button aria-label={t('pages.groups.addGroup')} type="primary" icon={<PlusOutlined />} onClick={openCreate}>
                             {!isMobile && t('pages.groups.addGroup')}
                           </Button>
                         </div>

+ 4 - 4
frontend/src/pages/hosts/HostList.tsx

@@ -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>
         );

+ 2 - 0
frontend/src/pages/inbounds/clients/AttachClientsModal.tsx

@@ -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}

+ 2 - 0
frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx

@@ -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}

+ 1 - 0
frontend/src/pages/inbounds/clients/DetachClientsModal.tsx

@@ -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')}

+ 4 - 1
frontend/src/pages/inbounds/form/FallbacksCard.tsx

@@ -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}>

+ 1 - 1
frontend/src/pages/inbounds/form/protocols/accounts-list.tsx

@@ -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>

+ 1 - 0
frontend/src/pages/inbounds/form/protocols/mtproto.tsx

@@ -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']);

+ 1 - 0
frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx

@@ -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']);

+ 6 - 6
frontend/src/pages/inbounds/form/protocols/tun.tsx

@@ -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>

+ 1 - 1
frontend/src/pages/inbounds/form/protocols/wireguard.tsx

@@ -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')}>

+ 1 - 1
frontend/src/pages/inbounds/form/security/reality.tsx

@@ -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

+ 1 - 0
frontend/src/pages/inbounds/form/security/tls.tsx

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

+ 1 - 1
frontend/src/pages/inbounds/form/transport/sockopt.tsx

@@ -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 && (
               <>

+ 16 - 15
frontend/src/pages/inbounds/info/InboundInfoModal.tsx

@@ -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>

+ 15 - 6
frontend/src/pages/inbounds/list/InboundList.tsx

@@ -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>

+ 2 - 2
frontend/src/pages/inbounds/list/RowActions.tsx

@@ -69,7 +69,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
   const { t } = useTranslation();
   return (
     <div className="action-buttons">
-      <Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} onClick={() => onClick('edit')} />
+      <Button type="text" size="small" style={{ fontSize: 16 }} icon={<EditOutlined />} aria-label={t('edit')} onClick={() => onClick('edit')} />
       <Dropdown
         trigger={['click']}
         menu={{
@@ -77,7 +77,7 @@ export function RowActionsCell({ record, subEnable, hasClients, onClick }: RowAc
           onClick: ({ key }) => onClick(key as RowAction),
         }}
       >
-        <Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} />
+        <Button type="text" size="small" style={{ fontSize: 16 }} icon={<MoreOutlined />} aria-label={t('more')} />
       </Dropdown>
     </div>
   );

+ 14 - 6
frontend/src/pages/inbounds/qr/QrPanel.tsx

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

+ 3 - 3
frontend/src/pages/index/BackupModal.tsx

@@ -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>

+ 1 - 0
frontend/src/pages/index/GeodataSection.tsx

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

+ 25 - 4
frontend/src/pages/index/IndexPage.tsx

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

+ 3 - 2
frontend/src/pages/index/LogModal.tsx

@@ -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>
 

+ 5 - 0
frontend/src/pages/index/VersionModal.tsx

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

+ 3 - 2
frontend/src/pages/index/XrayLogModal.tsx

@@ -4,6 +4,7 @@ import { Button, Checkbox, Form, Input, Modal, Select, Tag } from 'antd';
 import { DownloadOutlined, SyncOutlined } from '@ant-design/icons';
 
 import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { useDatepicker } from '@/hooks/useDatepicker';
 import { useMediaQuery } from '@/hooks/useMediaQuery';
 import './XrayLogModal.css';
@@ -132,7 +133,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
       title={
         <>
           {t('pages.index.accessLogs')}
-          <SyncOutlined spin={loading} className="reload-icon" onClick={refresh} />
+          <SyncOutlined spin={loading} className="reload-icon" role="button" tabIndex={0} aria-label={t('refresh')} onClick={refresh} onKeyDown={activateOnKey(refresh)} />
         </>
       }
     >
@@ -177,7 +178,7 @@ export default function XrayLogModal({ open, onClose }: XrayLogModalProps) {
           </Checkbox>
         </Form.Item>
         <Form.Item className="download-item">
-          <Button type="primary" onClick={download} icon={<DownloadOutlined />} />
+          <Button type="primary" onClick={download} icon={<DownloadOutlined />} aria-label={t('download')} />
         </Form.Item>
       </Form>
 

+ 6 - 5
frontend/src/pages/index/XrayStatusCard.tsx

@@ -9,6 +9,7 @@ import {
 } from '@ant-design/icons';
 
 import type { Status } from '@/models/status';
+import { activateOnKey } from '@/utils/a11y';
 import './XrayStatusCard.css';
 
 interface XrayStatusCardProps {
@@ -67,7 +68,7 @@ export default function XrayStatusCard({
               <span>{t('pages.index.xrayStatusError')}</span>
             </Col>
             <Col>
-              <BarsOutlined className="cursor-pointer" onClick={onOpenLogs} />
+              <BarsOutlined className="cursor-pointer" role="button" tabIndex={0} aria-label={t('pages.index.logs')} onClick={onOpenLogs} onKeyDown={activateOnKey(onOpenLogs)} />
             </Col>
           </Row>
         }
@@ -90,21 +91,21 @@ export default function XrayStatusCard({
     // sense when one is configured (unlike IP limit, which no longer needs it)
     ...(accessLogEnable
       ? [
-          <Space className="action" key="xraylogs" onClick={onOpenXrayLogs}>
+          <Space className="action" key="xraylogs" role="button" tabIndex={0} aria-label={t('pages.index.accessLogs')} onClick={onOpenXrayLogs} onKeyDown={activateOnKey(onOpenXrayLogs)}>
             <BarsOutlined />
             {!isMobile && <span>{t('pages.index.accessLogs')}</span>}
           </Space>,
         ]
       : []),
-    <Space className="action" key="stop" onClick={onStopXray}>
+    <Space className="action" key="stop" role="button" tabIndex={0} aria-label={t('pages.index.stopXray')} onClick={onStopXray} onKeyDown={activateOnKey(onStopXray)}>
       <PoweroffOutlined />
       {!isMobile && <span>{t('pages.index.stopXray')}</span>}
     </Space>,
-    <Space className="action" key="restart" onClick={onRestartXray}>
+    <Space className="action" key="restart" role="button" tabIndex={0} aria-label={t('pages.index.restartXray')} onClick={onRestartXray} onKeyDown={activateOnKey(onRestartXray)}>
       <ReloadOutlined />
       {!isMobile && <span>{t('pages.index.restartXray')}</span>}
     </Space>,
-    <Space className="action" key="switch" onClick={onOpenVersionSwitch}>
+    <Space className="action" key="switch" role="button" tabIndex={0} aria-label={t('pages.index.xraySwitch')} onClick={onOpenVersionSwitch} onKeyDown={activateOnKey(onOpenVersionSwitch)}>
       <ToolOutlined />
       {!isMobile && (
         <span>

+ 31 - 13
frontend/src/pages/nodes/NodeList.tsx

@@ -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>

+ 4 - 0
frontend/src/pages/settings/TelegramTab.tsx

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

+ 11 - 4
frontend/src/pages/settings/TwoFactorModal.tsx

@@ -4,6 +4,7 @@ import { Button, Divider, Input, Modal, QRCode, message } from 'antd';
 import * as OTPAuth from 'otpauth';
 
 import { ClipboardManager } from '@/utils';
+import { activateOnKey } from '@/utils/a11y';
 import { TotpCodeSchema } from '@/schemas/login';
 import './TwoFactorModal.css';
 
@@ -108,7 +109,14 @@ export default function TwoFactorModal({
           <p>{t('pages.settings.security.twoFactorModalSteps')}</p>
           <Divider />
           <p>{t('pages.settings.security.twoFactorModalFirstStep')}</p>
-          <div className="qr-wrap">
+          <div
+            className="qr-wrap"
+            role="button"
+            tabIndex={0}
+            aria-label={t('copy')}
+            onClick={copyToken}
+            onKeyDown={activateOnKey(copyToken)}
+          >
             <QRCode
               className="qr-code"
               value={qrValue}
@@ -119,18 +127,17 @@ export default function TwoFactorModal({
               bgColor="#ffffff"
               errorLevel="L"
               title={t('copy')}
-              onClick={copyToken}
             />
             <span className="qr-token">{token}</span>
           </div>
           <Divider />
           <p>{t('pages.settings.security.twoFactorModalSecondStep')}</p>
-          <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
+          <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
         </>
       ) : (
         <>
           <p>{description}</p>
-          <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
+          <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} aria-label={t('twoFactorCode')} />
         </>
       )}
       </Modal>

+ 5 - 2
frontend/src/pages/settings/catTabLabel.tsx

@@ -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 }}>

+ 8 - 2
frontend/src/pages/xray/balancers/BalancerFormModal.tsx

@@ -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>

+ 3 - 3
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -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>
 

+ 6 - 6
frontend/src/pages/xray/dns/DnsServerModal.tsx

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

+ 3 - 1
frontend/src/pages/xray/dns/DnsTab.tsx

@@ -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>

+ 6 - 3
frontend/src/pages/xray/dns/useDnsColumns.tsx

@@ -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],
   );
 }

+ 4 - 3
frontend/src/pages/xray/outbounds/OutboundCardList.tsx

@@ -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>

+ 7 - 7
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -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>
                     ),

+ 1 - 0
frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx

@@ -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}

+ 6 - 0
frontend/src/pages/xray/outbounds/protocols/dns.tsx

@@ -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>

+ 11 - 0
frontend/src/pages/xray/outbounds/protocols/freedom.tsx

@@ -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>

+ 11 - 3
frontend/src/pages/xray/outbounds/protocols/wireguard.tsx

@@ -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('')}
                         />
                       </>

+ 8 - 7
frontend/src/pages/xray/outbounds/useOutboundColumns.tsx

@@ -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>

+ 5 - 0
frontend/src/pages/xray/routing/RouteTester.tsx

@@ -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

+ 6 - 5
frontend/src/pages/xray/routing/RuleCardList.tsx

@@ -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>

+ 12 - 9
frontend/src/pages/xray/routing/RuleFormModal.tsx

@@ -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>
           }
         >

+ 7 - 6
frontend/src/pages/xray/routing/useRoutingColumns.tsx

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

+ 10 - 0
frontend/src/utils/a11y.ts

@@ -0,0 +1,10 @@
+import type { KeyboardEvent } from 'react';
+
+export function activateOnKey(handler: () => void) {
+  return (event: KeyboardEvent) => {
+    if (event.key === 'Enter' || event.key === ' ') {
+      event.preventDefault();
+      handler();
+    }
+  };
+}

+ 17 - 5
internal/web/translation/ar-EG.json

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

+ 17 - 5
internal/web/translation/en-US.json

@@ -15,6 +15,10 @@
   "copied": "Copied",
   "more": "more",
   "download": "Download",
+  "regenerate": "Regenerate",
+  "jsonEditor": "JSON editor",
+  "downloadImage": "Download Image",
+  "sort": "Sort",
   "remark": "Remark",
   "enable": "Enabled",
   "protocol": "Protocol",
@@ -118,7 +122,8 @@
     "logout": "Log Out",
     "link": "Manage",
     "donate": "Donate",
-    "docs": "Documentation"
+    "docs": "Documentation",
+    "openMenu": "Open menu"
   },
   "pages": {
     "login": {
@@ -712,7 +717,8 @@
           "requestHeader": "Request Header",
           "responseHeader": "Response Header"
         }
-      }
+      },
+      "sniffingDestOverride": "Destination override"
     },
     "clients": {
       "tabBasics": "Basics",
@@ -1260,7 +1266,9 @@
         "custom": "Custom (crontab)",
         "seconds": "Seconds",
         "minutes": "Minutes",
-        "hours": "Hours"
+        "hours": "Hours",
+        "interval": "Interval",
+        "unit": "Unit"
       },
       "tgNotifyBackup": "Database Backup",
       "tgNotifyBackupDesc": "Send a database backup file with a report.",
@@ -1734,7 +1742,8 @@
         "city": "City",
         "allCities": "All Cities",
         "privateKey": "Private Key",
-        "load": "Load"
+        "load": "Load",
+        "moveToTop": "Move to top"
       },
       "outboundSub": {
         "manage": "Subscriptions",
@@ -1833,7 +1842,10 @@
         "tolerance": "Tolerance",
         "baselines": "Baselines",
         "costs": "Costs",
-        "balancerDesc": "It is not possible to use balancerTag and outboundTag at the same time. If used at the same time, only outboundTag will work."
+        "balancerDesc": "It is not possible to use balancerTag and outboundTag at the same time. If used at the same time, only outboundTag will work.",
+        "costMatch": "Tag pattern",
+        "costValue": "Weight",
+        "costRegexp": "Regular expression match"
       },
       "wireguard": {
         "secretKey": "Secret Key",

+ 17 - 5
internal/web/translation/es-ES.json

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

+ 17 - 5
internal/web/translation/fa-IR.json

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

+ 17 - 5
internal/web/translation/id-ID.json

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

+ 17 - 5
internal/web/translation/ja-JP.json

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

+ 17 - 5
internal/web/translation/pt-BR.json

@@ -15,6 +15,10 @@
   "copied": "Copiado",
   "more": "mais",
   "download": "Baixar",
+  "regenerate": "Regenerar",
+  "jsonEditor": "Editor JSON",
+  "downloadImage": "Baixar imagem",
+  "sort": "Ordenar",
   "remark": "Observação",
   "enable": "Ativado",
   "protocol": "Protocolo",
@@ -118,7 +122,8 @@
     "link": "Gerenciar",
     "donate": "Doar",
     "hosts": "Hosts",
-    "docs": "Documentação"
+    "docs": "Documentação",
+    "openMenu": "Abrir menu"
   },
   "pages": {
     "login": {
@@ -712,7 +717,8 @@
           "requestHeader": "Cabeçalho da Requisição",
           "responseHeader": "Cabeçalho da Resposta"
         }
-      }
+      },
+      "sniffingDestOverride": "Substituição de destino"
     },
     "clients": {
       "tabBasics": "Básico",
@@ -1142,7 +1148,9 @@
         "custom": "Personalizado (crontab)",
         "seconds": "Segundos",
         "minutes": "Minutos",
-        "hours": "Horas"
+        "hours": "Horas",
+        "interval": "Intervalo",
+        "unit": "Unidade"
       },
       "tgNotifyBackup": "Backup do Banco de Dados",
       "tgNotifyBackupDesc": "Enviar arquivo de backup do banco de dados junto com o relatório.",
@@ -1618,7 +1626,8 @@
         "city": "Cidade",
         "allCities": "Todas as Cidades",
         "privateKey": "Chave Privada",
-        "load": "Carga"
+        "load": "Carga",
+        "moveToTop": "Mover para o topo"
       },
       "outboundSub": {
         "manage": "Assinaturas",
@@ -1717,7 +1726,10 @@
         "tolerance": "Tolerância",
         "baselines": "Baselines",
         "costs": "Costs",
-        "balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará."
+        "balancerDesc": "Não é possível usar balancerTag e outboundTag ao mesmo tempo. Se usados simultaneamente, apenas outboundTag funcionará.",
+        "costMatch": "Padrão de tag",
+        "costValue": "Peso",
+        "costRegexp": "Correspondência por expressão regular"
       },
       "wireguard": {
         "secretKey": "Chave Secreta",

+ 17 - 5
internal/web/translation/ru-RU.json

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

+ 17 - 5
internal/web/translation/tr-TR.json

@@ -15,6 +15,10 @@
   "copied": "Kopyalandı",
   "more": "Diğer",
   "download": "İndir",
+  "regenerate": "Yeniden Oluştur",
+  "jsonEditor": "JSON Düzenleyici",
+  "downloadImage": "Resmi İndir",
+  "sort": "Sırala",
   "remark": "Açıklama",
   "enable": "Etkin",
   "protocol": "Protokol",
@@ -118,7 +122,8 @@
     "link": "Yönet",
     "donate": "Bağış Yap",
     "hosts": "Host'lar",
-    "docs": "Belgeler"
+    "docs": "Belgeler",
+    "openMenu": "Menüyü aç"
   },
   "pages": {
     "login": {
@@ -712,7 +717,8 @@
           "requestHeader": "İstek Başlığı",
           "responseHeader": "Yanıt Başlığı"
         }
-      }
+      },
+      "sniffingDestOverride": "Hedef geçersiz kılma"
     },
     "clients": {
       "tabBasics": "Temel",
@@ -1142,7 +1148,9 @@
         "custom": "Özel (crontab)",
         "seconds": "Saniye",
         "minutes": "Dakika",
-        "hours": "Saat"
+        "hours": "Saat",
+        "interval": "Aralık",
+        "unit": "Birim"
       },
       "tgNotifyBackup": "Veritabanı Yedeği",
       "tgNotifyBackupDesc": "Bir rapor ile birlikte veritabanı yedek dosyasını gönderir.",
@@ -1618,7 +1626,8 @@
         "city": "Şehir",
         "allCities": "Tüm Şehirler",
         "privateKey": "Özel Anahtar",
-        "load": "Yükle"
+        "load": "Yükle",
+        "moveToTop": "En üste taşı"
       },
       "outboundSub": {
         "manage": "Abonelikler",
@@ -1717,7 +1726,10 @@
         "tolerance": "Tolerans",
         "baselines": "Baselines",
         "costs": "Costs",
-        "balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur."
+        "balancerDesc": "Dengeleyici Etiketi (balancerTag) ve Giden Bağlantı Etiketi (outboundTag) aynı anda kullanılamaz. Aynı anda kullanıldığında yalnızca giden bağlantı etiketi geçerli olur.",
+        "costMatch": "Etiket deseni",
+        "costValue": "Ağırlık",
+        "costRegexp": "Düzenli ifade eşleşmesi"
       },
       "wireguard": {
         "secretKey": "Gizli Anahtar",

+ 17 - 5
internal/web/translation/uk-UA.json

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

+ 17 - 5
internal/web/translation/vi-VN.json

@@ -15,6 +15,10 @@
   "copied": "Đã sao chép",
   "more": "thêm",
   "download": "Tải xuống",
+  "regenerate": "Tạo lại",
+  "jsonEditor": "Trình chỉnh sửa JSON",
+  "downloadImage": "Tải hình ảnh",
+  "sort": "Sắp xếp",
   "remark": "Ghi chú",
   "enable": "Kích hoạt",
   "protocol": "Giao thức",
@@ -118,7 +122,8 @@
     "link": "Quản lý",
     "donate": "Quyên góp",
     "hosts": "Hosts",
-    "docs": "Tài liệu"
+    "docs": "Tài liệu",
+    "openMenu": "Mở menu"
   },
   "pages": {
     "login": {
@@ -712,7 +717,8 @@
           "requestHeader": "Header yêu cầu",
           "responseHeader": "Header phản hồi"
         }
-      }
+      },
+      "sniffingDestOverride": "Ghi đè đích"
     },
     "clients": {
       "tabBasics": "Cơ bản",
@@ -1142,7 +1148,9 @@
         "custom": "Tùy chỉnh (crontab)",
         "seconds": "Giây",
         "minutes": "Phút",
-        "hours": "Giờ"
+        "hours": "Giờ",
+        "interval": "Khoảng thời gian",
+        "unit": "Đơn vị"
       },
       "tgNotifyBackup": "Sao lưu Cơ sở dữ liệu",
       "tgNotifyBackupDesc": "Bao gồm tệp sao lưu cơ sở dữ liệu với thông báo báo cáo.",
@@ -1618,7 +1626,8 @@
         "city": "Thành phố",
         "allCities": "Tất cả thành phố",
         "privateKey": "Khóa riêng",
-        "load": "Tải"
+        "load": "Tải",
+        "moveToTop": "Chuyển lên đầu"
       },
       "outboundSub": {
         "manage": "Đăng ký",
@@ -1717,7 +1726,10 @@
         "tolerance": "Dung sai",
         "baselines": "Baselines",
         "costs": "Costs",
-        "balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động."
+        "balancerDesc": "Không thể sử dụng balancerTag và outboundTag cùng một lúc. Nếu sử dụng cùng lúc thì chỉ outboundTag mới hoạt động.",
+        "costMatch": "Mẫu thẻ",
+        "costValue": "Trọng số",
+        "costRegexp": "Khớp biểu thức chính quy"
       },
       "wireguard": {
         "secretKey": "Khoá bí mật",

+ 17 - 5
internal/web/translation/zh-CN.json

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

+ 17 - 5
internal/web/translation/zh-TW.json

@@ -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": "金鑰",

Некоторые файлы не были показаны из-за большого количества измененных файлов