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
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
50 changed files with 1203 additions and 886 deletions

View file

@ -50,12 +50,12 @@ const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.base
// Labels are i18n-driven so the sidebar matches the locale picked
// in panel settings without a page reload of the sidebar component.
const tabs = computed(() => [
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
{ key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}panel/nodes`, icon: 'cluster', title: t('menu.nodes') },
{ key: `${prefix}panel/settings`, icon: 'setting', title: t('menu.settings') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: t('menu.xray') },
{ key: `${prefix}logout`, icon: 'logout', title: t('logout') },
]);
const activeTab = ref([props.requestUri]);
@ -90,20 +90,9 @@ function closeDrawer() {
<template>
<div class="ant-sidebar">
<a-layout-sider
:theme="currentTheme"
collapsible
:collapsed="collapsed"
breakpoint="md"
@collapse="onCollapse"
>
<a-layout-sider :theme="currentTheme" collapsible :collapsed="collapsed" breakpoint="md" @collapse="onCollapse">
<ThemeSwitch />
<a-menu
:theme="currentTheme"
mode="inline"
:selected-keys="activeTab"
@click="({ key }) => openLink(key)"
>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
@ -111,22 +100,10 @@ function closeDrawer() {
</a-menu>
</a-layout-sider>
<a-drawer
placement="left"
:closable="false"
:open="drawerOpen"
:wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }"
:style="{ height: '100%' }"
@close="closeDrawer"
>
<a-drawer placement="left" :closable="false" :open="drawerOpen" :wrap-class-name="currentTheme"
:wrap-style="{ padding: 0 }" :style="{ height: '100%' }" @close="closeDrawer">
<ThemeSwitch />
<a-menu
:theme="currentTheme"
mode="inline"
:selected-keys="activeTab"
@click="({ key }) => openLink(key)"
>
<a-menu :theme="currentTheme" mode="inline" :selected-keys="activeTab" @click="({ key }) => openLink(key)">
<a-menu-item v-for="tab in tabs" :key="tab.key">
<component :is="iconByName[tab.icon]" />
<span>{{ tab.title }}</span>
@ -142,7 +119,7 @@ function closeDrawer() {
</template>
<style scoped>
.ant-sidebar > .ant-layout-sider {
.ant-sidebar>.ant-layout-sider {
height: 100%;
}
@ -171,12 +148,12 @@ function closeDrawer() {
/* On mobile the drawer is the menu hide the inline sider's content
* + the collapse trigger so the sider stops taking layout space and
* leaves no remnant button next to the page. */
.ant-sidebar > .ant-layout-sider :deep(.ant-layout-sider-children),
.ant-sidebar > .ant-layout-sider :deep(.ant-layout-sider-trigger) {
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-children),
.ant-sidebar>.ant-layout-sider :deep(.ant-layout-sider-trigger) {
display: none;
}
.ant-sidebar > .ant-layout-sider {
.ant-sidebar>.ant-layout-sider {
flex: 0 0 0 !important;
max-width: 0 !important;
min-width: 0 !important;

View file

@ -7,8 +7,12 @@ defineProps({
<template>
<a-statistic :title="title" :value="value">
<template #prefix><slot name="prefix" /></template>
<template #suffix><slot name="suffix" /></template>
<template #prefix>
<slot name="prefix" />
</template>
<template #suffix>
<slot name="suffix" />
</template>
</a-statistic>
</template>

View file

@ -51,29 +51,11 @@ function onAntChange(next) {
</script>
<template>
<PersianDatePicker
v-if="isJalali"
v-model="stringValue"
:format="ISO_FORMAT"
:display-format="persianDisplayFormat"
:placeholder="placeholder"
:disabled="disabled"
color="#1677ff"
auto-submit
append-to="body"
input-class="ant-input persian-datepicker-input"
class="jalali-datepicker"
/>
<a-date-picker
v-else
:value="value"
:show-time="showTime ? { format: 'HH:mm:ss' } : false"
:format="format"
:placeholder="placeholder"
:disabled="disabled"
:style="{ width: '100%' }"
@update:value="onAntChange"
/>
<PersianDatePicker v-if="isJalali" v-model="stringValue" :format="ISO_FORMAT" :display-format="persianDisplayFormat"
:placeholder="placeholder" :disabled="disabled" color="#1677ff" auto-submit append-to="body"
input-class="ant-input persian-datepicker-input" class="jalali-datepicker" />
<a-date-picker v-else :value="value" :show-time="showTime ? { format: 'HH:mm:ss' } : false" :format="format"
:placeholder="placeholder" :disabled="disabled" :style="{ width: '100%' }" @update:value="onAntChange" />
</template>
<style scoped>
@ -142,8 +124,8 @@ function onAntChange(next) {
background: #fff;
color: rgba(0, 0, 0, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.08),
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
0 3px 6px -4px rgba(0, 0, 0, 0.12),
0 9px 28px 8px rgba(0, 0, 0, 0.05);
border-radius: 8px;
overflow: hidden;
}
@ -166,7 +148,7 @@ function onAntChange(next) {
}
.vpd-wrapper .vpd-body .vpd-month-label,
.vpd-wrapper .vpd-body .vpd-month-label > span {
.vpd-wrapper .vpd-body .vpd-month-label>span {
color: rgba(0, 0, 0, 0.88);
}
@ -271,8 +253,8 @@ body.dark .vpd-wrapper .vpd-content {
background: #1a2c4d;
color: rgba(255, 255, 255, 0.88);
box-shadow: 0 6px 16px 0 rgba(0, 0, 0, 0.32),
0 3px 6px -4px rgba(0, 0, 0, 0.48),
0 9px 28px 8px rgba(0, 0, 0, 0.2);
0 3px 6px -4px rgba(0, 0, 0, 0.48),
0 9px 28px 8px rgba(0, 0, 0, 0.2);
}
body.dark .vpd-wrapper .vpd-body {
@ -281,7 +263,7 @@ body.dark .vpd-wrapper .vpd-body {
}
body.dark .vpd-wrapper .vpd-body .vpd-month-label,
body.dark .vpd-wrapper .vpd-body .vpd-month-label > span {
body.dark .vpd-wrapper .vpd-body .vpd-month-label>span {
color: rgba(255, 255, 255, 0.88);
}

View file

@ -66,27 +66,23 @@ function newNoiseItem() {
</script>
<template>
<a-form
v-if="showTcp || showUdp || showQuic"
:colon="false"
:label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }"
>
<a-form v-if="showTcp || showUdp || showQuic" :colon="false" :label-col="{ md: { span: 8 } }"
:wrapper-col="{ md: { span: 14 } }">
<!-- ============================== TCP MASKS ============================== -->
<template v-if="showTcp">
<a-form-item label="TCP Masks">
<a-button type="primary" size="small" @click="stream.addTcpMask('fragment')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(mask, mIdx) in (stream.finalmask.tcp || [])" :key="`tcp-${mIdx}`">
<a-divider :style="{ margin: '0' }">
TCP Mask {{ mIdx + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delTcpMask(mIdx)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delTcpMask(mIdx)" />
</a-divider>
<a-form-item label="Type">
@ -144,16 +140,16 @@ function newNoiseItem() {
<!-- Clients -->
<a-form-item label="Clients">
<a-button type="primary" size="small" @click="mask.settings.clients.push([newClientServerItem()])">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.clients" :key="`tcp-cg-${mIdx}-${gi}`">
<a-divider :style="{ margin: '0' }">
Clients Group {{ gi + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.clients.splice(gi, 1)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.clients.splice(gi, 1)" />
</a-divider>
<template v-for="(item, ii) in group" :key="`tcp-ci-${mIdx}-${gi}-${ii}`">
<a-form-item label="Type">
@ -177,13 +173,12 @@ function newNoiseItem() {
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="item.type === 'base64'" compact>
<a-input
v-model:value="item.packet"
placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }"
/>
<a-input v-model:value="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="item.packet = RandomUtil.randomBase64()">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
@ -194,16 +189,16 @@ function newNoiseItem() {
<!-- Servers -->
<a-form-item label="Servers">
<a-button type="primary" size="small" @click="mask.settings.servers.push([newClientServerItem()])">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(group, gi) in mask.settings.servers" :key="`tcp-sg-${mIdx}-${gi}`">
<a-divider :style="{ margin: '0' }">
Servers Group {{ gi + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.servers.splice(gi, 1)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.servers.splice(gi, 1)" />
</a-divider>
<template v-for="(item, ii) in group" :key="`tcp-si-${mIdx}-${gi}-${ii}`">
<a-form-item label="Type">
@ -227,13 +222,12 @@ function newNoiseItem() {
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="item.type === 'base64'" compact>
<a-input
v-model:value="item.packet"
placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }"
/>
<a-input v-model:value="item.packet" placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="item.packet = RandomUtil.randomBase64()">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="item.packet" placeholder="binary data" />
@ -248,17 +242,17 @@ function newNoiseItem() {
<template v-if="showUdp">
<a-form-item label="UDP Masks">
<a-button type="primary" size="small" @click="addUdpMaskWithDefault">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(mask, mIdx) in (stream.finalmask.udp || [])" :key="`udp-${mIdx}`">
<a-divider :style="{ margin: '0' }">
UDP Mask {{ mIdx + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delUdpMask(mIdx)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="stream.delUdpMask(mIdx)" />
</a-divider>
<a-form-item label="Type">
@ -290,13 +284,8 @@ function newNoiseItem() {
<a-input v-model:value="mask.settings.domain" placeholder="e.g., www.example.com" />
</a-form-item>
<a-form-item v-if="mask.type === 'xdns'" label="Domains">
<a-select
v-model:value="mask.settings.domains"
mode="tags"
:style="{ width: '100%' }"
:token-separators="[',']"
placeholder="e.g., www.example.com"
/>
<a-select v-model:value="mask.settings.domains" mode="tags" :style="{ width: '100%' }"
:token-separators="[',']" placeholder="e.g., www.example.com" />
</a-form-item>
<!-- Noise -->
@ -306,16 +295,16 @@ function newNoiseItem() {
</a-form-item>
<a-form-item label="Noise">
<a-button type="primary" size="small" @click="mask.settings.noise.push(newNoiseItem())">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(n, ni) in mask.settings.noise" :key="`udp-noise-${mIdx}-${ni}`">
<a-divider :style="{ margin: '0' }">
Noise {{ ni + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.noise.splice(ni, 1)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.noise.splice(ni, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="n.type" @change="(t) => changeItemType(n, t)">
@ -335,13 +324,11 @@ function newNoiseItem() {
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="n.type === 'base64'" compact>
<a-input
v-model:value="n.packet"
placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }"
/>
<a-input v-model:value="n.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="n.packet = RandomUtil.randomBase64()">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="n.packet" placeholder="binary data" />
@ -356,16 +343,16 @@ function newNoiseItem() {
<template v-if="mask.type === 'header-custom'">
<a-form-item label="Client">
<a-button type="primary" size="small" @click="mask.settings.client.push(newUdpClientServerItem())">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(c, ci) in mask.settings.client" :key="`udp-c-${mIdx}-${ci}`">
<a-divider :style="{ margin: '0' }">
Client {{ ci + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.client.splice(ci, 1)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.client.splice(ci, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="c.type" @change="(t) => changeItemType(c, t)">
@ -385,13 +372,11 @@ function newNoiseItem() {
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="c.type === 'base64'" compact>
<a-input
v-model:value="c.packet"
placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }"
/>
<a-input v-model:value="c.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="c.packet = RandomUtil.randomBase64()">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="c.packet" placeholder="binary data" />
@ -401,16 +386,16 @@ function newNoiseItem() {
<a-divider :style="{ margin: '0' }" />
<a-form-item label="Server">
<a-button type="primary" size="small" @click="mask.settings.server.push(newUdpClientServerItem())">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
</a-button>
</a-form-item>
<template v-for="(s, si) in mask.settings.server" :key="`udp-s-${mIdx}-${si}`">
<a-divider :style="{ margin: '0' }">
Server {{ si + 1 }}
<DeleteOutlined
:style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.server.splice(si, 1)"
/>
<DeleteOutlined :style="{ color: 'rgb(255, 77, 79)', cursor: 'pointer', marginLeft: '8px' }"
@click="mask.settings.server.splice(si, 1)" />
</a-divider>
<a-form-item label="Type">
<a-select :value="s.type" @change="(t) => changeItemType(s, t)">
@ -430,13 +415,11 @@ function newNoiseItem() {
</template>
<a-form-item v-else label="Packet">
<a-input-group v-if="s.type === 'base64'" compact>
<a-input
v-model:value="s.packet"
placeholder="binary data"
:style="{ width: 'calc(100% - 32px)' }"
/>
<a-input v-model:value="s.packet" placeholder="binary data" :style="{ width: 'calc(100% - 32px)' }" />
<a-button @click="s.packet = RandomUtil.randomBase64()">
<template #icon><ReloadOutlined /></template>
<template #icon>
<ReloadOutlined />
</template>
</a-button>
</a-input-group>
<a-input v-else v-model:value="s.packet" placeholder="binary data" />
@ -502,39 +485,24 @@ function newNoiseItem() {
<a-switch v-model:checked="stream.finalmask.quicParams.disablePathMTUDiscovery" />
</a-form-item>
<a-form-item label="Max Incoming Streams">
<a-input-number
v-model:value="stream.finalmask.quicParams.maxIncomingStreams"
:min="8"
placeholder="1024 = default"
/>
<a-input-number v-model:value="stream.finalmask.quicParams.maxIncomingStreams" :min="8"
placeholder="1024 = default" />
</a-form-item>
<a-form-item label="Init Stream Window">
<a-input-number
v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow"
:min="16384"
placeholder="8388608 = default"
/>
<a-input-number v-model:value="stream.finalmask.quicParams.initStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Max Stream Window">
<a-input-number
v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow"
:min="16384"
placeholder="8388608 = default"
/>
<a-input-number v-model:value="stream.finalmask.quicParams.maxStreamReceiveWindow" :min="16384"
placeholder="8388608 = default" />
</a-form-item>
<a-form-item label="Init Conn Window">
<a-input-number
v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow"
:min="16384"
placeholder="20971520 = default"
/>
<a-input-number v-model:value="stream.finalmask.quicParams.initConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
<a-form-item label="Max Conn Window">
<a-input-number
v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow"
:min="16384"
placeholder="20971520 = default"
/>
<a-input-number v-model:value="stream.finalmask.quicParams.maxConnectionReceiveWindow" :min="16384"
placeholder="20971520 = default" />
</a-form-item>
</template>
</template>

View file

@ -10,16 +10,9 @@ defineProps({
</script>
<template>
<svg
:width="width"
:height="height"
viewBox="0 0 640 512"
fill="currentColor"
aria-hidden="true"
style="vertical-align: -1px; display: inline-block;"
>
<svg :width="width" :height="height" viewBox="0 0 640 512" fill="currentColor" aria-hidden="true"
style="vertical-align: -1px; display: inline-block;">
<path
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z"
/>
d="M484.4 96C407 96 349.2 164.1 320 208.5C290.8 164.1 233 96 155.6 96C69.75 96 0 167.8 0 256s69.75 160 155.6 160C233.1 416 290.8 347.9 320 303.5C349.2 347.9 407 416 484.4 416C570.3 416 640 344.2 640 256S570.3 96 484.4 96zM155.6 368C96.25 368 48 317.8 48 256s48.25-112 107.6-112c67.75 0 120.5 82.25 137.1 112C276 285.8 223.4 368 155.6 368zM484.4 368c-67.75 0-120.5-82.25-137.1-112C364 226.2 416.6 144 484.4 144C543.8 144 592 194.2 592 256S543.8 368 484.4 368z" />
</svg>
</template>

View file

@ -43,28 +43,10 @@ function onKeydown(e) {
</script>
<template>
<a-modal
:open="open"
:title="title"
:ok-text="okText"
cancel-text="Cancel"
:mask-closable="false"
:confirm-loading="loading"
@ok="ok"
@cancel="close"
>
<a-textarea
v-if="type === 'textarea'"
v-model:value="value"
:auto-size="{ minRows: 10, maxRows: 20 }"
autofocus
@keydown="onKeydown"
/>
<a-input
v-else
v-model:value="value"
autofocus
@keydown="onKeydown"
/>
<a-modal :open="open" :title="title" :ok-text="okText" cancel-text="Cancel" :mask-closable="false"
:confirm-loading="loading" @ok="ok" @cancel="close">
<a-textarea v-if="type === 'textarea'" v-model:value="value" :auto-size="{ minRows: 10, maxRows: 20 }" autofocus
@keydown="onKeydown" />
<a-input v-else v-model:value="value" autofocus @keydown="onKeydown" />
</a-modal>
</template>

View file

@ -19,8 +19,12 @@ const padding = computed(() =>
<a-row :gutter="[8, 16]">
<a-col :xs="24" :lg="12">
<a-list-item-meta>
<template #title><slot name="title" /></template>
<template #description><slot name="description" /></template>
<template #title>
<slot name="title" />
</template>
<template #description>
<slot name="description" />
</template>
</a-list-item-meta>
</a-col>
<a-col :xs="24" :lg="12">

View file

@ -220,16 +220,8 @@ const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
</script>
<template>
<svg
ref="svgRef"
width="100%"
:height="height"
:viewBox="viewBoxAttr"
preserveAspectRatio="none"
class="sparkline-svg"
@mousemove="onMouseMove"
@mouseleave="onMouseLeave"
>
<svg ref="svgRef" width="100%" :height="height" :viewBox="viewBoxAttr" preserveAspectRatio="none"
class="sparkline-svg" @mousemove="onMouseMove" @mouseleave="onMouseLeave">
<defs>
<linearGradient :id="gradId" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" :stop-color="stroke" :stop-opacity="fillOpacity" />
@ -238,70 +230,28 @@ const gradId = `spkGrad-${Math.random().toString(36).slice(2, 9)}`;
</defs>
<g v-if="showGrid">
<line
v-for="(g, i) in gridLines"
:key="i"
:x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2"
:stroke="gridColor" stroke-width="1"
class="cpu-grid-line"
/>
<line v-for="(g, i) in gridLines" :key="i" :x1="g.x1" :y1="g.y1" :x2="g.x2" :y2="g.y2" :stroke="gridColor"
stroke-width="1" class="cpu-grid-line" />
</g>
<g v-if="showAxes">
<text
v-for="(t, i) in yTicks"
:key="'y' + i"
class="cpu-grid-y-text"
:x="Math.max(0, paddingLeft - 4)"
:y="t.y + 4"
text-anchor="end"
font-size="10"
>{{ t.label }}</text>
<text
v-for="(t, i) in xTicks"
:key="'x' + i"
class="cpu-grid-x-text"
:x="t.x"
:y="paddingTop + drawHeight + 14"
text-anchor="middle"
font-size="10"
>{{ t.label }}</text>
<text v-for="(t, i) in yTicks" :key="'y' + i" class="cpu-grid-y-text" :x="Math.max(0, paddingLeft - 4)"
:y="t.y + 4" text-anchor="end" font-size="10">{{ t.label }}</text>
<text v-for="(t, i) in xTicks" :key="'x' + i" class="cpu-grid-x-text" :x="t.x" :y="paddingTop + drawHeight + 14"
text-anchor="middle" font-size="10">{{ t.label }}</text>
</g>
<path v-if="areaPath" :d="areaPath" :fill="`url(#${gradId})`" stroke="none" />
<polyline
:points="pointsStr"
fill="none"
:stroke="stroke"
:stroke-width="strokeWidth"
stroke-linecap="round"
stroke-linejoin="round"
/>
<circle
v-if="showMarker && lastPoint"
:cx="lastPoint[0]" :cy="lastPoint[1]"
:r="markerRadius"
:fill="stroke"
/>
<polyline :points="pointsStr" fill="none" :stroke="stroke" :stroke-width="strokeWidth" stroke-linecap="round"
stroke-linejoin="round" />
<circle v-if="showMarker && lastPoint" :cx="lastPoint[0]" :cy="lastPoint[1]" :r="markerRadius" :fill="stroke" />
<g v-if="showTooltip && hoverIdx >= 0 && pointsArr[hoverIdx]">
<line
class="cpu-grid-h-line"
:x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]"
:y1="paddingTop" :y2="paddingTop + drawHeight"
stroke="rgba(0,0,0,0.2)" stroke-width="1"
/>
<circle
:cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]"
r="3.5" :fill="stroke"
/>
<text
class="cpu-grid-text"
:x="pointsArr[hoverIdx][0]"
:y="paddingTop + 12"
text-anchor="middle"
font-size="11"
>{{ fmtHoverText() }}</text>
<line class="cpu-grid-h-line" :x1="pointsArr[hoverIdx][0]" :x2="pointsArr[hoverIdx][0]" :y1="paddingTop"
:y2="paddingTop + drawHeight" stroke="rgba(0,0,0,0.2)" stroke-width="1" />
<circle :cx="pointsArr[hoverIdx][0]" :cy="pointsArr[hoverIdx][1]" r="3.5" :fill="stroke" />
<text class="cpu-grid-text" :x="pointsArr[hoverIdx][0]" :y="paddingTop + 12" text-anchor="middle"
font-size="11">{{ fmtHoverText() }}</text>
</g>
</svg>
</template>

View file

@ -266,33 +266,44 @@ export default defineComponent({
user-select: none;
touch-action: none;
}
.sortable-icon:hover {
color: rgba(255, 255, 255, 0.85);
background: rgba(255, 255, 255, 0.06);
}
.sortable-icon:active { cursor: grabbing; }
.sortable-icon:active {
cursor: grabbing;
}
.sortable-icon:focus-visible {
outline: 2px solid #008771;
outline-offset: 2px;
}
.light .sortable-icon { color: rgba(0, 0, 0, 0.45); }
.light .sortable-icon {
color: rgba(0, 0, 0, 0.45);
}
.light .sortable-icon:hover {
color: rgba(0, 0, 0, 0.85);
background: rgba(0, 0, 0, 0.05);
}
.sortable-table-dragging .sortable-source-row > td {
.sortable-table-dragging .sortable-source-row>td {
background: rgba(0, 135, 113, 0.10) !important;
transition: background-color 0.18s ease;
}
.sortable-table-dragging .sortable-source-row .routing-index,
.sortable-table-dragging .sortable-source-row .outbound-index {
opacity: 0.45;
}
.sortable-table-dragging .sortable-row > td {
.sortable-table-dragging .sortable-row>td {
transition: background-color 0.18s ease;
}
.sortable-table-dragging,
.sortable-table-dragging * {
user-select: none;

View file

@ -39,19 +39,18 @@ function download(content, name) {
<template>
<a-modal :open="open" :title="title" :closable="true" @cancel="close">
<a-textarea
:value="content"
readonly
:auto-size="{ minRows: 10, maxRows: 20 }"
class="text-modal-content"
/>
<a-textarea :value="content" readonly :auto-size="{ minRows: 10, maxRows: 20 }" class="text-modal-content" />
<template #footer>
<a-button v-if="fileName" @click="download(content, fileName)">
<template #icon><DownloadOutlined /></template>
<template #icon>
<DownloadOutlined />
</template>
{{ fileName }}
</a-button>
<a-button type="primary" @click="copy(content)">
<template #icon><CopyOutlined /></template>
<template #icon>
<CopyOutlined />
</template>
Copy
</a-button>
</template>

View file

@ -362,7 +362,7 @@ const title = computed(() =>
<a-form-item v-else>
<template #label>
<a-tooltip :title="t('pages.inbounds.leaveBlankToNeverExpire')">{{ t('pages.inbounds.expireDate')
}}</a-tooltip>
}}</a-tooltip>
</template>
<DateTimePicker v-model:value="expiryDate" />
<a-tag v-if="mode === 'edit' && isExpired" color="red">{{ t('depleted') }}</a-tag>

View file

@ -291,8 +291,8 @@ function rowKey(client) {
{{ IntlUtil.formatRelativeTime(client.expiryTime) }}
</a-tag>
</a-popover>
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)"
:style="{ border: 'none' }" class="infinite-tag">
<a-tag v-else :color="ColorUtils.userExpiryColor(expireDiff, client, isDarkTheme)" :style="{ border: 'none' }"
class="infinite-tag">
<InfinityIcon />
</a-tag>
</div>
@ -373,7 +373,9 @@ function rowKey(client) {
<a-tag v-else-if="client.expiryTime < 0" color="green">
{{ -client.expiryTime / 86400000 }}d ({{ t('pages.client.delayedStart') }})
</a-tag>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
</div>
@ -561,6 +563,7 @@ function rowKey(client) {
flex-direction: column;
gap: 6px;
}
:global(body.dark) .client-card {
border-color: rgba(255, 255, 255, 0.1);
}
@ -571,6 +574,7 @@ function rowKey(client) {
gap: 8px;
min-width: 0;
}
.client-card-head .client-email {
flex: 1;
min-width: 0;
@ -580,6 +584,7 @@ function rowKey(client) {
overflow: hidden;
text-overflow: ellipsis;
}
.client-card-actions {
margin-left: auto;
display: flex;
@ -587,6 +592,7 @@ function rowKey(client) {
gap: 8px;
flex-shrink: 0;
}
.client-card-actions .row-icon {
font-size: 20px;
padding: 4px;
@ -605,12 +611,14 @@ function rowKey(client) {
flex-direction: column;
gap: 4px;
}
.client-card-foot .stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.client-card-foot .stat-label {
font-size: 10px;
text-transform: uppercase;
@ -619,6 +627,7 @@ function rowKey(client) {
min-width: 96px;
flex-shrink: 0;
}
.client-card-foot :deep(.ant-tag) {
margin: 0;
}

View file

@ -560,19 +560,11 @@ watch(
<a-input v-model:value="dbForm.remark" />
</a-form-item>
<a-form-item :label="t('pages.inbounds.deployTo')">
<a-select
v-model:value="dbForm.nodeId"
:disabled="mode === 'edit'"
:placeholder="t('pages.inbounds.localPanel')"
allow-clear
>
<a-select v-model:value="dbForm.nodeId" :disabled="mode === 'edit'"
:placeholder="t('pages.inbounds.localPanel')" allow-clear>
<a-select-option :value="null">{{ t('pages.inbounds.localPanel') }}</a-select-option>
<a-select-option
v-for="n in selectableNodes"
:key="n.id"
:value="n.id"
:disabled="n.status === 'offline'"
>
<a-select-option v-for="n in selectableNodes" :key="n.id" :value="n.id"
:disabled="n.status === 'offline'">
{{ n.name }}{{ n.status === 'offline' ? ' (offline)' : '' }}
</a-select-option>
</a-select>

View file

@ -327,14 +327,16 @@ const showSubscriptionTab = computed(
<tr>
<td>{{ t('pages.inbounds.createdAt') }}</td>
<td>
<a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker) }}</a-tag>
<a-tag v-if="clientSettings.created_at">{{ IntlUtil.formatDate(clientSettings.created_at, datepicker)
}}</a-tag>
<a-tag v-else>-</a-tag>
</td>
</tr>
<tr>
<td>{{ t('pages.inbounds.updatedAt') }}</td>
<td>
<a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker) }}</a-tag>
<a-tag v-if="clientSettings.updated_at">{{ IntlUtil.formatDate(clientSettings.updated_at, datepicker)
}}</a-tag>
<a-tag v-else>-</a-tag>
</td>
</tr>
@ -356,7 +358,7 @@ const showSubscriptionTab = computed(
<div class="ip-log">
<div v-if="clientIpsArray.length > 0">
<a-tag v-for="(item, idx) in clientIpsArray" :key="idx" color="blue" class="ip-log-row">{{ item
}}</a-tag>
}}</a-tag>
</div>
<a-tag v-else>{{ clientIpsText || t('tgbot.noIpRecord') }}</a-tag>
</div>
@ -472,7 +474,7 @@ const showSubscriptionTab = computed(
</a-tooltip>
</div>
<a :href="subJsonLink" target="_blank" rel="noopener noreferrer" class="link-panel-anchor">{{ subJsonLink
}}</a>
}}</a>
</div>
</template>
</a-tab-pane>
@ -627,11 +629,7 @@ const showSubscriptionTab = computed(
<dd><a-tag class="value-tag">{{ inbound.settings.ip }}</a-tag></dd>
</div>
<template v-if="inbound.settings.auth === 'password' && inbound.settings.accounts?.length">
<div
v-for="(account, idx) in inbound.settings.accounts"
:key="idx"
class="info-row"
>
<div v-for="(account, idx) in inbound.settings.accounts" :key="idx" class="info-row">
<dt>{{ t('username') }} #{{ idx + 1 }}</dt>
<dd class="account-row">
<a-tag color="green" class="value-tag">{{ account.user }}</a-tag>
@ -651,11 +649,7 @@ const showSubscriptionTab = computed(
<!-- HTTP accounts -->
<dl v-if="dbInbound.isHTTP && inbound.settings.accounts?.length" class="info-list info-list-block">
<div
v-for="(account, idx) in inbound.settings.accounts"
:key="idx"
class="info-row"
>
<div v-for="(account, idx) in inbound.settings.accounts" :key="idx" class="info-row">
<dt>{{ t('username') }} #{{ idx + 1 }}</dt>
<dd class="account-row">
<a-tag color="green" class="value-tag">{{ account.user }}</a-tag>

View file

@ -276,8 +276,7 @@ function showQrCodeMenu(dbInbound) {
<span class="card-id">#{{ record.id }}</span>
<span class="tag-name">{{ record.remark }}</span>
<div class="card-actions" @click.stop>
<a-switch :checked="record.enable" size="small"
@change="(next) => onSwitchEnable(record, next)" />
<a-switch :checked="record.enable" size="small" @change="(next) => onSwitchEnable(record, next)" />
<a-dropdown :trigger="['click']" placement="bottomRight">
<MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay>
@ -391,17 +390,17 @@ function showQrCodeMenu(dbInbound) {
:color="ColorUtils.usageColor(Date.now(), expireDiff, record._expiryTime)">
{{ IntlUtil.formatRelativeTime(record.expiryTime) }}
</a-tag>
<a-tag v-else color="purple"><InfinityIcon /></a-tag>
<a-tag v-else color="purple">
<InfinityIcon />
</a-tag>
</div>
</div>
<!-- Expanded client list (multi-user only) -->
<div v-if="record.isMultiUser() && isExpanded(record.id)" class="card-clients">
<ClientRowTable :db-inbound="record" :is-mobile="true"
:traffic-diff="trafficDiff" :expire-diff="expireDiff" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
@edit-client="(p) => emit('edit-client', p)"
@qrcode-client="(p) => emit('qrcode-client', p)"
<ClientRowTable :db-inbound="record" :is-mobile="true" :traffic-diff="trafficDiff" :expire-diff="expireDiff"
:online-clients="onlineClients" :last-online-map="lastOnlineMap" :is-dark-theme="isDarkTheme"
@edit-client="(p) => emit('edit-client', p)" @qrcode-client="(p) => emit('qrcode-client', p)"
@info-client="(p) => emit('info-client', p)"
@reset-traffic-client="(p) => emit('reset-traffic-client', p)"
@delete-client="(p) => emit('delete-client', p)"
@ -412,8 +411,7 @@ function showQrCodeMenu(dbInbound) {
<!-- ====================== Desktop: a-table ======================== -->
<a-table v-else :columns="columns" :data-source="visibleInbounds" :row-key="(r) => r.id"
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }"
:style="{ marginTop: '10px' }" size="small"
:pagination="paginationFor(visibleInbounds)" :scroll="{ x: 1000 }" :style="{ marginTop: '10px' }" size="small"
:row-class-name="(r) => (r.isMultiUser() ? '' : 'hide-expand-icon')">
<!-- Per-inbound client list, expanded by clicking the row's
default expand chevron. Hidden via row-class-name for
@ -697,6 +695,7 @@ function showQrCodeMenu(dbInbound) {
flex-direction: column;
gap: 8px;
}
:global(body.dark) .inbound-card {
background: rgba(255, 255, 255, 0.03);
border-color: rgba(255, 255, 255, 0.1);
@ -709,10 +708,12 @@ function showQrCodeMenu(dbInbound) {
cursor: pointer;
user-select: none;
}
.card-id {
font-size: 11px;
opacity: 0.6;
}
.tag-name {
font-weight: 600;
flex: 1;
@ -721,18 +722,21 @@ function showQrCodeMenu(dbInbound) {
text-overflow: ellipsis;
white-space: nowrap;
}
.card-actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.card-expand {
font-size: 12px;
opacity: 0.6;
transition: transform 150ms ease;
flex-shrink: 0;
}
.card-expand.is-expanded {
transform: rotate(90deg);
}
@ -742,12 +746,14 @@ function showQrCodeMenu(dbInbound) {
flex-direction: column;
gap: 6px;
}
.stat-row {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.stat-label {
font-size: 10px;
text-transform: uppercase;
@ -756,6 +762,7 @@ function showQrCodeMenu(dbInbound) {
min-width: 96px;
flex-shrink: 0;
}
.card-stats :deep(.ant-tag) {
margin: 0;
}
@ -777,10 +784,12 @@ function showQrCodeMenu(dbInbound) {
padding: 0 12px;
min-height: 44px;
}
:deep(.ant-card-head-title),
:deep(.ant-card-extra) {
padding: 8px 0;
}
:deep(.ant-card-body) {
padding: 8px;
}
@ -790,7 +799,8 @@ function showQrCodeMenu(dbInbound) {
flex-wrap: wrap;
gap: 6px;
}
.filter-bar.mobile > * {
.filter-bar.mobile>* {
margin-bottom: 0;
}

View file

@ -608,11 +608,11 @@ function onRowAction({ key, dbInbound }) {
<!-- Inbound list toolbar, search/filter, columns, row actions -->
<a-col :span="24">
<InboundList :db-inbounds="dbInbounds" :client-count="clientCount" :online-clients="onlineClients"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark"
:expire-diff="expireDiff" :traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh" @add-inbound="onAddInbound"
@general-action="onGeneralAction" @row-action="onRowAction" @edit-client="onEditClient"
@qrcode-client="onQrcodeClient" @info-client="onInfoClient"
:last-online-map="lastOnlineMap" :is-dark-theme="themeState.isDark" :expire-diff="expireDiff"
:traffic-diff="trafficDiff" :page-size="pageSize" :is-mobile="isMobile"
:sub-enable="subSettings.enable" :nodes-by-id="nodesById" @refresh="refresh"
@add-inbound="onAddInbound" @general-action="onGeneralAction" @row-action="onRowAction"
@edit-client="onEditClient" @qrcode-client="onQrcodeClient" @info-client="onInfoClient"
@reset-traffic-client="onResetTrafficClient" @delete-client="onDeleteClient"
@toggle-enable-client="onToggleEnableClient" />
</a-col>

View file

@ -107,11 +107,7 @@ function download() {
</a-tooltip>
</div>
<div v-if="showQr" class="qr-panel-canvas">
<canvas
ref="canvas"
:style="{ width: `${size}px`, height: `${size}px` }"
@click="copy"
/>
<canvas ref="canvas" :style="{ width: `${size}px`, height: `${size}px` }" @click="copy" />
</div>
</div>
</template>
@ -154,5 +150,4 @@ function download() {
image-rendering: pixelated;
image-rendering: crisp-edges;
}
</style>

View file

@ -89,8 +89,8 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
</script>
<template>
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth"
:class="{ 'logmodal-mobile': isMobile }" @cancel="close">
<a-modal :open="open" :closable="true" :footer="null" :width="modalWidth" :class="{ 'logmodal-mobile': isMobile }"
@cancel="close">
<template #title>
{{ t('pages.index.logs') }}
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
@ -178,6 +178,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
flex-wrap: wrap;
row-gap: 8px;
}
.log-toolbar .download-item {
margin-left: auto;
}
@ -185,12 +186,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
.log-container {
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
below so each level keeps 4.5:1 contrast against the container. */
--log-stamp: #3c89e8;
--log-debug: #3c89e8;
--log-info: #008771;
--log-notice: #008771;
--log-stamp: #3c89e8;
--log-debug: #3c89e8;
--log-info: #008771;
--log-notice: #008771;
--log-warning: #f37b24;
--log-error: #e04141;
--log-error: #e04141;
--log-unknown: #595959;
--log-divider: rgba(128, 128, 128, 0.18);
@ -208,14 +209,37 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
background: rgba(0, 0, 0, 0.04);
}
.log-stamp { color: var(--log-stamp); }
.log-level { margin-left: 4px; }
.level-debug { color: var(--log-debug); }
.level-info { color: var(--log-info); }
.level-notice { color: var(--log-notice); }
.level-warning { color: var(--log-warning); }
.level-error { color: var(--log-error); }
.level-unknown { color: var(--log-unknown); }
.log-stamp {
color: var(--log-stamp);
}
.log-level {
margin-left: 4px;
}
.level-debug {
color: var(--log-debug);
}
.level-info {
color: var(--log-info);
}
.level-notice {
color: var(--log-notice);
}
.level-warning {
color: var(--log-warning);
}
.level-error {
color: var(--log-error);
}
.level-unknown {
color: var(--log-unknown);
}
.log-container-mobile {
padding: 8px;
@ -229,7 +253,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
padding: 20px 0;
}
.log-line + .log-line {
.log-line+.log-line {
margin-top: 2px;
}
@ -237,7 +261,11 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
border-bottom: 1px solid var(--log-divider);
padding: 8px 0;
}
.log-card:last-child { border-bottom: 0; }
.log-card:last-child {
border-bottom: 0;
}
.log-card-head {
display: flex;
align-items: center;
@ -245,6 +273,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
gap: 8px;
margin-bottom: 4px;
}
.log-time {
display: inline-flex;
align-items: baseline;
@ -253,11 +282,13 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
font-size: 12px;
letter-spacing: 0.02em;
}
.log-date {
font-size: 10px;
font-weight: 500;
opacity: 0.55;
}
.log-level-badge {
display: inline-block;
font-size: 10px;
@ -270,10 +301,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
white-space: nowrap;
background: color-mix(in srgb, currentColor 14%, transparent);
}
.log-body {
font-size: 12px;
word-break: break-word;
}
.log-body-text {
margin-left: 4px;
}
@ -283,23 +316,23 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.88);
--log-stamp: #6aa6ee;
--log-debug: #6aa6ee;
--log-info: #4ed3a6;
--log-notice: #4ed3a6;
--log-stamp: #6aa6ee;
--log-debug: #6aa6ee;
--log-info: #4ed3a6;
--log-notice: #4ed3a6;
--log-warning: #ffb872;
--log-error: #ff7575;
--log-error: #ff7575;
--log-unknown: #b5b5b5;
--log-divider: rgba(255, 255, 255, 0.1);
}
:global([data-theme="ultra-dark"]) .log-container {
--log-stamp: #7fb6f1;
--log-debug: #7fb6f1;
--log-info: #5fd9b0;
--log-notice: #5fd9b0;
--log-stamp: #7fb6f1;
--log-debug: #7fb6f1;
--log-info: #5fd9b0;
--log-notice: #5fd9b0;
--log-warning: #ffcc88;
--log-error: #ff8a8a;
--log-error: #ff8a8a;
--log-unknown: #c4c4c4;
--log-divider: rgba(255, 255, 255, 0.12);
}
@ -310,10 +343,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '800px'));
padding-bottom: 0 !important;
max-width: 100vw !important;
}
:global(.logmodal-mobile .ant-modal-content) {
border-radius: 0;
height: 100vh;
}
:global(.logmodal-mobile .ant-modal-body) {
padding: 12px;
}

View file

@ -30,8 +30,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
<a-col :xs="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color"
:trail-color="trailColor" :percent="status.cpu.percent" :width="gaugeSize" />
<a-progress type="dashboard" status="normal" :stroke-color="status.cpu.color" :trail-color="trailColor"
:percent="status.cpu.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
<a-tooltip>
@ -46,8 +46,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
</a-col>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color"
:trail-color="trailColor" :percent="status.mem.percent" :width="gaugeSize" />
<a-progress type="dashboard" status="normal" :stroke-color="status.mem.color" :trail-color="trailColor"
:percent="status.mem.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
{{ SizeFormatter.sizeFormat(status.mem.total) }}
@ -60,8 +60,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
<a-col :xs="24" :md="12">
<a-row>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color"
:trail-color="trailColor" :percent="status.swap.percent" :width="gaugeSize" />
<a-progress type="dashboard" status="normal" :stroke-color="status.swap.color" :trail-color="trailColor"
:percent="status.swap.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
{{ SizeFormatter.sizeFormat(status.swap.total) }}
@ -69,8 +69,8 @@ const trailColor = 'rgba(128, 128, 128, 0.25)';
</a-col>
<a-col :span="12" class="text-center">
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color"
:trail-color="trailColor" :percent="status.disk.percent" :width="gaugeSize" />
<a-progress type="dashboard" status="normal" :stroke-color="status.disk.color" :trail-color="trailColor"
:percent="status.disk.percent" :width="gaugeSize" />
<div>
<b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
{{ SizeFormatter.sizeFormat(status.disk.total) }}

View file

@ -130,12 +130,9 @@ watch([activeKey, bucket], () => {
<div class="cpu-chart-meta">
Timeframe: {{ bucket }} sec per point (total {{ points.length }} points)
</div>
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220"
:stroke="strokeColor" :stroke-width="2.2"
:show-grid="true" :show-axes="true" :tick-count-x="5"
:max-points="points.length || 1"
:fill-opacity="0.18" :marker-radius="3.2" :show-tooltip="true"
:value-min="0" :value-max="activeMetric?.valueMax ?? null"
<Sparkline :data="points" :labels="labels" :vb-width="840" :height="220" :stroke="strokeColor" :stroke-width="2.2"
:show-grid="true" :show-axes="true" :tick-count-x="5" :max-points="points.length || 1" :fill-opacity="0.18"
:marker-radius="3.2" :show-tooltip="true" :value-min="0" :value-max="activeMetric?.valueMax ?? null"
:y-formatter="yFormatter" />
</div>
</a-modal>

View file

@ -161,7 +161,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
<table v-else class="xraylog-table">
<thead>
<tr>
<th>Date</th><th>From</th><th>To</th><th>Inbound</th><th>Outbound</th><th>Email</th>
<th>Date</th>
<th>From</th>
<th>To</th>
<th>Inbound</th>
<th>Outbound</th>
<th>Email</th>
</tr>
</thead>
<tbody>
@ -190,9 +195,11 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
flex-wrap: wrap;
row-gap: 8px;
}
.log-toolbar .filter-item {
flex: 1 1 160px;
}
.log-toolbar .download-item {
margin-left: auto;
}
@ -201,7 +208,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
/* Per-theme palette — overridden in body.dark / [data-theme="ultra-dark"]
below so blocked/proxy rows keep 4.5:1 contrast on darker surfaces. */
--log-blocked: #e04141;
--log-proxy: #3c89e8;
--log-proxy: #3c89e8;
--log-divider: rgba(128, 128, 128, 0.18);
margin-top: 12px;
@ -215,6 +222,7 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
border-radius: 6px;
background: rgba(0, 0, 0, 0.04);
}
.log-container-mobile {
padding: 8px;
font-size: 12px;
@ -231,7 +239,10 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
border-bottom: 1px solid var(--log-divider);
padding: 8px 0;
}
.log-card:last-child { border-bottom: 0; }
.log-card:last-child {
border-bottom: 0;
}
.log-card-head {
display: flex;
@ -240,11 +251,13 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
gap: 8px;
margin-bottom: 4px;
}
.log-time {
font-weight: 600;
font-size: 12px;
letter-spacing: 0.02em;
}
.log-event-tag {
margin: 0;
font-size: 10px;
@ -260,9 +273,11 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
font-size: 12px;
margin-bottom: 4px;
}
.log-addr {
word-break: break-all;
}
.log-arrow {
opacity: 0.5;
}
@ -274,12 +289,14 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
font-size: 11px;
opacity: 0.75;
}
.log-meta-pair {
display: inline-flex;
align-items: baseline;
gap: 4px;
word-break: break-all;
}
.log-meta-key {
font-size: 10px;
text-transform: uppercase;
@ -293,13 +310,13 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
color: rgba(255, 255, 255, 0.88);
--log-blocked: #ff7575;
--log-proxy: #6aa6ee;
--log-proxy: #6aa6ee;
--log-divider: rgba(255, 255, 255, 0.1);
}
:global([data-theme="ultra-dark"]) .log-container {
--log-blocked: #ff8a8a;
--log-proxy: #7fb6f1;
--log-proxy: #7fb6f1;
--log-divider: rgba(255, 255, 255, 0.12);
}
@ -309,10 +326,12 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
padding-bottom: 0 !important;
max-width: 100vw !important;
}
:global(.xraylog-modal-mobile .ant-modal-content) {
border-radius: 0;
height: 100vh;
}
:global(.xraylog-modal-mobile .ant-modal-body) {
padding: 12px;
}
@ -321,11 +340,18 @@ const modalWidth = computed(() => (isMobile.value ? '100vw' : '80vw'));
border-collapse: collapse;
width: 100%;
}
.xraylog-table td,
.xraylog-table th {
padding: 2px 15px;
text-align: left;
}
.xraylog-table .log-row-1 { color: var(--log-blocked); }
.xraylog-table .log-row-2 { color: var(--log-proxy); }
.xraylog-table .log-row-1 {
color: var(--log-blocked);
}
.xraylog-table .log-row-2 {
color: var(--log-proxy);
}
</style>

View file

@ -111,17 +111,8 @@ async function onSave() {
</script>
<template>
<a-modal
:open="open"
:title="title"
:confirm-loading="submitting"
:ok-text="t('save')"
:cancel-text="t('cancel')"
:mask-closable="false"
width="640px"
@ok="onSave"
@cancel="close"
>
<a-modal :open="open" :title="title" :confirm-loading="submitting" :ok-text="t('save')" :cancel-text="t('cancel')"
:mask-closable="false" width="640px" @ok="onSave" @cancel="close">
<a-form layout="vertical" :model="form">
<a-row :gutter="16">
<a-col :span="12">
@ -171,10 +162,7 @@ async function onSave() {
</a-row>
<a-form-item :label="t('pages.nodes.apiToken')" required>
<a-input-password
v-model:value="form.apiToken"
:placeholder="t('pages.nodes.apiTokenPlaceholder')"
/>
<a-input-password v-model:value="form.apiToken" :placeholder="t('pages.nodes.apiTokenPlaceholder')" />
<div class="hint">{{ t('pages.nodes.apiTokenHint') }}</div>
</a-form-item>
@ -183,20 +171,11 @@ async function onSave() {
{{ t('pages.nodes.testConnection') }}
</a-button>
<div v-if="testResult" class="test-result">
<a-alert
v-if="testResult.status === 'online'"
type="success"
show-icon
<a-alert v-if="testResult.status === 'online'" type="success" show-icon
:message="t('pages.nodes.connectionOk', { ms: testResult.latencyMs })"
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined"
/>
<a-alert
v-else
type="error"
show-icon
:message="t('pages.nodes.connectionFailed')"
:description="testResult.error"
/>
:description="testResult.xrayVersion ? `Xray ${testResult.xrayVersion}` : undefined" />
<a-alert v-else type="error" show-icon :message="t('pages.nodes.connectionFailed')"
:description="testResult.error" />
</div>
</div>
</a-form>

View file

@ -79,33 +79,15 @@ watch(() => props.node?.id, (a, b) => {
<div class="node-history-panel">
<div class="series">
<div class="series-title">{{ t('pages.nodes.cpu') }}</div>
<Sparkline
:data="cpuPoints"
:labels="cpuLabels"
:vb-width="640" :height="120"
stroke="#008771"
:show-grid="true" :show-axes="true"
:tick-count-x="4"
:max-points="cpuPoints.length || 1"
:fill-opacity="0.18"
:marker-radius="2.6"
:show-tooltip="true"
/>
<Sparkline :data="cpuPoints" :labels="cpuLabels" :vb-width="640" :height="120" stroke="#008771" :show-grid="true"
:show-axes="true" :tick-count-x="4" :max-points="cpuPoints.length || 1" :fill-opacity="0.18"
:marker-radius="2.6" :show-tooltip="true" />
</div>
<div class="series">
<div class="series-title">{{ t('pages.nodes.mem') }}</div>
<Sparkline
:data="memPoints"
:labels="memLabels"
:vb-width="640" :height="120"
stroke="#7c4dff"
:show-grid="true" :show-axes="true"
:tick-count-x="4"
:max-points="memPoints.length || 1"
:fill-opacity="0.18"
:marker-radius="2.6"
:show-tooltip="true"
/>
<Sparkline :data="memPoints" :labels="memLabels" :vb-width="640" :height="120" stroke="#7c4dff" :show-grid="true"
:show-axes="true" :tick-count-x="4" :max-points="memPoints.length || 1" :fill-opacity="0.18"
:marker-radius="2.6" :show-tooltip="true" />
</div>
</div>
</template>

View file

@ -76,19 +76,15 @@ function formatPct(p) {
<a-card size="small" hoverable>
<div class="toolbar">
<a-button type="primary" @click="emit('add')">
<template #icon><PlusOutlined /></template>
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.nodes.addNode') }}
</a-button>
</div>
<a-table
:data-source="dataSource"
:pagination="false"
:loading="loading"
:scroll="{ x: 'max-content' }"
size="middle"
row-key="id"
>
<a-table :data-source="dataSource" :pagination="false" :loading="loading" :scroll="{ x: 'max-content' }"
size="middle" row-key="id">
<template #expandedRowRender="{ record }">
<NodeHistoryPanel :node="record" />
</template>
@ -110,7 +106,8 @@ function formatPct(p) {
<a-table-column :title="t('pages.nodes.status')" data-index="status" align="center">
<template #default="{ record }">
<a-space :size="4">
<a-badge :status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
<a-badge
:status="statusColor(record.status) === 'green' ? 'success' : (statusColor(record.status) === 'red' ? 'error' : 'default')" />
<span>{{ t(`pages.nodes.statusValues.${record.status || 'unknown'}`) }}</span>
<a-tooltip v-if="record.lastError" :title="record.lastError">
<ExclamationCircleOutlined style="color: #faad14" />
@ -150,11 +147,7 @@ function formatPct(p) {
<a-table-column :title="t('pages.nodes.enable')" data-index="enable" align="center" :width="80">
<template #default="{ record }">
<a-switch
:checked="record.enable"
size="small"
@change="(v) => emit('toggle-enable', record, v)"
/>
<a-switch :checked="record.enable" size="small" @change="(v) => emit('toggle-enable', record, v)" />
</template>
</a-table-column>
@ -163,17 +156,23 @@ function formatPct(p) {
<a-space>
<a-tooltip :title="t('pages.nodes.probe')">
<a-button type="text" size="small" @click="emit('probe', record)">
<template #icon><ThunderboltOutlined /></template>
<template #icon>
<ThunderboltOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('edit')">
<a-button type="text" size="small" @click="emit('edit', record)">
<template #icon><EditOutlined /></template>
<template #icon>
<EditOutlined />
</template>
</a-button>
</a-tooltip>
<a-tooltip :title="t('delete')">
<a-button type="text" size="small" danger @click="emit('delete', record)">
<template #icon><DeleteOutlined /></template>
<template #icon>
<DeleteOutlined />
</template>
</a-button>
</a-tooltip>
</a-space>

View file

@ -100,10 +100,7 @@ async function onToggleEnable(node, next) {
<template>
<a-config-provider :theme="antdThemeConfig">
<a-layout
class="nodes-page"
:class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }"
>
<a-layout class="nodes-page" :class="{ 'is-dark': themeState.isDark, 'is-ultra': themeState.isUltra }">
<AppSidebar :base-path="basePath" :request-uri="requestUri" />
<a-layout class="content-shell">
@ -117,40 +114,29 @@ async function onToggleEnable(node, next) {
<a-card size="small" hoverable class="summary-card">
<a-row :gutter="[16, 12]">
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.totalNodes')"
:value="String(totals.total)"
>
<CustomStatistic :title="t('pages.nodes.totalNodes')" :value="String(totals.total)">
<template #prefix>
<CloudServerOutlined />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.onlineNodes')"
:value="String(totals.online)"
>
<CustomStatistic :title="t('pages.nodes.onlineNodes')" :value="String(totals.online)">
<template #prefix>
<CheckCircleOutlined style="color: #52c41a" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.offlineNodes')"
:value="String(totals.offline)"
>
<CustomStatistic :title="t('pages.nodes.offlineNodes')" :value="String(totals.offline)">
<template #prefix>
<CloseCircleOutlined style="color: #ff4d4f" />
</template>
</CustomStatistic>
</a-col>
<a-col :sm="12" :md="6">
<CustomStatistic
:title="t('pages.nodes.avgLatency')"
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'"
>
<CustomStatistic :title="t('pages.nodes.avgLatency')"
:value="totals.avgLatency > 0 ? `${totals.avgLatency} ms` : '-'">
<template #prefix>
<ThunderboltOutlined />
</template>
@ -162,29 +148,16 @@ async function onToggleEnable(node, next) {
<!-- Node table -->
<a-col :span="24">
<NodeList
:nodes="nodes"
:loading="loading"
:is-mobile="isMobile"
@add="onAdd"
@edit="onEdit"
@delete="onDelete"
@probe="onProbe"
@toggle-enable="onToggleEnable"
/>
<NodeList :nodes="nodes" :loading="loading" :is-mobile="isMobile" @add="onAdd" @edit="onEdit"
@delete="onDelete" @probe="onProbe" @toggle-enable="onToggleEnable" />
</a-col>
</a-row>
</a-spin>
</a-layout-content>
</a-layout>
<NodeFormModal
v-model:open="formOpen"
:mode="formMode"
:node="formNode"
:test-connection="testConnection"
:save="onSave"
/>
<NodeFormModal v-model:open="formOpen" :mode="formMode" :node="formNode" :test-connection="testConnection"
:save="onSave" />
</a-layout>
</a-config-provider>
</template>

View file

@ -221,12 +221,7 @@ function toggleTwoFactor() {
<template #title>{{ t('pages.nodes.apiToken') }}</template>
<template #description>{{ t('pages.nodes.apiTokenHint') }}</template>
<template #control>
<a-input-password
:value="apiToken"
readonly
:loading="apiTokenLoading"
style="min-width: 240px"
/>
<a-input-password :value="apiToken" readonly :loading="apiTokenLoading" style="min-width: 240px" />
</template>
</SettingListItem>
<a-list-item>

View file

@ -163,11 +163,8 @@ const themeClass = computed(() => ({
<ThemeSwitchLogin />
<span>{{ t('pages.settings.language') }}</span>
<a-select v-model:value="lang" class="lang-select" @change="onLangChange">
<a-select-option
v-for="l in LanguageManager.supportedLanguages"
:key="l.value"
:value="l.value"
>
<a-select-option v-for="l in LanguageManager.supportedLanguages" :key="l.value"
:value="l.value">
<span :aria-label="l.name">{{ l.icon }}</span>
&nbsp;&nbsp;<span>{{ l.name }}</span>
</a-select-option>
@ -175,7 +172,9 @@ const themeClass = computed(() => ({
</a-space>
</template>
<a-button shape="circle">
<template #icon><SettingOutlined /></template>
<template #icon>
<SettingOutlined />
</template>
</a-button>
</a-popover>
</template>
@ -185,12 +184,7 @@ const themeClass = computed(() => ({
<a-col :xs="24" :sm="subJsonUrl || subClashUrl ? 12 : 24" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">{{ t('pages.settings.subSettings') }}</a-tag>
<canvas
ref="subQr"
class="qr-canvas"
:title="t('copy')"
@click="copy(subUrl)"
/>
<canvas ref="subQr" class="qr-canvas" :title="t('copy')" @click="copy(subUrl)" />
</div>
</a-col>
<a-col v-if="subJsonUrl" :xs="24" :sm="12" class="qr-col">
@ -198,23 +192,13 @@ const themeClass = computed(() => ({
<a-tag color="purple" class="qr-tag">
{{ t('pages.settings.subSettings') }} JSON
</a-tag>
<canvas
ref="subJsonQr"
class="qr-canvas"
:title="t('copy')"
@click="copy(subJsonUrl)"
/>
<canvas ref="subJsonQr" class="qr-canvas" :title="t('copy')" @click="copy(subJsonUrl)" />
</div>
</a-col>
<a-col v-if="subClashUrl" :xs="24" :sm="12" class="qr-col">
<div class="qr-box">
<a-tag color="purple" class="qr-tag">Clash / Mihomo</a-tag>
<canvas
ref="subClashQr"
class="qr-canvas"
:title="t('copy')"
@click="copy(subClashUrl)"
/>
<canvas ref="subClashQr" class="qr-canvas" :title="t('copy')" @click="copy(subClashUrl)" />
</div>
</a-col>
</a-row>
@ -248,12 +232,7 @@ const themeClass = computed(() => ({
<!-- ============== Individual links ============== -->
<div v-if="links.length" class="links-section">
<div
v-for="(link, idx) in links"
:key="link"
class="link-row"
@click="copy(link)"
>
<div v-for="(link, idx) in links" :key="link" class="link-row" @click="copy(link)">
<a-tag color="purple" class="link-tag">{{ linkName(link, idx) }}</a-tag>
<div class="link-box">
<CopyOutlined class="link-copy-icon" />
@ -267,12 +246,15 @@ const themeClass = computed(() => ({
<a-col :xs="24" :sm="12" class="app-col">
<a-dropdown :trigger="['click']">
<a-button :block="isMobile" size="large" type="primary">
<AndroidOutlined /> Android <DownOutlined />
<AndroidOutlined /> Android
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
<a-menu-item key="android-v2box" @click="open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`)">V2Box</a-menu-item>
<a-menu-item key="android-v2rayng" @click="open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`)">V2RayNG</a-menu-item>
<a-menu-item key="android-v2box"
@click="open(`v2box://install-sub?url=${encodeURIComponent(subUrl)}&name=${encodeURIComponent(sId)}`)">V2Box</a-menu-item>
<a-menu-item key="android-v2rayng"
@click="open(`v2rayng://install-config?url=${encodeURIComponent(subUrl)}`)">V2RayNG</a-menu-item>
<a-menu-item key="android-singbox" @click="copy(subUrl)">Sing-box</a-menu-item>
<a-menu-item key="android-v2raytun" @click="copy(subUrl)">V2RayTun</a-menu-item>
<a-menu-item key="android-npvtunnel" @click="copy(subUrl)">NPV Tunnel</a-menu-item>
@ -284,7 +266,8 @@ const themeClass = computed(() => ({
<a-col :xs="24" :sm="12" class="app-col">
<a-dropdown :trigger="['click']">
<a-button :block="isMobile" size="large" type="primary">
<AppleOutlined /> iOS <DownOutlined />
<AppleOutlined /> iOS
<DownOutlined />
</a-button>
<template #overlay>
<a-menu>
@ -314,14 +297,17 @@ const themeClass = computed(() => ({
min-height: 100vh;
background: var(--bg-page);
}
.subscription-page.is-dark {
--bg-page: #0a1222;
--bg-card: #151f31;
}
.subscription-page.is-dark.is-ultra {
--bg-page: #050505;
--bg-card: #0c0e12;
}
.subscription-page :deep(.ant-layout),
.subscription-page :deep(.ant-layout-content) {
background: transparent;
@ -339,10 +325,12 @@ const themeClass = computed(() => ({
.qr-row {
margin-bottom: 12px;
}
.qr-col {
display: flex;
justify-content: center;
}
.qr-box {
display: inline-flex;
flex-direction: column;
@ -350,11 +338,13 @@ const themeClass = computed(() => ({
gap: 4px;
width: 220px;
}
.qr-tag {
width: 100%;
text-align: center;
margin: 0;
}
.qr-canvas {
cursor: pointer;
background: #fff;
@ -370,16 +360,19 @@ const themeClass = computed(() => ({
.info-table {
margin-top: 12px;
}
.info-table :deep(.ant-descriptions-view),
.info-table :deep(.ant-descriptions-view) table,
.info-table :deep(.ant-descriptions-view) th,
.info-table :deep(.ant-descriptions-view) td {
border-color: rgba(0, 0, 0, 0.18) !important;
}
.info-table :deep(tbody > tr > th),
.info-table :deep(tbody > tr > td) {
border-bottom: 1px solid rgba(0, 0, 0, 0.18) !important;
}
.info-table :deep(tbody > tr:last-child > th),
.info-table :deep(tbody > tr:last-child > td) {
border-bottom: none !important;
@ -391,10 +384,12 @@ const themeClass = computed(() => ({
.is-dark .info-table :deep(.ant-descriptions-view) td {
border-color: rgba(255, 255, 255, 0.18) !important;
}
.is-dark .info-table :deep(tbody > tr > th),
.is-dark .info-table :deep(tbody > tr > td) {
border-bottom: 1px solid rgba(255, 255, 255, 0.18) !important;
}
.is-dark .info-table :deep(tbody > tr:last-child > th),
.is-dark .info-table :deep(tbody > tr:last-child > td) {
border-bottom: none !important;
@ -404,17 +399,20 @@ const themeClass = computed(() => ({
.links-section {
margin-top: 16px;
}
.link-row {
position: relative;
margin-bottom: 16px;
text-align: center;
}
.link-tag {
margin-bottom: -10px;
position: relative;
z-index: 2;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.2);
}
.link-box {
cursor: pointer;
border-radius: 12px;
@ -430,19 +428,23 @@ const themeClass = computed(() => ({
background: rgba(0, 0, 0, 0.03);
border: 1px solid rgba(0, 0, 0, 0.08);
}
.link-box:hover {
background: rgba(0, 0, 0, 0.05);
border-color: rgba(0, 0, 0, 0.14);
}
.link-copy-icon {
margin-right: 6px;
opacity: 0.6;
}
.is-dark .link-box {
background: rgba(0, 0, 0, 0.2);
border-color: rgba(255, 255, 255, 0.1);
color: rgba(255, 255, 255, 0.85);
}
.is-dark .link-box:hover {
background: rgba(0, 0, 0, 0.3);
border-color: rgba(255, 255, 255, 0.2);
@ -452,6 +454,7 @@ const themeClass = computed(() => ({
.apps-row {
margin-top: 24px;
}
.app-col {
text-align: center;
}
@ -459,6 +462,7 @@ const themeClass = computed(() => ({
.settings-popover {
min-width: 220px;
}
.lang-select {
width: 100%;
}

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>

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>

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>

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>

View file

@ -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 ============================== -->

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>

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>

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>

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>

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;

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "استخدام ملف hosts من نظام مثبت",
"usePreset": "استخدام النموذج",
"dnsPresetTitle": "قوالب DNS",
"dnsPresetFamily": "العائلي"
"dnsPresetFamily": "العائلي",
"serveStale": "تقديم النتائج المنتهية",
"serveStaleDesc": "إرجاع نتائج الكاش المنتهية الصلاحية أثناء التحديث في الخلفية",
"serveExpiredTTL": "مدة صلاحية النتائج المنتهية",
"serveExpiredTTLDesc": "مدة صلاحية إدخالات الكاش المنتهية بالثواني؛ 0 = لا تنتهي أبدًا",
"timeoutMs": "المهلة (مللي ثانية)",
"skipFallback": "تخطي الاحتياطي",
"finalQuery": "الاستعلام النهائي",
"hosts": "Hosts",
"hostsAdd": "إضافة Host",
"hostsEmpty": "لم يتم تعريف أي Host",
"hostsDomain": "النطاق (مثل domain:example.com)",
"hostsValues": "عنوان IP أو نطاق — اكتب واضغط Enter",
"clearAll": "حذف الكل",
"clearAllTitle": "حذف جميع خوادم DNS؟",
"clearAllConfirm": "سيؤدي هذا إلى إزالة جميع خوادم DNS من القائمة. لا يمكن التراجع عن هذا الإجراء."
},
"fakedns": {
"add": "أضف Fake DNS",

View file

@ -752,9 +752,24 @@
"unexpectIPs": "Unexpect IPs",
"useSystemHosts": "Use System Hosts",
"useSystemHostsDesc": "Use the hosts file from an installed system",
"serveStale": "Serve Stale",
"serveStaleDesc": "Return expired cached results while refreshing in the background",
"serveExpiredTTL": "Serve Expired TTL",
"serveExpiredTTLDesc": "Validity (seconds) of stale cache entries; 0 = never expire",
"timeoutMs": "Timeout (ms)",
"skipFallback": "Skip Fallback",
"finalQuery": "Final Query",
"hosts": "Hosts",
"hostsAdd": "Add Host",
"hostsEmpty": "No host overrides defined",
"hostsDomain": "Domain (e.g. domain:example.com)",
"hostsValues": "IP or domain — type and press Enter",
"usePreset": "Use Preset",
"dnsPresetTitle": "DNS Presets",
"dnsPresetFamily": "Family"
"dnsPresetFamily": "Family",
"clearAll": "Delete All",
"clearAllTitle": "Delete all DNS servers?",
"clearAllConfirm": "This removes every DNS server from the list. This cannot be undone."
},
"fakedns": {
"add": "Add Fake DNS",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Usar el archivo hosts de un sistema instalado",
"usePreset": "Usar plantilla",
"dnsPresetTitle": "Plantillas DNS",
"dnsPresetFamily": "Familiar"
"dnsPresetFamily": "Familiar",
"serveStale": "Servir caducados",
"serveStaleDesc": "Devolver resultados caducados de la caché mientras se actualiza en segundo plano",
"serveExpiredTTL": "TTL de caducados",
"serveExpiredTTLDesc": "Validez (segundos) de las entradas caducadas en la caché; 0 = nunca caduca",
"timeoutMs": "Tiempo de espera (ms)",
"skipFallback": "Omitir respaldo",
"finalQuery": "Consulta final",
"hosts": "Hosts",
"hostsAdd": "Agregar Host",
"hostsEmpty": "No hay Hosts definidos",
"hostsDomain": "Dominio (ej. domain:example.com)",
"hostsValues": "IP o dominio — escribe y presiona Enter",
"clearAll": "Eliminar todos",
"clearAllTitle": "¿Eliminar todos los servidores DNS?",
"clearAllConfirm": "Esto eliminará todos los servidores DNS de la lista. No se puede deshacer."
},
"fakedns": {
"add": "Agregar DNS Falso",

View file

@ -752,9 +752,24 @@
"unexpectIPs": "آی‌پی‌های غیرمنتظره",
"useSystemHosts": "استفاده از Hosts سیستم",
"useSystemHostsDesc": "استفاده از فایل hosts یک سیستم نصب‌شده",
"serveStale": "ارائه نتایج منقضی",
"serveStaleDesc": "بازگرداندن نتایج منقضی کش هنگام بروزرسانی در پس‌زمینه",
"serveExpiredTTL": "TTL نتایج منقضی",
"serveExpiredTTLDesc": "مدت اعتبار نتایج منقضی به ثانیه؛ ۰ یعنی هرگز منقضی نمی‌شود",
"timeoutMs": "زمان انتظار (میلی‌ثانیه)",
"skipFallback": "رد کردن Fallback",
"finalQuery": "پرس‌وجوی نهایی",
"hosts": "Hosts",
"hostsAdd": "افزودن Host",
"hostsEmpty": "هیچ Host تعریف نشده",
"hostsDomain": "دامنه (مثلاً domain:example.com)",
"hostsValues": "آی‌پی یا دامنه — تایپ کنید و Enter بزنید",
"usePreset": "استفاده از پیش‌تنظیم",
"dnsPresetTitle": "پیش‌تنظیم‌های DNS",
"dnsPresetFamily": "خانوادگی"
"dnsPresetFamily": "خانوادگی",
"clearAll": "حذف همه",
"clearAllTitle": "حذف همه سرورهای DNS؟",
"clearAllConfirm": "این کار همه سرورهای DNS را از لیست حذف می‌کند و قابل بازگشت نیست."
},
"fakedns": {
"add": "افزودن دی‌ان‌اس جعلی",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Gunakan file hosts dari sistem yang terinstal",
"usePreset": "Gunakan templat",
"dnsPresetTitle": "Templat DNS",
"dnsPresetFamily": "Keluarga"
"dnsPresetFamily": "Keluarga",
"serveStale": "Sajikan Kedaluwarsa",
"serveStaleDesc": "Mengembalikan hasil cache yang kedaluwarsa saat memperbarui di latar belakang",
"serveExpiredTTL": "TTL Kedaluwarsa",
"serveExpiredTTLDesc": "Masa berlaku (detik) entri cache kedaluwarsa; 0 = tidak pernah kedaluwarsa",
"timeoutMs": "Batas waktu (ms)",
"skipFallback": "Lewati Fallback",
"finalQuery": "Kueri Akhir",
"hosts": "Hosts",
"hostsAdd": "Tambah Host",
"hostsEmpty": "Tidak ada Host yang ditentukan",
"hostsDomain": "Domain (mis. domain:example.com)",
"hostsValues": "IP atau domain — ketik dan tekan Enter",
"clearAll": "Hapus Semua",
"clearAllTitle": "Hapus semua server DNS?",
"clearAllConfirm": "Ini akan menghapus semua server DNS dari daftar. Tidak dapat dibatalkan."
},
"fakedns": {
"add": "Tambahkan DNS Palsu",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "インストール済みシステムのhostsファイルを使用する",
"usePreset": "テンプレートを使用",
"dnsPresetTitle": "DNSテンプレート",
"dnsPresetFamily": "ファミリー"
"dnsPresetFamily": "ファミリー",
"serveStale": "期限切れキャッシュを使用",
"serveStaleDesc": "バックグラウンドで更新中に期限切れキャッシュ結果を返す",
"serveExpiredTTL": "期限切れTTL",
"serveExpiredTTLDesc": "期限切れキャッシュエントリの有効期間。0 = 無期限",
"timeoutMs": "タイムアウト (ms)",
"skipFallback": "フォールバックをスキップ",
"finalQuery": "最終クエリ",
"hosts": "Hosts",
"hostsAdd": "Host を追加",
"hostsEmpty": "Host が定義されていません",
"hostsDomain": "ドメイン (例: domain:example.com)",
"hostsValues": "IP またはドメイン — 入力して Enter",
"clearAll": "すべて削除",
"clearAllTitle": "すべての DNS サーバを削除しますか?",
"clearAllConfirm": "リストからすべての DNS サーバが削除されます。この操作は元に戻せません。"
},
"fakedns": {
"add": "フェイクDNS追加",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Usar o arquivo hosts de um sistema instalado",
"usePreset": "Usar modelo",
"dnsPresetTitle": "Modelos DNS",
"dnsPresetFamily": "Familiar"
"dnsPresetFamily": "Familiar",
"serveStale": "Servir Expirados",
"serveStaleDesc": "Retornar resultados expirados do cache enquanto atualiza em segundo plano",
"serveExpiredTTL": "TTL de Expirados",
"serveExpiredTTLDesc": "Validade (segundos) das entradas expiradas no cache; 0 = nunca expira",
"timeoutMs": "Tempo limite (ms)",
"skipFallback": "Ignorar Fallback",
"finalQuery": "Consulta Final",
"hosts": "Hosts",
"hostsAdd": "Adicionar Host",
"hostsEmpty": "Nenhum Host definido",
"hostsDomain": "Domínio (ex. domain:example.com)",
"hostsValues": "IP ou domínio — digite e pressione Enter",
"clearAll": "Remover Todos",
"clearAllTitle": "Remover todos os servidores DNS?",
"clearAllConfirm": "Isso remove todos os servidores DNS da lista. Não pode ser desfeito."
},
"fakedns": {
"add": "Adicionar Fake DNS",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Использовать файл hosts из установленной системы",
"usePreset": "Использовать шаблон",
"dnsPresetTitle": "Шаблоны DNS",
"dnsPresetFamily": "Семейный"
"dnsPresetFamily": "Семейный",
"serveStale": "Использовать устаревшие",
"serveStaleDesc": "Возвращать устаревшие результаты из кэша во время обновления в фоне",
"serveExpiredTTL": "TTL устаревших",
"serveExpiredTTLDesc": "Срок действия (секунды) устаревших записей кэша; 0 = бессрочно",
"timeoutMs": "Тайм-аут (мс)",
"skipFallback": "Пропустить Fallback",
"finalQuery": "Финальный запрос",
"hosts": "Hosts",
"hostsAdd": "Добавить Host",
"hostsEmpty": "Host не определены",
"hostsDomain": "Домен (напр. domain:example.com)",
"hostsValues": "IP или домен — введите и нажмите Enter",
"clearAll": "Удалить все",
"clearAllTitle": "Удалить все DNS-серверы?",
"clearAllConfirm": "Все DNS-серверы будут удалены из списка. Это действие нельзя отменить."
},
"fakedns": {
"add": "Создать Fake DNS",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Yüklü bir sistemden hosts dosyasını kullan",
"usePreset": "Şablon kullan",
"dnsPresetTitle": "DNS Şablonları",
"dnsPresetFamily": "Aile"
"dnsPresetFamily": "Aile",
"serveStale": "Süresi Dolmuş Sonuçları Sun",
"serveStaleDesc": "Arka planda yenilenirken süresi dolmuş önbellek sonuçlarını döndür",
"serveExpiredTTL": "Süresi Dolmuş TTL",
"serveExpiredTTLDesc": "Süresi dolmuş önbellek girdilerinin geçerlilik süresi (saniye); 0 = asla",
"timeoutMs": "Zaman aşımı (ms)",
"skipFallback": "Yedekleri Atla",
"finalQuery": "Son Sorgu",
"hosts": "Hosts",
"hostsAdd": "Host Ekle",
"hostsEmpty": "Tanımlı Host yok",
"hostsDomain": "Alan adı (ör. domain:example.com)",
"hostsValues": "IP veya alan adı — yazıp Enter'a basın",
"clearAll": "Tümünü Sil",
"clearAllTitle": "Tüm DNS sunucularını sil?",
"clearAllConfirm": "Bu, tüm DNS sunucularını listeden kaldırır. Geri alınamaz."
},
"fakedns": {
"add": "Sahte DNS Ekle",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Використовувати файл hosts з встановленої системи",
"usePreset": "Використати шаблон",
"dnsPresetTitle": "Шаблони DNS",
"dnsPresetFamily": "Сімейний"
"dnsPresetFamily": "Сімейний",
"serveStale": "Видавати застарілі",
"serveStaleDesc": "Повертати застарілі результати з кешу під час фонового оновлення",
"serveExpiredTTL": "TTL застарілих",
"serveExpiredTTLDesc": "Термін дії (секунди) застарілих записів кешу; 0 = ніколи",
"timeoutMs": "Тайм-аут (мс)",
"skipFallback": "Пропустити Fallback",
"finalQuery": "Фінальний запит",
"hosts": "Hosts",
"hostsAdd": "Додати Host",
"hostsEmpty": "Host не визначено",
"hostsDomain": "Домен (напр. domain:example.com)",
"hostsValues": "IP або домен — введіть і натисніть Enter",
"clearAll": "Видалити всі",
"clearAllTitle": "Видалити всі DNS-сервери?",
"clearAllConfirm": "Усі DNS-сервери буде видалено зі списку. Дію не можна скасувати."
},
"fakedns": {
"add": "Додати підроблений DNS",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "Sử dụng file hosts từ hệ thống đã cài đặt",
"usePreset": "Dùng mẫu",
"dnsPresetTitle": "Mẫu DNS",
"dnsPresetFamily": "Gia đình"
"dnsPresetFamily": "Gia đình",
"serveStale": "Phục vụ kết quả hết hạn",
"serveStaleDesc": "Trả về kết quả cache đã hết hạn trong khi làm mới ở chế độ nền",
"serveExpiredTTL": "TTL hết hạn",
"serveExpiredTTLDesc": "Thời gian hiệu lực (giây) của các mục cache hết hạn; 0 = không bao giờ hết hạn",
"timeoutMs": "Thời gian chờ (ms)",
"skipFallback": "Bỏ qua Fallback",
"finalQuery": "Truy vấn cuối",
"hosts": "Hosts",
"hostsAdd": "Thêm Host",
"hostsEmpty": "Chưa có Host nào",
"hostsDomain": "Tên miền (vd. domain:example.com)",
"hostsValues": "IP hoặc tên miền — nhập và nhấn Enter",
"clearAll": "Xóa tất cả",
"clearAllTitle": "Xóa tất cả máy chủ DNS?",
"clearAllConfirm": "Thao tác này sẽ xóa toàn bộ máy chủ DNS khỏi danh sách. Không thể hoàn tác."
},
"fakedns": {
"add": "Thêm DNS giả",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "使用已安装系统的hosts文件",
"usePreset": "使用模板",
"dnsPresetTitle": "DNS模板",
"dnsPresetFamily": "家庭"
"dnsPresetFamily": "家庭",
"serveStale": "提供过期结果",
"serveStaleDesc": "在后台刷新时返回过期的缓存结果",
"serveExpiredTTL": "过期TTL",
"serveExpiredTTLDesc": "过期缓存条目的有效期0 = 永不过期",
"timeoutMs": "超时 (毫秒)",
"skipFallback": "跳过回退",
"finalQuery": "最终查询",
"hosts": "Hosts",
"hostsAdd": "添加 Host",
"hostsEmpty": "未定义任何 Host",
"hostsDomain": "域名 (例如 domain:example.com)",
"hostsValues": "IP 或域名 — 输入后按 Enter",
"clearAll": "删除全部",
"clearAllTitle": "删除所有 DNS 服务器?",
"clearAllConfirm": "此操作将从列表中删除所有 DNS 服务器,且无法撤销。"
},
"fakedns": {
"add": "添加假 DNS",

View file

@ -754,7 +754,22 @@
"useSystemHostsDesc": "使用已安裝系統的hosts檔案",
"usePreset": "使用範本",
"dnsPresetTitle": "DNS範本",
"dnsPresetFamily": "家庭"
"dnsPresetFamily": "家庭",
"serveStale": "提供過期結果",
"serveStaleDesc": "在背景重新整理時傳回過期的快取結果",
"serveExpiredTTL": "過期TTL",
"serveExpiredTTLDesc": "過期快取項目的有效期0 = 永不過期",
"timeoutMs": "逾時 (毫秒)",
"skipFallback": "跳過回退",
"finalQuery": "最終查詢",
"hosts": "Hosts",
"hostsAdd": "新增 Host",
"hostsEmpty": "未定義任何 Host",
"hostsDomain": "網域 (例如 domain:example.com)",
"hostsValues": "IP 或網域 — 輸入後按 Enter",
"clearAll": "全部刪除",
"clearAllTitle": "刪除所有 DNS 伺服器?",
"clearAllConfirm": "此操作將從清單中刪除所有 DNS 伺服器,無法復原。"
},
"fakedns": {
"add": "新增假 DNS",