feat(frontend): xray tab fixes — modal close, tag validation, full XHTTP, reset to default

Modal close: BalancersTab / OutboundsTab / RoutingTab confirmDelete used
arrow expressions that returned splice's removed-items array. AD-Vue 4
treats truthy non-thenables from onOk as "still pending" and never closes
the dialog (see ActionButton.js:103-106), so the confirm modal stayed
open. Wrap the body so onOk returns undefined and AD-Vue auto-closes.

Tag validation: outbound + balancer modals only flipped between
warning/success on duplicate, leaving the empty case as a green ✓.
Split into a 3-state computed — error (empty) / warning (duplicate) /
success — and wire a help message so the input clearly explains why
the OK button is disabled.

Reset to default: re-add the legacy "Reset to Default" panel at the
bottom of BasicsTab. Calls /panel/setting/getDefaultJsonConfig and
overwrites templateSettings; the existing watch re-stringifies so the
JSON tab + dirty-poll see the new state.

Restored Basics option lists from main: IPs (4→10, +Vietnam/Spain/
Indonesia/Ukraine/Türkiye/Brazil), DomainsOptions (4→10, +regex
entries), BlockDomainsOptions (5→17, +Malware/Phishing/Adult/regex),
ServicesOptions (Reddit/Speedtest in, off-template Microsoft out).

Outbound form parity with main:
  • Reverse Sniffing UI for VLESS — toggle + destOverride checkboxes
    (HTTP/TLS/QUIC/FAKEDNS) + Metadata/Route Only + IPs/Domains
    excluded multi-selects, gated on reverseTag being set.
  • Full XHTTP transport — request headers list, Max Upload Size /
    Min Upload Interval (packet-up), Padding Obfs Mode + sub-fields,
    Uplink HTTP Method, Session/Sequence/UplinkData placement +
    keys, No gRPC Header (stream-up/stream-one), expanded XMUX with
    Max Concurrency/Connections/Reuse/Request/Reusable/Keep-alive.

Strip a-divider from the outbound form per request — replaced with
plain section/item heading divs so the labels and per-row delete
icons stay but the horizontal rule is gone.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 21:47:22 +02:00
parent 90792e0f43
commit 18434bdbbd
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
8 changed files with 356 additions and 44 deletions

View file

@ -52,11 +52,28 @@ watch(() => props.open, (next) => {
}
});
const tagEmpty = computed(() => !form.tag?.trim());
const duplicateTag = computed(
() => !form.tag || props.otherTags.includes(form.tag),
() => !!form.tag && props.otherTags.includes(form.tag.trim()),
);
const emptySelector = computed(() => form.selector.length === 0);
const isValid = computed(() => !duplicateTag.value && !emptySelector.value);
const isValid = computed(
() => !tagEmpty.value && !duplicateTag.value && !emptySelector.value,
);
const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning';
return 'success';
});
const tagHelp = computed(() => {
if (tagEmpty.value) return 'Tag is required';
if (duplicateTag.value) return 'Tag already used by another balancer';
return '';
});
const selectorValidateStatus = computed(() => (emptySelector.value ? 'error' : 'success'));
const selectorHelp = computed(() => (emptySelector.value ? 'Pick at least one outbound' : ''));
function close() { emit('update:open', false); }
function onOk() {
@ -78,7 +95,12 @@ const okText = computed(() =>
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')"
:ok-button-props="{ disabled: !isValid }" :mask-closable="false" @ok="onOk" @cancel="close">
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
<a-form-item label="Tag" :validate-status="duplicateTag ? 'warning' : 'success'" has-feedback>
<a-form-item
label="Tag"
:validate-status="tagValidateStatus"
:help="tagHelp"
has-feedback
>
<a-input v-model:value="form.tag" placeholder="unique balancer tag" />
</a-form-item>
@ -88,7 +110,12 @@ const okText = computed(() =>
</a-select>
</a-form-item>
<a-form-item label="Selector" :validate-status="emptySelector ? 'warning' : 'success'" has-feedback>
<a-form-item
label="Selector"
:validate-status="selectorValidateStatus"
:help="selectorHelp"
has-feedback
>
<a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
<a-select-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
</a-select>

View file

@ -118,7 +118,11 @@ function confirmDelete(idx) {
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => props.templateSettings.routing.balancers.splice(idx, 1),
// Wrap in a block so we discard splice's return value AD-Vue
// 4 leaves the modal open if onOk returns a truthy non-thenable
// (it expects a Promise to await), and splice() returns the array
// of removed items.
onOk: () => { props.templateSettings.routing.balancers.splice(idx, 1); },
});
}

View file

@ -2,6 +2,7 @@
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
import { Modal } from 'ant-design-vue';
import { OutboundDomainStrategies } from '@/models/outbound.js';
import SettingListItem from '@/components/SettingListItem.vue';
@ -25,7 +26,17 @@ const props = defineProps({
nordExist: { type: Boolean, default: false },
});
const emit = defineEmits(['update:outbound-test-url', 'show-warp', 'show-nord']);
const emit = defineEmits(['update:outbound-test-url', 'show-warp', 'show-nord', 'reset-default']);
function confirmResetDefault() {
Modal.confirm({
title: t('pages.settings.resetDefaultConfig'),
okText: t('reset'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => { emit('reset-default'); },
});
}
// === Static option lists (mirror legacy) =============================
const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
@ -35,35 +46,61 @@ const ERROR_LOG = ['none', './error.log'];
const MASK_ADDRESS = ['quarter', 'half', 'full'];
const BITTORRENT_PROTOCOLS = ['bittorrent'];
// Country lists copied from the legacy template's settingsData. Keep
// them alphabetised by ISO code so additions stay obvious in diffs.
// Country / service lists mirror the legacy panel's settingsData
// (web/html/xray.html on main). Keep additions in sync with that file
// so Vue 3 + legacy stay swappable while the migration finishes.
const IPS_OPTIONS = [
{ label: 'Private IPs', value: 'geoip:private' },
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
{ label: '🇨🇳 China', value: 'geoip:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
];
const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads (Iran)', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads', value: 'geosite:category-ads-all' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:category-ir' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:category-ru' },
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
{ label: '🇪🇸 Spain', value: 'geoip:es' },
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
];
const DOMAINS_OPTIONS = [
{ label: 'Private DNS', value: 'geosite:private' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:category-ir' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:category-ru' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const BLOCK_DOMAINS_OPTIONS = [
{ label: 'Ads All', value: 'geosite:category-ads-all' },
{ label: 'Ads IR 🇮🇷', value: 'ext:geosite_IR.dat:category-ads-all' },
{ label: 'Ads RU 🇷🇺', value: 'ext:geosite_RU.dat:category-ads-all' },
{ label: 'Malware 🇮🇷', value: 'ext:geosite_IR.dat:malware' },
{ label: 'Phishing 🇮🇷', value: 'ext:geosite_IR.dat:phishing' },
{ label: 'Cryptominers 🇮🇷', value: 'ext:geosite_IR.dat:cryptominers' },
{ label: 'Adult +18', value: 'geosite:category-porn' },
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
{ label: '🇨🇳 China', value: 'geosite:cn' },
{ label: '🇨🇳 .cn', value: 'regexp:.*\\.cn$' },
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:ru-available-only-inside' },
{ label: '🇷🇺 .ru', value: 'regexp:.*\\.ru$' },
{ label: '🇷🇺 .su', value: 'regexp:.*\\.su$' },
{ label: '🇷🇺 .рф', value: 'regexp:.*\\.xn--p1ai$' },
{ label: '🇻🇳 .vn', value: 'regexp:.*\\.vn$' },
];
const SERVICES_OPTIONS = [
{ label: 'Google', value: 'geosite:google' },
{ label: 'Apple', value: 'geosite:apple' },
{ label: 'Meta', value: 'geosite:meta' },
{ label: 'Microsoft', value: 'geosite:microsoft' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Google', value: 'geosite:google' },
{ label: 'OpenAI', value: 'geosite:openai' },
{ label: 'Spotify', value: 'geosite:spotify' },
{ label: 'Netflix', value: 'geosite:netflix' },
{ label: 'Reddit', value: 'geosite:reddit' },
{ label: 'Speedtest', value: 'geosite:speedtest' },
];
// === Routing-rule helpers (matches legacy templateRule{Getter,Setter}) ==
@ -441,6 +478,14 @@ const localOutboundTestUrl = computed({
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="reset" :header="t('pages.settings.resetDefaultConfig')">
<a-space direction="horizontal" :style="{ padding: '0 20px' }">
<a-button danger @click="confirmResetDefault">
{{ t('pages.settings.resetDefaultConfig') }}
</a-button>
</a-space>
</a-collapse-panel>
</a-collapse>
</template>

View file

@ -112,6 +112,20 @@ const duplicateTag = computed(() => {
return (props.existingTags || []).includes(myTag);
});
const tagEmpty = computed(() => !outbound.value?.tag?.trim());
const tagValidateStatus = computed(() => {
if (tagEmpty.value) return 'error';
if (duplicateTag.value) return 'warning';
return 'success';
});
const tagHelp = computed(() => {
if (tagEmpty.value) return 'Tag is required';
if (duplicateTag.value) return 'Tag already used by another outbound';
return '';
});
// ============== Submit ==============
function onOk() {
if (!outbound.value) return;
@ -215,7 +229,7 @@ function regenerateWgKeys() {
</a-form-item>
<!-- Tag -->
<a-form-item label="Tag" :validate-status="duplicateTag ? 'warning' : 'success'" has-feedback>
<a-form-item label="Tag" :validate-status="tagValidateStatus" :help="tagHelp" has-feedback>
<a-input v-model:value="outbound.tag" placeholder="unique-tag" />
</a-form-item>
@ -235,7 +249,6 @@ function regenerateWgKeys() {
<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' } : {}" />
@ -257,7 +270,6 @@ function regenerateWgKeys() {
</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' }] : []" />
@ -270,15 +282,15 @@ function regenerateWgKeys() {
</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 }}
<div class="item-heading">
<span>Noise {{ index + 1 }}</span>
<DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
@click="outbound.settings.noises.splice(index, 1)" />
</a-divider>
</div>
<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-option>
</a-select>
</a-form-item>
<a-form-item label="Packet">
@ -300,7 +312,7 @@ function regenerateWgKeys() {
<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-option>
</a-select>
</a-form-item>
</template>
@ -321,10 +333,10 @@ function regenerateWgKeys() {
</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 }}
<div class="item-heading">
<span>Rule {{ index + 1 }}</span>
<DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
</a-divider>
</div>
<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>
@ -382,11 +394,11 @@ function regenerateWgKeys() {
</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 }}
<div class="item-heading">
<span>Peer {{ index + 1 }}</span>
<DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
@click="outbound.settings.peers.splice(index, 1)" />
</a-divider>
</div>
<a-form-item label="Endpoint">
<a-input v-model:value="peer.endpoint" />
</a-form-item>
@ -442,6 +454,41 @@ function regenerateWgKeys() {
<a-form-item v-if="isVLESS" label="Reverse tag">
<a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
</a-form-item>
<!-- Reverse-Sniffing surfaced only when a reverse tag is set,
mirroring the legacy form. Defaults populated by the model
so the toggle/checkboxes always have a backing field. -->
<template v-if="isVLESS && outbound.settings.reverseTag">
<a-form-item label="Reverse Sniffing">
<a-switch v-model:checked="outbound.settings.reverseSniffing.enabled" />
</a-form-item>
<template v-if="outbound.settings.reverseSniffing.enabled">
<!-- Align the checkbox row with the input fields above
same span as wrapper-col (14), offset by label-col (8)
so the row starts where Reverse Tag's input starts. -->
<a-form-item :wrapper-col="{ md: { span: 14, offset: 8 } }">
<a-checkbox-group v-model:value="outbound.settings.reverseSniffing.destOverride"
class="sniffing-options">
<a-checkbox v-for="(value, label) in SNIFFING_OPTION" :key="value" :value="value">{{ label
}}</a-checkbox>
</a-checkbox-group>
</a-form-item>
<a-form-item label="Metadata Only">
<a-switch v-model:checked="outbound.settings.reverseSniffing.metadataOnly" />
</a-form-item>
<a-form-item label="Route Only">
<a-switch v-model:checked="outbound.settings.reverseSniffing.routeOnly" />
</a-form-item>
<a-form-item label="IPs Excluded">
<a-select v-model:value="outbound.settings.reverseSniffing.ipsExcluded" mode="tags"
:token-separators="[',']" placeholder="IP/CIDR/geoip:*/ext:*" :style="{ width: '100%' }" />
</a-form-item>
<a-form-item label="Domains Excluded">
<a-select v-model:value="outbound.settings.reverseSniffing.domainsExcluded" mode="tags"
:token-separators="[',']" placeholder="domain:*/ext:*" :style="{ width: '100%' }" />
</a-form-item>
</template>
</template>
<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>
@ -489,7 +536,6 @@ function regenerateWgKeys() {
<!-- ============== 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"
@ -573,7 +619,8 @@ function regenerateWgKeys() {
</a-form-item>
</template>
<!-- XHTTP -->
<!-- XHTTP full parity with legacy outbound form. The model
already carries every field below; we just surface them. -->
<template v-if="outbound.stream.network === 'xhttp'">
<a-form-item :label="t('host')">
<a-input v-model:value="outbound.stream.xhttp.host" />
@ -581,17 +628,160 @@ function regenerateWgKeys() {
<a-form-item :label="t('path')">
<a-input v-model:value="outbound.stream.xhttp.path" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.stream.tcp.requestHeader')">
<a-button size="small" @click="outbound.stream.xhttp.addHeader('', '')">
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<a-form-item :wrapper-col="{ span: 24 }">
<a-input-group v-for="(header, idx) in outbound.stream.xhttp.headers" :key="idx" compact class="mb-8">
<a-input v-model:value="header.name" :style="{ width: '45%' }" placeholder="Name">
<template #addonBefore>{{ idx + 1 }}</template>
</a-input>
<a-input v-model:value="header.value" :style="{ width: '45%' }" placeholder="Value" />
<a-button @click="outbound.stream.xhttp.removeHeader(idx)">
<template #icon>
<MinusOutlined />
</template>
</a-button>
</a-input-group>
</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-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Max Upload Size (Byte)">
<a-input v-model:value="outbound.stream.xhttp.scMaxEachPostBytes" />
</a-form-item>
<a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Min Upload Interval (Ms)">
<a-input v-model:value="outbound.stream.xhttp.scMinPostsIntervalMs" />
</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="Padding Obfs Mode">
<a-switch v-model:checked="outbound.stream.xhttp.xPaddingObfsMode" />
</a-form-item>
<template v-if="outbound.stream.xhttp.xPaddingObfsMode">
<a-form-item label="Padding Key">
<a-input v-model:value="outbound.stream.xhttp.xPaddingKey" placeholder="x_padding" />
</a-form-item>
<a-form-item label="Padding Header">
<a-input v-model:value="outbound.stream.xhttp.xPaddingHeader" placeholder="X-Padding" />
</a-form-item>
<a-form-item label="Padding Placement">
<a-select v-model:value="outbound.stream.xhttp.xPaddingPlacement">
<a-select-option value="">Default (queryInHeader)</a-select-option>
<a-select-option value="queryInHeader">queryInHeader</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Padding Method">
<a-select v-model:value="outbound.stream.xhttp.xPaddingMethod">
<a-select-option value="">Default (repeat-x)</a-select-option>
<a-select-option value="repeat-x">repeat-x</a-select-option>
<a-select-option value="tokenish">tokenish</a-select-option>
</a-select>
</a-form-item>
</template>
<a-form-item label="Uplink HTTP Method">
<a-select v-model:value="outbound.stream.xhttp.uplinkHTTPMethod">
<a-select-option value="">Default (POST)</a-select-option>
<a-select-option value="POST">POST</a-select-option>
<a-select-option value="PUT">PUT</a-select-option>
<a-select-option value="GET" :disabled="outbound.stream.xhttp.mode !== 'packet-up'">GET (packet-up
only)</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Session Placement">
<a-select v-model:value="outbound.stream.xhttp.sessionPlacement">
<a-select-option value="">Default (path)</a-select-option>
<a-select-option value="path">path</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item
v-if="outbound.stream.xhttp.sessionPlacement && outbound.stream.xhttp.sessionPlacement !== 'path'"
label="Session Key">
<a-input v-model:value="outbound.stream.xhttp.sessionKey" placeholder="x_session" />
</a-form-item>
<a-form-item label="Sequence Placement">
<a-select v-model:value="outbound.stream.xhttp.seqPlacement">
<a-select-option value="">Default (path)</a-select-option>
<a-select-option value="path">path</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="outbound.stream.xhttp.seqPlacement && outbound.stream.xhttp.seqPlacement !== 'path'"
label="Sequence Key">
<a-input v-model:value="outbound.stream.xhttp.seqKey" placeholder="x_seq" />
</a-form-item>
<a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'" label="Uplink Data Placement">
<a-select v-model:value="outbound.stream.xhttp.uplinkDataPlacement">
<a-select-option value="">Default (body)</a-select-option>
<a-select-option value="body">body</a-select-option>
<a-select-option value="header">header</a-select-option>
<a-select-option value="cookie">cookie</a-select-option>
<a-select-option value="query">query</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'
&& outbound.stream.xhttp.uplinkDataPlacement
&& outbound.stream.xhttp.uplinkDataPlacement !== 'body'" label="Uplink Data Key">
<a-input v-model:value="outbound.stream.xhttp.uplinkDataKey" placeholder="x_data" />
</a-form-item>
<a-form-item v-if="outbound.stream.xhttp.mode === 'packet-up'
&& outbound.stream.xhttp.uplinkDataPlacement
&& outbound.stream.xhttp.uplinkDataPlacement !== 'body'" label="Uplink Chunk Size">
<a-input-number v-model:value="outbound.stream.xhttp.uplinkChunkSize" :min="0"
placeholder="0 (unlimited)" />
</a-form-item>
<a-form-item
v-if="outbound.stream.xhttp.mode === 'stream-up' || outbound.stream.xhttp.mode === 'stream-one'"
label="No gRPC Header">
<a-switch v-model:checked="outbound.stream.xhttp.noGRPCHeader" />
</a-form-item>
<a-form-item label="XMUX">
<a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
</a-form-item>
<template v-if="outbound.stream.xhttp.enableXmux">
<a-form-item v-if="!outbound.stream.xhttp.xmux.maxConnections" label="Max Concurrency">
<a-input v-model:value="outbound.stream.xhttp.xmux.maxConcurrency" />
</a-form-item>
<a-form-item v-if="!outbound.stream.xhttp.xmux.maxConcurrency" label="Max Connections">
<a-input v-model:value="outbound.stream.xhttp.xmux.maxConnections" />
</a-form-item>
<a-form-item label="Max Reuse Times">
<a-input v-model:value="outbound.stream.xhttp.xmux.cMaxReuseTimes" />
</a-form-item>
<a-form-item label="Max Request Times">
<a-input v-model:value="outbound.stream.xhttp.xmux.hMaxRequestTimes" />
</a-form-item>
<a-form-item label="Max Reusable Secs">
<a-input v-model:value="outbound.stream.xhttp.xmux.hMaxReusableSecs" />
</a-form-item>
<a-form-item label="Keep Alive Period">
<a-input-number v-model:value="outbound.stream.xhttp.xmux.hKeepAlivePeriod" :min="0" />
</a-form-item>
</template>
</template>
<!-- Hysteria transport -->
@ -628,7 +818,6 @@ function regenerateWgKeys() {
<!-- ============== 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>
@ -689,7 +878,6 @@ function regenerateWgKeys() {
<!-- ============== 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>
@ -721,7 +909,6 @@ function regenerateWgKeys() {
<!-- ============== 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>
@ -735,7 +922,7 @@ function regenerateWgKeys() {
<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-option>
</a-select>
</a-form-item>
</template>
@ -780,8 +967,40 @@ function regenerateWgKeys() {
margin-left: 8px;
}
.mb-8 {
margin-bottom: 8px;
}
.section-heading {
font-weight: 500;
margin: 12px 0 6px;
opacity: 0.85;
}
.item-heading {
display: flex;
align-items: center;
gap: 8px;
font-weight: 500;
margin: 8px 0 4px;
opacity: 0.85;
}
.json-editor {
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
/* AD-Vue 4 renders a-checkbox children inside a-checkbox-group as
* inline-block, but inside a narrow form wrapper they can wrap
* inconsistently. Force a clean horizontal row with even gaps. */
.sniffing-options {
display: flex;
flex-wrap: wrap;
gap: 8px 16px;
}
.sniffing-options :deep(.ant-checkbox-wrapper) {
margin-inline-start: 0;
}
</style>

View file

@ -79,7 +79,7 @@ function confirmDelete(idx) {
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => props.templateSettings.outbounds.splice(idx, 1),
onOk: () => { props.templateSettings.outbounds.splice(idx, 1); },
});
}
function setFirst(idx) {

View file

@ -129,7 +129,7 @@ function confirmDelete(idx) {
okText: t('delete'),
okType: 'danger',
cancelText: t('cancel'),
onOk: () => props.templateSettings.routing.rules.splice(idx, 1),
onOk: () => { props.templateSettings.routing.rules.splice(idx, 1); },
});
}

View file

@ -44,6 +44,7 @@ const {
resetOutboundsTraffic,
testOutbound,
saveAll,
resetToDefault,
restartXray,
} = useXraySetting();
@ -211,6 +212,7 @@ function confirmRestart() {
@update:outbound-test-url="(v) => (outboundTestUrl = v)"
@show-warp="showWarp"
@show-nord="showNord"
@reset-default="resetToDefault"
/>
</a-tab-pane>

View file

@ -159,6 +159,20 @@ export function useXraySetting() {
return null;
}
async function resetToDefault() {
spinning.value = true;
try {
const msg = await HttpUtil.get('/panel/setting/getDefaultJsonConfig');
if (msg?.success) {
// Mutate templateSettings — the watch above re-stringifies into
// xraySetting so the Advanced JSON tab and dirty-poll see it.
templateSettings.value = JSON.parse(JSON.stringify(msg.obj));
}
} finally {
spinning.value = false;
}
}
async function restartXray() {
spinning.value = true;
try {
@ -218,6 +232,7 @@ export function useXraySetting() {
resetOutboundsTraffic,
testOutbound,
saveAll,
resetToDefault,
restartXray,
};
}