From 591ad3e178e95d9d92d57b120cdec5795fcfd13f Mon Sep 17 00:00:00 2001 From: Zichao Lin Date: Tue, 7 Jul 2026 09:46:50 +0800 Subject: [PATCH] refactor(i18n): move translations to JSON files under app/locales, dynamic language switch --- app/i18n.py | 334 ++++++------------------------------ app/locales/en.json | 249 +++++++++++++++++++++++++++ app/locales/zh.json | 249 +++++++++++++++++++++++++++ app/routers/web.py | 3 + app/static/style.css | 3 + app/templates/base.html | 7 +- app/templates/settings.html | 5 +- 7 files changed, 565 insertions(+), 285 deletions(-) create mode 100644 app/locales/en.json create mode 100644 app/locales/zh.json diff --git a/app/i18n.py b/app/i18n.py index 9761b7d..e97fe52 100644 --- a/app/i18n.py +++ b/app/i18n.py @@ -1,290 +1,62 @@ -# Translation keys for zh (Chinese) and en (English) -_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"}, +"""i18n module — loads translations from app/locales/*.json. - # ── Auth ── - "login.title": {"zh": "登录", "en": "Login"}, - "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"}, +Usage in templates: {{ 'key'|t(user.language) }} +Usage in Python: from app.i18n import t; t("key", lang) - # ── Dashboard ── - "dash.welcome": {"zh": "👋 欢迎,", "en": "👋 Welcome, "}, - "dash.total_cards": {"zh": "全部明信片", "en": "Total postcards"}, - "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"}, +To add a new language: + 1. Create app/locales/.json with translation keys + 2. Done — it will be auto-detected and available in the UI +""" - # ── Profiles ── - "profiles.title": {"zh": "Profile", "en": "Profiles"}, - "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?"}, +import json +from pathlib import Path - # ── Postcards list ── - "pc_list.title": {"zh": " 的明信片", "en": "'s postcards"}, - "pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"}, - "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."}, +_LOCALES_DIR = Path(__file__).resolve().parent / "locales" +_cache: dict[str, dict[str, str]] = {} +_loaded = False - # ── 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 ── - "status.sent": {"zh": "已寄出", "en": "Sent"}, - "status.delivered": {"zh": "已送达", "en": "Delivered"}, - "status.received": {"zh": "已收到", "en": "Received"}, - - # ── Postcard form ── - "pc_form.edit": {"zh": "编辑明信片", "en": "Edit postcard"}, - "pc_form.new": {"zh": "新建明信片", "en": "New postcard"}, - "pc_form.number": {"zh": "编号", "en": "Number"}, - "pc_form.number_hint": {"zh": "格式:AB-1234567", "en": "Format: AB-1234567"}, - "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 _load() -> None: + global _loaded + if _loaded: + return + _LOCALES_DIR.mkdir(exist_ok=True) + for f in _LOCALES_DIR.glob("*.json"): + lang = f.stem # e.g. "zh", "en", "ja" + with open(f, encoding="utf-8") as fh: + _cache[lang] = json.load(fh) + _loaded = True def t(key: str, lang: str = "zh") -> str: - """Translate a key to the given language. Falls back to zh, then key itself.""" - entry = _translations.get(key) - if entry is None: - return key - return entry.get(lang) or entry.get("zh") or key + """Translate *key* to *lang*. Falls back to zh, then to the raw key.""" + _load() + # Try requested language + val = _cache.get(lang, {}).get(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 diff --git a/app/locales/en.json b/app/locales/en.json new file mode 100644 index 0000000..8627f92 --- /dev/null +++ b/app/locales/en.json @@ -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": " *" +} \ No newline at end of file diff --git a/app/locales/zh.json b/app/locales/zh.json new file mode 100644 index 0000000..f19b667 --- /dev/null +++ b/app/locales/zh.json @@ -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": " *" +} \ No newline at end of file diff --git a/app/routers/web.py b/app/routers/web.py index ef21b2f..a65a6f6 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -73,6 +73,9 @@ def _translate_filter(key: str, lang: str = "zh") -> str: 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: return RedirectResponse(url, status_code=303) diff --git a/app/static/style.css b/app/static/style.css index 60bf4f3..68c7ca9 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -30,6 +30,9 @@ a:hover { text-decoration: underline; } .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: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 { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; } diff --git a/app/templates/base.html b/app/templates/base.html index 9fe6f48..4679bd6 100644 --- a/app/templates/base.html +++ b/app/templates/base.html @@ -19,8 +19,11 @@ {% endif %} {{ 'nav.api_docs'|t(user.language) }}
- - +
{{ 'nav.logout'|t(user.language) }} {% endif %} diff --git a/app/templates/settings.html b/app/templates/settings.html index 956a298..dddcc05 100644 --- a/app/templates/settings.html +++ b/app/templates/settings.html @@ -51,8 +51,9 @@

{{ 'settings.language'|t(user.language) }}