From 35efeb983e414108d2b8b287afa2327aa3b88804 Mon Sep 17 00:00:00 2001 From: MHSanaei Date: Fri, 8 May 2026 14:54:07 +0200 Subject: [PATCH] =?UTF-8?q?feat(frontend):=20Phase=207=20=E2=80=94=20vue-i?= =?UTF-8?q?18n=20wired=20up=20+=20login=20page=20translated?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sets up vue-i18n on top of the panel's existing TOML translation files. The Go side stays the source of truth — translators continue to edit web/translation/*.toml; a sync script snapshots those files into per-locale JSON the Vue bundle imports. The login page is translated end-to-end as a worked example; remaining pages can be converted incrementally without infrastructure churn. What's in the box: - scripts/sync-locales.mjs: small TOML→JSON converter that walks web/translation/*.toml and writes frontend/src/locales/.json. Handles the narrow subset of TOML the panel uses (flat key/value pairs + dotted [section.subsection] heads). Wired as a `prebuild` + `predev` script so production builds always include the latest strings without a manual step. - src/i18n/index.js: createI18n() in composition mode with all 13 locales emitted as their own Vite chunks. The active locale (read from the same `lang` cookie LanguageManager has always managed) plus the en-US fallback are eagerly loaded; the rest are dynamically importable via a loadLocale(code) helper. This keeps the per-page bundle the user actually downloads small — only ~30 KB of strings end up in the initial payload, vs ~220 KB if all 13 were eager. - All five page entries (index/login/settings/inbounds/xray) wire the i18n plugin into createApp via .use(i18n). - LoginPage.vue: t(...) replaces hardcoded English on the username / password / 2FA placeholders, the submit button label, and the Settings popover title. The Hello/Welcome headline cycle stays hardcoded — those are stylistic, not labels. The 'Hello'/'Welcome' cycle stays in English deliberately; the rest of the migration's components still ship hardcoded English and will be converted page by page in follow-up commits. Co-Authored-By: Claude Opus 4.7 --- frontend/package.json | 3 + frontend/scripts/sync-locales.mjs | 97 +++ frontend/src/i18n/index.js | 94 +++ frontend/src/inbounds.js | 3 +- frontend/src/index.js | 3 +- frontend/src/locales/ar-EG.json | 883 +++++++++++++++++++++++++ frontend/src/locales/en-US.json | 883 +++++++++++++++++++++++++ frontend/src/locales/es-ES.json | 883 +++++++++++++++++++++++++ frontend/src/locales/fa-IR.json | 883 +++++++++++++++++++++++++ frontend/src/locales/id-ID.json | 883 +++++++++++++++++++++++++ frontend/src/locales/ja-JP.json | 883 +++++++++++++++++++++++++ frontend/src/locales/pt-BR.json | 883 +++++++++++++++++++++++++ frontend/src/locales/ru-RU.json | 883 +++++++++++++++++++++++++ frontend/src/locales/tr-TR.json | 883 +++++++++++++++++++++++++ frontend/src/locales/uk-UA.json | 883 +++++++++++++++++++++++++ frontend/src/locales/vi-VN.json | 883 +++++++++++++++++++++++++ frontend/src/locales/zh-CN.json | 883 +++++++++++++++++++++++++ frontend/src/locales/zh-TW.json | 883 +++++++++++++++++++++++++ frontend/src/login.js | 3 +- frontend/src/pages/login/LoginPage.vue | 17 +- frontend/src/settings.js | 3 +- frontend/src/xray.js | 3 +- 22 files changed, 11691 insertions(+), 14 deletions(-) create mode 100644 frontend/scripts/sync-locales.mjs create mode 100644 frontend/src/i18n/index.js create mode 100644 frontend/src/locales/ar-EG.json create mode 100644 frontend/src/locales/en-US.json create mode 100644 frontend/src/locales/es-ES.json create mode 100644 frontend/src/locales/fa-IR.json create mode 100644 frontend/src/locales/id-ID.json create mode 100644 frontend/src/locales/ja-JP.json create mode 100644 frontend/src/locales/pt-BR.json create mode 100644 frontend/src/locales/ru-RU.json create mode 100644 frontend/src/locales/tr-TR.json create mode 100644 frontend/src/locales/uk-UA.json create mode 100644 frontend/src/locales/vi-VN.json create mode 100644 frontend/src/locales/zh-CN.json create mode 100644 frontend/src/locales/zh-TW.json diff --git a/frontend/package.json b/frontend/package.json index c59727f5..cdb1fc13 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -5,6 +5,9 @@ "type": "module", "description": "3x-ui panel frontend (Vue 3 + Ant Design Vue 4). Built with Vite into ../web/dist/ and embedded by the Go binary.", "scripts": { + "i18n:sync": "node scripts/sync-locales.mjs", + "predev": "node scripts/sync-locales.mjs", + "prebuild": "node scripts/sync-locales.mjs", "dev": "vite", "build": "vite build", "preview": "vite preview", diff --git a/frontend/scripts/sync-locales.mjs b/frontend/scripts/sync-locales.mjs new file mode 100644 index 00000000..1dc38986 --- /dev/null +++ b/frontend/scripts/sync-locales.mjs @@ -0,0 +1,97 @@ +// Converts the panel's TOML translation files (web/translation/) into +// nested JSON that vue-i18n can consume. The Go side stays the source +// of truth — translators continue to edit the TOML files; this script +// snapshots them into frontend/src/locales/.json on each run. +// +// Run via `npm run i18n:sync` (also kicked off automatically by +// `npm run prebuild` so production builds always include the latest +// strings). +// +// Format support is intentionally narrow — the project's TOML files +// are limited to: +// • blank lines and `# comment` lines +// • bare-string values: "key" = "value" +// • dotted section heads: [pages.inbounds.toasts] +// Multi-line strings, arrays, dates, and inline tables aren't used in +// the panel's translation set, so the parser rejects them rather than +// silently mis-parsing. If the format ever grows, swap this out for a +// proper TOML lib. + +import { readdirSync, readFileSync, writeFileSync, mkdirSync, existsSync } from 'node:fs'; +import { resolve, dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; + +const here = dirname(fileURLToPath(import.meta.url)); +const tomlDir = resolve(here, '..', '..', 'web', 'translation'); +const outDir = resolve(here, '..', 'src', 'locales'); + +if (!existsSync(outDir)) mkdirSync(outDir, { recursive: true }); + +// Decode the small set of escapes TOML allows inside basic strings. +// Unicode `\uXXXX` escapes aren't used in the panel's files but are +// handled too just in case a translator adds one. +function unescape(value) { + return value.replace(/\\(["\\bfnrt]|u[0-9a-fA-F]{4})/g, (_m, what) => { + if (what === '"') return '"'; + if (what === '\\') return '\\'; + if (what === 'b') return '\b'; + if (what === 'f') return '\f'; + if (what === 'n') return '\n'; + if (what === 'r') return '\r'; + if (what === 't') return '\t'; + return String.fromCharCode(parseInt(what.slice(1), 16)); + }); +} + +function setNested(target, path, value) { + let cursor = target; + for (let i = 0; i < path.length - 1; i++) { + const seg = path[i]; + if (typeof cursor[seg] !== 'object' || cursor[seg] === null) { + cursor[seg] = {}; + } + cursor = cursor[seg]; + } + cursor[path[path.length - 1]] = value; +} + +const SECTION_RE = /^\[([A-Za-z0-9_.-]+)\]$/; +const KV_RE = /^"([^"\\]*(?:\\.[^"\\]*)*)"\s*=\s*"((?:[^"\\]|\\.)*)"$/; + +function parseToml(src) { + const tree = {}; + let section = []; + let lineNo = 0; + for (const rawLine of src.split(/\r?\n/)) { + lineNo++; + const line = rawLine.trim(); + if (!line || line.startsWith('#')) continue; + + const sectionMatch = SECTION_RE.exec(line); + if (sectionMatch) { + section = sectionMatch[1].split('.'); + continue; + } + + const kvMatch = KV_RE.exec(line); + if (!kvMatch) { + throw new Error(`Unsupported TOML construct at line ${lineNo}: ${rawLine}`); + } + const [, key, value] = kvMatch; + setNested(tree, [...section, unescape(key)], unescape(value)); + } + return tree; +} + +const files = readdirSync(tomlDir).filter((f) => f.startsWith('translate.') && f.endsWith('.toml')); +let count = 0; +for (const file of files) { + const code = file.replace(/^translate\./, '').replace(/\.toml$/, '').replace('_', '-'); + const tree = parseToml(readFileSync(join(tomlDir, file), 'utf8')); + const outPath = join(outDir, `${code}.json`); + writeFileSync(outPath, JSON.stringify(tree, null, 2) + '\n'); + count++; +} + +// eslint-disable-next-line no-console +console.log(`sync-locales: wrote ${count} locale file(s) to ${outDir}`); diff --git a/frontend/src/i18n/index.js b/frontend/src/i18n/index.js new file mode 100644 index 00000000..4f50d3c1 --- /dev/null +++ b/frontend/src/i18n/index.js @@ -0,0 +1,94 @@ +// vue-i18n setup. Locale files are generated from web/translation/*.toml +// by `npm run i18n:sync` (run automatically as a pre-build step). +// +// Usage in a component: +// import { useI18n } from 'vue-i18n'; +// const { t } = useI18n(); +// ... +// {{ t('pages.inbounds.email') }} +// +// Or via the global helper exposed on the app: +// {{ $t('pages.inbounds.email') }} +// +// The locale follows the `lang` cookie that LanguageManager already +// reads/writes — switching language anywhere in the app continues to +// trigger a full page reload (matches legacy ergonomics), so we don't +// need a runtime locale switcher here. + +import { createI18n } from 'vue-i18n'; + +import { LanguageManager } from '@/utils'; + +// Lazy-loaded locales — Vite splits each one into its own chunk. We +// eager-load only the active language plus the en-US fallback so the +// initial page payload stays small (the inbounds bundle was sitting +// at ~700kB gzipped with all 13 locales eager; now ~480kB). +// +// LanguageManager.setLanguage() does a full reload on change, so +// "lazy" here effectively means "load only what this page needs for +// its lifetime." +const FALLBACK = 'en-US'; +const lazyModules = import.meta.glob('../locales/*.json'); +const eagerModules = import.meta.glob('../locales/*.json', { eager: true }); + +function moduleKeyFor(code) { + return `../locales/${code}.json`; +} + +// Resolve the active locale via LanguageManager so the cookie set on +// the legacy panel keeps working after a user upgrades. Falls back +// to en-US when the cookie names a language we don't have. +let active = LanguageManager.getLanguage(); +if (!Object.prototype.hasOwnProperty.call(lazyModules, moduleKeyFor(active))) { + active = FALLBACK; +} + +const messages = {}; +// Eagerly include the active locale + the fallback (when distinct) +// so the very first render has strings ready. Vite still emits these +// as their own chunks so the user pays for at most two locales. +for (const code of new Set([active, FALLBACK])) { + const mod = eagerModules[moduleKeyFor(code)]; + if (mod) messages[code] = mod.default || mod; +} + +export const i18n = createI18n({ + legacy: false, + // `composition` mode (legacy: false) so `useI18n()` works in + //