i18n(frontend): translate every remaining English string on the index page

Closes the index page's i18n coverage. Combined with the page-chrome
commit, every label users see on the dashboard is now sourced from
the TOML translation files.

Per file:
- IndexPage.vue: loading-spinner tip (initial + dynamic).
- BackupModal.vue: modal title, both list-item titles + descriptions
  ("Back up" / "Restore"), in-flight busy tips ("Importing database…"
  / "Restarting panel…").
- PanelUpdateModal.vue: modal title, update-available alert,
  current/latest version row labels, "Up to date" tag + label,
  primary action button. Modal.confirm now uses the translated
  panelUpdateDialog / panelUpdateDialogDesc with #version#
  substitution; success toast uses panelUpdateStartedPopover.
- LogModal.vue: title slot ("Logs"). The Debug/Info/Notice/Warning/
  Error log-level options stay literal — they're xray's wire values,
  not user-facing labels (matches the existing settings-page choice).
- XrayLogModal.vue: title + Filter label. Direct/Blocked/Proxy stay
  literal for the same reason.
- VersionModal.vue: modal title + xray-switch alert + per-file
  tooltip + "Update all" button + custom-geo collapse header. The
  Modal.confirm flows for switchXrayVersion + updateGeofile use
  translated dialog/desc with #version# / #filename# substitution.
- CpuHistoryModal.vue: title slot.
- CustomGeoSection.vue: routing-hint alert, Add / Update-all buttons,
  every column title (computed for live locale), copy/edit/download/
  delete tooltips, copy toast, delete-confirm modal, empty-state
  text.
- CustomGeoFormModal.vue: add/edit titles, OK/cancel labels, Type/
  Alias/URL field labels, alias placeholder, all three validation
  toasts.

Total: ~50 strings localised across 8 index-page files. The Hello /
Welcome login headline cycle and a handful of literal xray wire
values (Direct/Blocked/Proxy/log levels) are intentionally kept
hardcoded.

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

View file

@ -1,7 +1,10 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { DownloadOutlined, UploadOutlined } from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
const { t } = useI18n();
defineProps({
open: { type: Boolean, default: false },
basePath: { type: String, default: '' },
@ -32,7 +35,7 @@ function importDb() {
formData.append('db', dbFile);
close();
emit('busy', { busy: true, tip: 'Importing database…' });
emit('busy', { busy: true, tip: t('pages.index.importDatabase') + '…' });
const upload = await HttpUtil.post('/panel/api/server/importDB', formData, {
headers: { 'Content-Type': 'multipart/form-data' },
@ -42,7 +45,7 @@ function importDb() {
return;
}
emit('busy', { busy: true, tip: 'Restarting panel…' });
emit('busy', { busy: true, tip: t('pages.settings.restartPanel') + '…' });
const restart = await HttpUtil.post('/panel/setting/restartPanel');
if (restart?.success) {
await PromiseUtil.sleep(5000);
@ -56,12 +59,12 @@ function importDb() {
</script>
<template>
<a-modal :open="open" title="Database backup & restore" :closable="true" :footer="null" @cancel="close">
<a-modal :open="open" :title="t('pages.index.backupTitle')" :closable="true" :footer="null" @cancel="close">
<a-list bordered class="backup-list">
<a-list-item class="backup-item">
<a-list-item-meta>
<template #title>Back up</template>
<template #description>Click to download a .db file containing a backup of your current database to your device.</template>
<template #title>{{ t('pages.index.exportDatabase') }}</template>
<template #description>{{ t('pages.index.exportDatabaseDesc') }}</template>
</a-list-item-meta>
<a-button type="primary" @click="exportDb">
<template #icon><DownloadOutlined /></template>
@ -70,8 +73,8 @@ function importDb() {
<a-list-item class="backup-item">
<a-list-item-meta>
<template #title>Restore</template>
<template #description>Click to upload a .db file. The panel restarts after restore your session will reconnect automatically.</template>
<template #title>{{ t('pages.index.importDatabase') }}</template>
<template #description>{{ t('pages.index.importDatabaseDesc') }}</template>
</a-list-item-meta>
<a-button type="primary" @click="importDb">
<template #icon><UploadOutlined /></template>

View file

@ -1,8 +1,11 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { HttpUtil } from '@/utils';
import Sparkline from '@/components/Sparkline.vue';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
status: { type: Object, required: true },
@ -48,7 +51,7 @@ watch(bucket, () => { if (props.open) fetchBucket(); });
<template>
<a-modal :open="open" :closable="true" :footer="null" width="900px" @cancel="close">
<template #title>
CPU history
{{ t('pages.index.cpu') }}
<a-select v-model:value="bucket" size="small" class="bucket-select">
<a-select-option :value="2">2m</a-select-option>
<a-select-option :value="30">30m</a-select-option>

View file

@ -1,8 +1,11 @@
<script setup>
import { reactive, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { message } from 'ant-design-vue';
import { HttpUtil } from '@/utils';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
// Populate with the record when editing; null/undefined when adding.
@ -41,22 +44,22 @@ function close() {
function validate() {
// Backend expects a filesystem-safe alias; legacy enforces the same regex.
if (!/^[a-z0-9_-]+$/.test(form.alias || '')) {
message.error('Alias must contain only lowercase letters, digits, dashes or underscores.');
message.error(t('pages.index.customGeoValidationAlias'));
return false;
}
const u = (form.url || '').trim();
if (!/^https?:\/\//i.test(u)) {
message.error('URL must start with http:// or https://');
message.error(t('pages.index.customGeoValidationUrl'));
return false;
}
try {
const parsed = new URL(u);
if (parsed.protocol !== 'http:' && parsed.protocol !== 'https:') {
message.error('URL must start with http:// or https://');
message.error(t('pages.index.customGeoValidationUrl'));
return false;
}
} catch (_e) {
message.error('URL must start with http:// or https://');
message.error(t('pages.index.customGeoValidationUrl'));
return false;
}
return true;
@ -83,28 +86,28 @@ async function submit() {
<template>
<a-modal
:open="open"
:title="editing ? 'Edit custom geo entry' : 'Add custom geo entry'"
:title="editing ? t('pages.index.customGeoModalEdit') : t('pages.index.customGeoModalAdd')"
:confirm-loading="saving"
ok-text="Save"
cancel-text="Close"
:ok-text="t('pages.index.customGeoModalSave')"
:cancel-text="t('close')"
@ok="submit"
@cancel="close"
>
<a-form layout="vertical">
<a-form-item label="Type">
<a-form-item :label="t('pages.index.customGeoType')">
<a-select v-model:value="form.type" :disabled="editing">
<a-select-option value="geosite">geosite</a-select-option>
<a-select-option value="geoip">geoip</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Alias">
<a-form-item :label="t('pages.index.customGeoAlias')">
<a-input
v-model:value="form.alias"
:disabled="editing"
placeholder="lowercase letters, digits, dashes, underscores"
:placeholder="t('pages.index.customGeoAliasPlaceholder')"
/>
</a-form-item>
<a-form-item label="URL">
<a-form-item :label="t('pages.index.customGeoUrl')">
<a-input v-model:value="form.url" placeholder="https://" />
</a-form-item>
</a-form>

View file

@ -1,5 +1,6 @@
<script setup>
import { ref, watch } from 'vue';
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import {
PlusOutlined,
@ -12,6 +13,8 @@ import {
import { HttpUtil, ClipboardManager } from '@/utils';
import CustomGeoFormModal from './CustomGeoFormModal.vue';
const { t } = useI18n();
const props = defineProps({
// Re-fetch the list when the parent collapse expands this section.
active: { type: Boolean, default: false },
@ -25,13 +28,14 @@ const actionId = ref(null);
const formOpen = ref(false);
const editingRecord = ref(null);
const columns = [
{ title: 'Alias', key: 'alias', width: 200 },
{ title: 'URL', key: 'url', ellipsis: true },
{ title: 'Ext', key: 'extDat', width: 220 },
{ title: 'Last updated', key: 'lastUpdatedAt', width: 140 },
{ title: 'Actions', key: 'action', width: 120 },
];
// Computed so column titles re-render after a locale swap.
const columns = computed(() => [
{ title: t('pages.index.customGeoAlias'), key: 'alias', width: 200 },
{ title: t('pages.index.customGeoUrl'), key: 'url', ellipsis: true },
{ title: t('pages.index.customGeoExtColumn'), key: 'extDat', width: 220 },
{ title: t('pages.index.customGeoLastUpdated'), key: 'lastUpdatedAt', width: 140 },
{ title: t('pages.index.customGeoActions'), key: 'action', width: 120 },
]);
async function loadList() {
loading.value = true;
@ -63,7 +67,7 @@ function extDisplay(record) {
async function copyExt(record) {
const text = extDisplay(record);
const ok = await ClipboardManager.copyText(text);
if (ok) message.success(`Copied: ${text}`);
if (ok) message.success(`${t('copied')}: ${text}`);
}
function formatTime(ts) {
@ -87,11 +91,11 @@ function relativeTime(ts) {
function confirmDelete(record) {
Modal.confirm({
title: 'Delete custom geo entry',
content: `Delete "${record.alias}"? This cannot be undone.`,
okText: 'Delete',
title: t('pages.index.customGeoDelete'),
content: t('pages.index.customGeoDeleteConfirm'),
okText: t('delete'),
okType: 'danger',
cancelText: 'Cancel',
cancelText: t('cancel'),
onOk: async () => {
const msg = await HttpUtil.post(`/panel/api/custom-geo/delete/${record.id}`);
if (msg?.success) await loadList();
@ -134,17 +138,17 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
type="info"
show-icon
class="mb-10"
message="Reference custom files in routing rules with ext:&lt;filename&gt;:tag"
:message="t('pages.index.customGeoRoutingHint')"
/>
<div class="toolbar">
<a-button type="primary" :loading="loading" @click="openAdd">
<template #icon><PlusOutlined /></template>
Add
{{ t('pages.index.customGeoAdd') }}
</a-button>
<a-button :loading="updatingAll" :disabled="!list.length" @click="updateAll">
<template #icon><ReloadOutlined /></template>
Update all
{{ t('pages.index.geofilesUpdateAll') }}
</a-button>
<span v-if="list.length" class="custom-geo-count">{{ list.length }}</span>
</div>
@ -177,7 +181,7 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
</template>
<template v-else-if="column.key === 'extDat'">
<a-tooltip title="Copy">
<a-tooltip :title="t('copy')">
<code class="custom-geo-ext-code custom-geo-copyable" @click="copyExt(record)">
{{ extDisplay(record) }}
</code>
@ -193,12 +197,12 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
<template v-else-if="column.key === 'action'">
<a-space size="small">
<a-tooltip title="Edit">
<a-tooltip :title="t('pages.index.customGeoEdit')">
<a-button type="link" size="small" @click="openEdit(record)">
<template #icon><EditOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="Download">
<a-tooltip :title="t('pages.index.customGeoDownload')">
<a-button
type="link"
size="small"
@ -208,7 +212,7 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
<template #icon><ReloadOutlined /></template>
</a-button>
</a-tooltip>
<a-tooltip title="Delete">
<a-tooltip :title="t('pages.index.customGeoDelete')">
<a-button type="link" size="small" danger @click="confirmDelete(record)">
<template #icon><DeleteOutlined /></template>
</a-button>
@ -220,7 +224,7 @@ watch(() => props.active, (next) => { if (next) loadList(); }, { immediate: true
<template #emptyText>
<div class="custom-geo-empty">
<InboxOutlined class="custom-geo-empty-icon" />
<div>No custom geo entries yet</div>
<div>{{ t('pages.index.customGeoEmpty') }}</div>
</div>
</template>
</a-table>

View file

@ -57,7 +57,7 @@ const versionOpen = ref(false);
// Page-level loading overlay; modals can request it via @busy.
const loading = ref(false);
const loadingTip = ref('Loading…');
const loadingTip = ref(t('loading'));
function setBusy({ busy, tip }) {
loading.value = busy;
if (tip) loadingTip.value = tip;
@ -85,7 +85,7 @@ function openVersionSwitch() { versionOpen.value = true; }
<a-layout class="content-shell">
<a-layout-content class="content-area">
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : 'Loading…'" size="large">
<a-spin :spinning="loading || !fetched" :delay="200" :tip="loading ? loadingTip : t('loading')" size="large">
<div v-if="!fetched" class="loading-spacer" />
<a-row v-else :gutter="[isMobile ? 8 : 16, isMobile ? 0 : 12]">

View file

@ -1,9 +1,12 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, PromiseUtil } from '@/utils';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
});
@ -94,7 +97,7 @@ watch([rows, level, syslog], () => { if (props.open) refresh(); });
<template>
<a-modal :open="open" :closable="true" :footer="null" width="800px" @cancel="close">
<template #title>
Logs
{{ t('pages.index.logs') }}
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template>

View file

@ -1,9 +1,12 @@
<script setup>
import { useI18n } from 'vue-i18n';
import { Modal, message } from 'ant-design-vue';
import { CloudDownloadOutlined } from '@ant-design/icons-vue';
import { HttpUtil, PromiseUtil } from '@/utils';
import axios from 'axios';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
info: {
@ -20,14 +23,13 @@ function close() {
function updatePanel() {
Modal.confirm({
title: 'Update panel',
content: `The panel will be updated to ${props.info.latestVersion || ''} and restarted. Continue?`,
okText: 'Confirm',
cancelText: 'Cancel',
title: t('pages.index.panelUpdateDialog'),
content: t('pages.index.panelUpdateDialogDesc').replace('#version#', props.info.latestVersion || ''),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
const tip = props.info.latestVersion
? `Installation in progress, please do not refresh (${props.info.latestVersion})`
: 'Installation in progress, please do not refresh';
const baseTip = t('pages.index.dontRefresh');
const tip = props.info.latestVersion ? `${baseTip} (${props.info.latestVersion})` : baseTip;
close();
emit('busy', { busy: true, tip });
const msg = await HttpUtil.post('/panel/api/server/updatePanel');
@ -48,7 +50,7 @@ function updatePanel() {
await PromiseUtil.sleep(2000);
}
if (back) {
message.success('Panel update started');
message.success(t('pages.index.panelUpdateStartedPopover'));
await PromiseUtil.sleep(800);
}
window.location.reload();
@ -58,34 +60,34 @@ function updatePanel() {
</script>
<template>
<a-modal :open="open" title="Update panel" :closable="true" :footer="null" @cancel="close">
<a-modal :open="open" :title="t('pages.index.updatePanel')" :closable="true" :footer="null" @cancel="close">
<a-alert
v-if="info.updateAvailable"
type="warning"
class="mb-12"
message="A new panel version is available. Update will restart the service."
:message="t('pages.index.panelUpdateDesc')"
show-icon
/>
<a-list bordered class="version-list">
<a-list-item class="version-list-item">
<span>Current version</span>
<a-tag color="green">v{{ info.currentVersion || 'unknown' }}</a-tag>
<span>{{ t('pages.index.currentPanelVersion') }}</span>
<a-tag color="green">v{{ info.currentVersion || '?' }}</a-tag>
</a-list-item>
<a-list-item v-if="info.updateAvailable" class="version-list-item">
<span>Latest version</span>
<span>{{ t('pages.index.latestPanelVersion') }}</span>
<a-tag color="purple">{{ info.latestVersion || '-' }}</a-tag>
</a-list-item>
<a-list-item v-else class="version-list-item">
<span>Panel is up to date</span>
<a-tag color="green">Up to date</a-tag>
<span>{{ t('pages.index.panelUpToDate') }}</span>
<a-tag color="green">{{ t('pages.index.panelUpToDate') }}</a-tag>
</a-list-item>
</a-list>
<div class="actions-row">
<a-button type="primary" :disabled="!info.updateAvailable" @click="updatePanel">
<template #icon><CloudDownloadOutlined /></template>
Update panel
{{ t('pages.index.updatePanel') }}
</a-button>
</div>
</a-modal>

View file

@ -1,10 +1,13 @@
<script setup>
import { ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { Modal } from 'ant-design-vue';
import { ReloadOutlined } from '@ant-design/icons-vue';
import { HttpUtil } from '@/utils';
import CustomGeoSection from './CustomGeoSection.vue';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
status: { type: Object, required: true },
@ -36,13 +39,13 @@ function close() {
function switchXrayVersion(version) {
Modal.confirm({
title: 'Switch xray version',
content: `Are you sure you want to install ${version}? This will restart xray.`,
okText: 'Confirm',
cancelText: 'Cancel',
title: t('pages.index.xraySwitchVersionDialog'),
content: t('pages.index.xraySwitchVersionDialogDesc').replace('#version#', version),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
close();
emit('busy', { busy: true, tip: `Installing ${version}` });
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
try {
await HttpUtil.post(`/panel/api/server/installXray/${version}`);
} finally {
@ -55,15 +58,15 @@ function switchXrayVersion(version) {
function updateGeofile(fileName) {
const isSingle = !!fileName;
Modal.confirm({
title: 'Update geofile',
title: t('pages.index.geofileUpdateDialog'),
content: isSingle
? `Update ${fileName}? Xray will restart after the file is replaced.`
: 'Update all geofiles? Xray will restart after the files are replaced.',
okText: 'Confirm',
cancelText: 'Cancel',
? t('pages.index.geofileUpdateDialogDesc').replace('#filename#', fileName)
: t('pages.index.geofilesUpdateDialogDesc'),
okText: t('confirm'),
cancelText: t('cancel'),
onOk: async () => {
close();
emit('busy', { busy: true, tip: 'Updating geofiles…' });
emit('busy', { busy: true, tip: t('pages.index.dontRefresh') });
const url = isSingle
? `/panel/api/server/updateGeofile/${fileName}`
: '/panel/api/server/updateGeofile';
@ -80,14 +83,14 @@ watch(() => props.open, (next) => { if (next) fetchVersions(); });
</script>
<template>
<a-modal :open="open" title="Xray updates" :closable="true" :footer="null" @cancel="close">
<a-modal :open="open" :title="t('pages.index.xrayUpdates')" :closable="true" :footer="null" @cancel="close">
<a-spin :spinning="loading">
<a-collapse v-model:active-key="activeKey" accordion>
<a-collapse-panel key="1" header="Xray">
<a-alert
type="warning"
class="mb-12"
message="Click a version to install it. Xray will restart automatically."
:message="t('pages.index.xraySwitchClickDesk')"
show-icon
/>
<a-list bordered class="version-list">
@ -105,17 +108,17 @@ watch(() => props.open, (next) => { if (next) fetchVersions(); });
<a-list bordered class="version-list">
<a-list-item v-for="(file, index) in GEOFILES" :key="file" class="version-list-item">
<a-tag :color="index % 2 === 0 ? 'purple' : 'green'">{{ file }}</a-tag>
<a-tooltip title="Update this file">
<a-tooltip :title="t('update')">
<ReloadOutlined class="reload-icon" @click="updateGeofile(file)" />
</a-tooltip>
</a-list-item>
</a-list>
<div class="actions-row">
<a-button @click="updateGeofile('')">Update all</a-button>
<a-button @click="updateGeofile('')">{{ t('pages.index.geofilesUpdateAll') }}</a-button>
</div>
</a-collapse-panel>
<a-collapse-panel key="3" header="Custom geo">
<a-collapse-panel key="3" :header="t('pages.index.customGeoTitle')">
<CustomGeoSection :active="activeKey === '3'" />
</a-collapse-panel>
</a-collapse>

View file

@ -1,9 +1,12 @@
<script setup>
import { computed, ref, watch } from 'vue';
import { useI18n } from 'vue-i18n';
import { DownloadOutlined, SyncOutlined } from '@ant-design/icons-vue';
import { HttpUtil, FileManager, IntlUtil, PromiseUtil } from '@/utils';
const { t } = useI18n();
const props = defineProps({
open: { type: Boolean, default: false },
});
@ -102,7 +105,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
<template>
<a-modal :open="open" :closable="true" :footer="null" width="80vw" @cancel="close">
<template #title>
Xray logs
{{ t('pages.index.logs') }}
<SyncOutlined :spin="loading" class="reload-icon" @click="refresh" />
</template>
@ -116,7 +119,7 @@ watch([rows, showDirect, showBlocked, showProxy], () => { if (props.open) refres
<a-select-option value="500">500</a-select-option>
</a-select>
</a-form-item>
<a-form-item label="Filter">
<a-form-item :label="t('filter')">
<a-input v-model:value="filter" size="small" @keyup.enter="refresh" />
</a-form-item>
<a-form-item>