mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-06-06 21:24:10 +00:00
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:
parent
90792e0f43
commit
18434bdbbd
8 changed files with 356 additions and 44 deletions
|
|
@ -52,11 +52,28 @@ watch(() => props.open, (next) => {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const tagEmpty = computed(() => !form.tag?.trim());
|
||||||
const duplicateTag = computed(
|
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 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 close() { emit('update:open', false); }
|
||||||
function onOk() {
|
function onOk() {
|
||||||
|
|
@ -78,7 +95,12 @@ const okText = computed(() =>
|
||||||
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')"
|
<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">
|
: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 :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-input v-model:value="form.tag" placeholder="unique balancer tag" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
|
@ -88,7 +110,12 @@ const okText = computed(() =>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</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 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-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,11 @@ function confirmDelete(idx) {
|
||||||
okText: t('delete'),
|
okText: t('delete'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
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); },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
import { computed } from 'vue';
|
import { computed } from 'vue';
|
||||||
import { useI18n } from 'vue-i18n';
|
import { useI18n } from 'vue-i18n';
|
||||||
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
|
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
|
||||||
|
import { Modal } from 'ant-design-vue';
|
||||||
|
|
||||||
import { OutboundDomainStrategies } from '@/models/outbound.js';
|
import { OutboundDomainStrategies } from '@/models/outbound.js';
|
||||||
import SettingListItem from '@/components/SettingListItem.vue';
|
import SettingListItem from '@/components/SettingListItem.vue';
|
||||||
|
|
@ -25,7 +26,17 @@ const props = defineProps({
|
||||||
nordExist: { type: Boolean, default: false },
|
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) =============================
|
// === Static option lists (mirror legacy) =============================
|
||||||
const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
|
const ROUTING_DOMAIN_STRATEGIES = ['AsIs', 'IPIfNonMatch', 'IPOnDemand'];
|
||||||
|
|
@ -35,35 +46,61 @@ const ERROR_LOG = ['none', './error.log'];
|
||||||
const MASK_ADDRESS = ['quarter', 'half', 'full'];
|
const MASK_ADDRESS = ['quarter', 'half', 'full'];
|
||||||
const BITTORRENT_PROTOCOLS = ['bittorrent'];
|
const BITTORRENT_PROTOCOLS = ['bittorrent'];
|
||||||
|
|
||||||
// Country lists copied from the legacy template's settingsData. Keep
|
// Country / service lists mirror the legacy panel's settingsData
|
||||||
// them alphabetised by ISO code so additions stay obvious in diffs.
|
// (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 = [
|
const IPS_OPTIONS = [
|
||||||
{ label: 'Private IPs', value: 'geoip:private' },
|
{ label: 'Private IPs', value: 'geoip:private' },
|
||||||
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
|
{ label: '🇮🇷 Iran', value: 'ext:geoip_IR.dat:ir' },
|
||||||
{ label: '🇨🇳 China', value: 'geoip:cn' },
|
{ label: '🇨🇳 China', value: 'geoip:cn' },
|
||||||
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
|
{ label: '🇷🇺 Russia', value: 'ext:geoip_RU.dat:ru' },
|
||||||
];
|
{ label: '🇻🇳 Vietnam', value: 'geoip:vn' },
|
||||||
const BLOCK_DOMAINS_OPTIONS = [
|
{ label: '🇪🇸 Spain', value: 'geoip:es' },
|
||||||
{ label: 'Ads (Iran)', value: 'ext:geosite_IR.dat:category-ads-all' },
|
{ label: '🇮🇩 Indonesia', value: 'geoip:id' },
|
||||||
{ label: 'Ads', value: 'geosite:category-ads-all' },
|
{ label: '🇺🇦 Ukraine', value: 'geoip:ua' },
|
||||||
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:category-ir' },
|
{ label: '🇹🇷 Türkiye', value: 'geoip:tr' },
|
||||||
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
{ label: '🇧🇷 Brazil', value: 'geoip:br' },
|
||||||
{ label: '🇷🇺 Russia', value: 'ext:geosite_RU.dat:category-ru' },
|
|
||||||
];
|
];
|
||||||
const DOMAINS_OPTIONS = [
|
const DOMAINS_OPTIONS = [
|
||||||
{ label: 'Private DNS', value: 'geosite:private' },
|
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:ir' },
|
||||||
{ label: '🇮🇷 Iran', value: 'ext:geosite_IR.dat:category-ir' },
|
{ label: '🇮🇷 .ir', value: 'regexp:.*\\.ir$' },
|
||||||
|
{ label: '🇮🇷 .ایران', value: 'regexp:.*\\.xn--mgba3a4f16a$' },
|
||||||
{ label: '🇨🇳 China', value: 'geosite:cn' },
|
{ 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 = [
|
const SERVICES_OPTIONS = [
|
||||||
{ label: 'Google', value: 'geosite:google' },
|
|
||||||
{ label: 'Apple', value: 'geosite:apple' },
|
{ label: 'Apple', value: 'geosite:apple' },
|
||||||
{ label: 'Meta', value: 'geosite:meta' },
|
{ label: 'Meta', value: 'geosite:meta' },
|
||||||
{ label: 'Microsoft', value: 'geosite:microsoft' },
|
{ label: 'Google', value: 'geosite:google' },
|
||||||
{ label: 'Netflix', value: 'geosite:netflix' },
|
|
||||||
{ label: 'OpenAI', value: 'geosite:openai' },
|
{ label: 'OpenAI', value: 'geosite:openai' },
|
||||||
{ label: 'Spotify', value: 'geosite:spotify' },
|
{ 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}) ==
|
// === Routing-rule helpers (matches legacy templateRule{Getter,Setter}) ==
|
||||||
|
|
@ -441,6 +478,14 @@ const localOutboundTestUrl = computed({
|
||||||
</template>
|
</template>
|
||||||
</SettingListItem>
|
</SettingListItem>
|
||||||
</a-collapse-panel>
|
</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>
|
</a-collapse>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -112,6 +112,20 @@ const duplicateTag = computed(() => {
|
||||||
return (props.existingTags || []).includes(myTag);
|
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 ==============
|
// ============== Submit ==============
|
||||||
function onOk() {
|
function onOk() {
|
||||||
if (!outbound.value) return;
|
if (!outbound.value) return;
|
||||||
|
|
@ -215,7 +229,7 @@ function regenerateWgKeys() {
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<!-- Tag -->
|
<!-- 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-input v-model:value="outbound.tag" placeholder="unique-tag" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
|
|
@ -235,7 +249,6 @@ function regenerateWgKeys() {
|
||||||
<a-input v-model:value="outbound.settings.redirect" />
|
<a-input v-model:value="outbound.settings.redirect" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
||||||
<a-divider :style="{ margin: '4px 0' }">Fragment</a-divider>
|
|
||||||
<a-form-item label="Fragment">
|
<a-form-item label="Fragment">
|
||||||
<a-switch :checked="!!outbound.settings.fragment && Object.keys(outbound.settings.fragment).length > 0"
|
<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' } : {}" />
|
@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>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<a-divider :style="{ margin: '4px 0' }">Noises</a-divider>
|
|
||||||
<a-form-item label="Noises">
|
<a-form-item label="Noises">
|
||||||
<a-switch :checked="(outbound.settings.noises || []).length > 0"
|
<a-switch :checked="(outbound.settings.noises || []).length > 0"
|
||||||
@change="(checked) => outbound.settings.noises = checked ? [{ type: 'rand', packet: '10-20', delay: '10-16', applyTo: 'ip' }] : []" />
|
@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-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
|
<template v-for="(noise, index) in outbound.settings.noises || []" :key="index">
|
||||||
<a-divider :style="{ margin: '4px 0' }">
|
<div class="item-heading">
|
||||||
Noise {{ index + 1 }}
|
<span>Noise {{ index + 1 }}</span>
|
||||||
<DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
|
<DeleteOutlined v-if="outbound.settings.noises.length > 1" class="danger-icon"
|
||||||
@click="outbound.settings.noises.splice(index, 1)" />
|
@click="outbound.settings.noises.splice(index, 1)" />
|
||||||
</a-divider>
|
</div>
|
||||||
<a-form-item label="Type">
|
<a-form-item label="Type">
|
||||||
<a-select v-model:value="noise.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 v-for="x in ['rand', 'base64', 'str', 'hex']" :key="x" :value="x">{{ x
|
||||||
}}</a-select-option>
|
}}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<a-form-item label="Packet">
|
<a-form-item label="Packet">
|
||||||
|
|
@ -300,7 +312,7 @@ function regenerateWgKeys() {
|
||||||
<a-form-item label="Response Type">
|
<a-form-item label="Response Type">
|
||||||
<a-select v-model:value="outbound.settings.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 v-for="x in ['', 'none', 'http']" :key="x" :value="x">{{ x || '(empty)'
|
||||||
}}</a-select-option>
|
}}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -321,10 +333,10 @@ function regenerateWgKeys() {
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
|
<template v-for="(rule, index) in outbound.settings.rules || []" :key="index">
|
||||||
<a-divider :style="{ margin: '4px 0' }">
|
<div class="item-heading">
|
||||||
Rule {{ index + 1 }}
|
<span>Rule {{ index + 1 }}</span>
|
||||||
<DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
|
<DeleteOutlined class="danger-icon" @click="outbound.settings.rules.splice(index, 1)" />
|
||||||
</a-divider>
|
</div>
|
||||||
<a-form-item label="Action">
|
<a-form-item label="Action">
|
||||||
<a-select v-model:value="rule.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-option v-for="a in DNSRuleActions" :key="a" :value="a">{{ a }}</a-select-option>
|
||||||
|
|
@ -382,11 +394,11 @@ function regenerateWgKeys() {
|
||||||
</a-button>
|
</a-button>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
<template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
|
<template v-for="(peer, index) in outbound.settings.peers || []" :key="index">
|
||||||
<a-divider :style="{ margin: '4px 0' }">
|
<div class="item-heading">
|
||||||
Peer {{ index + 1 }}
|
<span>Peer {{ index + 1 }}</span>
|
||||||
<DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
|
<DeleteOutlined v-if="outbound.settings.peers.length > 1" class="danger-icon"
|
||||||
@click="outbound.settings.peers.splice(index, 1)" />
|
@click="outbound.settings.peers.splice(index, 1)" />
|
||||||
</a-divider>
|
</div>
|
||||||
<a-form-item label="Endpoint">
|
<a-form-item label="Endpoint">
|
||||||
<a-input v-model:value="peer.endpoint" />
|
<a-input v-model:value="peer.endpoint" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -442,6 +454,41 @@ function regenerateWgKeys() {
|
||||||
<a-form-item v-if="isVLESS" label="Reverse tag">
|
<a-form-item v-if="isVLESS" label="Reverse tag">
|
||||||
<a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
|
<a-input v-model:value="outbound.settings.reverseTag" placeholder="optional" />
|
||||||
</a-form-item>
|
</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-form-item v-if="outbound.canEnableTlsFlow()" label="Flow">
|
||||||
<a-select v-model:value="outbound.settings.flow">
|
<a-select v-model:value="outbound.settings.flow">
|
||||||
<a-select-option value="">{{ t('none') }}</a-select-option>
|
<a-select-option value="">{{ t('none') }}</a-select-option>
|
||||||
|
|
@ -489,7 +536,6 @@ function regenerateWgKeys() {
|
||||||
|
|
||||||
<!-- ============== Stream settings ============== -->
|
<!-- ============== Stream settings ============== -->
|
||||||
<template v-if="outbound.canEnableStream()">
|
<template v-if="outbound.canEnableStream()">
|
||||||
<a-divider :style="{ margin: '4px 0' }">{{ t('transmission') }}</a-divider>
|
|
||||||
<a-form-item :label="t('transmission')">
|
<a-form-item :label="t('transmission')">
|
||||||
<a-select :value="outbound.stream.network" @change="streamNetworkChange">
|
<a-select :value="outbound.stream.network" @change="streamNetworkChange">
|
||||||
<a-select-option v-for="net in (isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS)" :key="net"
|
<a-select-option v-for="net in (isHysteria ? [...NETWORKS, 'hysteria'] : NETWORKS)" :key="net"
|
||||||
|
|
@ -573,7 +619,8 @@ function regenerateWgKeys() {
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</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'">
|
<template v-if="outbound.stream.network === 'xhttp'">
|
||||||
<a-form-item :label="t('host')">
|
<a-form-item :label="t('host')">
|
||||||
<a-input v-model:value="outbound.stream.xhttp.host" />
|
<a-input v-model:value="outbound.stream.xhttp.host" />
|
||||||
|
|
@ -581,17 +628,160 @@ function regenerateWgKeys() {
|
||||||
<a-form-item :label="t('path')">
|
<a-form-item :label="t('path')">
|
||||||
<a-input v-model:value="outbound.stream.xhttp.path" />
|
<a-input v-model:value="outbound.stream.xhttp.path" />
|
||||||
</a-form-item>
|
</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-form-item label="Mode">
|
||||||
<a-select v-model:value="outbound.stream.xhttp.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-option v-for="m in Object.values(MODE_OPTION)" :key="m" :value="m">{{ m }}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</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-input v-model:value="outbound.stream.xhttp.xPaddingBytes" />
|
||||||
</a-form-item>
|
</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-form-item label="XMUX">
|
||||||
<a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
|
<a-switch v-model:checked="outbound.stream.xhttp.enableXmux" />
|
||||||
</a-form-item>
|
</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>
|
</template>
|
||||||
|
|
||||||
<!-- Hysteria transport -->
|
<!-- Hysteria transport -->
|
||||||
|
|
@ -628,7 +818,6 @@ function regenerateWgKeys() {
|
||||||
|
|
||||||
<!-- ============== TLS / Reality ============== -->
|
<!-- ============== TLS / Reality ============== -->
|
||||||
<template v-if="outbound.canEnableTls()">
|
<template v-if="outbound.canEnableTls()">
|
||||||
<a-divider :style="{ margin: '4px 0' }">{{ t('security') }}</a-divider>
|
|
||||||
<a-form-item :label="t('security')">
|
<a-form-item :label="t('security')">
|
||||||
<a-radio-group v-model:value="outbound.stream.security" button-style="solid">
|
<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="none">{{ t('none') }}</a-radio-button>
|
||||||
|
|
@ -689,7 +878,6 @@ function regenerateWgKeys() {
|
||||||
|
|
||||||
<!-- ============== sockopt ============== -->
|
<!-- ============== sockopt ============== -->
|
||||||
<template v-if="outbound.stream">
|
<template v-if="outbound.stream">
|
||||||
<a-divider :style="{ margin: '4px 0' }">Sockopts</a-divider>
|
|
||||||
<a-form-item label="Sockopts">
|
<a-form-item label="Sockopts">
|
||||||
<a-switch v-model:checked="outbound.stream.sockoptSwitch" />
|
<a-switch v-model:checked="outbound.stream.sockoptSwitch" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -721,7 +909,6 @@ function regenerateWgKeys() {
|
||||||
|
|
||||||
<!-- ============== Mux ============== -->
|
<!-- ============== Mux ============== -->
|
||||||
<template v-if="outbound.canEnableMux()">
|
<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-form-item :label="t('pages.settings.mux')">
|
||||||
<a-switch v-model:checked="outbound.mux.enabled" />
|
<a-switch v-model:checked="outbound.mux.enabled" />
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
|
|
@ -735,7 +922,7 @@ function regenerateWgKeys() {
|
||||||
<a-form-item label="xudp UDP 443">
|
<a-form-item label="xudp UDP 443">
|
||||||
<a-select v-model:value="outbound.mux.xudpProxyUDP443">
|
<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 v-for="x in ['reject', 'allow', 'skip']" :key="x" :value="x">{{ x
|
||||||
}}</a-select-option>
|
}}</a-select-option>
|
||||||
</a-select>
|
</a-select>
|
||||||
</a-form-item>
|
</a-form-item>
|
||||||
</template>
|
</template>
|
||||||
|
|
@ -780,8 +967,40 @@ function regenerateWgKeys() {
|
||||||
margin-left: 8px;
|
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 {
|
.json-editor {
|
||||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||||
font-size: 12px;
|
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>
|
</style>
|
||||||
|
|
|
||||||
|
|
@ -79,7 +79,7 @@ function confirmDelete(idx) {
|
||||||
okText: t('delete'),
|
okText: t('delete'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: () => props.templateSettings.outbounds.splice(idx, 1),
|
onOk: () => { props.templateSettings.outbounds.splice(idx, 1); },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
function setFirst(idx) {
|
function setFirst(idx) {
|
||||||
|
|
|
||||||
|
|
@ -129,7 +129,7 @@ function confirmDelete(idx) {
|
||||||
okText: t('delete'),
|
okText: t('delete'),
|
||||||
okType: 'danger',
|
okType: 'danger',
|
||||||
cancelText: t('cancel'),
|
cancelText: t('cancel'),
|
||||||
onOk: () => props.templateSettings.routing.rules.splice(idx, 1),
|
onOk: () => { props.templateSettings.routing.rules.splice(idx, 1); },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@ const {
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
saveAll,
|
saveAll,
|
||||||
|
resetToDefault,
|
||||||
restartXray,
|
restartXray,
|
||||||
} = useXraySetting();
|
} = useXraySetting();
|
||||||
|
|
||||||
|
|
@ -211,6 +212,7 @@ function confirmRestart() {
|
||||||
@update:outbound-test-url="(v) => (outboundTestUrl = v)"
|
@update:outbound-test-url="(v) => (outboundTestUrl = v)"
|
||||||
@show-warp="showWarp"
|
@show-warp="showWarp"
|
||||||
@show-nord="showNord"
|
@show-nord="showNord"
|
||||||
|
@reset-default="resetToDefault"
|
||||||
/>
|
/>
|
||||||
</a-tab-pane>
|
</a-tab-pane>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,20 @@ export function useXraySetting() {
|
||||||
return null;
|
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() {
|
async function restartXray() {
|
||||||
spinning.value = true;
|
spinning.value = true;
|
||||||
try {
|
try {
|
||||||
|
|
@ -218,6 +232,7 @@ export function useXraySetting() {
|
||||||
resetOutboundsTraffic,
|
resetOutboundsTraffic,
|
||||||
testOutbound,
|
testOutbound,
|
||||||
saveAll,
|
saveAll,
|
||||||
|
resetToDefault,
|
||||||
restartXray,
|
restartXray,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue