mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-05 20:54:14 +00:00
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:
parent
7a4317086b
commit
886376db7d
54 changed files with 779 additions and 399 deletions
2
.github/workflows/ci.yml
vendored
2
.github/workflows/ci.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
2
.github/workflows/codeql.yml
vendored
2
.github/workflows/codeql.yml
vendored
|
|
@ -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"
|
||||
|
|
|
|||
11
frontend/src/components/AppBridge.tsx
Normal file
11
frontend/src/components/AppBridge.tsx
Normal 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}</>;
|
||||
}
|
||||
40
frontend/src/components/InputAddon.css
Normal file
40
frontend/src/components/InputAddon.css
Normal 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;
|
||||
}
|
||||
21
frontend/src/components/InputAddon.tsx
Normal file
21
frontend/src/components/InputAddon.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
43
frontend/src/components/SettingListItem.css
Normal file
43
frontend/src/components/SettingListItem.css
Normal 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);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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: (
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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'));
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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')}>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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')}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 (
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -82,3 +82,9 @@
|
|||
border-radius: 4px;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.security-actions {
|
||||
padding: 12px 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
),
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
</>
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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} />
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
12
frontend/src/utils/messageBus.ts
Normal file
12
frontend/src/utils/messageBus.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue