i18n(frontend): translate page chrome — sidebar, save bars, tabs, summary cards

Replaces hardcoded English with t() calls in the components every
user sees on every page load. The translations themselves come from
the existing TOML files via the sync script — no new strings, no
new locale keys.

Per component:
- AppSidebar.vue: 5 menu titles (dashboard / inbounds / settings /
  xray / logout). Computed so the sidebar re-renders when the
  cookie-driven locale flips on reload.
- IndexPage.vue: Quick actions card title + Logs / Backup / Up-to-
  date / Update buttons.
- StatusCard.vue: CPU / Memory / Swap / Storage labels +
  logical-processors / frequency tooltips.
- XrayStatusCard.vue: card title + error popover header + Stop /
  Restart / Switch xray action labels (kept the v-prefix version
  string as-is — it's content, not a label).
- SettingsPage.vue: 5 tab titles + Save / Restart-panel buttons +
  unsaved-changes warning.
- XrayPage.vue: 6 tab titles + Save / Restart-xray buttons +
  unsaved-changes warning.
- InboundsPage.vue: 5 summary-stat card titles.
- InboundList.vue: 10 column titles (computed for live locale),
  Add inbound / General actions buttons + every dropdown menu item,
  search placeholder, filter radio labels, popover titles
  (disabled / depleted / depleting / online), traffic + info
  popover row labels.

Total: ~75 strings localised across 8 files. The remaining English
labels live in the per-tab settings forms, the form modals
(Inbound / Client / Outbound / Rule / Balancer / WARP / Nord), and
the per-row table cell helpers — all incremental work that doesn't
touch infrastructure.

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
MHSanaei 2026-05-08 15:07:41 +02:00
parent 35efeb983e
commit e7d117f11f
No known key found for this signature in database
GPG key ID: 7E4060F2FBE5AB7A
8 changed files with 136 additions and 107 deletions

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import { ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { import {
DashboardOutlined, DashboardOutlined,
UserOutlined, UserOutlined,
@ -13,6 +14,8 @@ import {
import { currentTheme } from '@/composables/useTheme.js'; import { currentTheme } from '@/composables/useTheme.js';
import ThemeSwitch from '@/components/ThemeSwitch.vue'; import ThemeSwitch from '@/components/ThemeSwitch.vue';
const { t } = useI18n();
const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed'; const SIDEBAR_COLLAPSED_KEY = 'isSidebarCollapsed';
const props = defineProps({ const props = defineProps({
@ -42,13 +45,15 @@ const iconByName = {
// would turn /panel/settings + 'panel/...' into /panel/panel/...). // would turn /panel/settings + 'panel/...' into /panel/panel/...).
const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`; const prefix = props.basePath?.startsWith('/') ? props.basePath : `/${props.basePath || ''}`;
const tabs = [ // Labels are i18n-driven so the sidebar matches the locale picked
{ key: `${prefix}panel/`, icon: 'dashboard', title: 'Dashboard' }, // in panel settings without a page reload of the sidebar component.
{ key: `${prefix}panel/inbounds`, icon: 'user', title: 'Inbounds' }, const tabs = computed(() => [
{ key: `${prefix}panel/settings`, icon: 'setting', title: 'Settings' }, { key: `${prefix}panel/`, icon: 'dashboard', title: t('menu.dashboard') },
{ key: `${prefix}panel/xray`, icon: 'tool', title: 'Xray' }, { key: `${prefix}panel/inbounds`, icon: 'user', title: t('menu.inbounds') },
{ key: `${prefix}logout/`, icon: 'logout', title: 'Logout' }, { 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]); const activeTab = ref([props.requestUri]);

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { import {
PlusOutlined, PlusOutlined,
MenuOutlined, MenuOutlined,
@ -29,6 +30,8 @@ import { DBInbound } from '@/models/dbinbound.js';
import { Inbound } from '@/models/inbound.js'; import { Inbound } from '@/models/inbound.js';
import ClientRowTable from './ClientRowTable.vue'; import ClientRowTable from './ClientRowTable.vue';
const { t } = useI18n();
const props = defineProps({ const props = defineProps({
dbInbounds: { type: Array, required: true }, dbInbounds: { type: Array, required: true },
clientCount: { type: Object, required: true }, clientCount: { type: Object, required: true },
@ -136,26 +139,27 @@ const visibleInbounds = computed(() => {
// ============ Columns ================================================= // ============ Columns =================================================
// `key`-driven so we can render via the body-cell slot below. AD-Vue 4's // `key`-driven so we can render via the body-cell slot below. AD-Vue 4's
// `responsive` array still works on column defs. // `responsive` array still works on column defs. Computed so column
const desktopColumns = [ // labels react to live locale switches.
const desktopColumns = computed(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] }, { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 30, responsive: ['xs'] },
{ title: 'Action', key: 'action', align: 'center', width: 30 }, { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 30 },
{ title: 'Enable', key: 'enable', align: 'center', width: 35 }, { title: t('pages.inbounds.enable'), key: 'enable', align: 'center', width: 35 },
{ title: 'Remark', dataIndex: 'remark', key: 'remark', align: 'center', width: 60 }, { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'center', width: 60 },
{ title: 'Port', dataIndex: 'port', key: 'port', align: 'center', width: 40 }, { title: t('pages.inbounds.port'), dataIndex: 'port', key: 'port', align: 'center', width: 40 },
{ title: 'Protocol', key: 'protocol', align: 'left', width: 70 }, { title: t('pages.inbounds.protocol'), key: 'protocol', align: 'left', width: 70 },
{ title: 'Clients', key: 'clients', align: 'left', width: 50 }, { title: t('clients'), key: 'clients', align: 'left', width: 50 },
{ title: 'Traffic', key: 'traffic', align: 'center', width: 90 }, { title: t('pages.inbounds.traffic'), key: 'traffic', align: 'center', width: 90 },
{ title: 'All-time', key: 'allTimeInbound', align: 'center', width: 60 }, { title: t('pages.inbounds.allTimeTraffic'), key: 'allTimeInbound', align: 'center', width: 60 },
{ title: 'Expiry', key: 'expiryTime', align: 'center', width: 40 }, { title: t('pages.inbounds.expireDate'), key: 'expiryTime', align: 'center', width: 40 },
]; ]);
const mobileColumns = [ const mobileColumns = computed(() => [
{ title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 10, responsive: ['s'] }, { title: 'ID', dataIndex: 'id', key: 'id', align: 'right', width: 10, responsive: ['s'] },
{ title: 'Action', key: 'action', align: 'center', width: 25 }, { title: t('pages.inbounds.operate'), key: 'action', align: 'center', width: 25 },
{ title: 'Remark', dataIndex: 'remark', key: 'remark', align: 'left', width: 70 }, { title: t('pages.inbounds.remark'), dataIndex: 'remark', key: 'remark', align: 'left', width: 70 },
{ title: 'Info', key: 'info', align: 'center', width: 10 }, { title: t('info'), key: 'info', align: 'center', width: 10 },
]; ]);
const columns = computed(() => (props.isMobile ? mobileColumns : desktopColumns)); const columns = computed(() => (props.isMobile ? mobileColumns.value : desktopColumns.value));
// ============ Pagination ============================================ // ============ Pagination ============================================
function paginationFor(rows) { function paginationFor(rows) {
@ -208,32 +212,32 @@ function showQrCodeMenu(dbInbound) {
<a-space direction="horizontal"> <a-space direction="horizontal">
<a-button type="primary" @click="emit('add-inbound')"> <a-button type="primary" @click="emit('add-inbound')">
<template #icon><PlusOutlined /></template> <template #icon><PlusOutlined /></template>
<template v-if="!isMobile">Add inbound</template> <template v-if="!isMobile">{{ t('pages.inbounds.addInbound') }}</template>
</a-button> </a-button>
<a-dropdown :trigger="['click']"> <a-dropdown :trigger="['click']">
<a-button type="primary"> <a-button type="primary">
<template #icon><MenuOutlined /></template> <template #icon><MenuOutlined /></template>
<template v-if="!isMobile">General actions</template> <template v-if="!isMobile">{{ t('pages.inbounds.generalActions') }}</template>
</a-button> </a-button>
<template #overlay> <template #overlay>
<a-menu @click="(a) => emit('general-action', a.key)"> <a-menu @click="(a) => emit('general-action', a.key)">
<a-menu-item key="import"> <a-menu-item key="import">
<ImportOutlined /> Import inbound <ImportOutlined /> {{ t('pages.inbounds.importInbound') }}
</a-menu-item> </a-menu-item>
<a-menu-item key="export"> <a-menu-item key="export">
<ExportOutlined /> Export <ExportOutlined /> {{ t('pages.inbounds.export') }}
</a-menu-item> </a-menu-item>
<a-menu-item v-if="subEnable" key="subs"> <a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> Export Subscription <ExportOutlined /> {{ t('pages.inbounds.export') }} {{ t('pages.settings.subSettings') }}
</a-menu-item> </a-menu-item>
<a-menu-item key="resetInbounds"> <a-menu-item key="resetInbounds">
<ReloadOutlined /> Reset all traffic <ReloadOutlined /> {{ t('pages.inbounds.resetAllTraffic') }}
</a-menu-item> </a-menu-item>
<a-menu-item key="resetClients"> <a-menu-item key="resetClients">
<FileDoneOutlined /> Reset all client traffic <FileDoneOutlined /> {{ t('pages.inbounds.resetAllClientTraffics') }}
</a-menu-item> </a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item"> <a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> Delete depleted clients <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
</template> </template>
@ -250,12 +254,12 @@ function showQrCodeMenu(dbInbound) {
<template #title> <template #title>
<div class="auto-refresh-title"> <div class="auto-refresh-title">
<a-switch v-model:checked="isRefreshEnabled" size="small" /> <a-switch v-model:checked="isRefreshEnabled" size="small" />
<span>Auto refresh</span> <span>{{ t('pages.inbounds.autoRefresh') }}</span>
</div> </div>
</template> </template>
<template #content> <template #content>
<a-space direction="vertical"> <a-space direction="vertical">
<span>Auto-refresh interval</span> <span>{{ t('pages.inbounds.autoRefreshInterval') }}</span>
<a-select <a-select
v-model:value="refreshIntervalMs" v-model:value="refreshIntervalMs"
:disabled="!isRefreshEnabled" :disabled="!isRefreshEnabled"
@ -284,7 +288,7 @@ function showQrCodeMenu(dbInbound) {
<a-input <a-input
v-if="!enableFilter" v-if="!enableFilter"
v-model:value="searchKey" v-model:value="searchKey"
placeholder="Search" :placeholder="t('search')"
autofocus autofocus
:size="isMobile ? 'small' : 'middle'" :size="isMobile ? 'small' : 'middle'"
:style="{ maxWidth: '300px' }" :style="{ maxWidth: '300px' }"
@ -295,12 +299,12 @@ function showQrCodeMenu(dbInbound) {
button-style="solid" button-style="solid"
:size="isMobile ? 'small' : 'middle'" :size="isMobile ? 'small' : 'middle'"
> >
<a-radio-button value="">None</a-radio-button> <a-radio-button value="">{{ t('none') }}</a-radio-button>
<a-radio-button value="active">Active</a-radio-button> <a-radio-button value="active">{{ t('subscription.active') }}</a-radio-button>
<a-radio-button value="deactive">Disabled</a-radio-button> <a-radio-button value="deactive">{{ t('disabled') }}</a-radio-button>
<a-radio-button value="depleted">Depleted</a-radio-button> <a-radio-button value="depleted">{{ t('depleted') }}</a-radio-button>
<a-radio-button value="expiring">Depleting</a-radio-button> <a-radio-button value="expiring">{{ t('depletingSoon') }}</a-radio-button>
<a-radio-button value="online">Online</a-radio-button> <a-radio-button value="online">{{ t('online') }}</a-radio-button>
</a-radio-group> </a-radio-group>
</div> </div>
@ -343,31 +347,31 @@ function showQrCodeMenu(dbInbound) {
<MoreOutlined class="row-action-trigger" @click.prevent /> <MoreOutlined class="row-action-trigger" @click.prevent />
<template #overlay> <template #overlay>
<a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })"> <a-menu @click="(a) => emit('row-action', { key: a.key, dbInbound: record })">
<a-menu-item key="edit"><EditOutlined /> Edit</a-menu-item> <a-menu-item key="edit"><EditOutlined /> {{ t('edit') }}</a-menu-item>
<a-menu-item v-if="showQrCodeMenu(record)" key="qrcode"> <a-menu-item v-if="showQrCodeMenu(record)" key="qrcode">
<QrcodeOutlined /> QR code <QrcodeOutlined /> {{ t('qrCode') }}
</a-menu-item> </a-menu-item>
<template v-if="record.isMultiUser()"> <template v-if="record.isMultiUser()">
<a-menu-item key="addClient"><UserAddOutlined /> Add client</a-menu-item> <a-menu-item key="addClient"><UserAddOutlined /> {{ t('pages.client.add') }}</a-menu-item>
<a-menu-item key="addBulkClient"><UsergroupAddOutlined /> Add bulk clients</a-menu-item> <a-menu-item key="addBulkClient"><UsergroupAddOutlined /> {{ t('pages.client.bulk') }}</a-menu-item>
<a-menu-item key="copyClients"><CopyOutlined /> Copy clients from inbound</a-menu-item> <a-menu-item key="copyClients"><CopyOutlined /> {{ t('pages.client.copyFromInbound') }}</a-menu-item>
<a-menu-item key="resetClients"><FileDoneOutlined /> Reset client traffic</a-menu-item> <a-menu-item key="resetClients"><FileDoneOutlined /> {{ t('pages.inbounds.resetInboundClientTraffics') }}</a-menu-item>
<a-menu-item key="export"><ExportOutlined /> Export</a-menu-item> <a-menu-item key="export"><ExportOutlined /> {{ t('pages.inbounds.export') }}</a-menu-item>
<a-menu-item v-if="subEnable" key="subs"> <a-menu-item v-if="subEnable" key="subs">
<ExportOutlined /> Export Subscription <ExportOutlined /> {{ t('pages.inbounds.export') }} {{ t('pages.settings.subSettings') }}
</a-menu-item> </a-menu-item>
<a-menu-item key="delDepletedClients" class="danger-item"> <a-menu-item key="delDepletedClients" class="danger-item">
<RestOutlined /> Delete depleted clients <RestOutlined /> {{ t('pages.inbounds.delDepletedClients') }}
</a-menu-item> </a-menu-item>
</template> </template>
<template v-else> <template v-else>
<a-menu-item key="showInfo"><InfoCircleOutlined /> Info</a-menu-item> <a-menu-item key="showInfo"><InfoCircleOutlined /> {{ t('info') }}</a-menu-item>
</template> </template>
<a-menu-item key="clipboard"><CopyOutlined /> Export inbound</a-menu-item> <a-menu-item key="clipboard"><CopyOutlined /> {{ t('pages.inbounds.exportInbound') }}</a-menu-item>
<a-menu-item key="resetTraffic"><RetweetOutlined /> Reset traffic</a-menu-item> <a-menu-item key="resetTraffic"><RetweetOutlined /> {{ t('pages.inbounds.resetTraffic') }}</a-menu-item>
<a-menu-item key="clone"><BlockOutlined /> Clone</a-menu-item> <a-menu-item key="clone"><BlockOutlined /> {{ t('pages.inbounds.clone') }}</a-menu-item>
<a-menu-item key="delete" class="danger-item"> <a-menu-item key="delete" class="danger-item">
<DeleteOutlined /> Delete <DeleteOutlined /> {{ t('delete') }}
</a-menu-item> </a-menu-item>
</a-menu> </a-menu>
</template> </template>
@ -398,25 +402,25 @@ function showQrCodeMenu(dbInbound) {
<template v-else-if="column.key === 'clients'"> <template v-else-if="column.key === 'clients'">
<template v-if="clientCount[record.id]"> <template v-if="clientCount[record.id]">
<a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag> <a-tag color="green" style="margin: 0">{{ clientCount[record.id].clients }}</a-tag>
<a-popover v-if="clientCount[record.id].deactive.length" title="Disabled"> <a-popover v-if="clientCount[record.id].deactive.length" :title="t('disabled')">
<template #content> <template #content>
<div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].deactive" :key="email">{{ email }}</div>
</template> </template>
<a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag> <a-tag style="margin: 0; padding: 0 2px">{{ clientCount[record.id].deactive.length }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].depleted.length" title="Depleted"> <a-popover v-if="clientCount[record.id].depleted.length" :title="t('depleted')">
<template #content> <template #content>
<div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].depleted" :key="email">{{ email }}</div>
</template> </template>
<a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length }}</a-tag> <a-tag color="red" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].depleted.length }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].expiring.length" title="Depleting soon"> <a-popover v-if="clientCount[record.id].expiring.length" :title="t('depletingSoon')">
<template #content> <template #content>
<div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].expiring" :key="email">{{ email }}</div>
</template> </template>
<a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length }}</a-tag> <a-tag color="orange" style="margin: 0; padding: 0 2px">{{ clientCount[record.id].expiring.length }}</a-tag>
</a-popover> </a-popover>
<a-popover v-if="clientCount[record.id].online.length" title="Online"> <a-popover v-if="clientCount[record.id].online.length" :title="t('online')">
<template #content> <template #content>
<div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div> <div v-for="email in clientCount[record.id].online" :key="email">{{ email }}</div>
</template> </template>
@ -436,7 +440,7 @@ function showQrCodeMenu(dbInbound) {
<td> {{ SizeFormatter.sizeFormat(record.down) }}</td> <td> {{ SizeFormatter.sizeFormat(record.down) }}</td>
</tr> </tr>
<tr v-if="record.total > 0 && record.up + record.down < record.total"> <tr v-if="record.total > 0 && record.up + record.down < record.total">
<td>Remaining</td> <td>{{ t('remained') }}</td>
<td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td> <td>{{ SizeFormatter.sizeFormat(record.total - record.up - record.down) }}</td>
</tr> </tr>
</tbody> </tbody>
@ -476,19 +480,19 @@ function showQrCodeMenu(dbInbound) {
<table cellpadding="2"> <table cellpadding="2">
<tbody> <tbody>
<tr> <tr>
<td>Protocol</td> <td>{{ t('pages.inbounds.protocol') }}</td>
<td><a-tag color="purple">{{ record.protocol }}</a-tag></td> <td><a-tag color="purple">{{ record.protocol }}</a-tag></td>
</tr> </tr>
<tr> <tr>
<td>Port</td> <td>{{ t('pages.inbounds.port') }}</td>
<td><a-tag>{{ record.port }}</a-tag></td> <td><a-tag>{{ record.port }}</a-tag></td>
</tr> </tr>
<tr v-if="clientCount[record.id]"> <tr v-if="clientCount[record.id]">
<td>Clients</td> <td>{{ t('clients') }}</td>
<td><a-tag color="blue">{{ clientCount[record.id].clients }}</a-tag></td> <td><a-tag color="blue">{{ clientCount[record.id].clients }}</a-tag></td>
</tr> </tr>
<tr> <tr>
<td>Traffic</td> <td>{{ t('pages.inbounds.traffic') }}</td>
<td> <td>
<a-tag> <a-tag>
{{ SizeFormatter.sizeFormat(record.up + record.down) }} / {{ SizeFormatter.sizeFormat(record.up + record.down) }} /
@ -498,7 +502,7 @@ function showQrCodeMenu(dbInbound) {
</td> </td>
</tr> </tr>
<tr> <tr>
<td>Expiry</td> <td>{{ t('pages.inbounds.expireDate') }}</td>
<td> <td>
<a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag> <a-tag v-if="record.expiryTime > 0">{{ IntlUtil.formatRelativeTime(record.expiryTime) }}</a-tag>
<a-tag v-else color="purple"></a-tag> <a-tag v-else color="purple"></a-tag>

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { theme as antdTheme, Modal, message } from 'ant-design-vue'; import { theme as antdTheme, Modal, message } from 'ant-design-vue';
import { import {
SwapOutlined, SwapOutlined,
@ -25,6 +26,8 @@ import TextModal from '@/components/TextModal.vue';
import PromptModal from '@/components/PromptModal.vue'; import PromptModal from '@/components/PromptModal.vue';
import { useInbounds } from './useInbounds.js'; import { useInbounds } from './useInbounds.js';
const { t } = useI18n();
const antdThemeConfig = computed(() => ({ const antdThemeConfig = computed(() => ({
algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
})); }));
@ -531,7 +534,7 @@ function onRowAction({ key, dbInbound }) {
<a-row :gutter="[16, 12]"> <a-row :gutter="[16, 12]">
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<CustomStatistic <CustomStatistic
title="Total ↑ / ↓" :title="t('pages.inbounds.totalDownUp')"
:value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`" :value="`${SizeFormatter.sizeFormat(totals.up)} / ${SizeFormatter.sizeFormat(totals.down)}`"
> >
<template #prefix><SwapOutlined /></template> <template #prefix><SwapOutlined /></template>
@ -539,7 +542,7 @@ function onRowAction({ key, dbInbound }) {
</a-col> </a-col>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<CustomStatistic <CustomStatistic
title="Total usage" :title="t('pages.inbounds.totalUsage')"
:value="SizeFormatter.sizeFormat(totals.up + totals.down)" :value="SizeFormatter.sizeFormat(totals.up + totals.down)"
> >
<template #prefix><PieChartOutlined /></template> <template #prefix><PieChartOutlined /></template>
@ -547,19 +550,19 @@ function onRowAction({ key, dbInbound }) {
</a-col> </a-col>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<CustomStatistic <CustomStatistic
title="All-time traffic" :title="t('pages.inbounds.allTimeTrafficUsage')"
:value="SizeFormatter.sizeFormat(totals.allTime)" :value="SizeFormatter.sizeFormat(totals.allTime)"
> >
<template #prefix><HistoryOutlined /></template> <template #prefix><HistoryOutlined /></template>
</CustomStatistic> </CustomStatistic>
</a-col> </a-col>
<a-col :sm="12" :md="5"> <a-col :sm="12" :md="5">
<CustomStatistic title="Inbounds" :value="String(dbInbounds.length)"> <CustomStatistic :title="t('pages.inbounds.inboundCount')" :value="String(dbInbounds.length)">
<template #prefix><BarsOutlined /></template> <template #prefix><BarsOutlined /></template>
</CustomStatistic> </CustomStatistic>
</a-col> </a-col>
<a-col :sm="24" :md="4"> <a-col :sm="24" :md="4">
<CustomStatistic title="Clients" value=" "> <CustomStatistic :title="t('clients')" value=" ">
<template #prefix> <template #prefix>
<a-space direction="horizontal"> <a-space direction="horizontal">
<TeamOutlined /> <TeamOutlined />

View file

@ -1,8 +1,11 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { theme as antdTheme } from 'ant-design-vue'; import { theme as antdTheme } from 'ant-design-vue';
import { BarsOutlined, CloudServerOutlined, CloudDownloadOutlined } from '@ant-design/icons-vue'; import { BarsOutlined, CloudServerOutlined, CloudDownloadOutlined } from '@ant-design/icons-vue';
const { t } = useI18n();
import { HttpUtil } from '@/utils'; import { HttpUtil } from '@/utils';
import { theme as themeState } from '@/composables/useTheme.js'; import { theme as themeState } from '@/composables/useTheme.js';
import { useStatus } from '@/composables/useStatus.js'; import { useStatus } from '@/composables/useStatus.js';
@ -104,29 +107,31 @@ function openVersionSwitch() { versionOpen.value = true; }
</a-col> </a-col>
<a-col :sm="24" :lg="12"> <a-col :sm="24" :lg="12">
<a-card title="Quick actions" hoverable> <a-card :title="t('menu.link')" hoverable>
<template v-if="panelUpdateInfo.updateAvailable" #extra> <template v-if="panelUpdateInfo.updateAvailable" #extra>
<a-tooltip :title="`Update panel: ${panelUpdateInfo.latestVersion}`"> <a-tooltip :title="`${t('pages.index.updatePanel')}: ${panelUpdateInfo.latestVersion}`">
<a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true"> <a-tag color="orange" class="update-tag" @click="panelUpdateOpen = true">
<CloudDownloadOutlined /> <CloudDownloadOutlined />
{{ panelUpdateInfo.latestVersion }} {{ panelUpdateInfo.latestVersion }}
<span v-if="!isMobile">Update</span> <span v-if="!isMobile">{{ t('update') }}</span>
</a-tag> </a-tag>
</a-tooltip> </a-tooltip>
</template> </template>
<template #actions> <template #actions>
<a-space class="action" @click="logsOpen = true"> <a-space class="action" @click="logsOpen = true">
<BarsOutlined /> <BarsOutlined />
<span v-if="!isMobile">Logs</span> <span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
</a-space> </a-space>
<a-space class="action" @click="backupOpen = true"> <a-space class="action" @click="backupOpen = true">
<CloudServerOutlined /> <CloudServerOutlined />
<span v-if="!isMobile">Backup</span> <span v-if="!isMobile">{{ t('pages.index.backupTitle') }}</span>
</a-space> </a-space>
<a-space class="action" @click="panelUpdateOpen = true"> <a-space class="action" @click="panelUpdateOpen = true">
<CloudDownloadOutlined /> <CloudDownloadOutlined />
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ panelUpdateInfo.updateAvailable ? `Update → ${panelUpdateInfo.latestVersion}` : 'Up to date' }} {{ panelUpdateInfo.updateAvailable
? `${t('update')}${panelUpdateInfo.latestVersion}`
: t('pages.index.panelUpToDate') }}
</span> </span>
</a-space> </a-space>
</template> </template>

View file

@ -1,8 +1,11 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
import { AreaChartOutlined, HistoryOutlined } from '@ant-design/icons-vue'; import { AreaChartOutlined, HistoryOutlined } from '@ant-design/icons-vue';
import { CPUFormatter, SizeFormatter } from '@/utils'; import { CPUFormatter, SizeFormatter } from '@/utils';
const { t } = useI18n();
defineProps({ defineProps({
status: { type: Object, required: true }, status: { type: Object, required: true },
isMobile: { type: Boolean, default: false }, isMobile: { type: Boolean, default: false },
@ -25,16 +28,16 @@ defineEmits(['open-cpu-history']);
:percent="status.cpu.percent" :percent="status.cpu.percent"
/> />
<div> <div>
<b>CPU:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }} <b>{{ t('pages.index.cpu') }}:</b> {{ CPUFormatter.cpuCoreFormat(status.cpuCores) }}
<a-tooltip> <a-tooltip>
<template #title> <template #title>
<div><b>Logical processors:</b> {{ status.logicalPro }}</div> <div><b>{{ t('pages.index.logicalProcessors') }}:</b> {{ status.logicalPro }}</div>
<div><b>Frequency:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}</div> <div><b>{{ t('pages.index.frequency') }}:</b> {{ CPUFormatter.cpuSpeedFormat(status.cpuSpeedMhz) }}</div>
</template> </template>
<AreaChartOutlined /> <AreaChartOutlined />
</a-tooltip> </a-tooltip>
<a-tooltip> <a-tooltip>
<template #title>CPU history</template> <template #title>{{ t('pages.index.cpu') }}</template>
<a-button size="small" shape="circle" class="ml-8" @click="$emit('open-cpu-history')"> <a-button size="small" shape="circle" class="ml-8" @click="$emit('open-cpu-history')">
<template #icon><HistoryOutlined /></template> <template #icon><HistoryOutlined /></template>
</a-button> </a-button>
@ -50,7 +53,7 @@ defineEmits(['open-cpu-history']);
:percent="status.mem.percent" :percent="status.mem.percent"
/> />
<div> <div>
<b>Memory:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} / <b>{{ t('pages.index.memory') }}:</b> {{ SizeFormatter.sizeFormat(status.mem.current) }} /
{{ SizeFormatter.sizeFormat(status.mem.total) }} {{ SizeFormatter.sizeFormat(status.mem.total) }}
</div> </div>
</a-col> </a-col>
@ -68,7 +71,7 @@ defineEmits(['open-cpu-history']);
:percent="status.swap.percent" :percent="status.swap.percent"
/> />
<div> <div>
<b>Swap:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} / <b>{{ t('pages.index.swap') }}:</b> {{ SizeFormatter.sizeFormat(status.swap.current) }} /
{{ SizeFormatter.sizeFormat(status.swap.total) }} {{ SizeFormatter.sizeFormat(status.swap.total) }}
</div> </div>
</a-col> </a-col>
@ -81,7 +84,7 @@ defineEmits(['open-cpu-history']);
:percent="status.disk.percent" :percent="status.disk.percent"
/> />
<div> <div>
<b>Storage:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} / <b>{{ t('pages.index.storage') }}:</b> {{ SizeFormatter.sizeFormat(status.disk.current) }} /
{{ SizeFormatter.sizeFormat(status.disk.total) }} {{ SizeFormatter.sizeFormat(status.disk.total) }}
</div> </div>
</a-col> </a-col>

View file

@ -1,4 +1,5 @@
<script setup> <script setup>
import { useI18n } from 'vue-i18n';
import { import {
BarsOutlined, BarsOutlined,
PoweroffOutlined, PoweroffOutlined,
@ -6,6 +7,8 @@ import {
ToolOutlined, ToolOutlined,
} from '@ant-design/icons-vue'; } from '@ant-design/icons-vue';
const { t } = useI18n();
defineProps({ defineProps({
status: { type: Object, required: true }, status: { type: Object, required: true },
isMobile: { type: Boolean, default: false }, isMobile: { type: Boolean, default: false },
@ -30,7 +33,7 @@ function badgeAnimationClass(color) {
<a-card hoverable> <a-card hoverable>
<template #title> <template #title>
<a-space direction="horizontal"> <a-space direction="horizontal">
<span>Xray status</span> <span>{{ t('pages.index.xrayStatus') }}</span>
<a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green"> <a-tag v-if="isMobile && status.xray.version && status.xray.version !== 'Unknown'" color="green">
v{{ status.xray.version }} v{{ status.xray.version }}
</a-tag> </a-tag>
@ -50,7 +53,7 @@ function badgeAnimationClass(color) {
<a-popover> <a-popover>
<template #title> <template #title>
<a-row type="flex" align="middle" justify="space-between"> <a-row type="flex" align="middle" justify="space-between">
<a-col><span>Xray error</span></a-col> <a-col><span>{{ t('pages.index.xrayStatusError') }}</span></a-col>
<a-col> <a-col>
<BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" /> <BarsOutlined class="cursor-pointer" @click="$emit('open-logs')" />
</a-col> </a-col>
@ -74,22 +77,22 @@ function badgeAnimationClass(color) {
<template #actions> <template #actions>
<a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')"> <a-space v-if="ipLimitEnable" direction="horizontal" class="action" @click="$emit('open-xray-logs')">
<BarsOutlined /> <BarsOutlined />
<span v-if="!isMobile">Logs</span> <span v-if="!isMobile">{{ t('pages.index.logs') }}</span>
</a-space> </a-space>
<a-space direction="horizontal" class="action" @click="$emit('stop-xray')"> <a-space direction="horizontal" class="action" @click="$emit('stop-xray')">
<PoweroffOutlined /> <PoweroffOutlined />
<span v-if="!isMobile">Stop xray</span> <span v-if="!isMobile">{{ t('pages.index.stopXray') }}</span>
</a-space> </a-space>
<a-space direction="horizontal" class="action" @click="$emit('restart-xray')"> <a-space direction="horizontal" class="action" @click="$emit('restart-xray')">
<ReloadOutlined /> <ReloadOutlined />
<span v-if="!isMobile">Restart xray</span> <span v-if="!isMobile">{{ t('pages.index.restartXray') }}</span>
</a-space> </a-space>
<a-space direction="horizontal" class="action" @click="$emit('open-version-switch')"> <a-space direction="horizontal" class="action" @click="$emit('open-version-switch')">
<ToolOutlined /> <ToolOutlined />
<span v-if="!isMobile"> <span v-if="!isMobile">
{{ status.xray.version && status.xray.version !== 'Unknown' {{ status.xray.version && status.xray.version !== 'Unknown'
? `v${status.xray.version}` ? `v${status.xray.version}`
: 'Switch xray' }} : t('pages.index.xraySwitch') }}
</span> </span>
</a-space> </a-space>
</template> </template>

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue'; import { computed, onMounted, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { theme as antdTheme, Modal } from 'ant-design-vue'; import { theme as antdTheme, Modal } from 'ant-design-vue';
import { import {
SettingOutlined, SettingOutlined,
@ -20,6 +21,8 @@ import TelegramTab from './TelegramTab.vue';
import SubscriptionGeneralTab from './SubscriptionGeneralTab.vue'; import SubscriptionGeneralTab from './SubscriptionGeneralTab.vue';
import SubscriptionFormatsTab from './SubscriptionFormatsTab.vue'; import SubscriptionFormatsTab from './SubscriptionFormatsTab.vue';
const { t } = useI18n();
const antdThemeConfig = computed(() => ({ const antdThemeConfig = computed(() => ({
algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm, algorithm: themeState.isDark ? antdTheme.darkAlgorithm : antdTheme.defaultAlgorithm,
})); }));
@ -185,10 +188,10 @@ const alertVisible = ref(true);
<a-col :xs="24" :sm="10" class="header-actions"> <a-col :xs="24" :sm="10" class="header-actions">
<a-space direction="horizontal"> <a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="saveAll"> <a-button type="primary" :disabled="saveDisabled" @click="saveAll">
Save {{ t('pages.settings.save') }}
</a-button> </a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="restartPanel"> <a-button type="primary" danger :disabled="!saveDisabled" @click="restartPanel">
Restart panel {{ t('pages.settings.restartPanel') }}
</a-button> </a-button>
</a-space> </a-space>
</a-col> </a-col>
@ -197,7 +200,7 @@ const alertVisible = ref(true);
<a-alert <a-alert
type="warning" type="warning"
show-icon show-icon
message="Save before restarting — unsaved changes are dropped on restart." :message="t('pages.settings.infoDesc')"
/> />
</a-col> </a-col>
</a-row> </a-row>
@ -209,28 +212,28 @@ const alertVisible = ref(true);
<a-tab-pane key="1" class="tab-pane"> <a-tab-pane key="1" class="tab-pane">
<template #tab> <template #tab>
<SettingOutlined /> <SettingOutlined />
<span>Panel</span> <span>{{ t('pages.settings.panelSettings') }}</span>
</template> </template>
<GeneralTab :all-setting="allSetting" /> <GeneralTab :all-setting="allSetting" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="2" class="tab-pane"> <a-tab-pane key="2" class="tab-pane">
<template #tab> <template #tab>
<SafetyOutlined /> <SafetyOutlined />
<span>Security</span> <span>{{ t('pages.settings.securitySettings') }}</span>
</template> </template>
<SecurityTab :all-setting="allSetting" /> <SecurityTab :all-setting="allSetting" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="3" class="tab-pane"> <a-tab-pane key="3" class="tab-pane">
<template #tab> <template #tab>
<MessageOutlined /> <MessageOutlined />
<span>Telegram</span> <span>{{ t('pages.settings.TGBotSettings') }}</span>
</template> </template>
<TelegramTab :all-setting="allSetting" /> <TelegramTab :all-setting="allSetting" />
</a-tab-pane> </a-tab-pane>
<a-tab-pane key="4" class="tab-pane"> <a-tab-pane key="4" class="tab-pane">
<template #tab> <template #tab>
<CloudServerOutlined /> <CloudServerOutlined />
<span>Subscription</span> <span>{{ t('pages.settings.subSettings') }}</span>
</template> </template>
<SubscriptionGeneralTab :all-setting="allSetting" /> <SubscriptionGeneralTab :all-setting="allSetting" />
</a-tab-pane> </a-tab-pane>
@ -241,7 +244,7 @@ const alertVisible = ref(true);
> >
<template #tab> <template #tab>
<CodeOutlined /> <CodeOutlined />
<span>Subscription (Formats)</span> <span>{{ t('pages.settings.subSettings') }} (Formats)</span>
</template> </template>
<SubscriptionFormatsTab :all-setting="allSetting" /> <SubscriptionFormatsTab :all-setting="allSetting" />
</a-tab-pane> </a-tab-pane>

View file

@ -1,5 +1,6 @@
<script setup> <script setup>
import { computed, ref } from 'vue'; import { computed, ref } from 'vue';
import { useI18n } from 'vue-i18n';
import { theme as antdTheme, Modal } from 'ant-design-vue'; import { theme as antdTheme, Modal } from 'ant-design-vue';
import { import {
SettingOutlined, SettingOutlined,
@ -24,6 +25,8 @@ import WarpModal from './WarpModal.vue';
import NordModal from './NordModal.vue'; import NordModal from './NordModal.vue';
import { useXraySetting } from './useXraySetting.js'; import { useXraySetting } from './useXraySetting.js';
const { t } = useI18n();
// Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing, // Phase 6-i: scaffold + advanced JSON tab. Other tabs (Basics, Routing,
// Outbounds, Balancers, DNS) land in subsequent 6-iivi commits they // Outbounds, Balancers, DNS) land in subsequent 6-iivi commits they
// each need their own tree of structured forms or a dedicated modal. // each need their own tree of structured forms or a dedicated modal.
@ -161,10 +164,10 @@ function confirmRestart() {
<a-col :xs="24" :sm="14" class="header-actions"> <a-col :xs="24" :sm="14" class="header-actions">
<a-space direction="horizontal"> <a-space direction="horizontal">
<a-button type="primary" :disabled="saveDisabled" @click="saveAll"> <a-button type="primary" :disabled="saveDisabled" @click="saveAll">
Save {{ t('pages.xray.save') }}
</a-button> </a-button>
<a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart"> <a-button type="primary" danger :disabled="!saveDisabled" @click="confirmRestart">
Restart xray {{ t('pages.xray.restart') }}
</a-button> </a-button>
<a-popover v-if="restartResult" placement="rightTop"> <a-popover v-if="restartResult" placement="rightTop">
<template #title>Xray restart output</template> <template #title>Xray restart output</template>
@ -180,7 +183,7 @@ function confirmRestart() {
<a-alert <a-alert
type="warning" type="warning"
show-icon show-icon
message="Save before restarting — unsaved changes are dropped on restart." :message="t('pages.settings.infoDesc')"
/> />
</a-col> </a-col>
</a-row> </a-row>
@ -192,7 +195,7 @@ function confirmRestart() {
<a-tabs default-active-key="tpl-basic"> <a-tabs default-active-key="tpl-basic">
<a-tab-pane key="tpl-basic" class="tab-pane"> <a-tab-pane key="tpl-basic" class="tab-pane">
<template #tab> <template #tab>
<SettingOutlined /> <span>Basic template</span> <SettingOutlined /> <span>{{ t('pages.xray.basicTemplate') }}</span>
</template> </template>
<BasicsTab <BasicsTab
:template-settings="templateSettings" :template-settings="templateSettings"
@ -207,7 +210,7 @@ function confirmRestart() {
<a-tab-pane key="tpl-routing" class="tab-pane"> <a-tab-pane key="tpl-routing" class="tab-pane">
<template #tab> <template #tab>
<SwapOutlined /> <span>Routing</span> <SwapOutlined /> <span>{{ t('pages.xray.Routings') }}</span>
</template> </template>
<RoutingTab <RoutingTab
:template-settings="templateSettings" :template-settings="templateSettings"
@ -219,7 +222,7 @@ function confirmRestart() {
<a-tab-pane key="tpl-outbound" class="tab-pane"> <a-tab-pane key="tpl-outbound" class="tab-pane">
<template #tab> <template #tab>
<UploadOutlined /> <span>Outbounds</span> <UploadOutlined /> <span>{{ t('pages.xray.Outbounds') }}</span>
</template> </template>
<OutboundsTab <OutboundsTab
:template-settings="templateSettings" :template-settings="templateSettings"
@ -236,7 +239,7 @@ function confirmRestart() {
<a-tab-pane key="tpl-balancer" class="tab-pane"> <a-tab-pane key="tpl-balancer" class="tab-pane">
<template #tab> <template #tab>
<ClusterOutlined /> <span>Balancers</span> <ClusterOutlined /> <span>{{ t('pages.xray.Balancers') }}</span>
</template> </template>
<BalancersTab :template-settings="templateSettings" /> <BalancersTab :template-settings="templateSettings" />
</a-tab-pane> </a-tab-pane>
@ -250,7 +253,7 @@ function confirmRestart() {
<a-tab-pane key="tpl-advanced" class="tab-pane"> <a-tab-pane key="tpl-advanced" class="tab-pane">
<template #tab> <template #tab>
<CodeOutlined /> <span>Advanced (JSON)</span> <CodeOutlined /> <span>{{ t('pages.xray.advancedTemplate') }}</span>
</template> </template>
<a-form layout="vertical"> <a-form layout="vertical">
<a-form-item label="Outbound test URL"> <a-form-item label="Outbound test URL">