diff --git a/frontend/src/pages/nodes/NodeFormModal.tsx b/frontend/src/pages/nodes/NodeFormModal.tsx
index 2ce412fd..795fec76 100644
--- a/frontend/src/pages/nodes/NodeFormModal.tsx
+++ b/frontend/src/pages/nodes/NodeFormModal.tsx
@@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import {
Alert,
+ Button,
Col,
Form,
Input,
@@ -74,6 +75,7 @@ export default function NodeFormModal({
onOpenChange,
}: NodeFormModalProps) {
const { t } = useTranslation();
+ const [messageApi, messageContextHolder] = message.useMessage();
const [form, setForm] = useState
(defaultForm);
const [submitting, setSubmitting] = useState(false);
@@ -132,7 +134,7 @@ export default function NodeFormModal({
try {
const payload = buildPayload();
if (!payload.address || !payload.port) {
- message.error(t('pages.nodes.toasts.fillRequired'));
+ messageApi.error(t('pages.nodes.toasts.fillRequired'));
return;
}
const msg = await testConnection(payload);
@@ -149,7 +151,7 @@ export default function NodeFormModal({
async function onSave() {
const payload = buildPayload();
if (!payload.name || !payload.address || !payload.port) {
- message.error(t('pages.nodes.toasts.fillRequired'));
+ messageApi.error(t('pages.nodes.toasts.fillRequired'));
return;
}
setSubmitting(true);
@@ -168,10 +170,12 @@ export default function NodeFormModal({
}
return (
-
+ {messageContextHolder}
+
-
{testResult && (
{testResult.status === 'online' ? (
@@ -291,6 +295,7 @@ export default function NodeFormModal({
)}
-
+
+ >
);
}
diff --git a/frontend/src/pages/nodes/NodeList.tsx b/frontend/src/pages/nodes/NodeList.tsx
index 7a16f082..3cfaee99 100644
--- a/frontend/src/pages/nodes/NodeList.tsx
+++ b/frontend/src/pages/nodes/NodeList.tsx
@@ -118,6 +118,37 @@ export default function NodeList({
}
const columns = useMemo
>(() => [
+ {
+ title: t('pages.nodes.actions'),
+ align: 'center',
+ width: 160,
+ render: (_value, record) => (
+
+
+ } onClick={() => onProbe(record)} />
+
+
+ } onClick={() => onEdit(record)} />
+
+
+ } onClick={() => onDelete(record)} />
+
+
+ ),
+ },
+ {
+ title: t('pages.nodes.enable'),
+ dataIndex: 'enable',
+ align: 'center',
+ width: 80,
+ render: (_value, record) => (
+ onToggleEnable(record, v)}
+ />
+ ),
+ },
{
title: t('pages.nodes.name'),
dataIndex: 'name',
@@ -234,38 +265,6 @@ export default function NodeList({
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
- {
- title: t('pages.nodes.enable'),
- dataIndex: 'enable',
- align: 'center',
- width: 80,
- render: (_value, record) => (
- onToggleEnable(record, v)}
- />
- ),
- },
- {
- title: t('pages.nodes.actions'),
- align: 'center',
- width: 160,
- fixed: 'right',
- render: (_value, record) => (
-
-
- } onClick={() => onProbe(record)} />
-
-
- } onClick={() => onEdit(record)} />
-
-
- } onClick={() => onDelete(record)} />
-
-
- ),
- },
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
return (
diff --git a/frontend/src/pages/nodes/NodesPage.tsx b/frontend/src/pages/nodes/NodesPage.tsx
index 40915c2e..d6aea0a1 100644
--- a/frontend/src/pages/nodes/NodesPage.tsx
+++ b/frontend/src/pages/nodes/NodesPage.tsx
@@ -1,4 +1,4 @@
-import { useCallback, useMemo, useState } from 'react';
+import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
import {
@@ -17,6 +17,7 @@ import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal';
+import { setMessageInstance } from '@/utils/messageBus';
import './NodesPage.css';
const basePath = window.X_UI_BASE_PATH || '';
@@ -27,6 +28,8 @@ export default function NodesPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
+ const [messageApi, messageContextHolder] = message.useMessage();
+ useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const {
nodes,
@@ -76,21 +79,21 @@ export default function NodesPage() {
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(node.id);
- if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
+ if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
},
});
- }, [modal, t, remove]);
+ }, [modal, t, remove, messageApi]);
const onProbe = useCallback(async (node: NodeRecord) => {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
- message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
+ messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
- message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
+ messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
}
}
- }, [probe, t]);
+ }, [probe, t, messageApi]);
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next);
@@ -105,6 +108,7 @@ export default function NodesPage() {
return (
+ {messageContextHolder}
{modalContextHolder}
diff --git a/frontend/src/pages/settings/SecurityTab.css b/frontend/src/pages/settings/SecurityTab.css
index 836ab016..158f45ed 100644
--- a/frontend/src/pages/settings/SecurityTab.css
+++ b/frontend/src/pages/settings/SecurityTab.css
@@ -82,3 +82,9 @@
border-radius: 4px;
word-break: break-all;
}
+
+.security-actions {
+ padding: 12px 0;
+ display: flex;
+ align-items: center;
+}
diff --git a/frontend/src/pages/settings/SecurityTab.tsx b/frontend/src/pages/settings/SecurityTab.tsx
index 6a6cad64..00d88928 100644
--- a/frontend/src/pages/settings/SecurityTab.tsx
+++ b/frontend/src/pages/settings/SecurityTab.tsx
@@ -6,7 +6,6 @@ import {
Empty,
Form,
Input,
- List,
Modal,
Space,
Spin,
@@ -61,6 +60,7 @@ const TFA_INITIAL: TfaState = {
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal();
+ const [messageApi, messageContextHolder] = message.useMessage();
const [tfa, setTfa] = useState(TFA_INITIAL);
const [user, setUser] = useState({
@@ -145,7 +145,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
if (!token) return;
try {
await navigator.clipboard.writeText(token);
- message.success(t('copySuccess'));
+ messageApi.success(t('copySuccess'));
} catch {
const ta = document.createElement('textarea');
ta.value = token;
@@ -153,7 +153,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
- message.success(t('copySuccess'));
+ messageApi.success(t('copySuccess'));
}
}
@@ -165,7 +165,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
async function confirmCreateToken() {
const name = createName.trim();
if (!name) {
- message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
+ messageApi.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return;
}
setCreating(true);
@@ -231,7 +231,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
type: 'set',
onConfirm: (ok: boolean) => {
if (ok) {
- message.success(t('pages.settings.security.twoFactorModalSetSuccess'));
+ messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else {
updateSetting({ twoFactorEnable: false });
@@ -246,7 +246,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
type: 'confirm',
onConfirm: (ok: boolean) => {
if (!ok) return;
- message.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
+ messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
},
});
@@ -255,6 +255,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
return (
<>
+ {messageContextHolder}
{modalContextHolder}
updateUserField('newPassword', e.target.value)} />
-
+
{t('confirm')}
-
+
>
),
},
diff --git a/frontend/src/pages/settings/SettingsPage.tsx b/frontend/src/pages/settings/SettingsPage.tsx
index 4f958a3a..5f2ee688 100644
--- a/frontend/src/pages/settings/SettingsPage.tsx
+++ b/frontend/src/pages/settings/SettingsPage.tsx
@@ -14,6 +14,7 @@ import {
Spin,
Tabs,
Tooltip,
+ message,
} from 'antd';
import {
CloudServerOutlined,
@@ -24,6 +25,7 @@ import {
} from '@ant-design/icons';
import { HttpUtil, PromiseUtil } from '@/utils';
+import { setMessageInstance } from '@/utils/messageBus';
import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useAllSetting } from '@/hooks/useAllSetting';
@@ -77,6 +79,11 @@ export default function SettingsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
+ const [messageApi, messageContextHolder] = message.useMessage();
+
+ useEffect(() => {
+ setMessageInstance(messageApi);
+ }, [messageApi]);
const {
allSetting,
@@ -259,6 +266,7 @@ export default function SettingsPage() {
return (
+ {messageContextHolder}
{modalContextHolder}
diff --git a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx
index bd0c0fab..de6e2f42 100644
--- a/frontend/src/pages/settings/SubscriptionFormatsTab.tsx
+++ b/frontend/src/pages/settings/SubscriptionFormatsTab.tsx
@@ -5,7 +5,6 @@ import {
Collapse,
Input,
InputNumber,
- List,
Select,
Space,
Switch,
@@ -258,7 +257,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
{fragment && (
-
+
-
+
)}
>
),
@@ -299,7 +298,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
{noisesEnabled && (
-
+
({
key: String(index),
label: `Noise №${index + 1}`,
@@ -340,7 +339,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
),
}))} />
+ Noise
-
+
)}
>
),
@@ -354,7 +353,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
{muxEnabled && (
-
+
-
+
)}
>
),
@@ -395,7 +394,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
{directEnabled && (
-
+
-
+
)}
>
),
diff --git a/frontend/src/pages/settings/TwoFactorModal.tsx b/frontend/src/pages/settings/TwoFactorModal.tsx
index 8ae8a35f..b686926c 100644
--- a/frontend/src/pages/settings/TwoFactorModal.tsx
+++ b/frontend/src/pages/settings/TwoFactorModal.tsx
@@ -28,6 +28,7 @@ export default function TwoFactorModal({
onOpenChange,
}: TwoFactorModalProps) {
const { t } = useTranslation();
+ const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState('');
const [qrValue, setQrValue] = useState('');
const totpRef = useRef(null);
@@ -68,7 +69,7 @@ export default function TwoFactorModal({
if (totpRef.current.generate() === enteredCode) {
close(true);
} else {
- message.error(t('pages.settings.security.twoFactorModalError'));
+ messageApi.error(t('pages.settings.security.twoFactorModalError'));
}
}
@@ -78,15 +79,17 @@ export default function TwoFactorModal({
async function copyToken() {
const ok = await ClipboardManager.copyText(token);
- if (ok) message.success(t('copied'));
+ if (ok) messageApi.success(t('copied'));
}
return (
-
+ {messageContextHolder}
+ {t('cancel')},
@@ -124,6 +127,7 @@ export default function TwoFactorModal({
setEnteredCode(e.target.value)} style={{ width: '100%' }} />
>
)}
-
+
+ >
);
}
diff --git a/frontend/src/pages/sub/SubPage.tsx b/frontend/src/pages/sub/SubPage.tsx
index 7a834690..b0eeabc6 100644
--- a/frontend/src/pages/sub/SubPage.tsx
+++ b/frontend/src/pages/sub/SubPage.tsx
@@ -25,6 +25,7 @@ import {
} from '@ant-design/icons';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
+import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './SubPage.css';
@@ -78,6 +79,8 @@ function linkName(link: string, idx: number): string {
export default function SubPage() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
+ const [messageApi, messageContextHolder] = message.useMessage();
+ useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [isMobile, setIsMobile] = useState(() => window.innerWidth < 576);
const [lang, setLang] = useState(() => LanguageManager.getLanguage());
@@ -109,8 +112,8 @@ export default function SubPage() {
const copy = useCallback(async (value: string) => {
if (!value) return;
const ok = await ClipboardManager.copyText(value);
- if (ok) message.success(t('copied'));
- }, [t]);
+ if (ok) messageApi.success(t('copied'));
+ }, [t, messageApi]);
const open = useCallback((url: string) => {
if (!url) return;
@@ -273,6 +276,7 @@ export default function SubPage() {
return (
+ {messageContextHolder}
diff --git a/frontend/src/pages/xray/DnsPresetsModal.css b/frontend/src/pages/xray/DnsPresetsModal.css
index aae34835..a0da4965 100644
--- a/frontend/src/pages/xray/DnsPresetsModal.css
+++ b/frontend/src/pages/xray/DnsPresetsModal.css
@@ -1,8 +1,30 @@
+.preset-list {
+ border: 1px solid rgba(5, 5, 5, 0.06);
+ border-radius: 8px;
+ overflow: hidden;
+}
+
+body.dark .preset-list,
+html[data-theme='ultra-dark'] .preset-list {
+ border-color: rgba(255, 255, 255, 0.12);
+}
+
.preset-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
+ padding: 12px 24px;
+ border-bottom: 1px solid rgba(5, 5, 5, 0.06);
+}
+
+.preset-row:last-child {
+ border-bottom: 0;
+}
+
+body.dark .preset-row,
+html[data-theme='ultra-dark'] .preset-row {
+ border-bottom-color: rgba(255, 255, 255, 0.08);
}
.preset-name {
diff --git a/frontend/src/pages/xray/DnsPresetsModal.tsx b/frontend/src/pages/xray/DnsPresetsModal.tsx
index cd049fde..97a5b5da 100644
--- a/frontend/src/pages/xray/DnsPresetsModal.tsx
+++ b/frontend/src/pages/xray/DnsPresetsModal.tsx
@@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next';
-import { Button, List, Modal, Space, Tag } from 'antd';
+import { Button, Modal, Space, Tag } from 'antd';
import './DnsPresetsModal.css';
interface DnsPresetsModalProps {
@@ -47,9 +47,9 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
mask={{ closable: false }}
onCancel={onClose}
>
-
+
{PRESETS.map((preset) => (
-
+
{preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS'}
@@ -59,9 +59,9 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
onInstall([...preset.data])}>
{t('install')}
-
+
))}
-
+
);
}
diff --git a/frontend/src/pages/xray/DnsServerModal.tsx b/frontend/src/pages/xray/DnsServerModal.tsx
index 1652fecb..9e1be244 100644
--- a/frontend/src/pages/xray/DnsServerModal.tsx
+++ b/frontend/src/pages/xray/DnsServerModal.tsx
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Button, Divider, Form, Input, InputNumber, Modal, Select, Switch } from 'antd';
+import { Button, Divider, Form, Input, InputNumber, Modal, Select, Space, Switch } from 'antd';
import { PlusOutlined, MinusOutlined } from '@ant-design/icons';
+import InputAddon from '@/components/InputAddon';
export type DnsServerValue =
| string
@@ -190,39 +191,45 @@ export default function DnsServerModal({
} onClick={() => updateList('domains', (d) => d.push(''))} />
{form.domains.map((value, idx) => (
- updateList('domains', (d) => { d[idx] = e.target.value; })}
- addonAfter={ updateList('domains', (d) => d.splice(idx, 1))} />}
- />
+
+ updateList('domains', (d) => { d[idx] = e.target.value; })}
+ />
+ updateList('domains', (d) => d.splice(idx, 1))}>
+
+
+
))}
} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
{form.expectedIPs.map((value, idx) => (
- updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
- addonAfter={ updateList('expectedIPs', (d) => d.splice(idx, 1))} />}
- />
+
+ updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
+ />
+ updateList('expectedIPs', (d) => d.splice(idx, 1))}>
+
+
+
))}
} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
{form.unexpectedIPs.map((value, idx) => (
- updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
- addonAfter={ updateList('unexpectedIPs', (d) => d.splice(idx, 1))} />}
- />
+
+ updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
+ />
+ updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
+
+
+
))}
diff --git a/frontend/src/pages/xray/NordModal.tsx b/frontend/src/pages/xray/NordModal.tsx
index a9897c39..26306b10 100644
--- a/frontend/src/pages/xray/NordModal.tsx
+++ b/frontend/src/pages/xray/NordModal.tsx
@@ -58,6 +58,7 @@ export default function NordModal({
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
+ const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState(null);
const [token, setToken] = useState('');
@@ -184,7 +185,7 @@ export default function NordModal({
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
setServers(next);
- if (next.length === 0) message.warning('No servers found for the selected country');
+ if (next.length === 0) messageApi.warning('No servers found for the selected country');
} finally {
setLoading(false);
}
@@ -196,7 +197,7 @@ export default function NordModal({
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) {
- message.error('Selected server does not advertise a NordLynx public key.');
+ messageApi.error('Selected server does not advertise a NordLynx public key.');
return null;
}
return {
@@ -215,7 +216,7 @@ export default function NordModal({
const ob = buildNordOutbound();
if (!ob) return;
onAddOutbound(ob);
- message.success('NordVPN outbound added');
+ messageApi.success('NordVPN outbound added');
onClose();
}
@@ -230,12 +231,14 @@ export default function NordModal({
oldTag,
newTag: ob.tag as string,
});
- message.success('NordVPN outbound updated');
+ messageApi.success('NordVPN outbound updated');
onClose();
}
return (
-
+ <>
+ {messageContextHolder}
+
{nordData == null ? (
)}
-
+
+ >
);
}
diff --git a/frontend/src/pages/xray/OutboundFormModal.tsx b/frontend/src/pages/xray/OutboundFormModal.tsx
index fa6095f4..58ce3747 100644
--- a/frontend/src/pages/xray/OutboundFormModal.tsx
+++ b/frontend/src/pages/xray/OutboundFormModal.tsx
@@ -17,6 +17,7 @@ import {
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils';
+import InputAddon from '@/components/InputAddon';
import {
Outbound,
Protocols,
@@ -67,6 +68,7 @@ export default function OutboundFormModal({
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
+ const [messageApi, messageContextHolder] = message.useMessage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const outboundRef = useRef(null);
const [, setTick] = useState(0);
@@ -119,7 +121,7 @@ export default function OutboundFormModal({
try {
parsed = JSON.parse(raw);
} catch (e) {
- message.error(`JSON: ${(e as Error).message}`);
+ messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
try {
@@ -130,7 +132,7 @@ export default function OutboundFormModal({
refresh();
return true;
} catch (e) {
- message.error(`JSON: ${(e as Error).message}`);
+ messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
}
@@ -219,11 +221,11 @@ export default function OutboundFormModal({
if (!ob) return;
if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
if (!ob.tag?.trim()) {
- message.error('Tag is required');
+ messageApi.error('Tag is required');
return;
}
if (duplicateTag) {
- message.error('Tag already used by another outbound');
+ messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(ob.toJson());
@@ -235,17 +237,17 @@ export default function OutboundFormModal({
try {
const next = Outbound.fromLink(link);
if (!next) {
- message.error('Wrong Link!');
+ messageApi.error('Wrong Link!');
return;
}
outboundRef.current = next;
primeAdvancedJson();
setLinkInput('');
- message.success('Link imported successfully...');
+ messageApi.success('Link imported successfully...');
setActiveKey('1');
refresh();
} catch (e) {
- message.error(`Link parse: ${(e as Error).message}`);
+ messageApi.error(`Link parse: ${(e as Error).message}`);
}
}
@@ -256,21 +258,26 @@ export default function OutboundFormModal({
if (!ob) {
return (
-
+ <>
+ {messageContextHolder}
+
+ >
);
}
return (
-
+ <>
+ {messageContextHolder}
+
}
{ob.canEnableMux() && }
-
- {ob.stream && ob.canEnableStream() && (
-
- )}
+ {ob.stream && ob.canEnableStream() && (
+
+ )}
+ >
),
},
{
@@ -453,7 +461,8 @@ export default function OutboundFormModal({
},
]}
/>
-
+
+ >
);
}
@@ -808,17 +817,17 @@ function WireguardFields({ ob, refresh, regenerate, t }: TFieldProps & { regener
{(peer.allowedIPs || []).map((ip, idx) => (
- { peer.allowedIPs![idx] = e.target.value; refresh(); }}
- addonAfter={
- (peer.allowedIPs || []).length > 1 ? (
- { peer.allowedIPs!.splice(idx, 1); refresh(); }} />
- ) : undefined
- }
- />
+
+ { peer.allowedIPs![idx] = e.target.value; refresh(); }}
+ />
+ {(peer.allowedIPs || []).length > 1 && (
+ { peer.allowedIPs!.splice(idx, 1); refresh(); }}>
+
+
+ )}
+
))}
{(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => (
-
+
+ {`${idx + 1}`}
{ header.name = e.target.value; refresh(); }}
/>
{ header.value = e.target.value; refresh(); }}
/>
} onClick={() => { xh.removeHeader(idx); refresh(); }} />
-
+
))}
diff --git a/frontend/src/pages/xray/RuleFormModal.tsx b/frontend/src/pages/xray/RuleFormModal.tsx
index 81d752bf..2c14c638 100644
--- a/frontend/src/pages/xray/RuleFormModal.tsx
+++ b/frontend/src/pages/xray/RuleFormModal.tsx
@@ -1,7 +1,8 @@
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
-import { Button, Form, Input, Modal, Select, Tooltip } from 'antd';
+import { Button, Form, Input, Modal, Select, Space, Tooltip } from 'antd';
import { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
+import InputAddon from '@/components/InputAddon';
export interface RoutingRule {
type?: string;
@@ -207,11 +208,10 @@ export default function RuleFormModal({
{form.attrs.map((attr, idx) => (
-
+
+ {`${idx + 1}`}
{
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
@@ -220,7 +220,6 @@ export default function RuleFormModal({
/>
{
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
@@ -231,7 +230,7 @@ export default function RuleFormModal({
icon={}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/>
-
+
))}
diff --git a/frontend/src/pages/xray/WarpModal.tsx b/frontend/src/pages/xray/WarpModal.tsx
index 92bbdfdc..6eda6888 100644
--- a/frontend/src/pages/xray/WarpModal.tsx
+++ b/frontend/src/pages/xray/WarpModal.tsx
@@ -72,6 +72,7 @@ export default function WarpModal({
onResetOutbound,
onRemoveOutbound,
}: WarpModalProps) {
+ const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState(null);
const [warpConfig, setWarpConfig] = useState(null);
@@ -191,7 +192,7 @@ export default function WarpModal({
function addOutbound() {
if (!stagedOutbound) {
- message.warning('Fetch the WARP config first.');
+ messageApi.warning('Fetch the WARP config first.');
return;
}
onAddOutbound(stagedOutbound);
@@ -207,7 +208,9 @@ export default function WarpModal({
const hasConfig = !ObjectUtil.isEmpty(warpConfig);
return (
-
+ <>
+ {messageContextHolder}
+
{!hasWarp ? (
} onClick={register}>
Create WARP account
@@ -348,6 +351,7 @@ export default function WarpModal({
)}
>
)}
-
+
+ >
);
}
diff --git a/frontend/src/pages/xray/XrayPage.tsx b/frontend/src/pages/xray/XrayPage.tsx
index ce074ea4..37bbf741 100644
--- a/frontend/src/pages/xray/XrayPage.tsx
+++ b/frontend/src/pages/xray/XrayPage.tsx
@@ -36,6 +36,7 @@ import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/components/AppSidebar';
import JsonEditor from '@/components/JsonEditor';
+import { setMessageInstance } from '@/utils/messageBus';
import BasicsTab from './BasicsTab';
import RoutingTab from './RoutingTab';
@@ -65,6 +66,8 @@ export default function XrayPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
+ const [messageApi, messageContextHolder] = message.useMessage();
+ useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const xs = useXraySetting();
const {
fetched,
@@ -239,7 +242,7 @@ export default function XrayPage() {
try {
JSON.parse(xraySetting);
} catch (e) {
- message.error(`Advanced JSON: ${(e as Error).message}`);
+ messageApi.error(`Advanced JSON: ${(e as Error).message}`);
setActiveTabKey('tpl-advanced');
return;
}
@@ -252,6 +255,7 @@ export default function XrayPage() {
return (
+ {messageContextHolder}
{modalContextHolder}
diff --git a/frontend/src/utils/index.js b/frontend/src/utils/index.js
index b2741280..dd8cb6a2 100644
--- a/frontend/src/utils/index.js
+++ b/frontend/src/utils/index.js
@@ -1,5 +1,5 @@
import axios from 'axios';
-import { message as antMessage } from 'antd';
+import { getMessage } from './messageBus';
export class Msg {
constructor(success = false, msg = "", obj = null) {
@@ -15,7 +15,7 @@ export class HttpUtil {
return;
}
const messageType = msg.success ? 'success' : 'error';
- antMessage[messageType](msg.msg);
+ getMessage()[messageType](msg.msg);
}
static _respToMsg(resp) {
diff --git a/frontend/src/utils/messageBus.ts b/frontend/src/utils/messageBus.ts
new file mode 100644
index 00000000..a249bf3a
--- /dev/null
+++ b/frontend/src/utils/messageBus.ts
@@ -0,0 +1,12 @@
+import { message as staticMessage } from 'antd';
+import type { MessageInstance } from 'antd/es/message/interface';
+
+let current: MessageInstance | typeof staticMessage = staticMessage;
+
+export function setMessageInstance(instance: MessageInstance) {
+ current = instance;
+}
+
+export function getMessage(): MessageInstance | typeof staticMessage {
+ return current;
+}
diff --git a/sub/subController.go b/sub/subController.go
index 2eeeefe7..990c12e5 100644
--- a/sub/subController.go
+++ b/sub/subController.go
@@ -188,9 +188,6 @@ func (a *SUBController) serveSubPage(c *gin.Context, basePath string, page PageD
}
// JSON-marshal the view-model so the SPA can read it as a plain
- // object on mount. PageData fields are already in the shape the Vue
- // component expects, plus a `links` array carrying the rendered
- // share URLs.
// The panel's "Calendar Type" setting decides whether the SubPage
// renders dates in Gregorian or Jalali — surface it here so the SPA
// can match the rest of the panel without a round-trip.
diff --git a/web/controller/xui.go b/web/controller/xui.go
index 7f7f81de..2da9a52e 100644
--- a/web/controller/xui.go
+++ b/web/controller/xui.go
@@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
a.xraySettingController = NewXraySettingController(g)
}
-// All four panel pages now serve the Vue 3 builds from web/dist/
+// The main panel's HTML routes serve the pre-built SPA pages from distFS,
// instead of rendering the legacy Go templates. Each handler is a
// thin wrapper around serveDistPage so the basePath injection +
// no-cache headers stay centralised.
diff --git a/web/web.go b/web/web.go
index e903a016..b6c050f4 100644
--- a/web/web.go
+++ b/web/web.go
@@ -40,13 +40,7 @@ var i18nFS embed.FS
// distFS embeds the Vite-built frontend (web/dist/). Every user-facing
// HTML route is served straight out of this FS — the legacy Go
// templates and `web/assets/` tree are gone post-Phase 8.
-//
-// `all:` is required so files whose names start with `_` are NOT
-// silently excluded by go:embed's default rules. Vite/rolldown emits
-// `_plugin-vue_export-helper-.js` for the @vitejs/plugin-vue
-// runtime; without `all:` the chunk would be missing from the binary
-// at runtime → 404 → blank-page boot failure.
-//
+
//go:embed all:dist
var distFS embed.FS