Files
3x-ui/frontend/src/pages/xray/OutboundFormModal.vue
T
MHSanaei b02091d598 feat(frontend): rebuild xray outbound modal with structured per-protocol forms
Replaces the JSON textareas with the same shape the legacy panel uses:
all 11 outbound protocols (vmess/vless/trojan/shadowsocks/socks/http/
mixed/wireguard/tun/dns/loopback/blackhole/freedom) get dedicated
fields, every transport (TCP/KCP/WS/gRPC/HTTPUpgrade/XHTTP) gets its
own panel, and TLS/Reality/sockopt/Mux are configured through the same
controls as the inbound side. Brings the SPA outbound editor to parity
with main so users no longer have to drop into raw JSON.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-08 17:21:34 +02:00

784 lines
34 KiB
Vue

<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { SyncOutlined, PlusOutlined, MinusOutlined, DeleteOutlined } from '@ant-design/icons-vue';
import { Wireguard } from '@/utils';
import {
Outbound,
Protocols,
SSMethods,
TLS_FLOW_CONTROL,
UTLS_FINGERPRINT,
ALPN_OPTION,
SNIFFING_OPTION,
USERS_SECURITY,
OutboundDomainStrategies,
WireguardDomainStrategy,
Address_Port_Strategy,
MODE_OPTION,
DNSRuleActions,
} from '@/models/outbound.js';
const { t } = useI18n();
// Structured outbound add/edit modal — mirrors the legacy
// web/html/form/outbound.html. Covers every protocol + transport
// combination the legacy panel exposes; the JSON tab still lets
// power-users hand-edit fields the structured form doesn't surface
// (deep finalmask/quic tuning, reverse-sniffing, etc.).
const props = defineProps({
open: { type: Boolean, default: false },
outbound: { type: Object, default: null },
existingTags: { type: Array, default: () => [] },
});
const emit = defineEmits(['update:open', 'confirm']);
const PROTOCOL_OPTIONS = Object.values(Protocols);
const SECURITY_OPTIONS = Object.values(USERS_SECURITY);
const FLOW_OPTIONS = Object.values(TLS_FLOW_CONTROL);
const UTLS_OPTIONS = Object.values(UTLS_FINGERPRINT);
const ALPN_OPTIONS = Object.values(ALPN_OPTION);
const NETWORKS = ['tcp', 'kcp', 'ws', 'grpc', 'httpupgrade', 'xhttp'];
const NETWORK_LABELS = {
tcp: 'TCP (RAW)',
kcp: 'mKCP',
ws: 'WebSocket',
grpc: 'gRPC',
httpupgrade: 'HTTPUpgrade',
xhttp: 'XHTTP',
};
// Reactive draft — Outbound instance built from the prop on open.
const outbound = ref(null);
const isEdit = ref(false);
const activeKey = ref('1');
const linkInput = ref('');
// Advanced JSON editor — kept in sync with the parsed Outbound on tab
// switch so users can copy/paste a full JSON config when the structured
// form doesn't reach a field.
const advancedJson = ref('');
watch(() => props.open, (next) => {
if (!next) return;
if (props.outbound) {
isEdit.value = true;
outbound.value = Outbound.fromJson(props.outbound);
} else {
isEdit.value = false;
outbound.value = new Outbound();
}
activeKey.value = '1';
linkInput.value = '';
primeAdvancedJson();
});
watch(activeKey, (key) => {
if (key === '2') primeAdvancedJson();
});
function primeAdvancedJson() {
if (!outbound.value) { advancedJson.value = ''; return; }
try {
advancedJson.value = JSON.stringify(outbound.value.toJson(), null, 2);
} catch (_e) {
advancedJson.value = '';
}
}
function close() { emit('update:open', false); }
function onProtocolChange(next) {
if (!outbound.value) return;
outbound.value.protocol = next;
}
function streamNetworkChange(next) {
if (!outbound.value?.stream) return;
outbound.value.stream.network = next;
if (!outbound.value.canEnableTls()) outbound.value.stream.security = 'none';
}
const duplicateTag = computed(() => {
if (!outbound.value?.tag) return false;
const myTag = outbound.value.tag.trim();
if (!myTag) return false;
if (isEdit.value && props.outbound?.tag === myTag) return false;
return (props.existingTags || []).includes(myTag);
});
// ============== Submit ==============
function onOk() {
if (!outbound.value) return;
if (!outbound.value.tag?.trim()) {
message.error(t('somethingWentWrong'));
return;
}
if (duplicateTag.value) {
message.error(t('somethingWentWrong'));
return;
}
// If user spent time in the JSON tab, prefer that body — round-trip
// it through Outbound.fromJson so the wire shape stays consistent.
if (activeKey.value === '2' && advancedJson.value.trim()) {
try {
const parsed = JSON.parse(advancedJson.value);
const built = Outbound.fromJson(parsed);
emit('confirm', built.toJson());
return;
} catch (e) {
message.error(`JSON: ${e.message}`);
return;
}
}
emit('confirm', outbound.value.toJson());
}
// ============== Link → outbound ==============
// The legacy "convert link" button takes a vmess://, vless://, ss://,
// trojan:// or hysteria2:// share-link string and rebuilds the
// outbound from it. The Outbound class doesn't have a native parser —
// we only support a friendly URL parse for the common shapes.
function convertLink() {
const link = linkInput.value.trim();
if (!link) return;
try {
if (link.startsWith('vmess://')) {
const data = JSON.parse(atob(link.replace(/^vmess:\/\//, '')));
const ob = new Outbound(data.ps || 'vmess', Protocols.VMess);
ob.settings.address = data.add;
ob.settings.port = Number(data.port) || 443;
ob.settings.id = data.id;
ob.settings.security = data.scy || USERS_SECURITY.AUTO;
ob.stream.network = data.net || 'tcp';
if (data.tls === 'tls') ob.stream.security = 'tls';
outbound.value = ob;
message.success(t('copySuccess'));
activeKey.value = '1';
} else {
message.warning('Only vmess:// links are supported by the quick converter for now — paste full JSON in the editor instead.');
}
} catch (e) {
message.error(`Link parse: ${e.message}`);
}
}
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Outbounds')}`
: `+ ${t('pages.xray.Outbounds')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
);
// Helper getters / shortcuts used by the template.
const proto = computed(() => outbound.value?.protocol);
const isVMess = computed(() => proto.value === Protocols.VMess);
const isVLESS = computed(() => proto.value === Protocols.VLESS);
const isVMessOrVLess = computed(() => isVMess.value || isVLESS.value);
const isTrojan = computed(() => proto.value === Protocols.Trojan);
const isShadowsocks = computed(() => proto.value === Protocols.Shadowsocks);
const isSocks = computed(() => proto.value === Protocols.Socks);
const isHTTP = computed(() => proto.value === Protocols.HTTP);
const isFreedom = computed(() => proto.value === Protocols.Freedom);
const isBlackhole = computed(() => proto.value === Protocols.Blackhole);
const isDNS = computed(() => proto.value === Protocols.DNS);
const isWireguard = computed(() => proto.value === Protocols.Wireguard);
const isHysteria = computed(() => proto.value === Protocols.Hysteria);
function regenerateWgKeys() {
if (!outbound.value?.settings) return;
const pair = Wireguard.generateKeypair();
outbound.value.settings.secretKey = pair.privateKey;
outbound.value.settings.pubKey = pair.publicKey;
}
</script>
<template>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="780px"
@ok="onOk" @cancel="close">
<a-tabs v-if="outbound" v-model:active-key="activeKey">
<!-- ============================== FORM ============================== -->
<a-tab-pane key="1" :tab="t('pages.xray.basicTemplate')">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<!-- Protocol -->
<a-form-item :label="t('protocol')">
<a-select :value="proto" @change="onProtocolChange">
<a-select-option v-for="p in PROTOCOL_OPTIONS" :key="p" :value="p">{{ p }}</a-select-option>
</a-select>
</a-form-item>
<!-- Tag -->
<a-form-item label="Tag" :validate-status="duplicateTag ? 'warning' : 'success'" has-feedback>
<a-input v-model:value="outbound.tag" placeholder="unique-tag" />
</a-form-item>
<!-- Send through -->
<a-form-item label="Send through">
<a-input v-model:value="outbound.sendThrough" placeholder="local IP" />
</a-form-item>
<!-- ============== Freedom ============== -->
<template v-if="isFreedom">
<a-form-item label="Strategy">
<a-select v-model:value="outbound.settings.domainStrategy">
<a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Redirect">
<a-input v-model:value="outbound.settings.redirect" />
</a-form-item>
<a-divider :style="{ margin: '4px 0' }">Fragment</a-divider>
<a-form-item label="Fragment">
<a-switch :checked="!!outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0"
@change="(checked) => outbound.settings.fragment = checked ? { packets: 'tlshello', length: '100-200', interval: '10-20', maxSplit: '300-400' } : {}" />
</a-form-item>
<template v-if="outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0">
<a-form-item label="Packets">
<a-select v-model:value="outbound.settings.fragment.packets">
<a-select-option v-for="p in ['1-3', 'tlshello']" :key="p" :value="p">{{ p }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Length">
<a-input v-model:value="outbound.settings.fragment.length" placeholder="100-200" />
</a-form-item>
<a-form-item label="Interval">
<a-input v-model:value="outbound.settings.fragment.interval" placeholder="10-20" />
</a-form-item>
<a-form-item label="Max Split">
<a-input v-model:value="outbound.settings.fragment.maxSplit" placeholder="300-400" />
</a-form-item>
</template>
<a-divider :style="{ margin: '4px 0' }">Noises</a-divider>
<a-form-item label="Noises">
<a-switch :checked="(outbound.settings.noises || []).length > 0"
@change="(checked) => outbound.settings.noises = checked ? [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }] : []" />
<a-button v-if="outbound.settings.noises && outbound.settings.noises.length > 0" size="small"
type="primary" class="ml-8"
@click="outbound.settings.noises.push({ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
<a-divider :style="{ margin: '4px 0' }">
Noise {{ index + 1 }}
<DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
@click="outbound.settings.noises.splice(index, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select v-model:value="noise.type">
<a-select-option v-for="x in ['rand', 'base64', 'str', 'hex']" :key="x" :value="x">{{ x
}}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
<a-input v-model:value="noise.packet" />
</a-form-item>
<a-form-item label="Delay (ms)">
<a-input v-model:value="noise.delay" />
</a-form-item>
<a-form-item label="Apply to">
<a-select v-model:value="noise.applyTo">
<a-select-option v-for="x in ['ip', 'ipv4', 'ipv6']" :key="x" :value="x">{{ x }}</a-select-option>
</a-select>
</a-form-item>
</template>
</template>
<!-- ============== Blackhole ============== -->
<template v-if="isBlackhole">
<a-form-item label="Response Type">
<a-select v-model:value="outbound.settings.type">
<a-select-option v-for="x in ['', 'none', 'http']" :key="x" :value="x">{{ x || '(empty)'
}}</a-select-option>
</a-select>
</a-form-item>
</template>
<!-- ============== DNS ============== -->
<template v-if="isDNS">
<a-form-item :label="t('pages.inbounds.network')">
<a-select v-model:value="outbound.settings.network">
<a-select-option v-for="x in ['udp', 'tcp']" :key="x" :value="x">{{ x }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Rules">
<a-button size="small" type="primary"
@click="outbound.settings.rules.push({ action: 'direct', qtype: '', domain: '' })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
<a-divider :style="{ margin: '4px 0' }">
Rule {{ index + 1 }}
<DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
</a-divider>
<a-form-item label="Action">
<a-select v-model:value="rule.action">
<a-select-option v-for="a in DNSRuleActions" :key="a" :value="a">{{ a }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="QType">
<a-input v-model:value="rule.qtype" placeholder="1,3,23-24" />
</a-form-item>
<a-form-item :label="t('domainName')">
<a-input v-model:value="rule.domain" placeholder="domain:example.com" />
</a-form-item>
</template>
</template>
<!-- ============== WireGuard ============== -->
<template v-if="isWireguard">
<a-form-item :label="t('pages.inbounds.address')">
<a-input v-model:value="outbound.settings.address" />
</a-form-item>
<a-form-item>
<template #label>
{{ t('pages.inbounds.privatekey') }}
<SyncOutlined class="random-icon" @click="regenerateWgKeys" />
</template>
<a-input v-model:value="outbound.settings.secretKey" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-input :value="outbound.settings.pubKey" disabled />
</a-form-item>
<a-form-item label="Domain strategy">
<a-select v-model:value="outbound.settings.domainStrategy">
<a-select-option v-for="x in ['', ...WireguardDomainStrategy]" :key="x || '__'" :value="x">
{{ x || `(${t('none')})` }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="MTU">
<a-input-number v-model:value="outbound.settings.mtu" :min="0" />
</a-form-item>
<a-form-item label="Workers">
<a-input-number v-model:value="outbound.settings.workers" :min="0" />
</a-form-item>
<a-form-item label="No-kernel TUN">
<a-switch v-model:checked="outbound.settings.noKernelTun" />
</a-form-item>
<a-form-item label="Reserved">
<a-input v-model:value="outbound.settings.reserved" />
</a-form-item>
<a-form-item label="Peers">
<a-button size="small" type="primary"
@click="outbound.settings.peers.push({ endpoint: '', publicKey: '', psk: '', allowedIPs: [''], keepAlive: 0 })">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
<a-divider :style="{ margin: '4px 0' }">
Peer {{ index + 1 }}
<DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
@click="outbound.settings.peers.splice(index, 1)" />
</a-divider>
<a-form-item label="Endpoint">
<a-input v-model:value="peer.endpoint" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-input v-model:value="peer.publicKey" />
</a-form-item>
<a-form-item label="PSK">
<a-input v-model:value="peer.psk" />
</a-form-item>
<a-form-item label="Allowed IPs">
<template v-for="(_, idx) in peer.allowedIPs" :key="idx">
<a-input v-model:value="peer.allowedIPs[idx]" :style="{ marginBottom: '4px' }">
<template v-if="peer.allowedIPs.length > 1" #addonAfter>
<MinusOutlined @click="peer.allowedIPs.splice(idx, 1)" />
</template>
</a-input>
</template>
<a-button size="small" @click="peer.allowedIPs.push('')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item label="Keep alive">
<a-input-number v-model:value="peer.keepAlive" :min="0" />
</a-form-item>
</template>
</template>
<!-- ============== Address + Port (most protocols) ============== -->
<template v-if="outbound.hasAddressPort()">
<a-form-item :label="t('pages.inbounds.address')">
<a-input v-model:value="outbound.settings.address" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.port')">
<a-input-number v-model:value="outbound.settings.port" :min="1" :max="65535" />
</a-form-item>
</template>
<!-- ============== VMess / VLess user ============== -->
<template v-if="isVMessOrVLess">
<a-form-item label="ID">
<a-input v-model:value="outbound.settings.id" />
</a-form-item>
<a-form-item v-if="isVMess" :label="t('security')">
<a-select v-model:value="outbound.settings.security">
<a-select-option v-for="s in SECURITY_OPTIONS" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="isVLESS" :label="t('encryption')">
<a-input v-model:value="outbound.settings.encryption" />
</a-form-item>
<a-form-item v-if="isVLESS" label="Reverse tag">
<a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
</a-form-item>
<a-form-item v-if="outbound.canEnableTlsFlow()" label="Flow">
<a-select v-model:value="outbound.settings.flow">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in FLOW_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
</template>
<!-- ============== Trojan / Shadowsocks ============== -->
<template v-if="isTrojan || isShadowsocks">
<a-form-item :label="t('password')">
<a-input v-model:value="outbound.settings.password" />
</a-form-item>
</template>
<template v-if="isShadowsocks">
<a-form-item :label="t('encryption')">
<a-select v-model:value="outbound.settings.method">
<a-select-option v-for="(m, k) in SSMethods" :key="m" :value="m">{{ k }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="UDP over TCP">
<a-switch v-model:checked="outbound.settings.uot" />
</a-form-item>
<a-form-item label="UoT version">
<a-input-number v-model:value="outbound.settings.UoTVersion" :min="1" :max="2" />
</a-form-item>
</template>
<!-- ============== SOCKS / HTTP ============== -->
<template v-if="outbound.hasUsername()">
<a-form-item :label="t('username')">
<a-input v-model:value="outbound.settings.user" />
</a-form-item>
<a-form-item :label="t('password')">
<a-input v-model:value="outbound.settings.pass" />
</a-form-item>
</template>
<!-- ============== Hysteria ============== -->
<template v-if="isHysteria">
<a-form-item label="Version">
<a-input-number :value="outbound.settings.version || 2" :min="2" :max="2" disabled />
</a-form-item>
</template>
<!-- ============== Stream settings ============== -->
<template v-if="outbound.canEnableStream()">
<a-divider :style="{ margin: '4px 0' }">{{ t('transmission') }}</a-divider>
<a-form-item :label="t('transmission')">
<a-select :value="outbound.stream.network" @change="streamNetworkChange">
<a-select-option v-for="net in (isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS)" :key="net"
:value="net">
{{ NETWORK_LABELS[net] || net }}
</a-select-option>
</a-select>
</a-form-item>
<!-- TCP -->
<template v-if="outbound.stream.network === 'tcp'">
<a-form-item :label="`HTTP ${t('camouflage')}`">
<a-switch :checked="outbound.stream.tcp.type === 'http'"
@change="(checked) => outbound.stream.tcp.type = checked ? 'http' : 'none'" />
</a-form-item>
<template v-if="outbound.stream.tcp.type === 'http'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.tcp.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.tcp.path" />
</a-form-item>
</template>
</template>
<!-- KCP -->
<template v-if="outbound.stream.network === 'kcp'">
<a-form-item label="MTU">
<a-input-number v-model:value="outbound.stream.kcp.mtu" :min="0" />
</a-form-item>
<a-form-item label="TTI (ms)">
<a-input-number v-model:value="outbound.stream.kcp.tti" :min="0" />
</a-form-item>
<a-form-item label="Uplink (MB/s)">
<a-input-number v-model:value="outbound.stream.kcp.upCap" :min="0" />
</a-form-item>
<a-form-item label="Downlink (MB/s)">
<a-input-number v-model:value="outbound.stream.kcp.downCap" :min="0" />
</a-form-item>
<a-form-item label="CWND multiplier">
<a-input-number v-model:value="outbound.stream.kcp.cwndMultiplier" :min="1" />
</a-form-item>
<a-form-item label="Max sending window">
<a-input-number v-model:value="outbound.stream.kcp.maxSendingWindow" :min="0" />
</a-form-item>
</template>
<!-- WebSocket -->
<template v-if="outbound.stream.network === 'ws'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.ws.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.ws.path" />
</a-form-item>
<a-form-item label="Heartbeat (s)">
<a-input-number v-model:value="outbound.stream.ws.heartbeatPeriod" :min="0" />
</a-form-item>
</template>
<!-- gRPC -->
<template v-if="outbound.stream.network === 'grpc'">
<a-form-item label="Service name">
<a-input v-model:value="outbound.stream.grpc.serviceName" />
</a-form-item>
<a-form-item label="Authority">
<a-input v-model:value="outbound.stream.grpc.authority" />
</a-form-item>
<a-form-item label="Multi mode">
<a-switch v-model:checked="outbound.stream.grpc.multiMode" />
</a-form-item>
</template>
<!-- HTTPUpgrade -->
<template v-if="outbound.stream.network === 'httpupgrade'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.httpupgrade.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.httpupgrade.path" />
</a-form-item>
</template>
<!-- XHTTP -->
<template v-if="outbound.stream.network === 'xhttp'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.xhttp.host" />
</a-form-item>
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.xhttp.path" />
</a-form-item>
<a-form-item label="Mode">
<a-select v-model:value="outbound.stream.xhttp.mode">
<a-select-option v-for="m in Object.values(MODE_OPTION)" :key="m" :value="m">{{ m }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Padding bytes">
<a-input v-model:value="outbound.stream.xhttp.xPaddingBytes" />
</a-form-item>
<a-form-item label="XMUX">
<a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
</a-form-item>
</template>
<!-- Hysteria transport -->
<template v-if="outbound.stream.network === 'hysteria'">
<a-form-item label="Auth password">
<a-input v-model:value="outbound.stream.hysteria.auth" />
</a-form-item>
<a-form-item label="Congestion">
<a-select v-model:value="outbound.stream.hysteria.congestion">
<a-select-option value="">BBR (auto)</a-select-option>
<a-select-option value="brutal">Brutal</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Upload">
<a-input v-model:value="outbound.stream.hysteria.up" placeholder="100 mbps" />
</a-form-item>
<a-form-item label="Download">
<a-input v-model:value="outbound.stream.hysteria.down" placeholder="100 mbps" />
</a-form-item>
<a-form-item label="UDP hop port">
<a-input v-model:value="outbound.stream.hysteria.udphopPort" placeholder="1145-1919" />
</a-form-item>
<a-form-item label="Max idle (s)">
<a-input-number v-model:value="outbound.stream.hysteria.maxIdleTimeout" :min="4" :max="120" />
</a-form-item>
<a-form-item label="Keep alive (s)">
<a-input-number v-model:value="outbound.stream.hysteria.keepAlivePeriod" :min="2" :max="60" />
</a-form-item>
<a-form-item label="Disable Path MTU">
<a-switch v-model:checked="outbound.stream.hysteria.disablePathMTUDiscovery" />
</a-form-item>
</template>
</template>
<!-- ============== TLS / Reality ============== -->
<template v-if="outbound.canEnableTls()">
<a-divider :style="{ margin: '4px 0' }">{{ t('security') }}</a-divider>
<a-form-item :label="t('security')">
<a-radio-group v-model:value="outbound.stream.security" button-style="solid">
<a-radio-button value="none">{{ t('none') }}</a-radio-button>
<a-radio-button value="tls">TLS</a-radio-button>
<a-radio-button v-if="outbound.canEnableReality()" value="reality">Reality</a-radio-button>
</a-radio-group>
</a-form-item>
<template v-if="outbound.stream.isTls">
<a-form-item label="SNI">
<a-input v-model:value="outbound.stream.tls.serverName" placeholder="server name" />
</a-form-item>
<a-form-item label="uTLS">
<a-select v-model:value="outbound.stream.tls.fingerprint">
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="ALPN">
<a-select v-model:value="outbound.stream.tls.alpn" mode="multiple">
<a-select-option v-for="alpn in ALPN_OPTIONS" :key="alpn" :value="alpn">{{ alpn }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="ECH">
<a-input v-model:value="outbound.stream.tls.echConfigList" />
</a-form-item>
<a-form-item label="Verify peer name">
<a-input v-model:value="outbound.stream.tls.verifyPeerCertByName" placeholder="cloudflare-dns.com" />
</a-form-item>
<a-form-item label="Pinned SHA256">
<a-input v-model:value="outbound.stream.tls.pinnedPeerCertSha256" placeholder="base64 SHA256" />
</a-form-item>
</template>
<template v-if="outbound.stream.isReality">
<a-form-item label="SNI">
<a-input v-model:value="outbound.stream.reality.serverName" />
</a-form-item>
<a-form-item label="uTLS">
<a-select v-model:value="outbound.stream.reality.fingerprint">
<a-select-option v-for="key in UTLS_OPTIONS" :key="key" :value="key">{{ key }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Short ID">
<a-input v-model:value="outbound.stream.reality.shortId" />
</a-form-item>
<a-form-item label="SpiderX">
<a-input v-model:value="outbound.stream.reality.spiderX" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.publicKey')">
<a-textarea v-model:value="outbound.stream.reality.publicKey" :auto-size="{ minRows: 2 }" />
</a-form-item>
<a-form-item label="mldsa65 verify">
<a-textarea v-model:value="outbound.stream.reality.mldsa65Verify" :auto-size="{ minRows: 2 }" />
</a-form-item>
</template>
</template>
<!-- ============== sockopt ============== -->
<template v-if="outbound.stream">
<a-divider :style="{ margin: '4px 0' }">Sockopts</a-divider>
<a-form-item label="Sockopts">
<a-switch v-model:checked="outbound.stream.sockoptSwitch" />
</a-form-item>
<template v-if="outbound.stream.sockoptSwitch">
<a-form-item label="Dialer proxy">
<a-input v-model:value="outbound.stream.sockopt.dialerProxy" />
</a-form-item>
<a-form-item label="Address+Port strategy">
<a-select v-model:value="outbound.stream.sockopt.addressPortStrategy">
<a-select-option v-for="key in Object.values(Address_Port_Strategy)" :key="key" :value="key">
{{ key }}
</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Keep alive interval">
<a-input-number v-model:value="outbound.stream.sockopt.tcpKeepAliveInterval" :min="0" />
</a-form-item>
<a-form-item label="TCP Fast Open">
<a-switch v-model:checked="outbound.stream.sockopt.tcpFastOpen" />
</a-form-item>
<a-form-item label="Multipath TCP">
<a-switch v-model:checked="outbound.stream.sockopt.tcpMptcp" />
</a-form-item>
<a-form-item label="Penetrate">
<a-switch v-model:checked="outbound.stream.sockopt.penetrate" />
</a-form-item>
</template>
</template>
<!-- ============== Mux ============== -->
<template v-if="outbound.canEnableMux()">
<a-divider :style="{ margin: '4px 0' }">{{ t('pages.settings.mux') }}</a-divider>
<a-form-item :label="t('pages.settings.mux')">
<a-switch v-model:checked="outbound.mux.enabled" />
</a-form-item>
<template v-if="outbound.mux.enabled">
<a-form-item label="Concurrency">
<a-input-number v-model:value="outbound.mux.concurrency" :min="-1" :max="1024" />
</a-form-item>
<a-form-item label="xudp concurrency">
<a-input-number v-model:value="outbound.mux.xudpConcurrency" :min="-1" :max="1024" />
</a-form-item>
<a-form-item label="xudp UDP 443">
<a-select v-model:value="outbound.mux.xudpProxyUDP443">
<a-select-option v-for="x in ['reject', 'allow', 'skip']" :key="x" :value="x">{{ x
}}</a-select-option>
</a-select>
</a-form-item>
</template>
</template>
</a-form>
</a-tab-pane>
<!-- ============================== JSON ============================== -->
<a-tab-pane key="2" tab="JSON">
<a-space direction="vertical" :size="10" :style="{ width: '100%', marginTop: '10px' }">
<a-input-search v-model:value="linkInput" placeholder="vmess:// vless:// trojan:// ss:// hysteria2://"
@search="convertLink">
<template #enterButton>
<a-button>Convert</a-button>
</template>
</a-input-search>
<a-textarea v-model:value="advancedJson" :auto-size="{ minRows: 14, maxRows: 30 }" spellcheck="false"
class="json-editor" />
</a-space>
</a-tab-pane>
</a-tabs>
</a-modal>
</template>
<style scoped>
.random-icon {
cursor: pointer;
color: var(--ant-primary-color, #1890ff);
margin-left: 4px;
}
.danger-icon {
cursor: pointer;
color: #ff4d4f;
margin-left: 8px;
}
.ml-8 {
margin-left: 8px;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
</style>