refactor(i18n): move translations to JSON files under app/locales, dynamic language switch

This commit is contained in:
2026-07-07 09:46:50 +08:00
parent fb98974382
commit 591ad3e178
7 changed files with 565 additions and 285 deletions

View File

@@ -1,290 +1,62 @@
# Translation keys for zh (Chinese) and en (English) """i18n module — loads translations from app/locales/*.json.
_translations: dict[str, dict[str, str]] = {
# ── Brand / Nav ──
"brand": {"zh": "📮 星笺", "en": "📮 Mailova"},
"nav.profiles": {"zh": "Profile", "en": "Profiles"},
"nav.settings": {"zh": "设置", "en": "Settings"},
"nav.invites": {"zh": "邀请码管理", "en": "Invites"},
"nav.logout": {"zh": "退出", "en": "Logout"},
"nav.api_docs": {"zh": "API 文档", "en": "API Docs"},
# ── Auth ── Usage in templates: {{ 'key'|t(user.language) }}
"login.title": {"zh": "登录", "en": "Login"}, Usage in Python: from app.i18n import t; t("key", lang)
"login.username": {"zh": "用户名", "en": "Username"},
"login.password": {"zh": "密码", "en": "Password"},
"login.remember": {"zh": "记住我", "en": "Remember me"},
"login.no_account": {"zh": "没有账号?", "en": "No account?"},
"login.go_register": {"zh": "去注册", "en": "Sign up"},
"register.title": {"zh": "注册", "en": "Register"},
"register.hint": {"zh": "邀请码由已有用户或管理员提供", "en": "Invite code provided by an existing user or admin"},
"register.invite_code": {"zh": "邀请码", "en": "Invite code"},
"register.invite_ph": {"zh": "8位字母数字", "en": "8-char code"},
"register.username": {"zh": "用户名", "en": "Username"},
"register.password": {"zh": "密码", "en": "Password"},
"register.confirm_password": {"zh": "确认密码", "en": "Confirm password"},
"register.has_account": {"zh": "已有账号?", "en": "Already have an account?"},
"register.go_login": {"zh": "去登录", "en": "Log in"},
# ── Dashboard ── To add a new language:
"dash.welcome": {"zh": "👋 欢迎,", "en": "👋 Welcome, "}, 1. Create app/locales/<lang>.json with translation keys
"dash.total_cards": {"zh": "全部明信片", "en": "Total postcards"}, 2. Done — it will be auto-detected and available in the UI
"dash.sent": {"zh": "已寄出", "en": "Sent"}, """
"dash.delivered": {"zh": "已送达", "en": "Delivered"},
"dash.received": {"zh": "已收到", "en": "Received"},
"dash.countries": {"zh": "国家/地区", "en": "Countries"},
"dash.chart_sent": {"zh": "📤 寄出", "en": "📤 Sent"},
"dash.chart_received": {"zh": "📬 收到", "en": "📬 Received"},
"dash.recent": {"zh": "最近动态", "en": "Recent activity"},
"dash.empty": {"zh": "暂无明信片", "en": "No postcards yet"},
"dash.welcome_title": {"zh": "欢迎使用星笺!", "en": "Welcome to Mailova!"},
"dash.welcome_hint": {"zh": "创建一个", "en": "Create a"},
"dash.welcome_hint2": {"zh": " 开始管理你的明信片", "en": " to start managing your postcards"},
"dash.today": {"zh": "今天", "en": "Today"},
"dash.yesterday": {"zh": "昨天", "en": "Yesterday"},
"dash.days_ago": {"zh": "天前", "en": " days ago"},
# ── Profiles ── import json
"profiles.title": {"zh": "Profile", "en": "Profiles"}, from pathlib import Path
"profiles.new_ph": {"zh": "新 Profile 昵称", "en": "New profile nickname"},
"profiles.new_btn": {"zh": "+ 新建", "en": "+ New"},
"profiles.rename": {"zh": "改名", "en": "Rename"},
"profiles.total": {"zh": "总计", "en": "Total"},
"profiles.countries": {"zh": "国家", "en": "Countries"},
"profiles.notes_ph": {"zh": "备注(仅自己可见)", "en": "Notes (private)"},
"profiles.save": {"zh": "保存", "en": "Save"},
"profiles.created_at": {"zh": "创建于", "en": "Created"},
"profiles.view": {"zh": "查看", "en": "View"},
"profiles.delete": {"zh": "删除", "en": "Delete"},
"profiles.confirm_delete": {"zh": "确定删除此 Profile", "en": "Delete this profile?"},
# ── Postcards list ── _LOCALES_DIR = Path(__file__).resolve().parent / "locales"
"pc_list.title": {"zh": " 的明信片", "en": "'s postcards"}, _cache: dict[str, dict[str, str]] = {}
"pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"}, _loaded = False
"pc_list.duplicate": {"zh": "编号已存在", "en": "Card number already exists"},
"pc_list.all_countries": {"zh": "全部国家", "en": "All countries"},
"pc_list.filter": {"zh": "筛选", "en": "Filter"},
"pc_list.tab_sent": {"zh": "寄出", "en": "Sent"},
"pc_list.tab_received": {"zh": "收到", "en": "Received"},
"pc_list.mark_delivered": {"zh": "已送达", "en": "Delivered"},
"pc_list.confirm_delivered": {"zh": "确认已送达?", "en": "Mark as delivered?"},
"pc_list.empty_sent": {"zh": "没有寄出的明信片。", "en": "No sent postcards."},
"pc_list.empty_received": {"zh": "没有收到的明信片。", "en": "No received postcards."},
# ── Postcard detail ──
"pc_detail.title": {"zh": "明信片详情", "en": "Postcard detail"},
"pc_detail.edit": {"zh": "编辑", "en": "Edit"},
"pc_detail.mark_delivered": {"zh": "已送达", "en": "Mark delivered"},
"pc_detail.confirm_delivered": {"zh": "确认已送达?", "en": "Mark as delivered?"},
"pc_detail.delete": {"zh": "删除", "en": "Delete"},
"pc_detail.confirm_delete": {"zh": "确定删除?", "en": "Delete this postcard?"},
"pc_detail.front": {"zh": "正面", "en": "Front"},
"pc_detail.back": {"zh": "反面", "en": "Back"},
"pc_detail.no_front": {"zh": "📷 未上传正面图片", "en": "📷 No front image"},
"pc_detail.no_back": {"zh": "📷 未上传反面图片", "en": "📷 No back image"},
"pc_detail.upload_front": {"zh": "上传正面", "en": "Upload front"},
"pc_detail.upload_back": {"zh": "上传反面", "en": "Upload back"},
"pc_detail.card_number": {"zh": "编号", "en": "Number"},
"pc_detail.status": {"zh": "状态", "en": "Status"},
"pc_detail.recipient": {"zh": "收件人", "en": "Recipient"},
"pc_detail.country": {"zh": "国家/地区", "en": "Country"},
"pc_detail.send_time": {"zh": "寄出时间", "en": "Sent date"},
"pc_detail.arrival_time": {"zh": "到达时间", "en": "Arrival date"},
"pc_detail.sender": {"zh": "发件人", "en": "Sender"},
"pc_detail.receive_time": {"zh": "收到时间", "en": "Received date"},
"pc_detail.profile": {"zh": "所属 Profile", "en": "Profile"},
"pc_detail.notes": {"zh": "备注", "en": "Notes"},
# ── Status labels ── def _load() -> None:
"status.sent": {"zh": "已寄出", "en": "Sent"}, global _loaded
"status.delivered": {"zh": "已送达", "en": "Delivered"}, if _loaded:
"status.received": {"zh": "已收到", "en": "Received"}, return
_LOCALES_DIR.mkdir(exist_ok=True)
# ── Postcard form ── for f in _LOCALES_DIR.glob("*.json"):
"pc_form.edit": {"zh": "编辑明信片", "en": "Edit postcard"}, lang = f.stem # e.g. "zh", "en", "ja"
"pc_form.new": {"zh": "新建明信片", "en": "New postcard"}, with open(f, encoding="utf-8") as fh:
"pc_form.number": {"zh": "编号", "en": "Number"}, _cache[lang] = json.load(fh)
"pc_form.number_hint": {"zh": "格式AB-1234567", "en": "Format: AB-1234567"}, _loaded = True
"pc_form.status": {"zh": "状态", "en": "Status"},
"pc_form.recipient": {"zh": "收件人", "en": "Recipient"},
"pc_form.country": {"zh": "国家/地区", "en": "Country"},
"pc_form.send_time": {"zh": "寄出时间", "en": "Sent date"},
"pc_form.arrival_time": {"zh": "到达时间", "en": "Arrival date"},
"pc_form.sender": {"zh": "发件人", "en": "Sender"},
"pc_form.receive_time": {"zh": "收到时间", "en": "Received date"},
"pc_form.notes": {"zh": "备注", "en": "Notes"},
"pc_form.notes_ph": {"zh": "可选备注信息", "en": "Optional notes"},
"pc_form.save": {"zh": "保存", "en": "Save"},
"pc_form.cancel": {"zh": "取消", "en": "Cancel"},
# ── Settings ──
"settings.title": {"zh": "用户设置", "en": "Settings"},
"settings.tab_account": {"zh": "账号", "en": "Account"},
"settings.tab_showcase": {"zh": "展示", "en": "Showcase"},
"settings.change_username": {"zh": "修改用户名", "en": "Change username"},
"settings.current_username": {"zh": "当前用户名", "en": "Current username"},
"settings.new_username": {"zh": "新用户名", "en": "New username"},
"settings.change_password": {"zh": "修改密码", "en": "Change password"},
"settings.current_password": {"zh": "当前密码", "en": "Current password"},
"settings.new_password": {"zh": "新密码", "en": "New password"},
"settings.confirm_password": {"zh": "确认新密码", "en": "Confirm new password"},
"settings.language": {"zh": "语言", "en": "Language"},
"settings.language_zh": {"zh": "中文", "en": "中文"},
"settings.language_en": {"zh": "English", "en": "English"},
"settings.showcase_global": {"zh": "全局展示设置", "en": "Global showcase settings"},
"settings.showcase_mode": {"zh": "展示页面模式", "en": "Showcase mode"},
"settings.mode_profile": {"zh": "按 Profile 分类", "en": "Grouped by profile"},
"settings.mode_flat": {"zh": "全部合并", "en": "All together"},
"settings.save_global": {"zh": "保存全局设置", "en": "Save global settings"},
"settings.public_url": {"zh": "公开展示页地址:", "en": "Public showcase: "},
"settings.public_url_label": {"zh": "公开展示页:", "en": "Public showcase: "},
"settings.profiles_showcase": {"zh": "各 Profile 展示设置", "en": "Per-profile showcase"},
"settings.showcase_on": {"zh": "展示中", "en": "Showing"},
"settings.showcase_off": {"zh": "未开启", "en": "Off"},
"settings.enable_showcase": {"zh": "开启展示", "en": "Enable showcase"},
"settings.showcase_content": {"zh": "展示内容", "en": "Content"},
"settings.content_both": {"zh": "发出 + 收到", "en": "Sent + Received"},
"settings.content_sent": {"zh": "仅发出", "en": "Sent only"},
"settings.content_received": {"zh": "仅收到", "en": "Received only"},
"settings.save": {"zh": "保存", "en": "Save"},
# ── Admin invites ──
"invites.title": {"zh": "邀请码管理", "en": "Invite codes"},
"invites.create": {"zh": "创建邀请码", "en": "Create invite code"},
"invites.code_label": {"zh": "邀请码(留空自动生成)", "en": "Code (leave empty to auto-generate)"},
"invites.code_ph": {"zh": "自动生成", "en": "Auto-generate"},
"invites.limit_type": {"zh": "限制类型", "en": "Limit type"},
"invites.unlimited": {"zh": "无限制", "en": "Unlimited"},
"invites.uses": {"zh": "使用次数", "en": "Max uses"},
"invites.expires": {"zh": "有效期", "en": "Expiration"},
"invites.times": {"zh": "次数", "en": "Times"},
"invites.create_btn": {"zh": "创建", "en": "Create"},
"invites.creator": {"zh": "创建者", "en": "By"},
"invites.used": {"zh": "已用", "en": "Used"},
"invites.no_expire": {"zh": "永不过期", "en": "No expiration"},
"invites.expires_at": {"zh": "过期", "en": "Expires"},
"invites.edit": {"zh": "编辑", "en": "Edit"},
"invites.save": {"zh": "保存", "en": "Save"},
"invites.delete": {"zh": "删除", "en": "Delete"},
"invites.confirm_delete": {"zh": "确定删除此邀请码?", "en": "Delete this invite code?"},
"invites.max_uses": {"zh": "最大使用次数", "en": "Max uses"},
"invites.expires_time": {"zh": "过期时间", "en": "Expiration"},
"invites.registered_users": {"zh": "注册用户", "en": "Registered users"},
"invites.empty": {"zh": "暂无邀请码", "en": "No invite codes yet"},
# ── Showcase (public) ──
"showcase.title": {"zh": " 的明信片", "en": "'s postcards"},
"showcase.empty": {"zh": "暂无展示内容", "en": "Nothing to show yet"},
"showcase.tab_sent": {"zh": "发出", "en": "Sent"},
"showcase.tab_received": {"zh": "收到", "en": "Received"},
# ── Showcase manage ──
"showcase_manage.title": {"zh": "展示管理", "en": "Showcase manager"},
"showcase_manage.global": {"zh": "全局展示设置", "en": "Global showcase settings"},
"showcase_manage.save": {"zh": "保存全局设置", "en": "Save global settings"},
"showcase_manage.public_url": {"zh": "公开展示页地址:", "en": "Public showcase: "},
"showcase_manage.profiles": {"zh": "各 Profile 展示设置", "en": "Per-profile settings"},
"showcase_manage.on": {"zh": "展示中", "en": "Showing"},
"showcase_manage.off": {"zh": "未开启", "en": "Off"},
"showcase_manage.enable": {"zh": "开启展示", "en": "Enable"},
"showcase_manage.content": {"zh": "展示内容", "en": "Content"},
"showcase_manage.both": {"zh": "发出 + 收到", "en": "Sent + Received"},
"showcase_manage.sent_only": {"zh": "仅发出", "en": "Sent only"},
"showcase_manage.received_only": {"zh": "仅收到", "en": "Received only"},
# ── Profile showcase ──
"profile_showcase.title": {"zh": " 展示设置", "en": " showcase settings"},
"profile_showcase.enable": {"zh": "开启展示", "en": "Enable showcase"},
"profile_showcase.content": {"zh": "展示内容", "en": "Content"},
"profile_showcase.both": {"zh": "发出 + 收到", "en": "Sent + Received"},
"profile_showcase.sent_only": {"zh": "仅发出", "en": "Sent only"},
"profile_showcase.received_only": {"zh": "仅收到", "en": "Received only"},
"profile_showcase.single_control": {"zh": "单张明信片展示控制", "en": "Per-card visibility"},
"profile_showcase.hidden": {"zh": "隐藏", "en": "Hidden"},
"profile_showcase.hint": {"zh": "开启展示后可控制单张明信片的可见性", "en": "Enable showcase to control per-card visibility"},
# ── Flash messages ──
"flash.username_taken": {"zh": "用户名已存在", "en": "Username already taken"},
"flash.username_ok": {"zh": "用户名修改成功", "en": "Username changed"},
"flash.wrong_password": {"zh": "当前密码错误", "en": "Wrong password"},
"flash.password_mismatch": {"zh": "两次输入的新密码不一致", "en": "Passwords don't match"},
"flash.password_short": {"zh": "新密码长度至少6位", "en": "Password must be at least 6 characters"},
"flash.password_ok": {"zh": "密码修改成功", "en": "Password changed"},
# ── API Keys ──
"settings.tab_apikeys": {"zh": "API Keys", "en": "API Keys"},
"apikey.title": {"zh": "API Key 管理", "en": "API Key Management"},
"apikey.hint": {"zh": "使用 API Key 通过 HTTP 请求访问星笺的公共 API。请妥善保管你的 Key它仅在创建时显示一次。", "en": "Use API Keys to access Mailova's public API via HTTP requests. Keep your key safe — it is only shown once upon creation."},
"apikey.created": {"zh": "✅ 新 Key 已创建:", "en": "✅ New key created:"},
"apikey.copy_now": {"zh": "⚠️ 请立即复制保存,关闭后将无法再次查看完整 Key。", "en": "⚠️ Copy it now. You won't be able to see the full key again after leaving this page."},
"apikey.name_ph": {"zh": "Key 名称(可选)", "en": "Key name (optional)"},
"apikey.create": {"zh": "创建 Key", "en": "Create Key"},
"apikey.name": {"zh": "名称", "en": "Name"},
"apikey.key": {"zh": "Key", "en": "Key"},
"apikey.created_at": {"zh": "创建时间", "en": "Created"},
"apikey.last_used": {"zh": "最后使用", "en": "Last used"},
"apikey.delete": {"zh": "删除", "en": "Delete"},
"apikey.confirm_delete": {"zh": "确定删除此 API Key", "en": "Delete this API key?"},
"apikey.empty": {"zh": "暂无 API Key", "en": "No API keys yet"},
# ── API Docs ──
"api_docs.title": {"zh": "API 文档", "en": "API Documentation"},
"api_docs.intro": {"zh": "星笺提供 RESTful API供第三方应用集成使用。所有需要鉴权的请求需在 Header 中携带 X-API-Key。", "en": "Mailova provides a RESTful API for third-party integrations. All authenticated requests must include the X-API-Key header."},
"api_docs.base_url": {"zh": "基础 URL", "en": "Base URL"},
"api_docs.auth": {"zh": "鉴权方式", "en": "Authentication"},
"api_docs.auth_desc": {"zh": "在请求头中携带你的 API Key", "en": "Include your API Key in the request header:"},
"api_docs.profile_ops": {"zh": "Profile 操作", "en": "Profile Operations"},
"api_docs.postcard_ops": {"zh": "明信片操作", "en": "Postcard Operations"},
"api_docs.upload_op": {"zh": "图片上传", "en": "Image Upload"},
"api_docs.key_ops": {"zh": "API Key 管理", "en": "API Key Management"},
"api_docs.showcase_ops": {"zh": "公开展示(无需鉴权)", "en": "Public Showcase (no auth)"},
"api_docs.method": {"zh": "方法", "en": "Method"},
"api_docs.path": {"zh": "路径", "en": "Path"},
"api_docs.desc": {"zh": "说明", "en": "Description"},
"api_docs.params": {"zh": "参数", "en": "Parameters"},
"api_docs.body": {"zh": "请求体 (JSON)", "en": "Request Body (JSON)"},
"api_docs.response": {"zh": "响应", "en": "Response"},
"api_docs.status": {"zh": "状态码", "en": "Status"},
"api_docs.go_settings": {"zh": "去设置页创建 API Key →", "en": "Go to Settings to create an API Key →"},
"api_docs.try_it": {"zh": "试试看", "en": "Try it"},
"api_docs.api_key_ph": {"zh": "输入你的 API Key", "en": "Enter your API Key"},
"api_docs.send": {"zh": "发送", "en": "Send"},
"api_docs.response_label": {"zh": "响应结果", "en": "Response"},
"api_docs.no_auth": {"zh": "此接口无需鉴权", "en": "No authentication required"},
# ── API Docs (endpoint descriptions) ──
"api_docs.ep.list_profiles": {"zh": "获取当前用户的所有 Profile 列表。", "en": "List all profiles for the current user."},
"api_docs.ep.create_profile": {"zh": "创建新 Profile。", "en": "Create a new profile."},
"api_docs.ep.get_profile": {"zh": "获取单个 Profile 详情。", "en": "Get a single profile by ID."},
"api_docs.ep.update_profile": {"zh": "更新 Profile。", "en": "Update a profile."},
"api_docs.ep.delete_profile": {"zh": "删除 Profile 及其下所有明信片。", "en": "Delete a profile and all its postcards."},
"api_docs.ep.list_postcards": {"zh": "获取指定 Profile 下的明信片列表。", "en": "List postcards under a profile."},
"api_docs.ep.create_postcard": {"zh": "创建新明信片。", "en": "Create a new postcard."},
"api_docs.ep.get_postcard": {"zh": "获取单张明信片详情。", "en": "Get a single postcard by ID."},
"api_docs.ep.update_postcard": {"zh": "更新明信片。仅传入需要修改的字段。", "en": "Update a postcard. Only send fields you want to change."},
"api_docs.ep.delete_postcard": {"zh": "删除单张明信片。", "en": "Delete a single postcard."},
"api_docs.ep.upload_image": {"zh": "上传明信片图片(正面或背面),使用", "en": "Upload a postcard image (front or back), using"},
"api_docs.ep.upload_uses": {"zh": "上传明信片图片(正面或背面),使用", "en": "Upload a postcard image (front or back), using"},
"api_docs.ep.list_keys": {"zh": "获取当前用户的所有 API Key。", "en": "List all API keys for the current user."},
"api_docs.ep.create_key": {"zh": "创建新的 API Key。创建后完整 Key 仅在响应中显示一次。", "en": "Create a new API key. The full key is only shown once in the response."},
"api_docs.ep.delete_key": {"zh": "删除指定 API Key。", "en": "Delete an API key."},
"api_docs.ep.showcase_user": {"zh": "获取用户的公开展示数据。", "en": "Get a user's public showcase data."},
"api_docs.ep.showcase_profile": {"zh": "获取单个 Profile 的展示数据。", "en": "Get showcase data for a single profile."},
"api_docs.ep.filter_hint": {"zh": "按状态筛选sent / delivered / received", "en": "Filter by status: sent / delivered / received"},
"api_docs.ep.optional_str": {"zh": "string可选", "en": "string (optional)"},
"api_docs.ep.image_file": {"zh": "图片文件", "en": "image file"},
"api_docs.ep.side_hint": {"zh": "string可选— front 或 back默认 front", "en": "string (optional) — front or back, defaults to front"},
"api_docs.ep.key_name_hint": {"zh": "string可选— Key 的备注名称", "en": "string (optional) — a label for the key"},
"api_docs.ep.country_hint": {"zh": "两个大写字母,如 JP", "en": "two uppercase letters, e.g. JP"},
"api_docs.ep.date_hint": {"zh": "格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM", "en": "format: YYYY-MM-DD or YYYY-MM-DDTHH:MM"},
# ── Misc ──
"misc.required": {"zh": " *", "en": " *"},
}
def t(key: str, lang: str = "zh") -> str: def t(key: str, lang: str = "zh") -> str:
"""Translate a key to the given language. Falls back to zh, then key itself.""" """Translate *key* to *lang*. Falls back to zh, then to the raw key."""
entry = _translations.get(key) _load()
if entry is None: # Try requested language
return key val = _cache.get(lang, {}).get(key)
return entry.get(lang) or entry.get("zh") or key if val is not None:
return val
# Fallback to zh
if lang != "zh":
val = _cache.get("zh", {}).get(key)
if val is not None:
return val
# Fallback to any available language
for translations in _cache.values():
if key in translations:
return translations[key]
return key
def get_languages() -> dict[str, str]:
"""Return available languages as {code: native_name}.
Each JSON file can optionally include a top-level ``_meta`` key
with ``{"name": "语言名称"}`` to set the display name.
If absent, the language code is used as-is.
"""
_load()
result: dict[str, str] = {}
for lang in sorted(_cache):
meta = _cache[lang].get("_meta", {})
result[lang] = meta.get("name", lang)
return result

249
app/locales/en.json Normal file
View File

@@ -0,0 +1,249 @@
{
"_meta": {
"name": "English"
},
"brand": "📮 Mailova",
"nav.profiles": "Profiles",
"nav.settings": "Settings",
"nav.invites": "Invites",
"nav.logout": "Logout",
"nav.api_docs": "API Docs",
"login.title": "Login",
"login.username": "Username",
"login.password": "Password",
"login.remember": "Remember me",
"login.no_account": "No account?",
"login.go_register": "Sign up",
"register.title": "Register",
"register.hint": "Invite code provided by an existing user or admin",
"register.invite_code": "Invite code",
"register.invite_ph": "8-char code",
"register.username": "Username",
"register.password": "Password",
"register.confirm_password": "Confirm password",
"register.has_account": "Already have an account?",
"register.go_login": "Log in",
"dash.welcome": "👋 Welcome, ",
"dash.total_cards": "Total postcards",
"dash.sent": "Sent",
"dash.delivered": "Delivered",
"dash.received": "Received",
"dash.countries": "Countries",
"dash.chart_sent": "📤 Sent",
"dash.chart_received": "📬 Received",
"dash.recent": "Recent activity",
"dash.empty": "No postcards yet",
"dash.welcome_title": "Welcome to Mailova!",
"dash.welcome_hint": "Create a",
"dash.welcome_hint2": " to start managing your postcards",
"dash.today": "Today",
"dash.yesterday": "Yesterday",
"dash.days_ago": " days ago",
"profiles.title": "Profiles",
"profiles.new_ph": "New profile nickname",
"profiles.new_btn": "+ New",
"profiles.rename": "Rename",
"profiles.total": "Total",
"profiles.countries": "Countries",
"profiles.notes_ph": "Notes (private)",
"profiles.save": "Save",
"profiles.created_at": "Created",
"profiles.view": "View",
"profiles.delete": "Delete",
"profiles.confirm_delete": "Delete this profile?",
"pc_list.title": "'s postcards",
"pc_list.new": "+ New postcard",
"pc_list.duplicate": "Card number already exists",
"pc_list.all_countries": "All countries",
"pc_list.filter": "Filter",
"pc_list.tab_sent": "Sent",
"pc_list.tab_received": "Received",
"pc_list.mark_delivered": "Delivered",
"pc_list.confirm_delivered": "Mark as delivered?",
"pc_list.empty_sent": "No sent postcards.",
"pc_list.empty_received": "No received postcards.",
"pc_detail.title": "Postcard detail",
"pc_detail.edit": "Edit",
"pc_detail.mark_delivered": "Mark delivered",
"pc_detail.confirm_delivered": "Mark as delivered?",
"pc_detail.delete": "Delete",
"pc_detail.confirm_delete": "Delete this postcard?",
"pc_detail.front": "Front",
"pc_detail.back": "Back",
"pc_detail.no_front": "📷 No front image",
"pc_detail.no_back": "📷 No back image",
"pc_detail.upload_front": "Upload front",
"pc_detail.upload_back": "Upload back",
"pc_detail.card_number": "Number",
"pc_detail.status": "Status",
"pc_detail.recipient": "Recipient",
"pc_detail.country": "Country",
"pc_detail.send_time": "Sent date",
"pc_detail.arrival_time": "Arrival date",
"pc_detail.sender": "Sender",
"pc_detail.receive_time": "Received date",
"pc_detail.profile": "Profile",
"pc_detail.notes": "Notes",
"status.sent": "Sent",
"status.delivered": "Delivered",
"status.received": "Received",
"pc_form.edit": "Edit postcard",
"pc_form.new": "New postcard",
"pc_form.number": "Number",
"pc_form.number_hint": "Format: AB-1234567",
"pc_form.status": "Status",
"pc_form.recipient": "Recipient",
"pc_form.country": "Country",
"pc_form.send_time": "Sent date",
"pc_form.arrival_time": "Arrival date",
"pc_form.sender": "Sender",
"pc_form.receive_time": "Received date",
"pc_form.notes": "Notes",
"pc_form.notes_ph": "Optional notes",
"pc_form.save": "Save",
"pc_form.cancel": "Cancel",
"settings.title": "Settings",
"settings.tab_account": "Account",
"settings.tab_showcase": "Showcase",
"settings.change_username": "Change username",
"settings.current_username": "Current username",
"settings.new_username": "New username",
"settings.change_password": "Change password",
"settings.current_password": "Current password",
"settings.new_password": "New password",
"settings.confirm_password": "Confirm new password",
"settings.language": "Language",
"settings.language_zh": "中文",
"settings.language_en": "English",
"settings.showcase_global": "Global showcase settings",
"settings.showcase_mode": "Showcase mode",
"settings.mode_profile": "Grouped by profile",
"settings.mode_flat": "All together",
"settings.save_global": "Save global settings",
"settings.public_url": "Public showcase: ",
"settings.public_url_label": "Public showcase: ",
"settings.profiles_showcase": "Per-profile showcase",
"settings.showcase_on": "Showing",
"settings.showcase_off": "Off",
"settings.enable_showcase": "Enable showcase",
"settings.showcase_content": "Content",
"settings.content_both": "Sent + Received",
"settings.content_sent": "Sent only",
"settings.content_received": "Received only",
"settings.save": "Save",
"invites.title": "Invite codes",
"invites.create": "Create invite code",
"invites.code_label": "Code (leave empty to auto-generate)",
"invites.code_ph": "Auto-generate",
"invites.limit_type": "Limit type",
"invites.unlimited": "Unlimited",
"invites.uses": "Max uses",
"invites.expires": "Expiration",
"invites.times": "Times",
"invites.create_btn": "Create",
"invites.creator": "By",
"invites.used": "Used",
"invites.no_expire": "No expiration",
"invites.expires_at": "Expires",
"invites.edit": "Edit",
"invites.save": "Save",
"invites.delete": "Delete",
"invites.confirm_delete": "Delete this invite code?",
"invites.max_uses": "Max uses",
"invites.expires_time": "Expiration",
"invites.registered_users": "Registered users",
"invites.empty": "No invite codes yet",
"showcase.title": "'s postcards",
"showcase.empty": "Nothing to show yet",
"showcase.tab_sent": "Sent",
"showcase.tab_received": "Received",
"showcase_manage.title": "Showcase manager",
"showcase_manage.global": "Global showcase settings",
"showcase_manage.save": "Save global settings",
"showcase_manage.public_url": "Public showcase: ",
"showcase_manage.profiles": "Per-profile settings",
"showcase_manage.on": "Showing",
"showcase_manage.off": "Off",
"showcase_manage.enable": "Enable",
"showcase_manage.content": "Content",
"showcase_manage.both": "Sent + Received",
"showcase_manage.sent_only": "Sent only",
"showcase_manage.received_only": "Received only",
"profile_showcase.title": " showcase settings",
"profile_showcase.enable": "Enable showcase",
"profile_showcase.content": "Content",
"profile_showcase.both": "Sent + Received",
"profile_showcase.sent_only": "Sent only",
"profile_showcase.received_only": "Received only",
"profile_showcase.single_control": "Per-card visibility",
"profile_showcase.hidden": "Hidden",
"profile_showcase.hint": "Enable showcase to control per-card visibility",
"flash.username_taken": "Username already taken",
"flash.username_ok": "Username changed",
"flash.wrong_password": "Wrong password",
"flash.password_mismatch": "Passwords don't match",
"flash.password_short": "Password must be at least 6 characters",
"flash.password_ok": "Password changed",
"settings.tab_apikeys": "API Keys",
"apikey.title": "API Key Management",
"apikey.hint": "Use API Keys to access Mailova's public API via HTTP requests. Keep your key safe — it is only shown once upon creation.",
"apikey.created": "✅ New key created:",
"apikey.copy_now": "⚠️ Copy it now. You won't be able to see the full key again after leaving this page.",
"apikey.name_ph": "Key name (optional)",
"apikey.create": "Create Key",
"apikey.name": "Name",
"apikey.key": "Key",
"apikey.created_at": "Created",
"apikey.last_used": "Last used",
"apikey.delete": "Delete",
"apikey.confirm_delete": "Delete this API key?",
"apikey.empty": "No API keys yet",
"api_docs.title": "API Documentation",
"api_docs.intro": "Mailova provides a RESTful API for third-party integrations. All authenticated requests must include the X-API-Key header.",
"api_docs.base_url": "Base URL",
"api_docs.auth": "Authentication",
"api_docs.auth_desc": "Include your API Key in the request header:",
"api_docs.profile_ops": "Profile Operations",
"api_docs.postcard_ops": "Postcard Operations",
"api_docs.upload_op": "Image Upload",
"api_docs.key_ops": "API Key Management",
"api_docs.showcase_ops": "Public Showcase (no auth)",
"api_docs.method": "Method",
"api_docs.path": "Path",
"api_docs.desc": "Description",
"api_docs.params": "Parameters",
"api_docs.body": "Request Body (JSON)",
"api_docs.response": "Response",
"api_docs.status": "Status",
"api_docs.go_settings": "Go to Settings to create an API Key →",
"api_docs.try_it": "Try it",
"api_docs.api_key_ph": "Enter your API Key",
"api_docs.send": "Send",
"api_docs.response_label": "Response",
"api_docs.no_auth": "No authentication required",
"api_docs.ep.list_profiles": "List all profiles for the current user.",
"api_docs.ep.create_profile": "Create a new profile.",
"api_docs.ep.get_profile": "Get a single profile by ID.",
"api_docs.ep.update_profile": "Update a profile.",
"api_docs.ep.delete_profile": "Delete a profile and all its postcards.",
"api_docs.ep.list_postcards": "List postcards under a profile.",
"api_docs.ep.create_postcard": "Create a new postcard.",
"api_docs.ep.get_postcard": "Get a single postcard by ID.",
"api_docs.ep.update_postcard": "Update a postcard. Only send fields you want to change.",
"api_docs.ep.delete_postcard": "Delete a single postcard.",
"api_docs.ep.upload_image": "Upload a postcard image (front or back), using",
"api_docs.ep.upload_uses": "Upload a postcard image (front or back), using",
"api_docs.ep.list_keys": "List all API keys for the current user.",
"api_docs.ep.create_key": "Create a new API key. The full key is only shown once in the response.",
"api_docs.ep.delete_key": "Delete an API key.",
"api_docs.ep.showcase_user": "Get a user's public showcase data.",
"api_docs.ep.showcase_profile": "Get showcase data for a single profile.",
"api_docs.ep.filter_hint": "Filter by status: sent / delivered / received",
"api_docs.ep.optional_str": "string (optional)",
"api_docs.ep.image_file": "image file",
"api_docs.ep.side_hint": "string (optional) — front or back, defaults to front",
"api_docs.ep.key_name_hint": "string (optional) — a label for the key",
"api_docs.ep.country_hint": "two uppercase letters, e.g. JP",
"api_docs.ep.date_hint": "format: YYYY-MM-DD or YYYY-MM-DDTHH:MM",
"misc.required": " *"
}

249
app/locales/zh.json Normal file
View File

@@ -0,0 +1,249 @@
{
"_meta": {
"name": "中文"
},
"brand": "📮 星笺",
"nav.profiles": "Profile",
"nav.settings": "设置",
"nav.invites": "邀请码管理",
"nav.logout": "退出",
"nav.api_docs": "API 文档",
"login.title": "登录",
"login.username": "用户名",
"login.password": "密码",
"login.remember": "记住我",
"login.no_account": "没有账号?",
"login.go_register": "去注册",
"register.title": "注册",
"register.hint": "邀请码由已有用户或管理员提供",
"register.invite_code": "邀请码",
"register.invite_ph": "8位字母数字",
"register.username": "用户名",
"register.password": "密码",
"register.confirm_password": "确认密码",
"register.has_account": "已有账号?",
"register.go_login": "去登录",
"dash.welcome": "👋 欢迎,",
"dash.total_cards": "全部明信片",
"dash.sent": "已寄出",
"dash.delivered": "已送达",
"dash.received": "已收到",
"dash.countries": "国家/地区",
"dash.chart_sent": "📤 寄出",
"dash.chart_received": "📬 收到",
"dash.recent": "最近动态",
"dash.empty": "暂无明信片",
"dash.welcome_title": "欢迎使用星笺!",
"dash.welcome_hint": "创建一个",
"dash.welcome_hint2": " 开始管理你的明信片",
"dash.today": "今天",
"dash.yesterday": "昨天",
"dash.days_ago": "天前",
"profiles.title": "Profile",
"profiles.new_ph": "新 Profile 昵称",
"profiles.new_btn": "+ 新建",
"profiles.rename": "改名",
"profiles.total": "总计",
"profiles.countries": "国家",
"profiles.notes_ph": "备注(仅自己可见)",
"profiles.save": "保存",
"profiles.created_at": "创建于",
"profiles.view": "查看",
"profiles.delete": "删除",
"profiles.confirm_delete": "确定删除此 Profile",
"pc_list.title": " 的明信片",
"pc_list.new": "+ 新明信片",
"pc_list.duplicate": "编号已存在",
"pc_list.all_countries": "全部国家",
"pc_list.filter": "筛选",
"pc_list.tab_sent": "寄出",
"pc_list.tab_received": "收到",
"pc_list.mark_delivered": "已送达",
"pc_list.confirm_delivered": "确认已送达?",
"pc_list.empty_sent": "没有寄出的明信片。",
"pc_list.empty_received": "没有收到的明信片。",
"pc_detail.title": "明信片详情",
"pc_detail.edit": "编辑",
"pc_detail.mark_delivered": "已送达",
"pc_detail.confirm_delivered": "确认已送达?",
"pc_detail.delete": "删除",
"pc_detail.confirm_delete": "确定删除?",
"pc_detail.front": "正面",
"pc_detail.back": "反面",
"pc_detail.no_front": "📷 未上传正面图片",
"pc_detail.no_back": "📷 未上传反面图片",
"pc_detail.upload_front": "上传正面",
"pc_detail.upload_back": "上传反面",
"pc_detail.card_number": "编号",
"pc_detail.status": "状态",
"pc_detail.recipient": "收件人",
"pc_detail.country": "国家/地区",
"pc_detail.send_time": "寄出时间",
"pc_detail.arrival_time": "到达时间",
"pc_detail.sender": "发件人",
"pc_detail.receive_time": "收到时间",
"pc_detail.profile": "所属 Profile",
"pc_detail.notes": "备注",
"status.sent": "已寄出",
"status.delivered": "已送达",
"status.received": "已收到",
"pc_form.edit": "编辑明信片",
"pc_form.new": "新建明信片",
"pc_form.number": "编号",
"pc_form.number_hint": "格式AB-1234567",
"pc_form.status": "状态",
"pc_form.recipient": "收件人",
"pc_form.country": "国家/地区",
"pc_form.send_time": "寄出时间",
"pc_form.arrival_time": "到达时间",
"pc_form.sender": "发件人",
"pc_form.receive_time": "收到时间",
"pc_form.notes": "备注",
"pc_form.notes_ph": "可选备注信息",
"pc_form.save": "保存",
"pc_form.cancel": "取消",
"settings.title": "用户设置",
"settings.tab_account": "账号",
"settings.tab_showcase": "展示",
"settings.change_username": "修改用户名",
"settings.current_username": "当前用户名",
"settings.new_username": "新用户名",
"settings.change_password": "修改密码",
"settings.current_password": "当前密码",
"settings.new_password": "新密码",
"settings.confirm_password": "确认新密码",
"settings.language": "语言",
"settings.language_zh": "中文",
"settings.language_en": "English",
"settings.showcase_global": "全局展示设置",
"settings.showcase_mode": "展示页面模式",
"settings.mode_profile": "按 Profile 分类",
"settings.mode_flat": "全部合并",
"settings.save_global": "保存全局设置",
"settings.public_url": "公开展示页地址:",
"settings.public_url_label": "公开展示页:",
"settings.profiles_showcase": "各 Profile 展示设置",
"settings.showcase_on": "展示中",
"settings.showcase_off": "未开启",
"settings.enable_showcase": "开启展示",
"settings.showcase_content": "展示内容",
"settings.content_both": "发出 + 收到",
"settings.content_sent": "仅发出",
"settings.content_received": "仅收到",
"settings.save": "保存",
"invites.title": "邀请码管理",
"invites.create": "创建邀请码",
"invites.code_label": "邀请码(留空自动生成)",
"invites.code_ph": "自动生成",
"invites.limit_type": "限制类型",
"invites.unlimited": "无限制",
"invites.uses": "使用次数",
"invites.expires": "有效期",
"invites.times": "次数",
"invites.create_btn": "创建",
"invites.creator": "创建者",
"invites.used": "已用",
"invites.no_expire": "永不过期",
"invites.expires_at": "过期",
"invites.edit": "编辑",
"invites.save": "保存",
"invites.delete": "删除",
"invites.confirm_delete": "确定删除此邀请码?",
"invites.max_uses": "最大使用次数",
"invites.expires_time": "过期时间",
"invites.registered_users": "注册用户",
"invites.empty": "暂无邀请码",
"showcase.title": " 的明信片",
"showcase.empty": "暂无展示内容",
"showcase.tab_sent": "发出",
"showcase.tab_received": "收到",
"showcase_manage.title": "展示管理",
"showcase_manage.global": "全局展示设置",
"showcase_manage.save": "保存全局设置",
"showcase_manage.public_url": "公开展示页地址:",
"showcase_manage.profiles": "各 Profile 展示设置",
"showcase_manage.on": "展示中",
"showcase_manage.off": "未开启",
"showcase_manage.enable": "开启展示",
"showcase_manage.content": "展示内容",
"showcase_manage.both": "发出 + 收到",
"showcase_manage.sent_only": "仅发出",
"showcase_manage.received_only": "仅收到",
"profile_showcase.title": " 展示设置",
"profile_showcase.enable": "开启展示",
"profile_showcase.content": "展示内容",
"profile_showcase.both": "发出 + 收到",
"profile_showcase.sent_only": "仅发出",
"profile_showcase.received_only": "仅收到",
"profile_showcase.single_control": "单张明信片展示控制",
"profile_showcase.hidden": "隐藏",
"profile_showcase.hint": "开启展示后可控制单张明信片的可见性",
"flash.username_taken": "用户名已存在",
"flash.username_ok": "用户名修改成功",
"flash.wrong_password": "当前密码错误",
"flash.password_mismatch": "两次输入的新密码不一致",
"flash.password_short": "新密码长度至少6位",
"flash.password_ok": "密码修改成功",
"settings.tab_apikeys": "API Keys",
"apikey.title": "API Key 管理",
"apikey.hint": "使用 API Key 通过 HTTP 请求访问星笺的公共 API。请妥善保管你的 Key它仅在创建时显示一次。",
"apikey.created": "✅ 新 Key 已创建:",
"apikey.copy_now": "⚠️ 请立即复制保存,关闭后将无法再次查看完整 Key。",
"apikey.name_ph": "Key 名称(可选)",
"apikey.create": "创建 Key",
"apikey.name": "名称",
"apikey.key": "Key",
"apikey.created_at": "创建时间",
"apikey.last_used": "最后使用",
"apikey.delete": "删除",
"apikey.confirm_delete": "确定删除此 API Key",
"apikey.empty": "暂无 API Key",
"api_docs.title": "API 文档",
"api_docs.intro": "星笺提供 RESTful API供第三方应用集成使用。所有需要鉴权的请求需在 Header 中携带 X-API-Key。",
"api_docs.base_url": "基础 URL",
"api_docs.auth": "鉴权方式",
"api_docs.auth_desc": "在请求头中携带你的 API Key",
"api_docs.profile_ops": "Profile 操作",
"api_docs.postcard_ops": "明信片操作",
"api_docs.upload_op": "图片上传",
"api_docs.key_ops": "API Key 管理",
"api_docs.showcase_ops": "公开展示(无需鉴权)",
"api_docs.method": "方法",
"api_docs.path": "路径",
"api_docs.desc": "说明",
"api_docs.params": "参数",
"api_docs.body": "请求体 (JSON)",
"api_docs.response": "响应",
"api_docs.status": "状态码",
"api_docs.go_settings": "去设置页创建 API Key →",
"api_docs.try_it": "试试看",
"api_docs.api_key_ph": "输入你的 API Key",
"api_docs.send": "发送",
"api_docs.response_label": "响应结果",
"api_docs.no_auth": "此接口无需鉴权",
"api_docs.ep.list_profiles": "获取当前用户的所有 Profile 列表。",
"api_docs.ep.create_profile": "创建新 Profile。",
"api_docs.ep.get_profile": "获取单个 Profile 详情。",
"api_docs.ep.update_profile": "更新 Profile。",
"api_docs.ep.delete_profile": "删除 Profile 及其下所有明信片。",
"api_docs.ep.list_postcards": "获取指定 Profile 下的明信片列表。",
"api_docs.ep.create_postcard": "创建新明信片。",
"api_docs.ep.get_postcard": "获取单张明信片详情。",
"api_docs.ep.update_postcard": "更新明信片。仅传入需要修改的字段。",
"api_docs.ep.delete_postcard": "删除单张明信片。",
"api_docs.ep.upload_image": "上传明信片图片(正面或背面),使用",
"api_docs.ep.upload_uses": "上传明信片图片(正面或背面),使用",
"api_docs.ep.list_keys": "获取当前用户的所有 API Key。",
"api_docs.ep.create_key": "创建新的 API Key。创建后完整 Key 仅在响应中显示一次。",
"api_docs.ep.delete_key": "删除指定 API Key。",
"api_docs.ep.showcase_user": "获取用户的公开展示数据。",
"api_docs.ep.showcase_profile": "获取单个 Profile 的展示数据。",
"api_docs.ep.filter_hint": "按状态筛选sent / delivered / received",
"api_docs.ep.optional_str": "string可选",
"api_docs.ep.image_file": "图片文件",
"api_docs.ep.side_hint": "string可选— front 或 back默认 front",
"api_docs.ep.key_name_hint": "string可选— Key 的备注名称",
"api_docs.ep.country_hint": "两个大写字母,如 JP",
"api_docs.ep.date_hint": "格式 YYYY-MM-DD 或 YYYY-MM-DDTHH:MM",
"misc.required": " *"
}

View File

@@ -73,6 +73,9 @@ def _translate_filter(key: str, lang: str = "zh") -> str:
templates.env.filters["t"] = _translate_filter templates.env.filters["t"] = _translate_filter
from app.i18n import get_languages as _get_languages
templates.env.globals["available_languages"] = _get_languages()
def _redirect(url: str) -> RedirectResponse: def _redirect(url: str) -> RedirectResponse:
return RedirectResponse(url, status_code=303) return RedirectResponse(url, status_code=303)

View File

@@ -30,6 +30,9 @@ a:hover { text-decoration: underline; }
.nav-logout { color: var(--danger) !important; } .nav-logout { color: var(--danger) !important; }
.nav-lang-btn { background: none; border: 1px solid var(--border); border-radius: 4px; padding: .15rem .45rem; font-size: .72rem; color: var(--text-muted); cursor: pointer; line-height: 1; } .nav-lang-btn { background: none; border: 1px solid var(--border); border-radius: 4px; padding: .15rem .45rem; font-size: .72rem; color: var(--text-muted); cursor: pointer; line-height: 1; }
.nav-lang-btn:hover { background: var(--bg); color: var(--primary); } .nav-lang-btn:hover { background: var(--bg); color: var(--primary); }
.nav-lang-select { background: none; border: 1px solid var(--border); border-radius: 4px; padding: .15rem .3rem; font-size: .72rem; color: var(--text-muted); cursor: pointer; line-height: 1; outline: none; }
.nav-lang-select:hover { background: var(--bg); color: var(--primary); }
.nav-lang-select option { background: var(--card-bg); color: var(--text); }
/* Container */ /* Container */
.container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; } .container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; }

View File

@@ -19,8 +19,11 @@
{% endif %} {% endif %}
<a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a> <a href="/api/docs">{{ 'nav.api_docs'|t(user.language) }}</a>
<form method="post" action="/settings/language" class="inline" style="display:inline-flex;align-items:center;gap:0"> <form method="post" action="/settings/language" class="inline" style="display:inline-flex;align-items:center;gap:0">
<input type="hidden" name="language" value="{{ 'en' if user.language == 'zh' else 'zh' }}"> <select name="language" class="nav-lang-select" onchange="this.form.submit()">
<button type="submit" class="nav-lang-btn">{{ 'EN' if user.language == 'zh' else '中' }}</button> {% for code, name in available_languages.items() %}
<option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select>
</form> </form>
<a href="/logout" class="nav-logout">{{ 'nav.logout'|t(user.language) }}</a> <a href="/logout" class="nav-logout">{{ 'nav.logout'|t(user.language) }}</a>
{% endif %} {% endif %}

View File

@@ -51,8 +51,9 @@
<h3 style="margin-bottom:.75rem">{{ 'settings.language'|t(user.language) }}</h3> <h3 style="margin-bottom:.75rem">{{ 'settings.language'|t(user.language) }}</h3>
<form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap"> <form method="post" action="/settings/language" style="display:flex;align-items:center;gap:.75rem;flex-wrap:wrap">
<select name="language" style="width:auto"> <select name="language" style="width:auto">
<option value="zh" {% if user.language == 'zh' %}selected{% endif %}>{{ 'settings.language_zh'|t(user.language) }}</option> {% for code, name in available_languages.items() %}
<option value="en" {% if user.language == 'en' %}selected{% endif %}>{{ 'settings.language_en'|t(user.language) }}</option> <option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option>
{% endfor %}
</select> </select>
<button type="submit" class="btn btn-primary btn-sm">{{ 'settings.save'|t(user.language) }}</button> <button type="submit" class="btn btn-primary btn-sm">{{ 'settings.save'|t(user.language) }}</button>
</form> </form>