mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-11-15 09:35:00 +03:00
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:
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user