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:
MHSanaei
2026-05-10 17:03:11 +02:00
parent 8e7d215b4a
commit a96612f595
50 changed files with 1203 additions and 886 deletions
+2 -12
View File
@@ -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>
+103
View File
@@ -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>
+71 -49
View File
@@ -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>
+198 -60
View File
@@ -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 ============================== -->
+74 -55
View File
@@ -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>
+37 -28
View File
@@ -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>
+35 -23
View File
@@ -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>
+38 -24
View File
@@ -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>
+42 -83
View File
@@ -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;