mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-10 07:05:02 +03:00
e2184777a0
* fix(frontend): make the panel usable with screen readers * fix(frontend): keep mobile tab names and the inbound expiry hint Review of #6907 found two gaps in the screen-reader pass: - On phone-width screens catTabLabel renders only the icon and names the tab by putting aria-label on it, but callers now pass that icon with aria-hidden for the desktop label, so every Settings, Xray and Host category tab lost its name under TalkBack. catTabLabel now clears aria-hidden on the icon it uses as the name. - DateTimePicker forwarded id and aria-label but not aria-describedby, so the inbound expiry hint ("leave blank to never expire") was never attached. It now forwards it on both the Gregorian and Jalali input.
1051 lines
36 KiB
TypeScript
1051 lines
36 KiB
TypeScript
import { useCallback, useId, 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 { isOutboundProtocol } from '@/schemas/primitives';
|
|
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';
|
|
|
|
const defaultOutboundSubscriptionUserAgent = '3x-ui-outbound-sub/1.0';
|
|
|
|
interface OutboundSub {
|
|
id: number;
|
|
remark?: string;
|
|
url?: string;
|
|
enabled?: boolean;
|
|
allowPrivate?: boolean;
|
|
allowInsecure?: boolean;
|
|
userAgent?: string;
|
|
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 uid = useId();
|
|
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: '',
|
|
userAgent: '',
|
|
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 (isOutboundProtocol(o, '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;
|
|
userAgent?: string;
|
|
updateInterval?: number;
|
|
enabled?: boolean;
|
|
allowPrivate?: boolean;
|
|
allowInsecure?: boolean;
|
|
prepend?: boolean;
|
|
}) {
|
|
return {
|
|
remark: src.remark ?? '',
|
|
url: src.url ?? '',
|
|
tagPrefix: src.tagPrefix ?? '',
|
|
userAgent: src.userAgent ?? '',
|
|
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: '',
|
|
userAgent: '',
|
|
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 ?? '',
|
|
userAgent: sub.userAgent ?? '',
|
|
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,
|
|
userAgent: newSub.userAgent,
|
|
allowPrivate: newSub.allowPrivate,
|
|
allowInsecure: newSub.allowInsecure,
|
|
},
|
|
);
|
|
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 aria-hidden />} onClick={openAdd}>
|
|
{!isMobile && t('pages.xray.Outbounds')}
|
|
</Button>
|
|
<Button icon={<CloudOutlined aria-hidden />} onClick={openSubManager}>
|
|
{t('pages.xray.outboundSub.manage')}
|
|
</Button>
|
|
<Dropdown
|
|
trigger={['click']}
|
|
menu={{
|
|
items: [
|
|
{
|
|
key: 'warp',
|
|
icon: <CloudOutlined aria-hidden />,
|
|
label: 'WARP',
|
|
onClick: onShowWarp,
|
|
},
|
|
{
|
|
key: 'nord',
|
|
icon: <ApiOutlined aria-hidden />,
|
|
label: 'NordVPN',
|
|
onClick: onShowNord,
|
|
},
|
|
{
|
|
key: 'pia',
|
|
icon: <ApiOutlined aria-hidden />,
|
|
label: t('pages.xray.pia.menu'),
|
|
onClick: onShowPia,
|
|
},
|
|
{ type: 'divider' },
|
|
{
|
|
key: 'import',
|
|
icon: <ImportOutlined aria-hidden />,
|
|
label: t('pages.xray.importOutbounds'),
|
|
onClick: () => setImportOpen(true),
|
|
},
|
|
{
|
|
key: 'export',
|
|
icon: <ExportOutlined aria-hidden />,
|
|
label: t('pages.xray.exportOutbounds'),
|
|
disabled: outbounds.length === 0,
|
|
onClick: exportOutbounds,
|
|
},
|
|
],
|
|
}}
|
|
>
|
|
<Button icon={<MoreOutlined aria-hidden />}>{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 aria-hidden />}
|
|
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 aria-hidden />}
|
|
/>
|
|
</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 aria-hidden 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')} htmlFor={`${uid}-remark`}>
|
|
<Input
|
|
id={`${uid}-remark`}
|
|
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')} htmlFor={`${uid}-url`} required>
|
|
<Input
|
|
id={`${uid}-url`}
|
|
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')} htmlFor={`${uid}-tagPrefix`}>
|
|
<Input
|
|
id={`${uid}-tagPrefix`}
|
|
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.userAgent')} htmlFor={`${uid}-userAgent`}>
|
|
<Input
|
|
id={`${uid}-userAgent`}
|
|
value={newSub.userAgent}
|
|
onChange={(e) => setNewSub({ ...newSub, userAgent: e.target.value })}
|
|
placeholder={defaultOutboundSubscriptionUserAgent}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item label={t('pages.xray.outboundSub.interval')} htmlFor={`${uid}-interval`}>
|
|
<Space>
|
|
<InputNumber
|
|
id={`${uid}-interval`}
|
|
min={0}
|
|
value={intervalHours}
|
|
onChange={onNumber((v) => setIntervalHM(v, intervalMinutes))}
|
|
style={{ width: 80 }}
|
|
/>{' '}
|
|
{t('pages.xray.outboundSub.hours')}
|
|
<InputNumber
|
|
aria-label={t('pages.xray.outboundSub.minutes')}
|
|
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')} htmlFor={`${uid}-enabled`}>
|
|
<Switch
|
|
id={`${uid}-enabled`}
|
|
checked={newSub.enabled}
|
|
onChange={(v) => setNewSub({ ...newSub, enabled: v })}
|
|
/>
|
|
</Form.Item>
|
|
<Form.Item
|
|
label={t('pages.xray.outboundSub.allowPrivate')}
|
|
htmlFor={`${uid}-allowPrivate`}
|
|
>
|
|
<Switch
|
|
id={`${uid}-allowPrivate`}
|
|
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')}
|
|
htmlFor={`${uid}-allowInsecure`}
|
|
>
|
|
<Switch
|
|
id={`${uid}-allowInsecure`}
|
|
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')} htmlFor={`${uid}-prepend`}>
|
|
<Switch
|
|
id={`${uid}-prepend`}
|
|
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 aria-hidden />
|
|
) : (
|
|
<PlusOutlined aria-hidden />
|
|
)
|
|
}
|
|
>
|
|
{editingSubId != null ? t('save') : t('pages.xray.outboundSub.addButton')}
|
|
</Button>
|
|
<Button
|
|
onClick={previewSub}
|
|
loading={previewing}
|
|
icon={<EyeOutlined aria-hidden />}
|
|
>
|
|
{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 aria-hidden />}
|
|
onClick={loadSubs}
|
|
loading={subsLoading}
|
|
/>
|
|
{subs.length > 0 && (
|
|
<Button
|
|
size="small"
|
|
type="primary"
|
|
icon={<ReloadOutlined aria-hidden />}
|
|
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 aria-hidden />}
|
|
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 aria-hidden />}
|
|
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' }} aria-label={r.lastError} />
|
|
</Tooltip>
|
|
) : (
|
|
<Tooltip title={t('pages.xray.outboundSub.statusOk')}>
|
|
<CheckCircleOutlined
|
|
style={{ color: '#008771' }}
|
|
aria-label={t('pages.xray.outboundSub.statusOk')}
|
|
/>
|
|
</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"
|
|
aria-label={t('pages.xray.outboundSub.colEnabled')}
|
|
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 aria-hidden />}
|
|
onClick={() => openEditSub(r)}
|
|
title={t('edit')}
|
|
/>
|
|
<Button
|
|
aria-label={t('pages.xray.outboundSub.refreshNow')}
|
|
size="small"
|
|
icon={<ReloadOutlined aria-hidden />}
|
|
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 aria-hidden />}
|
|
/>
|
|
</Popconfirm>
|
|
</Space>
|
|
),
|
|
},
|
|
]}
|
|
/>
|
|
)}
|
|
<div style={{ marginTop: 8, fontSize: 12, color: '#666' }}>
|
|
{t('pages.xray.outboundSub.restartHint')}
|
|
</div>
|
|
</div>
|
|
</Space>
|
|
</Modal>
|
|
</>
|
|
);
|
|
}
|