mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-28 14:07:13 +00:00
411271b454
* refactor(ui): share one onNumber handler for numeric setting inputs The Number(v) || 0 idiom in InputNumber onChange handlers is the root pattern behind the cleared-port bug (#6121): AntD reports a cleared field as null, and || 0 turns that into a stored zero or a min-clamp. The port fields got an inline null-guard; the other sixteen numeric settings kept the idiom, so every new field is a chance to reintroduce the bug. Extract the guard into onNumber(apply): null, empty and NaN change events are ignored so a cleared field snaps back to its stored value on blur, and numeric events pass through unchanged. Convert all sixteen sites in the settings and xray pages. Two sites keep their deliberate different semantics: smtpPort falls back to 587 on clear, and the Telegram notify interval clamps through Math.max. For the non-port fields this changes clearing from storing 0 to keeping the stored value; zero remains reachable by typing it. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> * refactor(ui): fold the remaining hand-rolled numeric guards into onNumber From review: ObservatorySettingsTab's sampling field hand-rolled the same ignore-null semantic and smtpPort kept a fallback-to-587 on clear that nothing documents as intentional and that silently overwrites a configured non-standard port — both now go through the shared helper, leaving the Telegram interval clamp as the one deliberate exception. Also from review: narrow the helper to numbers only (no stringMode input exists in the repo, and the string branch codified a guarantee the number-typed callback cannot honour), soften the docblock to describe behavior rather than promise prevention, add a GeneralTab component test covering the clear-vs-typed-zero semantics, and assert the blur snap-back in both settings tests so a display/state desync cannot ship unnoticed. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> --------- Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
770 lines
29 KiB
TypeScript
770 lines
29 KiB
TypeScript
import { useCallback, useMemo, useRef, useState } from 'react';
|
|
import { useTranslation } from 'react-i18next';
|
|
import {
|
|
Button,
|
|
Col,
|
|
Dropdown,
|
|
Form,
|
|
Input,
|
|
InputNumber,
|
|
Modal,
|
|
Popconfirm,
|
|
Radio,
|
|
Row,
|
|
Space,
|
|
Switch,
|
|
Table,
|
|
Tag,
|
|
Tooltip,
|
|
message,
|
|
} from 'antd';
|
|
import {
|
|
PlusOutlined,
|
|
CloudOutlined,
|
|
ApiOutlined,
|
|
MoreOutlined,
|
|
RetweetOutlined,
|
|
PlayCircleOutlined,
|
|
ReloadOutlined,
|
|
DeleteOutlined,
|
|
EditOutlined,
|
|
EyeOutlined,
|
|
ArrowUpOutlined,
|
|
ArrowDownOutlined,
|
|
CheckCircleOutlined,
|
|
WarningOutlined,
|
|
ExportOutlined,
|
|
ImportOutlined,
|
|
} from '@ant-design/icons';
|
|
|
|
import { HttpUtil } from '@/utils';
|
|
import { onNumber } from '@/utils/onNumber';
|
|
import PromptModal from '@/components/feedback/PromptModal';
|
|
import TextModal from '@/components/feedback/TextModal';
|
|
|
|
import OutboundFormModal from './OutboundFormModal';
|
|
import { propagateOutboundTagRename } from '../basics/helpers';
|
|
import { planOutboundDeletion, applyOutboundDeletion } from '../reference-cleanup';
|
|
import DeletionImpactList from '../DeletionImpactList';
|
|
import { isBalancerLoopbackTag } from '../balancers/balancer-loopback';
|
|
import type { XraySettingsValue, SetTemplate, OutboundTestMode, OutboundTestState, OutboundTrafficRow } from '@/hooks/useXraySetting';
|
|
import './OutboundsTab.css';
|
|
|
|
import type { OutboundRow } from './outbounds-tab-types';
|
|
import { originalOutboundIndex } from './outbounds-tab-helpers';
|
|
import { useOutboundColumns } from './useOutboundColumns';
|
|
import OutboundCardList from './OutboundCardList';
|
|
import SubscriptionOutbounds from './SubscriptionOutbounds';
|
|
|
|
interface OutboundSub {
|
|
id: number;
|
|
remark?: string;
|
|
url?: string;
|
|
enabled?: boolean;
|
|
allowPrivate?: boolean;
|
|
allowInsecure?: boolean;
|
|
prepend?: boolean;
|
|
priority?: number;
|
|
tagPrefix?: string;
|
|
updateInterval?: number;
|
|
lastUpdated?: number;
|
|
lastError?: string;
|
|
outboundCount?: number;
|
|
}
|
|
|
|
interface OutboundsTabProps {
|
|
templateSettings: XraySettingsValue | null;
|
|
setTemplateSettings: SetTemplate;
|
|
outboundsTraffic: OutboundTrafficRow[];
|
|
outboundTestStates: Record<number, OutboundTestState>;
|
|
subscriptionTestStates: Record<string, OutboundTestState>;
|
|
testingAll: boolean;
|
|
inboundTags: string[];
|
|
subscriptionOutbounds?: unknown[];
|
|
subscriptionOutboundTags?: string[];
|
|
isMobile: boolean;
|
|
onResetTraffic: (tag: string) => void;
|
|
onTest: (index: number, mode: string) => void;
|
|
onTestSubscription: (outbound: Record<string, unknown>, mode: string) => void;
|
|
onTestAll: (mode: string) => void;
|
|
onShowWarp: () => void;
|
|
onShowNord: () => void;
|
|
onRefreshXrayData?: () => void;
|
|
}
|
|
|
|
export default function OutboundsTab({
|
|
templateSettings,
|
|
setTemplateSettings,
|
|
outboundsTraffic,
|
|
outboundTestStates,
|
|
subscriptionTestStates,
|
|
testingAll,
|
|
inboundTags: _inboundTags,
|
|
subscriptionOutbounds,
|
|
subscriptionOutboundTags,
|
|
isMobile,
|
|
onResetTraffic,
|
|
onTest,
|
|
onTestSubscription,
|
|
onTestAll,
|
|
onShowWarp,
|
|
onShowNord,
|
|
onRefreshXrayData,
|
|
}: OutboundsTabProps) {
|
|
const { t } = useTranslation();
|
|
const [modal, modalContextHolder] = Modal.useModal();
|
|
const [messageApi, messageContextHolder] = message.useMessage();
|
|
const [testMode, setTestMode] = useState<OutboundTestMode>('tcp');
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [editingOutbound, setEditingOutbound] = useState<Record<string, unknown> | null>(null);
|
|
const [editingIndex, setEditingIndex] = useState<number | null>(null);
|
|
const [existingTags, setExistingTags] = useState<string[]>([]);
|
|
|
|
// Subscription manager (CRUD + reorder + refresh + preview)
|
|
const [subDrawerOpen, setSubDrawerOpen] = useState(false);
|
|
const [subs, setSubs] = useState<OutboundSub[]>([]);
|
|
const [subsLoading, setSubsLoading] = useState(false);
|
|
const [newSub, setNewSub] = useState({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
|
|
const [editingSubId, setEditingSubId] = useState<number | null>(null);
|
|
const [savingSub, setSavingSub] = useState(false);
|
|
const [refreshingId, setRefreshingId] = useState<number | null>(null);
|
|
const [refreshingAll, setRefreshingAll] = useState(false);
|
|
const [busyId, setBusyId] = useState<number | null>(null);
|
|
const [previewing, setPreviewing] = useState(false);
|
|
const [previewData, setPreviewData] = useState<{ tag?: string; protocol?: string }[] | null>(null);
|
|
|
|
// Convenience: expose hours/minutes for the interval input
|
|
const intervalHours = Math.floor((newSub.updateInterval || 600) / 3600);
|
|
const intervalMinutes = Math.floor(((newSub.updateInterval || 600) % 3600) / 60);
|
|
function setIntervalHM(h: number, m: number) {
|
|
const secs = Math.max(60, (h || 0) * 3600 + (m || 0) * 60);
|
|
setNewSub((prev) => ({ ...prev, updateInterval: secs }));
|
|
}
|
|
|
|
const outbounds = useMemo(
|
|
() => (templateSettings?.outbounds || []) as unknown as OutboundRow[],
|
|
[templateSettings?.outbounds],
|
|
);
|
|
|
|
const rows = useMemo(
|
|
() =>
|
|
outbounds
|
|
.map((o, i) => ({ ...o, key: i }))
|
|
.filter((o) => !isBalancerLoopbackTag(o.tag || '')),
|
|
[outbounds],
|
|
);
|
|
const rowsRef = useRef<OutboundRow[]>([]);
|
|
rowsRef.current = rows;
|
|
|
|
const dialerProxyTags = useMemo(() => {
|
|
const tags = new Set<string>();
|
|
(templateSettings?.outbounds || []).forEach((o, i) => {
|
|
if (i === editingIndex) return;
|
|
if (o?.protocol === 'blackhole') return;
|
|
if (o?.tag) tags.add(o.tag);
|
|
});
|
|
for (const tag of subscriptionOutboundTags || []) {
|
|
if (tag) tags.add(tag);
|
|
}
|
|
return [...tags];
|
|
}, [templateSettings?.outbounds, editingIndex, subscriptionOutboundTags]);
|
|
|
|
const mutate = useCallback(
|
|
(mutator: (next: XraySettingsValue) => void) => {
|
|
setTemplateSettings((prev) => {
|
|
if (!prev) return prev;
|
|
const clone = JSON.parse(JSON.stringify(prev)) as XraySettingsValue;
|
|
mutator(clone);
|
|
return clone;
|
|
});
|
|
},
|
|
[setTemplateSettings],
|
|
);
|
|
|
|
function openAdd() {
|
|
setEditingOutbound(null);
|
|
setEditingIndex(null);
|
|
setExistingTags((templateSettings?.outbounds || []).map((o) => o?.tag).filter((tg): tg is string => !!tg));
|
|
setModalOpen(true);
|
|
}
|
|
|
|
function openSubManager() {
|
|
setSubDrawerOpen(true);
|
|
loadSubs();
|
|
}
|
|
function openEdit(idx: number) {
|
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
setEditingOutbound((templateSettings?.outbounds || [])[target] as Record<string, unknown>);
|
|
setEditingIndex(target);
|
|
setExistingTags(
|
|
(templateSettings?.outbounds || [])
|
|
.filter((_, i) => i !== target)
|
|
.map((o) => o?.tag)
|
|
.filter((tg): tg is string => !!tg),
|
|
);
|
|
setModalOpen(true);
|
|
}
|
|
function onConfirm(outbound: Record<string, unknown>) {
|
|
mutate((tt) => {
|
|
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
|
const newTag = typeof outbound.tag === 'string' ? outbound.tag : '';
|
|
if (editingIndex == null) {
|
|
if (!newTag) return;
|
|
tt.outbounds.push(outbound as never);
|
|
} else {
|
|
const oldTag = tt.outbounds[editingIndex]?.tag;
|
|
tt.outbounds[editingIndex] = outbound as never;
|
|
if (oldTag && newTag && oldTag !== newTag) {
|
|
propagateOutboundTagRename(tt, oldTag, newTag);
|
|
}
|
|
}
|
|
});
|
|
setModalOpen(false);
|
|
}
|
|
|
|
function confirmDelete(idx: number) {
|
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
const impact = templateSettings
|
|
? planOutboundDeletion(templateSettings, target)
|
|
: { rules: [], balancers: [], observatory: false, burst: false };
|
|
modal.confirm({
|
|
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
|
|
content: <DeletionImpactList impact={impact} />,
|
|
okText: t('delete'),
|
|
okType: 'danger',
|
|
cancelText: t('cancel'),
|
|
onOk: () => mutate((tt) => applyOutboundDeletion(tt, target)),
|
|
});
|
|
}
|
|
function setFirst(idx: number) {
|
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
mutate((tt) => {
|
|
if (!tt.outbounds) return;
|
|
const [moved] = tt.outbounds.splice(target, 1);
|
|
tt.outbounds.unshift(moved);
|
|
});
|
|
}
|
|
function moveUp(idx: number) {
|
|
if (idx <= 0) return;
|
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
const prev = originalOutboundIndex(rowsRef.current, idx - 1);
|
|
mutate((tt) => {
|
|
if (!tt.outbounds) return;
|
|
[tt.outbounds[prev], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[prev]];
|
|
});
|
|
}
|
|
function moveDown(idx: number) {
|
|
if (idx >= rowsRef.current.length - 1) return;
|
|
const target = originalOutboundIndex(rowsRef.current, idx);
|
|
const next = originalOutboundIndex(rowsRef.current, idx + 1);
|
|
mutate((tt) => {
|
|
if (!tt.outbounds) return;
|
|
[tt.outbounds[next], tt.outbounds[target]] = [tt.outbounds[target], tt.outbounds[next]];
|
|
});
|
|
}
|
|
|
|
const [importOpen, setImportOpen] = useState(false);
|
|
const [exportOpen, setExportOpen] = useState(false);
|
|
const [exportContent, setExportContent] = useState('');
|
|
|
|
function exportOutbounds() {
|
|
setExportContent(JSON.stringify(outbounds, null, 2));
|
|
setExportOpen(true);
|
|
}
|
|
|
|
function importOutbounds(value: string) {
|
|
let parsed: unknown;
|
|
try {
|
|
parsed = JSON.parse(value);
|
|
} catch {
|
|
messageApi.error(t('pages.xray.importInvalidJson'));
|
|
return;
|
|
}
|
|
const obj = parsed as { outbounds?: unknown };
|
|
const list = Array.isArray(parsed) ? parsed : Array.isArray(obj?.outbounds) ? obj.outbounds : null;
|
|
if (!list) {
|
|
messageApi.error(t('pages.xray.importInvalidJson'));
|
|
return;
|
|
}
|
|
mutate((tt) => {
|
|
if (!Array.isArray(tt.outbounds)) tt.outbounds = [];
|
|
tt.outbounds.push(...(list as never[]));
|
|
});
|
|
setImportOpen(false);
|
|
}
|
|
|
|
// --- Subscription management (minimal inline UI) ---
|
|
async function loadSubs() {
|
|
setSubsLoading(true);
|
|
try {
|
|
const r = await HttpUtil.get('/panel/api/xray/outbound-subs');
|
|
if (r?.success) setSubs(Array.isArray(r.obj) ? r.obj : []);
|
|
} catch {
|
|
messageApi.error(t('pages.xray.outboundSub.toastLoadFailed'));
|
|
} finally {
|
|
setSubsLoading(false);
|
|
}
|
|
}
|
|
function subBody(src: { remark?: string; url?: string; tagPrefix?: string; updateInterval?: number; enabled?: boolean; allowPrivate?: boolean; allowInsecure?: boolean; prepend?: boolean }) {
|
|
return {
|
|
remark: src.remark ?? '',
|
|
url: src.url ?? '',
|
|
tagPrefix: src.tagPrefix ?? '',
|
|
updateInterval: src.updateInterval ?? 600,
|
|
enabled: src.enabled ?? true,
|
|
allowPrivate: src.allowPrivate ?? false,
|
|
allowInsecure: src.allowInsecure ?? false,
|
|
prepend: src.prepend ?? false,
|
|
};
|
|
}
|
|
function resetSubForm() {
|
|
setNewSub({ remark: '', url: '', tagPrefix: '', updateInterval: 600, enabled: true, allowPrivate: false, allowInsecure: false, prepend: false });
|
|
setEditingSubId(null);
|
|
setPreviewData(null);
|
|
}
|
|
function openEditSub(sub: OutboundSub) {
|
|
setNewSub({
|
|
remark: sub.remark ?? '',
|
|
url: sub.url ?? '',
|
|
tagPrefix: sub.tagPrefix ?? '',
|
|
updateInterval: sub.updateInterval ?? 600,
|
|
enabled: sub.enabled ?? true,
|
|
allowPrivate: sub.allowPrivate ?? false,
|
|
allowInsecure: sub.allowInsecure ?? false,
|
|
prepend: sub.prepend ?? false,
|
|
});
|
|
setEditingSubId(sub.id);
|
|
setPreviewData(null);
|
|
}
|
|
async function saveSub() {
|
|
if (!newSub.url.trim()) {
|
|
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
|
|
return;
|
|
}
|
|
setSavingSub(true);
|
|
try {
|
|
const url = editingSubId != null
|
|
? `/panel/api/xray/outbound-subs/${editingSubId}`
|
|
: '/panel/api/xray/outbound-subs';
|
|
const r = await HttpUtil.post<OutboundSub>(url, subBody(newSub));
|
|
if (r?.success) {
|
|
messageApi.success(t(editingSubId != null ? 'pages.xray.outboundSub.toastUpdated' : 'pages.xray.outboundSub.toastAdded'));
|
|
const createdId = editingSubId == null ? r.obj?.id : undefined;
|
|
resetSubForm();
|
|
await loadSubs();
|
|
if (createdId) await refreshOne(createdId);
|
|
onRefreshXrayData?.();
|
|
} else {
|
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
|
|
}
|
|
} catch {
|
|
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
|
|
} finally {
|
|
setSavingSub(false);
|
|
}
|
|
}
|
|
async function previewSub() {
|
|
if (!newSub.url.trim()) {
|
|
messageApi.warning(t('pages.xray.outboundSub.toastUrlRequired'));
|
|
return;
|
|
}
|
|
setPreviewing(true);
|
|
setPreviewData(null);
|
|
try {
|
|
const r = await HttpUtil.post<{ tag?: string; protocol?: string }[]>('/panel/api/xray/outbound-subs/parse', { url: newSub.url, allowPrivate: newSub.allowPrivate });
|
|
if (r?.success && Array.isArray(r.obj)) {
|
|
setPreviewData(r.obj);
|
|
if (r.obj.length === 0) messageApi.info(t('pages.xray.outboundSub.previewEmpty'));
|
|
} else {
|
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.previewEmpty'));
|
|
}
|
|
} catch {
|
|
messageApi.error(t('pages.xray.outboundSub.previewEmpty'));
|
|
} finally {
|
|
setPreviewing(false);
|
|
}
|
|
}
|
|
async function toggleEnabled(sub: OutboundSub) {
|
|
setBusyId(sub.id);
|
|
try {
|
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${sub.id}`, subBody({ ...sub, enabled: !sub.enabled }));
|
|
if (r?.success) {
|
|
await loadSubs();
|
|
onRefreshXrayData?.();
|
|
} else {
|
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastAddFailed'));
|
|
}
|
|
} catch {
|
|
messageApi.error(t('pages.xray.outboundSub.toastAddFailed'));
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
}
|
|
async function moveSub(id: number, dir: 'up' | 'down') {
|
|
setBusyId(id);
|
|
try {
|
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/move`, { dir });
|
|
if (r?.success) {
|
|
await loadSubs();
|
|
onRefreshXrayData?.();
|
|
}
|
|
} catch {
|
|
/* ignore */
|
|
} finally {
|
|
setBusyId(null);
|
|
}
|
|
}
|
|
async function refreshOne(id: number) {
|
|
setRefreshingId(id);
|
|
try {
|
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/refresh`);
|
|
if (r?.success) {
|
|
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
|
await loadSubs();
|
|
onRefreshXrayData?.();
|
|
} else {
|
|
messageApi.error(r?.msg || t('pages.xray.outboundSub.toastRefreshFailed'));
|
|
}
|
|
} catch {
|
|
messageApi.error(t('pages.xray.outboundSub.toastRefreshFailed'));
|
|
} finally {
|
|
setRefreshingId(null);
|
|
}
|
|
}
|
|
async function refreshAllSubs() {
|
|
if (subs.length === 0) return;
|
|
setRefreshingAll(true);
|
|
try {
|
|
for (const s of subs) {
|
|
try { await HttpUtil.post(`/panel/api/xray/outbound-subs/${s.id}/refresh`); } catch { /* continue */ }
|
|
}
|
|
messageApi.success(t('pages.xray.outboundSub.toastRefreshed'));
|
|
await loadSubs();
|
|
onRefreshXrayData?.();
|
|
} finally {
|
|
setRefreshingAll(false);
|
|
}
|
|
}
|
|
async function deleteOne(id: number) {
|
|
try {
|
|
const r = await HttpUtil.post(`/panel/api/xray/outbound-subs/${id}/del`);
|
|
if (r?.success) {
|
|
messageApi.success(t('pages.xray.outboundSub.toastDeleted'));
|
|
await loadSubs();
|
|
onRefreshXrayData?.();
|
|
}
|
|
} catch {
|
|
messageApi.error(t('pages.xray.outboundSub.toastDeleteFailed'));
|
|
}
|
|
}
|
|
|
|
const columns = useOutboundColumns({
|
|
testMode,
|
|
rows,
|
|
outboundsTraffic,
|
|
outboundTestStates,
|
|
openEdit,
|
|
setFirst,
|
|
moveUp,
|
|
moveDown,
|
|
confirmDelete,
|
|
onResetTraffic,
|
|
onTest,
|
|
});
|
|
|
|
return (
|
|
<>
|
|
{modalContextHolder}
|
|
{messageContextHolder}
|
|
<Space orientation="vertical" size="middle" style={{ width: '100%' }}>
|
|
<Row gutter={[12, 12]} align="middle" justify="space-between">
|
|
<Col xs={24} sm={12}>
|
|
<Space size="small" wrap>
|
|
<Button type="primary" icon={<PlusOutlined />} onClick={openAdd}>
|
|
{!isMobile && t('pages.xray.Outbounds')}
|
|
</Button>
|
|
<Button icon={<CloudOutlined />} onClick={openSubManager}>
|
|
{t('pages.xray.outboundSub.manage')}
|
|
</Button>
|
|
<Dropdown
|
|
trigger={['click']}
|
|
menu={{
|
|
items: [
|
|
{ key: 'warp', icon: <CloudOutlined />, label: 'WARP', onClick: onShowWarp },
|
|
{ key: 'nord', icon: <ApiOutlined />, label: 'NordVPN', onClick: onShowNord },
|
|
{ type: 'divider' },
|
|
{ key: 'import', icon: <ImportOutlined />, label: t('pages.xray.importOutbounds'), onClick: () => setImportOpen(true) },
|
|
{ key: 'export', icon: <ExportOutlined />, label: t('pages.xray.exportOutbounds'), disabled: outbounds.length === 0, onClick: exportOutbounds },
|
|
],
|
|
}}
|
|
>
|
|
<Button icon={<MoreOutlined />}>{t('more')}</Button>
|
|
</Dropdown>
|
|
</Space>
|
|
</Col>
|
|
<Col xs={24} sm={12} className="toolbar-right">
|
|
<Space size="small" wrap>
|
|
<Tooltip title={t('pages.xray.outbound.testModeTooltip')}>
|
|
<Radio.Group value={testMode} onChange={(e) => setTestMode(e.target.value)} buttonStyle="solid" size="small">
|
|
<Radio.Button value="tcp">TCP</Radio.Button>
|
|
<Radio.Button value="http">HTTP</Radio.Button>
|
|
<Radio.Button value="real">{t('pages.xray.outbound.modeRealDelay')}</Radio.Button>
|
|
</Radio.Group>
|
|
</Tooltip>
|
|
<Button type="primary" loading={testingAll} icon={<PlayCircleOutlined />} onClick={() => onTestAll(testMode)}>
|
|
{!isMobile && t('pages.xray.outbound.testAll')}
|
|
</Button>
|
|
<Popconfirm
|
|
placement="topRight"
|
|
okText={t('reset')}
|
|
cancelText={t('cancel')}
|
|
title={t('pages.inbounds.resetAllTrafficContent')}
|
|
onConfirm={() => onResetTraffic('-alltags-')}
|
|
>
|
|
<Button aria-label={t('pages.inbounds.resetTraffic')} icon={<RetweetOutlined />} />
|
|
</Popconfirm>
|
|
</Space>
|
|
</Col>
|
|
</Row>
|
|
|
|
{isMobile ? (
|
|
<OutboundCardList
|
|
rows={rows}
|
|
testMode={testMode}
|
|
outboundsTraffic={outboundsTraffic}
|
|
outboundTestStates={outboundTestStates}
|
|
setFirst={setFirst}
|
|
openEdit={openEdit}
|
|
onResetTraffic={onResetTraffic}
|
|
confirmDelete={confirmDelete}
|
|
onTest={onTest}
|
|
/>
|
|
) : (
|
|
<Table
|
|
columns={columns}
|
|
dataSource={rows}
|
|
rowKey={(r) => r.key}
|
|
pagination={false}
|
|
size="small"
|
|
locale={{
|
|
emptyText: (
|
|
<div className="card-empty">
|
|
<ExportOutlined style={{ fontSize: 32, marginBottom: 8 }} />
|
|
<div>{t('noData')}</div>
|
|
</div>
|
|
),
|
|
}}
|
|
/>
|
|
)}
|
|
|
|
<OutboundFormModal
|
|
open={modalOpen}
|
|
outbound={editingOutbound}
|
|
existingTags={existingTags}
|
|
dialerProxyTags={dialerProxyTags}
|
|
onClose={() => setModalOpen(false)}
|
|
onConfirm={onConfirm}
|
|
/>
|
|
<PromptModal
|
|
open={importOpen}
|
|
onClose={() => setImportOpen(false)}
|
|
title={t('pages.xray.importOutbounds')}
|
|
okText={t('pages.xray.importOutbounds')}
|
|
type="textarea"
|
|
json
|
|
onConfirm={importOutbounds}
|
|
/>
|
|
<TextModal
|
|
open={exportOpen}
|
|
onClose={() => setExportOpen(false)}
|
|
title={t('pages.xray.exportOutbounds')}
|
|
content={exportContent}
|
|
fileName="outbounds.json"
|
|
json
|
|
/>
|
|
|
|
{/* Subscription outbounds (read-only, merged at runtime) */}
|
|
{Array.isArray(subscriptionOutbounds) && subscriptionOutbounds.length > 0 && (
|
|
<SubscriptionOutbounds
|
|
subscriptionOutbounds={subscriptionOutbounds}
|
|
outboundsTraffic={outboundsTraffic}
|
|
subscriptionTestStates={subscriptionTestStates}
|
|
testMode={testMode}
|
|
isMobile={isMobile}
|
|
onTestSubscription={onTestSubscription}
|
|
/>
|
|
)}
|
|
</Space>
|
|
|
|
<Modal
|
|
title={t('pages.xray.outboundSub.title')}
|
|
open={subDrawerOpen}
|
|
onCancel={() => setSubDrawerOpen(false)}
|
|
footer={null}
|
|
width={isMobile ? '100%' : 640}
|
|
destroyOnHidden
|
|
>
|
|
<Space orientation="vertical" style={{ width: '100%' }} size="large">
|
|
<div>
|
|
{editingSubId != null && (
|
|
<div style={{ marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
<Tag color="blue">{t('edit')}</Tag>
|
|
<span style={{ fontWeight: 600 }}>{newSub.remark || newSub.url}</span>
|
|
</div>
|
|
)}
|
|
<Form layout="vertical" size="small">
|
|
<Form.Item label={t('pages.xray.outboundSub.remark')}>
|
|
<Input value={newSub.remark} onChange={(e) => setNewSub({ ...newSub, remark: e.target.value })} placeholder={t('pages.xray.outboundSub.remarkPlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.url')} required>
|
|
<Input value={newSub.url} onChange={(e) => setNewSub({ ...newSub, url: e.target.value })} placeholder={t('pages.xray.outboundSub.urlPlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.tagPrefix')}>
|
|
<Input value={newSub.tagPrefix} onChange={(e) => setNewSub({ ...newSub, tagPrefix: e.target.value })} placeholder={t('pages.xray.outboundSub.tagPrefixPlaceholder')} />
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.interval')}>
|
|
<Space>
|
|
<InputNumber
|
|
min={0}
|
|
value={intervalHours}
|
|
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
|
style={{ width: 80 }}
|
|
/> {t('pages.xray.outboundSub.hours')}
|
|
<InputNumber
|
|
min={0}
|
|
max={59}
|
|
value={intervalMinutes}
|
|
onChange={onNumber((v) => setIntervalHM(intervalHours, v))}
|
|
style={{ width: 80 }}
|
|
/> {t('pages.xray.outboundSub.minutes')}
|
|
</Space>
|
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
{t('pages.xray.outboundSub.intervalHint')}
|
|
</div>
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.enabled')}>
|
|
<Switch checked={newSub.enabled} onChange={(v) => setNewSub({ ...newSub, enabled: v })} />
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.allowPrivate')}>
|
|
<Switch checked={newSub.allowPrivate} onChange={(v) => setNewSub({ ...newSub, allowPrivate: v })} />
|
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
{t('pages.xray.outboundSub.allowPrivateHint')}
|
|
</div>
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.hosts.fields.allowInsecure')}>
|
|
<Switch checked={newSub.allowInsecure} onChange={(v) => setNewSub({ ...newSub, allowInsecure: v })} />
|
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
{t('pages.hosts.hints.allowInsecure')}
|
|
</div>
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.prepend')}>
|
|
<Switch checked={newSub.prepend} onChange={(v) => setNewSub({ ...newSub, prepend: v })} />
|
|
<div style={{ fontSize: 12, color: '#888', marginTop: 4 }}>
|
|
{t('pages.xray.outboundSub.prependHint')}
|
|
</div>
|
|
</Form.Item>
|
|
<Space wrap>
|
|
<Button type="primary" onClick={saveSub} loading={savingSub} icon={editingSubId != null ? <EditOutlined /> : <PlusOutlined />}>
|
|
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
|
|
</Button>
|
|
<Button onClick={previewSub} loading={previewing} icon={<EyeOutlined />}>
|
|
{t('pages.xray.outboundSub.preview')}
|
|
</Button>
|
|
{editingSubId != null && <Button onClick={resetSubForm}>{t('cancel')}</Button>}
|
|
</Space>
|
|
{previewData && previewData.length > 0 && (
|
|
<div style={{ marginTop: 8 }}>
|
|
<div style={{ fontSize: 12, color: '#888', marginBottom: 4 }}>{previewData.length} · {t('pages.xray.Outbounds')}</div>
|
|
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 4, maxHeight: 120, overflow: 'auto' }}>
|
|
{previewData.map((o, i) => (
|
|
<Tag key={i}>{o?.tag || '—'}{o?.protocol ? ` · ${o.protocol}` : ''}</Tag>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
</Form>
|
|
</div>
|
|
|
|
<div>
|
|
<div style={{ fontWeight: 600, marginBottom: 8, display: 'flex', alignItems: 'center', gap: 8 }}>
|
|
{t('pages.xray.outboundSub.active')}
|
|
<Button aria-label={t('refresh')} size="small" icon={<ReloadOutlined />} onClick={loadSubs} loading={subsLoading} />
|
|
{subs.length > 0 && (
|
|
<Button size="small" type="primary" icon={<ReloadOutlined />} onClick={refreshAllSubs} loading={refreshingAll}>
|
|
{t('pages.xray.outboundSub.refreshAll')}
|
|
</Button>
|
|
)}
|
|
</div>
|
|
{subs.length === 0 ? (
|
|
<div style={{ color: '#888' }}>{t('pages.xray.outboundSub.empty')}</div>
|
|
) : (
|
|
<Table
|
|
size="small"
|
|
dataSource={subs}
|
|
rowKey={(r) => r.id}
|
|
pagination={false}
|
|
scroll={{ x: true }}
|
|
columns={[
|
|
{
|
|
title: '',
|
|
key: 'order',
|
|
width: 56,
|
|
render: (_: unknown, r: OutboundSub, index: number) => (
|
|
<Space size={0}>
|
|
<Button aria-label={t('pages.inbounds.form.moveUp')} type="text" size="small" icon={<ArrowUpOutlined />} disabled={index === 0 || busyId === r.id} onClick={() => moveSub(r.id, 'up')} />
|
|
<Button aria-label={t('pages.inbounds.form.moveDown')} type="text" size="small" icon={<ArrowDownOutlined />} disabled={index === subs.length - 1 || busyId === r.id} onClick={() => moveSub(r.id, 'down')} />
|
|
</Space>
|
|
),
|
|
},
|
|
{
|
|
title: t('pages.xray.outboundSub.colRemark'),
|
|
key: 'remark',
|
|
render: (_: unknown, r: OutboundSub) => (
|
|
<div>
|
|
<div>{r.remark || <em>{t('pages.xray.outboundSub.auto')}</em>}</div>
|
|
{r.tagPrefix && <div style={{ fontSize: 11, color: '#888' }}>{r.tagPrefix}</div>}
|
|
</div>
|
|
),
|
|
},
|
|
{ title: t('pages.xray.Outbounds'), dataIndex: 'outboundCount', key: 'outboundCount', align: 'center', render: (v) => v ?? 0 },
|
|
{
|
|
title: t('status'),
|
|
key: 'status',
|
|
align: 'center',
|
|
render: (_: unknown, r: OutboundSub) => (r.lastError
|
|
? <Tooltip title={r.lastError}><WarningOutlined style={{ color: '#e04141' }} /></Tooltip>
|
|
: <Tooltip title={t('pages.xray.outboundSub.statusOk')}><CheckCircleOutlined style={{ color: '#008771' }} /></Tooltip>),
|
|
},
|
|
{ title: t('pages.xray.outboundSub.colLastFetch'), dataIndex: 'lastUpdated', key: 'lastUpdated', render: (v: number) => v ? new Date(v * 1000).toLocaleString() : t('pages.xray.outboundSub.never') },
|
|
{
|
|
title: t('pages.xray.outboundSub.colEnabled'),
|
|
key: 'enabled',
|
|
align: 'center',
|
|
render: (_: unknown, r: OutboundSub) => <Switch size="small" checked={!!r.enabled} loading={busyId === r.id} onChange={() => toggleEnabled(r)} />,
|
|
},
|
|
{
|
|
title: '',
|
|
key: 'actions',
|
|
render: (_: unknown, r: OutboundSub) => (
|
|
<Space>
|
|
<Button aria-label={t('edit')} size="small" icon={<EditOutlined />} onClick={() => openEditSub(r)} title={t('edit')} />
|
|
<Button aria-label={t('pages.xray.outboundSub.refreshNow')} size="small" icon={<ReloadOutlined />} loading={refreshingId === r.id} onClick={() => refreshOne(r.id)} title={t('pages.xray.outboundSub.refreshNow')} />
|
|
<Popconfirm title={t('pages.xray.outboundSub.deleteConfirm')} okText={t('delete')} cancelText={t('cancel')} onConfirm={() => deleteOne(r.id)}>
|
|
<Button aria-label={t('delete')} size="small" danger icon={<DeleteOutlined />} />
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
|
{t('pages.xray.outboundSub.restartHint')}
|
|
</div>
|
|
</div>
|
|
</Space>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|