feat(frontend): rebuild xray DNS section to match main branch

DnsTab now exposes every field the legacy panel did — top-level toggles
(tag, hosts, queryStrategy, disableCache/queryConcurrency, fallback
strategy, client subnet), the servers table with per-row strategy and
domain/expectIP/unexpectedIP overrides, and the Fake DNS pool. The new
DnsServerModal covers the full add/edit flow and collapses to a bare
string when the user only sets an address — matching the wire shape
the legacy form emits for plain DNS entries like "8.8.8.8".

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 17:21:19 +02:00
parent 36e75143fa
commit d8721093e4
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
2 changed files with 504 additions and 74 deletions

View file

@ -0,0 +1,168 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
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 },
isEdit: { type: Boolean, default: false },
});
const emit = defineEmits(['update:open', 'confirm']);
const DEFAULT_SERVER = () => ({
address: 'localhost',
port: 53,
domains: [],
expectIPs: [],
unexpectedIPs: [],
queryStrategy: 'UseIP',
skipFallback: true,
disableCache: false,
finalQuery: false,
});
const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
const form = reactive(DEFAULT_SERVER());
watch(() => props.open, (next) => {
if (!next) return;
Object.assign(form, DEFAULT_SERVER());
if (props.server == null) return;
if (typeof props.server === 'string') {
form.address = props.server;
return;
}
// Object copy fields, defaulting missing arrays to empty.
Object.assign(form, {
...DEFAULT_SERVER(),
...props.server,
domains: [...(props.server.domains || [])],
expectIPs: [...(props.server.expectIPs || [])],
unexpectedIPs: [...(props.server.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.unexpectedIPs.length === 0
&& form.port === 53
&& form.queryStrategy === 'UseIP'
&& form.skipFallback === true
&& form.disableCache === false
&& form.finalQuery === false;
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,
});
}
}
const title = computed(() =>
props.isEdit ? t('pages.xray.dns.edit') : t('pages.xray.dns.add'),
);
</script>
<template>
<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" />
</a-form-item>
<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.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-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>
</a-button>
<template v-for="(_, idx) in form.domains" :key="`d${idx}`">
<a-input v-model:value="form.domains[idx]" :style="{ marginTop: '4px' }">
<template #addonAfter>
<MinusOutlined @click="form.domains.splice(idx, 1)" />
</template>
</a-input>
</template>
</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>
<template v-for="(_, idx) in form.expectIPs" :key="`e${idx}`">
<a-input v-model:value="form.expectIPs[idx]" :style="{ marginTop: '4px' }">
<template #addonAfter>
<MinusOutlined @click="form.expectIPs.splice(idx, 1)" />
</template>
</a-input>
</template>
</a-form-item>
<a-form-item :label="t('pages.xray.dns.unexpectIPs')">
<a-button size="small" type="primary" @click="form.unexpectedIPs.push('')">
<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' }">
<template #addonAfter>
<MinusOutlined @click="form.unexpectedIPs.splice(idx, 1)" />
</template>
</a-input>
</template>
</a-form-item>
<a-divider :style="{ margin: '5px 0' }" />
<a-form-item label="Skip fallback">
<a-switch v-model:checked="form.skipFallback" />
</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>
</a-form>
</a-modal>
</template>

View file

@ -1,27 +1,46 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
EditOutlined,
DeleteOutlined,
} from '@ant-design/icons-vue';
// Compact DNS editor a master enable switch plus a JSON textarea import SettingListItem from '@/components/SettingListItem.vue';
// for the full dns + fakedns trees. The legacy panel had a import DnsServerModal from './DnsServerModal.vue';
// dedicated DNS-server modal + fakedns row editor; both are large
// enough to deserve their own commits. For now this gives users a const { t } = useI18n();
// working path to edit DNS settings without leaving the structured
// page. // 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({ const props = defineProps({
templateSettings: { type: Object, default: null }, templateSettings: { type: Object, default: null },
}); });
const enableDns = computed({ const STRATEGIES = ['UseSystem', 'UseIP', 'UseIPv4', 'UseIPv6'];
// ============== Master toggle ==============
const enableDNS = computed({
get: () => !!props.templateSettings?.dns, get: () => !!props.templateSettings?.dns,
set: (next) => { set: (next) => {
if (!props.templateSettings) return; if (!props.templateSettings) return;
if (next) { if (next) {
props.templateSettings.dns = { props.templateSettings.dns = {
servers: [],
queryStrategy: 'UseIP',
tag: 'dns_inbound', tag: 'dns_inbound',
clientIp: '',
queryStrategy: 'UseIP',
disableCache: false,
disableFallback: false,
disableFallbackIfMatch: false,
useSystemHosts: false,
enableParallelQuery: false, enableParallelQuery: false,
servers: [],
}; };
props.templateSettings.fakedns = null; props.templateSettings.fakedns = null;
} else { } else {
@ -31,81 +50,324 @@ const enableDns = computed({
}, },
}); });
const dnsJson = computed({ // ============== Field bridges ==============
get: () => { function dnsField(field, fallback) {
if (!props.templateSettings?.dns) return ''; return computed({
try { return JSON.stringify(props.templateSettings.dns, null, 2); } get: () => props.templateSettings?.dns?.[field] ?? fallback,
catch (_e) { return ''; } set: (v) => {
if (props.templateSettings?.dns) props.templateSettings.dns[field] = v;
}, },
set: (next) => { });
if (!props.templateSettings) return;
try {
const parsed = next.trim() ? JSON.parse(next) : null;
props.templateSettings.dns = parsed;
} catch (_e) {
// wait for valid JSON leaves the previous value untouched
} }
},
const dnsTag = dnsField('tag', 'dns_inbound');
const dnsClientIp = dnsField('clientIp', '');
const dnsStrategy = dnsField('queryStrategy', 'UseIP');
const dnsDisableCache = dnsField('disableCache', false);
const dnsDisableFallback = dnsField('disableFallback', false);
const dnsDisableFallbackIfMatch = dnsField('disableFallbackIfMatch', false);
const dnsEnableParallelQuery = dnsField('enableParallelQuery', false);
const dnsUseSystemHosts = dnsField('useSystemHosts', false);
// ============== DNS server table ==============
const dnsServers = computed(() => {
const list = props.templateSettings?.dns?.servers || [];
return list.map((s, idx) => ({ key: idx, server: s }));
}); });
const fakednsJson = computed({ const dnsColumns = computed(() => [
get: () => { { title: '#', key: 'action', align: 'center', width: 60 },
if (!props.templateSettings?.fakedns) return ''; { title: t('pages.inbounds.address'), key: 'address', align: 'left' },
try { return JSON.stringify(props.templateSettings.fakedns, null, 2); } { title: t('pages.xray.dns.domains'), key: 'domains', align: 'left' },
catch (_e) { return ''; } { title: t('pages.xray.dns.expectIPs'), key: 'expectIPs', align: 'left' },
}, ]);
set: (next) => {
if (!props.templateSettings) return; function addrFor(server) {
try { return typeof server === 'string' ? server : server?.address || '';
const parsed = next.trim() ? JSON.parse(next) : null; }
if (parsed) props.templateSettings.fakedns = parsed; function domainsFor(server) {
else delete props.templateSettings.fakedns; return typeof server === 'object' ? (server.domains || []).join(',') : '';
} catch (_e) { /* wait for valid JSON */ } }
}, function expectIPsFor(server) {
return typeof server === 'object' ? (server.expectIPs || []).join(',') : '';
}
// ============== Server modal ==============
const serverModalOpen = ref(false);
const editingServer = ref(null);
const editingIndex = ref(null);
function openAddServer() {
editingServer.value = null;
editingIndex.value = null;
serverModalOpen.value = true;
}
function openEditServer(idx) {
editingServer.value = props.templateSettings.dns.servers[idx];
editingIndex.value = idx;
serverModalOpen.value = true;
}
function onServerConfirm(value) {
if (!props.templateSettings?.dns) return;
if (!Array.isArray(props.templateSettings.dns.servers)) {
props.templateSettings.dns.servers = [];
}
if (editingIndex.value == null) {
props.templateSettings.dns.servers.push(value);
} else {
props.templateSettings.dns.servers[editingIndex.value] = value;
}
serverModalOpen.value = false;
}
function deleteServer(idx) {
props.templateSettings.dns.servers.splice(idx, 1);
}
// ============== Fake DNS table ==============
const DEFAULT_FAKEDNS = () => ({ ipPool: '198.18.0.0/15', poolSize: 65535 });
const fakeDnsList = computed(() => {
const list = Array.isArray(props.templateSettings?.fakedns)
? props.templateSettings.fakedns
: [];
return list.map((entry, idx) => ({ key: idx, ...entry }));
}); });
const fakednsColumns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 60 },
{ title: 'IP pool', dataIndex: 'ipPool', key: 'ipPool', align: 'left' },
{ title: 'Pool size', dataIndex: 'poolSize', key: 'poolSize', align: 'right', width: 120 },
]);
function addFakedns() {
if (!props.templateSettings) return;
if (!Array.isArray(props.templateSettings.fakedns)) {
props.templateSettings.fakedns = [];
}
props.templateSettings.fakedns.push(DEFAULT_FAKEDNS());
}
function deleteFakedns(idx) {
props.templateSettings.fakedns.splice(idx, 1);
if (props.templateSettings.fakedns.length === 0) {
props.templateSettings.fakedns = null;
}
}
function updateFakednsField(idx, field, value) {
if (!props.templateSettings.fakedns?.[idx]) return;
props.templateSettings.fakedns[idx] = {
...props.templateSettings.fakedns[idx],
[field]: value,
};
}
</script> </script>
<template> <template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }"> <a-collapse default-active-key="1">
<a-form layout="vertical"> <!-- ============== General DNS settings ============== -->
<a-form-item label="Enable DNS"> <a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
<a-switch v-model:checked="enableDns" /> <SettingListItem paddings="small">
</a-form-item> <template #title>{{ t('pages.xray.dns.enable') }}</template>
<template #description>{{ t('pages.xray.dns.enableDesc') }}</template>
<template v-if="enableDns"> <template #control>
<a-alert <a-switch v-model:checked="enableDNS" />
type="info"
show-icon
message="The full DNS tree is editable here. A dedicated server-by-server editor is coming in a future commit."
class="mb-12"
/>
<a-form-item label="dns (JSON)">
<a-textarea
v-model:value="dnsJson"
:auto-size="{ minRows: 12, maxRows: 28 }"
spellcheck="false"
class="json-editor"
/>
</a-form-item>
<a-form-item label="fakedns (JSON, optional)">
<a-textarea
v-model:value="fakednsJson"
:auto-size="{ minRows: 6, maxRows: 18 }"
spellcheck="false"
class="json-editor"
placeholder="Leave empty to omit fakedns."
/>
</a-form-item>
</template> </template>
</a-form> </SettingListItem>
<template v-if="enableDNS">
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.tag') }}</template>
<template #description>{{ t('pages.xray.dns.tagDesc') }}</template>
<template #control>
<a-input v-model:value="dnsTag" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.clientIp') }}</template>
<template #description>{{ t('pages.xray.dns.clientIpDesc') }}</template>
<template #control>
<a-input v-model:value="dnsClientIp" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.strategy') }}</template>
<template #description>{{ t('pages.xray.dns.strategyDesc') }}</template>
<template #control>
<a-select v-model:value="dnsStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.disableCache') }}</template>
<template #description>{{ t('pages.xray.dns.disableCacheDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsDisableCache" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.disableFallback') }}</template>
<template #description>{{ t('pages.xray.dns.disableFallbackDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsDisableFallback" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.disableFallbackIfMatch') }}</template>
<template #description>{{ t('pages.xray.dns.disableFallbackIfMatchDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsDisableFallbackIfMatch" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.enableParallelQuery') }}</template>
<template #description>{{ t('pages.xray.dns.enableParallelQueryDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsEnableParallelQuery" />
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>{{ t('pages.xray.dns.useSystemHosts') }}</template>
<template #description>{{ t('pages.xray.dns.useSystemHostsDesc') }}</template>
<template #control>
<a-switch v-model:checked="dnsUseSystemHosts" />
</template>
</SettingListItem>
</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-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
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<a-space :size="6">
<span class="row-index">{{ index + 1 }}</span>
<a-dropdown :trigger="['click']">
<a-button shape="circle" size="small">
<MoreOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item @click="openEditServer(index)">
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="deleteServer(index)">
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
</a-dropdown>
</a-space> </a-space>
</template> </template>
<template v-else-if="column.key === 'address'">
{{ addrFor(record.server) }}
</template>
<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>
</template>
</a-table>
</a-space>
</template>
</a-collapse-panel>
<!-- ============== Fake DNS ============== -->
<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>
{{ t('pages.xray.fakedns.add') }}
</a-button>
</a-empty>
<template v-else>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-button type="primary" @click="addFakedns">
<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
>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<a-space :size="6">
<span class="row-index">{{ index + 1 }}</span>
<a-button shape="circle" size="small" danger @click="deleteFakedns(index)">
<DeleteOutlined />
</a-button>
</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)"
/>
</template>
<template v-else-if="column.key === 'poolSize'">
<a-input-number
:value="record.poolSize"
:min="1"
size="small"
@change="(v) => updateFakednsField(index, 'poolSize', v)"
/>
</template>
</template>
</a-table>
</a-space>
</template>
</a-collapse-panel>
</a-collapse>
<DnsServerModal
v-model:open="serverModalOpen"
:server="editingServer"
:is-edit="editingIndex != null"
@confirm="onServerConfirm"
/>
</template>
<style scoped> <style scoped>
.mb-12 { margin-bottom: 12px; } .row-index {
.json-editor { font-weight: 500;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace; opacity: 0.7;
font-size: 12px;
} }
.muted { opacity: 0.7; word-break: break-all; }
.danger { color: #ff4d4f; }
</style> </style>