Internationalization
One "Saved successfully" string costs two files: a key in the Chinese dictionary, a key in the English one. The view itself contains nothing but t('common.saveOk'), not a single hardcoded character. Switching languages therefore becomes switching dictionaries, and every string you write from here on owes that debt twice.
How it hangs together
The kernel package's locales/index.ts wires up vue-i18n:
export const i18n = createI18n({
legacy: false,
locale: 'zh-CN',
fallbackLocale: 'en-US',
messages: {
'zh-CN': zhCN as Messages,
'en-US': enUS as Messages,
},
})
/** A translator for non-setup contexts (utility functions). */
export const t = i18n.global.tComposition API mode (legacy: false), default locale zh-CN, falling back to en-US for any key missing from the active locale. Say zh-CN.ts is missing a key: the UI doesn't throw and doesn't show the raw key name either, it just displays whatever English string sits at that key in en-US.ts. The built-in translation surface is two files — locales/zh-CN.ts and locales/en-US.ts — with no per-component splitting and no lazy loading.
Those two live in the package: an app can't edit them, and doesn't need to. Put your own text in the app's src/locales/ext/<locale>/<module>.ts; main.ts hands them to the kernel with createSmartAdmin({ locales: import.meta.glob('./locales/ext/*/*.ts', { eager: true }) }), and registerLocales deep-merges them into the dictionaries by namespace. The filename becomes the top-level namespace, and overriding a built-in string touches only the one key you write. See the README in the template's copy of that directory.
App.vue keeps Naive UI's own locale (which drives its built-in strings — date pickers, pagination, and so on) in sync with the app locale:
const naiveLocale = computed(() => (app.locale === 'en-US' ? enUS : zhCN))
const naiveDateLocale = computed(() => (app.locale === 'en-US' ? dateEnUS : dateZhCN))
watch(
() => app.locale,
(l) => {
i18n.global.locale.value = l
},
{ immediate: true },
)So there are really two locale surfaces kept in lockstep from one source (app.locale): vue-i18n's i18n.global.locale (which drives t()) and Naive UI's n-config-provider :locale/:date-locale (which drives its internal components' text).
Adding a translation key
Both message files are grouped by feature namespace — common, app, settings, login, module, menu, user, config, dict, org, file, notice, role, log, profile, error, recycle, and more. Two real examples:
// zh-CN.ts
user: {
title: '用户管理',
account: '账号',
deleteConfirm: '确定删除用户「{name}」?',
}
// en-US.ts
user: {
title: 'Users',
account: 'Account',
deleteConfirm: 'Delete user "{name}"?',
}To add a key:
- Pick the right namespace (only open a new top-level one for a genuinely new feature area).
- Add it to both languages at once, at the same dot-path, with matching interpolation placeholders (
{name},{count}, …). Kernel keys go in the package'szh-CN.tsanden-US.ts; app keys go inext/zh-CN/<module>.tsandext/en-US/<module>.ts. - Use it inside a component with
t('namespace.key'); outside setup (e.g. functions inutils/), use the exportedthelper, which an app imports fromsmart-admin-web.
Frontend Standards makes this a hard rule: all visible text in views goes through t('...') — no hardcoded Chinese/English string literals.
Switching language at runtime
stores/app.ts owns the locale as a persisted UI preference:
export type Locale = 'zh-CN' | 'en-US'
state: () => ({ ...DEFAULTS, collapsed: false, locale: 'zh-CN' as Locale }),
actions: {
setLocale(l: Locale) {
this.locale = l
},
}Calling useAppStore().setLocale('en-US') updates app.locale; the whole store persists to localStorage (key app) via Pinia's persistence plugin, so the choice survives a reload. App.vue's watch(() => app.locale, ...) (shown above) then pushes that value into i18n.global.locale.value, and the naiveLocale/naiveDateLocale computed properties derive from the same app.locale — so vue-i18n text and Naive UI's built-in text switch together, in one render.
How backend error codes land on the text in error.* (the numeric-code → msgKey → translation-key mapping) isn't on this page — it's bound up with the response contract, in Adapting to the Backend.