diff --git a/app/i18n.py b/app/i18n.py new file mode 100644 index 0000000..014827d --- /dev/null +++ b/app/i18n.py @@ -0,0 +1,212 @@ +# 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"}, + + # ── 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"}, + + # ── 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"}, + + # ── 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?"}, + + # ── Postcards list ── + "pc_list.title": {"zh": " 的明信片", "en": "'s postcards"}, + "pc_list.new": {"zh": "+ 新明信片", "en": "+ New postcard"}, + "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 ── + "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"}, + + # ── 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"}, + + # ── Misc ── + "misc.required": {"zh": " *", "en": " *"}, +} + + +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 diff --git a/app/models.py b/app/models.py index 252946b..2c4f066 100644 --- a/app/models.py +++ b/app/models.py @@ -19,6 +19,7 @@ class User(Base): is_admin = Column(Boolean, nullable=False, default=False) invited_by_code_id = Column(Integer, ForeignKey("invite_codes.id", ondelete="SET NULL"), nullable=True) showcase_mode = Column(String(16), nullable=False, default="profile") + language = Column(String(4), nullable=False, default="zh") created_at = Column(DateTime, default=_utcnow) profiles = relationship("Profile", back_populates="user", cascade="all, delete-orphan") diff --git a/app/routers/web.py b/app/routers/web.py index dbbe93b..4b53bd6 100644 --- a/app/routers/web.py +++ b/app/routers/web.py @@ -22,6 +22,7 @@ from random import choice from string import ascii_letters, digits from app.models import InviteCode, Postcard, Profile, User +from app.i18n import t as _t router = APIRouter(tags=["web"]) templates = Jinja2Templates(directory=str(Path(__file__).resolve().parent.parent / "templates")) @@ -66,6 +67,13 @@ def _strftime(dt: datetime, fmt: str) -> str: templates.env.filters["strftime"] = _strftime +def _translate_filter(key: str, lang: str = "zh") -> str: + return _t(key, lang) + + +templates.env.filters["t"] = _translate_filter + + def _redirect(url: str) -> RedirectResponse: return RedirectResponse(url, status_code=303) @@ -353,6 +361,19 @@ def change_password( ) +@router.post("/settings/language") +def change_language( + request: Request, + language: str = Form(...), + user: User = Depends(get_current_user_web), + db: Session = Depends(get_db), +): + if language in ("zh", "en"): + user.language = language + db.commit() + return _redirect(request.headers.get("referer", "/settings")) + + # ---------- Auth ---------- @router.get("/logout") @@ -798,4 +819,5 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d return templates.TemplateResponse("showcase.html", { "request": request, "profile_user": user, "mode": user.showcase_mode, "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, + "lang": user.language, }) diff --git a/app/static/style.css b/app/static/style.css index bb0b300..60bf4f3 100644 --- a/app/static/style.css +++ b/app/static/style.css @@ -28,6 +28,8 @@ a:hover { text-decoration: underline; } .nav-links a { color: var(--text-muted); font-size: .8rem; } .nav-links a:hover { color: var(--primary); text-decoration: none; } .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); } /* Container */ .container { max-width: 960px; margin: 2rem auto; padding: 0 1.5rem; } diff --git a/app/templates/admin_invites.html b/app/templates/admin_invites.html index 46aea33..d0c0751 100644 --- a/app/templates/admin_invites.html +++ b/app/templates/admin_invites.html @@ -1,8 +1,8 @@ {% extends "base.html" %} -{% block title %}邀请码管理 - 星笺{% endblock %} +{% block title %}{{ 'invites.title'|t(user.language) }} - {{ 'brand'|t(user.language) }}{% endblock %} {% block content %}
暂无邀请码
+{{ 'invites.title'|t(user.language) }}
暂无明信片
- {% endif %} -没有账号?去注册
+{{ 'login.no_account'|t(user.language if user else 'zh') }}{{ 'login.go_register'|t(user.language if user else 'zh') }}
邀请码由已有用户或管理员提供
+{{ 'register.hint'|t(user.language if user else 'zh') }}
{% if error %}已有账号?去登录
+{{ 'register.has_account'|t(user.language if user else 'zh') }}{{ 'register.go_login'|t(user.language if user else 'zh') }}
- 公开展示页:/u/{{ user.username }} + {{ 'settings.public_url_label'|t(user.language) }}/u/{{ user.username }}
暂无展示内容
+{{ 'showcase.empty'|t(lang) }}
- 公开展示页地址:/u/{{ user.username }} + {{ 'showcase_manage.public_url'|t(user.language) }}/u/{{ user.username }}
单张明信片展示控制(关闭展示后可单独隐藏):
+{{ 'profile_showcase.single_control'|t(user.language) }}:
{% for pc in pc_list %}暂无 Profile,请先创建
+{{ 'profiles.new_ph'|t(user.language) }}