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"
- "**.cjs"
- "**.ts"
- "**.vue"
- "**.html"
- "**.css"
- "frontend/package.json"
@ -27,7 +26,6 @@ on:
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "**.html"
- "**.css"
- "frontend/package.json"

View file

@ -14,7 +14,6 @@ on:
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "frontend/package-lock.json"
pull_request:
paths:
@ -25,7 +24,6 @@ on:
- "**.mjs"
- "**.cjs"
- "**.ts"
- "**.vue"
- "frontend/package-lock.json"
schedule:
- 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 { Col, List, Row } from 'antd';
import { Col, Row } from 'antd';
import './SettingListItem.css';
interface SettingListItemProps {
paddings?: 'small' | 'default';
@ -18,15 +19,18 @@ export default function SettingListItem({
}: SettingListItemProps) {
const padding = paddings === 'small' ? '10px 20px' : '20px';
return (
<List.Item style={{ padding }}>
<div className="setting-list-item" style={{ padding }}>
<Row gutter={[8, 16]} style={{ width: '100%' }}>
<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 xs={24} lg={12}>
{control ?? children}
</Col>
</Row>
</List.Item>
</div>
);
}

View file

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

View file

@ -23,7 +23,6 @@ function applyDom(isDark: boolean, isUltra: boolean) {
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.
const initialDark = readBool(STORAGE_DARK, true);
const initialUltra = readBool(STORAGE_ULTRA, false);

View file

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

View file

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

View file

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

View file

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

View file

@ -49,6 +49,7 @@ export default function ClientInfoModal({
onOpenChange,
}: ClientInfoModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [links, setLinks] = useState<string[]>([]);
useEffect(() => {
@ -93,16 +94,18 @@ export default function ClientInfoModal({
async function copyValue(text: string) {
if (!text) return;
const ok = await ClipboardManager.copyText(String(text));
if (ok) message.success(t('copied'));
if (ok) messageApi.success(t('copied'));
}
return (
<Modal
open={open}
title={client ? client.email : t('info')}
footer={null}
width={640}
onCancel={() => onOpenChange(false)}
<>
{messageContextHolder}
<Modal
open={open}
title={client ? client.email : t('info')}
footer={null}
width={640}
onCancel={() => onOpenChange(false)}
>
{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 CustomStatistic from '@/components/CustomStatistic';
import { IntlUtil, ObjectUtil, SizeFormatter } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import ClientFormModal from './ClientFormModal';
import ClientInfoModal from './ClientInfoModal';
import ClientQrModal from './ClientQrModal';
@ -86,6 +87,8 @@ export default function ClientsPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const {
clients, inbounds, onlines, loading, fetched, subSettings,
@ -318,7 +321,7 @@ export default function ClientsPage() {
try {
const msg = await setEnable(row, next);
if (!msg?.success) {
message.error(msg?.msg || t('somethingWentWrong'));
messageApi.error(msg?.msg || t('somethingWentWrong'));
}
} finally {
setTogglingEmail(null);
@ -348,14 +351,14 @@ export default function ClientsPage() {
cancelText: t('cancel'),
onOk: async () => {
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) {
if (!row?.email || !Array.isArray(row.inboundIds) || row.inboundIds.length === 0) {
message.warning(t('pages.clients.resetNotPossible'));
messageApi.warning(t('pages.clients.resetNotPossible'));
return;
}
modal.confirm({
@ -365,7 +368,7 @@ export default function ClientsPage() {
cancelText: t('cancel'),
onOk: async () => {
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'),
onOk: async () => {
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();
if (msg?.success) {
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) {
message.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
messageApi.success(t('pages.clients.toasts.bulkDeleted', { count: ok }));
} else {
message.warning(firstError
messageApi.warning(firstError
? `${t('pages.clients.toasts.bulkDeletedMixed', { ok, failed })} — ${firstError}`
: t('pages.clients.toasts.bulkDeletedMixed', { ok, failed }));
}
@ -620,6 +623,7 @@ export default function ClientsPage() {
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />
@ -758,6 +762,7 @@ export default function ClientsPage() {
rowSelection={rowSelection}
pagination={tablePagination}
size="small"
scroll={{ x: 1200 }}
onChange={onTableChange}
locale={{
emptyText: (

View file

@ -40,6 +40,7 @@ import {
SizeFormatter,
Wireguard,
} from '@/utils';
import InputAddon from '@/components/InputAddon';
import { getRandomRealityTarget } from '@/models/reality-targets';
import {
Inbound,
@ -157,6 +158,7 @@ export default function InboundFormModal({
dbInbounds,
}: InboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const { nodes: availableNodes } = useNodes();
const selectableNodes = useMemo(
() => (availableNodes || []).filter((n: NodeRecord) => n.enable),
@ -413,8 +415,8 @@ export default function InboundFormModal({
const defaults = deriveFallbackDefaults(child);
return { ...row, ...defaults };
}));
message.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child');
}, [dbInbounds, t]);
messageApi.success(t('pages.inbounds.fallbacks.rederived') || 'Re-filled from child');
}, [dbInbounds, t, messageApi]);
const quickAddAllFallbacks = useCallback(() => {
const masterId = dbInbound?.id;
@ -438,13 +440,13 @@ export default function InboundFormModal({
added += 1;
}
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 {
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;
});
}, [dbInbound, dbInbounds, t]);
}, [dbInbound, dbInbounds, t, messageApi]);
const fallbackChildOptions = useMemo(() => {
const list = dbInbounds || [];
@ -652,16 +654,16 @@ export default function InboundFormModal({
try {
return parseAdvancedSliceOrFallback(rawText, fallback);
} catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`);
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
throw e;
}
}, []);
}, [messageApi]);
const compactAdvancedJson = (raw: string, fallback: string, label: string) => {
try {
return JSON.stringify(JSON.parse(raw || fallback));
} catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`);
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
throw e;
}
};
@ -692,11 +694,11 @@ export default function InboundFormModal({
});
refresh();
} 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 true;
}, [t, refresh, parseAdvancedSliceWithLabel]);
}, [t, refresh, parseAdvancedSliceWithLabel, messageApi]);
const handleTabChange = (next: string) => {
if (activeTabKey === 'advanced' && next !== 'advanced') {
@ -734,23 +736,23 @@ export default function InboundFormModal({
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`${label} JSON invalid: ${(e as Error).message}`);
messageApi.error(`${label} JSON invalid: ${(e as Error).message}`);
return;
}
const unwrapped = unwrapWrappedObject(parsed, key);
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;
}
try {
advancedTextRef.current[slice] = JSON.stringify(unwrapped, null, 2);
refresh();
} 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;
if (!ib) return '';
try {
@ -769,19 +771,18 @@ export default function InboundFormModal({
} catch {
return '';
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [inboundRef.current, canEnableStream]);
})();
const setAdvancedAllValue = (next: string) => {
let parsed: any;
try {
parsed = JSON.parse(next);
} catch (e) {
message.error(`All JSON invalid: ${(e as Error).message}`);
messageApi.error(`All JSON invalid: ${(e as Error).message}`);
return;
}
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;
}
const ib = inboundRef.current;
@ -804,7 +805,7 @@ export default function InboundFormModal({
: '{}';
refresh();
} 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 && (
<Form.Item label={t('pages.inbounds.deployTo')}>
<Select
value={form.nodeId}
value={form.nodeId ?? ''}
disabled={mode === 'edit'}
placeholder={t('pages.inbounds.localPanel')}
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) => (
<Select.Option key={n.id} value={n.id} disabled={n.status === '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.'}
</Paragraph>
{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) => (
<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) && (
<Row gutter={8} style={{ marginTop: 8 }}>
<Col xs={24} md={8}>
<Input addonBefore="SNI" placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.name} onChange={(e) => updateFallback(record.rowKey, { name: e.target.value })} />
<Space.Compact block>
<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 xs={24} md={5}>
<Input addonBefore="ALPN" placeholder={t('pages.inbounds.fallbacks.matchAny') || 'any'}
value={record.alpn} onChange={(e) => updateFallback(record.rowKey, { alpn: e.target.value })} />
<Space.Compact block>
<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 xs={24} md={7}>
<Input addonBefore="Path" placeholder="/" value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })} />
<Space.Compact block>
<InputAddon>Path</InputAddon>
<Input placeholder="/" value={record.path}
onChange={(e) => updateFallback(record.rowKey, { path: e.target.value })} />
</Space.Compact>
</Col>
<Col xs={24} md={4}>
<InputNumber addonBefore="xver" min={0} max={2} style={{ width: '100%' }}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })} />
<Space.Compact block>
<InputAddon>xver</InputAddon>
<InputNumber min={0} max={2} style={{ width: '100%' }}
value={record.xver}
onChange={(v) => updateFallback(record.rowKey, { xver: Number(v) || 0 })} />
</Space.Compact>
</Col>
</Row>
)}
@ -1146,10 +1159,10 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.settings.accounts || []).map((account: any, idx: number) => (
<Space.Compact key={idx} className="mb-8" block>
<Input style={{ width: '45%' }} value={account.user}
addonBefore={String(idx + 1)} placeholder="Username"
<InputAddon>{String(idx + 1)}</InputAddon>
<Input value={account.user} placeholder="Username"
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(); }} />
<Button onClick={() => { ib.settings.delAccount(idx); refresh(); }}>
<MinusOutlined />
@ -1208,9 +1221,10 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.settings.portMap as { name: string; value: string }[]).map((pm, idx) => (
<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(); }} />
<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(); }} />
<Button onClick={() => { ib.settings.removePortMap(idx); refresh(); }}>
<MinusOutlined />
@ -1240,11 +1254,15 @@ export default function InboundFormModal({
<PlusOutlined />
</Button>
{(ib.settings.gateway || []).map((_ip: string, j: number) => (
<Input key={`tun-gw-${j}`} className="mt-4"
placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'}
value={ib.settings.gateway[j]}
onChange={(e) => { ib.settings.gateway[j] = e.target.value; refresh(); }}
addonAfter={<Button size="small" onClick={() => { ib.settings.gateway.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} />
<Space.Compact key={`tun-gw-${j}`} block className="mt-4">
<Input
placeholder={j === 0 ? '10.0.0.1/16' : 'fc00::1/64'}
value={ib.settings.gateway[j]}
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 label="DNS">
@ -1252,11 +1270,15 @@ export default function InboundFormModal({
<PlusOutlined />
</Button>
{(ib.settings.dns || []).map((_ip: string, j: number) => (
<Input key={`tun-dns-${j}`} className="mt-4"
placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'}
value={ib.settings.dns[j]}
onChange={(e) => { ib.settings.dns[j] = e.target.value; refresh(); }}
addonAfter={<Button size="small" onClick={() => { ib.settings.dns.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} />
<Space.Compact key={`tun-dns-${j}`} block className="mt-4">
<Input
placeholder={j === 0 ? '1.1.1.1' : '8.8.8.8'}
value={ib.settings.dns[j]}
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 label="User level">
@ -1268,11 +1290,15 @@ export default function InboundFormModal({
<PlusOutlined />
</Button>
{(ib.settings.autoSystemRoutingTable || []).map((_ip: string, j: number) => (
<Input key={`tun-rt-${j}`} className="mt-4"
placeholder={j === 0 ? '0.0.0.0/0' : '::/0'}
value={ib.settings.autoSystemRoutingTable[j]}
onChange={(e) => { ib.settings.autoSystemRoutingTable[j] = e.target.value; refresh(); }}
addonAfter={<Button size="small" onClick={() => { ib.settings.autoSystemRoutingTable.splice(j, 1); refresh(); }}><MinusOutlined /></Button>} />
<Space.Compact key={`tun-rt-${j}`} block className="mt-4">
<Input
placeholder={j === 0 ? '0.0.0.0/0' : '::/0'}
value={ib.settings.autoSystemRoutingTable[j]}
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 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 />
</Button>
{(peer.allowedIPs || []).map((_ip: string, j: number) => (
<Input key={j} className="mt-4"
value={peer.allowedIPs[j]}
onChange={(e) => { peer.allowedIPs[j] = e.target.value; refresh(); }}
addonAfter={peer.allowedIPs.length > 1
? <Button size="small" onClick={() => { peer.allowedIPs.splice(j, 1); refresh(); }}><MinusOutlined /></Button>
: undefined} />
<Space.Compact key={j} block className="mt-4">
<Input
value={peer.allowedIPs[j]}
onChange={(e) => { peer.allowedIPs[j] = e.target.value; refresh(); }} />
{peer.allowedIPs.length > 1 && (
<Button size="small" onClick={() => { peer.allowedIPs.splice(j, 1); refresh(); }}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))}
</Form.Item>
<Form.Item label="Keep-alive">
@ -1388,12 +1418,16 @@ export default function InboundFormModal({
</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></>}>
{(ib.stream.tcp.request.path || []).map((_p: string, idx: number) => (
<Input key={`tcp-path-${idx}`} className="mb-4"
value={ib.stream.tcp.request.path[idx]}
onChange={(e) => { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }}
addonAfter={ib.stream.tcp.request.path.length > 1
? <Button size="small" onClick={() => { ib.stream.tcp.request.removePath(idx); refresh(); }}><MinusOutlined /></Button>
: undefined} />
<Space.Compact key={`tcp-path-${idx}`} block className="mb-4">
<Input
value={ib.stream.tcp.request.path[idx]}
onChange={(e) => { ib.stream.tcp.request.path[idx] = e.target.value; refresh(); }} />
{ib.stream.tcp.request.path.length > 1 && (
<Button size="small" onClick={() => { ib.stream.tcp.request.removePath(idx); refresh(); }}>
<MinusOutlined />
</Button>
)}
</Space.Compact>
))}
</Form.Item>
<Form.Item label={t('pages.inbounds.stream.tcp.requestHeader')}>
@ -1405,10 +1439,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.tcp.request.headers as { name: string; value: string }[]).map((h, idx) => (
<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')}
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')}
onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.tcp.request.removeHeader(idx); refresh(); }}>
@ -1440,10 +1475,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.tcp.response.headers as { name: string; value: string }[]).map((h, idx) => (
<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')}
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')}
onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.tcp.response.removeHeader(idx); refresh(); }}>
@ -1482,10 +1518,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.ws.headers as { name: string; value: string }[]).map((h, idx) => (
<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')}
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')}
onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.ws.removeHeader(idx); refresh(); }}>
@ -1518,10 +1555,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.httpupgrade.headers as { name: string; value: string }[]).map((h, idx) => (
<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')}
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')}
onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.httpupgrade.removeHeader(idx); refresh(); }}>
@ -1545,10 +1583,11 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.xhttp.headers as { name: string; value: string }[]).map((h, idx) => (
<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')}
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')}
onChange={(e) => { h.value = e.target.value; 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}
onChange={(v) => { row.port = Number(v) || 0; refresh(); }} />
</Tooltip>
<Input style={{ width: '35%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
onChange={(e) => { row.remark = e.target.value; refresh(); }}
addonAfter={<MinusOutlined onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }} />} />
<Input style={{ width: '25%' }} value={row.remark} placeholder={t('pages.inbounds.remark')}
onChange={(e) => { row.remark = e.target.value; refresh(); }} />
<InputAddon onClick={() => { ib.stream.externalProxy.splice(idx, 1); refresh(); }}>
<MinusOutlined />
</InputAddon>
</Space.Compact>
))}
</Form.Item>
@ -1762,9 +1803,10 @@ export default function InboundFormModal({
<Form.Item wrapperCol={{ span: 24 }}>
{(ib.stream.hysteria.masquerade.headers as { name: string; value: string }[]).map((h, idx) => (
<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(); }} />
<Input style={{ width: '45%' }} value={h.value} placeholder="Value"
<Input value={h.value} placeholder="Value"
onChange={(e) => { h.value = e.target.value; refresh(); }} />
<Button onClick={() => { ib.stream.hysteria.masquerade.removeHeader(idx); refresh(); }}>
<MinusOutlined />
@ -2078,19 +2120,22 @@ export default function InboundFormModal({
tabItems.push({ key: 'advanced', label: t('pages.xray.advancedTemplate'), children: renderAdvancedTab() });
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
confirmLoading={saving}
mask={{ closable: false }}
width={780}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
<Tabs activeKey={activeTabKey} onChange={handleTabChange} items={tabItems} />
</Modal>
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
confirmLoading={saving}
mask={{ closable: false }}
width={780}
onOk={submit}
onCancel={onClose}
destroyOnHidden
>
<Tabs activeKey={activeTabKey} onChange={handleTabChange} items={tabItems} />
</Modal>
</>
);
}

View file

@ -1,6 +1,7 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
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 {
@ -106,7 +107,7 @@ interface InboundInfoModalProps {
function copyText(value: unknown, t: (k: string) => string) {
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,
message,
} from 'antd';
import { setMessageInstance } from '@/utils/messageBus';
import {
SwapOutlined,
PieChartOutlined,
@ -76,6 +78,10 @@ export default function InboundsPage() {
applyInboundsEvent,
} = useInbounds();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const { nodes: nodesList } = useNodes();
const nodesById = useMemo(() => {
const map = new Map<number, ReturnType<typeof useNodes>['nodes'][number]>();
@ -305,7 +311,7 @@ export default function InboundsPage() {
}, []);
const confirmDelete = useCallback((dbInbound: any) => {
Modal.confirm({
modal.confirm({
title: `Delete inbound "${dbInbound.remark}"?`,
content: 'This removes the inbound and all its clients. This cannot be undone.',
okText: 'Delete',
@ -316,10 +322,10 @@ export default function InboundsPage() {
if (msg?.success) await refresh();
},
});
}, [refresh]);
}, [modal, refresh]);
const confirmResetTraffic = useCallback((dbInbound: any) => {
Modal.confirm({
modal.confirm({
title: `Reset traffic for "${dbInbound.remark}"?`,
content: 'Resets up/down counters to 0 for this inbound.',
okText: 'Reset',
@ -329,10 +335,10 @@ export default function InboundsPage() {
if (msg?.success) await refresh();
},
});
}, [refresh]);
}, [modal, refresh]);
const confirmClone = useCallback((dbInbound: any) => {
Modal.confirm({
modal.confirm({
title: `Clone inbound "${dbInbound.remark}"?`,
content: 'Creates a copy with a new port and an empty client list.',
okText: 'Clone',
@ -365,7 +371,7 @@ export default function InboundsPage() {
if (msg?.success) await refresh();
},
});
}, [refresh]);
}, [modal, refresh]);
const onGeneralAction = useCallback((key: GeneralAction) => {
switch (key) {
@ -373,7 +379,7 @@ export default function InboundsPage() {
case 'export': exportAllLinks(); break;
case 'subs': exportAllSubs(); break;
case 'resetInbounds':
Modal.confirm({
modal.confirm({
title: 'Reset all inbound traffic?',
okText: 'Reset',
cancelText: 'Cancel',
@ -384,9 +390,9 @@ export default function InboundsPage() {
});
break;
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 }) => {
switch (key) {
@ -421,15 +427,17 @@ export default function InboundsPage() {
confirmClone(dbInbound);
break;
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 requestUri = typeof window !== 'undefined' ? window.location.pathname : '';
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={`inbounds-page${isDark ? ' is-dark' : ''}${isUltra ? ' is-ultra' : ''}`}>
<AppSidebar basePath={basePath} requestUri={requestUri} />

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -4,11 +4,31 @@
.version-list {
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 {
display: flex;
align-items: center;
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 {

View file

@ -1,5 +1,5 @@
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 axios from 'axios';
@ -84,23 +84,23 @@ export default function PanelUpdateModal({ open, info, onClose, onBusy }: PanelU
/>
)}
<List bordered className="version-list">
<List.Item className="version-list-item">
<div className="version-list">
<div className="version-list-item">
<span>{t('pages.index.currentPanelVersion')}</span>
<Tag color="green">v{info.currentVersion || '?'}</Tag>
</List.Item>
</div>
{info.updateAvailable ? (
<List.Item className="version-list-item">
<div className="version-list-item">
<span>{t('pages.index.latestPanelVersion')}</span>
<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>
<Tag color="green">{t('pages.index.panelUpToDate')}</Tag>
</List.Item>
</div>
)}
</List>
</div>
<div className="actions-row">
<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 { Modal, Select, Tabs } from 'antd';
@ -54,7 +54,6 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
const [bucket, setBucket] = useState(2);
const [points, setPoints] = useState<number[]>([]);
const [labels, setLabels] = useState<string[]>([]);
const openRef = useRef(open);
const activeMetric = useMemo(() => METRICS.find((m) => m.key === activeKey), [activeKey]);
const strokeColor = activeMetric?.stroke || status?.cpu?.color || '#008771';
@ -93,15 +92,14 @@ export default function SystemHistoryModal({ open, status, onClose }: SystemHist
}, [activeMetric, bucket]);
useEffect(() => {
openRef.current = open;
if (open) {
setActiveKey('cpu');
}
}, [open]);
useEffect(() => {
if (openRef.current) fetchBucket();
}, [activeKey, bucket, fetchBucket]);
if (open) fetchBucket();
}, [open, activeKey, bucket, fetchBucket]);
return (
<Modal

View file

@ -4,12 +4,31 @@
.version-list {
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 {
display: flex;
justify-content: space-between;
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 {

View file

@ -1,6 +1,6 @@
import { useCallback, useEffect, useState } from 'react';
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 { HttpUtil } from '@/utils';
@ -119,17 +119,17 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
title={t('pages.index.xraySwitchClickDesk')}
showIcon
/>
<List bordered className="version-list">
<div className="version-list">
{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>
<Radio
checked={version === `v${status?.xray?.version}`}
onClick={() => switchXrayVersion(version)}
/>
</List.Item>
</div>
))}
</List>
</div>
</>
),
},
@ -138,9 +138,9 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
label: 'Geofiles',
children: (
<>
<List bordered className="version-list">
<div className="version-list">
{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>
<Tooltip title={t('update')}>
<ReloadOutlined
@ -148,9 +148,9 @@ export default function VersionModal({ open, status, onClose, onBusy }: VersionM
onClick={() => updateGeofile(file)}
/>
</Tooltip>
</List.Item>
</div>
))}
</List>
</div>
<div className="actions-row">
<Button onClick={() => updateGeofile('')}>
{t('pages.index.geofilesUpdateAll')}

View file

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

View file

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

View file

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

View file

@ -118,6 +118,37 @@ export default function NodeList({
}
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'),
dataIndex: 'name',
@ -234,38 +265,6 @@ export default function NodeList({
width: 120,
render: (_value, record) => relativeTime(record.lastHeartbeat),
},
{
title: t('pages.nodes.enable'),
dataIndex: 'enable',
align: 'center',
width: 80,
render: (_value, record) => (
<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]);
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 { Card, Col, ConfigProvider, Layout, Modal, Row, Spin, message } from 'antd';
import {
@ -17,6 +17,7 @@ import AppSidebar from '@/components/AppSidebar';
import CustomStatistic from '@/components/CustomStatistic';
import NodeList from './NodeList';
import NodeFormModal from './NodeFormModal';
import { setMessageInstance } from '@/utils/messageBus';
import './NodesPage.css';
const basePath = window.X_UI_BASE_PATH || '';
@ -27,6 +28,8 @@ export default function NodesPage() {
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [modal, modalContextHolder] = Modal.useModal();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const {
nodes,
@ -76,21 +79,21 @@ export default function NodesPage() {
cancelText: t('cancel'),
onOk: async () => {
const msg = await remove(node.id);
if (msg?.success) message.success(t('pages.nodes.toasts.deleted'));
if (msg?.success) messageApi.success(t('pages.nodes.toasts.deleted'));
},
});
}, [modal, t, remove]);
}, [modal, t, remove, messageApi]);
const onProbe = useCallback(async (node: NodeRecord) => {
const msg = await probe(node.id);
if (msg?.success && msg.obj) {
if (msg.obj.status === 'online') {
message.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
messageApi.success(t('pages.nodes.connectionOk', { ms: msg.obj.latencyMs }));
} else {
message.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
messageApi.error(msg.obj.error || t('pages.nodes.toasts.probeFailed'));
}
}
}, [probe, t]);
}, [probe, t, messageApi]);
const onToggleEnable = useCallback(async (node: NodeRecord, next: boolean) => {
await setEnable(node.id, next);
@ -105,6 +108,7 @@ export default function NodesPage() {
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />

View file

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

View file

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

View file

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

View file

@ -5,7 +5,6 @@ import {
Collapse,
Input,
InputNumber,
List,
Select,
Space,
Switch,
@ -258,7 +257,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Switch checked={fragment} onChange={setFragmentEnabled} />
</SettingListItem>
{fragment && (
<List.Item className="nested-block">
<div className="nested-block">
<Collapse items={[
{
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} />
</SettingListItem>
{noisesEnabled && (
<List.Item className="nested-block">
<div className="nested-block">
<Collapse items={noisesArray.map((noise, index) => ({
key: String(index),
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>
</List.Item>
</div>
)}
</>
),
@ -354,7 +353,7 @@ export default function SubscriptionFormatsTab({ allSetting, updateSetting }: Su
<Switch checked={muxEnabled} onChange={setMuxEnabled} />
</SettingListItem>
{muxEnabled && (
<List.Item className="nested-block">
<div className="nested-block">
<Collapse items={[
{
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} />
</SettingListItem>
{directEnabled && (
<List.Item className="nested-block">
<div className="nested-block">
<Collapse items={[
{
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,
}: TwoFactorModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
const [enteredCode, setEnteredCode] = useState('');
const [qrValue, setQrValue] = useState('');
const totpRef = useRef<OTPAuth.TOTP | null>(null);
@ -68,7 +69,7 @@ export default function TwoFactorModal({
if (totpRef.current.generate() === enteredCode) {
close(true);
} else {
message.error(t('pages.settings.security.twoFactorModalError'));
messageApi.error(t('pages.settings.security.twoFactorModalError'));
}
}
@ -78,15 +79,17 @@ export default function TwoFactorModal({
async function copyToken() {
const ok = await ClipboardManager.copyText(token);
if (ok) message.success(t('copied'));
if (ok) messageApi.success(t('copied'));
}
return (
<Modal
open={open}
title={title}
closable
onCancel={onCancel}
<>
{messageContextHolder}
<Modal
open={open}
title={title}
closable
onCancel={onCancel}
footer={[
<Button key="cancel" onClick={onCancel}>{t('cancel')}</Button>,
<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%' }} />
</>
)}
</Modal>
</Modal>
</>
);
}

View file

@ -25,6 +25,7 @@ import {
} from '@ant-design/icons';
import { ClipboardManager, IntlUtil, LanguageManager } from '@/utils';
import { setMessageInstance } from '@/utils/messageBus';
import { pauseAnimationsUntilLeave, useTheme } from '@/hooks/useTheme';
import './SubPage.css';
@ -78,6 +79,8 @@ function linkName(link: string, idx: number): string {
export default function SubPage() {
const { t } = useTranslation();
const { isDark, isUltra, toggleTheme, toggleUltra, antdThemeConfig } = useTheme();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const [isMobile, setIsMobile] = useState<boolean>(() => window.innerWidth < 576);
const [lang, setLang] = useState<string>(() => LanguageManager.getLanguage());
@ -109,8 +112,8 @@ export default function SubPage() {
const copy = useCallback(async (value: string) => {
if (!value) return;
const ok = await ClipboardManager.copyText(value);
if (ok) message.success(t('copied'));
}, [t]);
if (ok) messageApi.success(t('copied'));
}, [t, messageApi]);
const open = useCallback((url: string) => {
if (!url) return;
@ -273,6 +276,7 @@ export default function SubPage() {
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
<Layout className={pageClass}>
<Layout.Content className="content">
<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 {
display: flex;
align-items: center;
justify-content: space-between;
gap: 8px;
padding: 12px 24px;
border-bottom: 1px solid rgba(5, 5, 5, 0.06);
}
.preset-row:last-child {
border-bottom: 0;
}
body.dark .preset-row,
html[data-theme='ultra-dark'] .preset-row {
border-bottom-color: rgba(255, 255, 255, 0.08);
}
.preset-name {

View file

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

View file

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

View file

@ -58,6 +58,7 @@ export default function NordModal({
onRemoveOutbound,
onRemoveRoutingRules,
}: NordModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [nordData, setNordData] = useState<NordData | null>(null);
const [token, setToken] = useState('');
@ -184,7 +185,7 @@ export default function NordModal({
})
.sort((a: NordServer, b: NordServer) => a.load - b.load);
setServers(next);
if (next.length === 0) message.warning('No servers found for the selected country');
if (next.length === 0) messageApi.warning('No servers found for the selected country');
} finally {
setLoading(false);
}
@ -196,7 +197,7 @@ export default function NordModal({
const tech = server.technologies?.find((tt) => tt.id === 35);
const publicKey = tech?.metadata?.find((m) => m.name === 'public_key')?.value;
if (!publicKey) {
message.error('Selected server does not advertise a NordLynx public key.');
messageApi.error('Selected server does not advertise a NordLynx public key.');
return null;
}
return {
@ -215,7 +216,7 @@ export default function NordModal({
const ob = buildNordOutbound();
if (!ob) return;
onAddOutbound(ob);
message.success('NordVPN outbound added');
messageApi.success('NordVPN outbound added');
onClose();
}
@ -230,12 +231,14 @@ export default function NordModal({
oldTag,
newTag: ob.tag as string,
});
message.success('NordVPN outbound updated');
messageApi.success('NordVPN outbound updated');
onClose();
}
return (
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
<>
{messageContextHolder}
<Modal open={open} title="NordVPN NordLynx" footer={null} onCancel={onClose}>
{nordData == null ? (
<Tabs
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 { Wireguard } from '@/utils';
import InputAddon from '@/components/InputAddon';
import {
Outbound,
Protocols,
@ -67,6 +68,7 @@ export default function OutboundFormModal({
onConfirm,
}: OutboundFormModalProps) {
const { t } = useTranslation();
const [messageApi, messageContextHolder] = message.useMessage();
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const outboundRef = useRef<any>(null);
const [, setTick] = useState(0);
@ -119,7 +121,7 @@ export default function OutboundFormModal({
try {
parsed = JSON.parse(raw);
} catch (e) {
message.error(`JSON: ${(e as Error).message}`);
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
try {
@ -130,7 +132,7 @@ export default function OutboundFormModal({
refresh();
return true;
} catch (e) {
message.error(`JSON: ${(e as Error).message}`);
messageApi.error(`JSON: ${(e as Error).message}`);
return false;
}
}
@ -219,11 +221,11 @@ export default function OutboundFormModal({
if (!ob) return;
if (activeKey === '2' && !applyAdvancedJsonToForm()) return;
if (!ob.tag?.trim()) {
message.error('Tag is required');
messageApi.error('Tag is required');
return;
}
if (duplicateTag) {
message.error('Tag already used by another outbound');
messageApi.error('Tag already used by another outbound');
return;
}
onConfirm(ob.toJson());
@ -235,17 +237,17 @@ export default function OutboundFormModal({
try {
const next = Outbound.fromLink(link);
if (!next) {
message.error('Wrong Link!');
messageApi.error('Wrong Link!');
return;
}
outboundRef.current = next;
primeAdvancedJson();
setLinkInput('');
message.success('Link imported successfully...');
messageApi.success('Link imported successfully...');
setActiveKey('1');
refresh();
} catch (e) {
message.error(`Link parse: ${(e as Error).message}`);
messageApi.error(`Link parse: ${(e as Error).message}`);
}
}
@ -256,21 +258,26 @@ export default function OutboundFormModal({
if (!ob) {
return (
<Modal open={open} title={title} footer={null} onCancel={onClose} />
<>
{messageContextHolder}
<Modal open={open} title={title} footer={null} onCancel={onClose} />
</>
);
}
return (
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
>
<>
{messageContextHolder}
<Modal
open={open}
title={title}
okText={okText}
cancelText={t('close')}
mask={{ closable: false }}
width={780}
onOk={onOk}
onCancel={onClose}
>
<Tabs
activeKey={activeKey}
onChange={onTabChange}
@ -279,6 +286,7 @@ export default function OutboundFormModal({
key: '1',
label: t('pages.xray.basicTemplate'),
children: (
<>
<Form colon={false} labelCol={{ md: { span: 8 } }} wrapperCol={{ md: { span: 14 } }}>
<Form.Item label={t('protocol')}>
<Select
@ -423,11 +431,11 @@ export default function OutboundFormModal({
{ob.stream && <SockoptFields ob={ob} refresh={refresh} />}
{ob.canEnableMux() && <MuxFields ob={ob} refresh={refresh} t={t} />}
{ob.stream && ob.canEnableStream() && (
<FinalMaskForm stream={ob.stream} protocol={proto} onChange={refresh} />
)}
</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 label="Allowed IPs">
{(peer.allowedIPs || []).map((ip, idx) => (
<Input
key={idx}
value={ip}
style={{ marginBottom: 4 }}
onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
addonAfter={
(peer.allowedIPs || []).length > 1 ? (
<MinusOutlined onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }} />
) : undefined
}
/>
<Space.Compact key={idx} block style={{ marginBottom: 4 }}>
<Input
value={ip}
onChange={(e) => { peer.allowedIPs![idx] = e.target.value; refresh(); }}
/>
{(peer.allowedIPs || []).length > 1 && (
<InputAddon onClick={() => { peer.allowedIPs!.splice(idx, 1); refresh(); }}>
<MinusOutlined />
</InputAddon>
)}
</Space.Compact>
))}
<Button
size="small"
@ -1047,22 +1056,20 @@ function XhttpFields({ ob, refresh, t }: TFieldProps) {
</Form.Item>
<Form.Item wrapperCol={{ span: 24 }}>
{(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
value={header.name}
addonBefore={`${idx + 1}`}
style={{ width: '45%' }}
placeholder="Name"
onChange={(e) => { header.name = e.target.value; refresh(); }}
/>
<Input
value={header.value}
style={{ width: '45%' }}
placeholder="Value"
onChange={(e) => { header.value = e.target.value; refresh(); }}
/>
<Button icon={<MinusOutlined />} onClick={() => { xh.removeHeader(idx); refresh(); }} />
</Input.Group>
</Space.Compact>
))}
</Form.Item>

View file

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

View file

@ -72,6 +72,7 @@ export default function WarpModal({
onResetOutbound,
onRemoveOutbound,
}: WarpModalProps) {
const [messageApi, messageContextHolder] = message.useMessage();
const [loading, setLoading] = useState(false);
const [warpData, setWarpData] = useState<WarpData | null>(null);
const [warpConfig, setWarpConfig] = useState<WarpConfig | null>(null);
@ -191,7 +192,7 @@ export default function WarpModal({
function addOutbound() {
if (!stagedOutbound) {
message.warning('Fetch the WARP config first.');
messageApi.warning('Fetch the WARP config first.');
return;
}
onAddOutbound(stagedOutbound);
@ -207,7 +208,9 @@ export default function WarpModal({
const hasConfig = !ObjectUtil.isEmpty(warpConfig);
return (
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
<>
{messageContextHolder}
<Modal open={open} title="Cloudflare WARP" footer={null} onCancel={onClose}>
{!hasWarp ? (
<Button type="primary" loading={loading} icon={<ApiOutlined />} onClick={register}>
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 AppSidebar from '@/components/AppSidebar';
import JsonEditor from '@/components/JsonEditor';
import { setMessageInstance } from '@/utils/messageBus';
import BasicsTab from './BasicsTab';
import RoutingTab from './RoutingTab';
@ -65,6 +66,8 @@ export default function XrayPage() {
const { t } = useTranslation();
const { isDark, isUltra, antdThemeConfig } = useTheme();
const { isMobile } = useMediaQuery();
const [messageApi, messageContextHolder] = message.useMessage();
useEffect(() => { setMessageInstance(messageApi); }, [messageApi]);
const xs = useXraySetting();
const {
fetched,
@ -239,7 +242,7 @@ export default function XrayPage() {
try {
JSON.parse(xraySetting);
} catch (e) {
message.error(`Advanced JSON: ${(e as Error).message}`);
messageApi.error(`Advanced JSON: ${(e as Error).message}`);
setActiveTabKey('tpl-advanced');
return;
}
@ -252,6 +255,7 @@ export default function XrayPage() {
return (
<ConfigProvider theme={antdThemeConfig}>
{messageContextHolder}
{modalContextHolder}
<Layout className={pageClass}>
<AppSidebar basePath={basePath} requestUri={requestUri} />

View file

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

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
// object on mount. PageData fields are already in the shape the Vue
// component expects, plus a `links` array carrying the rendered
// share URLs.
// The panel's "Calendar Type" setting decides whether the SubPage
// renders dates in Gregorian or Jalali — surface it here so the SPA
// can match the rest of the panel without a round-trip.

View file

@ -48,7 +48,7 @@ func (a *XUIController) initRouter(g *gin.RouterGroup) {
a.xraySettingController = NewXraySettingController(g)
}
// All four panel pages now serve the Vue 3 builds from web/dist/
// The main panel's HTML routes serve the pre-built SPA pages from distFS,
// instead of rendering the legacy Go templates. Each handler is a
// thin wrapper around serveDistPage so the basePath injection +
// no-cache headers stay centralised.

View file

@ -40,13 +40,7 @@ var i18nFS embed.FS
// distFS embeds the Vite-built frontend (web/dist/). Every user-facing
// HTML route is served straight out of this FS — the legacy Go
// templates and `web/assets/` tree are gone post-Phase 8.
//
// `all:` is required so files whose names start with `_` are NOT
// silently excluded by go:embed's default rules. Vite/rolldown emits
// `_plugin-vue_export-helper-<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
var distFS embed.FS