From c3fdce783a5bd46d2efe4906c21964d21b5e5a58 Mon Sep 17 00:00:00 2001 From: hmjvalineY Date: Thu, 27 Aug 2026 23:54:16 +0800 Subject: [PATCH 1/5] Add internationalisation support to the platform UI MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The platform UI was English-only with no i18n infrastructure, while the Node-RED editor embedded inside it already ships 10 locales. A user with a Chinese browser locale got a localised editor inside an English shell. Adds the plumbing, an `en` baseline for the login and sign-up pages, a `zh-TW` locale, and a per-user language preference. Frontend uses vue-i18n; the backend uses fastify-i18n over node-polyglot. Locale files live in `frontend/src/locales/.json` and `locales//common.json`. Both webpack entrypoints register the plugin, so the first-run setup flow can be translated too. Locale resolution is: the user's stored `language`, then the request or browser locale, then `en`. The login and sign-up pages render before there is a session, so browser detection is what makes them translatable at all; signing in must not reset a user with no stored preference to English. `fastify-i18n` narrows a regional tag onto its base language but does not widen a script-qualified one, so LOCALE_ALIASES maps `zh-Hant-TW` onto `zh-TW` rather than letting it fall back to English. Existing API error strings are deliberately left untranslated. Several are part of the de-facto contract — the sign-up page and a unit test both match `'user registration not enabled'` literally — so translating them would be a breaking change. Those responses already carry a stable `code` field, which is what callers should branch on. Tests assert every locale defines the same keys as `en`, and that the frontend and backend agree on the supported set, so a missing translation fails CI rather than silently rendering English. Refs #8311 Signed-off-by: hmjvalineY --- docs/contribute/i18n.md | 176 ++++++++++++++ .../20260827-01-add-language-to-users.js | 28 +++ forge/db/models/User.js | 3 + forge/db/views/User.js | 4 + forge/forge.js | 3 + forge/i18n/index.js | 30 +++ forge/i18n/locales.js | 55 +++++ forge/routes/api/shared/users.js | 6 + forge/routes/api/user.js | 7 +- frontend/src/i18n.js | 105 ++++++++ frontend/src/locales/en.json | 79 ++++++ frontend/src/locales/zh-TW.json | 79 ++++++ frontend/src/main.js | 2 + frontend/src/pages/Login.vue | 30 +-- frontend/src/pages/account/Create.vue | 88 ++++--- frontend/src/pages/account/Settings.vue | 41 +++- frontend/src/setup.js | 2 + frontend/src/stores/account-auth.js | 18 ++ locales/en/common.json | 8 + locales/zh-TW/common.json | 8 + package-lock.json | 229 ++++++++++++++++-- package.json | 2 + test/unit/forge/i18n/locales_spec.js | 164 +++++++++++++ test/unit/frontend/i18n.spec.js | 49 ++++ 24 files changed, 1143 insertions(+), 73 deletions(-) create mode 100644 docs/contribute/i18n.md create mode 100644 forge/db/migrations/20260827-01-add-language-to-users.js create mode 100644 forge/i18n/index.js create mode 100644 forge/i18n/locales.js create mode 100644 frontend/src/i18n.js create mode 100644 frontend/src/locales/en.json create mode 100644 frontend/src/locales/zh-TW.json create mode 100644 locales/en/common.json create mode 100644 locales/zh-TW/common.json create mode 100644 test/unit/forge/i18n/locales_spec.js create mode 100644 test/unit/frontend/i18n.spec.js diff --git a/docs/contribute/i18n.md b/docs/contribute/i18n.md new file mode 100644 index 0000000000..97f849d23b --- /dev/null +++ b/docs/contribute/i18n.md @@ -0,0 +1,176 @@ +--- +navTitle: Internationalisation +meta: + description: How translatable strings are handled in the FlowFuse platform, and how to add or extend a locale. + tags: + - flowfuse + - i18n + - internationalisation + - localisation + - contributing +--- + +# Internationalisation + +The platform UI is translatable. This page covers where the strings live, how a +locale is chosen at runtime, and what to do when you add a string or a language. + +## Libraries + +| Side | Library | Interpolation syntax | +|---|---|---| +| Frontend | [`vue-i18n`](https://vue-i18n.intlify.dev/) | `{name}` | +| Backend | [`fastify-i18n`](https://github.com/Vanilla-IceCream/fastify-i18n) (wrapping [`node-polyglot`](https://github.com/airbnb/polyglot.js)) | `%{name}` | + +The two interpolation syntaxes are **different**. This is a property of the +underlying libraries, not a choice — copying a message from one side to the +other means rewriting its placeholders. + +## Where strings live + +``` +locales//common.json server-side messages +frontend/src/locales/.json browser-side messages +``` + +Locale codes are [BCP 47](https://www.rfc-editor.org/info/bcp47) tags, so +`zh-TW` rather than `zh_TW` or `tw`. + +The list of locales the platform ships is declared twice, because the frontend +is bundled separately from the server: + +- `forge/i18n/locales.js` — `SUPPORTED_LOCALES` +- `frontend/src/i18n.js` — `SUPPORTED_LOCALES` + +`test/unit/forge/i18n/locales_spec.js` asserts the two agree, and that every +locale defines exactly the same keys as `en`. A missing translation is a test +failure rather than something that silently renders English in production. + +## Key naming + +Keys are hierarchical namespaces grouping semantically related messages: + +```json +{ + "common": { + "actions": { "login": "Login" }, + "errors": { "requiredField": "Required field" } + }, + "auth": { + "login": { + "forgotPassword": "Forgot your password?", + "errors": { "loginFailed": "Login failed" } + } + } +} +``` + +Put a message under `common` when more than one page uses it, and under a +page-specific namespace otherwise. Keys are sorted alphabetically within each +object — this keeps diffs readable when locales are edited in parallel. + +## Using a message + +In a template: + +```html + +``` + +In component code — both Options and Composition API, since the plugin is +registered with `globalInjection`: + +```js +this.errors.general = this.$t('auth.login.errors.loginFailed') +``` + +When a message contains markup — a link inside a sentence — do **not** split it +into fragments. Word order differs between languages, and fragments cannot be +reordered. Use `` with a named slot so the translator controls where the +markup lands: + +```html + + + +``` + +```json +{ "tcs": "I accept the {termsLink}" } +``` + +`scope="global"` is required in components that do not call `useI18n()`. + +Messages read inside `data()` are a trap: prefer a `computed`, so the value is +re-evaluated when the locale changes rather than frozen at construction. + +## How a locale is chosen + +In order of precedence: + +1. The user's stored preference — `User.language`, set on the account settings page +2. The request or browser locale — `Accept-Language` on the server, `navigator.language` in the browser +3. `en` + +Steps 1 and 2 exist separately because the login and sign-up pages render +before there is a session, so there is no stored preference to read. The browser +locale is what makes those pages translatable at all. + +`resolveLocale()` in `frontend/src/i18n.js` narrows an arbitrary tag onto one we +ship — `zh-Hant-TW` and `zh` both resolve to `zh-TW` — so an unrecognised tag +falls back rather than rendering raw keys. + +The server needs a little help to match that. `fastify-i18n` narrows a regional +tag onto its base language, so `en-GB` finds `en`, but it does not widen a +script-qualified tag onto a regional one: `zh-Hant-TW` would fall back to +English rather than finding `zh-TW`. Chrome reports exactly that tag for +Traditional Chinese on some platforms, so `forge/i18n/locales.js` declares +`LOCALE_ALIASES` mapping those tags onto the canonical locale. Add an entry +there if a locale you add has script-qualified variants in the wild. + +A user with no stored preference keeps whatever the browser negotiated. Signing +in must not quietly reset someone to English. + +## Adding a locale + +Taking `de` as the example: + +1. `locales/de/common.json` — copy `locales/en/common.json` and translate +2. `frontend/src/locales/de.json` — copy `frontend/src/locales/en.json` and translate +3. Add `'de'` to `SUPPORTED_LOCALES` in `forge/i18n/locales.js` +4. Add `{ value: 'de', label: 'Deutsch' }` to `SUPPORTED_LOCALES` in `frontend/src/i18n.js`, and import the file into `messages` +5. Run `npm run test:unit:forge` — the parity test will name any key you missed + +Write the `label` in the language itself. Someone looking for their own language +should not have to read English to find it. + +## Adding a string + +Add the key to **every** locale file, not just `en`. The parity test fails +otherwise. If you cannot translate it, that is a signal the locale needs a +maintainer for that language — not a reason to skip the key. + +## Deliberately out of scope + +**API error strings are not translated.** Some of them are part of the de-facto +contract: `frontend/src/pages/account/Create.vue` branches on +`err.response.data.error === 'user registration not enabled'`, and a unit test +matches the same literal. Translating those strings would break both. + +Those responses already carry a stable `code` field — +`user_registration_unavailable` in that example — which is what callers should +branch on. Migrating consumers to `code`, and only then translating the +human-readable `error` text, is a separate piece of work. + +The server-side plumbing is registered and `request.i18n.t()` is available, so +that work does not need to start from nothing. The natural first consumer is +`forge/postoffice/templates/`, where the output is read by a person and parsed +by nobody. + +## Not yet handled + +- **Pluralisation.** Both libraries support it; no message needs it yet. Reach for the library's own plural syntax rather than branching in a component. +- **Date, time, and number formatting.** Still locale-independent. +- **Right-to-left layouts.** No RTL locale ships yet; the CSS has not been audited for it. diff --git a/forge/db/migrations/20260827-01-add-language-to-users.js b/forge/db/migrations/20260827-01-add-language-to-users.js new file mode 100644 index 0000000000..d89ebdec2e --- /dev/null +++ b/forge/db/migrations/20260827-01-add-language-to-users.js @@ -0,0 +1,28 @@ +/** + * Let a user pick the language the platform is presented in. + * + * language - a BCP 47 locale tag, e.g. `zh-TW`. Null means the user has + * expressed no preference, in which case the platform negotiates a + * locale from the request (`Accept-Language` on the server, + * `navigator.language` in the browser) and falls back to English. + * Null is the default so existing users keep the behaviour they + * have today. + * + * This is stored server-side rather than kept in the browser — unlike the theme + * preference, which is local-only — because content the platform generates + * outside a browser session needs it too, most obviously the emails rendered in + * forge/postoffice/templates. + */ + +const { DataTypes } = require('sequelize') + +module.exports = { + up: async (context) => { + await context.addColumn('Users', 'language', { + type: DataTypes.STRING, + allowNull: true, + defaultValue: null + }) + }, + down: async (context) => {} +} diff --git a/forge/db/models/User.js b/forge/db/models/User.js index 86f29680e5..a425813e59 100644 --- a/forge/db/models/User.js +++ b/forge/db/models/User.js @@ -39,6 +39,9 @@ module.exports = { } }, tcs_accepted: { type: DataTypes.DATE, allowNull: true }, + // A BCP 47 locale tag. Null means 'no preference' — the platform then + // negotiates one from the request. See forge/i18n. + language: { type: DataTypes.STRING, allowNull: true, defaultValue: null }, suspended: { type: DataTypes.BOOLEAN, defaultValue: false diff --git a/forge/db/views/User.js b/forge/db/views/User.js index b03afbdb03..f141f6b3be 100644 --- a/forge/db/views/User.js +++ b/forge/db/views/User.js @@ -12,6 +12,7 @@ module.exports = function (app) { mfa_enabled: { type: 'boolean' }, free_trial_available: { type: 'boolean' }, tcs_accepted: { type: 'string' }, + language: { type: 'string' }, password_expired: { type: 'boolean' }, pendingEmailChange: { type: 'boolean' }, SSOGroups: { type: 'array' } @@ -30,6 +31,9 @@ module.exports = function (app) { // Only include the tcs_accepted date if 'tcs-required' is enabled result.tcs_accepted = user.tcs_accepted } + if (user.language) { + result.language = user.language + } result.email_verified = user.email_verified if (user.defaultTeamId) { result.defaultTeam = app.db.models.Team.encodeHashid(user.defaultTeamId) diff --git a/forge/forge.js b/forge/forge.js index dbddbb1819..0c33dca46e 100644 --- a/forge/forge.js +++ b/forge/forge.js @@ -13,6 +13,7 @@ const containers = require('./containers') const db = require('./db') const ee = require('./ee') const housekeeper = require('./housekeeper') +const i18n = require('./i18n') const { generatePassword } = require('./lib/userTeam') const license = require('./licensing') const notifications = require('./notifications') @@ -196,6 +197,8 @@ module.exports = async (options = {}) => { await server.register(caches) // DB : the database connection/models/views/controllers await server.register(db) + // Internationalisation : request-scoped translation + await server.register(i18n) // Settings await server.register(settings) // License diff --git a/forge/i18n/index.js b/forge/i18n/index.js new file mode 100644 index 0000000000..dedfbeeafa --- /dev/null +++ b/forge/i18n/index.js @@ -0,0 +1,30 @@ +const fastifyI18n = require('fastify-i18n') +const fp = require('fastify-plugin') + +const { FALLBACK_LOCALE, messages } = require('./locales') + +/** + * Server-side internationalisation. + * + * Decorates the request with `request.i18n`, whose `t()` resolves a key against + * the locale negotiated from the request's `Accept-Language` header, falling + * back to English. `fastify-i18n` narrows a regional tag onto its base language + * — `en-GB` finds `en` — but does not widen a script-qualified tag onto a + * regional one, which is why `forge/i18n/locales.js` declares LOCALE_ALIASES so + * `zh-Hant-TW` resolves to `zh-TW` instead of falling back to English. + * + * Note on scope: existing API error strings are deliberately NOT routed through + * this. Several of them are part of the de-facto contract — the frontend + * branches on `err.response.data.error === 'user registration not enabled'` + * (frontend/src/pages/account/Create.vue) and a unit test matches the same + * literal — so translating them would be a breaking change. Those responses + * already carry a stable `code` field, which is the right thing for callers to + * branch on; migrating consumers to it is a separate piece of work. See + * docs/contribute/i18n.md. + */ +module.exports = fp(async function (app, opts) { + await app.register(fastifyI18n, { + fallbackLocale: FALLBACK_LOCALE, + messages + }) +}, { name: 'app.i18n' }) diff --git a/forge/i18n/locales.js b/forge/i18n/locales.js new file mode 100644 index 0000000000..d10693aba6 --- /dev/null +++ b/forge/i18n/locales.js @@ -0,0 +1,55 @@ +/** + * The locales the platform ships translations for. + * + * Shared between the i18n plugin, the API schemas that validate a user's + * language preference, and the tests that assert locale files stay in step. + * The frontend keeps its own copy of this list in `frontend/src/i18n.js` + * because it is bundled separately; `test/unit/forge/i18n/locales_spec.js` + * asserts the two do not drift apart. + */ + +const en = require('../../locales/en/common.json') +const zhTW = require('../../locales/zh-TW/common.json') + +const FALLBACK_LOCALE = 'en' + +/** + * Canonical locale tags. These are what a user's `language` preference may be + * set to, and what the account settings UI offers. + */ +const SUPPORTED_LOCALES = ['en', 'zh-TW'] + +/** + * Message catalogues, keyed by canonical locale. + */ +const catalogues = { + en, + 'zh-TW': zhTW +} + +/** + * Extra tags browsers send that should resolve to a locale we ship rather than + * falling back to English. + * + * `fastify-i18n` narrows a regional tag onto its base language — `ja-JP` finds + * `ja` — but it does not widen a script-qualified tag onto a regional one, so + * `zh-Hant-TW` would not find `zh-TW` on its own. Chrome reports exactly that + * tag for Traditional Chinese on some platforms, so it is worth handling. + */ +const LOCALE_ALIASES = { + 'zh-Hant': 'zh-TW', + 'zh-Hant-TW': 'zh-TW' +} + +const messages = { ...catalogues } +for (const [alias, canonical] of Object.entries(LOCALE_ALIASES)) { + messages[alias] = catalogues[canonical] +} + +module.exports = { + FALLBACK_LOCALE, + SUPPORTED_LOCALES, + LOCALE_ALIASES, + catalogues, + messages +} diff --git a/forge/routes/api/shared/users.js b/forge/routes/api/shared/users.js index 0e0f5920b1..60e75b17b5 100644 --- a/forge/routes/api/shared/users.js +++ b/forge/routes/api/shared/users.js @@ -112,6 +112,12 @@ module.exports = { if (request.body.tcs_accepted) { user.tcs_accepted = new Date() } + // Unlike the fields above, null is meaningful for `language` — it + // clears the preference so the platform negotiates a locale from + // the request instead. So test for presence, not truthiness. + if (Object.hasOwn(request.body, 'language')) { + user.language = request.body.language || null + } if (isAdmin) { // Settings only an admin can modify if (request.body.email_verified !== undefined) { diff --git a/forge/routes/api/user.js b/forge/routes/api/user.js index 7105566f08..6d4a6f0a8b 100644 --- a/forge/routes/api/user.js +++ b/forge/routes/api/user.js @@ -1,3 +1,5 @@ +const { SUPPORTED_LOCALES } = require('../../i18n/locales') + const sharedUser = require('./shared/users') const UserInvitations = require('./userInvitations') const UserNotifications = require('./userNotifications') @@ -163,7 +165,10 @@ module.exports = async function (app) { username: { type: 'string' }, email: { type: 'string' }, tcs_accepted: { type: 'boolean' }, - defaultTeam: { type: 'string' } + defaultTeam: { type: 'string' }, + // null clears the preference, letting the platform + // negotiate a locale from the request instead + language: { type: ['string', 'null'], enum: [...SUPPORTED_LOCALES, null] } } }, response: { diff --git a/frontend/src/i18n.js b/frontend/src/i18n.js new file mode 100644 index 0000000000..b3d6856328 --- /dev/null +++ b/frontend/src/i18n.js @@ -0,0 +1,105 @@ +import { createI18n } from 'vue-i18n' + +import en from './locales/en.json' +import zhTW from './locales/zh-TW.json' + +export const FALLBACK_LOCALE = 'en' + +/** + * Locales the platform ships translations for. + * + * `label` is deliberately written in the language itself — someone looking for + * their own language should not have to read English to find it. + */ +export const SUPPORTED_LOCALES = [ + { value: 'en', label: 'English' }, + { value: 'zh-TW', label: '繁體中文' } +] + +const messages = { + en, + 'zh-TW': zhTW +} + +const STORAGE_KEY = 'ff-locale' + +/** + * Map an arbitrary locale tag onto one we actually have messages for. + * + * Falls back through the base language so `zh-Hant-TW` and `zh-TW` both land on + * `zh-TW`, and anything unrecognised lands on `en` rather than rendering keys. + * + * @param {string} [tag] a BCP 47 locale tag + * @returns {string} a locale present in SUPPORTED_LOCALES + */ +export function resolveLocale (tag) { + if (!tag) { + return FALLBACK_LOCALE + } + const supported = SUPPORTED_LOCALES.map(l => l.value) + if (supported.includes(tag)) { + return tag + } + const lower = tag.toLowerCase() + const exact = supported.find(l => l.toLowerCase() === lower) + if (exact) { + return exact + } + // `zh-Hant-TW` -> try `zh-TW`, then any locale sharing the base language + const base = lower.split('-')[0] + const regional = supported.find(l => l.toLowerCase().startsWith(`${base}-`)) + if (regional) { + return regional + } + const baseMatch = supported.find(l => l.toLowerCase() === base) + return baseMatch || FALLBACK_LOCALE +} + +/** + * The locale to start up with. + * + * The login and sign-up pages render before there is a session, so there is no + * stored user preference to read at that point. We use the last locale this + * browser was set to, then the browser's own language, then English. + */ +function initialLocale () { + try { + const stored = localStorage.getItem(STORAGE_KEY) + if (stored) { + return resolveLocale(stored) + } + } catch { /* localStorage unavailable — fall through to navigator */ } + return resolveLocale(navigator.language) +} + +const i18n = createI18n({ + locale: initialLocale(), + fallbackLocale: FALLBACK_LOCALE, + messages, + legacy: false, + globalInjection: true, + // Falling back to `en` is intended behaviour, not a problem to report on + // every render while a locale is still being translated. + missingWarn: false, + fallbackWarn: false +}) + +/** + * Switch the active locale and remember it for the next pre-session page load. + * + * @param {string} tag a BCP 47 locale tag; unrecognised values fall back to `en` + * @returns {string} the locale that was actually applied + */ +export function setLocale (tag) { + const resolved = resolveLocale(tag) + i18n.global.locale.value = resolved + document.documentElement.setAttribute('lang', resolved) + try { + localStorage.setItem(STORAGE_KEY, resolved) + } catch { /* ignore — the locale still applies for this page load */ } + return resolved +} + +document.documentElement.setAttribute('lang', i18n.global.locale.value) + +export default i18n diff --git a/frontend/src/locales/en.json b/frontend/src/locales/en.json new file mode 100644 index 0000000000..5b8fb5f464 --- /dev/null +++ b/frontend/src/locales/en.json @@ -0,0 +1,79 @@ +{ + "common": { + "actions": { + "cancel": "Cancel", + "continue": "Continue", + "edit": "Edit", + "login": "Login", + "save": "Save Changes", + "signUp": "Sign Up" + }, + "errors": { + "invalidEmail": "Invalid email address", + "requiredField": "Required field", + "tooLong": "Too long", + "unexpected": "An unexpected error occurred. Please try again later or contact support." + }, + "fields": { + "confirmPassword": "Confirm Password", + "email": "E-Mail Address", + "fullName": "Full Name", + "password": "Password", + "username": "Username" + } + }, + "auth": { + "login": { + "forgotPassword": "Forgot your password?", + "loggingIn": "Logging in...", + "mfaPrompt": "Enter the 6-digit security code from your authenticator app", + "signInWith": "SIGN IN WITH {provider}", + "signInWithGoogle": "Sign In with Google", + "usernameOrEmail": "Username / E-Mail", + "errors": { + "loginFailed": "Login failed", + "tooManyAttempts": "Too many login attempts. Try again later." + } + }, + "signUp": { + "alreadyRegistered": "Already registered? {loginLink}", + "joinReason": "What brings you to FlowFuse?", + "loginHere": "Log in here", + "signUpWithGoogle": "Sign up with Google", + "ssoCreated": "You can now login using your SSO Provider.", + "tcs": "I accept the {termsLink}", + "tcsLink": "FlowFuse Terms & Conditions.", + "reasons": { + "business": "Business Needs", + "education": "Educational Use", + "personal": "Personal Use" + }, + "errors": { + "checkFields": "Please check all fields are valid", + "emailInvalid": "Enter a valid email address", + "emailRequired": "Email is required", + "invalidRequest": "Invalid request", + "nameNotUrl": "Names can not be URLs", + "passwordComplexity": "Password needs to be more complex", + "passwordMatchEmail": "Password must not match email", + "passwordMatchName": "Password must not match name", + "passwordMatchUsername": "Password must not match username", + "passwordMismatch": "Passwords do not match", + "passwordRequired": "Password is required", + "passwordTooLong": "Password too long", + "passwordTooShort": "Password must be 8 characters or more", + "registrationDisabled": "User registration is not enabled", + "tooManyAttempts": "Too many attempts. Try again later.", + "usernameCharset": "Must only contain a-z A-Z 0-9 - _", + "usernameRequired": "Username is required" + } + } + }, + "account": { + "settings": { + "language": "Language", + "languageDescription": "The language used across the platform", + "languageSystem": "Browser default" + } + } +} diff --git a/frontend/src/locales/zh-TW.json b/frontend/src/locales/zh-TW.json new file mode 100644 index 0000000000..dee74b42da --- /dev/null +++ b/frontend/src/locales/zh-TW.json @@ -0,0 +1,79 @@ +{ + "common": { + "actions": { + "cancel": "取消", + "continue": "繼續", + "edit": "編輯", + "login": "登入", + "save": "儲存變更", + "signUp": "註冊" + }, + "errors": { + "invalidEmail": "電子郵件地址無效", + "requiredField": "此欄位為必填", + "tooLong": "長度過長", + "unexpected": "發生非預期的錯誤,請稍後再試或聯繫技術支援。" + }, + "fields": { + "confirmPassword": "確認密碼", + "email": "電子郵件地址", + "fullName": "姓名", + "password": "密碼", + "username": "使用者名稱" + } + }, + "auth": { + "login": { + "forgotPassword": "忘記密碼?", + "loggingIn": "正在登入…", + "mfaPrompt": "請輸入驗證器應用程式顯示的 6 位數安全碼", + "signInWith": "使用 {provider} 登入", + "signInWithGoogle": "使用 Google 登入", + "usernameOrEmail": "使用者名稱 / 電子郵件", + "errors": { + "loginFailed": "登入失敗", + "tooManyAttempts": "登入嘗試次數過多,請稍後再試。" + } + }, + "signUp": { + "alreadyRegistered": "已經有帳號了? {loginLink}", + "joinReason": "您使用 FlowFuse 的主要目的是什麼?", + "loginHere": "由此登入", + "signUpWithGoogle": "使用 Google 註冊", + "ssoCreated": "您現在可以透過 SSO 供應商登入。", + "tcs": "我接受 {termsLink}", + "tcsLink": "FlowFuse 服務條款。", + "reasons": { + "business": "商業需求", + "education": "教育用途", + "personal": "個人使用" + }, + "errors": { + "checkFields": "請確認所有欄位皆填寫正確", + "emailInvalid": "請輸入有效的電子郵件地址", + "emailRequired": "請填寫電子郵件地址", + "invalidRequest": "請求無效", + "nameNotUrl": "姓名不可為網址", + "passwordComplexity": "密碼複雜度不足", + "passwordMatchEmail": "密碼不可與電子郵件相同", + "passwordMatchName": "密碼不可與姓名相同", + "passwordMatchUsername": "密碼不可與使用者名稱相同", + "passwordMismatch": "兩次輸入的密碼不一致", + "passwordRequired": "請填寫密碼", + "passwordTooLong": "密碼長度過長", + "passwordTooShort": "密碼長度須為 8 個字元以上", + "registrationDisabled": "系統未開放使用者註冊", + "tooManyAttempts": "嘗試次數過多,請稍後再試。", + "usernameCharset": "僅可包含 a-z A-Z 0-9 - _", + "usernameRequired": "請填寫使用者名稱" + } + } + }, + "account": { + "settings": { + "language": "語言", + "languageDescription": "平台介面使用的語言", + "languageSystem": "跟隨瀏覽器設定" + } + } +} diff --git a/frontend/src/main.js b/frontend/src/main.js index 8248283aa7..e1c731ba11 100644 --- a/frontend/src/main.js +++ b/frontend/src/main.js @@ -15,6 +15,7 @@ import App from './App.vue' import Loading from './components/Loading.vue' import SectionNavigationHeader from './components/SectionNavigationHeader.vue' import TeamLink from './components/router-links/TeamLink.vue' +import i18n from './i18n.js' import PageLayout from './layouts/Page.vue' import router from './routes.js' import Alerts from './services/alerts.js' @@ -36,6 +37,7 @@ const app = createApp(App) .use(pinia) .use(router) .use(VueShepherdPlugin) + .use(i18n) const servicesOrchestrator = getAppOrchestrator() diff --git a/frontend/src/pages/Login.vue b/frontend/src/pages/Login.vue index 7218a8a08c..81c28152a6 100644 --- a/frontend/src/pages/Login.vue +++ b/frontend/src/pages/Login.vue @@ -1,9 +1,9 @@ @@ -234,20 +232,20 @@ - + @@ -263,14 +261,14 @@ - +
- No Remote Instances found. + {{ $t('ui.noRemoteInstancesFound') }}
@@ -288,10 +286,10 @@ > @@ -319,9 +317,9 @@ -

This action cannot be undone.

+

{{ $t('ui.thisActionCannotBeUndone') }}

@@ -349,9 +347,9 @@ @confirm="moveDevicesToUnassigned(checkedDevices)" > diff --git a/frontend/src/components/FinishSetup.vue b/frontend/src/components/FinishSetup.vue index 9b870f6d55..950023a149 100644 --- a/frontend/src/components/FinishSetup.vue +++ b/frontend/src/components/FinishSetup.vue @@ -1,7 +1,7 @@ diff --git a/frontend/src/components/GoogleLoginButton.vue b/frontend/src/components/GoogleLoginButton.vue index a13389cc14..58ea90ca27 100644 --- a/frontend/src/components/GoogleLoginButton.vue +++ b/frontend/src/components/GoogleLoginButton.vue @@ -27,6 +27,8 @@ import { computed, ref } from 'vue' import { useRoute } from 'vue-router' import { GoogleLogin } from 'vue3-google-login' +import { t } from '../i18n.js' + import SSOApi from '@/api/sso.js' import SpinnerIcon from '@/components/icons/Spinner.js' import { useAccountSettingsStore } from '@/stores/account-settings.js' @@ -36,7 +38,7 @@ withDefaults(defineProps<{ label?: string disabled?: boolean }>(), { - label: 'Sign In with Google', + label: t('ui.signInWithGoogle'), disabled: false }) diff --git a/frontend/src/components/JsonViewer.vue b/frontend/src/components/JsonViewer.vue index ccf055974a..baa7132f63 100644 --- a/frontend/src/components/JsonViewer.vue +++ b/frontend/src/components/JsonViewer.vue @@ -26,7 +26,7 @@ :title="wrapped ? 'Word wrap on' : 'Word wrap off'" @click="wrapped = !wrapped" > - Wrap + {{ $t('ui.wrap') }}
- + diff --git a/frontend/src/components/Offline.vue b/frontend/src/components/Offline.vue index 1c3e7abc67..1e2a53cf00 100644 --- a/frontend/src/components/Offline.vue +++ b/frontend/src/components/Offline.vue @@ -1,10 +1,10 @@ diff --git a/frontend/src/components/SectionTopMenu.vue b/frontend/src/components/SectionTopMenu.vue index 1975026847..315b6ffbc6 100644 --- a/frontend/src/components/SectionTopMenu.vue +++ b/frontend/src/components/SectionTopMenu.vue @@ -34,7 +34,7 @@
diff --git a/frontend/src/components/SelectInstance.vue b/frontend/src/components/SelectInstance.vue index fd6680f9a6..0bd4cdba67 100644 --- a/frontend/src/components/SelectInstance.vue +++ b/frontend/src/components/SelectInstance.vue @@ -3,10 +3,10 @@ v-model="input.application" :options="options.applications" :disabled="noApplications || loading.applications" - placeholder="Select an application" + :placeholder="$t('ui.selectAnApplication')" data-form="application" > - Application + {{ $t('ui.application') }} - Node-RED Instance + {{ $t('ui.nodeRedInstance') }}