chore(frontend): antd v6 polish, theme + modal fixes

- adopt message.useMessage hook + messageBus bridge so HttpUtil messages
  inherit ConfigProvider theme tokens
- replace deprecated antd APIs (List, Input addonBefore/After, Empty
  imageStyle); introduce InputAddon helper + SettingListItem custom rows
- fix dark/ultra selectors in portaled modals (body.dark,
  html[data-theme='ultra-dark']) instead of nonexistent .is-dark/.is-ultra
- add horizontal scroll to clients table; reorder node columns so
  actions+enable sit at the left
- swap raw button for antd Button in NodeFormModal test connection
- fix FinalMaskForm nested-form by hoisting it outside OutboundFormModal's
  parent Form
- fix advanced "all" JSON tab in InboundFormModal — useMemo on a mutated
  ref was stale; compute on every render
- fix chart-on-open for SystemHistory + XrayMetrics modals by adding open
  to effect deps (useRef.current doesn't trigger re-runs)
- switch i18next interpolation to single-brace {var} to match locale files
- drop residual Vue mentions in CI workflows and Go comments
This commit is contained in:
MHSanaei 2026-05-22 02:55:25 +02:00
parent 7a4317086b
commit 886376db7d
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
54 changed files with 779 additions and 399 deletions

View file

@ -10,7 +10,6 @@ on:
- "**.mjs" - "**.mjs"
- "**.cjs" - "**.cjs"
- "**.ts" - "**.ts"
- "**.vue"
- "**.html" - "**.html"
- "**.css" - "**.css"
- "frontend/package.json" - "frontend/package.json"
@ -27,7 +26,6 @@ on:
- "**.mjs" - "**.mjs"
- "**.cjs" - "**.cjs"
- "**.ts" - "**.ts"
- "**.vue"
- "**.html" - "**.html"
- "**.css" - "**.css"
- "frontend/package.json" - "frontend/package.json"

View file

@ -14,7 +14,6 @@ on:
- "**.mjs" - "**.mjs"
- "**.cjs" - "**.cjs"
- "**.ts" - "**.ts"
- "**.vue"
- "frontend/package-lock.json" - "frontend/package-lock.json"
pull_request: pull_request:
paths: paths:
@ -25,7 +24,6 @@ on:
- "**.mjs" - "**.mjs"
- "**.cjs" - "**.cjs"
- "**.ts" - "**.ts"
- "**.vue"
- "frontend/package-lock.json" - "frontend/package-lock.json"
schedule: schedule:
- cron: "18 2 * * 2" - cron: "18 2 * * 2"

View file

@ -0,0 +1,11 @@
import { useEffect } from 'react';
import { App } from 'antd';
import { setMessageInstance } from '@/utils/messageBus';
export default function AppBridge({ children }: { children: React.ReactNode }) {
const { message } = App.useApp();
useEffect(() => {
setMessageInstance(message);
}, [message]);
return <>{children}</>;
}

View file

@ -0,0 +1,40 @@
.input-addon {
display: inline-flex;
align-items: center;
padding: 0 11px;
height: 32px;
font-size: 14px;
line-height: 30px;
background-color: rgba(0, 0, 0, 0.02);
border: 1px solid #d9d9d9;
border-radius: 6px;
position: relative;
z-index: 1;
color: rgba(0, 0, 0, 0.88);
white-space: nowrap;
}
body.dark .input-addon,
html[data-theme='ultra-dark'] .input-addon {
background-color: rgba(255, 255, 255, 0.04);
border-color: #424242;
color: rgba(255, 255, 255, 0.85);
}
.ant-space-compact > .input-addon:not(:first-child) {
margin-inline-start: -1px;
}
.ant-space-compact > .input-addon:first-child {
border-start-end-radius: 0;
border-end-end-radius: 0;
}
.ant-space-compact > .input-addon:last-child {
border-start-start-radius: 0;
border-end-start-radius: 0;
}
.ant-space-compact > .input-addon:not(:first-child):not(:last-child) {
border-radius: 0;
}

View file

@ -0,0 +1,21 @@
import type { CSSProperties, ReactNode } from 'react';
import './InputAddon.css';
interface InputAddonProps {
children: ReactNode;
className?: string;
style?: CSSProperties;
onClick?: () => void;
}
export default function InputAddon({ children, className = '', style, onClick }: InputAddonProps) {
return (
<span
className={`input-addon ${className}`.trim()}
style={style}
onClick={onClick}
>
{children}
</span>
);
}

View file

@ -0,0 +1,43 @@
.setting-list-item {
display: flex;
align-items: center;
justify-content: space-between;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.setting-list-item:last-child {
border-bottom: 0;
}
body.dark .setting-list-item,
html[data-theme='ultra-dark'] .setting-list-item {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.setting-list-meta {
display: flex;
flex-direction: column;
gap: 4px;
}
.setting-list-title {
font-size: 14px;
color: rgba(0, 0, 0, 0.88);
font-weight: 500;
}
.setting-list-description {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5715;
}
body.dark .setting-list-title,
html[data-theme='ultra-dark'] .setting-list-title {
color: rgba(255, 255, 255, 0.85);
}
body.dark .setting-list-description,
html[data-theme='ultra-dark'] .setting-list-description {
color: rgba(255, 255, 255, 0.45);
}

View file

@ -1,5 +1,6 @@
import type { ReactNode } from 'react'; import type { ReactNode } from 'react';
import { Col, List, Row } from 'antd'; import { Col, Row } from 'antd';
import './SettingListItem.css';
interface SettingListItemProps { interface SettingListItemProps {
paddings?: 'small' | 'default'; paddings?: 'small' | 'default';
@ -18,15 +19,18 @@ export default function SettingListItem({
}: SettingListItemProps) { }: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px'; const padding = paddings === 'small' ? '10px 20px' : '20px';
return ( return (
<List.Item style={{ padding }}> <div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}> <Row gutter={[8, 16]} style={{ width: '100%' }}>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
<List.Item.Meta title={title} description={description} /> <div className="setting-list-meta">
{title && <div className="setting-list-title">{title}</div>}
{description && <div className="setting-list-description">{description}</div>}
</div>
</Col> </Col>
<Col xs={24} lg={12}> <Col xs={24} lg={12}>
{control ?? children} {control ?? children}
</Col> </Col>
</Row> </Row>
</List.Item> </div>
); );
} }

View file

@ -12,10 +12,11 @@ interface TextModalProps {
} }
export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) { export default function TextModal({ open, onClose, title, content, fileName = '' }: TextModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
async function copy() { async function copy() {
const ok = await ClipboardManager.copyText(content || ''); const ok = await ClipboardManager.copyText(content || '');
if (ok) { if (ok) {
message.success('Copied'); messageApi.success('Copied');
onClose(); onClose();
} }
} }
@ -26,11 +27,13 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={title} <Modal
onCancel={onClose} open={open}
destroyOnHidden title={title}
onCancel={onClose}
destroyOnHidden
footer={( footer={(
<> <>
{fileName && ( {fileName && (
@ -50,6 +53,7 @@ export default function TextModal({ open, onClose, title, content, fileName = ''
overflowY: 'auto', overflowY: 'auto',
}} }}
/> />
</Modal> </Modal>
</>
); );
} }

View file

@ -23,7 +23,6 @@ function applyDom(isDark: boolean, isUltra: boolean) {
if (msg) msg.className = isDark ? 'dark' : 'light'; if (msg) msg.className = isDark ? 'dark' : 'light';
} }
// Mirror the Vue useTheme module: apply current localStorage state at
// module load so the document is in the right theme before React mounts. // module load so the document is in the right theme before React mounts.
const initialDark = readBool(STORAGE_DARK, true); const initialDark = readBool(STORAGE_DARK, true);
const initialUltra = readBool(STORAGE_ULTRA, false); const initialUltra = readBool(STORAGE_ULTRA, false);

View file

@ -25,7 +25,7 @@ export async function readyI18n() {
lng: active, lng: active,
fallbackLng: FALLBACK, fallbackLng: FALLBACK,
resources: { [FALLBACK]: { translation: enUS } }, resources: { [FALLBACK]: { translation: enUS } },
interpolation: { escapeValue: false }, interpolation: { escapeValue: false, prefix: '{', suffix: '}' },
returnNull: false, returnNull: false,
}); });
if (active !== FALLBACK) { if (active !== FALLBACK) {

View file

@ -29,6 +29,7 @@ function highlightJson(str: string): string {
export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) { export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps) {
const [copied, setCopied] = useState(false); const [copied, setCopied] = useState(false);
const [messageApi, messageContextHolder] = message.useMessage();
const highlighted = useMemo( const highlighted = useMemo(
() => (lang === 'json' ? highlightJson(code) : escapeHtml(code)), () => (lang === 'json' ? highlightJson(code) : escapeHtml(code)),
@ -39,15 +40,16 @@ export default function CodeBlock({ code = '', lang = 'json' }: CodeBlockProps)
try { try {
await navigator.clipboard.writeText(code); await navigator.clipboard.writeText(code);
setCopied(true); setCopied(true);
message.success('Copied'); messageApi.success('Copied');
window.setTimeout(() => setCopied(false), 2000); window.setTimeout(() => setCopied(false), 2000);
} catch { } catch {
message.error('Copy failed'); messageApi.error('Copy failed');
} }
} }
return ( return (
<div className="code-block-wrapper"> <div className="code-block-wrapper">
{messageContextHolder}
<div className="code-toolbar"> <div className="code-toolbar">
<span className="lang-badge">{lang.toUpperCase()}</span> <span className="lang-badge">{lang.toUpperCase()}</span>
<button <button

View file

@ -73,6 +73,7 @@ export default function ClientBulkAddModal({
onSaved, onSaved,
}: ClientBulkAddModalProps) { }: ClientBulkAddModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
const [delayedStart, setDelayedStart] = useState(false); const [delayedStart, setDelayedStart] = useState(false);
@ -153,7 +154,7 @@ export default function ClientBulkAddModal({
async function submit() { async function submit() {
if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) { if (!Array.isArray(form.inboundIds) || form.inboundIds.length === 0) {
message.error(t('pages.clients.selectInbound')); messageApi.error(t('pages.clients.selectInbound'));
return; return;
} }
const emails = buildEmails(); const emails = buildEmails();
@ -190,9 +191,9 @@ export default function ClientBulkAddModal({
} }
} }
if (failed === 0) { if (failed === 0) {
message.success(t('pages.clients.toasts.bulkCreated', { count: ok })); messageApi.success(t('pages.clients.toasts.bulkCreated', { count: ok }));
} else { } else {
message.warning(firstError messageApi.warning(firstError
? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}` ? `${t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkCreatedMixed', { ok, failed })); : t('pages.clients.toasts.bulkCreatedMixed', { ok, failed }));
} }
@ -204,10 +205,12 @@ export default function ClientBulkAddModal({
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={t('pages.clients.bulk')} <Modal
okText={t('create')} open={open}
title={t('pages.clients.bulk')}
okText={t('create')}
cancelText={t('close')} cancelText={t('close')}
confirmLoading={saving} confirmLoading={saving}
mask={{ closable: false }} mask={{ closable: false }}
@ -332,6 +335,7 @@ export default function ClientBulkAddModal({
</Form.Item> </Form.Item>
)} )}
</Form> </Form>
</Modal> </Modal>
</>
); );
} }

View file

@ -129,6 +129,7 @@ export default function ClientFormModal({
onOpenChange, onOpenChange,
}: ClientFormModalProps) { }: ClientFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const isEdit = mode === 'edit'; const isEdit = mode === 'edit';
const [form, setForm] = useState<FormState>(emptyForm); const [form, setForm] = useState<FormState>(emptyForm);
@ -268,11 +269,11 @@ export default function ClientFormModal({
async function onSubmit() { async function onSubmit() {
if (!form.email || form.email.trim() === '') { if (!form.email || form.email.trim() === '') {
message.error(`${t('pages.clients.email')} *`); messageApi.error(`${t('pages.clients.email')} *`);
return; return;
} }
if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) { if (!isEdit && (!form.inboundIds || form.inboundIds.length === 0)) {
message.error(t('pages.clients.selectInbound')); messageApi.error(t('pages.clients.selectInbound'));
return; return;
} }
const expiryTime = form.delayedStart const expiryTime = form.delayedStart
@ -324,10 +325,12 @@ export default function ClientFormModal({
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')} <Modal
destroyOnHidden open={open}
title={isEdit ? t('pages.clients.editTitle') : t('pages.clients.addTitle')}
destroyOnHidden
okText={isEdit ? t('save') : t('create')} okText={isEdit ? t('save') : t('create')}
cancelText={t('cancel')} cancelText={t('cancel')}
okButtonProps={{ loading: submitting }} okButtonProps={{ loading: submitting }}
@ -517,6 +520,7 @@ export default function ClientFormModal({
</Form.Item> </Form.Item>
)} )}
</Form> </Form>
</Modal> </Modal>
</>
); );
} }

View file

@ -49,6 +49,7 @@ export default function ClientInfoModal({
onOpenChange, onOpenChange,
}: ClientInfoModalProps) { }: ClientInfoModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [links, setLinks] = useState<string[]>([]); const [links, setLinks] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
@ -93,16 +94,18 @@ export default function ClientInfoModal({
async function copyValue(text: string) { async function copyValue(text: string) {
if (!text) return; if (!text) return;
const ok = await ClipboardManager.copyText(String(text)); const ok = await ClipboardManager.copyText(String(text));
if (ok) message.success(t('copied')); if (ok) messageApi.success(t('copied'));
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={client ? client.email : t('info')} <Modal
footer={null} open={open}
width={640} title={client ? client.email : t('info')}
onCancel={() => onOpenChange(false)} footer={null}
width={640}
onCancel={() => onOpenChange(false)}
> >
{client && ( {client && (
<> <>
@ -289,6 +292,7 @@ export default function ClientInfoModal({
)} )}
</> </>
)} )}
</Modal> </Modal>
</>
); );
} }

View file

@ -48,6 +48,7 @@ import type { ClientRecord, InboundOption } from '@/hooks/useClients';
import AppSidebar from '@/components/AppSidebar'; import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic'; import CustomStatistic from '@/components/CustomStatistic';
import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils'; import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import ClientFormModal from './ClientFormModal'; import ClientFormModal from './ClientFormModal';
import ClientInfoModal from './ClientInfoModal'; import ClientInfoModal from './ClientInfoModal';
import ClientQrModal from './ClientQrModal'; import ClientQrModal from './ClientQrModal';
@ -86,6 +87,8 @@ export default function ClientsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { const {
clients, inbounds, onlines, loading, fetched, subSettings, clients, inbounds, onlines, loading, fetched, subSettings,
@ -318,7 +321,7 @@ export default function ClientsPage() {
try { try {
const msg = await setEnable(row, next); const msg = await setEnable(row, next);
if (!msg?.success) { if (!msg?.success) {
message.error(msg?.msg || t('somethingWentWrong')); messageApi.error(msg?.msg || t('somethingWentWrong'));
} }
} finally { } finally {
setTogglingEmail(null); setTogglingEmail(null);
@ -348,14 +351,14 @@ export default function ClientsPage() {
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const msg = await remove(row.email); const msg = await remove(row.email);
if (msg?.success) message.success(t('pages.clients.toasts.deleted')); if (msg?.success) messageApi.success(t('pages.clients.toasts.deleted'));
}, },
}); });
} }
function onResetTraffic(row: ClientRecord) { function onResetTraffic(row: ClientRecord) {
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) { if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
message.warning(t('pages.clients.resetNotPossible')); messageApi.warning(t('pages.clients.resetNotPossible'));
return; return;
} }
modal.confirm({ modal.confirm({
@ -365,7 +368,7 @@ export default function ClientsPage() {
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const msg = await resetTraffic(row); const msg = await resetTraffic(row);
if (msg?.success) message.success(t('pages.clients.toasts.trafficReset')); if (msg?.success) messageApi.success(t('pages.clients.toasts.trafficReset'));
}, },
}); });
} }
@ -389,7 +392,7 @@ export default function ClientsPage() {
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const msg = await resetAllTraffics(); const msg = await resetAllTraffics();
if (msg?.success) message.success(t('pages.clients.toasts.allTrafficsReset')); if (msg?.success) messageApi.success(t('pages.clients.toasts.allTrafficsReset'));
}, },
}); });
} }
@ -405,7 +408,7 @@ export default function ClientsPage() {
const msg = await delDepleted(); const msg = await delDepleted();
if (msg?.success) { if (msg?.success) {
const deleted = msg.obj?.deleted ?? 0; const deleted = msg.obj?.deleted ?? 0;
message.success(t('pages.clients.toasts.delDepleted', { count: deleted })); messageApi.success(t('pages.clients.toasts.delDepleted', { count: deleted }));
} }
}, },
}); });
@ -434,9 +437,9 @@ export default function ClientsPage() {
} }
} }
if (failed === 0) { if (failed === 0) {
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok })); messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else { } else {
message.warning(firstError messageApi.warning(firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}` ? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })); : t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
} }
@ -620,6 +623,7 @@ export default function ClientsPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar basePath={basePath} requestUri={requestUri} />
@ -758,6 +762,7 @@ export default function ClientsPage() {
rowSelection={rowSelection} rowSelection={rowSelection}
pagination={tablePagination} pagination={tablePagination}
size="small" size="small"
scroll={{ x: 1200 }}
onChange={onTableChange} onChange={onTableChange}
locale={{ locale={{
emptyText: ( emptyText: (

View file

@ -40,6 +40,7 @@ import {
SizeFormatter, SizeFormatter,
Wireguard, Wireguard,
} from '@/utils'; } from '@/utils';
import InputAddon from '@/components/InputAddon';
import { getRandomRealityTarget } from '@/models/reality-targets'; import { getRandomRealityTarget } from '@/models/reality-targets';
import { import {
Inbound, Inbound,
@ -157,6 +158,7 @@ export default function InboundFormModal({
dbInbounds, dbInbounds,
}: InboundFormModalProps) { }: InboundFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const { nodes: availableNodes } = useNodes(); const { nodes: availableNodes } = useNodes();
const selectableNodes = useMemo( const selectableNodes = useMemo(
() => (availableNodes || []).filter((n: NodeRecord) => n.enable), () => (availableNodes || []).filter((n: NodeRecord) => n.enable),
@ -413,8 +415,8 @@ export default function InboundFormModal({
const defaults = deriveFallbackDefaults(child); const defaults = deriveFallbackDefaults(child);
return { ...row, ...defaults }; return { ...row, ...defaults };
})); }));
message.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child'); messageApi.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child');
}, [dbInbounds, t]); }, [dbInbounds, t, messageApi]);
const quickAddAllFallbacks = useCallback(() => { const quickAddAllFallbacks = useCallback(() => {
const masterId = dbInbound?.id; const masterId = dbInbound?.id;
@ -438,13 +440,13 @@ export default function InboundFormModal({
added += 1; added += 1;
} }
if (added > 0) { if (added > 0) {
message.success(t('pages.inbounds.fallbacks.quickAdded', { n: added }) || `Added ${added} fallback(s)`); messageApi.success(t('pages.inbounds.fallbacks.quickAdded', { n: added }) || `Added ${added} fallback(s)`);
} else { } else {
message.info(t('pages.inbounds.fallbacks.quickAddedNone') || 'No new eligible inbounds to add'); messageApi.info(t('pages.inbounds.fallbacks.quickAddedNone') || 'No new eligible inbounds to add');
} }
return next; return next;
}); });
}, [dbInbound, dbInbounds, t]); }, [dbInbound, dbInbounds, t, messageApi]);
const fallbackChildOptions = useMemo(() => { const fallbackChildOptions = useMemo(() => {
const list = dbInbounds || []; const list = dbInbounds || [];
@ -652,16 +654,16 @@ export default function InboundFormModal({
try { try {
return parseAdvancedSliceOrFallback(rawText, fallback); return parseAdvancedSliceOrFallback(rawText, fallback);
} catch (e) { } catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`); messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
throw e; throw e;
} }
}, []); }, [messageApi]);
const compactAdvancedJson = (raw: string, fallback: string, label: string) => { const compactAdvancedJson = (raw: string, fallback: string, label: string) => {
try { try {
return JSON.stringify(JSON.parse(raw || fallback)); return JSON.stringify(JSON.parse(raw || fallback));
} catch (e) { } catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`); messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
throw e; throw e;
} }
}; };
@ -692,11 +694,11 @@ export default function InboundFormModal({
}); });
refresh(); refresh();
} catch (e) { } catch (e) {
message.error(`${t('pages.inbounds.advanced.jsonErrorPrefix')}: ${(e as Error).message}`); messageApi.error(`${t('pages.inbounds.advanced.jsonErrorPrefix')}: ${(e as Error).message}`);
return false; return false;
} }
return true; return true;
}, [t, refresh, parseAdvancedSliceWithLabel]); }, [t, refresh, parseAdvancedSliceWithLabel, messageApi]);
const handleTabChange = (next: string) => { const handleTabChange = (next: string) => {
if (activeTabKey === 'advanced' && next !== 'advanced') { if (activeTabKey === 'advanced' && next !== 'advanced') {
@ -734,23 +736,23 @@ export default function InboundFormModal({
try { try {
parsed = JSON.parse(next); parsed = JSON.parse(next);
} catch (e) { } catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`); messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
return; return;
} }
const unwrapped = unwrapWrappedObject(parsed, key); const unwrapped = unwrapWrappedObject(parsed, key);
if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) { if (!unwrapped || typeof unwrapped !== 'object' || Array.isArray(unwrapped)) {
message.error(`${label} JSON must be an object or { ${key}: { ... } }.`); messageApi.error(`${label} JSON must be an object or { ${key}: { ... } }.`);
return; return;
} }
try { try {
advancedTextRef.current[slice] = JSON.stringify(unwrapped, null, 2); advancedTextRef.current[slice] = JSON.stringify(unwrapped, null, 2);
refresh(); refresh();
} catch (e) { } catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`); messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
} }
}; };
const advancedAllValue = useMemo(() => { const advancedAllValue = (() => {
const ib = inboundRef.current; const ib = inboundRef.current;
if (!ib) return ''; if (!ib) return '';
try { try {
@ -769,19 +771,18 @@ export default function InboundFormModal({
} catch { } catch {
return ''; return '';
} }
// eslint-disable-next-line react-hooks/exhaustive-deps })();
}, [inboundRef.current, canEnableStream]);
const setAdvancedAllValue = (next: string) => { const setAdvancedAllValue = (next: string) => {
let parsed: any; let parsed: any;
try { try {
parsed = JSON.parse(next); parsed = JSON.parse(next);
} catch (e) { } catch (e) {
message.error(`All JSON invalid: ${(e as Error).message}`); messageApi.error(`All JSON invalid: ${(e as Error).message}`);
return; return;
} }
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) { if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
message.error('All JSON must be an inbound object.'); messageApi.error('All JSON must be an inbound object.');
return; return;
} }
const ib = inboundRef.current; const ib = inboundRef.current;
@ -804,7 +805,7 @@ export default function InboundFormModal({
: '{}'; : '{}';
refresh(); refresh();
} catch (e) { } catch (e) {
message.error(`All JSON invalid: ${(e as Error).message}`); messageApi.error(`All JSON invalid: ${(e as Error).message}`);
} }
}; };
@ -919,13 +920,13 @@ export default function InboundFormModal({
{selectableNodes.length > 0 && isNodeEligible && ( {selectableNodes.length > 0 && isNodeEligible && (
<Form.Item label={t('pages.inbounds.deployTo')}> <Form.Item label={t('pages.inbounds.deployTo')}>
<Select <Select
value={form.nodeId} value={form.nodeId ?? ''}
disabled={mode === 'edit'} disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')} placeholder={t('pages.inbounds.localPanel')}
allowClear allowClear
onChange={(v) => { form.nodeId = v ?? null; refresh(); }} onChange={(v) => { form.nodeId = v === '' || v == null ? null : v; refresh(); }}
> >
<Select.Option value={null}>{t('pages.inbounds.localPanel')}</Select.Option> <Select.Option value="">{t('pages.inbounds.localPanel')}</Select.Option>
{selectableNodes.map((n: NodeRecord) => ( {selectableNodes.map((n: NodeRecord) => (
<Select.Option key={n.id} value={n.id} disabled={n.status === 'offline'}> <Select.Option key={n.id} value={n.id} disabled={n.status === 'offline'}>
{n.name}{n.status === 'offline' ? ' (offline)' : ''} {n.name}{n.status === 'offline' ? ' (offline)' : ''}
@ -991,7 +992,7 @@ export default function InboundFormModal({
{t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.'} {t('pages.inbounds.fallbacks.help') || 'When a connection on this inbound does not match any client, route it to another inbound. Pick a child below and the routing fields (SNI / ALPN / path / xver) auto-fill from its transport — most setups need no further tweaking. Each child should listen on 127.0.0.1 with security=none.'}
</Paragraph> </Paragraph>
{fallbacks.length === 0 && ( {fallbacks.length === 0 && (
<Empty description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'} imageStyle={{ height: 40 }} style={{ margin: '8px 0 12px' }} /> <Empty description={t('pages.inbounds.fallbacks.empty') || 'No fallbacks yet'} styles={{ image: { height: 40 } }} style={{ margin: '8px 0 12px' }} />
)} )}
{fallbacks.map((record, index) => ( {fallbacks.map((record, index) => (
<div key={record.rowKey} style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}> <div key={record.rowKey} style={{ border: '1px solid var(--app-border-tertiary)', borderRadius: 6, padding: '10px 12px', marginBottom: 8 }}>
@ -1043,21 +1044,33 @@ export default function InboundFormModal({
{fallbackEditing.has(record.rowKey) && ( {fallbackEditing.has(record.rowKey) && (
<Row gutter={8} style={{ marginTop: 8 }}> <Row gutter={8} style={{ marginTop: 8 }}>
<Col xs={24} md={8}> <Col xs={24} md={8}>
<Input addonBefore="SNI" placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'} <Space.Compact block>
value={record.name} onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })} /> <InputAddon>SNI</InputAddon>
<Input placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name} onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })} />
</Space.Compact>
</Col> </Col>
<Col xs={24} md={5}> <Col xs={24} md={5}>
<Input addonBefore="ALPN" placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'} <Space.Compact block>
value={record.alpn} onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })} /> <InputAddon>ALPN</InputAddon>
<Input placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn} onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })} />
</Space.Compact>
</Col> </Col>
<Col xs={24} md={7}> <Col xs={24} md={7}>
<Input addonBefore="Path" placeholder="/" value={record.path} <Space.Compact block>
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })} /> <InputAddon>Path</InputAddon>
<Input placeholder="/" value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })} />
</Space.Compact>
</Col> </Col>
<Col xs={24} md={4}> <Col xs={24} md={4}>
<InputNumber addonBefore="xver" min={0} max={2} style={{ width: '100%' }} <Space.Compact block>
value={record.xver} <InputAddon>xver</InputAddon>
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })} /> <InputNumber min={0} max={2} style={{ width: '100%' }}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })} />
</Space.Compact>
</Col> </Col>
</Row> </Row>
)} )}
@ -1146,10 +1159,10 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.settings.accounts || []).map((account: any, idx: number) => ( {(ib.settings.accounts || []).map((account: any, idx: number) => (
<Space.Compact key={idx} className="mb-8" block> <Space.Compact key={idx} className="mb-8" block>
<Input style={{ width: '45%' }} value={account.user} <InputAddon>{String(idx + 1)}</InputAddon>
addonBefore={String(idx + 1)} placeholder="Username" <Input value={account.user} placeholder="Username"
onChange={(e) => { account.user = e.target.value; refresh(); }} /> onChange={(e) => { account.user = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={account.pass} placeholder="Password" <Input value={account.pass} placeholder="Password"
onChange={(e) => { account.pass = e.target.value; refresh(); }} /> onChange={(e) => { account.pass = e.target.value; refresh(); }} />
<Button onClick={() => { ib.settings.delAccount(idx); refresh(); }}> <Button onClick={() => { ib.settings.delAccount(idx); refresh(); }}>
<MinusOutlined /> <MinusOutlined />
@ -1208,9 +1221,10 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.settings.portMap as { name: string; value: string }[]).map((pm, idx) => ( {(ib.settings.portMap as { name: string; value: string }[]).map((pm, idx) => (
<Space.Compact key={`pm-${idx}`} className="mb-8" block> <Space.Compact key={`pm-${idx}`} className="mb-8" block>
<Input style={{ width: '30%' }} value={pm.name} placeholder="5555" addonBefore={String(idx + 1)} <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={pm.name} placeholder="5555"
onChange={(e) => { pm.name = e.target.value; refresh(); }} /> onChange={(e) => { pm.name = e.target.value; refresh(); }} />
<Input style={{ width: '60%' }} value={pm.value} placeholder="1.1.1.1:7777" <Input value={pm.value} placeholder="1.1.1.1:7777"
onChange={(e) => { pm.value = e.target.value; refresh(); }} /> onChange={(e) => { pm.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.settings.removePortMap(idx); refresh(); }}> <Button onClick={() => { ib.settings.removePortMap(idx); refresh(); }}>
<MinusOutlined /> <MinusOutlined />
@ -1240,11 +1254,15 @@ export default function InboundFormModal({
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{(ib.settings.gateway || []).map((_ip: string, j: number) => ( {(ib.settings.gateway || []).map((_ip: string, j: number) => (
<Input key={`tun-gw-${j}`} className="mt-4" <Space.Compact key={`tun-gw-${j}`} block className="mt-4">
placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'} <Input
value={ib.settings.gateway[j]} placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'}
onChange={(e) => { ib.settings.gateway[j] = e.target.value; refresh(); }} value={ib.settings.gateway[j]}
addonAfter={<Button size="small" onClick={() => { ib.settings.gateway.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} /> onChange={(e) => { ib.settings.gateway[j] = e.target.value; refresh(); }} />
<Button size="small" onClick={() => { ib.settings.gateway.splice(j, 1); refresh(); }}>
<MinusOutlined />
</Button>
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label="DNS"> <Form.Item label="DNS">
@ -1252,11 +1270,15 @@ export default function InboundFormModal({
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{(ib.settings.dns || []).map((_ip: string, j: number) => ( {(ib.settings.dns || []).map((_ip: string, j: number) => (
<Input key={`tun-dns-${j}`} className="mt-4" <Space.Compact key={`tun-dns-${j}`} block className="mt-4">
placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'} <Input
value={ib.settings.dns[j]} placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'}
onChange={(e) => { ib.settings.dns[j] = e.target.value; refresh(); }} value={ib.settings.dns[j]}
addonAfter={<Button size="small" onClick={() => { ib.settings.dns.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} /> onChange={(e) => { ib.settings.dns[j] = e.target.value; refresh(); }} />
<Button size="small" onClick={() => { ib.settings.dns.splice(j, 1); refresh(); }}>
<MinusOutlined />
</Button>
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label="User level"> <Form.Item label="User level">
@ -1268,11 +1290,15 @@ export default function InboundFormModal({
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{(ib.settings.autoSystemRoutingTable || []).map((_ip: string, j: number) => ( {(ib.settings.autoSystemRoutingTable || []).map((_ip: string, j: number) => (
<Input key={`tun-rt-${j}`} className="mt-4" <Space.Compact key={`tun-rt-${j}`} block className="mt-4">
placeholder={j === 0 ? '0.0.0.0/0' : '::/0'} <Input
value={ib.settings.autoSystemRoutingTable[j]} placeholder={j === 0 ? '0.0.0.0/0' : '::/0'}
onChange={(e) => { ib.settings.autoSystemRoutingTable[j] = e.target.value; refresh(); }} value={ib.settings.autoSystemRoutingTable[j]}
addonAfter={<Button size="small" onClick={() => { ib.settings.autoSystemRoutingTable.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} /> onChange={(e) => { ib.settings.autoSystemRoutingTable[j] = e.target.value; refresh(); }} />
<Button size="small" onClick={() => { ib.settings.autoSystemRoutingTable.splice(j, 1); refresh(); }}>
<MinusOutlined />
</Button>
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label={<Tooltip title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">Auto outbounds interface</Tooltip>}> <Form.Item label={<Tooltip title="Physical interface for outbound traffic. Use 'auto' to detect; auto-enabled when Auto system routes is set.">Auto outbounds interface</Tooltip>}>
@ -1326,12 +1352,16 @@ export default function InboundFormModal({
<PlusOutlined /> <PlusOutlined />
</Button> </Button>
{(peer.allowedIPs || []).map((_ip: string, j: number) => ( {(peer.allowedIPs || []).map((_ip: string, j: number) => (
<Input key={j} className="mt-4" <Space.Compact key={j} block className="mt-4">
value={peer.allowedIPs[j]} <Input
onChange={(e) => { peer.allowedIPs[j] = e.target.value; refresh(); }} value={peer.allowedIPs[j]}
addonAfter={peer.allowedIPs.length > 1 onChange={(e) => { peer.allowedIPs[j] = e.target.value; refresh(); }} />
? <Button size="small" onClick={() => { peer.allowedIPs.splice(j, 1); refresh(); }}><MinusOutlined /></Button> {peer.allowedIPs.length > 1 && (
: undefined} /> <Button size="small" onClick={() => { peer.allowedIPs.splice(j, 1); refresh(); }}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label="Keep-alive"> <Form.Item label="Keep-alive">
@ -1388,12 +1418,16 @@ export default function InboundFormModal({
</Form.Item> </Form.Item>
<Form.Item label={<>{t('pages.inbounds.stream.tcp.path')} <Button size="small" style={{ marginLeft: 6 }} onClick={() => { ib.stream.tcp.request.addPath('/'); refresh(); }}><PlusOutlined /></Button></>}> <Form.Item label={<>{t('pages.inbounds.stream.tcp.path')} <Button size="small" style={{ marginLeft: 6 }} onClick={() => { ib.stream.tcp.request.addPath('/'); refresh(); }}><PlusOutlined /></Button></>}>
{(ib.stream.tcp.request.path || []).map((_p: string, idx: number) => ( {(ib.stream.tcp.request.path || []).map((_p: string, idx: number) => (
<Input key={`tcp-path-${idx}`} className="mb-4" <Space.Compact key={`tcp-path-${idx}`} block className="mb-4">
value={ib.stream.tcp.request.path[idx]} <Input
onChange={(e) => { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }} value={ib.stream.tcp.request.path[idx]}
addonAfter={ib.stream.tcp.request.path.length > 1 onChange={(e) => { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }} />
? <Button size="small" onClick={() => { ib.stream.tcp.request.removePath(idx); refresh(); }}><MinusOutlined /></Button> {ib.stream.tcp.request.path.length > 1 && (
: undefined} /> <Button size="small" onClick={() => { ib.stream.tcp.request.removePath(idx); refresh(); }}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}> <Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}>
@ -1405,10 +1439,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.tcp.request.headers as { name: string; value: string }[]).map((h, idx) => ( {(ib.stream.tcp.request.headers as { name: string; value: string }[]).map((h, idx) => (
<Space.Compact key={`tcp-rh-${idx}`} className="mb-8" block> <Space.Compact key={`tcp-rh-${idx}`} className="mb-8" block>
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={h.name}
placeholder={t('pages.inbounds.stream.general.name')} placeholder={t('pages.inbounds.stream.general.name')}
onChange={(e) => { h.name = e.target.value; refresh(); }} /> onChange={(e) => { h.name = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={h.value} <Input value={h.value}
placeholder={t('pages.inbounds.stream.general.value')} placeholder={t('pages.inbounds.stream.general.value')}
onChange={(e) => { h.value = e.target.value; refresh(); }} /> onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.tcp.request.removeHeader(idx); refresh(); }}> <Button onClick={() => { ib.stream.tcp.request.removeHeader(idx); refresh(); }}>
@ -1440,10 +1475,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.tcp.response.headers as { name: string; value: string }[]).map((h, idx) => ( {(ib.stream.tcp.response.headers as { name: string; value: string }[]).map((h, idx) => (
<Space.Compact key={`tcp-rsh-${idx}`} className="mb-8" block> <Space.Compact key={`tcp-rsh-${idx}`} className="mb-8" block>
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={h.name}
placeholder={t('pages.inbounds.stream.general.name')} placeholder={t('pages.inbounds.stream.general.name')}
onChange={(e) => { h.name = e.target.value; refresh(); }} /> onChange={(e) => { h.name = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={h.value} <Input value={h.value}
placeholder={t('pages.inbounds.stream.general.value')} placeholder={t('pages.inbounds.stream.general.value')}
onChange={(e) => { h.value = e.target.value; refresh(); }} /> onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.tcp.response.removeHeader(idx); refresh(); }}> <Button onClick={() => { ib.stream.tcp.response.removeHeader(idx); refresh(); }}>
@ -1482,10 +1518,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.ws.headers as { name: string; value: string }[]).map((h, idx) => ( {(ib.stream.ws.headers as { name: string; value: string }[]).map((h, idx) => (
<Space.Compact key={`ws-h-${idx}`} className="mb-8" block> <Space.Compact key={`ws-h-${idx}`} className="mb-8" block>
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={h.name}
placeholder={t('pages.inbounds.stream.general.name')} placeholder={t('pages.inbounds.stream.general.name')}
onChange={(e) => { h.name = e.target.value; refresh(); }} /> onChange={(e) => { h.name = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={h.value} <Input value={h.value}
placeholder={t('pages.inbounds.stream.general.value')} placeholder={t('pages.inbounds.stream.general.value')}
onChange={(e) => { h.value = e.target.value; refresh(); }} /> onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.ws.removeHeader(idx); refresh(); }}> <Button onClick={() => { ib.stream.ws.removeHeader(idx); refresh(); }}>
@ -1518,10 +1555,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.httpupgrade.headers as { name: string; value: string }[]).map((h, idx) => ( {(ib.stream.httpupgrade.headers as { name: string; value: string }[]).map((h, idx) => (
<Space.Compact key={`hu-h-${idx}`} className="mb-8" block> <Space.Compact key={`hu-h-${idx}`} className="mb-8" block>
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={h.name}
placeholder={t('pages.inbounds.stream.general.name')} placeholder={t('pages.inbounds.stream.general.name')}
onChange={(e) => { h.name = e.target.value; refresh(); }} /> onChange={(e) => { h.name = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={h.value} <Input value={h.value}
placeholder={t('pages.inbounds.stream.general.value')} placeholder={t('pages.inbounds.stream.general.value')}
onChange={(e) => { h.value = e.target.value; refresh(); }} /> onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.httpupgrade.removeHeader(idx); refresh(); }}> <Button onClick={() => { ib.stream.httpupgrade.removeHeader(idx); refresh(); }}>
@ -1545,10 +1583,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.xhttp.headers as { name: string; value: string }[]).map((h, idx) => ( {(ib.stream.xhttp.headers as { name: string; value: string }[]).map((h, idx) => (
<Space.Compact key={`xh-h-${idx}`} className="mb-8" block> <Space.Compact key={`xh-h-${idx}`} className="mb-8" block>
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={h.name}
placeholder={t('pages.inbounds.stream.general.name')} placeholder={t('pages.inbounds.stream.general.name')}
onChange={(e) => { h.name = e.target.value; refresh(); }} /> onChange={(e) => { h.name = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={h.value} <Input value={h.value}
placeholder={t('pages.inbounds.stream.general.value')} placeholder={t('pages.inbounds.stream.general.value')}
onChange={(e) => { h.value = e.target.value; refresh(); }} /> onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.xhttp.removeHeader(idx); refresh(); }}> <Button onClick={() => { ib.stream.xhttp.removeHeader(idx); refresh(); }}>
@ -1665,9 +1704,11 @@ export default function InboundFormModal({
<InputNumber value={row.port} style={{ width: '15%' }} min={1} max={65535} <InputNumber value={row.port} style={{ width: '15%' }} min={1} max={65535}
onChange={(v) => { row.port = Number(v) || 0; refresh(); }} /> onChange={(v) => { row.port = Number(v) || 0; refresh(); }} />
</Tooltip> </Tooltip>
<Input style={{ width: '35%' }} value={row.remark} placeholder={t('pages.inbounds.remark')} <Input style={{ width: '25%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
onChange={(e) => { row.remark = e.target.value; refresh(); }} onChange={(e) => { row.remark = e.target.value; refresh(); }} />
addonAfter={<MinusOutlined onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }} />} /> <InputAddon onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }}>
<MinusOutlined />
</InputAddon>
</Space.Compact> </Space.Compact>
))} ))}
</Form.Item> </Form.Item>
@ -1762,9 +1803,10 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.hysteria.masquerade.headers as { name: string; value: string }[]).map((h, idx) => ( {(ib.stream.hysteria.masquerade.headers as { name: string; value: string }[]).map((h, idx) => (
<Space.Compact key={`mh-${idx}`} className="mb-8" block> <Space.Compact key={`mh-${idx}`} className="mb-8" block>
<Input style={{ width: '45%' }} value={h.name} addonBefore={String(idx + 1)} placeholder="Name" <InputAddon>{String(idx + 1)}</InputAddon>
<Input value={h.name} placeholder="Name"
onChange={(e) => { h.name = e.target.value; refresh(); }} /> onChange={(e) => { h.name = e.target.value; refresh(); }} />
<Input style={{ width: '45%' }} value={h.value} placeholder="Value" <Input value={h.value} placeholder="Value"
onChange={(e) => { h.value = e.target.value; refresh(); }} /> onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.hysteria.masquerade.removeHeader(idx); refresh(); }}> <Button onClick={() => { ib.stream.hysteria.masquerade.removeHeader(idx); refresh(); }}>
<MinusOutlined /> <MinusOutlined />
@ -2078,19 +2120,22 @@ export default function InboundFormModal({
tabItems.push({ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: renderAdvancedTab() }); tabItems.push({ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: renderAdvancedTab() });
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={title} <Modal
okText={okText} open={open}
cancelText={t('close')} title={title}
confirmLoading={saving} okText={okText}
mask={{ closable: false }} cancelText={t('close')}
width={780} confirmLoading={saving}
onOk={submit} mask={{ closable: false }}
onCancel={onClose} width={780}
destroyOnHidden onOk={submit}
> onCancel={onClose}
<Tabs activeKey={activeTabKey} onChange={handleTabChange} items={tabItems} /> destroyOnHidden
</Modal> >
<Tabs activeKey={activeTabKey} onChange={handleTabChange} items={tabItems} />
</Modal>
</>
); );
} }

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip, message } from 'antd'; import { Button, Divider, Modal, Space, Tabs, Tag, Tooltip } from 'antd';
import { getMessage } from '@/utils/messageBus';
import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons'; import { CopyOutlined, SyncOutlined, DeleteOutlined, DownloadOutlined } from '@ant-design/icons';
import { import {
@ -106,7 +107,7 @@ interface InboundInfoModalProps {
function copyText(value: unknown, t: (k: string) => string) { function copyText(value: unknown, t: (k: string) => string) {
ClipboardManager.copyText(String(value ?? '')).then((ok) => { ClipboardManager.copyText(String(value ?? '')).then((ok) => {
if (ok) message.success(t('copied')); if (ok) getMessage().success(t('copied'));
}); });
} }

View file

@ -11,6 +11,8 @@ import {
Spin, Spin,
message, message,
} from 'antd'; } from 'antd';
import { setMessageInstance } from '@/utils/messageBus';
import { import {
SwapOutlined, SwapOutlined,
PieChartOutlined, PieChartOutlined,
@ -76,6 +78,10 @@ export default function InboundsPage() {
applyInboundsEvent, applyInboundsEvent,
} = useInbounds(); } = useInbounds();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes: nodesList } = useNodes(); const { nodes: nodesList } = useNodes();
const nodesById = useMemo(() => { const nodesById = useMemo(() => {
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>(); const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
@ -305,7 +311,7 @@ export default function InboundsPage() {
}, []); }, []);
const confirmDelete = useCallback((dbInbound: any) => { const confirmDelete = useCallback((dbInbound: any) => {
Modal.confirm({ modal.confirm({
title: `Delete inbound "${dbInbound.remark}"?`, title: `Delete inbound "${dbInbound.remark}"?`,
content: 'This removes the inbound and all its clients. This cannot be undone.', content: 'This removes the inbound and all its clients. This cannot be undone.',
okText: 'Delete', okText: 'Delete',
@ -316,10 +322,10 @@ export default function InboundsPage() {
if (msg?.success) await refresh(); if (msg?.success) await refresh();
}, },
}); });
}, [refresh]); }, [modal, refresh]);
const confirmResetTraffic = useCallback((dbInbound: any) => { const confirmResetTraffic = useCallback((dbInbound: any) => {
Modal.confirm({ modal.confirm({
title: `Reset traffic for "${dbInbound.remark}"?`, title: `Reset traffic for "${dbInbound.remark}"?`,
content: 'Resets up/down counters to 0 for this inbound.', content: 'Resets up/down counters to 0 for this inbound.',
okText: 'Reset', okText: 'Reset',
@ -329,10 +335,10 @@ export default function InboundsPage() {
if (msg?.success) await refresh(); if (msg?.success) await refresh();
}, },
}); });
}, [refresh]); }, [modal, refresh]);
const confirmClone = useCallback((dbInbound: any) => { const confirmClone = useCallback((dbInbound: any) => {
Modal.confirm({ modal.confirm({
title: `Clone inbound "${dbInbound.remark}"?`, title: `Clone inbound "${dbInbound.remark}"?`,
content: 'Creates a copy with a new port and an empty client list.', content: 'Creates a copy with a new port and an empty client list.',
okText: 'Clone', okText: 'Clone',
@ -365,7 +371,7 @@ export default function InboundsPage() {
if (msg?.success) await refresh(); if (msg?.success) await refresh();
}, },
}); });
}, [refresh]); }, [modal, refresh]);
const onGeneralAction = useCallback((key: GeneralAction) => { const onGeneralAction = useCallback((key: GeneralAction) => {
switch (key) { switch (key) {
@ -373,7 +379,7 @@ export default function InboundsPage() {
case 'export': exportAllLinks(); break; case 'export': exportAllLinks(); break;
case 'subs': exportAllSubs(); break; case 'subs': exportAllSubs(); break;
case 'resetInbounds': case 'resetInbounds':
Modal.confirm({ modal.confirm({
title: 'Reset all inbound traffic?', title: 'Reset all inbound traffic?',
okText: 'Reset', okText: 'Reset',
cancelText: 'Cancel', cancelText: 'Cancel',
@ -384,9 +390,9 @@ export default function InboundsPage() {
}); });
break; break;
default: default:
message.info(`General action "${key}" — coming in a later 5f subphase`); messageApi.info(`General action "${key}" — coming in a later 5f subphase`);
} }
}, [importInbound, exportAllLinks, exportAllSubs, refresh]); }, [modal, importInbound, exportAllLinks, exportAllSubs, refresh, messageApi]);
const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => { const onRowAction = useCallback(({ key, dbInbound }: { key: RowAction; dbInbound: any }) => {
switch (key) { switch (key) {
@ -421,15 +427,17 @@ export default function InboundsPage() {
confirmClone(dbInbound); confirmClone(dbInbound);
break; break;
default: default:
message.info(`Action "${key}" — coming in a later 5f subphase`); messageApi.info(`Action "${key}" — coming in a later 5f subphase`);
} }
}, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone]); }, [openEdit, checkFallback, findClientIndex, exportInboundLinks, exportInboundSubs, exportInboundClipboard, confirmDelete, confirmResetTraffic, confirmClone, messageApi]);
const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || ''; const basePath = (typeof window !== 'undefined' && window.X_UI_BASE_PATH) || '';
const requestUri = typeof window !== 'undefined' ? window.location.pathname : ''; const requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}> <Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar basePath={basePath} requestUri={requestUri} />

View file

@ -1,6 +1,7 @@
import { useEffect, useMemo, useState } from 'react'; import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Collapse, Modal } from 'antd'; import { Collapse, Modal } from 'antd';
import type { CollapseProps } from 'antd';
import { Protocols } from '@/models/inbound.js'; import { Protocols } from '@/models/inbound.js';
import QrPanel from './QrPanel'; import QrPanel from './QrPanel';
@ -56,7 +57,7 @@ export default function QrCodeModal({
const [wireguardLinks, setWireguardLinks] = useState<string[]>([]); const [wireguardLinks, setWireguardLinks] = useState<string[]>([]);
const [subLink, setSubLink] = useState(''); const [subLink, setSubLink] = useState('');
const [subJsonLink, setSubJsonLink] = useState(''); const [subJsonLink, setSubJsonLink] = useState('');
const [activeKeys, setActiveKeys] = useState<string[]>([]); const [defaultActive, setDefaultActive] = useState<string[]>([]);
useEffect(() => { useEffect(() => {
if (!open || !dbInbound) return; if (!open || !dbInbound) return;
@ -83,7 +84,7 @@ export default function QrCodeModal({
} }
setSubLink(nextSub); setSubLink(nextSub);
setSubJsonLink(nextSubJson); setSubJsonLink(nextSubJson);
setActiveKeys(nextSub ? ['sub'] : []); setDefaultActive(nextSub ? ['sub'] : []);
}, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]); }, [open, dbInbound, client, remarkModel, nodeAddress, subSettings]);
const qrItems = useMemo<QrItem[]>(() => { const qrItems = useMemo<QrItem[]>(() => {
@ -111,26 +112,29 @@ export default function QrCodeModal({
return items; return items;
}, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]); }, [subLink, subJsonLink, links, wireguardConfigs, wireguardLinks, t]);
const collapseItems = qrItems.map((item) => ({ const collapseItems: CollapseProps['items'] = useMemo(
key: item.key, () => qrItems.map((item) => ({
label: item.header, key: item.key,
children: ( label: item.header,
<QrPanel children: (
value={item.value} <QrPanel
remark={item.header} value={item.value}
downloadName={item.downloadName || ''} remark={item.header}
showQr={!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')} downloadName={item.downloadName || ''}
/> showQr={!item.value.includes('mldsa65') && !item.value.includes('ML-KEM-768')}
), />
})); ),
})),
[qrItems],
);
return ( return (
<Modal open={open} onCancel={onClose} title={t('qrCode')} footer={null} width={420} destroyOnHidden> <Modal open={open} onCancel={onClose} title={t('qrCode')} footer={null} width={420} destroyOnHidden>
{dbInbound && ( {dbInbound && collapseItems && collapseItems.length > 0 && (
<Collapse <Collapse
key={collapseItems.map((i) => i?.key).join('|')}
ghost ghost
activeKey={activeKeys} defaultActiveKey={defaultActive}
onChange={(keys) => setActiveKeys(Array.isArray(keys) ? keys : [keys])}
items={collapseItems} items={collapseItems}
/> />
)} )}

View file

@ -59,11 +59,12 @@ export default function QrPanel({
showQr = true, showQr = true,
}: QrPanelProps) { }: QrPanelProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const qrRef = useRef<HTMLDivElement | null>(null); const qrRef = useRef<HTMLDivElement | null>(null);
async function copy() { async function copy() {
const ok = await ClipboardManager.copyText(value); const ok = await ClipboardManager.copyText(value);
if (ok) message.success(t('copied')); if (ok) messageApi.success(t('copied'));
} }
function download() { function download() {
@ -77,7 +78,7 @@ export default function QrPanel({
if (!blob) return; if (!blob) return;
try { try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]); await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
message.success(t('copied')); messageApi.success(t('copied'));
} catch { } catch {
downloadImageBlob(blob, remark); downloadImageBlob(blob, remark);
} }
@ -91,6 +92,7 @@ export default function QrPanel({
return ( return (
<div className="qr-panel"> <div className="qr-panel">
{messageContextHolder}
<div className="qr-panel-header"> <div className="qr-panel-header">
<Tag color="green" className="qr-remark">{remark}</Tag> <Tag color="green" className="qr-remark">{remark}</Tag>
<Tooltip title={t('copy')}> <Tooltip title={t('copy')}>

View file

@ -1,9 +1,57 @@
.backup-list { .backup-list {
width: 100%; width: 100%;
border: 1px solid rgba(5, 5, 5, 0.06);
border-radius: 8px;
overflow: hidden;
}
body.dark .backup-list,
html[data-theme='ultra-dark'] .backup-list {
border-color: rgba(255, 255, 255, 0.12);
} }
.backup-item { .backup-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 16px; gap: 16px;
padding: 12px 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.backup-item:last-child {
border-bottom: 0;
}
body.dark .backup-item,
html[data-theme='ultra-dark'] .backup-item {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.backup-meta {
flex: 1;
display: flex;
flex-direction: column;
gap: 4px;
}
.backup-title {
font-size: 14px;
font-weight: 500;
color: rgba(0, 0, 0, 0.88);
}
.backup-description {
font-size: 14px;
color: rgba(0, 0, 0, 0.45);
line-height: 1.5715;
}
body.dark .backup-title,
html[data-theme='ultra-dark'] .backup-title {
color: rgba(255, 255, 255, 0.85);
}
body.dark .backup-description,
html[data-theme='ultra-dark'] .backup-description {
color: rgba(255, 255, 255, 0.45);
} }

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, List, Modal } from 'antd'; import { Button, Modal } from 'antd';
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons'; import { DownloadOutlined, UploadOutlined } from '@ant-design/icons';
import { HttpUtil, PromiseUtil } from '@/utils'; import { HttpUtil, PromiseUtil } from '@/utils';
@ -65,23 +65,23 @@ export default function BackupModal({ open, basePath: _basePath, onClose, onBusy
footer={null} footer={null}
onCancel={onClose} onCancel={onClose}
> >
<List bordered className="backup-list"> <div className="backup-list">
<List.Item className="backup-item"> <div className="backup-item">
<List.Item.Meta <div className="backup-meta">
title={t('pages.index.exportDatabase')} <div className="backup-title">{t('pages.index.exportDatabase')}</div>
description={t('pages.index.exportDatabaseDesc')} <div className="backup-description">{t('pages.index.exportDatabaseDesc')}</div>
/> </div>
<Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} /> <Button type="primary" onClick={exportDb} icon={<DownloadOutlined />} />
</List.Item> </div>
<List.Item className="backup-item"> <div className="backup-item">
<List.Item.Meta <div className="backup-meta">
title={t('pages.index.importDatabase')} <div className="backup-title">{t('pages.index.importDatabase')}</div>
description={t('pages.index.importDatabaseDesc')} <div className="backup-description">{t('pages.index.importDatabaseDesc')}</div>
/> </div>
<Button type="primary" onClick={importDb} icon={<UploadOutlined />} /> <Button type="primary" onClick={importDb} icon={<UploadOutlined />} />
</List.Item> </div>
</List> </div>
</Modal> </Modal>
); );
} }

View file

@ -25,6 +25,7 @@ export default function CustomGeoFormModal({
onSaved, onSaved,
}: CustomGeoFormModalProps) { }: CustomGeoFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [type, setType] = useState<'geosite' | 'geoip'>('geosite'); const [type, setType] = useState<'geosite' | 'geoip'>('geosite');
const [alias, setAlias] = useState(''); const [alias, setAlias] = useState('');
const [url, setUrl] = useState(''); const [url, setUrl] = useState('');
@ -47,22 +48,22 @@ export default function CustomGeoFormModal({
function validate(): boolean { function validate(): boolean {
if (!/^[a-z0-9_-]+$/.test(alias || '')) { if (!/^[a-z0-9_-]+$/.test(alias || '')) {
message.error(t('pages.index.customGeoValidationAlias')); messageApi.error(t('pages.index.customGeoValidationAlias'));
return false; return false;
} }
const u = (url || '').trim(); const u = (url || '').trim();
if (!/^https?:\/\//i.test(u)) { if (!/^https?:\/\//i.test(u)) {
message.error(t('pages.index.customGeoValidationUrl')); messageApi.error(t('pages.index.customGeoValidationUrl'));
return false; return false;
} }
try { try {
const parsed = new URL(u); const parsed = new URL(u);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') { if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
message.error(t('pages.index.customGeoValidationUrl')); messageApi.error(t('pages.index.customGeoValidationUrl'));
return false; return false;
} }
} catch { } catch {
message.error(t('pages.index.customGeoValidationUrl')); messageApi.error(t('pages.index.customGeoValidationUrl'));
return false; return false;
} }
return true; return true;
@ -86,9 +87,11 @@ export default function CustomGeoFormModal({
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')} <Modal
open={open}
title={editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')}
confirmLoading={saving} confirmLoading={saving}
okText={t('pages.index.customGeoModalSave')} okText={t('pages.index.customGeoModalSave')}
cancelText={t('close')} cancelText={t('close')}
@ -123,6 +126,7 @@ export default function CustomGeoFormModal({
/> />
</Form.Item> </Form.Item>
</Form> </Form>
</Modal> </Modal>
</>
); );
} }

View file

@ -51,6 +51,7 @@ function extDisplay(record: CustomGeoListRecord): string {
export default function CustomGeoSection({ active }: CustomGeoSectionProps) { export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [list, setList] = useState<CustomGeoListRecord[]>([]); const [list, setList] = useState<CustomGeoListRecord[]>([]);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [updatingAll, setUpdatingAll] = useState(false); const [updatingAll, setUpdatingAll] = useState(false);
@ -85,7 +86,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
async function copyExt(record: CustomGeoListRecord) { async function copyExt(record: CustomGeoListRecord) {
const text = extDisplay(record); const text = extDisplay(record);
const ok = await ClipboardManager.copyText(text); const ok = await ClipboardManager.copyText(text);
if (ok) message.success(`${t('copied')}: ${text}`); if (ok) messageApi.success(`${t('copied')}: ${text}`);
} }
function confirmDelete(record: CustomGeoListRecord) { function confirmDelete(record: CustomGeoListRecord) {
@ -120,7 +121,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
const failed = msg?.obj?.failed?.length || 0; const failed = msg?.obj?.failed?.length || 0;
if (msg?.success || ok > 0) { if (msg?.success || ok > 0) {
await loadList(); await loadList();
if (failed > 0) message.warning(`Updated ${ok}, failed ${failed}`); if (failed > 0) messageApi.warning(`Updated ${ok}, failed ${failed}`);
} }
} finally { } finally {
setUpdatingAll(false); setUpdatingAll(false);
@ -229,6 +230,7 @@ export default function CustomGeoSection({ active }: CustomGeoSectionProps) {
return ( return (
<div className="custom-geo-section"> <div className="custom-geo-section">
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Alert <Alert
type="info" type="info"

View file

@ -40,6 +40,7 @@ import { useMediaQuery } from '@/hooks/useMediaQuery';
import AppSidebar from '@/components/AppSidebar'; import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic'; import CustomStatistic from '@/components/CustomStatistic';
import JsonEditor from '@/components/JsonEditor'; import JsonEditor from '@/components/JsonEditor';
import { setMessageInstance } from '@/utils/messageBus';
import StatusCard from './StatusCard'; import StatusCard from './StatusCard';
import XrayStatusCard from './XrayStatusCard'; import XrayStatusCard from './XrayStatusCard';
import PanelUpdateModal from './PanelUpdateModal'; import PanelUpdateModal from './PanelUpdateModal';
@ -57,6 +58,8 @@ export default function IndexPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { status, fetched, refresh } = useStatus(); const { status, fetched, refresh } = useStatus();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [ipLimitEnable, setIpLimitEnable] = useState(false); const [ipLimitEnable, setIpLimitEnable] = useState(false);
const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({ const [panelUpdateInfo, setPanelUpdateInfo] = useState<PanelUpdateInfo>({
@ -139,7 +142,7 @@ export default function IndexPage() {
async function copyConfig() { async function copyConfig() {
const ok = await ClipboardManager.copyText(configText || ''); const ok = await ClipboardManager.copyText(configText || '');
if (ok) message.success('Copied'); if (ok) messageApi.success('Copied');
} }
function downloadConfig() { function downloadConfig() {
@ -150,6 +153,7 @@ export default function IndexPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar basePath={basePath} requestUri={requestUri} />

View file

@ -4,11 +4,31 @@
.version-list { .version-list {
width: 100%; width: 100%;
border: 1px solid rgba(5, 5, 5, 0.06);
border-radius: 8px;
overflow: hidden;
}
body.dark .version-list,
html[data-theme='ultra-dark'] .version-list {
border-color: rgba(255, 255, 255, 0.12);
} }
.version-list-item { .version-list-item {
display: flex; display: flex;
align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 12px 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.version-list-item:last-child {
border-bottom: 0;
}
body.dark .version-list-item,
html[data-theme='ultra-dark'] .version-list-item {
border-bottom-color: rgba(255, 255, 255, 0.08);
} }
.actions-row { .actions-row {

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Button, List, Modal, Tag } from 'antd'; import { Alert, Button, Modal, Tag } from 'antd';
import { CloudDownloadOutlined } from '@ant-design/icons'; import { CloudDownloadOutlined } from '@ant-design/icons';
import axios from 'axios'; import axios from 'axios';
@ -84,23 +84,23 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
/> />
)} )}
<List bordered className="version-list"> <div className="version-list">
<List.Item className="version-list-item"> <div className="version-list-item">
<span>{t('pages.index.currentPanelVersion')}</span> <span>{t('pages.index.currentPanelVersion')}</span>
<Tag color="green">v{info.currentVersion || '?'}</Tag> <Tag color="green">v{info.currentVersion || '?'}</Tag>
</List.Item> </div>
{info.updateAvailable ? ( {info.updateAvailable ? (
<List.Item className="version-list-item"> <div className="version-list-item">
<span>{t('pages.index.latestPanelVersion')}</span> <span>{t('pages.index.latestPanelVersion')}</span>
<Tag color="purple">{info.latestVersion || '-'}</Tag> <Tag color="purple">{info.latestVersion || '-'}</Tag>
</List.Item> </div>
) : ( ) : (
<List.Item className="version-list-item"> <div className="version-list-item">
<span>{t('pages.index.panelUpToDate')}</span> <span>{t('pages.index.panelUpToDate')}</span>
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag> <Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
</List.Item> </div>
)} )}
</List> </div>
<div className="actions-row"> <div className="actions-row">
<Button <Button

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Modal, Select, Tabs } from 'antd'; import { Modal, Select, Tabs } from 'antd';
@ -54,7 +54,6 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
const [bucket, setBucket] = useState(2); const [bucket, setBucket] = useState(2);
const [points, setPoints] = useState<number[]>([]); const [points, setPoints] = useState<number[]>([]);
const [labels, setLabels] = useState<string[]>([]); const [labels, setLabels] = useState<string[]>([]);
const openRef = useRef(open);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]); const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771'; const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
@ -93,15 +92,14 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
}, [activeMetric, bucket]); }, [activeMetric, bucket]);
useEffect(() => { useEffect(() => {
openRef.current = open;
if (open) { if (open) {
setActiveKey('cpu'); setActiveKey('cpu');
} }
}, [open]); }, [open]);
useEffect(() => { useEffect(() => {
if (openRef.current) fetchBucket(); if (open) fetchBucket();
}, [activeKey, bucket, fetchBucket]); }, [open, activeKey, bucket, fetchBucket]);
return ( return (
<Modal <Modal

View file

@ -4,12 +4,31 @@
.version-list { .version-list {
width: 100%; width: 100%;
border: 1px solid rgba(5, 5, 5, 0.06);
border-radius: 8px;
overflow: hidden;
}
body.dark .version-list,
html[data-theme='ultra-dark'] .version-list {
border-color: rgba(255, 255, 255, 0.12);
} }
.version-list-item { .version-list-item {
display: flex; display: flex;
justify-content: space-between; justify-content: space-between;
align-items: center; align-items: center;
padding: 12px 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.version-list-item:last-child {
border-bottom: 0;
}
body.dark .version-list-item,
html[data-theme='ultra-dark'] .version-list-item {
border-bottom-color: rgba(255, 255, 255, 0.08);
} }
.reload-icon { .reload-icon {

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react'; import { useCallback, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Alert, Button, Collapse, List, Modal, Radio, Spin, Tag, Tooltip } from 'antd'; import { Alert, Button, Collapse, Modal, Radio, Spin, Tag, Tooltip } from 'antd';
import { ReloadOutlined } from '@ant-design/icons'; import { ReloadOutlined } from '@ant-design/icons';
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
@ -119,17 +119,17 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
title={t('pages.index.xraySwitchClickDesk')} title={t('pages.index.xraySwitchClickDesk')}
showIcon showIcon
/> />
<List bordered className="version-list"> <div className="version-list">
{versions.map((version, index) => ( {versions.map((version, index) => (
<List.Item key={version} className="version-list-item"> <div key={version} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag> <Tag color={index % 2 === 0 ? 'purple' : 'green'}>{version}</Tag>
<Radio <Radio
checked={version === `v${status?.xray?.version}`} checked={version === `v${status?.xray?.version}`}
onClick={() => switchXrayVersion(version)} onClick={() => switchXrayVersion(version)}
/> />
</List.Item> </div>
))} ))}
</List> </div>
</> </>
), ),
}, },
@ -138,9 +138,9 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
label: 'Geofiles', label: 'Geofiles',
children: ( children: (
<> <>
<List bordered className="version-list"> <div className="version-list">
{GEOFILES.map((file, index) => ( {GEOFILES.map((file, index) => (
<List.Item key={file} className="version-list-item"> <div key={file} className="version-list-item">
<Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag> <Tag color={index % 2 === 0 ? 'purple' : 'green'}>{file}</Tag>
<Tooltip title={t('update')}> <Tooltip title={t('update')}>
<ReloadOutlined <ReloadOutlined
@ -148,9 +148,9 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
onClick={() => updateGeofile(file)} onClick={() => updateGeofile(file)}
/> />
</Tooltip> </Tooltip>
</List.Item> </div>
))} ))}
</List> </div>
<div className="actions-row"> <div className="actions-row">
<Button onClick={() => updateGeofile('')}> <Button onClick={() => updateGeofile('')}>
{t('pages.index.geofilesUpdateAll')} {t('pages.index.geofilesUpdateAll')}

View file

@ -185,7 +185,7 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
}, [open, fetchState, stopObsPolling]); }, [open, fetchState, stopObsPolling]);
useEffect(() => { useEffect(() => {
if (!openRef.current) return; if (!open) return;
if (isObservatory) { if (isObservatory) {
fetchObservatory(); fetchObservatory();
fetchObsBucket(); fetchObsBucket();
@ -202,20 +202,20 @@ export default function XrayMetricsModal({ open, onClose }: XrayMetricsModalProp
return () => { return () => {
stopObsPolling(); stopObsPolling();
}; };
}, [activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]); }, [open, activeKey, isObservatory, fetchObservatory, fetchObsBucket, fetchMetricBucket, stopObsPolling]);
useEffect(() => { useEffect(() => {
if (!openRef.current) return; if (!open) return;
if (isObservatory) { if (isObservatory) {
fetchObsBucket(); fetchObsBucket();
} else { } else {
fetchMetricBucket(); fetchMetricBucket();
} }
}, [bucket, isObservatory, fetchObsBucket, fetchMetricBucket]); }, [open, bucket, isObservatory, fetchObsBucket, fetchMetricBucket]);
useEffect(() => { useEffect(() => {
if (openRef.current && isObservatory) fetchObsBucket(); if (open && isObservatory) fetchObsBucket();
}, [obsActiveTag, isObservatory, fetchObsBucket]); }, [open, obsActiveTag, isObservatory, fetchObsBucket]);
return ( return (
<Modal <Modal

View file

@ -10,6 +10,7 @@ import {
Select, Select,
Space, Space,
Spin, Spin,
message,
} from 'antd'; } from 'antd';
import { import {
KeyOutlined, KeyOutlined,
@ -19,6 +20,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { HttpUtil, LanguageManager } from '@/utils'; import { HttpUtil, LanguageManager } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './LoginPage.css'; import './LoginPage.css';
@ -35,6 +37,11 @@ const basePath = window.X_UI_BASE_PATH || '';
export default function LoginPage() { export default function LoginPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const [fetched, setFetched] = useState(false); const [fetched, setFetched] = useState(false);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -131,6 +138,7 @@ export default function LoginPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<Layout.Content className="login-content"> <Layout.Content className="login-content">
<div className="login-toolbar"> <div className="login-toolbar">

View file

@ -2,6 +2,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { import {
Alert, Alert,
Button,
Col, Col,
Form, Form,
Input, Input,
@ -74,6 +75,7 @@ export default function NodeFormModal({
onOpenChange, onOpenChange,
}: NodeFormModalProps) { }: NodeFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [form, setForm] = useState<FormState>(defaultForm); const [form, setForm] = useState<FormState>(defaultForm);
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
@ -132,7 +134,7 @@ export default function NodeFormModal({
try { try {
const payload = buildPayload(); const payload = buildPayload();
if (!payload.address || !payload.port) { if (!payload.address || !payload.port) {
message.error(t('pages.nodes.toasts.fillRequired')); messageApi.error(t('pages.nodes.toasts.fillRequired'));
return; return;
} }
const msg = await testConnection(payload); const msg = await testConnection(payload);
@ -149,7 +151,7 @@ export default function NodeFormModal({
async function onSave() { async function onSave() {
const payload = buildPayload(); const payload = buildPayload();
if (!payload.name || !payload.address || !payload.port) { if (!payload.name || !payload.address || !payload.port) {
message.error(t('pages.nodes.toasts.fillRequired')); messageApi.error(t('pages.nodes.toasts.fillRequired'));
return; return;
} }
setSubmitting(true); setSubmitting(true);
@ -168,10 +170,12 @@ export default function NodeFormModal({
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={title} <Modal
confirmLoading={submitting} open={open}
title={title}
confirmLoading={submitting}
okText={t('save')} okText={t('save')}
cancelText={t('cancel')} cancelText={t('cancel')}
mask={{ closable: false }} mask={{ closable: false }}
@ -267,9 +271,9 @@ export default function NodeFormModal({
</Form.Item> </Form.Item>
<div className="test-row"> <div className="test-row">
<button type="button" disabled={testing} className="ant-btn ant-btn-default" onClick={onTest}> <Button type="default" loading={testing} onClick={onTest}>
{t('pages.nodes.testConnection')} {t('pages.nodes.testConnection')}
</button> </Button>
{testResult && ( {testResult && (
<div className="test-result"> <div className="test-result">
{testResult.status === 'online' ? ( {testResult.status === 'online' ? (
@ -291,6 +295,7 @@ export default function NodeFormModal({
)} )}
</div> </div>
</Form> </Form>
</Modal> </Modal>
</>
); );
} }

View file

@ -118,6 +118,37 @@ export default function NodeList({
} }
const columns = useMemo<ColumnsType<NodeRow>>(() => [ const columns = useMemo<ColumnsType<NodeRow>>(() => [
{
title: t('pages.nodes.actions'),
align: 'center',
width: 160,
render: (_value, record) => (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
},
{
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) => (
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
),
},
{ {
title: t('pages.nodes.name'), title: t('pages.nodes.name'),
dataIndex: 'name', dataIndex: 'name',
@ -234,38 +265,6 @@ export default function NodeList({
width: 120, width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat), render: (_value, record) => relativeTime(record.lastHeartbeat),
}, },
{
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) => (
<Switch
checked={!!record.enable}
size="small"
onChange={(v) => onToggleEnable(record, v)}
/>
),
},
{
title: t('pages.nodes.actions'),
align: 'center',
width: 160,
fixed: 'right',
render: (_value, record) => (
<Space>
<Tooltip title={t('pages.nodes.probe')}>
<Button type="text" size="small" icon={<ThunderboltOutlined />} onClick={() => onProbe(record)} />
</Tooltip>
<Tooltip title={t('edit')}>
<Button type="text" size="small" icon={<EditOutlined />} onClick={() => onEdit(record)} />
</Tooltip>
<Tooltip title={t('delete')}>
<Button type="text" size="small" danger icon={<DeleteOutlined />} onClick={() => onDelete(record)} />
</Tooltip>
</Space>
),
},
], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]); ], [t, showAddress, relativeTime, onToggleEnable, onProbe, onEdit, onDelete]);
return ( return (

View file

@ -1,4 +1,4 @@
import { useCallback, useMemo, useState } from 'react'; import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd'; import { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
import { import {
@ -17,6 +17,7 @@ import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic'; import CustomStatistic from '@/components/CustomStatistic';
import NodeList from './NodeList'; import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal'; import NodeFormModal from './NodeFormModal';
import { setMessageInstance } from '@/utils/messageBus';
import './NodesPage.css'; import './NodesPage.css';
const basePath = window.X_UI_BASE_PATH || ''; const basePath = window.X_UI_BASE_PATH || '';
@ -27,6 +28,8 @@ export default function NodesPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { const {
nodes, nodes,
@ -76,21 +79,21 @@ export default function NodesPage() {
cancelText: t('cancel'), cancelText: t('cancel'),
onOk: async () => { onOk: async () => {
const msg = await remove(node.id); 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 onProbe = useCallback(async (node: NodeRecord) => {
const msg = await probe(node.id); const msg = await probe(node.id);
if (msg?.success && msg.obj) { if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') { 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 { } 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) => { const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next); await setEnable(node.id, next);
@ -105,6 +108,7 @@ export default function NodesPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar basePath={basePath} requestUri={requestUri} />

View file

@ -82,3 +82,9 @@
border-radius: 4px; border-radius: 4px;
word-break: break-all; word-break: break-all;
} }
.security-actions {
padding: 12px 0;
display: flex;
align-items: center;
}

View file

@ -6,7 +6,6 @@ import {
Empty, Empty,
Form, Form,
Input, Input,
List,
Modal, Modal,
Space, Space,
Spin, Spin,
@ -61,6 +60,7 @@ const TFA_INITIAL: TfaState = {
export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) { export default function SecurityTab({ allSetting, updateSetting }: SecurityTabProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL); const [tfa, setTfa] = useState<TfaState>(TFA_INITIAL);
const [user, setUser] = useState({ const [user, setUser] = useState({
@ -145,7 +145,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
if (!token) return; if (!token) return;
try { try {
await navigator.clipboard.writeText(token); await navigator.clipboard.writeText(token);
message.success(t('copySuccess')); messageApi.success(t('copySuccess'));
} catch { } catch {
const ta = document.createElement('textarea'); const ta = document.createElement('textarea');
ta.value = token; ta.value = token;
@ -153,7 +153,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
ta.select(); ta.select();
document.execCommand('copy'); document.execCommand('copy');
document.body.removeChild(ta); 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() { async function confirmCreateToken() {
const name = createName.trim(); const name = createName.trim();
if (!name) { if (!name) {
message.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required'); messageApi.error(t('pages.settings.security.apiTokenNameRequired') || 'Name is required');
return; return;
} }
setCreating(true); setCreating(true);
@ -231,7 +231,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
type: 'set', type: 'set',
onConfirm: (ok: boolean) => { onConfirm: (ok: boolean) => {
if (ok) { if (ok) {
message.success(t('pages.settings.security.twoFactorModalSetSuccess')); messageApi.success(t('pages.settings.security.twoFactorModalSetSuccess'));
updateSetting({ twoFactorToken: newToken, twoFactorEnable: true }); updateSetting({ twoFactorToken: newToken, twoFactorEnable: true });
} else { } else {
updateSetting({ twoFactorEnable: false }); updateSetting({ twoFactorEnable: false });
@ -246,7 +246,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
type: 'confirm', type: 'confirm',
onConfirm: (ok: boolean) => { onConfirm: (ok: boolean) => {
if (!ok) return; if (!ok) return;
message.success(t('pages.settings.security.twoFactorModalDeleteSuccess')); messageApi.success(t('pages.settings.security.twoFactorModalDeleteSuccess'));
updateSetting({ twoFactorEnable: false, twoFactorToken: '' }); updateSetting({ twoFactorEnable: false, twoFactorToken: '' });
}, },
}); });
@ -255,6 +255,7 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
return ( return (
<> <>
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Collapse defaultActiveKey="1" items={[ <Collapse defaultActiveKey="1" items={[
{ {
@ -278,13 +279,13 @@ export default function SecurityTab({ allSetting, updateSetting }: SecurityTabPr
<Input.Password value={user.newPassword} autoComplete="new-password" <Input.Password value={user.newPassword} autoComplete="new-password"
onChange={(e) => updateUserField('newPassword', e.target.value)} /> onChange={(e) => updateUserField('newPassword', e.target.value)} />
</SettingListItem> </SettingListItem>
<List.Item> <div className="security-actions">
<Space style={{ padding: '0 20px' }}> <Space style={{ padding: '0 20px' }}>
<Button type="primary" loading={updating} onClick={onUpdateUserClick}> <Button type="primary" loading={updating} onClick={onUpdateUserClick}>
{t('confirm')} {t('confirm')}
</Button> </Button>
</Space> </Space>
</List.Item> </div>
</> </>
), ),
}, },

View file

@ -14,6 +14,7 @@ import {
Spin, Spin,
Tabs, Tabs,
Tooltip, Tooltip,
message,
} from 'antd'; } from 'antd';
import { import {
CloudServerOutlined, CloudServerOutlined,
@ -24,6 +25,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { HttpUtil, PromiseUtil } from '@/utils'; import { HttpUtil, PromiseUtil } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { useTheme } from '@/hooks/useTheme'; import { useTheme } from '@/hooks/useTheme';
import { useMediaQuery } from '@/hooks/useMediaQuery'; import { useMediaQuery } from '@/hooks/useMediaQuery';
import { useAllSetting } from '@/hooks/useAllSetting'; import { useAllSetting } from '@/hooks/useAllSetting';
@ -77,6 +79,11 @@ export default function SettingsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal(); const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => {
setMessageInstance(messageApi);
}, [messageApi]);
const { const {
allSetting, allSetting,
@ -259,6 +266,7 @@ export default function SettingsPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar basePath={basePath} requestUri={requestUri} />

View file

@ -5,7 +5,6 @@ import {
Collapse, Collapse,
Input, Input,
InputNumber, InputNumber,
List,
Select, Select,
Space, Space,
Switch, Switch,
@ -258,7 +257,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Switch checked={fragment} onChange={setFragmentEnabled} /> <Switch checked={fragment} onChange={setFragmentEnabled} />
</SettingListItem> </SettingListItem>
{fragment && ( {fragment && (
<List.Item className="nested-block"> <div className="nested-block">
<Collapse items={[ <Collapse items={[
{ {
key: 'sett', key: 'sett',
@ -285,7 +284,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
), ),
}, },
]} /> ]} />
</List.Item> </div>
)} )}
</> </>
), ),
@ -299,7 +298,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Switch checked={noisesEnabled} onChange={setNoisesEnabled} /> <Switch checked={noisesEnabled} onChange={setNoisesEnabled} />
</SettingListItem> </SettingListItem>
{noisesEnabled && ( {noisesEnabled && (
<List.Item className="nested-block"> <div className="nested-block">
<Collapse items={noisesArray.map((noise, index) => ({ <Collapse items={noisesArray.map((noise, index) => ({
key: String(index), key: String(index),
label: `Noise №${index + 1}`, label: `Noise №${index + 1}`,
@ -340,7 +339,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
), ),
}))} /> }))} />
<Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>+ Noise</Button> <Button type="primary" style={{ marginTop: 10 }} onClick={addNoise}>+ Noise</Button>
</List.Item> </div>
)} )}
</> </>
), ),
@ -354,7 +353,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Switch checked={muxEnabled} onChange={setMuxEnabled} /> <Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem> </SettingListItem>
{muxEnabled && ( {muxEnabled && (
<List.Item className="nested-block"> <div className="nested-block">
<Collapse items={[ <Collapse items={[
{ {
key: 'sett', key: 'sett',
@ -381,7 +380,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
), ),
}, },
]} /> ]} />
</List.Item> </div>
)} )}
</> </>
), ),
@ -395,7 +394,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Switch checked={directEnabled} onChange={setDirectEnabled} /> <Switch checked={directEnabled} onChange={setDirectEnabled} />
</SettingListItem> </SettingListItem>
{directEnabled && ( {directEnabled && (
<List.Item className="nested-block"> <div className="nested-block">
<Collapse items={[ <Collapse items={[
{ {
key: 'rules', key: 'rules',
@ -424,7 +423,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
), ),
}, },
]} /> ]} />
</List.Item> </div>
)} )}
</> </>
), ),

View file

@ -28,6 +28,7 @@ export default function TwoFactorModal({
onOpenChange, onOpenChange,
}: TwoFactorModalProps) { }: TwoFactorModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState(''); const [enteredCode, setEnteredCode] = useState('');
const [qrValue, setQrValue] = useState(''); const [qrValue, setQrValue] = useState('');
const totpRef = useRef<OTPAuth.TOTP | null>(null); const totpRef = useRef<OTPAuth.TOTP | null>(null);
@ -68,7 +69,7 @@ export default function TwoFactorModal({
if (totpRef.current.generate() === enteredCode) { if (totpRef.current.generate() === enteredCode) {
close(true); close(true);
} else { } 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() { async function copyToken() {
const ok = await ClipboardManager.copyText(token); const ok = await ClipboardManager.copyText(token);
if (ok) message.success(t('copied')); if (ok) messageApi.success(t('copied'));
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={title} <Modal
closable open={open}
onCancel={onCancel} title={title}
closable
onCancel={onCancel}
footer={[ footer={[
<Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>, <Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
<Button key="ok" type="primary" disabled={enteredCode.length < 6} onClick={onOk}> <Button key="ok" type="primary" disabled={enteredCode.length < 6} onClick={onOk}>
@ -124,6 +127,7 @@ export default function TwoFactorModal({
<Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} /> <Input value={enteredCode} onChange={(e) => setEnteredCode(e.target.value)} style={{ width: '100%' }} />
</> </>
)} )}
</Modal> </Modal>
</>
); );
} }

View file

@ -25,6 +25,7 @@ import {
} from '@ant-design/icons'; } from '@ant-design/icons';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils'; import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme'; import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './SubPage.css'; import './SubPage.css';
@ -78,6 +79,8 @@ function linkName(link: string, idx: number): string {
export default function SubPage() { export default function SubPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576); const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage()); const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
@ -109,8 +112,8 @@ export default function SubPage() {
const copy = useCallback(async (value: string) => { const copy = useCallback(async (value: string) => {
if (!value) return; if (!value) return;
const ok = await ClipboardManager.copyText(value); const ok = await ClipboardManager.copyText(value);
if (ok) message.success(t('copied')); if (ok) messageApi.success(t('copied'));
}, [t]); }, [t, messageApi]);
const open = useCallback((url: string) => { const open = useCallback((url: string) => {
if (!url) return; if (!url) return;
@ -273,6 +276,7 @@ export default function SubPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<Layout.Content className="content"> <Layout.Content className="content">
<Row justify="center"> <Row justify="center">

View file

@ -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 { .preset-row {
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
gap: 8px; 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 { .preset-name {

View file

@ -1,5 +1,5 @@
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { Button, List, Modal, Space, Tag } from 'antd'; import { Button, Modal, Space, Tag } from 'antd';
import './DnsPresetsModal.css'; import './DnsPresetsModal.css';
interface DnsPresetsModalProps { interface DnsPresetsModalProps {
@ -47,9 +47,9 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
mask={{ closable: false }} mask={{ closable: false }}
onCancel={onClose} onCancel={onClose}
> >
<List bordered> <div className="preset-list">
{PRESETS.map((preset) => ( {PRESETS.map((preset) => (
<List.Item key={preset.name} className="preset-row"> <div key={preset.name} className="preset-row">
<Space size="small" align="center"> <Space size="small" align="center">
<Tag color={preset.family ? 'purple' : 'green'}> <Tag color={preset.family ? 'purple' : 'green'}>
{preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS'} {preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS'}
@ -59,9 +59,9 @@ export default function DnsPresetsModal({ open, onClose, onInstall }: DnsPresets
<Button type="primary" size="small" onClick={() => onInstall([...preset.data])}> <Button type="primary" size="small" onClick={() => onInstall([...preset.data])}>
{t('install')} {t('install')}
</Button> </Button>
</List.Item> </div>
))} ))}
</List> </div>
</Modal> </Modal>
); );
} }

View file

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { PlusOutlined, MinusOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
export type DnsServerValue = export type DnsServerValue =
| string | string
@ -190,39 +191,45 @@ export default function DnsServerModal({
<Form.Item label={t('pages.xray.dns.domains')}> <Form.Item label={t('pages.xray.dns.domains')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} /> <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('domains', (d) => d.push(''))} />
{form.domains.map((value, idx) => ( {form.domains.map((value, idx) => (
<Input <Space.Compact key={`d${idx}`} block style={{ marginTop: 4 }}>
key={`d${idx}`} <Input
value={value} value={value}
style={{ marginTop: 4 }} onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })}
onChange={(e) => updateList('domains', (d) => { d[idx] = e.target.value; })} />
addonAfter={<MinusOutlined onClick={() => updateList('domains', (d) => d.splice(idx, 1))} />} <InputAddon onClick={() => updateList('domains', (d) => d.splice(idx, 1))}>
/> <MinusOutlined />
</InputAddon>
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.dns.expectIPs')}> <Form.Item label={t('pages.xray.dns.expectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} /> <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('expectedIPs', (d) => d.push(''))} />
{form.expectedIPs.map((value, idx) => ( {form.expectedIPs.map((value, idx) => (
<Input <Space.Compact key={`e${idx}`} block style={{ marginTop: 4 }}>
key={`e${idx}`} <Input
value={value} value={value}
style={{ marginTop: 4 }} onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })}
onChange={(e) => updateList('expectedIPs', (d) => { d[idx] = e.target.value; })} />
addonAfter={<MinusOutlined onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))} />} <InputAddon onClick={() => updateList('expectedIPs', (d) => d.splice(idx, 1))}>
/> <MinusOutlined />
</InputAddon>
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>
<Form.Item label={t('pages.xray.dns.unexpectIPs')}> <Form.Item label={t('pages.xray.dns.unexpectIPs')}>
<Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} /> <Button size="small" type="primary" icon={<PlusOutlined />} onClick={() => updateList('unexpectedIPs', (d) => d.push(''))} />
{form.unexpectedIPs.map((value, idx) => ( {form.unexpectedIPs.map((value, idx) => (
<Input <Space.Compact key={`u${idx}`} block style={{ marginTop: 4 }}>
key={`u${idx}`} <Input
value={value} value={value}
style={{ marginTop: 4 }} onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })}
onChange={(e) => updateList('unexpectedIPs', (d) => { d[idx] = e.target.value; })} />
addonAfter={<MinusOutlined onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))} />} <InputAddon onClick={() => updateList('unexpectedIPs', (d) => d.splice(idx, 1))}>
/> <MinusOutlined />
</InputAddon>
</Space.Compact>
))} ))}
</Form.Item> </Form.Item>

View file

@ -58,6 +58,7 @@ export default function NordModal({
onRemoveOutbound, onRemoveOutbound,
onRemoveRoutingRules, onRemoveRoutingRules,
}: NordModalProps) { }: NordModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null); const [nordData, setNordData] = useState<NordData | null>(null);
const [token, setToken] = useState(''); const [token, setToken] = useState('');
@ -184,7 +185,7 @@ export default function NordModal({
}) })
.sort((a: NordServer, b: NordServer) => a.load - b.load); .sort((a: NordServer, b: NordServer) => a.load - b.load);
setServers(next); 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 { } finally {
setLoading(false); setLoading(false);
} }
@ -196,7 +197,7 @@ export default function NordModal({
const tech = server.technologies?.find((tt) => tt.id === 35); const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value; const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) { 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 null;
} }
return { return {
@ -215,7 +216,7 @@ export default function NordModal({
const ob = buildNordOutbound(); const ob = buildNordOutbound();
if (!ob) return; if (!ob) return;
onAddOutbound(ob); onAddOutbound(ob);
message.success('NordVPN outbound added'); messageApi.success('NordVPN outbound added');
onClose(); onClose();
} }
@ -230,12 +231,14 @@ export default function NordModal({
oldTag, oldTag,
newTag: ob.tag as string, newTag: ob.tag as string,
}); });
message.success('NordVPN outbound updated'); messageApi.success('NordVPN outbound updated');
onClose(); onClose();
} }
return ( return (
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}> <>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
{nordData == null ? ( {nordData == null ? (
<Tabs <Tabs
defaultActiveKey="token" defaultActiveKey="token"
@ -387,6 +390,7 @@ export default function NordModal({
)} )}
</> </>
)} )}
</Modal> </Modal>
</>
); );
} }

View file

@ -17,6 +17,7 @@ import {
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons'; import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons';
import { Wireguard } from '@/utils'; import { Wireguard } from '@/utils';
import InputAddon from '@/components/InputAddon';
import { import {
Outbound, Outbound,
Protocols, Protocols,
@ -67,6 +68,7 @@ export default function OutboundFormModal({
onConfirm, onConfirm,
}: OutboundFormModalProps) { }: OutboundFormModalProps) {
const { t } = useTranslation(); const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any // eslint-disable-next-line @typescript-eslint/no-explicit-any
const outboundRef = useRef<any>(null); const outboundRef = useRef<any>(null);
const [, setTick] = useState(0); const [, setTick] = useState(0);
@ -119,7 +121,7 @@ export default function OutboundFormModal({
try { try {
parsed = JSON.parse(raw); parsed = JSON.parse(raw);
} catch (e) { } catch (e) {
message.error(`JSON: ${(e as Error).message}`); messageApi.error(`JSON: ${(e as Error).message}`);
return false; return false;
} }
try { try {
@ -130,7 +132,7 @@ export default function OutboundFormModal({
refresh(); refresh();
return true; return true;
} catch (e) { } catch (e) {
message.error(`JSON: ${(e as Error).message}`); messageApi.error(`JSON: ${(e as Error).message}`);
return false; return false;
} }
} }
@ -219,11 +221,11 @@ export default function OutboundFormModal({
if (!ob) return; if (!ob) return;
if (activeKey === '2' && !applyAdvancedJsonToForm()) return; if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
if (!ob.tag?.trim()) { if (!ob.tag?.trim()) {
message.error('Tag is required'); messageApi.error('Tag is required');
return; return;
} }
if (duplicateTag) { if (duplicateTag) {
message.error('Tag already used by another outbound'); messageApi.error('Tag already used by another outbound');
return; return;
} }
onConfirm(ob.toJson()); onConfirm(ob.toJson());
@ -235,17 +237,17 @@ export default function OutboundFormModal({
try { try {
const next = Outbound.fromLink(link); const next = Outbound.fromLink(link);
if (!next) { if (!next) {
message.error('Wrong Link!'); messageApi.error('Wrong Link!');
return; return;
} }
outboundRef.current = next; outboundRef.current = next;
primeAdvancedJson(); primeAdvancedJson();
setLinkInput(''); setLinkInput('');
message.success('Link imported successfully...'); messageApi.success('Link imported successfully...');
setActiveKey('1'); setActiveKey('1');
refresh(); refresh();
} catch (e) { } 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) { if (!ob) {
return ( return (
<Modal open={open} title={title} footer={null} onCancel={onClose} /> <>
{messageContextHolder}
<Modal open={open} title={title} footer={null} onCancel={onClose} />
</>
); );
} }
return ( return (
<Modal <>
open={open} {messageContextHolder}
title={title} <Modal
okText={okText} open={open}
cancelText={t('close')} title={title}
mask={{ closable: false }} okText={okText}
width={780} cancelText={t('close')}
onOk={onOk} mask={{ closable: false }}
onCancel={onClose} width={780}
> onOk={onOk}
onCancel={onClose}
>
<Tabs <Tabs
activeKey={activeKey} activeKey={activeKey}
onChange={onTabChange} onChange={onTabChange}
@ -279,6 +286,7 @@ export default function OutboundFormModal({
key: '1', key: '1',
label: t('pages.xray.basicTemplate'), label: t('pages.xray.basicTemplate'),
children: ( children: (
<>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}> <Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('protocol')}> <Form.Item label={t('protocol')}>
<Select <Select
@ -423,11 +431,11 @@ export default function OutboundFormModal({
{ob.stream && <SockoptFields ob={ob} refresh={refresh} />} {ob.stream && <SockoptFields ob={ob} refresh={refresh} />}
{ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />} {ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />}
{ob.stream && ob.canEnableStream() && (
<FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
)}
</Form> </Form>
{ob.stream && ob.canEnableStream() && (
<FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
)}
</>
), ),
}, },
{ {
@ -453,7 +461,8 @@ export default function OutboundFormModal({
}, },
]} ]}
/> />
</Modal> </Modal>
</>
); );
} }
@ -808,17 +817,17 @@ function WireguardFields({ ob, refresh, regenerate, t }: TFieldProps & { regener
</Form.Item> </Form.Item>
<Form.Item label="Allowed IPs"> <Form.Item label="Allowed IPs">
{(peer.allowedIPs || []).map((ip, idx) => ( {(peer.allowedIPs || []).map((ip, idx) => (
<Input <Space.Compact key={idx} block style={{ marginBottom: 4 }}>
key={idx} <Input
value={ip} value={ip}
style={{ marginBottom: 4 }} onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }} />
addonAfter={ {(peer.allowedIPs || []).length > 1 && (
(peer.allowedIPs || []).length > 1 ? ( <InputAddon onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }}>
<MinusOutlined onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }} /> <MinusOutlined />
) : undefined </InputAddon>
} )}
/> </Space.Compact>
))} ))}
<Button <Button
size="small" size="small"
@ -1047,22 +1056,20 @@ function XhttpFields({ ob, refresh, t }: TFieldProps) {
</Form.Item> </Form.Item>
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => ( {(xh.headers as Array<{ name: string; value: string }>).map((header, idx) => (
<Input.Group key={idx} compact className="mb-8"> <Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input <Input
value={header.name} value={header.name}
addonBefore={`${idx + 1}`}
style={{ width: '45%' }}
placeholder="Name" placeholder="Name"
onChange={(e) => { header.name = e.target.value; refresh(); }} onChange={(e) => { header.name = e.target.value; refresh(); }}
/> />
<Input <Input
value={header.value} value={header.value}
style={{ width: '45%' }}
placeholder="Value" placeholder="Value"
onChange={(e) => { header.value = e.target.value; refresh(); }} onChange={(e) => { header.value = e.target.value; refresh(); }}
/> />
<Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} /> <Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} />
</Input.Group> </Space.Compact>
))} ))}
</Form.Item> </Form.Item>

View file

@ -1,7 +1,8 @@
import { useEffect, useState } from 'react'; import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next'; 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 { PlusOutlined, MinusOutlined, QuestionCircleOutlined } from '@ant-design/icons';
import InputAddon from '@/components/InputAddon';
export interface RoutingRule { export interface RoutingRule {
type?: string; type?: string;
@ -207,11 +208,10 @@ export default function RuleFormModal({
</Form.Item> </Form.Item>
<Form.Item wrapperCol={{ span: 24 }}> <Form.Item wrapperCol={{ span: 24 }}>
{form.attrs.map((attr, idx) => ( {form.attrs.map((attr, idx) => (
<Input.Group key={idx} compact className="mb-8"> <Space.Compact key={idx} block className="mb-8">
<InputAddon>{`${idx + 1}`}</InputAddon>
<Input <Input
value={attr[0]} value={attr[0]}
style={{ width: '45%' }}
addonBefore={`${idx + 1}`}
placeholder="Name" placeholder="Name"
onChange={(e) => { onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a)); const next = form.attrs.map((a, i) => (i === idx ? ([e.target.value, a[1]] as [string, string]) : a));
@ -220,7 +220,6 @@ export default function RuleFormModal({
/> />
<Input <Input
value={attr[1]} value={attr[1]}
style={{ width: '45%' }}
placeholder="Value" placeholder="Value"
onChange={(e) => { onChange={(e) => {
const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a)); const next = form.attrs.map((a, i) => (i === idx ? ([a[0], e.target.value] as [string, string]) : a));
@ -231,7 +230,7 @@ export default function RuleFormModal({
icon={<MinusOutlined />} icon={<MinusOutlined />}
onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))} onClick={() => update('attrs', form.attrs.filter((_, i) => i !== idx))}
/> />
</Input.Group> </Space.Compact>
))} ))}
</Form.Item> </Form.Item>

View file

@ -72,6 +72,7 @@ export default function WarpModal({
onResetOutbound, onResetOutbound,
onRemoveOutbound, onRemoveOutbound,
}: WarpModalProps) { }: WarpModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null); const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null); const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
@ -191,7 +192,7 @@ export default function WarpModal({
function addOutbound() { function addOutbound() {
if (!stagedOutbound) { if (!stagedOutbound) {
message.warning('Fetch the WARP config first.'); messageApi.warning('Fetch the WARP config first.');
return; return;
} }
onAddOutbound(stagedOutbound); onAddOutbound(stagedOutbound);
@ -207,7 +208,9 @@ export default function WarpModal({
const hasConfig = !ObjectUtil.isEmpty(warpConfig); const hasConfig = !ObjectUtil.isEmpty(warpConfig);
return ( return (
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}> <>
{messageContextHolder}
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
{!hasWarp ? ( {!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}> <Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
Create WARP account Create WARP account
@ -348,6 +351,7 @@ export default function WarpModal({
)} )}
</> </>
)} )}
</Modal> </Modal>
</>
); );
} }

View file

@ -36,6 +36,7 @@ import { useXraySetting } from '@/hooks/useXraySetting';
import type { XraySettingsValue } from '@/hooks/useXraySetting'; import type { XraySettingsValue } from '@/hooks/useXraySetting';
import AppSidebar from '@/components/AppSidebar'; import AppSidebar from '@/components/AppSidebar';
import JsonEditor from '@/components/JsonEditor'; import JsonEditor from '@/components/JsonEditor';
import { setMessageInstance } from '@/utils/messageBus';
import BasicsTab from './BasicsTab'; import BasicsTab from './BasicsTab';
import RoutingTab from './RoutingTab'; import RoutingTab from './RoutingTab';
@ -65,6 +66,8 @@ export default function XrayPage() {
const { t } = useTranslation(); const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme(); const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery(); const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const xs = useXraySetting(); const xs = useXraySetting();
const { const {
fetched, fetched,
@ -239,7 +242,7 @@ export default function XrayPage() {
try { try {
JSON.parse(xraySetting); JSON.parse(xraySetting);
} catch (e) { } catch (e) {
message.error(`Advanced JSON: ${(e as Error).message}`); messageApi.error(`Advanced JSON: ${(e as Error).message}`);
setActiveTabKey('tpl-advanced'); setActiveTabKey('tpl-advanced');
return; return;
} }
@ -252,6 +255,7 @@ export default function XrayPage() {
return ( return (
<ConfigProvider theme={antdThemeConfig}> <ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder} {modalContextHolder}
<Layout className={pageClass}> <Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} /> <AppSidebar basePath={basePath} requestUri={requestUri} />

View file

@ -1,5 +1,5 @@
import axios from 'axios'; import axios from 'axios';
import { message as antMessage } from 'antd'; import { getMessage } from './messageBus';
export class Msg { export class Msg {
constructor(success = false, msg = "", obj = null) { constructor(success = false, msg = "", obj = null) {
@ -15,7 +15,7 @@ export class HttpUtil {
return; return;
} }
const messageType = msg.success ? 'success' : 'error'; const messageType = msg.success ? 'success' : 'error';
antMessage[messageType](msg.msg); getMessage()[messageType](msg.msg);
} }
static _respToMsg(resp) { static _respToMsg(resp) {

View file

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

View file

@ -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 // 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 // The panel's "Calendar Type" setting decides whether the SubPage
// renders dates in Gregorian or Jalali — surface it here so the SPA // renders dates in Gregorian or Jalali — surface it here so the SPA
// can match the rest of the panel without a round-trip. // can match the rest of the panel without a round-trip.

View file

@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
a.xraySettingController = NewXraySettingController(g) 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 // instead of rendering the legacy Go templates. Each handler is a
// thin wrapper around serveDistPage so the basePath injection + // thin wrapper around serveDistPage so the basePath injection +
// no-cache headers stay centralised. // no-cache headers stay centralised.

View file

@ -40,13 +40,7 @@ var i18nFS embed.FS
// distFS embeds the Vite-built frontend (web/dist/). Every user-facing // distFS embeds the Vite-built frontend (web/dist/). Every user-facing
// HTML route is served straight out of this FS — the legacy Go // HTML route is served straight out of this FS — the legacy Go
// templates and `web/assets/` tree are gone post-Phase 8. // 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-<hash>.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 //go:embed all:dist
var distFS embed.FS var distFS embed.FS