i18n(frontend): Phase 7-c — translate settings, inbounds modals, xray tabs

Continues the page-by-page translation pass started in cb37dd55 — runs
every user-visible string on settings (General/Security/Telegram/Sub),
inbounds (Client/QR/Info modals), and xray (Routing/Balancer/Rule/Warp/
Nord/Basics/Outbounds tabs) through useI18n. Updates the TOML→JSON sync
script to escape `@` (vue-i18n parses it as a linked-format prefix) and
refreshes all 13 locale files.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei
2026-05-08 17:20:30 +02:00
parent cb37dd55ca
commit 4322a18ee3
36 changed files with 755 additions and 877 deletions
+19 -24
View File
@@ -1,5 +1,8 @@
<script setup>
import { computed, reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
// Balancer add/edit modal — mirrors xray_balancer_modal.html.
// Tag must be unique across other balancers; selector is a tag-mode
@@ -61,27 +64,21 @@ function onOk() {
emit('confirm', { ...form });
}
const title = computed(() => (isEdit.value ? 'Edit balancer' : 'Add balancer'));
const okText = computed(() => (isEdit.value ? 'Update' : 'Add'));
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Balancers')}`
: `+ ${t('pages.xray.Balancers')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
);
</script>
<template>
<a-modal
:open="open"
:title="title"
:ok-text="okText"
cancel-text="Close"
:ok-button-props="{ disabled: !isValid }"
:mask-closable="false"
@ok="onOk"
@cancel="close"
>
<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="duplicateTag ? 'warning' : 'success'"
has-feedback
>
<a-form-item label="Tag" :validate-status="duplicateTag ? 'warning' : 'success'" has-feedback>
<a-input v-model:value="form.tag" placeholder="unique balancer tag" />
</a-form-item>
@@ -91,19 +88,17 @@ const okText = computed(() => (isEdit.value ? 'Update' : 'Add'));
</a-select>
</a-form-item>
<a-form-item
label="Selector"
:validate-status="emptySelector ? 'warning' : 'success'"
has-feedback
>
<a-form-item label="Selector" :validate-status="emptySelector ? 'warning' : 'success'" has-feedback>
<a-select v-model:value="form.selector" mode="tags" :token-separators="[',']">
<a-select-option v-for="t in outboundTags" :key="t" :value="t">{{ t }}</a-select-option>
<a-select-option v-for="tag in outboundTags" :key="tag" :value="tag">{{ tag }}</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Fallback">
<a-select v-model:value="form.fallbackTag" allow-clear>
<a-select-option v-for="t in ['', ...outboundTags]" :key="t || '__empty'" :value="t">{{ t || '(none)' }}</a-select-option>
<a-select-option v-for="tag in ['', ...outboundTags]" :key="tag || '__empty'" :value="tag">
{{ tag || `(${t('none')})` }}
</a-select-option>
</a-select>
</a-form-item>
</a-form>
+30 -29
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
@@ -10,6 +11,8 @@ import { Modal } from 'ant-design-vue';
import BalancerFormModal from './BalancerFormModal.vue';
const { t } = useI18n();
// Balancers tab — list + add/edit/delete over
// templateSettings.routing.balancers. The legacy panel kept the wire
// shape's `strategy: { type: 'random' }` nesting only when non-default;
@@ -111,46 +114,43 @@ function onConfirm(form) {
function confirmDelete(idx) {
Modal.confirm({
title: `Delete balancer #${idx + 1}?`,
okText: 'Delete',
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => props.templateSettings.routing.balancers.splice(idx, 1),
});
}
const columns = [
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 80 },
{ title: 'Tag', dataIndex: 'tag', key: 'tag', align: 'center', width: 160 },
{ title: 'Strategy', key: 'strategy', align: 'center', width: 140 },
{ title: 'Selector', key: 'selector', align: 'center' },
{ title: 'Fallback', dataIndex: 'fallbackTag', key: 'fallbackTag', align: 'center', width: 160 },
];
]);
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-empty v-if="rows.length === 0" description="No balancers yet">
<a-empty v-if="rows.length === 0" :description="t('emptyBalancersDesc')">
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add balancer
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Balancers') }}
</a-button>
</a-empty>
<template v-else>
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add balancer
<template #icon>
<PlusOutlined />
</template>
{{ t('pages.xray.Balancers') }}
</a-button>
<a-table
:columns="columns"
:data-source="rows"
:row-key="(r) => r.key"
:pagination="false"
size="small"
bordered
>
<a-table :columns="columns" :data-source="rows" :row-key="(r) => r.key" :pagination="false" size="small" bordered>
<template #bodyCell="{ column, record, index }">
<template v-if="column.key === 'action'">
<span class="row-index">{{ index + 1 }}</span>
@@ -161,10 +161,10 @@ const columns = [
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
@@ -184,13 +184,8 @@ const columns = [
</a-table>
</template>
<BalancerFormModal
v-model:open="modalOpen"
:balancer="editingBalancer"
:outbound-tags="outboundTags"
:other-tags="otherTags"
@confirm="onConfirm"
/>
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
:other-tags="otherTags" @confirm="onConfirm" />
</a-space>
</template>
@@ -200,6 +195,12 @@ const columns = [
opacity: 0.7;
margin-right: 6px;
}
.action-btn { vertical-align: middle; }
.danger { color: #ff4d4f; }
.action-btn {
vertical-align: middle;
}
.danger {
color: #ff4d4f;
}
</style>
+87 -103
View File
@@ -1,10 +1,13 @@
<script setup>
import { computed } from 'vue';
import { useI18n } from 'vue-i18n';
import { ExclamationCircleFilled, CloudOutlined, ApiOutlined } from '@ant-design/icons-vue';
import { OutboundDomainStrategies } from '@/models/outbound.js';
import SettingListItem from '@/components/SettingListItem.vue';
const { t } = useI18n();
// Phase 6-ii: structured editor for the most-touched fields of the
// xray template — outbound strategy, routing strategy, log levels,
// stat counters, and the "basic routing" lists (block IPs/domains/
@@ -223,18 +226,16 @@ const localOutboundTestUrl = computed({
<template>
<a-collapse default-active-key="1">
<a-collapse-panel key="1" header="General">
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Editing this template manually is for advanced users only."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-collapse-panel key="1" :header="t('pages.xray.generalConfigs')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.generalConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Freedom outbound strategy</template>
<template #description>How the direct outbound resolves destinations.</template>
<template #title>{{ t('pages.xray.FreedomStrategy') }}</template>
<template #description>{{ t('pages.xray.FreedomStrategyDesc') }}</template>
<template #control>
<a-select v-model:value="freedomStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in OutboundDomainStrategies" :key="s" :value="s">{{ s }}</a-select-option>
@@ -243,8 +244,8 @@ const localOutboundTestUrl = computed({
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Routing strategy</template>
<template #description>Domain strategy applied at the routing level.</template>
<template #title>{{ t('pages.xray.RoutingStrategy') }}</template>
<template #description>{{ t('pages.xray.RoutingStrategyDesc') }}</template>
<template #control>
<a-select v-model:value="routingStrategy" :style="{ width: '100%' }">
<a-select-option v-for="s in ROUTING_DOMAIN_STRATEGIES" :key="s" :value="s">{{ s }}</a-select-option>
@@ -253,25 +254,25 @@ const localOutboundTestUrl = computed({
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Outbound test URL</template>
<template #description>HTTP endpoint used for outbound latency tests.</template>
<template #title>{{ t('pages.xray.outboundTestUrl') }}</template>
<template #description>{{ t('pages.xray.outboundTestUrlDesc') }}</template>
<template #control>
<a-input v-model:value="localOutboundTestUrl" placeholder="https://www.google.com/generate_204" />
</template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="2" header="Statistics">
<a-collapse-panel key="2" :header="t('pages.xray.statistics')">
<SettingListItem paddings="small">
<template #title>Inbound uplink stats</template>
<template #title>{{ t('pages.xray.statsInboundUplink') }}</template>
<template #control><a-switch v-model:checked="statsInboundUplink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Inbound downlink stats</template>
<template #title>{{ t('pages.xray.statsInboundDownlink') }}</template>
<template #control><a-switch v-model:checked="statsInboundDownlink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Outbound uplink stats</template>
<template #title>{{ t('pages.xray.statsOutboundUplink') }}</template>
<template #control><a-switch v-model:checked="statsOutboundUplink" /></template>
</SettingListItem>
<SettingListItem paddings="small">
@@ -280,17 +281,16 @@ const localOutboundTestUrl = computed({
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="3" header="Logs">
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Empty access/error log fields disable that log; ./access.log writes to disk."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-collapse-panel key="3" :header="t('pages.xray.logConfigs')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.logConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Log level</template>
<template #title>{{ t('pages.xray.logLevel') }}</template>
<template #description>{{ t('pages.xray.logLevelDesc') }}</template>
<template #control>
<a-select v-model:value="logLevel" :style="{ width: '100%' }">
<a-select-option v-for="s in LOG_LEVELS" :key="s" :value="s">{{ s }}</a-select-option>
@@ -299,165 +299,144 @@ const localOutboundTestUrl = computed({
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Access log</template>
<template #title>{{ t('pages.xray.accessLog') }}</template>
<template #description>{{ t('pages.xray.accessLogDesc') }}</template>
<template #control>
<a-select v-model:value="accessLog" :style="{ width: '100%' }">
<a-select-option value="">Empty</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="s in ACCESS_LOG" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Error log</template>
<template #title>{{ t('pages.xray.errorLog') }}</template>
<template #description>{{ t('pages.xray.errorLogDesc') }}</template>
<template #control>
<a-select v-model:value="errorLog" :style="{ width: '100%' }">
<a-select-option value="">Empty</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="s in ERROR_LOG" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Mask address</template>
<template #description>Truncate IPs in logs.</template>
<template #title>{{ t('pages.xray.maskAddress') }}</template>
<template #description>{{ t('pages.xray.maskAddressDesc') }}</template>
<template #control>
<a-select v-model:value="maskAddressLog" :style="{ width: '100%' }">
<a-select-option value="">Empty</a-select-option>
<a-select-option value="">{{ t('none') }}</a-select-option>
<a-select-option v-for="s in MASK_ADDRESS" :key="s" :value="s">{{ s }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>DNS log</template>
<template #title>{{ t('pages.xray.dnsLog') }}</template>
<template #description>{{ t('pages.xray.dnsLogDesc') }}</template>
<template #control><a-switch v-model:checked="dnslog" /></template>
</SettingListItem>
</a-collapse-panel>
<a-collapse-panel key="4" header="Basic routing">
<a-alert
type="warning"
class="mb-12 hint-alert"
message="These shortcuts edit the underlying routing rules. Use the Routing tab for full control."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-collapse-panel key="4" :header="t('pages.xray.basicRouting')">
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.blockConnectionsConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Block torrent</template>
<template #title>{{ t('pages.xray.Torrent') }}</template>
<template #control><a-switch v-model:checked="torrentSettings" /></template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Block lists drop traffic matching these IPs / domains."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Block IPs</template>
<template #title>{{ t('pages.xray.blockips') }}</template>
<template #control>
<a-select v-model:value="blockedIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Block domains</template>
<template #title>{{ t('pages.xray.blockdomains') }}</template>
<template #control>
<a-select v-model:value="blockedDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in BLOCK_DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{
p.label }}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="Direct lists bypass the proxy for matched IPs / domains."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
<a-alert type="warning" class="mb-12 hint-alert" :message="t('pages.xray.directConnectionsConfigsDesc')">
<template #icon>
<ExclamationCircleFilled style="color: #FFA031;" />
</template>
</a-alert>
<SettingListItem paddings="small">
<template #title>Direct IPs</template>
<template #title>{{ t('pages.xray.directips') }}</template>
<template #control>
<a-select v-model:value="directIPs" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in IPS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>Direct domains</template>
<template #title>{{ t('pages.xray.directdomains') }}</template>
<template #control>
<a-select v-model:value="directDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in DOMAINS_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="IPv4 routing forces the listed services through an IPv4-only outbound."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
</a-alert>
<SettingListItem paddings="small">
<template #title>IPv4 forced</template>
<template #title>{{ t('pages.xray.ipv4Routing') }}</template>
<template #description>{{ t('pages.xray.ipv4RoutingDesc') }}</template>
<template #control>
<a-select v-model:value="ipv4Domains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
</template>
</SettingListItem>
<a-alert
type="warning"
class="mb-12 hint-alert"
message="WARP / NordVPN routing requires the matching outbound to exist — use the buttons to provision it."
>
<template #icon><ExclamationCircleFilled style="color: #FFA031;" /></template>
</a-alert>
<SettingListItem paddings="small">
<template #title>WARP routing</template>
<template #title>{{ t('pages.xray.warpRouting') }}</template>
<template #description>{{ t('pages.xray.warpRoutingDesc') }}</template>
<template #control>
<a-select
v-if="warpExist"
v-model:value="warpDomains"
mode="tags"
:style="{ width: '100%' }"
>
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select v-if="warpExist" v-model:value="warpDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
<a-button v-else type="primary" @click="emit('show-warp')">
<template #icon><CloudOutlined /></template>
Configure WARP
<template #icon>
<CloudOutlined />
</template>
WARP
</a-button>
</template>
</SettingListItem>
<SettingListItem paddings="small">
<template #title>NordVPN routing</template>
<template #title>{{ t('pages.xray.nordRouting') }}</template>
<template #description>{{ t('pages.xray.nordRoutingDesc') }}</template>
<template #control>
<a-select
v-if="nordExist"
v-model:value="nordDomains"
mode="tags"
:style="{ width: '100%' }"
>
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label }}</a-select-option>
<a-select v-if="nordExist" v-model:value="nordDomains" mode="tags" :style="{ width: '100%' }">
<a-select-option v-for="p in SERVICES_OPTIONS" :key="p.value" :value="p.value" :label="p.label">{{ p.label
}}</a-select-option>
</a-select>
<a-button v-else type="primary" @click="emit('show-nord')">
<template #icon><ApiOutlined /></template>
Configure NordVPN
<template #icon>
<ApiOutlined />
</template>
NordVPN
</a-button>
</template>
</SettingListItem>
@@ -466,6 +445,11 @@ const localOutboundTestUrl = computed({
</template>
<style scoped>
.mb-12 { margin-bottom: 12px; }
.hint-alert { text-align: center; }
.mb-12 {
margin-bottom: 12px;
}
.hint-alert {
text-align: center;
}
</style>
+48 -64
View File
@@ -225,44 +225,35 @@ function close() { emit('update:open', false); }
</script>
<template>
<a-modal
:open="open"
title="NordVPN NordLynx"
:footer="null"
:closable="true"
:mask-closable="true"
@cancel="close"
>
<a-modal :open="open" title="NordVPN NordLynx" :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 +
dialog headers translate. -->
<!-- Not authenticated → tabbed login (token or manual key) -->
<template v-if="nordData == null">
<a-tabs default-active-key="token">
<a-tab-pane key="token" tab="Access token">
<a-form
:colon="false"
:label-col="{ md: { span: 6 } }"
:wrapper-col="{ md: { span: 18 } }"
class="mt-20"
>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
<a-form-item label="Access token">
<a-input v-model:value="token" placeholder="Access token" />
<a-button type="primary" class="mt-10" :loading="loading" @click="login">
<template #icon><LoginOutlined /></template>
<template #icon>
<LoginOutlined />
</template>
Login
</a-button>
</a-form-item>
</a-form>
</a-tab-pane>
<a-tab-pane key="key" tab="Private key">
<a-form
:colon="false"
:label-col="{ md: { span: 6 } }"
:wrapper-col="{ md: { span: 18 } }"
class="mt-20"
>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-20">
<a-form-item label="Private key">
<a-input v-model:value="manualKey" placeholder="Private key" />
<a-button type="primary" class="mt-10" :loading="loading" @click="saveKey">
<template #icon><SaveOutlined /></template>
<template #icon>
<SaveOutlined />
</template>
Save
</a-button>
</a-form-item>
@@ -290,43 +281,20 @@ function close() { emit('update:open', false); }
<a-divider class="zero-margin">Settings</a-divider>
<a-form
:colon="false"
:label-col="{ md: { span: 6 } }"
:wrapper-col="{ md: { span: 18 } }"
class="mt-10"
>
<a-form :colon="false" :label-col="{ md: { span: 6 } }" :wrapper-col="{ md: { span: 18 } }" class="mt-10">
<a-form-item label="Country">
<a-select
v-model:value="countryId"
show-search
option-filter-prop="label"
@change="fetchServers"
>
<a-select-option
v-for="c in countries"
:key="c.id"
:value="c.id"
:label="c.name"
>
<a-select v-model:value="countryId" show-search option-filter-prop="label" @change="fetchServers">
<a-select-option v-for="c in countries" :key="c.id" :value="c.id" :label="c.name">
{{ c.name }} ({{ c.code }})
</a-select-option>
</a-select>
</a-form-item>
<a-form-item v-if="cities.length > 0" label="City">
<a-select
v-model:value="cityId"
show-search
option-filter-prop="label"
>
<a-select v-model:value="cityId" show-search option-filter-prop="label">
<a-select-option :value="null" label="All cities">All cities</a-select-option>
<a-select-option
v-for="c in cities"
:key="c.id"
:value="c.id"
:label="c.name"
>{{ c.name }}</a-select-option>
<a-select-option v-for="c in cities" :key="c.id" :value="c.id" :label="c.name">{{ c.name
}}</a-select-option>
</a-select>
</a-form-item>
@@ -349,13 +317,8 @@ function close() { emit('update:open', false); }
</template>
<template v-else>
<a-tag color="orange">Disabled</a-tag>
<a-button
type="primary"
class="ml-8"
:disabled="!serverId"
:loading="loading"
@click="addOutbound"
>Add outbound</a-button>
<a-button type="primary" class="ml-8" :disabled="!serverId" :loading="loading" @click="addOutbound">Add
outbound</a-button>
</template>
</template>
</a-modal>
@@ -367,29 +330,50 @@ function close() { emit('update:open', false); }
width: 100%;
border-collapse: collapse;
}
.nord-data-table td {
padding: 4px 8px;
word-break: break-all;
font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, monospace;
font-size: 12px;
}
.nord-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; }
.mt-8 { margin-top: 8px; }
.mt-10 { margin-top: 10px; }
.mt-20 { margin-top: 20px; }
.my-10 { margin: 10px 0; }
.ml-8 { margin-left: 8px; }
.zero-margin {
margin: 0;
}
.mt-8 {
margin-top: 8px;
}
.mt-10 {
margin-top: 10px;
}
.mt-20 {
margin-top: 20px;
}
.my-10 {
margin: 10px 0;
}
.ml-8 {
margin-left: 8px;
}
</style>
+24 -20
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
CloudOutlined,
@@ -23,6 +24,8 @@ import { SizeFormatter } from '@/utils';
import { Protocols } from '@/models/outbound.js';
import OutboundFormModal from './OutboundFormModal.vue';
const { t } = useI18n();
// Outbounds tab — list + actions over templateSettings.outbounds.
// Mirrors the legacy outbound table layout (identity / address /
// traffic / test result / test button) plus the row action menu
@@ -72,10 +75,10 @@ function onConfirm(outbound) {
function confirmDelete(idx) {
Modal.confirm({
title: `Delete outbound #${idx + 1}?`,
okText: 'Delete',
title: `${t('delete')} ${t('pages.xray.Outbounds')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => props.templateSettings.outbounds.splice(idx, 1),
});
}
@@ -148,14 +151,15 @@ function showSecurity(security) {
}
// === Columns ========================================================
const columns = [
// Computed so titles re-render after a locale swap.
const columns = computed(() => [
{ title: '#', key: 'action', align: 'center', width: 70 },
{ title: 'Tag', key: 'identity', align: 'left', width: 220 },
{ title: 'Address', key: 'address', align: 'left', width: 230 },
{ title: 'Traffic', key: 'traffic', align: 'left', width: 200 },
{ title: 'Test result', key: 'testResult', align: 'left', width: 140 },
{ title: 'Test', key: 'test', align: 'center', width: 80 },
];
{ title: t('pages.inbounds.address'), key: 'address', align: 'left', width: 230 },
{ title: t('pages.inbounds.traffic'), key: 'traffic', align: 'left', width: 200 },
{ title: t('check'), key: 'testResult', align: 'left', width: 140 },
{ title: t('check'), key: 'test', align: 'center', width: 80 },
]);
const rows = computed(() => {
if (!props.templateSettings?.outbounds) return [];
@@ -171,7 +175,7 @@ const rows = computed(() => {
<a-space size="small">
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
<span v-if="!isMobile">Add outbound</span>
<span v-if="!isMobile">{{ t('pages.xray.Outbounds') }}</span>
</a-button>
<a-button type="primary" @click="emit('show-warp')">
<template #icon><CloudOutlined /></template>
@@ -190,9 +194,9 @@ const rows = computed(() => {
</a-button>
<a-popconfirm
placement="topRight"
ok-text="Reset"
cancel-text="Cancel"
title="Reset traffic on every outbound?"
:ok-text="t('reset')"
:cancel-text="t('cancel')"
:title="t('pages.inbounds.resetAllTrafficContent')"
@confirm="emit('reset-traffic', '-alltags-')"
>
<a-button>
@@ -230,16 +234,16 @@ const rows = computed(() => {
<template #overlay>
<a-menu>
<a-menu-item v-if="index > 0" @click="setFirst(index)">
<VerticalAlignTopOutlined /> Move to top
<VerticalAlignTopOutlined />
</a-menu-item>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
<RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
@@ -303,10 +307,10 @@ const rows = computed(() => {
<EditOutlined /> Edit
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined /> Move up
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined /> Move down
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item @click="emit('reset-traffic', record.tag || '')">
<RetweetOutlined /> Reset traffic
@@ -368,7 +372,7 @@ const rows = computed(() => {
</template>
<template v-else-if="column.key === 'test'">
<a-tooltip title="Run a latency test through this outbound">
<a-tooltip :title="t('check')">
<a-button
type="primary"
shape="circle"
+22 -18
View File
@@ -1,5 +1,6 @@
<script setup>
import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import {
PlusOutlined,
MoreOutlined,
@@ -14,6 +15,8 @@ import { Modal } from 'ant-design-vue';
import RuleFormModal from './RuleFormModal.vue';
const { t } = useI18n();
// Routing tab — table over templateSettings.routing.rules with the
// modernised legacy column layout. Each row is rendered as a single
// "lead value + N more" pill per criterion (matches the legacy pill
@@ -122,10 +125,10 @@ function onRuleConfirm(rule) {
function confirmDelete(idx) {
Modal.confirm({
title: `Delete rule #${idx + 1}?`,
okText: 'Delete',
title: `${t('delete')} ${t('pages.xray.Routings')} #${idx + 1}?`,
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: () => props.templateSettings.routing.rules.splice(idx, 1),
});
}
@@ -142,27 +145,28 @@ function moveDown(idx) {
}
// === Columns =========================================================
const desktopColumns = [
// Computed so titles re-render after a locale swap.
const desktopColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: 'Source', align: 'left', width: 180, key: 'source' },
{ title: 'Network', align: 'left', width: 180, key: 'network' },
{ title: t('pages.inbounds.network'), align: 'left', width: 180, key: 'network' },
{ title: 'Destination', align: 'left', key: 'destination' },
{ title: 'Inbound', align: 'left', width: 180, key: 'inbound' },
{ title: 'Outbound', align: 'left', width: 170, key: 'target' },
];
const mobileColumns = [
{ title: t('pages.xray.Inbounds'), align: 'left', width: 180, key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 170, key: 'target' },
]);
const mobileColumns = computed(() => [
{ title: '#', align: 'center', width: 70, key: 'action' },
{ title: 'Inbound', align: 'left', key: 'inbound' },
{ title: 'Outbound', align: 'left', width: 140, key: 'target' },
];
const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns));
{ title: t('pages.xray.Inbounds'), align: 'left', key: 'inbound' },
{ title: t('pages.xray.Outbounds'), align: 'left', width: 140, key: 'target' },
]);
const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
</script>
<template>
<a-space direction="vertical" size="middle" :style="{ width: '100%' }">
<a-button type="primary" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add rule
{{ t('pages.xray.Routings') }}
</a-button>
<a-table
@@ -186,16 +190,16 @@ const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)
<template #overlay>
<a-menu>
<a-menu-item @click="openEdit(index)">
<EditOutlined /> Edit
<EditOutlined /> {{ t('edit') }}
</a-menu-item>
<a-menu-item :disabled="index === 0" @click="moveUp(index)">
<ArrowUpOutlined /> Move up
<ArrowUpOutlined />
</a-menu-item>
<a-menu-item :disabled="index === rows.length - 1" @click="moveDown(index)">
<ArrowDownOutlined /> Move down
<ArrowDownOutlined />
</a-menu-item>
<a-menu-item class="danger" @click="confirmDelete(index)">
<DeleteOutlined /> Delete
<DeleteOutlined /> {{ t('delete') }}
</a-menu-item>
</a-menu>
</template>
+12 -3
View File
@@ -120,8 +120,17 @@ function onOk() {
emit('confirm', buildResult());
}
const title = computed(() => (isEdit.value ? 'Edit rule' : 'Add rule'));
const okText = computed(() => (isEdit.value ? 'Update' : 'Add rule'));
import { useI18n } from 'vue-i18n';
const { t } = useI18n();
const title = computed(() =>
isEdit.value
? `${t('edit')} ${t('pages.xray.Routings')}`
: `+ ${t('pages.xray.Routings')}`,
);
const okText = computed(() =>
isEdit.value ? t('pages.client.submitEdit') : t('create'),
);
const NETWORKS = ['', 'TCP', 'UDP', 'TCP,UDP'];
const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
@@ -132,7 +141,7 @@ const PROTOCOLS = ['http', 'tls', 'bittorrent', 'quic'];
:open="open"
:title="title"
:ok-text="okText"
cancel-text="Close"
:cancel-text="t('close')"
:mask-closable="false"
width="640px"
@ok="onOk"
+4
View File
@@ -190,6 +190,10 @@ const hasConfig = computed(() => !ObjectUtil.isEmpty(warpConfig.value));
: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 +
dialog headers translate. -->
<!-- Not registered yet → single Create CTA -->
<template v-if="!hasWarp">
<a-button type="primary" :loading="loading" @click="register">
+19 -1
View File
@@ -42,6 +42,7 @@ const {
fetched,
spinning,
saveDisabled,
fetchError,
xraySetting,
templateSettings,
outboundTestUrl,
@@ -50,6 +51,7 @@ const {
restartResult,
outboundsTraffic,
outboundTestStates,
fetchAll,
fetchOutboundsTraffic,
resetOutboundsTraffic,
testOutbound,
@@ -131,6 +133,11 @@ const { isMobile } = useMediaQuery();
const basePath = window.__X_UI_BASE_PATH__ || '';
const requestUri = window.location.pathname;
// See SettingsPage scrollTarget — wrap so `document` is in scope.
function scrollTarget() {
return document.getElementById('content-layout');
}
function confirmRestart() {
Modal.confirm({
title: 'Restart xray?',
@@ -155,6 +162,17 @@ 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"
>
<template #extra>
<a-button type="primary" @click="fetchAll">{{ t('check') }}</a-button>
</template>
</a-result>
<template v-else>
<a-row :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">
<!-- Save / Restart bar -->
@@ -179,7 +197,7 @@ function confirmRestart() {
</a-space>
</a-col>
<a-col :xs="24" :sm="10" class="header-info">
<a-back-top :target="() => document.getElementById('content-layout')" :visibility-height="200" />
<a-back-top :target="scrollTarget" :visibility-height="200" />
<a-alert
type="warning"
show-icon