From 18434bdbbd39311bdefd16ba4318f865eaa512c3 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 8 May 2026 21:47:22 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20xray=20tab=20fixes=20?= =?UTF-8?q?=E2=80=94=20modal=20close,=20tag=20validation,=20full=20XHTTP,?= =?UTF-8?q?=20reset=20to=20default?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- frontend/src/pages/xray/BalancerFormModal.vue | 35 ++- frontend/src/pages/xray/BalancersTab.vue | 6 +- frontend/src/pages/xray/BasicsTab.vue | 77 ++++-- frontend/src/pages/xray/OutboundFormModal.vue | 261 ++++++++++++++++-- frontend/src/pages/xray/OutboundsTab.vue | 2 +- frontend/src/pages/xray/RoutingTab.vue | 2 +- frontend/src/pages/xray/XrayPage.vue | 2 + frontend/src/pages/xray/useXraySetting.js | 15 + 8 files changed, 356 insertions(+), 44 deletions(-) diff --git a/frontend/src/pages/xray/BalancerFormModal.vue b/frontend/src/pages/xray/BalancerFormModal.vue index 574bee3f..07c6d780 100644 --- a/frontend/src/pages/xray/BalancerFormModal.vue +++ b/frontend/src/pages/xray/BalancerFormModal.vue @@ -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(() => - + @@ -88,7 +110,12 @@ const okText = computed(() => - + {{ tag }} diff --git a/frontend/src/pages/xray/BalancersTab.vue b/frontend/src/pages/xray/BalancersTab.vue index d9ba980d..dc0145fa 100644 --- a/frontend/src/pages/xray/BalancersTab.vue +++ b/frontend/src/pages/xray/BalancersTab.vue @@ -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); }, }); } diff --git a/frontend/src/pages/xray/BasicsTab.vue b/frontend/src/pages/xray/BasicsTab.vue index 2c5c6f7c..196dcb06 100644 --- a/frontend/src/pages/xray/BasicsTab.vue +++ b/frontend/src/pages/xray/BasicsTab.vue @@ -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({ + + + + + {{ t('pages.settings.resetDefaultConfig') }} + + + diff --git a/frontend/src/pages/xray/OutboundFormModal.vue b/frontend/src/pages/xray/OutboundFormModal.vue index 080b9400..2e338d4f 100644 --- a/frontend/src/pages/xray/OutboundFormModal.vue +++ b/frontend/src/pages/xray/OutboundFormModal.vue @@ -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() { - + @@ -235,7 +249,6 @@ function regenerateWgKeys() { - Fragment @@ -257,7 +270,6 @@ function regenerateWgKeys() { - Noises @@ -270,15 +282,15 @@ function regenerateWgKeys() { @@ -321,10 +333,10 @@ function regenerateWgKeys() {