1
0

6 Коммиты 6c71b725da ... 86813758cc

Автор SHA1 Сообщение Дата
  MHSanaei 86813758cc fix(node): stop the offline-sync toast firing on saves to online nodes 7 часов назад
  MHSanaei 8332ba67ae chore(deps): bump antd to 6.5 and migrate deprecated component props 8 часов назад
  MHSanaei d8221a8153 fix(sub): bake Host VLESS Route into subscription UUIDs 11 часов назад
  MHSanaei 789e92cddc fix(clients): re-enable depleted clients on API renewal (#5619) 12 часов назад
  nima1024m 7a5d6da28c fix(xray): clean stale routing references when a balancer or outbound is deleted (#5648) 13 часов назад
  nima1024m 71aca2018a feat(a11y): screen-reader & keyboard accessibility across the panel (#5486) (#5652) 13 часов назад
100 измененных файлов с 1847 добавлено и 451 удалено
  1. 9 0
      frontend/eslint.config.js
  2. 546 122
      frontend/package-lock.json
  3. 10 6
      frontend/package.json
  4. 8 7
      frontend/public/openapi.json
  5. 4 2
      frontend/src/components/clients/ConfigBlock.tsx
  6. 3 1
      frontend/src/components/form/DateTimePicker.tsx
  7. 3 1
      frontend/src/components/form/HeaderMapEditor.tsx
  8. 3 1
      frontend/src/components/form/JsonEditor.tsx
  9. 2 2
      frontend/src/components/form/RemarkTemplateField.tsx
  10. 4 0
      frontend/src/components/form/RemarkVarPicker.tsx
  11. 7 1
      frontend/src/components/ui/InputAddon.tsx
  12. 8 3
      frontend/src/components/ui/SettingListItem.tsx
  13. 1 1
      frontend/src/components/ui/notifications/NotificationCard.tsx
  14. 1 1
      frontend/src/components/ui/notifications/NotificationGroup.tsx
  15. 3 1
      frontend/src/components/ui/notifications/NotificationHeader.tsx
  16. 11 1
      frontend/src/components/viz/Sparkline.tsx
  17. 1 1
      frontend/src/generated/examples.ts
  18. 2 1
      frontend/src/generated/schemas.ts
  19. 1 1
      frontend/src/layouts/AppSidebar.tsx
  20. 2 0
      frontend/src/lib/hosts/host-link.ts
  21. 6 1
      frontend/src/lib/xray/forms/SniffingFields.tsx
  22. 6 0
      frontend/src/lib/xray/forms/transport/CustomSockoptList.tsx
  23. 74 10
      frontend/src/lib/xray/forms/transport/FinalMaskForm.tsx
  24. 13 1
      frontend/src/lib/xray/inbound-link.ts
  25. 3 1
      frontend/src/pages/api-docs/ApiDocsPage.tsx
  26. 1 1
      frontend/src/pages/api-docs/endpoints.ts
  27. 1 0
      frontend/src/pages/clients/ClientBulkAddModal.tsx
  28. 12 10
      frontend/src/pages/clients/ClientFormModal.tsx
  29. 13 13
      frontend/src/pages/clients/ClientInfoModal.tsx
  30. 22 10
      frontend/src/pages/clients/ClientsPage.tsx
  31. 1 1
      frontend/src/pages/clients/SubLinksModal.tsx
  32. 3 3
      frontend/src/pages/groups/GroupsPage.tsx
  33. 1 1
      frontend/src/pages/hosts/HostFormModal.tsx
  34. 4 4
      frontend/src/pages/hosts/HostList.tsx
  35. 2 0
      frontend/src/pages/inbounds/clients/AttachClientsModal.tsx
  36. 2 0
      frontend/src/pages/inbounds/clients/AttachExistingClientsModal.tsx
  37. 1 0
      frontend/src/pages/inbounds/clients/DetachClientsModal.tsx
  38. 9 6
      frontend/src/pages/inbounds/form/FallbacksCard.tsx
  39. 1 1
      frontend/src/pages/inbounds/form/protocols/accounts-list.tsx
  40. 1 0
      frontend/src/pages/inbounds/form/protocols/mtproto.tsx
  41. 1 0
      frontend/src/pages/inbounds/form/protocols/shadowsocks.tsx
  42. 6 6
      frontend/src/pages/inbounds/form/protocols/tun.tsx
  43. 1 1
      frontend/src/pages/inbounds/form/protocols/wireguard.tsx
  44. 1 1
      frontend/src/pages/inbounds/form/security/reality.tsx
  45. 2 1
      frontend/src/pages/inbounds/form/security/tls.tsx
  46. 1 1
      frontend/src/pages/inbounds/form/transport/sockopt.tsx
  47. 16 15
      frontend/src/pages/inbounds/info/InboundInfoModal.tsx
  48. 15 6
      frontend/src/pages/inbounds/list/InboundList.tsx
  49. 2 2
      frontend/src/pages/inbounds/list/RowActions.tsx
  50. 14 6
      frontend/src/pages/inbounds/qr/QrPanel.tsx
  51. 3 3
      frontend/src/pages/index/BackupModal.tsx
  52. 1 0
      frontend/src/pages/index/GeodataSection.tsx
  53. 27 14
      frontend/src/pages/index/IndexPage.tsx
  54. 3 2
      frontend/src/pages/index/LogModal.tsx
  55. 5 0
      frontend/src/pages/index/VersionModal.tsx
  56. 3 2
      frontend/src/pages/index/XrayLogModal.tsx
  57. 6 5
      frontend/src/pages/index/XrayStatusCard.tsx
  58. 31 13
      frontend/src/pages/nodes/NodeList.tsx
  59. 4 0
      frontend/src/pages/settings/TelegramTab.tsx
  60. 11 4
      frontend/src/pages/settings/TwoFactorModal.tsx
  61. 5 2
      frontend/src/pages/settings/catTabLabel.tsx
  62. 40 0
      frontend/src/pages/xray/DeletionImpactList.tsx
  63. 8 2
      frontend/src/pages/xray/balancers/BalancerFormModal.tsx
  64. 11 17
      frontend/src/pages/xray/balancers/BalancersTab.tsx
  65. 0 16
      frontend/src/pages/xray/balancers/balancer-helpers.ts
  66. 2 2
      frontend/src/pages/xray/basics/BasicsTab.tsx
  67. 6 6
      frontend/src/pages/xray/dns/DnsServerModal.tsx
  68. 3 1
      frontend/src/pages/xray/dns/DnsTab.tsx
  69. 6 3
      frontend/src/pages/xray/dns/useDnsColumns.tsx
  70. 4 3
      frontend/src/pages/xray/outbounds/OutboundCardList.tsx
  71. 14 12
      frontend/src/pages/xray/outbounds/OutboundsTab.tsx
  72. 1 0
      frontend/src/pages/xray/outbounds/SubscriptionOutbounds.tsx
  73. 6 0
      frontend/src/pages/xray/outbounds/protocols/dns.tsx
  74. 11 0
      frontend/src/pages/xray/outbounds/protocols/freedom.tsx
  75. 11 3
      frontend/src/pages/xray/outbounds/protocols/wireguard.tsx
  76. 8 7
      frontend/src/pages/xray/outbounds/useOutboundColumns.tsx
  77. 236 0
      frontend/src/pages/xray/reference-cleanup.ts
  78. 5 0
      frontend/src/pages/xray/routing/RouteTester.tsx
  79. 6 5
      frontend/src/pages/xray/routing/RuleCardList.tsx
  80. 12 9
      frontend/src/pages/xray/routing/RuleFormModal.tsx
  81. 7 6
      frontend/src/pages/xray/routing/useRoutingColumns.tsx
  82. 3 2
      frontend/src/schemas/api/host.ts
  83. 1 0
      frontend/src/schemas/protocols/stream/external-proxy.ts
  84. 1 40
      frontend/src/test/balancer-observatory-sync.test.ts
  85. 6 0
      frontend/src/test/host-link.test.ts
  86. 11 0
      frontend/src/test/host-schema.test.ts
  87. 50 0
      frontend/src/test/inbound-link.test.ts
  88. 264 0
      frontend/src/test/routing-reference-cleanup.test.ts
  89. 10 0
      frontend/src/utils/a11y.ts
  90. 1 1
      go.mod
  91. 2 2
      go.sum
  92. 3 3
      internal/database/model/model.go
  93. 1 1
      internal/sub/clash_service.go
  94. 2 2
      internal/sub/endpoint.go
  95. 1 1
      internal/sub/endpoint_test.go
  96. 3 0
      internal/sub/host_sub.go
  97. 3 1
      internal/sub/json_service.go
  98. 8 6
      internal/sub/service.go
  99. 34 0
      internal/sub/vless_route.go
  100. 83 0
      internal/sub/vless_route_sub_test.go

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

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


+ 10 - 6
frontend/package.json

@@ -21,16 +21,16 @@
     "gen:zod": "cd .. && go run ./tools/openapigen"
   },
   "dependencies": {
-    "@ant-design/icons": "^6.2.5",
+    "@ant-design/icons": "^6.3.2",
     "@codemirror/lang-json": "^6.0.2",
     "@codemirror/theme-one-dark": "^6.1.3",
-    "@tanstack/react-query": "^5.101.1",
-    "@tanstack/react-query-devtools": "^5.101.1",
-    "antd": "^6.4.5",
+    "@tanstack/react-query": "^5.101.2",
+    "@tanstack/react-query-devtools": "^5.101.2",
+    "antd": "^6.5.0",
     "axios": "^1.18.1",
     "codemirror": "^6.0.2",
     "dayjs": "^1.11.21",
-    "i18next": "^26.3.2",
+    "i18next": "^26.3.3",
     "otpauth": "^9.5.1",
     "persian-calendar-suite": "^1.5.5",
     "qs": "^6.15.3",
@@ -51,7 +51,8 @@
     "@types/swagger-ui-react": "^5.18.0",
     "@vitejs/plugin-react": "^6.0.3",
     "@vitest/coverage-v8": "^4.1.9",
-    "eslint": "^10.5.0",
+    "eslint": "^10.6.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",

+ 8 - 7
frontend/public/openapi.json

@@ -1571,7 +1571,8 @@
             "type": "string"
           },
           "vlessRoute": {
-            "description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
+            "description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
+            "example": "443",
             "type": "string"
           }
         },
@@ -5848,7 +5849,7 @@
         "tags": [
           "Clients"
         ],
-        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
+        "summary": "Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: \"none\" clears it, \"xtls-rprx-vision\"/\"xtls-rprx-vision-udp443\" set it where the inbound supports it (omit or \"\" to leave it unchanged). Returns the adjusted count and per-email skip reasons.",
         "operationId": "post_panel_api_clients_bulkAdjust",
         "requestBody": {
           "required": true,
@@ -8130,7 +8131,7 @@
                       ],
                       "updatedAt": 0,
                       "verifyPeerCertByName": "",
-                      "vlessRoute": ""
+                      "vlessRoute": "443"
                     }
                   ]
                 }
@@ -8222,7 +8223,7 @@
                     ],
                     "updatedAt": 0,
                     "verifyPeerCertByName": "",
-                    "vlessRoute": ""
+                    "vlessRoute": "443"
                   }
                 }
               }
@@ -8317,7 +8318,7 @@
                       ],
                       "updatedAt": 0,
                       "verifyPeerCertByName": "",
-                      "vlessRoute": ""
+                      "vlessRoute": "443"
                     }
                   ]
                 }
@@ -8457,7 +8458,7 @@
                     ],
                     "updatedAt": 0,
                     "verifyPeerCertByName": "",
-                    "vlessRoute": ""
+                    "vlessRoute": "443"
                   }
                 }
               }
@@ -8569,7 +8570,7 @@
                     ],
                     "updatedAt": 0,
                     "verifyPeerCertByName": "",
-                    "vlessRoute": ""
+                    "vlessRoute": "443"
                   }
                 }
               }

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

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

@@ -47,7 +47,7 @@ export default function RemarkTemplateField({ value = '', onChange, maxLength, p
         maxLength={maxLength}
         placeholder={placeholder}
         onChange={(e) => onChange?.(e.target.value)}
-        addonAfter={
+        suffix={
           <Popover
             content={<RemarkVarPicker onPick={insertToken} />}
             trigger="click"
@@ -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={{ marginInlineEnd: -7 }} />
             </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, Fragment, 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) && node.type !== Fragment
+    ? cloneElement(node as ReactElement<{ 'aria-labelledby'?: string }>, { 'aria-labelledby': titleId })
+    : node;
   return (
     <div className="setting-list-item" style={{ padding }}>
       <Row gutter={[8, 16]} style={{ width: '100%' }}>
         <Col xs={24} lg={12}>
           <div className="setting-list-meta">
-            {title && <div className="setting-list-title">{title}</div>}
+            {title && <div className="setting-list-title" id={titleId}>{title}</div>}
             {description && <div className="setting-list-description">{description}</div>}
           </div>
         </Col>
         <Col xs={24} lg={12}>
-          {control ?? children}
+          {labelledNode}
         </Col>
       </Row>
     </div>

+ 1 - 1
frontend/src/components/ui/notifications/NotificationCard.tsx

@@ -12,7 +12,7 @@ export function NotificationCard({ icon, title, extra, children }: Props) {
   return (
     <Card
       size="small"
-      bordered
+      variant="outlined"
       title={<span>{icon} {title}</span>}
       extra={extra}
       style={{ borderWidth: 1 }}

+ 1 - 1
frontend/src/components/ui/notifications/NotificationGroup.tsx

@@ -40,7 +40,7 @@ export function NotificationGroup({ config, selected, onToggle, onToggleAll, all
         />
       }
     >
-      <Space direction="vertical" size={8} style={{ width: '100%' }}>
+      <Space orientation="vertical" size={8} style={{ width: '100%' }}>
         {config.events.map((event) => (
           <NotificationEvent
             key={event.key}

+ 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/generated/examples.ts

@@ -340,7 +340,7 @@ export const EXAMPLES: Record<string, unknown> = {
     ],
     "updatedAt": 0,
     "verifyPeerCertByName": "",
-    "vlessRoute": ""
+    "vlessRoute": "443"
   },
   "Inbound": {
     "clientStats": [

+ 2 - 1
frontend/src/generated/schemas.ts

@@ -1545,7 +1545,8 @@ export const SCHEMAS: Record<string, unknown> = {
         "type": "string"
       },
       "vlessRoute": {
-        "description": "VlessRoute is a free-form port/range routing spec (e.g. \"53,443,1000-2000\");\nstored verbatim, format-validated on the frontend.",
+        "description": "Single VLESS route value (0-65535) baked into the subscription UUID's 3rd\ngroup (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.",
+        "example": "443",
         "type": "string"
       }
     },

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

+ 2 - 0
frontend/src/lib/hosts/host-link.ts

@@ -17,6 +17,7 @@ export type HostLinkInput = Pick<
   | 'echConfigList'
   | 'overrideSniFromAddress'
   | 'keepSniBlank'
+  | 'vlessRoute'
 >;
 
 // hostToExternalProxyEntry projects a host onto the ExternalProxyEntry shape the
@@ -48,5 +49,6 @@ export function hostToExternalProxyEntry(host: HostLinkInput): ExternalProxyEntr
       host.pinnedPeerCertSha256 && host.pinnedPeerCertSha256.length > 0 ? host.pinnedPeerCertSha256 : undefined,
     verifyPeerCertByName: host.verifyPeerCertByName || undefined,
     echConfigList: host.echConfigList || undefined,
+    vlessRoute: host.vlessRoute || undefined,
   };
 }

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

+ 74 - 10
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
@@ -431,8 +444,17 @@ function FragmentRangeList({
             >
               <Input
                 placeholder={placeholder}
-                addonAfter={fields.length > minItems
-                  ? <DeleteOutlined className="danger-icon" onClick={() => remove(field.name)} />
+                suffix={fields.length > minItems
+                  ? (
+                    <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),
@@ -810,7 +854,7 @@ function GeckoPacketSizeInput({
   return (
     <Space.Compact block>
       <InputNumber
-        addonBefore="Min"
+        prefix="Min"
         min={GECKO_MIN_PACKET_SIZE}
         max={GECKO_MAX_PACKET_SIZE}
         precision={0}
@@ -820,7 +864,7 @@ function GeckoPacketSizeInput({
         style={{ width: '50%' }}
       />
       <InputNumber
-        addonBefore="Max"
+        prefix="Max"
         min={GECKO_MIN_PACKET_SIZE}
         max={GECKO_MAX_PACKET_SIZE}
         precision={0}
@@ -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>

+ 13 - 1
frontend/src/lib/xray/inbound-link.ts

@@ -326,6 +326,18 @@ export interface GenVlessLinkInput {
   externalProxy?: ExternalProxyEntry | null;
 }
 
+// Mirror of the Go applyVlessRoute: bake a single 0-65535 value into the UUID's
+// 3rd group (bytes 6-7), which xray reads as the vless route. Empty/invalid/non-
+// UUID input is returned unchanged.
+export function applyVlessRoute(id: string, route: string | undefined): string {
+  const r = (route ?? '').trim();
+  if (r === '' || !/^\d{1,5}$/.test(r)) return id;
+  const n = Number(r);
+  if (n > 65535) return id;
+  if (!/^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$/.test(id)) return id;
+  return id.slice(0, 14) + n.toString(16).padStart(4, '0') + id.slice(18);
+}
+
 // VLESS share link: vless://<uuid>@<host>:<port>?<query>#<remark>. The
 // query carries network type, encryption, network-specific knobs, and
 // security-specific knobs (TLS fingerprint/alpn/sni or Reality
@@ -437,7 +449,7 @@ export function genVlessLink(input: GenVlessLinkInput): string {
     params.set('flow', flow);
   }
 
-  const url = new URL(`vless://${clientId}@${formatUrlHost(address)}:${port}`);
+  const url = new URL(`vless://${applyVlessRoute(clientId, externalProxy?.vlessRoute)}@${formatUrlHost(address)}:${port}`);
   for (const [key, value] of params) url.searchParams.set(key, value);
   url.hash = encodeURIComponent(remark);
   return url.toString();

+ 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 - 1
frontend/src/pages/api-docs/endpoints.ts

@@ -673,7 +673,7 @@ export const sections: readonly Section[] = [
       {
         method: 'POST',
         path: '/panel/api/clients/bulkAdjust',
-        summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
+        summary: 'Shift expiry and/or traffic quota for many clients in one call. addDays/addBytes may be negative. Clients with unlimited expiry (expiryTime=0) or unlimited traffic (totalGB=0) are skipped for the corresponding field — bulk extend never converts unlimited to limited. A client that was auto-disabled solely because it was depleted (expired or over quota) is automatically re-enabled — locally and on its node — when the adjustment lifts it out of depletion; a manually-disabled or still-depleted client is left disabled. The optional flow directive sets the XTLS flow on every client: "none" clears it, "xtls-rprx-vision"/"xtls-rprx-vision-udp443" set it where the inbound supports it (omit or "" to leave it unchanged). Returns the adjusted count and per-email skip reasons.',
         body: '{\n  "emails": ["alice", "bob"],\n  "addDays": 30,\n  "addBytes": 53687091200,\n  "flow": "xtls-rprx-vision"\n}',
         response: '{\n  "success": true,\n  "obj": {\n    "adjusted": 2,\n    "skipped": [\n      { "email": "carol", "reason": "unlimited expiry" }\n    ]\n  }\n}',
       },

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

+ 1 - 1
frontend/src/pages/hosts/HostFormModal.tsx

@@ -260,7 +260,7 @@ export default function HostFormModal({ open, mode, host, inboundOptions, save,
                             <Input />
                           </Form.Item>
                           <Form.Item name="vlessRoute" label={t('pages.hosts.fields.vlessRoute')} tooltip={t('pages.hosts.hints.vlessRoute')}>
-                            <Input placeholder="53,443,1000-2000" />
+                            <Input placeholder="443" />
                           </Form.Item>
                           <Form.Item name="excludeFromSubTypes" label={t('pages.hosts.fields.excludeFromSubTypes')}>
                             <Select

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

+ 9 - 6
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,23 +79,25 @@ 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}>
                 <Input
-                  addonBefore="SNI"
+                  prefix="SNI"
                   placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
                   value={record.name}
                   onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })}
@@ -102,7 +105,7 @@ export default function FallbacksCard({
               </Col>
               <Col xs={24} sm={12}>
                 <Input
-                  addonBefore="ALPN"
+                  prefix="ALPN"
                   placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
                   value={record.alpn}
                   onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })}
@@ -110,7 +113,7 @@ export default function FallbacksCard({
               </Col>
               <Col xs={24} sm={12}>
                 <Input
-                  addonBefore="Path"
+                  prefix="Path"
                   placeholder="/"
                   value={record.path}
                   onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })}
@@ -118,7 +121,7 @@ export default function FallbacksCard({
               </Col>
               <Col xs={24} sm={12}>
                 <Input
-                  addonBefore="Dest"
+                  prefix="Dest"
                   placeholder={t('pages.inbounds.fallbacks.destPlaceholder') || 'auto'}
                   value={record.dest}
                   onChange={(e) => updateFallback(record.rowKey, { dest: e.target.value })}
@@ -126,7 +129,7 @@ export default function FallbacksCard({
               </Col>
               <Col xs={24} sm={12}>
                 <InputNumber
-                  addonBefore="xver"
+                  prefix="xver"
                   min={0}
                   max={2}
                   style={{ width: '100%' }}

+ 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

+ 2 - 1
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({
@@ -242,7 +243,7 @@ export default function TlsForm({
                   name={[certField.name, 'ocspStapling']}
                   label="OCSP Stapling"
                 >
-                  <InputNumber min={0} addonAfter="s" style={{ width: '50%' }} />
+                  <InputNumber min={0} suffix="s" style={{ width: '50%' }} />
                 </Form.Item>
                 <Form.Item
                   name={[certField.name, 'oneTimeLoading']}

+ 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))}
               />

+ 27 - 14
frontend/src/pages/index/IndexPage.tsx

@@ -34,10 +34,12 @@ import {
   DatabaseOutlined,
   ForkOutlined,
   CopyOutlined,
+  TelegramFilled,
 } from '@ant-design/icons';
 
 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 +213,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,23 +245,18 @@ export default function IndexPage() {
                       }
                       hoverable
                       actions={[
-                        <Space className="action" key="tg" onClick={openTelegram}>
-                          <svg
-                            viewBox="0 0 24 24"
-                            width="14"
-                            height="14"
-                            fill="currentColor"
-                            className="tg-icon"
-                            aria-hidden="true"
-                          >
-                            <path d="M21.93 4.34a1.5 1.5 0 0 0-2.05-1.6L2.97 9.6c-.92.36-.91 1.66.02 1.99l4.32 1.53 1.7 5.23a1 1 0 0 0 1.68.36l2.43-2.43 4.36 3.21a1.5 1.5 0 0 0 2.36-.91l3.09-13.86a1.5 1.5 0 0 0 0-.38ZM9.97 14.66l-.55 3.36-1.36-4.2 9.8-7.05-7.89 7.89Z" />
-                          </svg>
+                        <Space className="action" key="tg" role="button" tabIndex={0} aria-label="@XrayUI" onClick={openTelegram} onKeyDown={activateOnKey(openTelegram)}>
+                          <TelegramFilled className="tg-icon" aria-hidden="true" />
                           {!isMobile && <span>@XrayUI</span>}
                         </Space>,
                         <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 +279,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 +291,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 +402,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 }}>

+ 40 - 0
frontend/src/pages/xray/DeletionImpactList.tsx

@@ -0,0 +1,40 @@
+import { useTranslation } from 'react-i18next';
+
+import type { DeletionImpact } from './reference-cleanup';
+
+interface DeletionImpactListProps {
+  impact: DeletionImpact;
+}
+
+export default function DeletionImpactList({ impact }: DeletionImpactListProps) {
+  const { t } = useTranslation();
+
+  const lines: string[] = [];
+  for (const rule of impact.rules) {
+    lines.push(
+      rule.fate === 'removed'
+        ? t('pages.xray.refCleanup.ruleRemoved', { label: rule.label })
+        : t('pages.xray.refCleanup.ruleModified', { label: rule.label, keeps: rule.keeps ?? '' }),
+    );
+  }
+  for (const balancer of impact.balancers) {
+    lines.push(t('pages.xray.refCleanup.balancerRemoved', { tag: balancer.tag }));
+  }
+  if (impact.observatory) lines.push(t('pages.xray.observatory.deleteAlsoObservatory'));
+  if (impact.burst) lines.push(t('pages.xray.observatory.deleteAlsoBurst'));
+
+  if (lines.length === 0) return null;
+
+  return (
+    <div>
+      <p style={{ marginBottom: 8 }}>{t('pages.xray.refCleanup.header')}</p>
+      <ul style={{ margin: 0, paddingInlineStart: 20 }}>
+        {lines.map((line, i) => (
+          <li key={i}>
+            <bdi>{line}</bdi>
+          </li>
+        ))}
+      </ul>
+    </div>
+  );
+}

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

+ 11 - 17
frontend/src/pages/xray/balancers/BalancersTab.tsx

@@ -6,7 +6,9 @@ import type { ColumnsType } from 'antd/es/table';
 
 import BalancerFormModal from './BalancerFormModal';
 import type { BalancerFormValue } from './BalancerFormModal';
-import { syncObservatories, observersRemovedByDeletingBalancer } from './balancer-helpers';
+import { syncObservatories } from './balancer-helpers';
+import { planBalancerDeletion, applyBalancerDeletion } from '../reference-cleanup';
+import DeletionImpactList from '../DeletionImpactList';
 import ObservatorySettingsTab from './ObservatorySettingsTab';
 import { catTabLabel } from '@/pages/settings/catTabLabel';
 import { HttpUtil } from '@/utils';
@@ -185,24 +187,16 @@ export default function BalancersTab({
   }
 
   function confirmDelete(idx: number) {
-    const removed = templateSettings
-      ? observersRemovedByDeletingBalancer(templateSettings, idx)
-      : { observatory: false, burst: false };
-    const warnings: string[] = [];
-    if (removed.observatory) warnings.push(t('pages.xray.observatory.deleteAlsoObservatory'));
-    if (removed.burst) warnings.push(t('pages.xray.observatory.deleteAlsoBurst'));
+    const impact = templateSettings
+      ? planBalancerDeletion(templateSettings, idx)
+      : { rules: [], balancers: [], observatory: false, burst: false };
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
-      content: warnings.length ? warnings.join(' ') : undefined,
+      content: <DeletionImpactList impact={impact} />,
       okText: t('delete'),
       okType: 'danger',
       cancelText: t('cancel'),
-      onOk: () => mutate((tt) => {
-        if (tt.routing?.balancers) {
-          tt.routing.balancers.splice(idx, 1);
-          syncObservatories(tt);
-        }
-      }),
+      onOk: () => mutate((tt) => applyBalancerDeletion(tt, idx)),
     });
   }
 
@@ -217,7 +211,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 +243,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 +333,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>
 

+ 0 - 16
frontend/src/pages/xray/balancers/balancer-helpers.ts

@@ -72,19 +72,3 @@ export function syncObservatories(t: XraySettingsValue) {
     delete t.burstObservatory;
   }
 }
-
-export function observersRemovedByDeletingBalancer(
-  t: XraySettingsValue,
-  idx: number,
-): { observatory: boolean; burst: boolean } {
-  const hadObservatory = !!t.observatory;
-  const hadBurst = !!t.burstObservatory;
-  if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
-  const clone = JSON.parse(JSON.stringify(t)) as XraySettingsValue;
-  if (clone.routing?.balancers) clone.routing.balancers.splice(idx, 1);
-  syncObservatories(clone);
-  return {
-    observatory: hadObservatory && !clone.observatory,
-    burst: hadBurst && !clone.burstObservatory,
-  };
-}

+ 2 - 2
frontend/src/pages/xray/basics/BasicsTab.tsx

@@ -341,7 +341,7 @@ export default function BasicsTab({
                 min={0}
                 style={{ width: '100%' }}
                 placeholder="300"
-                addonAfter={t('pages.xray.seconds')}
+                suffix={t('pages.xray.seconds')}
                 onChange={(v) => setLevel0('connIdle', v as number | null)}
               />
             }
@@ -356,7 +356,7 @@ export default function BasicsTab({
                 min={0}
                 style={{ width: '100%' }}
                 placeholder={t('pages.xray.bufferSizePlaceholder')}
-                addonAfter="KB"
+                suffix="KB"
                 onChange={(v) => setLevel0('bufferSize', v as number | null)}
               />
             }

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

+ 14 - 12
frontend/src/pages/xray/outbounds/OutboundsTab.tsx

@@ -43,6 +43,8 @@ import TextModal from '@/components/feedback/TextModal';
 
 import OutboundFormModal from './OutboundFormModal';
 import { propagateOutboundTagRename } from '../basics/helpers';
+import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
+import DeletionImpactList from '../DeletionImpactList';
 import type { XraySettingsValue, SetTemplate, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
 import './OutboundsTab.css';
 
@@ -208,16 +210,16 @@ export default function OutboundsTab({
   }
 
   function confirmDelete(idx: number) {
+    const impact = templateSettings
+      ? planOutboundDeletion(templateSettings, idx)
+      : { rules: [], balancers: [], observatory: false, burst: false };
     modal.confirm({
       title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
+      content: <DeletionImpactList impact={impact} />,
       okText: t('delete'),
       okType: 'danger',
       cancelText: t('cancel'),
-      onOk: () => {
-        mutate((tt) => {
-          tt.outbounds?.splice(idx, 1);
-        });
-      },
+      onOk: () => mutate((tt) => applyOutboundDeletion(tt, idx)),
     });
   }
   function setFirst(idx: number) {
@@ -496,7 +498,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 +659,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 +682,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 +718,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>

+ 236 - 0
frontend/src/pages/xray/reference-cleanup.ts

@@ -0,0 +1,236 @@
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+import type { BalancerObject, RuleObject } from '@/schemas/routing';
+import { syncObservatories } from './balancers/balancer-helpers';
+
+/**
+ * Reference cleanup for the Xray-config blob: when an outbound or balancer is
+ * deleted, routing rules and balancers that point at it must be repaired in the
+ * same edit, or the saved config breaks the core (a dangling balancerTag stops
+ * Router.Init; a dangling outboundTag black-holes matched traffic).
+ *
+ * Keep/drop a rule by its destination: after the deletion, a rule that still has
+ * an outboundTag or balancerTag is kept (the dead reference is dropped); a rule
+ * left with neither is removed, since a destination-less rule black-holes the
+ * traffic it matches. Deleting an outbound cascades: if it empties a balancer's
+ * selector, that balancer is removed too, and its rules are repaired the same way.
+ */
+
+export type RuleFate = 'removed' | 'modified';
+
+export interface RuleImpact {
+  index: number;
+  label: string;
+  fate: RuleFate;
+  keeps?: string;
+}
+
+export interface BalancerImpact {
+  tag: string;
+  reason: 'selectorEmptied';
+}
+
+export interface DeletionImpact {
+  rules: RuleImpact[];
+  balancers: BalancerImpact[];
+  observatory: boolean;
+  burst: boolean;
+}
+
+const emptyImpact = (): DeletionImpact => ({ rules: [], balancers: [], observatory: false, burst: false });
+
+function ruleList(tt: XraySettingsValue): RuleObject[] {
+  const r = tt.routing?.rules;
+  return Array.isArray(r) ? r : [];
+}
+
+function balancerList(tt: XraySettingsValue): BalancerObject[] {
+  const b = tt.routing?.balancers;
+  return Array.isArray(b) ? b : [];
+}
+
+function outboundTagAt(tt: XraySettingsValue, index: number): string {
+  const o = tt.outbounds?.[index];
+  return typeof o?.tag === 'string' ? o.tag : '';
+}
+
+function balancerTagAt(tt: XraySettingsValue, index: number): string {
+  const b = balancerList(tt)[index];
+  return typeof b?.tag === 'string' ? b.tag : '';
+}
+
+function ruleLabel(rule: RuleObject, index: number): string {
+  const tag = typeof rule.ruleTag === 'string' ? rule.ruleTag.trim() : '';
+  return tag || `#${index + 1}`;
+}
+
+/** Balancers whose selector is left empty once `removedOutbounds` are gone. */
+function balancersEmptiedBy(tt: XraySettingsValue, removedOutbounds: Set<string>): string[] {
+  if (removedOutbounds.size === 0) return [];
+  const emptied: string[] = [];
+  for (const b of balancerList(tt)) {
+    const tag = typeof b?.tag === 'string' ? b.tag : '';
+    if (tag === '') continue;
+    const selector = Array.isArray(b?.selector) ? b.selector : [];
+    if (selector.length === 0) continue;
+    if (selector.every((s) => removedOutbounds.has(s))) emptied.push(tag);
+  }
+  return emptied;
+}
+
+/**
+ * Single source of truth for how a deletion affects one rule, shared by the
+ * preview (`ruleImpacts`) and the mutation (`applyCleanup`) so the two can never
+ * disagree. Returns null when the rule is untouched; otherwise `keeps` names the
+ * surviving destination, or is '' when none remains and the rule must be dropped.
+ */
+function classifyRule(
+  rule: RuleObject,
+  removedOutbounds: Set<string>,
+  removedBalancers: Set<string>,
+): { losesOut: boolean; losesBal: boolean; keeps: string } | null {
+  const out = typeof rule?.outboundTag === 'string' ? rule.outboundTag : '';
+  const bal = typeof rule?.balancerTag === 'string' ? rule.balancerTag : '';
+  const losesOut = out !== '' && removedOutbounds.has(out);
+  const losesBal = bal !== '' && removedBalancers.has(bal);
+  if (!losesOut && !losesBal) return null;
+  const keptOut = out !== '' && !losesOut ? out : '';
+  const keptBal = bal !== '' && !losesBal ? bal : '';
+  return { losesOut, losesBal, keeps: keptOut || keptBal };
+}
+
+function ruleImpacts(
+  tt: XraySettingsValue,
+  removedOutbounds: Set<string>,
+  removedBalancers: Set<string>,
+): RuleImpact[] {
+  const impacts: RuleImpact[] = [];
+  ruleList(tt).forEach((rule, index) => {
+    const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
+    if (!verdict) return;
+    impacts.push(
+      verdict.keeps
+        ? { index, label: ruleLabel(rule, index), fate: 'modified', keeps: verdict.keeps }
+        : { index, label: ruleLabel(rule, index), fate: 'removed' },
+    );
+  });
+  return impacts;
+}
+
+function applyCleanup(
+  tt: XraySettingsValue,
+  removedOutbounds: Set<string>,
+  removedBalancers: Set<string>,
+): void {
+  if (tt.routing && Array.isArray(tt.routing.rules)) {
+    const next: RuleObject[] = [];
+    for (const rule of tt.routing.rules) {
+      const verdict = classifyRule(rule, removedOutbounds, removedBalancers);
+      if (!verdict) {
+        next.push(rule);
+        continue;
+      }
+      if (verdict.losesOut) delete rule.outboundTag;
+      if (verdict.losesBal) delete rule.balancerTag;
+      if (verdict.keeps) next.push(rule);
+    }
+    tt.routing.rules = next;
+  }
+
+  if (tt.routing && Array.isArray(tt.routing.balancers)) {
+    const survivors: BalancerObject[] = [];
+    for (const balancer of tt.routing.balancers) {
+      if (!balancer) continue;
+      if (removedBalancers.has(balancer.tag)) continue;
+      if (removedOutbounds.size > 0 && Array.isArray(balancer.selector)) {
+        balancer.selector = balancer.selector.filter((s) => !removedOutbounds.has(s));
+      }
+      if (typeof balancer.fallbackTag === 'string' && removedOutbounds.has(balancer.fallbackTag)) {
+        balancer.fallbackTag = '';
+      }
+      survivors.push(balancer);
+    }
+    tt.routing.balancers = survivors;
+  }
+
+  if (removedOutbounds.size > 0 && Array.isArray(tt.outbounds)) {
+    tt.outbounds = tt.outbounds.filter(
+      (o) => !(typeof o?.tag === 'string' && removedOutbounds.has(o.tag)),
+    );
+    for (const outbound of tt.outbounds) {
+      const sockopt = (outbound as { streamSettings?: { sockopt?: { dialerProxy?: string } } })
+        ?.streamSettings?.sockopt;
+      if (sockopt && typeof sockopt.dialerProxy === 'string' && removedOutbounds.has(sockopt.dialerProxy)) {
+        delete sockopt.dialerProxy;
+      }
+    }
+  }
+
+  syncObservatories(tt);
+}
+
+function observersRemovedBy(
+  tt: XraySettingsValue,
+  removedOutbounds: Set<string>,
+  removedBalancers: Set<string>,
+): { observatory: boolean; burst: boolean } {
+  const hadObservatory = !!tt.observatory;
+  const hadBurst = !!tt.burstObservatory;
+  if (!hadObservatory && !hadBurst) return { observatory: false, burst: false };
+  const clone = JSON.parse(JSON.stringify(tt)) as XraySettingsValue;
+  applyCleanup(clone, removedOutbounds, removedBalancers);
+  return {
+    observatory: hadObservatory && !clone.observatory,
+    burst: hadBurst && !clone.burstObservatory,
+  };
+}
+
+export function planBalancerDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
+  const tag = balancerTagAt(tt, index);
+  if (!tag) return emptyImpact();
+  const removedOutbounds = new Set<string>();
+  const removedBalancers = new Set([tag]);
+  const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
+  return {
+    rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
+    balancers: [],
+    observatory: obs.observatory,
+    burst: obs.burst,
+  };
+}
+
+export function applyBalancerDeletion(tt: XraySettingsValue, index: number): void {
+  const tag = balancerTagAt(tt, index);
+  if (!tag) {
+    if (tt.routing && Array.isArray(tt.routing.balancers)) tt.routing.balancers.splice(index, 1);
+    syncObservatories(tt);
+    return;
+  }
+  applyCleanup(tt, new Set<string>(), new Set([tag]));
+}
+
+export function planOutboundDeletion(tt: XraySettingsValue, index: number): DeletionImpact {
+  const tag = outboundTagAt(tt, index);
+  if (!tag) return emptyImpact();
+  const removedOutbounds = new Set([tag]);
+  const cascaded = balancersEmptiedBy(tt, removedOutbounds);
+  const removedBalancers = new Set(cascaded);
+  const obs = observersRemovedBy(tt, removedOutbounds, removedBalancers);
+  return {
+    rules: ruleImpacts(tt, removedOutbounds, removedBalancers),
+    balancers: cascaded.map((bTag) => ({ tag: bTag, reason: 'selectorEmptied' as const })),
+    observatory: obs.observatory,
+    burst: obs.burst,
+  };
+}
+
+export function applyOutboundDeletion(tt: XraySettingsValue, index: number): void {
+  const tag = outboundTagAt(tt, index);
+  if (!tag) {
+    if (Array.isArray(tt.outbounds)) tt.outbounds.splice(index, 1);
+    syncObservatories(tt);
+    return;
+  }
+  const removedOutbounds = new Set([tag]);
+  const removedBalancers = new Set(balancersEmptiedBy(tt, removedOutbounds));
+  applyCleanup(tt, removedOutbounds, removedBalancers);
+}

+ 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>
           ) : (

+ 3 - 2
frontend/src/schemas/api/host.ts

@@ -58,11 +58,12 @@ export const HostFormSchema = z.object({
   muxParams: z.string().default(''),
   sockoptParams: z.string().default(''),
   finalMask: z.string().default(''),
-  // A comma-separated list of ports/ranges (e.g. "53,443,1000-2000"). Empty = none.
+  // Single value 0-65535 baked into the subscription UUID's 3rd group. Empty = none.
   vlessRoute: z
     .string()
     .trim()
-    .regex(/^(\d{1,5}(-\d{1,5})?)(\s*,\s*\d{1,5}(-\d{1,5})?)*$/, 'pages.hosts.toasts.badVlessRoute')
+    .regex(/^\d{1,5}$/, 'pages.hosts.toasts.badVlessRoute')
+    .refine((v) => Number(v) <= 65535, 'pages.hosts.toasts.badVlessRoute')
     .or(z.literal(''))
     .default(''),
 

+ 1 - 0
frontend/src/schemas/protocols/stream/external-proxy.ts

@@ -25,5 +25,6 @@ export const ExternalProxyEntrySchema = z.object({
   pinnedPeerCertSha256: z.array(z.string()).optional(),
   verifyPeerCertByName: z.string().optional(),
   echConfigList: z.string().optional(),
+  vlessRoute: z.string().optional(),
 });
 export type ExternalProxyEntry = z.infer<typeof ExternalProxyEntrySchema>;

+ 1 - 40
frontend/src/test/balancer-observatory-sync.test.ts

@@ -1,6 +1,6 @@
 import { describe, expect, it } from 'vitest';
 
-import { observersRemovedByDeletingBalancer, syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
+import { syncObservatories } from '@/pages/xray/balancers/balancer-helpers';
 import type { XraySettingsValue } from '@/hooks/useXraySetting';
 
 function tpl(routing: Record<string, unknown>, extra: Record<string, unknown> = {}): XraySettingsValue {
@@ -138,42 +138,3 @@ describe('syncObservatories', () => {
     );
   });
 });
-
-describe('observersRemovedByDeletingBalancer', () => {
-  it('reports the burst observer as removed when deleting the last leastLoad balancer', () => {
-    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
-    syncObservatories(t);
-    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: true });
-  });
-
-  it('keeps the burst observer when another balancer still needs it (overlap)', () => {
-    const t = tpl({
-      balancers: [
-        { tag: 'a', selector: ['prefixA', 'prefixB'], strategy: { type: 'leastLoad' } },
-        { tag: 'b', selector: ['prefixC', 'prefixB'], strategy: { type: 'leastLoad' } },
-      ],
-    });
-    syncObservatories(t);
-    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
-  });
-
-  it('reports the regular observer as removed when deleting the last leastPing balancer', () => {
-    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] });
-    syncObservatories(t);
-    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: true, burst: false });
-  });
-
-  it('reports nothing removed when the balancer never had an observer', () => {
-    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'] }] });
-    syncObservatories(t);
-    expect(observersRemovedByDeletingBalancer(t, 0)).toEqual({ observatory: false, burst: false });
-  });
-
-  it('does not mutate the template it inspects', () => {
-    const t = tpl({ balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastLoad' } }] });
-    syncObservatories(t);
-    const before = JSON.stringify(t);
-    observersRemovedByDeletingBalancer(t, 0);
-    expect(JSON.stringify(t)).toBe(before);
-  });
-});

+ 6 - 0
frontend/src/test/host-link.test.ts

@@ -17,6 +17,7 @@ describe('hostToExternalProxyEntry', () => {
     echConfigList: 'ECH',
     overrideSniFromAddress: false,
     keepSniBlank: false,
+    vlessRoute: '',
   };
 
   it('maps the overlapping fields onto an external-proxy entry', () => {
@@ -53,4 +54,9 @@ describe('hostToExternalProxyEntry', () => {
     const ep = hostToExternalProxyEntry({ ...base, port: 0 });
     expect(ep.port).toBe(443);
   });
+
+  it('carries a single vlessRoute value through to the entry', () => {
+    expect(hostToExternalProxyEntry({ ...base, vlessRoute: '443' }).vlessRoute).toBe('443');
+    expect(hostToExternalProxyEntry({ ...base, vlessRoute: '' }).vlessRoute).toBeUndefined();
+  });
 });

+ 11 - 0
frontend/src/test/host-schema.test.ts

@@ -36,6 +36,17 @@ describe('HostFormSchema', () => {
     expect(() => HostFormSchema.parse({ ...valid, port: 70000 })).toThrow();
   });
 
+  it('accepts a single vlessRoute 0-65535 and rejects specs/out-of-range', () => {
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '443' })).not.toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '0' })).not.toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '65535' })).not.toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '' })).not.toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '53,443' })).toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '1000-2000' })).toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: '70000' })).toThrow();
+    expect(() => HostFormSchema.parse({ ...valid, vlessRoute: 'abc' })).toThrow();
+  });
+
   it('rejects a bad security enum', () => {
     expect(() => HostFormSchema.parse({ ...valid, security: 'bogus' })).toThrow();
   });

+ 50 - 0
frontend/src/test/inbound-link.test.ts

@@ -6,6 +6,7 @@ import {
   genInboundLinks,
   genShadowsocksLink,
   genTrojanLink,
+  applyVlessRoute,
   genVlessLink,
   genVmessLink,
   genWireguardConfig,
@@ -88,6 +89,55 @@ describe('genVlessLink', () => {
   }
 });
 
+describe('applyVlessRoute', () => {
+  const id = '11111111-2222-4333-8444-555555555555';
+  it('encodes a single value into the 3rd group and no-ops on invalid input', () => {
+    expect(applyVlessRoute(id, '443')).toBe('11111111-2222-01bb-8444-555555555555');
+    expect(applyVlessRoute(id, '53')).toBe('11111111-2222-0035-8444-555555555555');
+    expect(applyVlessRoute(id, '0')).toBe('11111111-2222-0000-8444-555555555555');
+    expect(applyVlessRoute(id, '65535')).toBe('11111111-2222-ffff-8444-555555555555');
+    expect(applyVlessRoute(id, '')).toBe(id);
+    expect(applyVlessRoute(id, undefined)).toBe(id);
+    expect(applyVlessRoute(id, '70000')).toBe(id);
+    expect(applyVlessRoute(id, '53,443')).toBe(id);
+    expect(applyVlessRoute(id, 'abc')).toBe(id);
+    expect(applyVlessRoute('short', '443')).toBe('short');
+  });
+});
+
+describe('genVlessLink vlessRoute', () => {
+  const [, raw] = fixturesForProtocol('vless')[0];
+  const typed = InboundSchema.parse(raw);
+
+  it('bakes a host route value into the link UUID 3rd group', () => {
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'example.test',
+      port: typed.port,
+      forceTls: 'same',
+      remark: 'r',
+      clientId: '11111111-2222-4333-8444-555555555555',
+      flow: '' as never,
+      externalProxy: { forceTls: 'same', dest: 'example.test', port: typed.port, remark: '', vlessRoute: '443' },
+    });
+    expect(link).toContain('vless://11111111-2222-01bb-8444-555555555555@');
+  });
+
+  it('leaves the UUID unchanged when no route is set', () => {
+    const link = genVlessLink({
+      inbound: typed,
+      address: 'example.test',
+      port: typed.port,
+      forceTls: 'same',
+      remark: 'r',
+      clientId: '11111111-2222-4333-8444-555555555555',
+      flow: '' as never,
+      externalProxy: null,
+    });
+    expect(link).toContain('vless://11111111-2222-4333-8444-555555555555@');
+  });
+});
+
 describe('genTrojanLink', () => {
   const fixtures = fixturesForProtocol('trojan');
   expect(fixtures.length, 'need at least one trojan full-inbound fixture').toBeGreaterThan(0);

+ 264 - 0
frontend/src/test/routing-reference-cleanup.test.ts

@@ -0,0 +1,264 @@
+import { describe, expect, it } from 'vitest';
+
+import {
+  applyBalancerDeletion,
+  applyOutboundDeletion,
+  planBalancerDeletion,
+  planOutboundDeletion,
+} from '@/pages/xray/reference-cleanup';
+import type { XraySettingsValue } from '@/hooks/useXraySetting';
+
+function tpl(parts: Record<string, unknown>): XraySettingsValue {
+  return parts as unknown as XraySettingsValue;
+}
+
+function dialerProxyOf(tt: XraySettingsValue, tag: string): string | undefined {
+  const o = tt.outbounds?.find((x) => x?.tag === tag);
+  return (o as { streamSettings?: { sockopt?: { dialerProxy?: string } } } | undefined)
+    ?.streamSettings?.sockopt?.dialerProxy;
+}
+
+describe('outbound deletion', () => {
+  it('drops a rule whose only destination was the deleted outbound', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: { rules: [{ type: 'field', inboundTag: ['in-443'], outboundTag: 'proxy-us' }], balancers: [] },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.rules).toEqual([]);
+    expect(tt.outbounds).toEqual([]);
+  });
+
+  it('keeps a rule that still has a balancer, dropping only the dead outboundTag', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: {
+        rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'eu-pool' }],
+        balancers: [{ tag: 'eu-pool', selector: ['direct'] }],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.rules).toHaveLength(1);
+    expect(tt.routing!.rules![0].outboundTag).toBeUndefined();
+    expect(tt.routing!.rules![0].balancerTag).toBe('eu-pool');
+  });
+
+  it('reduces a multi-target selector and leaves the balancer and its rules intact', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }, { tag: 'proxy-uk' }],
+      routing: {
+        rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }],
+        balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'] }],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
+    expect(tt.routing!.rules).toHaveLength(1);
+    expect(tt.routing!.rules![0].balancerTag).toBe('pool');
+    expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['proxy-uk']);
+  });
+
+  it('cascade-removes a balancer whose selector is emptied, repairing its rules', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: {
+        rules: [
+          { type: 'field', inboundTag: ['in'], balancerTag: 'pool' },
+          { type: 'field', outboundTag: 'direct', balancerTag: 'pool' },
+        ],
+        balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+      },
+    });
+    const impact = planOutboundDeletion(tt, 0);
+    expect(impact.balancers).toEqual([{ tag: 'pool', reason: 'selectorEmptied' }]);
+    expect(impact.rules).toEqual([
+      { index: 0, label: '#1', fate: 'removed' },
+      { index: 1, label: '#2', fate: 'modified', keeps: 'direct' },
+    ]);
+
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.balancers).toEqual([]);
+    expect(tt.routing!.rules).toHaveLength(1);
+    expect(tt.routing!.rules![0].outboundTag).toBe('direct');
+    expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
+  });
+
+  it('clears a fallbackTag and a dialerProxy pointing at the deleted outbound', () => {
+    const tt = tpl({
+      outbounds: [
+        { tag: 'proxy-us' },
+        { tag: 'chain', streamSettings: { sockopt: { dialerProxy: 'proxy-us' } } },
+      ],
+      routing: {
+        rules: [],
+        balancers: [{ tag: 'pool', selector: ['proxy-us', 'proxy-uk'], fallbackTag: 'proxy-us' }],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.balancers![0].selector).toEqual(['proxy-uk']);
+    expect(tt.routing!.balancers![0].fallbackTag).toBe('');
+    expect(dialerProxyOf(tt, 'chain')).toBeUndefined();
+  });
+
+  it('never cascade-removes a tagless balancer (an empty tag must not match others)', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: {
+        rules: [],
+        balancers: [
+          { tag: '', selector: ['proxy-us'] },
+          { tag: '', selector: ['keep-me'] },
+        ],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.balancers).toHaveLength(2);
+  });
+
+  it('does not throw on null entries in rules/balancers/outbounds (unvalidated config)', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }, null],
+      routing: {
+        rules: [null, { type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' }],
+        balancers: [null, { tag: 'pool', selector: ['keep'] }],
+      },
+    });
+    expect(() => planOutboundDeletion(tt, 0)).not.toThrow();
+    expect(() => applyOutboundDeletion(tt, 0)).not.toThrow();
+    expect(tt.routing!.balancers).toEqual([{ tag: 'pool', selector: ['keep'] }]);
+  });
+
+  it('drops a rule that loses BOTH destinations in one cascade', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: {
+        rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
+        balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.rules).toEqual([]);
+    expect(tt.routing!.balancers).toEqual([]);
+  });
+
+  it('cleans a disabled rule too', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: {
+        rules: [{ type: 'field', enabled: false, inboundTag: ['in'], outboundTag: 'proxy-us' }],
+        balancers: [],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.rules).toEqual([]);
+  });
+
+  it('leaves unrelated rules and outbounds untouched', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }, { tag: 'direct' }],
+      routing: {
+        rules: [
+          { type: 'field', inboundTag: ['in'], outboundTag: 'proxy-us' },
+          { type: 'field', inboundTag: ['in2'], outboundTag: 'direct' },
+        ],
+        balancers: [],
+      },
+    });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.routing!.rules).toHaveLength(1);
+    expect(tt.routing!.rules![0].outboundTag).toBe('direct');
+    expect((tt.outbounds || []).map((o) => o?.tag)).toEqual(['direct']);
+  });
+
+  it('removes a referenced outbound with no rules and reports an empty impact', () => {
+    const tt = tpl({ outbounds: [{ tag: 'lonely' }], routing: { rules: [], balancers: [] } });
+    expect(planOutboundDeletion(tt, 0)).toEqual({ rules: [], balancers: [], observatory: false, burst: false });
+    applyOutboundDeletion(tt, 0);
+    expect(tt.outbounds).toEqual([]);
+  });
+
+  it('uses ruleTag as the impact label when present', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'x' }],
+      routing: { rules: [{ type: 'field', ruleTag: 'block-ads', outboundTag: 'x' }], balancers: [] },
+    });
+    expect(planOutboundDeletion(tt, 0).rules[0].label).toBe('block-ads');
+  });
+
+  it('does not mutate the template when only planning', () => {
+    const tt = tpl({
+      outbounds: [{ tag: 'proxy-us' }],
+      routing: {
+        rules: [{ type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' }],
+        balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+      },
+      burstObservatory: { subjectSelector: ['proxy-us'] },
+    });
+    const before = JSON.stringify(tt);
+    planOutboundDeletion(tt, 0);
+    expect(JSON.stringify(tt)).toBe(before);
+  });
+
+  it('predicts the surviving rule count exactly (plan/apply parity)', () => {
+    const make = () =>
+      tpl({
+        outbounds: [{ tag: 'proxy-us' }],
+        routing: {
+          rules: [
+            { type: 'field', inboundTag: ['a'], outboundTag: 'proxy-us' },
+            { type: 'field', outboundTag: 'proxy-us', balancerTag: 'pool' },
+            { type: 'field', inboundTag: ['b'], outboundTag: 'direct' },
+            { type: 'field', inboundTag: ['c'], balancerTag: 'pool' },
+          ],
+          balancers: [{ tag: 'pool', selector: ['proxy-us'] }],
+        },
+      });
+    const planned = make();
+    const applied = make();
+    const total = planned.routing!.rules!.length;
+    const removed = planOutboundDeletion(planned, 0).rules.filter((r) => r.fate === 'removed').length;
+    applyOutboundDeletion(applied, 0);
+    expect(applied.routing!.rules!.length).toBe(total - removed);
+  });
+});
+
+describe('balancer deletion', () => {
+  it('drops a rule whose only destination was the deleted balancer', () => {
+    const tt = tpl({
+      routing: { rules: [{ type: 'field', inboundTag: ['in'], balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+    });
+    applyBalancerDeletion(tt, 0);
+    expect(tt.routing!.balancers).toEqual([]);
+    expect(tt.routing!.rules).toEqual([]);
+  });
+
+  it('keeps a rule that still has an outbound, dropping only the dead balancerTag', () => {
+    const tt = tpl({
+      routing: { rules: [{ type: 'field', outboundTag: 'direct', balancerTag: 'pool' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+    });
+    applyBalancerDeletion(tt, 0);
+    expect(tt.routing!.rules).toHaveLength(1);
+    expect(tt.routing!.rules![0].balancerTag).toBeUndefined();
+    expect(tt.routing!.rules![0].outboundTag).toBe('direct');
+  });
+
+  it('reports and removes the observer when deleting the last leastPing balancer', () => {
+    const tt = tpl({
+      routing: { rules: [], balancers: [{ tag: 'b1', selector: ['a'], strategy: { type: 'leastPing' } }] },
+      observatory: { subjectSelector: ['a'] },
+    });
+    expect(planBalancerDeletion(tt, 0).observatory).toBe(true);
+    applyBalancerDeletion(tt, 0);
+    expect(tt.observatory).toBeUndefined();
+    expect(tt.routing!.balancers).toEqual([]);
+  });
+
+  it('does not report rules when the deleted balancer is unreferenced', () => {
+    const tt = tpl({
+      routing: { rules: [{ type: 'field', inboundTag: ['in'], outboundTag: 'direct' }], balancers: [{ tag: 'pool', selector: ['a'] }] },
+    });
+    expect(planBalancerDeletion(tt, 0).rules).toEqual([]);
+    applyBalancerDeletion(tt, 0);
+    expect(tt.routing!.rules).toHaveLength(1);
+  });
+});

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

+ 1 - 1
go.mod

@@ -19,7 +19,7 @@ require (
 	github.com/robfig/cron/v3 v3.0.1
 	github.com/shirou/gopsutil/v4 v4.26.5
 	github.com/skip2/go-qrcode v0.0.0-20200617195104-da1b6568686e
-	github.com/valyala/fasthttp v1.71.0
+	github.com/valyala/fasthttp v1.72.0
 	github.com/xlzd/gotp v0.1.0
 	github.com/xtls/xray-core v1.260327.1-0.20260627131803-45cf2898ab12
 	go.uber.org/atomic v1.11.0

+ 2 - 2
go.sum

@@ -204,8 +204,8 @@ github.com/ugorji/go/codec v1.3.1 h1:waO7eEiFDwidsBN6agj1vJQ4AG7lh2yqXyOXqhgQuyY
 github.com/ugorji/go/codec v1.3.1/go.mod h1:pRBVtBSKl77K30Bv8R2P+cLSGaTtex6fsA2Wjqmfxj4=
 github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw=
 github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc=
-github.com/valyala/fasthttp v1.71.0 h1:tepR7H+Guh9VUqxxcPggYi8R3lGUu2Rsdh+z7/FCY3k=
-github.com/valyala/fasthttp v1.71.0/go.mod h1:z1sDUvOShhXq/C9mwH/fSm1Vb71tUJwmQdgkBrBNwnA=
+github.com/valyala/fasthttp v1.72.0 h1:R7kYdoWhn1ye1fVpP+cDHDJwYm3NkwLliwgzJ/Abg7M=
+github.com/valyala/fasthttp v1.72.0/go.mod h1:zsbLTYqcpIktdQytlVBwIjY9La5d6bs990nBxWg8efk=
 github.com/valyala/fastjson v1.6.10 h1:/yjJg8jaVQdYR3arGxPE2X5z89xrlhS0eGXdv+ADTh4=
 github.com/valyala/fastjson v1.6.10/go.mod h1:e6FubmQouUNP73jtMLmcbxS6ydWIpOfhz34TSfO3JaE=
 github.com/vishvananda/netlink v1.3.1 h1:3AEMt62VKqz90r0tmNhog0r/PpWKmrEShJU0wJW6bV0=

+ 3 - 3
internal/database/model/model.go

@@ -772,9 +772,9 @@ type Host struct {
 	// merged into this host's JSON-subscription stream. Empty = no override.
 	FinalMask string `json:"finalMask" form:"finalMask" gorm:"type:text;column:final_mask"`
 
-	// VlessRoute is a free-form port/range routing spec (e.g. "53,443,1000-2000");
-	// stored verbatim, format-validated on the frontend.
-	VlessRoute string `json:"vlessRoute" form:"vlessRoute" gorm:"column:vless_route"`
+	// Single VLESS route value (0-65535) baked into the subscription UUID's 3rd
+	// group (bytes 6-7), which xray reads via net.PortFromBytes(id[6:8]). Empty = none.
+	VlessRoute string `json:"vlessRoute" form:"vlessRoute" gorm:"column:vless_route" example:"443"`
 
 	ExcludeFromSubTypes []string `json:"excludeFromSubTypes" form:"excludeFromSubTypes" gorm:"serializer:json;column:exclude_from_sub_types"`
 

+ 1 - 1
internal/sub/clash_service.go

@@ -239,7 +239,7 @@ func (s *SubClashService) buildProxy(subReq *SubService, inbound *model.Inbound,
 		proxy["cipher"] = cipher
 	case model.VLESS:
 		proxy["type"] = "vless"
-		proxy["uuid"] = client.ID
+		proxy["uuid"] = applyVlessRoute(client.ID, hostVlessRoute(ep))
 		var inboundSettings map[string]any
 		_ = json.Unmarshal([]byte(inbound.Settings), &inboundSettings)
 		streamSecurity, _ := stream["security"].(string)

+ 2 - 2
internal/sub/endpoint.go

@@ -77,7 +77,7 @@ func (s *SubService) buildEndpointLinks(
 	eps []ShareEndpoint,
 	params map[string]string,
 	baseSecurity string,
-	makeLink func(dest string, port int) string,
+	makeLink func(e ShareEndpoint) string,
 	makeRemark func(e ShareEndpoint) string,
 ) string {
 	links := make([]string, 0, len(eps))
@@ -92,7 +92,7 @@ func (s *SubService) buildEndpointLinks(
 		applyEndpointHostPath(e, nextParams)
 		applyEndpointAllowInsecure(e, nextParams, securityToApply)
 		links = append(links, buildLinkWithParamsAndSecurity(
-			makeLink(e.Address, e.Port),
+			makeLink(e),
 			nextParams,
 			makeRemark(e),
 			securityToApply,

+ 1 - 1
internal/sub/endpoint_test.go

@@ -80,7 +80,7 @@ func TestBuildEndpointLinks_ParamForm(t *testing.T) {
 		externalProxyToEndpoint(map[string]any{"forceTls": "none", "dest": "b.example.com", "port": float64(80), "remark": "B"}),
 	}
 	got := s.buildEndpointLinks(eps, params, "tls",
-		func(dest string, port int) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(dest, port)) },
+		func(e ShareEndpoint) string { return fmt.Sprintf("vless://uid@%s", joinHostPort(e.Address, e.Port)) },
 		func(e ShareEndpoint) string { return s.genRemark(in, "user", e.Remark, "") },
 	)
 	want := "vless://[email protected]:8443?fp=chrome&security=tls&sni=a.sni&type=tcp#ib-A-user\n" +

+ 3 - 0
internal/sub/host_sub.go

@@ -104,6 +104,9 @@ func hostToExternalProxyMap(h *model.Host, defaultDest string, defaultPort int)
 	if h.FinalMask != "" {
 		ep["finalMask"] = h.FinalMask
 	}
+	if h.VlessRoute != "" {
+		ep["vlessRoute"] = h.VlessRoute
+	}
 	return ep
 }
 

+ 3 - 1
internal/sub/json_service.go

@@ -210,7 +210,9 @@ func (s *SubJsonService) getConfig(subReq *SubService, inbound *model.Inbound, c
 		case "vmess":
 			newOutbounds = append(newOutbounds, s.genVnext(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "vless":
-			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, client, jsonMux(mux, hostMux)))
+			vc := client
+			vc.ID = applyVlessRoute(client.ID, hostVlessRoute(extPrxy))
+			newOutbounds = append(newOutbounds, s.genVless(inbound, streamSettings, vc, jsonMux(mux, hostMux)))
 		case "trojan", "shadowsocks":
 			newOutbounds = append(newOutbounds, s.genServer(inbound, streamSettings, client, jsonMux(mux, hostMux)))
 		case "hysteria":

+ 8 - 6
internal/sub/service.go

@@ -698,8 +698,8 @@ func (s *SubService) genVlessLink(inbound *model.Inbound, email string) string {
 			externalProxies,
 			params,
 			security,
-			func(dest string, port int) string {
-				return fmt.Sprintf("vless://%s@%s", uuid, joinHostPort(dest, port))
+			func(ep map[string]any, dest string, port int) string {
+				return fmt.Sprintf("vless://%s@%s", applyVlessRoute(uuid, hostVlessRoute(ep)), joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
 				return s.endpointRemark(inbound, email, ep, streamNetwork)
@@ -749,7 +749,7 @@ func (s *SubService) genTrojanLink(inbound *model.Inbound, email string) string
 			externalProxies,
 			params,
 			security,
-			func(dest string, port int) string {
+			func(_ map[string]any, dest string, port int) string {
 				return fmt.Sprintf("trojan://%s@%s", password, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
@@ -842,7 +842,7 @@ func (s *SubService) genShadowsocksLink(inbound *model.Inbound, email string) st
 			externalProxies,
 			proxyParams,
 			security,
-			func(dest string, port int) string {
+			func(_ map[string]any, dest string, port int) string {
 				return fmt.Sprintf("ss://%s@%s", userInfo, joinHostPort(dest, port))
 			},
 			func(ep map[string]any) string {
@@ -1697,7 +1697,7 @@ func (s *SubService) buildExternalProxyURLLinks(
 	externalProxies []any,
 	params map[string]string,
 	baseSecurity string,
-	makeLink func(dest string, port int) string,
+	makeLink func(ep map[string]any, dest string, port int) string,
 	makeRemark func(ep map[string]any) string,
 ) string {
 	eps := make([]ShareEndpoint, 0, len(externalProxies))
@@ -1705,7 +1705,9 @@ func (s *SubService) buildExternalProxyURLLinks(
 		ep, _ := externalProxy.(map[string]any)
 		eps = append(eps, externalProxyToEndpoint(ep))
 	}
-	return s.buildEndpointLinks(eps, params, baseSecurity, makeLink, func(e ShareEndpoint) string {
+	return s.buildEndpointLinks(eps, params, baseSecurity, func(e ShareEndpoint) string {
+		return makeLink(e.ep, e.Address, e.Port)
+	}, func(e ShareEndpoint) string {
 		return makeRemark(e.ep)
 	})
 }

+ 34 - 0
internal/sub/vless_route.go

@@ -0,0 +1,34 @@
+package sub
+
+import (
+	"strconv"
+	"strings"
+
+	"github.com/google/uuid"
+)
+
+// xray reads the route from UUID bytes 6-7 (net.PortFromBytes) and masks them to
+// zero before auth, so baking a 0-65535 value into the 3rd group routes without
+// breaking the user match. Empty/invalid/non-UUID input is returned unchanged.
+func applyVlessRoute(id, route string) string {
+	route = strings.TrimSpace(route)
+	if route == "" {
+		return id
+	}
+	n, err := strconv.Atoi(route)
+	if err != nil || n < 0 || n > 65535 {
+		return id
+	}
+	u, err := uuid.Parse(id)
+	if err != nil {
+		return id
+	}
+	u[6] = byte(n >> 8)
+	u[7] = byte(n)
+	return u.String()
+}
+
+func hostVlessRoute(ep map[string]any) string {
+	v, _ := ep["vlessRoute"].(string)
+	return v
+}

+ 83 - 0
internal/sub/vless_route_sub_test.go

@@ -0,0 +1,83 @@
+package sub
+
+import (
+	"strings"
+	"testing"
+
+	"github.com/mhsanaei/3x-ui/v3/internal/database/model"
+)
+
+func TestHostToExternalProxyMap_VlessRoute(t *testing.T) {
+	with := hostToExternalProxyMap(&model.Host{VlessRoute: "443"}, "d.example.com", 443)
+	if with["vlessRoute"] != "443" {
+		t.Fatalf(`ep["vlessRoute"] = %v, want "443"`, with["vlessRoute"])
+	}
+	without := hostToExternalProxyMap(&model.Host{}, "d.example.com", 443)
+	if _, ok := without["vlessRoute"]; ok {
+		t.Fatalf("empty VlessRoute must not add the key: %v", without["vlessRoute"])
+	}
+}
+
+// seedSubInbound's client UUID is 11111111-2222-4333-8444-<port>, so route 443
+// -> 01bb, 53 -> 0035, and a route-less host keeps 4333.
+func TestSub_HostVlessRoute_RawMultiHost(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "vr", 4500, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "A", Address: "a.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 2, Remark: "B", Address: "b.cdn.com", Port: 8443, Security: "tls", VlessRoute: "53"})
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 3, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls"})
+
+	links, _, _, _, err := NewSubService("").GetSubs("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetSubs: %v", err)
+	}
+	parts := strings.Split(strings.Join(links, "\n"), "\n")
+	if len(parts) != 3 {
+		t.Fatalf("want 3 host links, got %d: %v", len(parts), parts)
+	}
+	if !strings.Contains(parts[0], "vless://11111111-2222-01bb-8444-") {
+		t.Fatalf("host A (route 443) must encode 01bb: %s", parts[0])
+	}
+	if !strings.Contains(parts[1], "vless://11111111-2222-0035-8444-") {
+		t.Fatalf("host B (route 53) must encode 0035: %s", parts[1])
+	}
+	if !strings.Contains(parts[2], "vless://11111111-2222-4333-8444-") {
+		t.Fatalf("host C (no route) must keep the original 3rd group: %s", parts[2])
+	}
+}
+
+func TestSub_HostVlessRoute_JSON(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "vrj", 4501, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "J", Address: "j.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
+
+	js := NewSubJsonService("", "", "", NewSubService(""))
+	out, _, err := js.GetJson("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetJson: %v", err)
+	}
+	if !strings.Contains(out, "11111111-2222-01bb-8444-") {
+		t.Fatalf("json outbound id should encode route 443 (01bb):\n%s", out)
+	}
+	if strings.Contains(out, "11111111-2222-4333-8444-") {
+		t.Fatalf("original id 3rd group must be replaced in json:\n%s", out)
+	}
+}
+
+func TestSub_HostVlessRoute_Clash(t *testing.T) {
+	seedSubDB(t)
+	ib := seedSubInbound(t, "s1", "vrc", 4502, 1, wsTLSStream)
+	seedHost(t, &model.Host{InboundId: ib.Id, SortOrder: 1, Remark: "C", Address: "c.cdn.com", Port: 8443, Security: "tls", VlessRoute: "443"})
+
+	clash := NewSubClashService(false, "", NewSubService(""))
+	yaml, _, err := clash.GetClash("s1", "req.example.com")
+	if err != nil {
+		t.Fatalf("GetClash: %v", err)
+	}
+	if !strings.Contains(yaml, "11111111-2222-01bb-8444-") {
+		t.Fatalf("clash proxy uuid should encode route 443 (01bb):\n%s", yaml)
+	}
+	if strings.Contains(yaml, "11111111-2222-4333-8444-") {
+		t.Fatalf("original uuid 3rd group must be replaced in clash:\n%s", yaml)
+	}
+}

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