# RustPBX Console 和 Addon 多语言支持方案
## 概述
本文档描述 RustPBX Console 和 Addon 的多语言(i18n)实现方案。项目使用 **minijinja** 模板引擎和 **Alpine.js** 前端框架,本方案采用 **TOML** 格式的翻译文件(易于阅读和维护)+ 自定义 filter/function 的方式实现多语言支持。
## 架构设计
```
rustpbx/
├── locales/ # 翻译文件目录
│ ├── en.toml # 英文翻译
│ ├── zh.toml # 中文翻译
│ └── ja.toml # 日文翻译
├── src/
│ ├── console/
│ │ ├── i18n.rs # i18n 核心模块
│ │ └── mod.rs # Console 模块(注入 i18n)
│ └── addons/
│ └── {addon_name}/
│ └── locales/ # Addon 独立翻译
│ ├── en.toml
│ └── zh.toml
└── templates/
└── console/
└── layout.html # 主布局模板
```
---
## 第一部分:后端实现
### 1.1 翻译文件格式
翻译文件使用 **TOML** 格式,支持嵌套结构和变量插值:
```toml
# locales/en.toml
[common]
save = "Save"
cancel = "Cancel"
delete = "Delete"
edit = "Edit"
search = "Search"
loading = "Loading..."
confirm = "Are you sure?"
success = "Success"
error = "Error"
[nav]
dashboard = "Dashboard"
extensions = "Extensions"
routing = "Routing"
sip_trunk = "SIP Trunk / DID"
call_records = "Call Records"
diagnostics = "Diagnostics"
metrics = "Metrics"
addons = "Addons"
notifications = "Notifications"
settings = "Settings"
[auth]
login = "Sign In"
logout = "Sign Out"
email = "Email"
password = "Password"
forgot_password = "Forgot password?"
register = "Create Account"
[extension]
title = "Extensions"
search_placeholder = "Search extensions..."
new_extension = "New Extension"
extension_number = "Extension Number"
display_name = "Display Name"
status = "Status"
online = "Online"
offline = "Offline"
[messages]
saved = "{{name}} has been saved successfully."
deleted = "{{name}} has been deleted."
save_failed = "Failed to save {{name}}: {{error}}"
```
```toml
# locales/zh.toml
[common]
save = "保存"
cancel = "取消"
delete = "删除"
edit = "编辑"
search = "搜索"
loading = "加载中..."
confirm = "确定要执行此操作吗?"
success = "成功"
error = "错误"
[nav]
dashboard = "仪表盘"
extensions = "分机"
routing = "路由"
sip_trunk = "SIP中继/DID"
call_records = "通话记录"
diagnostics = "诊断"
metrics = "指标"
addons = "插件"
notifications = "通知"
settings = "设置"
[auth]
login = "登录"
logout = "退出"
email = "邮箱"
password = "密码"
forgot_password = "忘记密码?"
register = "创建账户"
[extension]
title = "分机管理"
search_placeholder = "搜索分机..."
new_extension = "新建分机"
extension_number = "分机号"
display_name = "显示名称"
status = "状态"
online = "在线"
offline = "离线"
[messages]
saved = "{{name}} 保存成功。"
deleted = "{{name}} 已删除。"
save_failed = "保存 {{name}} 失败:{{error}}"
```
### 1.2 创建 i18n 核心模块
```rust
// src/console/i18n.rs
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
use std::sync::Arc;
use tokio::sync::RwLock;
/// 翻译条目类型 (嵌套 HashMap)
pub type Translations = HashMap<String, serde_json::Value>;
/// 语言配置
#[derive(Debug, Clone)]
pub struct LocaleConfig {
/// 默认语言
pub default: String,
/// 支持的语言列表
pub available: Vec<LocaleInfo>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LocaleInfo {
pub code: String,
pub name: String,
pub native_name: String,
}
/// i18n 管理器
pub struct I18n {
/// 翻译缓存 (lang -> key -> value)
translations: RwLock<HashMap<String, Translations>>,
/// 配置
config: LocaleConfig,
/// Addon 翻译目录
addon_locales_dirs: RwLock<Vec<String>>,
}
impl I18n {
pub fn new(config: LocaleConfig) -> Self {
let i18n = Self {
translations: RwLock::new(HashMap::new()),
config,
addon_locales_dirs: RwLock::new(vec![]),
};
// 同步加载初始翻译
i18n.load_translations_sync();
i18n
}
/// 同步加载所有翻译文件(初始化时调用)
fn load_translations_sync(&self) {
let mut translations = self.translations.blocking_write();
// 加载核心翻译
for locale in &self.config.available {
if let Ok(t) = Self::load_locale_file_sync("locales", &locale.code) {
translations.insert(locale.code.clone(), t);
}
}
// 加载 addon 翻译
let addon_dirs = self.addon_locales_dirs.blocking_read();
for addon_dir in addon_dirs.iter() {
for locale in &self.config.available {
if let Ok(t) = Self::load_locale_file_sync(addon_dir, &locale.code) {
if let Some(existing) = translations.get_mut(&locale.code) {
Self::merge_translations(existing, t);
}
}
}
}
}
/// 异步加载所有翻译文件
pub async fn load_translations(&self) {
let mut translations = self.translations.write().await;
// 加载核心翻译
for locale in &self.config.available {
if let Ok(t) = Self::load_locale_file("locales", &locale.code).await {
translations.insert(locale.code.clone(), t);
}
}
// 加载 addon 翻译
let addon_dirs = self.addon_locales_dirs.read().await;
for addon_dir in addon_dirs.iter() {
for locale in &self.config.available {
if let Ok(t) = Self::load_locale_file(addon_dir, &locale.code).await {
if let Some(existing) = translations.get_mut(&locale.code) {
Self::merge_translations(existing, t);
}
}
}
}
}
/// 同步加载单个翻译文件 (TOML)
fn load_locale_file_sync(base_dir: &str, locale: &str) -> anyhow::Result<Translations> {
let path = format!("{}/{}.toml", base_dir, locale);
let content = std::fs::read_to_string(&path)?;
let value: toml::Value = toml::from_str(&content)?;
let json = serde_json::to_value(value)?;
Self::flatten_to_map(&json)
}
/// 异步加载单个翻译文件 (TOML)
async fn load_locale_file(base_dir: &str, locale: &str) -> anyhow::Result<Translations> {
let path = format!("{}/{}.toml", base_dir, locale);
let content = tokio::fs::read_to_string(&path).await?;
let value: toml::Value = toml::from_str(&content)?;
let json = serde_json::to_value(value)?;
Self::flatten_to_map(&json)
}
/// 将嵌套的 JSON 转换为扁平化的 HashMap
/// 例如: {"nav": {"dashboard": "Dashboard"}} -> {"nav.dashboard": "Dashboard"}
fn flatten_to_map(value: &serde_json::Value) -> anyhow::Result<Translations> {
let mut map = Translations::new();
Self::flatten_recursive(value, String::new(), &mut map);
Ok(map)
}
fn flatten_recursive(value: &serde_json::Value, prefix: String, map: &mut Translations) {
match value {
serde_json::Value::Object(obj) => {
for (k, v) in obj {
let new_prefix = if prefix.is_empty() {
k.clone()
} else {
format!("{}.{}", prefix, k)
};
Self::flatten_recursive(v, new_prefix, map);
}
}
serde_json::Value::String(s) => {
map.insert(prefix, serde_json::Value::String(s.clone()));
}
_ => {
map.insert(prefix, value.clone());
}
}
}
/// 合并翻译(addon 翻译合并到核心翻译)
fn merge_translations(base: &mut Translations, addon: Translations) {
for (key, value) in addon {
base.insert(key, value);
}
}
/// 注册 addon 翻译目录
pub async fn register_addon_locales(&self, _addon_name: &str, locales_dir: String) {
{
let mut dirs = self.addon_locales_dirs.write().await;
dirs.push(locales_dir);
}
// 重新加载翻译
self.load_translations().await;
}
/// 获取翻译
pub fn t(&self, locale: &str, key: &str) -> String {
let translations = self.translations.blocking_read();
// 尝试请求的语言
if let Some(trans) = translations.get(locale) {
if let Some(value) = trans.get(key) {
if let Some(s) = value.as_str() {
return s.to_string();
}
}
}
// 回退到默认语言
if locale != self.config.default {
if let Some(trans) = translations.get(&self.config.default) {
if let Some(value) = trans.get(key) {
if let Some(s) = value.as_str() {
return s.to_string();
}
}
}
}
// 返回 key 本身作为 fallback
key.to_string()
}
/// 获取翻译并替换变量
pub fn t_with_vars(&self, locale: &str, key: &str, vars: &HashMap<String, String>) -> String {
let mut text = self.t(locale, key);
for (k, v) in vars {
text = text.replace(&format!("{{{{{}}}}}", k), v);
}
text
}
/// 获取整个翻译对象(用于注入模板上下文,转换为嵌套结构)
pub fn get_translations(&self, locale: &str) -> serde_json::Value {
let translations = self.translations.blocking_read();
if let Some(trans) = translations.get(locale) {
// 将扁平化的 key 转换回嵌套结构
let mut result = serde_json::Map::new();
for (key, value) in trans {
Self::set_nested_value(&mut result, key, value.clone());
}
serde_json::Value::Object(result)
} else if let Some(trans) = translations.get(&self.config.default) {
let mut result = serde_json::Map::new();
for (key, value) in trans {
Self::set_nested_value(&mut result, key, value.clone());
}
serde_json::Value::Object(result)
} else {
serde_json::Value::Object(serde_json::Map::new())
}
}
/// 设置嵌套值
fn set_nested_value(map: &mut serde_json::Map<String, serde_json::Value>, key: &str, value: serde_json::Value) {
let parts: Vec<&str> = key.split('.').collect();
if parts.is_empty() {
return;
}
let mut current = map;
for (i, part) in parts.iter().enumerate() {
if i == parts.len() - 1 {
current.insert(part.to_string(), value);
} else {
let entry = current.entry(part.to_string()).or_insert_with(|| {
serde_json::Value::Object(serde_json::Map::new())
});
if let serde_json::Value::Object(ref mut m) = entry {
current = m;
} else {
break;
}
}
}
}
/// 获取支持的语言列表
pub fn available_locales(&self) -> &[LocaleInfo] {
&self.config.available
}
/// 获取默认语言
pub fn default_locale(&self) -> &str {
&self.config.default
}
}
/// 从请求中检测语言偏好
pub fn detect_locale(
accept_language: Option<&str>,
cookie_locale: Option<&str>,
default: &str,
) -> String {
// 1. 优先使用 cookie 中的设置
if let Some(locale) = cookie_locale {
if !locale.is_empty() {
return locale.to_string();
}
}
// 2. 解析 Accept-Language 头
if let Some(header) = accept_language {
// 简单解析,取第一个语言
if let Some(locale) = header.split(',').next() {
let locale = locale.split(';').next().unwrap_or(locale).trim();
// 标准化语言代码 (zh-CN -> zh, en-US -> en)
let locale = locale.split('-').next().unwrap_or(locale);
return locale.to_string();
}
}
// 3. 使用默认语言
default.to_string()
}
```
### 1.3 修改 ConsoleState 集成 i18n
```rust
// src/console/mod.rs 添加修改
use crate::console::i18n::{I18n, LocaleConfig, LocaleInfo, detect_locale};
pub struct ConsoleState {
db: DatabaseConnection,
config: ConsoleConfig,
session_key: Vec<u8>,
sip_server: Arc<RwLock<Option<SipServerRef>>>,
app_state: Arc<RwLock<Option<Weak<AppStateInner>>>>,
callrecord_formatter: Arc<dyn CallRecordFormatter>,
i18n: Arc<I18n>, // 新增
}
impl ConsoleState {
pub async fn initialize(
callrecord_formatter: Arc<dyn CallRecordFormatter>,
db: DatabaseConnection,
config: ConsoleConfig,
) -> Result<Arc<Self>> {
// ... 现有代码 ...
// 初始化 i18n
let locale_config = LocaleConfig {
default: config.locale_default.clone().unwrap_or_else(|| "en".to_string()),
available: config.locales.iter().map(|(code, info)| LocaleInfo {
code: code.clone(),
name: info.name.clone(),
native_name: info.native_name.clone(),
}).collect(),
};
let i18n = Arc::new(I18n::new(locale_config));
Ok(Arc::new(Self {
db,
config,
session_key,
sip_server: Arc::new(RwLock::new(None)),
app_state: Arc::new(RwLock::new(None)),
callrecord_formatter,
i18n,
}))
}
/// 渲染模板(支持多语言)
pub fn render(&self, template: &str, ctx: serde_json::Value, locale: &str) -> Response {
let mut ctx = ctx;
if ctx.is_object() {
if let Some(map) = ctx.as_object_mut() {
// ... 现有注入代码 ...
// 注入 i18n 相关变量
map.entry("locale")
.or_insert_with(|| serde_json::Value::String(locale.to_string()));
map.entry("t")
.or_insert_with(|| self.i18n.get_translations(locale));
map.entry("available_locales")
.or_insert_with(|| {
serde_json::to_value(self.i18n.available_locales()).unwrap_or(serde_json::Value::Null)
});
}
}
let mut tmpl_env = Environment::new();
// 添加 t 过滤器
let i18n_clone = self.i18n.clone();
let locale_clone = locale.to_string();
tmpl_env.add_filter("t", move |key: &str| -> String {
i18n_clone.t(&locale_clone, key)
});
// 添加 tvars 过滤器(带变量替换)
let i18n_clone2 = self.i18n.clone();
let locale_clone2 = locale.to_string();
tmpl_env.add_filter("tvars", move |(key, vars): (String, serde_json::Value)| -> String {
let vars_map: HashMap<String, String> = if let serde_json::Value::Object(m) = vars {
m.iter().filter_map(|(k, v)| {
v.as_str().map(|s| (k.clone(), s.to_string()))
}).collect()
} else {
HashMap::new()
};
i18n_clone2.t_with_vars(&locale_clone2, &key, &vars_map)
});
// ... 其余现有代码 ...
}
pub fn i18n(&self) -> &I18n {
&self.i18n
}
}
```
### 1.4 配置文件支持
```toml
# config/console.toml 添加语言配置
[console]
# ... 现有配置 ...
# 默认语言
locale_default = "en"
# 支持的语言
[console.locales.en]
name = "English"
native_name = "English"
[console.locales.zh]
name = "Chinese"
native_name = "中文"
[console.locales.ja]
name = "Japanese"
native_name = "日本語"
```
---
## 第二部分:前端模板改造
### 2.1 模板中使用翻译
修改模板文件,将硬编码文本替换为翻译函数:
```html
<!DOCTYPE html>
<!-- ... -->
<title>{% block title %}{{ "site.title"|t }}{% endblock %}</title>
</head>
<body>
<nav>
<a href="{{ bp }}/">
<span>{{ "nav.dashboard"|t }}</span>
</a>
<a href="{{ bp }}/extensions">
<span>{{ "nav.extensions"|t }}</span>
</a>
<a href="{{ bp }}/routing">
<span>{{ "nav.routing"|t }}</span>
</a>
</nav>
<!-- 语言切换器 -->
<div class="language-switcher">
<select x-model="currentLocale" @change="changeLocale($event.target.value)">
{% for loc in available_locales %}
<option value="{{ loc.code }}">{{ loc.native_name }}</option>
{% endfor %}
</select>
</div>
<!-- 按钮 -->
<button type="submit">{{ "common.save"|t }}</button>
<button type="button">{{ "common.cancel"|t }}</button>
<!-- 带变量的翻译 -->
<p>{{ "messages.saved"|tvars({"name": extension.display_name}) }}</p>
</body>
</html>
```
### 2.2 Alpine.js 前端翻译支持
在 layout.html 中添加前端翻译逻辑:
```html
<script>
document.addEventListener('alpine:init', () => {
const translations = {{ t | json | safe }};
Alpine.data('consoleApp', () => ({
darkMode: false,
currentLocale: '{{ locale|default("en") }}',
translations: translations,
t(key) {
const keys = key.split('.');
let value = this.translations;
for (const k of keys) {
if (value && typeof value === 'object' && k in value) {
value = value[k];
} else {
return key; }
}
return typeof value === 'string' ? value : key;
},
tvars(key, vars = {}) {
let text = this.t(key);
for (const [k, v] of Object.entries(vars)) {
text = text.replace(new RegExp(`{{${k}}}`, 'g'), v);
}
return text;
},
async changeLocale(locale) {
try {
document.cookie = `locale=${locale};path=/;max-age=31536000`;
window.location.reload();
} catch (e) {
console.error('Failed to change locale:', e);
}
}
}));
window.__t = function(key, vars) {
const app = Alpine.raw(document.querySelector('[x-data^="consoleApp"]'));
if (app && app._x_dataStack && app._x_dataStack[0]) {
const data = app._x_dataStack[0];
if (vars) {
return data.tvars(key, vars);
}
return data.t(key);
}
return key;
};
});
</script>
```
### 2.3 确认对话框多语言
```html
<div x-data="consoleConfirmDialog()">
<h2 x-text="title"></h2>
<p x-show="message" x-text="message"></p>
<button @click="close" x-text="cancelLabel"></button>
<button @click="confirm" x-text="confirmLabel"></button>
</div>
<script>
Alpine.data('consoleConfirmDialog', () => ({
init() {
window.addEventListener('console:confirm', (event) => {
const detail = event.detail || {};
this.title = detail.title || this.t('common.confirm');
this.confirmLabel = detail.confirmLabel || this.t('common.confirm');
this.cancelLabel = detail.cancelLabel || this.t('common.cancel');
});
},
t(key) {
return window.__t ? window.__t(key) : key;
}
}));
</script>
```
---
## 第三部分:Addon 多语言支持
### 3.1 Addon 翻译文件结构
每个 addon 可以有自己的翻译文件:
```
src/addons/queue/
├── locales/
│ ├── en.toml
│ └── zh.toml
├── mod.rs
└── templates/
└── queue.html
```
```toml
# src/addons/queue/locales/en.toml
[queue]
title = "Call Queues"
new_queue = "New Queue"
queue_name = "Queue Name"
strategy = "Ring Strategy"
agents = "Agents"
wait_time = "Max Wait Time"
[queue.strategies]
ringall = "Ring All"
leastrecent = "Least Recent"
fewestcalls = "Fewest Calls"
random = "Random"
```
```toml
# src/addons/queue/locales/zh.toml
[queue]
title = "呼叫队列"
new_queue = "新建队列"
queue_name = "队列名称"
strategy = "振铃策略"
agents = "坐席"
wait_time = "最大等待时间"
[queue.strategies]
ringall = "全部振铃"
leastrecent = "最近最少"
fewestcalls = "最少通话"
random = "随机"
```
### 3.2 扩展 Addon Trait
```rust
// src/addons/mod.rs 扩展
#[async_trait]
pub trait Addon: Send + Sync {
// ... 现有方法 ...
/// 返回 addon 的翻译目录路径
fn locales_dir(&self) -> Option<String> {
None
}
}
```
### 3.3 Addon 实现翻译支持
```rust
// src/addons/queue/mod.rs
impl Addon for QueueAddon {
fn id(&self) -> &'static str {
"queue"
}
fn name(&self) -> &'static str {
"Queue Manager"
}
fn description(&self) -> &'static str {
"Manage call queues and agents"
}
/// 提供翻译目录
fn locales_dir(&self) -> Option<String> {
Some("src/addons/queue/locales".to_string())
}
// ... 其他方法 ...
}
```
### 3.4 Addon Registry 注册翻译
```rust
// src/addons/registry.rs
impl AddonRegistry {
pub async fn register_addon(&mut self, addon: Arc<dyn Addon>) {
// 注册 addon 翻译目录
if let Some(locales_dir) = addon.locales_dir() {
if let Some(console_state) = &self.console_state {
console_state.i18n().register_addon_locales(
addon.id(),
locales_dir
).await;
}
}
// ... 现有注册逻辑 ...
}
}
```
### 3.5 Addon 模板使用翻译
```html
{% extends "console/layout.html" %}
{% block content %}
<div class="page-header">
<h1>{{ "queue.title"|t }}</h1>
<a href="{{ base_path }}/queues/new" class="btn btn-primary">
{{ "queue.new_queue"|t }}
</a>
</div>
<table>
<thead>
<tr>
<th>{{ "queue.queue_name"|t }}</th>
<th>{{ "queue.strategy"|t }}</th>
<th>{{ "queue.agents"|t }}</th>
<th>{{ "common.actions"|t }}</th>
</tr>
</thead>
<tbody>
{% for queue in queues %}
<tr>
<td>{{ queue.name }}</td>
<td>{{ "queue.strategies." ~ queue.strategy|t }}</td>
<td>{{ queue.agent_count }}</td>
<td>
<button @click="editQueue({{ queue.id }})">{{ "common.edit"|t }}</button>
<button @click="deleteQueue({{ queue.id }})">{{ "common.delete"|t }}</button>
</td>
</tr>
{% endfor %}
</tbody>
</table>
{% endblock %}
```
---
## 第四部分:语言切换实现
### 4.1 语言切换 API
```rust
// src/console/handlers/settings.rs
/// 设置用户语言偏好
pub async fn set_locale(
State(console): State<Arc<ConsoleState>>,
jar: CookieJar,
Json(payload): Json<SetLocaleRequest>,
) -> Result<(CookieJar, Json<serde_json::Value>), ApiError> {
let locale = payload.locale;
// 验证语言是否支持
if !console.i18n().available_locales().iter().any(|l| l.code == locale) {
return Err(ApiError::BadRequest("Unsupported locale".into()));
}
// 创建 cookie
let cookie = Cookie::build(("locale", locale.clone()))
.path("/")
.max_age(time::Duration::days(365))
.http_only(false)
.build();
Ok((
jar.add(cookie),
Json(serde_json::json!({ "success": true, "locale": locale }))
))
}
#[derive(Deserialize)]
pub struct SetLocaleRequest {
locale: String,
}
```
### 4.2 语言切换组件
```html
<div x-data="languageSwitcher()" class="relative">
<button
@click="open = !open"
class="flex items-center gap-2 rounded-lg px-3 py-2 text-sm hover:bg-slate-100"
>
<svg class="h-5 w-5" viewBox="0 0 24 24" fill="none" stroke="currentColor">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="1.5"
d="M12 21a9.004 9.004 0 0 0 8.716-6.747M12 21a9.004 9.004 0 0 1-8.716-6.747M12 21c2.485 0 4.5-4.03 4.5-9S14.485 3 12 3m0 18c-2.485 0-4.5-4.03-4.5-9S9.515 3 12 3m0 0a8.997 8.997 0 0 1 7.843 4.582M12 3a8.997 8.997 0 0 0-7.843 4.582m15.686 0A11.953 11.953 0 0 1 12 10.5c-2.998 0-5.74-1.1-7.843-2.918m15.686 0A8.959 8.959 0 0 1 21 12c0 .778-.099 1.533-.284 2.253m0 0A17.919 17.919 0 0 1 12 16.5c-3.162 0-6.133-.815-8.716-2.247m0 0A9.015 9.015 0 0 1 3 12c0-1.605.42-3.113 1.157-4.418" />
</svg>
<span x-text="currentLocaleName"></span>
</button>
<div x-show="open" @click.away="open = false" x-transition
class="absolute right-0 mt-2 w-40 rounded-lg bg-white shadow-lg ring-1 ring-black/5">
<div class="py-1">
{% for loc in available_locales %}
<button
@click="changeLocale('{{ loc.code }}')"
:class="{ 'bg-sky-50 text-sky-700': currentLocale === '{{ loc.code }}' }"
class="block w-full px-4 py-2 text-left text-sm hover:bg-slate-50"
>
{{ loc.native_name }}
<span class="text-slate-400 text-xs ml-2">{{ loc.name }}</span>
</button>
{% endfor %}
</div>
</div>
</div>
<script>
function languageSwitcher() {
return {
open: false,
currentLocale: '{{ locale|default("en") }}',
get currentLocaleName() {
const locales = {{ available_locales | json | safe }};
const loc = locales.find(l => l.code === this.currentLocale);
return loc ? loc.native_name : this.currentLocale;
},
async changeLocale(locale) {
if (locale === this.currentLocale) {
this.open = false;
return;
}
try {
const response = await fetch('{{ base_path }}/api/settings/locale', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ locale })
});
if (response.ok) {
window.location.reload();
}
} catch (e) {
console.error('Failed to change locale:', e);
}
this.open = false;
}
};
}
</script>
```
---
## 第五部分:实施步骤
### 阶段 1:基础设施 (1-2天)
1. **创建 i18n 核心模块**
- 实现 `src/console/i18n.rs`
- 项目已有 `toml` 依赖,无需额外添加
2. **创建翻译文件**
- 创建 `locales/` 目录
- 创建 `locales/en.toml` 和 `locales/zh.toml`
3. **修改配置**
- 更新 `config/console.toml` 添加语言配置
- 更新 `ConsoleConfig` 结构体
4. **集成到 ConsoleState**
- 修改 `render` 方法支持 i18n
- 添加 `t` 和 `tvars` 过滤器
### 阶段 2:模板改造 (2-3天)
1. **改造 layout.html**
- 替换所有硬编码文本
- 添加语言切换组件
- 注入前端翻译数据
2. **改造核心页面**
- 优先改造高频页面:dashboard, extensions, routing
- 然后改造其他页面
3. **改造认证页面**
- login.html, register.html, forgot.html
### 阶段 3:Addon 支持 (1-2天)
1. **扩展 Addon Trait**
- 添加 `locales_dir` 方法
2. **更新 Addon Registry**
- 自动注册 addon 翻译
3. **改造示例 Addon**
- 以 queue addon 为例展示完整流程
### 阶段 4:测试和优化 (1天)
1. **功能测试**
- 测试语言切换
- 测试各页面翻译
2. **性能优化**
- 翻译缓存
- 懒加载
---
## 附录
### A. TOML vs JSON 对比
| 可读性 | 高(类似 INI) | 中(大量引号和花括号) |
| 注释 | 支持 `#` 注释 | 不支持 |
| 多行字符串 | 支持 `"""` | 需要转义 `\n` |
| 编辑友好 | 是 | 需要注意逗号 |
| 工具支持 | 广泛 | 非常广泛 |
### B. 完整的翻译 key 命名规范
| 通用 | `common.` | `common.save`, `common.cancel` |
| 导航 | `nav.` | `nav.dashboard`, `nav.extensions` |
| 认证 | `auth.` | `auth.login`, `auth.logout` |
| 分机 | `extension.` | `extension.title`, `extension.search_placeholder` |
| 路由 | `routing.` | `routing.title`, `routing.new_route` |
| 通话记录 | `call_record.` | `call_record.title`, `call_record.duration` |
| 消息 | `messages.` | `messages.saved`, `messages.error` |
| Addon 特定 | `{addon_id}.` | `queue.title`, `voicemail.greeting` |
### C. 翻译文件检查脚本
```bash
#!/bin/bash
# scripts/check_i18n.sh
# 检查所有翻译文件的 key 是否一致
# 需要安装 toml-cli: cargo install toml-cli
# 或使用 Python: pip install toml
BASE_FILE="locales/en.toml"
OTHER_FILES=("locales/zh.toml" "locales/ja.toml")
# 使用 Python 提取所有 key
extract_keys() {
python3 -c "
import toml
import sys
def flatten(d, prefix=''):
keys = []
for k, v in d.items():
key = f'{prefix}.{k}' if prefix else k
if isinstance(v, dict):
keys.extend(flatten(v, key))
else:
keys.append(key)
return keys
data = toml.load('$1')
for key in sorted(flatten(data)):
print(key)
"
}
base_keys=$(extract_keys "$BASE_FILE")
for file in "${OTHER_FILES[@]}"; do
if [ ! -f "$file" ]; then
continue
fi
echo "Checking $file against $BASE_FILE..."
other_keys=$(extract_keys "$file")
# 检查缺失的 key
missing=$(comm -23 <(echo "$base_keys") <(echo "$other_keys"))
if [ -n "$missing" ]; then
echo "Missing keys in $file:"
echo "$missing"
fi
# 检查多余的 key
extra=$(comm -13 <(echo "$base_keys") <(echo "$other_keys"))
if [ -n "$extra" ]; then
echo "Extra keys in $file:"
echo "$extra"
fi
done
```
### D. 相关文件清单
需要修改/创建的文件:
```
新建文件:
- src/console/i18n.rs
- locales/en.toml
- locales/zh.toml
- templates/console/partials/language_switcher.html
修改文件:
- src/console/mod.rs
- src/console/handlers/settings.rs
- src/addons/mod.rs
- src/addons/registry.rs
- config/console.toml (或相应配置文件)
- templates/console/layout.html
- templates/console/*.html (所有模板)
Addon 文件 (示例):
- src/addons/queue/locales/en.toml
- src/addons/queue/locales/zh.toml
- src/addons/queue/mod.rs
- src/addons/queue/templates/*.html
```
### E. 示例:完整的中英文翻译文件
```toml
# locales/en.toml - 完整示例
[site]
title = "RustPBX Admin"
description = "RustPBX - A Rust-based PBX system"
[common]
save = "Save"
cancel = "Cancel"
delete = "Delete"
edit = "Edit"
create = "Create"
search = "Search"
loading = "Loading..."
confirm = "Are you sure?"
success = "Success"
error = "Error"
yes = "Yes"
no = "No"
actions = "Actions"
status = "Status"
name = "Name"
description = "Description"
enabled = "Enabled"
disabled = "Disabled"
all = "All"
none = "None"
no_data = "No data available"
required = "Required"
optional = "Optional"
[nav]
dashboard = "Dashboard"
extensions = "Extensions"
routing = "Routing"
sip_trunk = "SIP Trunk / DID"
call_records = "Call Records"
diagnostics = "Diagnostics"
metrics = "Metrics"
addons = "Addons"
notifications = "Notifications"
settings = "Settings"
[auth]
login = "Sign In"
logout = "Sign Out"
email = "Email"
password = "Password"
confirm_password = "Confirm Password"
forgot_password = "Forgot password?"
reset_password = "Reset Password"
register = "Create Account"
remember_me = "Remember me"
login_title = "Sign in to your account"
register_title = "Create a new account"
[dashboard]
title = "Dashboard"
welcome = "Welcome to RustPBX"
active_calls = "Active Calls"
total_extensions = "Total Extensions"
total_trunks = "Total Trunks"
recent_calls = "Recent Calls"
system_status = "System Status"
[extension]
title = "Extensions"
search_placeholder = "Search extensions..."
new_extension = "New Extension"
extension_number = "Extension Number"
display_name = "Display Name"
domain = "Domain"
password = "Password"
context = "Context"
online = "Online"
offline = "Offline"
register_success = "Extension created successfully"
update_success = "Extension updated successfully"
delete_success = "Extension deleted successfully"
[routing]
title = "Routing"
new_route = "New Route"
route_name = "Route Name"
pattern = "Pattern"
priority = "Priority"
target = "Target"
type = "Type"
[sip_trunk]
title = "SIP Trunks"
new_trunk = "New Trunk"
trunk_name = "Trunk Name"
host = "Host"
port = "Port"
username = "Username"
realm = "Realm"
register = "Register"
inbound = "Inbound"
outbound = "Outbound"
[call_record]
title = "Call Records"
caller = "Caller"
callee = "Callee"
duration = "Duration"
start_time = "Start Time"
end_time = "End Time"
direction = "Direction"
recording = "Recording"
download = "Download"
[messages]
saved = "{{name}} has been saved successfully."
deleted = "{{name}} has been deleted."
save_failed = "Failed to save {{name}}: {{error}}"
delete_confirm = "Are you sure you want to delete this {{name}}?"
network_error = "Network error. Please try again."
validation_error = "Please check your input."
unauthorized = "You are not authorized to perform this action."
session_expired = "Your session has expired. Please sign in again."
```
```toml
# locales/zh.toml - 完整示例
[site]
title = "RustPBX 管理后台"
description = "RustPBX - 基于 Rust 的 PBX 系统"
[common]
save = "保存"
cancel = "取消"
delete = "删除"
edit = "编辑"
create = "创建"
search = "搜索"
loading = "加载中..."
confirm = "确定要执行此操作吗?"
success = "成功"
error = "错误"
yes = "是"
no = "否"
actions = "操作"
status = "状态"
name = "名称"
description = "描述"
enabled = "已启用"
disabled = "已禁用"
all = "全部"
none = "无"
no_data = "暂无数据"
required = "必填"
optional = "可选"
[nav]
dashboard = "仪表盘"
extensions = "分机"
routing = "路由"
sip_trunk = "SIP中继/DID"
call_records = "通话记录"
diagnostics = "诊断"
metrics = "指标"
addons = "插件"
notifications = "通知"
settings = "设置"
[auth]
login = "登录"
logout = "退出"
email = "邮箱"
password = "密码"
confirm_password = "确认密码"
forgot_password = "忘记密码?"
reset_password = "重置密码"
register = "创建账户"
remember_me = "记住我"
login_title = "登录您的账户"
register_title = "创建新账户"
[dashboard]
title = "仪表盘"
welcome = "欢迎使用 RustPBX"
active_calls = "当前通话"
total_extensions = "分机总数"
total_trunks = "中继总数"
recent_calls = "最近通话"
system_status = "系统状态"
[extension]
title = "分机管理"
search_placeholder = "搜索分机..."
new_extension = "新建分机"
extension_number = "分机号"
display_name = "显示名称"
domain = "域名"
password = "密码"
context = "上下文"
online = "在线"
offline = "离线"
register_success = "分机创建成功"
update_success = "分机更新成功"
delete_success = "分机删除成功"
[routing]
title = "路由管理"
new_route = "新建路由"
route_name = "路由名称"
pattern = "匹配模式"
priority = "优先级"
target = "目标"
type = "类型"
[sip_trunk]
title = "SIP中继"
new_trunk = "新建中继"
trunk_name = "中继名称"
host = "主机"
port = "端口"
username = "用户名"
realm = "域"
register = "注册"
inbound = "呼入"
outbound = "呼出"
[call_record]
title = "通话记录"
caller = "主叫"
callee = "被叫"
duration = "时长"
start_time = "开始时间"
end_time = "结束时间"
direction = "方向"
recording = "录音"
download = "下载"
[messages]
saved = "{{name}} 保存成功。"
deleted = "{{name}} 已删除。"
save_failed = "保存 {{name}} 失败:{{error}}"
delete_confirm = "确定要删除此{{name}}吗?"
network_error = "网络错误,请重试。"
validation_error = "请检查您的输入。"
unauthorized = "您没有权限执行此操作。"
session_expired = "会话已过期,请重新登录。"
```