mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-09-02 00:17:13 +00:00
feat(xray/dns): align DNS settings with Xray docs + UI polish
- DNS server modal: rename expectIPs -> expectedIPs (per docs); add per-server tag, clientIP, serveStale, serveExpiredTTL, timeoutMs; flip skipFallback default to false; hydration still accepts legacy expectIPs for back-compat. - DNS tab: add hosts editor (domain -> IP/array), serveStale + serveExpiredTTL controls, "Use Preset" button bringing back the legacy preset gallery (Google / Cloudflare / AdGuard + Family variants — fixed AdGuard Family IPs that were wrong in legacy), and a "Delete All" button to wipe the server list at once. - i18n: add 15 new dns.* keys across all 13 locales. - Frontend-wide formatter pass on Vue components (whitespace and attribute layout only, no behavior changes). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -95,12 +95,7 @@ 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="tagValidateStatus"
|
||||
:help="tagHelp"
|
||||
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>
|
||||
|
||||
@@ -110,12 +105,7 @@ const okText = computed(() =>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item
|
||||
label="Selector"
|
||||
:validate-status="selectorValidateStatus"
|
||||
:help="selectorHelp"
|
||||
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>
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
});
|
||||
|
||||
const emit = defineEmits(['update:open', 'install']);
|
||||
|
||||
const PRESETS = [
|
||||
{
|
||||
name: 'Google DNS',
|
||||
family: false,
|
||||
data: [
|
||||
'8.8.8.8',
|
||||
'8.8.4.4',
|
||||
'2001:4860:4860::8888',
|
||||
'2001:4860:4860::8844',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cloudflare DNS',
|
||||
family: false,
|
||||
data: [
|
||||
'1.1.1.1',
|
||||
'1.0.0.1',
|
||||
'2606:4700:4700::1111',
|
||||
'2606:4700:4700::1001',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AdGuard DNS',
|
||||
family: false,
|
||||
data: [
|
||||
'94.140.14.14',
|
||||
'94.140.15.15',
|
||||
'2a10:50c0::ad1:ff',
|
||||
'2a10:50c0::ad2:ff',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'AdGuard Family DNS',
|
||||
family: true,
|
||||
data: [
|
||||
'94.140.14.15',
|
||||
'94.140.15.16',
|
||||
'2a10:50c0::bad1:ff',
|
||||
'2a10:50c0::bad2:ff',
|
||||
],
|
||||
},
|
||||
{
|
||||
name: 'Cloudflare Family DNS',
|
||||
family: true,
|
||||
data: [
|
||||
'1.1.1.3',
|
||||
'1.0.0.3',
|
||||
'2606:4700:4700::1113',
|
||||
'2606:4700:4700::1003',
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
const title = computed(() => t('pages.xray.dns.dnsPresetTitle'));
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
function install(preset) {
|
||||
emit('install', [...preset.data]);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal :open="open" :title="title" :footer="null" :mask-closable="false" @cancel="close">
|
||||
<a-list bordered>
|
||||
<a-list-item v-for="preset in PRESETS" :key="preset.name" class="preset-row">
|
||||
<a-space size="small" align="center">
|
||||
<a-tag :color="preset.family ? 'purple' : 'green'">
|
||||
{{ preset.family ? t('pages.xray.dns.dnsPresetFamily') : 'DNS' }}
|
||||
</a-tag>
|
||||
<span class="preset-name">{{ preset.name }}</span>
|
||||
</a-space>
|
||||
<a-button type="primary" size="small" @click="install(preset)">
|
||||
{{ t('install') }}
|
||||
</a-button>
|
||||
</a-list-item>
|
||||
</a-list>
|
||||
</a-modal>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.preset-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.preset-name {
|
||||
font-weight: 500;
|
||||
}
|
||||
</style>
|
||||
@@ -5,11 +5,6 @@ import { PlusOutlined, MinusOutlined } from '@ant-design/icons-vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// DNS server add/edit modal — mirrors web/html/modals/xray_dns_modal.html.
|
||||
// The legacy panel allowed both string-form ("8.8.8.8") and object-form
|
||||
// servers; we always edit as an object and the parent can decide
|
||||
// whether to collapse to a string when nothing besides address is set.
|
||||
|
||||
const props = defineProps({
|
||||
open: { type: Boolean, default: false },
|
||||
server: { type: [Object, String, null], default: null },
|
||||
@@ -22,12 +17,17 @@ const DEFAULT_SERVER = () => ({
|
||||
address: 'localhost',
|
||||
port: 53,
|
||||
domains: [],
|
||||
expectIPs: [],
|
||||
expectedIPs: [],
|
||||
unexpectedIPs: [],
|
||||
queryStrategy: 'UseIP',
|
||||
skipFallback: true,
|
||||
skipFallback: false,
|
||||
disableCache: false,
|
||||
finalQuery: false,
|
||||
tag: '',
|
||||
clientIP: '',
|
||||
serveStale: false,
|
||||
serveExpiredTTL: 0,
|
||||
timeoutMs: 4000,
|
||||
});
|
||||
|
||||
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
|
||||
@@ -42,45 +42,53 @@ watch(() => props.open, (next) => {
|
||||
form.address = props.server;
|
||||
return;
|
||||
}
|
||||
// Object — copy fields, defaulting missing arrays to empty.
|
||||
const incoming = props.server;
|
||||
Object.assign(form, {
|
||||
...DEFAULT_SERVER(),
|
||||
...props.server,
|
||||
domains: [...(props.server.domains || [])],
|
||||
expectIPs: [...(props.server.expectIPs || [])],
|
||||
unexpectedIPs: [...(props.server.unexpectedIPs || [])],
|
||||
...incoming,
|
||||
domains: [...(incoming.domains || [])],
|
||||
expectedIPs: [...(incoming.expectedIPs || incoming.expectIPs || [])],
|
||||
unexpectedIPs: [...(incoming.unexpectedIPs || [])],
|
||||
});
|
||||
});
|
||||
|
||||
function close() { emit('update:open', false); }
|
||||
|
||||
function onOk() {
|
||||
// If the user only set an address (everything else default), emit a
|
||||
// bare string — that's the wire shape the legacy panel uses for
|
||||
// servers like "8.8.8.8" and keeps the JSON tidy.
|
||||
const isPlain = form.domains.length === 0
|
||||
&& form.expectIPs.length === 0
|
||||
&& form.expectedIPs.length === 0
|
||||
&& form.unexpectedIPs.length === 0
|
||||
&& form.port === 53
|
||||
&& form.queryStrategy === 'UseIP'
|
||||
&& form.skipFallback === true
|
||||
&& form.skipFallback === false
|
||||
&& form.disableCache === false
|
||||
&& form.finalQuery === false;
|
||||
&& form.finalQuery === false
|
||||
&& !form.tag
|
||||
&& !form.clientIP
|
||||
&& form.serveStale === false
|
||||
&& form.serveExpiredTTL === 0
|
||||
&& form.timeoutMs === 4000;
|
||||
if (isPlain) {
|
||||
emit('confirm', form.address);
|
||||
} else {
|
||||
emit('confirm', {
|
||||
address: form.address,
|
||||
port: form.port,
|
||||
domains: [...form.domains].filter(Boolean),
|
||||
expectIPs: [...form.expectIPs].filter(Boolean),
|
||||
unexpectedIPs: [...form.unexpectedIPs].filter(Boolean),
|
||||
queryStrategy: form.queryStrategy,
|
||||
skipFallback: form.skipFallback,
|
||||
disableCache: form.disableCache,
|
||||
finalQuery: form.finalQuery,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const out = {
|
||||
address: form.address,
|
||||
port: form.port,
|
||||
domains: [...form.domains].filter(Boolean),
|
||||
expectedIPs: [...form.expectedIPs].filter(Boolean),
|
||||
unexpectedIPs: [...form.unexpectedIPs].filter(Boolean),
|
||||
queryStrategy: form.queryStrategy,
|
||||
skipFallback: form.skipFallback,
|
||||
disableCache: form.disableCache,
|
||||
finalQuery: form.finalQuery,
|
||||
serveStale: form.serveStale,
|
||||
serveExpiredTTL: form.serveExpiredTTL,
|
||||
timeoutMs: form.timeoutMs,
|
||||
};
|
||||
if (form.tag) out.tag = form.tag;
|
||||
if (form.clientIP) out.clientIP = form.clientIP;
|
||||
emit('confirm', out);
|
||||
}
|
||||
|
||||
const title = computed(() =>
|
||||
@@ -89,15 +97,8 @@ const title = computed(() =>
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:ok-text="t('confirm')"
|
||||
:cancel-text="t('close')"
|
||||
:mask-closable="false"
|
||||
@ok="onOk"
|
||||
@cancel="close"
|
||||
>
|
||||
<a-modal :open="open" :title="title" :ok-text="t('confirm')" :cancel-text="t('close')" :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="t('pages.inbounds.address')">
|
||||
<a-input v-model:value="form.address" />
|
||||
@@ -105,17 +106,28 @@ const title = computed(() =>
|
||||
<a-form-item :label="t('pages.inbounds.port')">
|
||||
<a-input-number v-model:value="form.port" :min="1" :max="65535" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.tag')">
|
||||
<a-input v-model:value="form.tag" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.clientIp')">
|
||||
<a-input v-model:value="form.clientIP" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.strategy')">
|
||||
<a-select v-model:value="form.queryStrategy" :style="{ width: '100%' }">
|
||||
<a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.timeoutMs')">
|
||||
<a-input-number v-model:value="form.timeoutMs" :min="0" :step="500" />
|
||||
</a-form-item>
|
||||
|
||||
<a-divider :style="{ margin: '5px 0' }" />
|
||||
|
||||
<a-form-item :label="t('pages.xray.dns.domains')">
|
||||
<a-button size="small" type="primary" @click="form.domains.push('')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template v-for="(_, idx) in form.domains" :key="`d${idx}`">
|
||||
<a-input v-model:value="form.domains[idx]" :style="{ marginTop: '4px' }">
|
||||
@@ -127,13 +139,15 @@ const title = computed(() =>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item :label="t('pages.xray.dns.expectIPs')">
|
||||
<a-button size="small" type="primary" @click="form.expectIPs.push('')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<a-button size="small" type="primary" @click="form.expectedIPs.push('')">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template v-for="(_, idx) in form.expectIPs" :key="`e${idx}`">
|
||||
<a-input v-model:value="form.expectIPs[idx]" :style="{ marginTop: '4px' }">
|
||||
<template v-for="(_, idx) in form.expectedIPs" :key="`e${idx}`">
|
||||
<a-input v-model:value="form.expectedIPs[idx]" :style="{ marginTop: '4px' }">
|
||||
<template #addonAfter>
|
||||
<MinusOutlined @click="form.expectIPs.splice(idx, 1)" />
|
||||
<MinusOutlined @click="form.expectedIPs.splice(idx, 1)" />
|
||||
</template>
|
||||
</a-input>
|
||||
</template>
|
||||
@@ -141,7 +155,9 @@ const title = computed(() =>
|
||||
|
||||
<a-form-item :label="t('pages.xray.dns.unexpectIPs')">
|
||||
<a-button size="small" type="primary" @click="form.unexpectedIPs.push('')">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
<template v-for="(_, idx) in form.unexpectedIPs" :key="`u${idx}`">
|
||||
<a-input v-model:value="form.unexpectedIPs[idx]" :style="{ marginTop: '4px' }">
|
||||
@@ -154,14 +170,20 @@ const title = computed(() =>
|
||||
|
||||
<a-divider :style="{ margin: '5px 0' }" />
|
||||
|
||||
<a-form-item label="Skip fallback">
|
||||
<a-form-item :label="t('pages.xray.dns.skipFallback')">
|
||||
<a-switch v-model:checked="form.skipFallback" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.finalQuery')">
|
||||
<a-switch v-model:checked="form.finalQuery" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.disableCache')">
|
||||
<a-switch v-model:checked="form.disableCache" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Final query">
|
||||
<a-switch v-model:checked="form.finalQuery" />
|
||||
<a-form-item :label="t('pages.xray.dns.serveStale')">
|
||||
<a-switch v-model:checked="form.serveStale" />
|
||||
</a-form-item>
|
||||
<a-form-item :label="t('pages.xray.dns.serveExpiredTTL')">
|
||||
<a-input-number v-model:value="form.serveExpiredTTL" :min="0" :step="60" />
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-modal>
|
||||
|
||||
@@ -1,31 +1,27 @@
|
||||
<script setup>
|
||||
import { computed, ref } from 'vue';
|
||||
import { computed, ref, watch } from 'vue';
|
||||
import { useI18n } from 'vue-i18n';
|
||||
import { Modal } from 'ant-design-vue';
|
||||
import {
|
||||
PlusOutlined,
|
||||
MoreOutlined,
|
||||
EditOutlined,
|
||||
DeleteOutlined,
|
||||
MenuOutlined,
|
||||
} from '@ant-design/icons-vue';
|
||||
|
||||
import SettingListItem from '@/components/SettingListItem.vue';
|
||||
import DnsServerModal from './DnsServerModal.vue';
|
||||
import DnsPresetsModal from './DnsPresetsModal.vue';
|
||||
|
||||
const { t } = useI18n();
|
||||
|
||||
// Structured DNS editor — mirrors web/html/settings/xray/dns.html.
|
||||
// Master enable switch + general DNS options + per-server table with
|
||||
// add/edit/delete (modal flow), plus a Fake DNS table. Both lists
|
||||
// flow through templateSettings.dns / .fakedns reactively so the
|
||||
// useXraySetting composable picks every edit up via its deep watch.
|
||||
|
||||
const props = defineProps({
|
||||
templateSettings: { type: Object, default: null },
|
||||
});
|
||||
|
||||
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
|
||||
|
||||
// ============== Master toggle ==============
|
||||
const enableDNS = computed({
|
||||
get: () => !!props.templateSettings?.dns,
|
||||
set: (next) => {
|
||||
@@ -40,6 +36,9 @@ const enableDNS = computed({
|
||||
disableFallbackIfMatch: false,
|
||||
useSystemHosts: false,
|
||||
enableParallelQuery: false,
|
||||
serveStale: false,
|
||||
serveExpiredTTL: 0,
|
||||
hosts: {},
|
||||
servers: [],
|
||||
};
|
||||
props.templateSettings.fakedns = null;
|
||||
@@ -50,7 +49,6 @@ const enableDNS = computed({
|
||||
},
|
||||
});
|
||||
|
||||
// ============== Field bridges ==============
|
||||
function dnsField(field, fallback) {
|
||||
return computed({
|
||||
get: () => props.templateSettings?.dns?.[field] ?? fallback,
|
||||
@@ -68,8 +66,53 @@ const dnsDisableFallback = dnsField('disableFallback', false);
|
||||
const dnsDisableFallbackIfMatch = dnsField('disableFallbackIfMatch', false);
|
||||
const dnsEnableParallelQuery = dnsField('enableParallelQuery', false);
|
||||
const dnsUseSystemHosts = dnsField('useSystemHosts', false);
|
||||
const dnsServeStale = dnsField('serveStale', false);
|
||||
const dnsServeExpiredTTL = dnsField('serveExpiredTTL', 0);
|
||||
|
||||
const hostsList = ref([]);
|
||||
|
||||
function hydrateHostsFromBackend() {
|
||||
const src = props.templateSettings?.dns?.hosts || {};
|
||||
hostsList.value = Object.entries(src).map(([domain, val]) => ({
|
||||
domain,
|
||||
values: Array.isArray(val) ? [...val] : [String(val)],
|
||||
}));
|
||||
}
|
||||
|
||||
function syncHostsToBackend() {
|
||||
if (!props.templateSettings?.dns) return;
|
||||
const obj = {};
|
||||
for (const row of hostsList.value) {
|
||||
if (!row.domain) continue;
|
||||
const vals = (row.values || []).filter(Boolean);
|
||||
if (vals.length === 0) continue;
|
||||
obj[row.domain] = vals.length === 1 ? vals[0] : vals;
|
||||
}
|
||||
if (Object.keys(obj).length > 0) {
|
||||
props.templateSettings.dns.hosts = obj;
|
||||
} else if ('hosts' in props.templateSettings.dns) {
|
||||
delete props.templateSettings.dns.hosts;
|
||||
}
|
||||
}
|
||||
|
||||
watch(
|
||||
() => !!props.templateSettings?.dns,
|
||||
(enabled) => {
|
||||
if (enabled) hydrateHostsFromBackend();
|
||||
else hostsList.value = [];
|
||||
},
|
||||
{ immediate: true },
|
||||
);
|
||||
|
||||
watch(hostsList, syncHostsToBackend, { deep: true });
|
||||
|
||||
function addHost() {
|
||||
hostsList.value.push({ domain: '', values: [] });
|
||||
}
|
||||
function deleteHost(idx) {
|
||||
hostsList.value.splice(idx, 1);
|
||||
}
|
||||
|
||||
// ============== DNS server table ==============
|
||||
const dnsServers = computed(() => {
|
||||
const list = props.templateSettings?.dns?.servers || [];
|
||||
return list.map((s, idx) => ({ key: idx, server: s }));
|
||||
@@ -79,7 +122,7 @@ const dnsColumns = computed(() => [
|
||||
{ title: '#', key: 'action', align: 'center', width: 60 },
|
||||
{ title: t('pages.inbounds.address'), key: 'address', align: 'left' },
|
||||
{ title: t('pages.xray.dns.domains'), key: 'domains', align: 'left' },
|
||||
{ title: t('pages.xray.dns.expectIPs'), key: 'expectIPs', align: 'left' },
|
||||
{ title: t('pages.xray.dns.expectIPs'), key: 'expectedIPs', align: 'left' },
|
||||
]);
|
||||
|
||||
function addrFor(server) {
|
||||
@@ -88,8 +131,10 @@ function addrFor(server) {
|
||||
function domainsFor(server) {
|
||||
return typeof server === 'object' ? (server.domains || []).join(',') : '';
|
||||
}
|
||||
function expectIPsFor(server) {
|
||||
return typeof server === 'object' ? (server.expectIPs || []).join(',') : '';
|
||||
function expectedIPsFor(server) {
|
||||
if (typeof server !== 'object' || !server) return '';
|
||||
const list = server.expectedIPs || server.expectIPs || [];
|
||||
return Array.isArray(list) ? list.join(',') : '';
|
||||
}
|
||||
|
||||
// ============== Server modal ==============
|
||||
@@ -122,6 +167,27 @@ function onServerConfirm(value) {
|
||||
function deleteServer(idx) {
|
||||
props.templateSettings.dns.servers.splice(idx, 1);
|
||||
}
|
||||
function clearAllServers() {
|
||||
if (!props.templateSettings?.dns) return;
|
||||
Modal.confirm({
|
||||
title: t('pages.xray.dns.clearAllTitle'),
|
||||
content: t('pages.xray.dns.clearAllConfirm'),
|
||||
okText: t('delete'),
|
||||
okButtonProps: { danger: true },
|
||||
cancelText: t('cancel'),
|
||||
onOk() {
|
||||
props.templateSettings.dns.servers = [];
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const presetsModalOpen = ref(false);
|
||||
function openPresets() { presetsModalOpen.value = true; }
|
||||
function onPresetInstall(serverList) {
|
||||
if (!props.templateSettings?.dns) return;
|
||||
props.templateSettings.dns.servers = serverList;
|
||||
presetsModalOpen.value = false;
|
||||
}
|
||||
|
||||
// ============== Fake DNS table ==============
|
||||
const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
|
||||
@@ -239,32 +305,102 @@ function updateFakednsField(idx, field, value) {
|
||||
<a-switch v-model:checked="dnsUseSystemHosts" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.xray.dns.serveStale') }}</template>
|
||||
<template #description>{{ t('pages.xray.dns.serveStaleDesc') }}</template>
|
||||
<template #control>
|
||||
<a-switch v-model:checked="dnsServeStale" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
|
||||
<SettingListItem paddings="small">
|
||||
<template #title>{{ t('pages.xray.dns.serveExpiredTTL') }}</template>
|
||||
<template #description>{{ t('pages.xray.dns.serveExpiredTTLDesc') }}</template>
|
||||
<template #control>
|
||||
<a-input-number v-model:value="dnsServeExpiredTTL" :min="0" :step="60" :style="{ width: '100%' }" />
|
||||
</template>
|
||||
</SettingListItem>
|
||||
</template>
|
||||
</a-collapse-panel>
|
||||
|
||||
<!-- ============== Hosts ============== -->
|
||||
<a-collapse-panel v-if="enableDNS" key="hosts" :header="t('pages.xray.dns.hosts')">
|
||||
<a-empty v-if="hostsList.length === 0" :description="t('pages.xray.dns.hostsEmpty')">
|
||||
<a-button type="primary" @click="addHost">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.hostsAdd') }}
|
||||
</a-button>
|
||||
</a-empty>
|
||||
|
||||
<template v-else>
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<a-button type="primary" @click="addHost">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.hostsAdd') }}
|
||||
</a-button>
|
||||
<div v-for="(row, idx) in hostsList" :key="`h${idx}`" class="hosts-row">
|
||||
<a-input v-model:value="row.domain" :placeholder="t('pages.xray.dns.hostsDomain')"
|
||||
:style="{ flex: '1 1 220px' }" />
|
||||
<a-select v-model:value="row.values" mode="tags" :placeholder="t('pages.xray.dns.hostsValues')"
|
||||
:style="{ flex: '2 1 320px' }" :token-separators="[',', ' ']" />
|
||||
<a-button danger @click="deleteHost(idx)">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</div>
|
||||
</a-space>
|
||||
</template>
|
||||
</a-collapse-panel>
|
||||
|
||||
<!-- ============== DNS servers ============== -->
|
||||
<a-collapse-panel v-if="enableDNS" key="2" header="DNS">
|
||||
<a-empty v-if="dnsServers.length === 0" :description="t('emptyDnsDesc')">
|
||||
<a-button type="primary" @click="openAddServer">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ t('pages.xray.dns.add') }}
|
||||
</a-button>
|
||||
<a-space>
|
||||
<a-button type="primary" @click="openAddServer">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.add') }}
|
||||
</a-button>
|
||||
<a-button @click="openPresets">
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.usePreset') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-empty>
|
||||
|
||||
<template v-else>
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<a-button type="primary" @click="openAddServer">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
{{ t('pages.xray.dns.add') }}
|
||||
</a-button>
|
||||
<a-table
|
||||
:columns="dnsColumns"
|
||||
:data-source="dnsServers"
|
||||
:row-key="(r) => r.key"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<a-space wrap>
|
||||
<a-button type="primary" @click="openAddServer">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.add') }}
|
||||
</a-button>
|
||||
<a-button @click="openPresets">
|
||||
<template #icon>
|
||||
<MenuOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.usePreset') }}
|
||||
</a-button>
|
||||
<a-button danger @click="clearAllServers">
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.dns.clearAll') }}
|
||||
</a-button>
|
||||
</a-space>
|
||||
<a-table :columns="dnsColumns" :data-source="dnsServers" :row-key="(r) => r.key" :pagination="false"
|
||||
size="small" bordered>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space :size="6">
|
||||
@@ -292,8 +428,8 @@ function updateFakednsField(idx, field, value) {
|
||||
<template v-else-if="column.key === 'domains'">
|
||||
<span class="muted">{{ domainsFor(record.server) }}</span>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'expectIPs'">
|
||||
<span class="muted">{{ expectIPsFor(record.server) }}</span>
|
||||
<template v-else-if="column.key === 'expectedIPs'">
|
||||
<span class="muted">{{ expectedIPsFor(record.server) }}</span>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -305,7 +441,9 @@ function updateFakednsField(idx, field, value) {
|
||||
<a-collapse-panel v-if="enableDNS" key="3" header="Fake DNS">
|
||||
<a-empty v-if="fakeDnsList.length === 0" :description="t('emptyFakeDnsDesc')">
|
||||
<a-button type="primary" @click="addFakedns">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.fakedns.add') }}
|
||||
</a-button>
|
||||
</a-empty>
|
||||
@@ -313,17 +451,13 @@ function updateFakednsField(idx, field, value) {
|
||||
<template v-else>
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<a-button type="primary" @click="addFakedns">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.fakedns.add') }}
|
||||
</a-button>
|
||||
<a-table
|
||||
:columns="fakednsColumns"
|
||||
:data-source="fakeDnsList"
|
||||
:row-key="(r) => r.key"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
bordered
|
||||
>
|
||||
<a-table :columns="fakednsColumns" :data-source="fakeDnsList" :row-key="(r) => r.key" :pagination="false"
|
||||
size="small" bordered>
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<a-space :size="6">
|
||||
@@ -334,19 +468,12 @@ function updateFakednsField(idx, field, value) {
|
||||
</a-space>
|
||||
</template>
|
||||
<template v-else-if="column.key === 'ipPool'">
|
||||
<a-input
|
||||
:value="record.ipPool"
|
||||
size="small"
|
||||
@change="(e) => updateFakednsField(index, 'ipPool', e.target.value)"
|
||||
/>
|
||||
<a-input :value="record.ipPool" size="small"
|
||||
@change="(e) => updateFakednsField(index, 'ipPool', e.target.value)" />
|
||||
</template>
|
||||
<template v-else-if="column.key === 'poolSize'">
|
||||
<a-input-number
|
||||
:value="record.poolSize"
|
||||
:min="1"
|
||||
size="small"
|
||||
@change="(v) => updateFakednsField(index, 'poolSize', v)"
|
||||
/>
|
||||
<a-input-number :value="record.poolSize" :min="1" size="small"
|
||||
@change="(v) => updateFakednsField(index, 'poolSize', v)" />
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
@@ -355,12 +482,9 @@ function updateFakednsField(idx, field, value) {
|
||||
</a-collapse-panel>
|
||||
</a-collapse>
|
||||
|
||||
<DnsServerModal
|
||||
v-model:open="serverModalOpen"
|
||||
:server="editingServer"
|
||||
:is-edit="editingIndex != null"
|
||||
@confirm="onServerConfirm"
|
||||
/>
|
||||
<DnsServerModal v-model:open="serverModalOpen" :server="editingServer" :is-edit="editingIndex != null"
|
||||
@confirm="onServerConfirm" />
|
||||
<DnsPresetsModal v-model:open="presetsModalOpen" @install="onPresetInstall" />
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
@@ -368,6 +492,20 @@ function updateFakednsField(idx, field, value) {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.muted { opacity: 0.7; word-break: break-all; }
|
||||
.danger { color: #ff4d4f; }
|
||||
|
||||
.muted {
|
||||
opacity: 0.7;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
|
||||
.hosts-row {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -343,8 +343,7 @@ function regenerateWgKeys() {
|
||||
<a-input-number v-model:value="outbound.settings.userLevel" :min="0" :style="{ width: '100%' }" />
|
||||
</a-form-item>
|
||||
<a-form-item label="Rules">
|
||||
<a-button size="small" type="primary"
|
||||
@click="outbound.settings.rules.push(new Outbound.DNSRule())">
|
||||
<a-button size="small" type="primary" @click="outbound.settings.rules.push(new Outbound.DNSRule())">
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
@@ -955,11 +954,8 @@ function regenerateWgKeys() {
|
||||
<!-- Gated by canEnableStream() so TCP masks don't leak into
|
||||
Freedom / Blackhole / DNS / Socks / HTTP / Wireguard outbounds
|
||||
(they don't have a stream config at all). Matches legacy. -->
|
||||
<FinalMaskForm
|
||||
v-if="outbound.stream && outbound.canEnableStream()"
|
||||
:stream="outbound.stream"
|
||||
:protocol="proto"
|
||||
/>
|
||||
<FinalMaskForm v-if="outbound.stream && outbound.canEnableStream()" :stream="outbound.stream"
|
||||
:protocol="proto" />
|
||||
</a-tab-pane>
|
||||
|
||||
<!-- ============================== JSON ============================== -->
|
||||
|
||||
@@ -180,29 +180,32 @@ const rows = computed(() => {
|
||||
<a-col :xs="24" :sm="14">
|
||||
<a-space size="small">
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
|
||||
</a-button>
|
||||
<a-button type="primary" @click="emit('show-warp')">
|
||||
<template #icon><CloudOutlined /></template>
|
||||
<template #icon>
|
||||
<CloudOutlined />
|
||||
</template>
|
||||
WARP
|
||||
</a-button>
|
||||
<a-button type="primary" @click="emit('show-nord')">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
<template #icon>
|
||||
<ApiOutlined />
|
||||
</template>
|
||||
NordVPN
|
||||
</a-button>
|
||||
</a-space>
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="10" class="toolbar-right">
|
||||
<a-popconfirm
|
||||
placement="topRight"
|
||||
:ok-text="t('reset')"
|
||||
:cancel-text="t('cancel')"
|
||||
:title="t('pages.inbounds.resetAllTrafficContent')"
|
||||
@confirm="emit('reset-traffic', '-alltags-')"
|
||||
>
|
||||
<a-popconfirm placement="topRight" :ok-text="t('reset')" :cancel-text="t('cancel')"
|
||||
:title="t('pages.inbounds.resetAllTrafficContent')" @confirm="emit('reset-traffic', '-alltags-')">
|
||||
<a-button>
|
||||
<template #icon><RetweetOutlined /></template>
|
||||
<template #icon>
|
||||
<RetweetOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-popconfirm>
|
||||
</a-col>
|
||||
@@ -220,8 +223,7 @@ const rows = computed(() => {
|
||||
</a-tooltip>
|
||||
<a-tag color="green">{{ record.protocol }}</a-tag>
|
||||
<template
|
||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
|
||||
>
|
||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)">
|
||||
<a-tag>{{ record.streamSettings?.network }}</a-tag>
|
||||
<a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
|
||||
{{ record.streamSettings.security }}
|
||||
@@ -267,15 +269,11 @@ const rows = computed(() => {
|
||||
<span v-else>failed</span>
|
||||
</span>
|
||||
<LoadingOutlined v-else-if="isTesting(index)" />
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
size="small"
|
||||
:loading="isTesting(index)"
|
||||
:disabled="isUntestable(record) || isTesting(index)"
|
||||
@click="emit('test', index)"
|
||||
>
|
||||
<template #icon><ThunderboltOutlined /></template>
|
||||
<a-button type="primary" shape="circle" size="small" :loading="isTesting(index)"
|
||||
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</span>
|
||||
</div>
|
||||
@@ -283,14 +281,7 @@ const rows = computed(() => {
|
||||
</template>
|
||||
|
||||
<!-- Desktop: table -->
|
||||
<a-table
|
||||
v-else
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r) => r.key"
|
||||
:pagination="false"
|
||||
size="small"
|
||||
>
|
||||
<a-table v-else :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<template v-if="column.key === 'action'">
|
||||
<div class="action-cell">
|
||||
@@ -333,8 +324,7 @@ const rows = computed(() => {
|
||||
<div class="protocol-line">
|
||||
<a-tag color="green">{{ record.protocol }}</a-tag>
|
||||
<template
|
||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)"
|
||||
>
|
||||
v-if="[Protocols.VMess, Protocols.VLESS, Protocols.Trojan, Protocols.Shadowsocks].includes(record.protocol)">
|
||||
<a-tag>{{ record.streamSettings?.network }}</a-tag>
|
||||
<a-tag v-if="showSecurity(record.streamSettings?.security)" color="purple">
|
||||
{{ record.streamSettings.security }}
|
||||
@@ -374,38 +364,34 @@ const rows = computed(() => {
|
||||
|
||||
<template v-else-if="column.key === 'test'">
|
||||
<a-tooltip :title="t('check')">
|
||||
<a-button
|
||||
type="primary"
|
||||
shape="circle"
|
||||
:loading="isTesting(index)"
|
||||
:disabled="isUntestable(record) || isTesting(index)"
|
||||
@click="emit('test', index)"
|
||||
>
|
||||
<template #icon><ThunderboltOutlined /></template>
|
||||
<a-button type="primary" shape="circle" :loading="isTesting(index)"
|
||||
:disabled="isUntestable(record) || isTesting(index)" @click="emit('test', index)">
|
||||
<template #icon>
|
||||
<ThunderboltOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-tooltip>
|
||||
</template>
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<OutboundFormModal
|
||||
v-model:open="modalOpen"
|
||||
:outbound="editingOutbound"
|
||||
:existing-tags="existingTags"
|
||||
:inbound-tags="inboundTagOptions"
|
||||
@confirm="onConfirm"
|
||||
/>
|
||||
<OutboundFormModal v-model:open="modalOpen" :outbound="editingOutbound" :existing-tags="existingTags"
|
||||
:inbound-tags="inboundTagOptions" @confirm="onConfirm" />
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.toolbar-right { display: flex; justify-content: flex-end; }
|
||||
.toolbar-right {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.card-empty {
|
||||
text-align: center;
|
||||
opacity: 0.4;
|
||||
padding: 16px 0;
|
||||
}
|
||||
|
||||
.outbound-card {
|
||||
border: 1px solid rgba(128, 128, 128, 0.2);
|
||||
border-radius: 8px;
|
||||
@@ -415,24 +401,28 @@ const rows = computed(() => {
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-head {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.card-identity {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.card-num {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
min-width: 18px;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.tag-name {
|
||||
font-weight: 500;
|
||||
max-width: 200px;
|
||||
@@ -441,6 +431,7 @@ const rows = computed(() => {
|
||||
white-space: nowrap;
|
||||
display: inline-block;
|
||||
}
|
||||
|
||||
.protocol-line {
|
||||
display: inline-flex;
|
||||
flex-wrap: wrap;
|
||||
@@ -452,12 +443,14 @@ const rows = computed(() => {
|
||||
flex-wrap: wrap;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.address-pill {
|
||||
font-size: 11px;
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
background: rgba(0, 0, 0, 0.05);
|
||||
}
|
||||
|
||||
:global(body.dark) .address-pill {
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
}
|
||||
@@ -467,6 +460,7 @@ const rows = computed(() => {
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-index {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
@@ -487,6 +481,7 @@ const rows = computed(() => {
|
||||
gap: 12px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.card-test {
|
||||
margin-left: auto;
|
||||
display: inline-flex;
|
||||
@@ -494,9 +489,20 @@ const rows = computed(() => {
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.traffic-up { color: #008771; font-size: 12px; }
|
||||
.traffic-down { color: #3c89e8; font-size: 12px; }
|
||||
.traffic-sep { display: inline-block; width: 4px; }
|
||||
.traffic-up {
|
||||
color: #008771;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.traffic-down {
|
||||
color: #3c89e8;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.traffic-sep {
|
||||
display: inline-block;
|
||||
width: 4px;
|
||||
}
|
||||
|
||||
.pill-ok,
|
||||
.pill-fail {
|
||||
@@ -507,9 +513,22 @@ const rows = computed(() => {
|
||||
border-radius: 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
.pill-ok { color: #008771; background: rgba(0, 135, 113, 0.12); }
|
||||
.pill-fail { color: #e04141; background: rgba(224, 65, 65, 0.12); }
|
||||
|
||||
.empty { opacity: 0.4; }
|
||||
.danger { color: #ff4d4f; }
|
||||
.pill-ok {
|
||||
color: #008771;
|
||||
background: rgba(0, 135, 113, 0.12);
|
||||
}
|
||||
|
||||
.pill-fail {
|
||||
color: #e04141;
|
||||
background: rgba(224, 65, 65, 0.12);
|
||||
}
|
||||
|
||||
.empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -169,19 +169,14 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<template>
|
||||
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
|
||||
<a-button type="primary" @click="openAdd">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
{{ t('pages.xray.Routings') }}
|
||||
</a-button>
|
||||
|
||||
<a-table
|
||||
:columns="columns"
|
||||
:data-source="rows"
|
||||
:row-key="(r) => r.key"
|
||||
:pagination="false"
|
||||
:scroll="isMobile ? {} : { x: 1000 }"
|
||||
size="small"
|
||||
class="routing-table"
|
||||
>
|
||||
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false"
|
||||
:scroll="isMobile ? {} : { x: 1000 }" size="small" class="routing-table">
|
||||
<template #bodyCell="{ column, record, index }">
|
||||
<!-- ============== # / actions ============== -->
|
||||
<template v-if="column.key === 'action'">
|
||||
@@ -218,21 +213,24 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">IP</span>
|
||||
<span class="criterion-value">{{ csv(record.sourceIP)[0] }}</span>
|
||||
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1 }}</span>
|
||||
<span v-if="csv(record.sourceIP).length > 1" class="criterion-more">+{{ csv(record.sourceIP).length - 1
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.sourcePort" :title="`Source port: ${record.sourcePort}`">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Port</span>
|
||||
<span class="criterion-value">{{ csv(record.sourcePort)[0] }}</span>
|
||||
<span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length - 1 }}</span>
|
||||
<span v-if="csv(record.sourcePort).length > 1" class="criterion-more">+{{ csv(record.sourcePort).length
|
||||
- 1 }}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.vlessRoute" :title="`VLESS route: ${record.vlessRoute}`">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">VLESS</span>
|
||||
<span class="criterion-value">{{ csv(record.vlessRoute)[0] }}</span>
|
||||
<span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length - 1 }}</span>
|
||||
<span v-if="csv(record.vlessRoute).length > 1" class="criterion-more">+{{ csv(record.vlessRoute).length
|
||||
- 1 }}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.sourceIP && !record.sourcePort && !record.vlessRoute" class="criterion-empty">—</span>
|
||||
@@ -246,14 +244,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">L4</span>
|
||||
<span class="criterion-value">{{ csv(record.network)[0] }}</span>
|
||||
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1 }}</span>
|
||||
<span v-if="csv(record.network).length > 1" class="criterion-more">+{{ csv(record.network).length - 1
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.protocol" :title="`Protocol: ${record.protocol}`">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Protocol</span>
|
||||
<span class="criterion-value">{{ csv(record.protocol)[0] }}</span>
|
||||
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1 }}</span>
|
||||
<span v-if="csv(record.protocol).length > 1" class="criterion-more">+{{ csv(record.protocol).length - 1
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.attrs" :title="`Attrs: ${record.attrs}`">
|
||||
@@ -280,14 +280,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Domain</span>
|
||||
<span class="criterion-value">{{ csv(record.domain)[0] }}</span>
|
||||
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1 }}</span>
|
||||
<span v-if="csv(record.domain).length > 1" class="criterion-more">+{{ csv(record.domain).length - 1
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.port" :title="`Destination port: ${record.port}`">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Port</span>
|
||||
<span class="criterion-value">{{ csv(record.port)[0] }}</span>
|
||||
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1 }}</span>
|
||||
<span v-if="csv(record.port).length > 1" class="criterion-more">+{{ csv(record.port).length - 1
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.ip && !record.domain && !record.port" class="criterion-empty">—</span>
|
||||
@@ -301,14 +303,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">Tag</span>
|
||||
<span class="criterion-value">{{ csv(record.inboundTag)[0] }}</span>
|
||||
<span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length - 1 }}</span>
|
||||
<span v-if="csv(record.inboundTag).length > 1" class="criterion-more">+{{ csv(record.inboundTag).length
|
||||
- 1 }}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<a-tooltip v-if="record.user" :title="`User: ${record.user}`">
|
||||
<span class="criterion-row">
|
||||
<span class="criterion-label">User</span>
|
||||
<span class="criterion-value">{{ csv(record.user)[0] }}</span>
|
||||
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1 }}</span>
|
||||
<span v-if="csv(record.user).length > 1" class="criterion-more">+{{ csv(record.user).length - 1
|
||||
}}</span>
|
||||
</span>
|
||||
</a-tooltip>
|
||||
<span v-if="!record.inboundTag && !record.user" class="criterion-empty">—</span>
|
||||
@@ -332,14 +336,8 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
</template>
|
||||
</a-table>
|
||||
|
||||
<RuleFormModal
|
||||
v-model:open="ruleModalOpen"
|
||||
:rule="editingRule"
|
||||
:inbound-tags="inboundTagOptions"
|
||||
:outbound-tags="outboundTagOptions"
|
||||
:balancer-tags="balancerTagOptions"
|
||||
@confirm="onRuleConfirm"
|
||||
/>
|
||||
<RuleFormModal v-model:open="ruleModalOpen" :rule="editingRule" :inbound-tags="inboundTagOptions"
|
||||
:outbound-tags="outboundTagOptions" :balancer-tags="balancerTagOptions" @confirm="onRuleConfirm" />
|
||||
</a-space>
|
||||
</template>
|
||||
|
||||
@@ -349,6 +347,7 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.row-index {
|
||||
font-weight: 500;
|
||||
opacity: 0.7;
|
||||
@@ -362,30 +361,36 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
gap: 2px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.criterion-row {
|
||||
display: inline-flex;
|
||||
align-items: baseline;
|
||||
gap: 4px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.criterion-label {
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.55;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.criterion-value {
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.criterion-more {
|
||||
font-size: 11px;
|
||||
padding: 0 5px;
|
||||
border-radius: 8px;
|
||||
background: rgba(0, 0, 0, 0.06);
|
||||
}
|
||||
|
||||
:global(body.dark) .criterion-more {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
|
||||
.criterion-empty {
|
||||
opacity: 0.4;
|
||||
}
|
||||
@@ -395,15 +400,19 @@ const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopCo
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.target-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.target-icon {
|
||||
font-size: 12px;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
.danger { color: #ff4d4f; }
|
||||
.danger {
|
||||
color: #ff4d4f;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -137,21 +137,14 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
:title="title"
|
||||
:ok-text="okText"
|
||||
:cancel-text="t('close')"
|
||||
:mask-closable="false"
|
||||
width="640px"
|
||||
@ok="onOk"
|
||||
@cancel="close"
|
||||
>
|
||||
<a-modal :open="open" :title="title" :ok-text="okText" :cancel-text="t('close')" :mask-closable="false" width="640px"
|
||||
@ok="onOk" @cancel="close">
|
||||
<a-form :colon="false" :label-col="{ md: { span: 8 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">
|
||||
Source IPs <QuestionCircleOutlined />
|
||||
Source IPs
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.sourceIP" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
@@ -160,7 +153,8 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">
|
||||
Source port <QuestionCircleOutlined />
|
||||
Source port
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.sourcePort" placeholder="53,443,1000-2000" />
|
||||
@@ -169,7 +163,8 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">
|
||||
VLESS route <QuestionCircleOutlined />
|
||||
VLESS route
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.vlessRoute" placeholder="53,443,1000-2000" />
|
||||
@@ -189,7 +184,9 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
|
||||
<a-form-item label="Attributes">
|
||||
<a-button size="small" @click="form.attrs.push(['', ''])">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-form-item>
|
||||
<a-form-item :wrapper-col="{ span: 24 }">
|
||||
@@ -199,35 +196,45 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
</a-input>
|
||||
<a-input :style="{ width: '45%' }" v-model:value="attr[1]" placeholder="Value" />
|
||||
<a-button @click="form.attrs.splice(idx, 1)">
|
||||
<template #icon><MinusOutlined /></template>
|
||||
<template #icon>
|
||||
<MinusOutlined />
|
||||
</template>
|
||||
</a-button>
|
||||
</a-input-group>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">IP <QuestionCircleOutlined /></a-tooltip>
|
||||
<a-tooltip title="Comma-separated list">IP
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.ip" placeholder="0.0.0.0/8, fc00::/7, geoip:ir" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">Domain <QuestionCircleOutlined /></a-tooltip>
|
||||
<a-tooltip title="Comma-separated list">Domain
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.domain" placeholder="google.com, geosite:cn" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">User <QuestionCircleOutlined /></a-tooltip>
|
||||
<a-tooltip title="Comma-separated list">User
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.user" placeholder="email address" />
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Comma-separated list">Port <QuestionCircleOutlined /></a-tooltip>
|
||||
<a-tooltip title="Comma-separated list">Port
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-input v-model:value="form.port" placeholder="53,443,1000-2000" />
|
||||
</a-form-item>
|
||||
@@ -240,18 +247,21 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
|
||||
<a-form-item label="Outbound tag">
|
||||
<a-select v-model:value="form.outboundTag">
|
||||
<a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)' }}</a-select-option>
|
||||
<a-select-option v-for="tag in outboundTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
|
||||
}}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
|
||||
<a-form-item>
|
||||
<template #label>
|
||||
<a-tooltip title="Routes traffic through one of the configured load balancers">
|
||||
Balancer tag <QuestionCircleOutlined />
|
||||
Balancer tag
|
||||
<QuestionCircleOutlined />
|
||||
</a-tooltip>
|
||||
</template>
|
||||
<a-select v-model:value="form.balancerTag">
|
||||
<a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)' }}</a-select-option>
|
||||
<a-select-option v-for="tag in balancerTags" :key="tag || '__empty'" :value="tag">{{ tag || '(none)'
|
||||
}}</a-select-option>
|
||||
</a-select>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
@@ -259,5 +269,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.mb-8 { margin-bottom: 8px; }
|
||||
.mb-8 {
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -182,14 +182,7 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<a-modal
|
||||
:open="open"
|
||||
title="Cloudflare WARP"
|
||||
:footer="null"
|
||||
:closable="true"
|
||||
:mask-closable="true"
|
||||
@cancel="close"
|
||||
>
|
||||
<a-modal :open="open" title="Cloudflare WARP" :footer="null" :closable="true" :mask-closable="true" @cancel="close">
|
||||
<!-- WARP / NordVPN provisioning forms keep technical wire labels in
|
||||
English on purpose: they map directly to API field names users
|
||||
look up in vendor docs. Only the primary action buttons +
|
||||
@@ -197,7 +190,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
<!-- Not registered yet → single Create CTA -->
|
||||
<template v-if="!hasWarp">
|
||||
<a-button type="primary" :loading="loading" @click="register">
|
||||
<template #icon><ApiOutlined /></template>
|
||||
<template #icon>
|
||||
<ApiOutlined />
|
||||
</template>
|
||||
Create WARP account
|
||||
</a-button>
|
||||
</template>
|
||||
@@ -226,7 +221,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
</table>
|
||||
|
||||
<a-button :loading="loading" type="primary" danger class="mt-8" @click="delConfig">
|
||||
<template #icon><DeleteOutlined /></template>
|
||||
<template #icon>
|
||||
<DeleteOutlined />
|
||||
</template>
|
||||
Delete account
|
||||
</a-button>
|
||||
|
||||
@@ -237,13 +234,8 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 14 } }">
|
||||
<a-form-item label="Key">
|
||||
<a-input v-model:value="warpPlus" placeholder="26-char WARP+ key" />
|
||||
<a-button
|
||||
type="primary"
|
||||
class="mt-8"
|
||||
:disabled="warpPlus.length < 26"
|
||||
:loading="loading"
|
||||
@click="updateLicense"
|
||||
>Update</a-button>
|
||||
<a-button type="primary" class="mt-8" :disabled="warpPlus.length < 26" :loading="loading"
|
||||
@click="updateLicense">Update</a-button>
|
||||
</a-form-item>
|
||||
</a-form>
|
||||
</a-collapse-panel>
|
||||
@@ -251,7 +243,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
|
||||
<a-divider class="zero-margin">Account info</a-divider>
|
||||
<a-button class="my-8" :loading="loading" type="primary" @click="getConfig">
|
||||
<template #icon><SyncOutlined /></template>
|
||||
<template #icon>
|
||||
<SyncOutlined />
|
||||
</template>
|
||||
Refresh
|
||||
</a-button>
|
||||
|
||||
@@ -305,7 +299,9 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
<template v-else>
|
||||
<a-tag color="orange">Disabled</a-tag>
|
||||
<a-button type="primary" :loading="loading" class="ml-8" @click="addOutbound">
|
||||
<template #icon><PlusOutlined /></template>
|
||||
<template #icon>
|
||||
<PlusOutlined />
|
||||
</template>
|
||||
Add outbound
|
||||
</a-button>
|
||||
</template>
|
||||
@@ -320,28 +316,46 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
|
||||
width: 100%;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
|
||||
.warp-data-table td {
|
||||
padding: 4px 8px;
|
||||
word-break: break-all;
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.warp-data-table td:first-child {
|
||||
font-family: inherit;
|
||||
font-weight: 500;
|
||||
white-space: nowrap;
|
||||
width: 130px;
|
||||
}
|
||||
|
||||
.row-odd {
|
||||
background: rgba(0, 0, 0, 0.03);
|
||||
}
|
||||
|
||||
:global(body.dark) .row-odd {
|
||||
background: rgba(255, 255, 255, 0.04);
|
||||
}
|
||||
|
||||
.zero-margin { margin: 0; }
|
||||
.my-8 { margin: 8px 0; }
|
||||
.mt-8 { margin-top: 8px; }
|
||||
.my-10 { margin: 10px 0; }
|
||||
.ml-8 { margin-left: 8px; }
|
||||
.zero-margin {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.my-8 {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.mt-8 {
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
.my-10 {
|
||||
margin: 10px 0;
|
||||
}
|
||||
|
||||
.ml-8 {
|
||||
margin-left: 8px;
|
||||
}
|
||||
</style>
|
||||
|
||||
@@ -207,10 +207,7 @@ function confirmRestart() {
|
||||
|
||||
<template>
|
||||
<a-config-provider :theme="antdThemeConfig">
|
||||
<a-layout
|
||||
class="xray-page"
|
||||
:class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
|
||||
>
|
||||
<a-layout class="xray-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
|
||||
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
|
||||
|
||||
<a-layout class="content-shell">
|
||||
@@ -218,12 +215,7 @@ function confirmRestart() {
|
||||
<a-spin :spinning="spinning || !fetched" :delay="200" tip="Loading…" size="large">
|
||||
<div v-if="!fetched" class="loading-spacer" />
|
||||
|
||||
<a-result
|
||||
v-else-if="fetchError"
|
||||
status="error"
|
||||
:title="t('somethingWentWrong')"
|
||||
:sub-title="fetchError"
|
||||
>
|
||||
<a-result v-else-if="fetchError" status="error" :title="t('somethingWentWrong')" :sub-title="fetchError">
|
||||
<template #extra>
|
||||
<a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
|
||||
</template>
|
||||
@@ -254,11 +246,7 @@ function confirmRestart() {
|
||||
</a-col>
|
||||
<a-col :xs="24" :sm="10" class="header-info">
|
||||
<a-back-top :target="scrollTarget" :visibility-height="200" />
|
||||
<a-alert
|
||||
type="warning"
|
||||
show-icon
|
||||
:message="t('pages.settings.infoDesc')"
|
||||
/>
|
||||
<a-alert type="warning" show-icon :message="t('pages.settings.infoDesc')" />
|
||||
</a-col>
|
||||
</a-row>
|
||||
</a-card>
|
||||
@@ -271,56 +259,35 @@ function confirmRestart() {
|
||||
<template #tab>
|
||||
<SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
|
||||
</template>
|
||||
<BasicsTab
|
||||
:template-settings="templateSettings"
|
||||
:outbound-test-url="outboundTestUrl"
|
||||
:warp-exist="warpExist"
|
||||
:nord-exist="nordExist"
|
||||
@update:outbound-test-url="(v) => (outboundTestUrl = v)"
|
||||
@show-warp="showWarp"
|
||||
@show-nord="showNord"
|
||||
@reset-default="resetToDefault"
|
||||
/>
|
||||
<BasicsTab :template-settings="templateSettings" :outbound-test-url="outboundTestUrl"
|
||||
:warp-exist="warpExist" :nord-exist="nordExist"
|
||||
@update:outbound-test-url="(v) => (outboundTestUrl = v)" @show-warp="showWarp"
|
||||
@show-nord="showNord" @reset-default="resetToDefault" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-routing" class="tab-pane">
|
||||
<template #tab>
|
||||
<SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
|
||||
</template>
|
||||
<RoutingTab
|
||||
:template-settings="templateSettings"
|
||||
:inbound-tags="inboundTags"
|
||||
:client-reverse-tags="clientReverseTags"
|
||||
:is-mobile="isMobile"
|
||||
/>
|
||||
<RoutingTab :template-settings="templateSettings" :inbound-tags="inboundTags"
|
||||
:client-reverse-tags="clientReverseTags" :is-mobile="isMobile" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-outbound" class="tab-pane">
|
||||
<template #tab>
|
||||
<UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
|
||||
</template>
|
||||
<OutboundsTab
|
||||
:template-settings="templateSettings"
|
||||
:outbounds-traffic="outboundsTraffic"
|
||||
:outbound-test-states="outboundTestStates"
|
||||
:inbound-tags="inboundTags"
|
||||
:is-mobile="isMobile"
|
||||
@reset-traffic="resetOutboundsTraffic"
|
||||
@test="onTestOutbound"
|
||||
@delete="onDeleteOutbound"
|
||||
@show-warp="showWarp"
|
||||
@show-nord="showNord"
|
||||
/>
|
||||
<OutboundsTab :template-settings="templateSettings" :outbounds-traffic="outboundsTraffic"
|
||||
:outbound-test-states="outboundTestStates" :inbound-tags="inboundTags" :is-mobile="isMobile"
|
||||
@reset-traffic="resetOutboundsTraffic" @test="onTestOutbound" @delete="onDeleteOutbound"
|
||||
@show-warp="showWarp" @show-nord="showNord" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-balancer" class="tab-pane">
|
||||
<template #tab>
|
||||
<ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
|
||||
</template>
|
||||
<BalancersTab
|
||||
:template-settings="templateSettings"
|
||||
:client-reverse-tags="clientReverseTags"
|
||||
/>
|
||||
<BalancersTab :template-settings="templateSettings" :client-reverse-tags="clientReverseTags" />
|
||||
</a-tab-pane>
|
||||
|
||||
<a-tab-pane key="tpl-dns" class="tab-pane">
|
||||
@@ -334,27 +301,16 @@ function confirmRestart() {
|
||||
<template #tab>
|
||||
<CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
|
||||
</template>
|
||||
<a-list-item-meta
|
||||
:title="t('pages.xray.Template')"
|
||||
:description="t('pages.xray.TemplateDesc')"
|
||||
/>
|
||||
<a-radio-group
|
||||
v-model:value="advSettings"
|
||||
button-style="solid"
|
||||
:size="isMobile ? 'small' : 'middle'"
|
||||
:style="{ margin: '12px 0' }"
|
||||
>
|
||||
<a-list-item-meta :title="t('pages.xray.Template')" :description="t('pages.xray.TemplateDesc')" />
|
||||
<a-radio-group v-model:value="advSettings" button-style="solid"
|
||||
:size="isMobile ? 'small' : 'middle'" :style="{ margin: '12px 0' }">
|
||||
<a-radio-button value="xraySetting">{{ t('pages.xray.completeTemplate') }}</a-radio-button>
|
||||
<a-radio-button value="inboundSettings">{{ t('pages.xray.Inbounds') }}</a-radio-button>
|
||||
<a-radio-button value="outboundSettings">{{ t('pages.xray.Outbounds') }}</a-radio-button>
|
||||
<a-radio-button value="routingRuleSettings">{{ t('pages.xray.Routings') }}</a-radio-button>
|
||||
</a-radio-group>
|
||||
<a-textarea
|
||||
v-model:value="advancedText"
|
||||
:auto-size="{ minRows: 18, maxRows: 40 }"
|
||||
spellcheck="false"
|
||||
class="json-editor"
|
||||
/>
|
||||
<a-textarea v-model:value="advancedText" :auto-size="{ minRows: 18, maxRows: 40 }"
|
||||
spellcheck="false" class="json-editor" />
|
||||
</a-tab-pane>
|
||||
</a-tabs>
|
||||
</a-col>
|
||||
@@ -364,21 +320,11 @@ function confirmRestart() {
|
||||
</a-layout-content>
|
||||
</a-layout>
|
||||
|
||||
<WarpModal
|
||||
v-model:open="warpOpen"
|
||||
:template-settings="templateSettings"
|
||||
@add-outbound="onAddOutbound"
|
||||
@reset-outbound="onResetOutbound"
|
||||
@remove-outbound="onRemoveOutboundByTag"
|
||||
/>
|
||||
<NordModal
|
||||
v-model:open="nordOpen"
|
||||
:template-settings="templateSettings"
|
||||
@add-outbound="onAddOutbound"
|
||||
@reset-outbound="onResetOutbound"
|
||||
@remove-outbound="onRemoveOutboundByIndex"
|
||||
@remove-routing-rules="onRemoveRoutingRules"
|
||||
/>
|
||||
<WarpModal v-model:open="warpOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
|
||||
@reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByTag" />
|
||||
<NordModal v-model:open="nordOpen" :template-settings="templateSettings" @add-outbound="onAddOutbound"
|
||||
@reset-outbound="onResetOutbound" @remove-outbound="onRemoveOutboundByIndex"
|
||||
@remove-routing-rules="onRemoveRoutingRules" />
|
||||
</a-layout>
|
||||
</a-config-provider>
|
||||
</template>
|
||||
@@ -407,23 +353,36 @@ function confirmRestart() {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.content-shell { background: transparent; }
|
||||
.content-area { padding: 24px; }
|
||||
.content-shell {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.loading-spacer { min-height: calc(100vh - 120px); }
|
||||
.content-area {
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.loading-spacer {
|
||||
min-height: calc(100vh - 120px);
|
||||
}
|
||||
|
||||
.header-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
}
|
||||
.header-actions { padding: 4px; }
|
||||
|
||||
.header-actions {
|
||||
padding: 4px;
|
||||
}
|
||||
|
||||
.header-info {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.tab-pane { padding-top: 20px; }
|
||||
.tab-pane {
|
||||
padding-top: 20px;
|
||||
}
|
||||
|
||||
.restart-icon {
|
||||
font-size: 16px;
|
||||
|
||||
Reference in New Issue
Block a user