mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-08-26 13:07:14 +00:00
bd6a6aba43
* feat(pia): add login-and-add WireGuard outbounds (#2) * fix(pia): keep PIA outbounds identifiable after the editor strips hostname The outbound editor drops piaHostname, so last-segment matching failed for hyphenated servers. Identify rows by the computed tag, re-encrypt stored tokens onto the active key, skip unusable catalog rows, and always release the catalog refresh latch.
986 lines
33 KiB
TypeScript
986 lines
33 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;
|
|
onShowPia: () => void;
|
|
onRefreshXrayData?: () => void;
|
|
}
|
|
|
|
export default function OutboundsTab({
|
|
templateSettings,
|
|
setTemplateSettings,
|
|
outboundsTraffic,
|
|
outboundTestStates,
|
|
subscriptionTestStates,
|
|
testingAll,
|
|
inboundTags: _inboundTags,
|
|
subscriptionOutbounds,
|
|
subscriptionOutboundTags,
|
|
isMobile,
|
|
onResetTraffic,
|
|
onTest,
|
|
onTestSubscription,
|
|
onTestAll,
|
|
onShowWarp,
|
|
onShowNord,
|
|
onShowPia,
|
|
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 },
|
|
{
|
|
key: 'pia',
|
|
icon: <ApiOutlined />,
|
|
label: t('pages.xray.pia.menu'),
|
|
onClick: onShowPia,
|
|
},
|
|
{ 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>
|
|
</>
|
|
);
|
|
}
|