mirror of
https://github.com/MHSanaei/3x-ui.git
synced 2026-05-13 09:36:05 +00:00
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>
206 lines
6 KiB
Vue
206 lines
6 KiB
Vue
<script setup>
|
|
import { computed, ref } from 'vue';
|
|
import { useI18n } from 'vue-i18n';
|
|
import {
|
|
PlusOutlined,
|
|
MoreOutlined,
|
|
EditOutlined,
|
|
DeleteOutlined,
|
|
} from '@ant-design/icons-vue';
|
|
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;
|
|
// we follow the same convention on submit.
|
|
|
|
const props = defineProps({
|
|
templateSettings: { type: Object, default: null },
|
|
});
|
|
|
|
const STRATEGY_LABELS = {
|
|
random: 'Random',
|
|
roundRobin: 'Round robin',
|
|
leastLoad: 'Least load',
|
|
leastPing: 'Least ping',
|
|
};
|
|
|
|
const rows = computed(() => {
|
|
const list = props.templateSettings?.routing?.balancers || [];
|
|
return list.map((b, idx) => ({
|
|
key: idx,
|
|
tag: b.tag || '',
|
|
strategy: b.strategy?.type || 'random',
|
|
selector: b.selector || [],
|
|
fallbackTag: b.fallbackTag || '',
|
|
}));
|
|
});
|
|
|
|
const outboundTags = computed(
|
|
() => (props.templateSettings?.outbounds || [])
|
|
.filter((o) => o.tag)
|
|
.map((o) => o.tag),
|
|
);
|
|
|
|
// === Modal state ====================================================
|
|
const modalOpen = ref(false);
|
|
const editingBalancer = ref(null);
|
|
const editingIndex = ref(null);
|
|
const otherTags = ref([]);
|
|
|
|
function tagPool(excludeIdx) {
|
|
return rows.value.filter((b) => b.key !== excludeIdx).map((b) => b.tag).filter(Boolean);
|
|
}
|
|
|
|
function openAdd() {
|
|
editingBalancer.value = null;
|
|
editingIndex.value = null;
|
|
otherTags.value = rows.value.map((b) => b.tag).filter(Boolean);
|
|
modalOpen.value = true;
|
|
}
|
|
function openEdit(idx) {
|
|
editingBalancer.value = rows.value[idx];
|
|
editingIndex.value = idx;
|
|
otherTags.value = tagPool(idx);
|
|
modalOpen.value = true;
|
|
}
|
|
|
|
function ensureBalancersArray() {
|
|
if (!props.templateSettings.routing) return null;
|
|
if (!Array.isArray(props.templateSettings.routing.balancers)) {
|
|
props.templateSettings.routing.balancers = [];
|
|
}
|
|
return props.templateSettings.routing.balancers;
|
|
}
|
|
|
|
function buildWireBalancer(form) {
|
|
const out = {
|
|
tag: form.tag,
|
|
selector: [...form.selector],
|
|
fallbackTag: form.fallbackTag,
|
|
};
|
|
if (form.strategy && form.strategy !== 'random') {
|
|
out.strategy = { type: form.strategy };
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function onConfirm(form) {
|
|
const arr = ensureBalancersArray();
|
|
if (!arr) return;
|
|
|
|
const wire = buildWireBalancer(form);
|
|
if (editingIndex.value == null) {
|
|
arr.push(wire);
|
|
} else {
|
|
const oldTag = arr[editingIndex.value]?.tag;
|
|
arr[editingIndex.value] = wire;
|
|
// Preserve the legacy behaviour: when a balancer's tag is renamed,
|
|
// chase the rename across routing rules so existing references
|
|
// don't dangle.
|
|
if (oldTag && oldTag !== wire.tag) {
|
|
const rules = props.templateSettings.routing.rules || [];
|
|
for (const rule of rules) {
|
|
if (rule?.balancerTag === oldTag) rule.balancerTag = wire.tag;
|
|
}
|
|
}
|
|
}
|
|
modalOpen.value = false;
|
|
}
|
|
|
|
function confirmDelete(idx) {
|
|
Modal.confirm({
|
|
title: `${t('delete')} ${t('pages.xray.Balancers')} #${idx + 1}?`,
|
|
okText: t('delete'),
|
|
okType: 'danger',
|
|
cancelText: t('cancel'),
|
|
onOk: () => props.templateSettings.routing.balancers.splice(idx, 1),
|
|
});
|
|
}
|
|
|
|
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="t('emptyBalancersDesc')">
|
|
<a-button type="primary" @click="openAdd">
|
|
<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>
|
|
{{ t('pages.xray.Balancers') }}
|
|
</a-button>
|
|
|
|
<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>
|
|
<a-dropdown :trigger="['click']">
|
|
<a-button shape="circle" size="small" class="action-btn">
|
|
<MoreOutlined />
|
|
</a-button>
|
|
<template #overlay>
|
|
<a-menu>
|
|
<a-menu-item @click="openEdit(index)">
|
|
<EditOutlined /> {{ t('edit') }}
|
|
</a-menu-item>
|
|
<a-menu-item class="danger" @click="confirmDelete(index)">
|
|
<DeleteOutlined /> {{ t('delete') }}
|
|
</a-menu-item>
|
|
</a-menu>
|
|
</template>
|
|
</a-dropdown>
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'strategy'">
|
|
<a-tag :color="record.strategy === 'random' ? 'purple' : 'green'">
|
|
{{ STRATEGY_LABELS[record.strategy] || record.strategy }}
|
|
</a-tag>
|
|
</template>
|
|
|
|
<template v-else-if="column.key === 'selector'">
|
|
<a-tag v-for="sel in record.selector" :key="sel" class="info-large-tag">{{ sel }}</a-tag>
|
|
</template>
|
|
</template>
|
|
</a-table>
|
|
</template>
|
|
|
|
<BalancerFormModal v-model:open="modalOpen" :balancer="editingBalancer" :outbound-tags="outboundTags"
|
|
:other-tags="otherTags" @confirm="onConfirm" />
|
|
</a-space>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.row-index {
|
|
font-weight: 500;
|
|
opacity: 0.7;
|
|
margin-right: 6px;
|
|
}
|
|
|
|
.action-btn {
|
|
vertical-align: middle;
|
|
}
|
|
|
|
.danger {
|
|
color: #ff4d4f;
|
|
}
|
|
</style>
|