Compare commits

..
18 Commits
Author SHA1 Message Date
earthjasonlin 0955c36f6b fix(dashboard): received country pie chart now groups by country_from (寄出国) 2026-08-21 14:44:22 +08:00
earthjasonlin d2a864c7ca feat(postcard): add optional send_time field to received card form 2026-08-21 14:34:43 +08:00
earthjasonlin 41dedff238 feat(postcard): disable send_time when status is pending, auto-fill on mark-sent 2026-08-01 21:36:51 +08:00
earthjasonlin b309144349 fix(postcard): mark-sent also uses fetch+reload, stays on current page 2026-07-28 07:58:14 +08:00
earthjasonlin 373db831c8 fix(postcard): mark-delivered stays on current page via fetch+reload instead of redirect 2026-07-28 07:54:23 +08:00
earthjasonlin 12de30d91f feat(dashboard): add pending as separate stat card and progress bar segment 2026-07-28 07:46:42 +08:00
earthjasonlin 64adbba302 fix(dashboard): include pending in progress bar and stats count 2026-07-28 07:43:39 +08:00
earthjasonlin 996770020f feat(dashboard): change country charts from bar to pie with legend 2026-07-28 07:41:16 +08:00
earthjasonlin 7da337cc4d feat(profiles): add pending count to profile card stats 2026-07-28 07:37:44 +08:00
earthjasonlin c23de38b21 fix(dashboard): convert UTC times to Beijing time before today/yesterday delta calculation 2026-07-28 07:29:12 +08:00
earthjasonlin ae1a23143e feat(ui): optimize landing page and showcase for mobile (hamburger nav, 2-col grid, compact lightbox) 2026-07-18 09:41:42 +08:00
earthjasonlin 64dfa71bd0 chore(ui): update favicon icon 2026-07-18 09:22:29 +08:00
earthjasonlin 6624985014 chore: remove stray favicon.ico from project root 2026-07-18 09:21:13 +08:00
earthjasonlin e7396de1a4 feat(ui): add favicon.ico route and link tag in base template 2026-07-18 09:21:07 +08:00
earthjasonlin b0e69fc123 fix(cache): move Cache-Control middleware before mounts using @app.middleware decorator 2026-07-18 09:16:45 +08:00
earthjasonlin 8771d105bb chore(deps): add boto3 to requirements.txt 2026-07-18 09:13:01 +08:00
earthjasonlin 1c53209acf fix(templates): replace all TemplateResponse with _render to bypass Jinja2 unhashable dict cache bug 2026-07-18 09:03:55 +08:00
earthjasonlin b80a1859a0 fix(templates): fix unhashable dict in Jinja2 cache by converting available_languages global to function 2026-07-18 08:57:42 +08:00
16 changed files with 197 additions and 140 deletions

No files matched your search

+1 -1
View File
@@ -1,5 +1,5 @@
@echo off @echo off
cd /d "%~dp0" cd /d "%~dp0"
call .venv\Scripts\activate.bat call .venv\Scripts\activate.bat
@rem python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload python -m uvicorn app.main:app --host 0.0.0.0 --port 8000 --reload
pause pause
+14 -9
View File
@@ -1,8 +1,8 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI, Request, Response from fastapi import FastAPI
from fastapi.responses import FileResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from starlette.middleware.base import BaseHTTPMiddleware
from app.config import UPLOAD_DIR from app.config import UPLOAD_DIR
from app.database import init_db from app.database import init_db
@@ -11,20 +11,25 @@ from app.routers import web, api
app = FastAPI(title="Mailova 星笺") app = FastAPI(title="Mailova 星笺")
UPLOAD_DIR.mkdir(exist_ok=True) UPLOAD_DIR.mkdir(exist_ok=True)
app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static") STATIC_DIR = Path(__file__).resolve().parent / "static"
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
# Browser cache: 30 days for uploaded images (they never change) # Cache-Control for uploaded images (30 days, immutable — filenames contain UUID)
class CacheControlMiddleware(BaseHTTPMiddleware): @app.middleware("http")
async def dispatch(self, request: Request, call_next): async def cache_control_middleware(request, call_next):
response: Response = await call_next(request) response = await call_next(request)
if request.url.path.startswith("/uploads/"): if request.url.path.startswith("/uploads/"):
response.headers["Cache-Control"] = "public, max-age=2592000, immutable" response.headers["Cache-Control"] = "public, max-age=2592000, immutable"
return response return response
app.add_middleware(CacheControlMiddleware) @app.get("/favicon.ico")
async def favicon():
return FileResponse(STATIC_DIR / "favicon.ico", media_type="image/x-icon")
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
app.include_router(web.router) app.include_router(web.router)
app.include_router(api.router) app.include_router(api.router)
+62 -67
View File
@@ -91,7 +91,19 @@ from app.storage import resolve_url as _resolve_image_url
templates.env.filters["image_url"] = lambda v: _resolve_image_url(v) if v else "" templates.env.filters["image_url"] = lambda v: _resolve_image_url(v) if v else ""
from app.i18n import get_languages as _get_languages from app.i18n import get_languages as _get_languages
templates.env.globals["available_languages"] = _get_languages() templates.env.globals["get_languages"] = _get_languages
def _render(request: Request, name: str, ctx: dict) -> HTMLResponse:
"""Render a Jinja2 template and return HTMLResponse.
Bypasses Starlette TemplateResponse to avoid a Jinja2 cache bug where
the context dict is passed as ``globals`` and used as an unhashable
cache key (``TypeError: unhashable type: 'dict'``).
"""
ctx["request"] = request
html = templates.get_template(name).render(ctx)
return HTMLResponse(html)
def _get_copyright() -> str: def _get_copyright() -> str:
@@ -151,8 +163,7 @@ def index_page(request: Request, db: Session = Depends(get_db)):
if not lang or lang not in avail: if not lang or lang not in avail:
lang = "zh" lang = "zh"
return templates.TemplateResponse("index.html", { return _render(request, "index.html", {"postcards": all_postcards[:20], "lang": lang,
"request": request, "postcards": all_postcards[:20], "lang": lang,
"has_more": len(all_postcards) > 20, "has_more": len(all_postcards) > 20,
}) })
@@ -248,7 +259,7 @@ def login_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db) user = redirect_if_not_logged_in(request, db)
if user: if user:
return _redirect("/dashboard") return _redirect("/dashboard")
return templates.TemplateResponse("login.html", {"request": request}) return _render(request, "login.html", {"request": request})
@router.post("/login") @router.post("/login")
@@ -263,8 +274,7 @@ def login_submit(
cleanup_expired_sessions(db) cleanup_expired_sessions(db)
user = db.query(User).filter(User.username == username).first() user = db.query(User).filter(User.username == username).first()
if not user or not verify_password(password, user.password_hash): if not user or not verify_password(password, user.password_hash):
return templates.TemplateResponse( return _render(request, "login.html", {"error": "用户名或密码错误"}, status_code=401
"login.html", {"request": request, "error": "用户名或密码错误"}, status_code=401
) )
is_remember = remember_me == "on" is_remember = remember_me == "on"
token = create_session(user.id, db, remember_me=is_remember) token = create_session(user.id, db, remember_me=is_remember)
@@ -283,7 +293,7 @@ def register_page(request: Request, db: Session = Depends(get_db)):
user = redirect_if_not_logged_in(request, db) user = redirect_if_not_logged_in(request, db)
if user: if user:
return _redirect("/dashboard") return _redirect("/dashboard")
return templates.TemplateResponse("register.html", {"request": request}) return _render(request, "register.html", {"request": request})
@router.post("/register") @router.post("/register")
@@ -298,30 +308,24 @@ def register_submit(
# Validate invite code # Validate invite code
code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first() code_obj = db.query(InviteCode).filter(InviteCode.code == invite_code).first()
if not code_obj: if not code_obj:
return templates.TemplateResponse( return _render(request, "register.html", {"error": "邀请码无效"}, status_code=400
"register.html", {"request": request, "error": "邀请码无效"}, status_code=400
) )
if code_obj.expires_at and code_obj.expires_at < datetime.utcnow(): if code_obj.expires_at and code_obj.expires_at < datetime.utcnow():
return templates.TemplateResponse( return _render(request, "register.html", {"error": "邀请码已过期"}, status_code=400
"register.html", {"request": request, "error": "邀请码已过期"}, status_code=400
) )
if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses: if code_obj.max_uses is not None and code_obj.use_count >= code_obj.max_uses:
return templates.TemplateResponse( return _render(request, "register.html", {"error": "邀请码使用次数已用尽"}, status_code=400
"register.html", {"request": request, "error": "邀请码使用次数已用尽"}, status_code=400
) )
# Validate passwords # Validate passwords
if password != confirm_password: if password != confirm_password:
return templates.TemplateResponse( return _render(request, "register.html", {"error": "两次输入的密码不一致"}, status_code=400
"register.html", {"request": request, "error": "两次输入的密码不一致"}, status_code=400
) )
if len(password) < 6: if len(password) < 6:
return templates.TemplateResponse( return _render(request, "register.html", {"error": "密码至少6个字符"}, status_code=400
"register.html", {"request": request, "error": "密码至少6个字符"}, status_code=400
) )
# Check username # Check username
if db.query(User).filter(User.username == username).first(): if db.query(User).filter(User.username == username).first():
return templates.TemplateResponse( return _render(request, "register.html", {"error": "用户名已存在"}, status_code=400
"register.html", {"request": request, "error": "用户名已存在"}, status_code=400
) )
# Create user # Create user
new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id) new_user = User(username=username, password_hash=hash_password(password), invited_by_code_id=code_obj.id)
@@ -359,7 +363,9 @@ def _admin_context(user, db, active_tab="invites", **extra):
def _admin_render(request, user, db, active_tab="invites", **extra): def _admin_render(request, user, db, active_tab="invites", **extra):
return templates.TemplateResponse("admin.html", _admin_context(user, db, active_tab, request=request, **extra)) ctx = _admin_context(user, db, active_tab, **extra)
ctx["request"] = request
return _render(request, "admin.html", ctx)
@router.get("/admin", response_class=HTMLResponse) @router.get("/admin", response_class=HTMLResponse)
@@ -501,8 +507,7 @@ def settings_page(
for p in profiles: for p in profiles:
profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all() profile_cards[p.id] = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return templates.TemplateResponse("settings.html", { return _render(request, "settings.html", {"user": user, "profiles": profiles,
"request": request, "user": user, "profiles": profiles,
"profile_cards": profile_cards, "active_tab": active_tab, "profile_cards": profile_cards, "active_tab": active_tab,
"api_keys": api_keys, "api_keys": api_keys,
}) })
@@ -516,13 +521,11 @@ def change_username(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
if db.query(User).filter(User.username == new_username, User.id != user.id).first(): if db.query(User).filter(User.username == new_username, User.id != user.id).first():
return templates.TemplateResponse( return _render(request, "settings.html", {"user": user, "error": "用户名已存在"}, status_code=400
"settings.html", {"request": request, "user": user, "error": "用户名已存在"}, status_code=400
) )
user.username = new_username user.username = new_username
db.commit() db.commit()
return templates.TemplateResponse( return _render(request, "settings.html", {"user": user, "success": "用户名修改成功"}
"settings.html", {"request": request, "user": user, "success": "用户名修改成功"}
) )
@@ -536,21 +539,17 @@ def change_password(
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
if not verify_password(old_password, user.password_hash): if not verify_password(old_password, user.password_hash):
return templates.TemplateResponse( return _render(request, "settings.html", {"user": user, "error": "当前密码错误"}, status_code=400
"settings.html", {"request": request, "user": user, "error": "当前密码错误"}, status_code=400
) )
if new_password != confirm_password: if new_password != confirm_password:
return templates.TemplateResponse( return _render(request, "settings.html", {"user": user, "error": "两次输入的新密码不一致"}, status_code=400
"settings.html", {"request": request, "user": user, "error": "两次输入的新密码不一致"}, status_code=400
) )
if len(new_password) < 6: if len(new_password) < 6:
return templates.TemplateResponse( return _render(request, "settings.html", {"user": user, "error": "新密码长度至少6位"}, status_code=400
"settings.html", {"request": request, "user": user, "error": "新密码长度至少6位"}, status_code=400
) )
user.password_hash = hash_password(new_password) user.password_hash = hash_password(new_password)
db.commit() db.commit()
return templates.TemplateResponse( return _render(request, "settings.html", {"user": user, "success": "密码修改成功"}
"settings.html", {"request": request, "user": user, "success": "密码修改成功"}
) )
@@ -594,6 +593,7 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
all_cards.extend(cards) all_cards.extend(cards)
total = len(all_cards) total = len(all_cards)
pending = sum(1 for c in all_cards if c.status == "pending")
sent = sum(1 for c in all_cards if c.status == "sent") sent = sum(1 for c in all_cards if c.status == "sent")
delivered = sum(1 for c in all_cards if c.status == "delivered") delivered = sum(1 for c in all_cards if c.status == "delivered")
received = sum(1 for c in all_cards if c.status == "received") received = sum(1 for c in all_cards if c.status == "received")
@@ -605,34 +605,36 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
# status distribution for progress bar # status distribution for progress bar
if total > 0: if total > 0:
pending_pct = round(pending / total * 100)
sent_pct = round(sent / total * 100) sent_pct = round(sent / total * 100)
delivered_pct = round(delivered / total * 100) delivered_pct = round(delivered / total * 100)
received_pct = round(received / total * 100) received_pct = round(received / total * 100)
else: else:
sent_pct = delivered_pct = received_pct = 0 pending_pct = sent_pct = delivered_pct = received_pct = 0
# country stats split by sent/received # country stats split by sent/received
sent_country_counts: dict[str, int] = {} sent_country_counts: dict[str, int] = {}
received_country_counts: dict[str, int] = {} received_country_counts: dict[str, int] = {}
for c in all_cards: for c in all_cards:
if c.status in ("pending", "sent", "delivered"):
if not c.country_to: if not c.country_to:
continue continue
if c.status in ("pending", "sent", "delivered"):
sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1 sent_country_counts[c.country_to] = sent_country_counts.get(c.country_to, 0) + 1
elif c.status == "received": elif c.status == "received":
received_country_counts[c.country_to] = received_country_counts.get(c.country_to, 0) + 1 if not c.country_from:
continue
received_country_counts[c.country_from] = received_country_counts.get(c.country_from, 0) + 1
sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1]) sent_country_stats = sorted(sent_country_counts.items(), key=lambda x: -x[1])
received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1]) received_country_stats = sorted(received_country_counts.items(), key=lambda x: -x[1])
from datetime import datetime, timezone as _tz from datetime import datetime, timezone as _tz
return templates.TemplateResponse("dashboard.html", { return _render(request, "dashboard.html", {"user": user, "profiles": profiles,
"request": request, "user": user, "profiles": profiles, "total": total, "pending": pending, "sent": sent, "delivered": delivered, "received": received,
"total": total, "sent": sent, "delivered": delivered, "received": received,
"countries": countries, "recent": recent, "countries": countries, "recent": recent,
"sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct, "pending_pct": pending_pct, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct,
"sent_country_stats": sent_country_stats, "received_country_stats": received_country_stats, "sent_country_stats": sent_country_stats, "received_country_stats": received_country_stats,
"now": datetime.now(_tz.utc), "now": datetime.now(_tz.utc) + timedelta(hours=8),
}) })
@@ -646,12 +648,13 @@ def profile_list(request: Request, user: User = Depends(get_current_user_web), d
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all() cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
extras[p.id] = { extras[p.id] = {
"total": len(cards), "total": len(cards),
"pending": sum(1 for c in cards if c.status == "pending"),
"sent": sum(1 for c in cards if c.status == "sent"), "sent": sum(1 for c in cards if c.status == "sent"),
"delivered": sum(1 for c in cards if c.status == "delivered"), "delivered": sum(1 for c in cards if c.status == "delivered"),
"received": sum(1 for c in cards if c.status == "received"), "received": sum(1 for c in cards if c.status == "received"),
"countries": len({c.country_to for c in cards if c.country_to}), "countries": len({c.country_to for c in cards if c.country_to}),
} }
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras}) return _render(request, "profiles.html", {"user": user, "profiles": profiles, "extras": extras})
@router.post("/profiles") @router.post("/profiles")
@@ -719,9 +722,7 @@ def postcard_list(
sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")] sent = [pc for pc in postcards if pc.status in ("pending", "sent", "delivered")]
received = [pc for pc in postcards if pc.status == "received"] received = [pc for pc in postcards if pc.status == "received"]
return templates.TemplateResponse( return _render(request, "postcards.html", {"user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country},
"postcards.html",
{"request": request, "user": user, "profile": profile, "sent": sent, "received": received, "countries": all_countries, "country": country},
) )
@@ -731,7 +732,7 @@ def postcard_new(request: Request, profile_id: int, create_type: str = Query("se
if not profile: if not profile:
return _redirect("/profiles") return _redirect("/profiles")
today = datetime.now().strftime("%Y-%m-%d") today = datetime.now().strftime("%Y-%m-%d")
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today}) return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None, "create_type": create_type, "today": today})
@router.post("/profiles/{profile_id}/postcards") @router.post("/profiles/{profile_id}/postcards")
@@ -760,8 +761,7 @@ def postcard_create(
Postcard.card_number == cn, Profile.user_id == user.id Postcard.card_number == cn, Profile.user_id == user.id
).first() ).first()
if dup: if dup:
return templates.TemplateResponse("postcard_form.html", { return _render(request, "postcard_form.html", {"user": user, "profile": profile, "postcard": None,
"request": request, "user": user, "profile": profile, "postcard": None,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
}) })
@@ -805,7 +805,7 @@ def postcard_detail(request: Request, postcard_id: int, user: User = Depends(get
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
return templates.TemplateResponse("postcard_detail.html", {"request": request, "user": user, "postcard": pc}) return _render(request, "postcard_detail.html", {"user": user, "postcard": pc})
@router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse) @router.get("/postcards/{postcard_id}/edit", response_class=HTMLResponse)
@@ -818,7 +818,7 @@ def postcard_edit(request: Request, postcard_id: int, user: User = Depends(get_c
) )
if not pc: if not pc:
return _redirect("/profiles") return _redirect("/profiles")
return templates.TemplateResponse("postcard_form.html", {"request": request, "user": user, "profile": pc.profile, "postcard": pc}) return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc})
@router.post("/postcards/{postcard_id}/edit") @router.post("/postcards/{postcard_id}/edit")
@@ -853,8 +853,7 @@ def postcard_update(
Profile.user_id == user.id Profile.user_id == user.id
).first() ).first()
if dup: if dup:
return templates.TemplateResponse("postcard_form.html", { return _render(request, "postcard_form.html", {"user": user, "profile": pc.profile, "postcard": pc,
"request": request, "user": user, "profile": pc.profile, "postcard": pc,
"error": _t("pc_list.duplicate", user.language), "error": _t("pc_list.duplicate", user.language),
}) })
@@ -901,7 +900,7 @@ def postcard_delete(postcard_id: int, user: User = Depends(get_current_user_web)
@router.post("/postcards/{postcard_id}/mark-delivered") @router.post("/postcards/{postcard_id}/mark-delivered")
def mark_delivered( async def mark_delivered(
postcard_id: int, postcard_id: int,
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -913,16 +912,16 @@ def mark_delivered(
.first() .first()
) )
if not pc: if not pc:
return _redirect("/profiles") return JSONResponse({"ok": False}, status_code=404)
pc.status = "delivered" pc.status = "delivered"
if not pc.arrival_time: if not pc.arrival_time:
pc.arrival_time = datetime.now() pc.arrival_time = datetime.now()
db.commit() db.commit()
return _redirect(f"/postcards/{postcard_id}") return JSONResponse({"ok": True})
@router.post("/postcards/{postcard_id}/mark-sent") @router.post("/postcards/{postcard_id}/mark-sent")
def mark_sent( async def mark_sent(
postcard_id: int, postcard_id: int,
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
@@ -935,12 +934,12 @@ def mark_sent(
.first() .first()
) )
if not pc: if not pc:
return _redirect("/profiles") return JSONResponse({"ok": False}, status_code=404)
pc.status = "sent" pc.status = "sent"
if not pc.send_time: if not pc.send_time:
pc.send_time = datetime.now() pc.send_time = datetime.now()
db.commit() db.commit()
return _redirect(f"/postcards/{postcard_id}") return JSONResponse({"ok": True})
# ---------- Image Upload ---------- # ---------- Image Upload ----------
@@ -1000,8 +999,7 @@ def profile_showcase_page(
if not p: if not p:
return _redirect("/settings?tab=showcase") return _redirect("/settings?tab=showcase")
cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all() cards = db.query(Postcard).filter(Postcard.profile_id == profile_id).all()
return templates.TemplateResponse("profile_showcase.html", { return _render(request, "profile_showcase.html", {"user": user, "profile": p, "cards": cards,
"request": request, "user": user, "profile": p, "cards": cards,
}) })
@@ -1073,8 +1071,7 @@ def public_showcase(request: Request, username: str, db: Session = Depends(get_d
profile_groups.append({"profile": p, "sent": p_sent, "received": p_received}) profile_groups.append({"profile": p, "sent": p_sent, "received": p_received})
sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True) sent_all.sort(key=lambda c: c.send_time or c.created_at, reverse=True)
received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True) received_all.sort(key=lambda c: c.receive_time or c.created_at, reverse=True)
resp = templates.TemplateResponse("showcase.html", { resp = _render(request, "showcase.html", {"profile_user": user, "mode": user.showcase_mode,
"request": request, "profile_user": user, "mode": user.showcase_mode,
"profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all, "profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all,
"lang": visitor_lang, "available_languages": _avail, "lang": visitor_lang, "available_languages": _avail,
}) })
@@ -1104,8 +1101,7 @@ def apikey_create(
db.commit() db.commit()
db.refresh(ak) db.refresh(ak)
api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all() api_keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).order_by(ApiKey.created_at.desc()).all()
return templates.TemplateResponse("settings.html", { return _render(request, "settings.html", {"user": user, "active_tab": "apikeys",
"request": request, "user": user, "active_tab": "apikeys",
"api_keys": api_keys, "new_key": ak.key, "success": "API Key created", "api_keys": api_keys, "new_key": ak.key, "success": "API Key created",
}) })
@@ -1153,6 +1149,5 @@ def api_docs_page(
user: User = Depends(get_current_user_web), user: User = Depends(get_current_user_web),
db: Session = Depends(get_db), db: Session = Depends(get_db),
): ):
return templates.TemplateResponse("api_docs.html", { return _render(request, "api_docs.html", {"user": user,
"request": request, "user": user,
}) })
Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

+35 -9
View File
@@ -186,17 +186,20 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.dash-stat-body { display: flex; flex-direction: column; } .dash-stat-body { display: flex; flex-direction: column; }
.dash-stat-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; } .dash-stat-value { font-size: 1.15rem; font-weight: 700; line-height: 1.2; }
.dash-stat-label { font-size: .68rem; color: var(--text-muted); } .dash-stat-label { font-size: .68rem; color: var(--text-muted); }
.dash-pending { color: var(--primary); }
.dash-sent { color: var(--sent); } .dash-sent { color: var(--sent); }
.dash-delivered { color: var(--delivered); } .dash-delivered { color: var(--delivered); }
.dash-received { color: var(--received); } .dash-received { color: var(--received); }
.dash-progress { margin-bottom: 2rem; } .dash-progress { margin-bottom: 2rem; }
.progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); } .progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
.progress-seg { height: 100%; } .progress-seg { height: 100%; }
.progress-pending { background: var(--primary); }
.progress-sent { background: var(--sent); } .progress-sent { background: var(--sent); }
.progress-delivered { background: var(--delivered); } .progress-delivered { background: var(--delivered); }
.progress-received { background: var(--received); } .progress-received { background: var(--received); }
.progress-legend { display: flex; gap: 1.25rem; margin-top: .5rem; font-size: .8rem; color: var(--text-muted); } .progress-legend { display: flex; gap: 1.25rem; margin-top: .5rem; font-size: .8rem; color: var(--text-muted); }
.dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .25rem; vertical-align: middle; } .dot { display: inline-block; width: 8px; height: 8px; border-radius: 50%; margin-right: .25rem; vertical-align: middle; }
.dot-pending { background: var(--primary); }
.dot-sent { background: var(--sent); } .dot-sent { background: var(--sent); }
.dot-delivered { background: var(--delivered); } .dot-delivered { background: var(--delivered); }
.dot-received { background: var(--received); } .dot-received { background: var(--received); }
@@ -229,6 +232,7 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.stat-value { font-size: 1.1rem; font-weight: 700; } .stat-value { font-size: 1.1rem; font-weight: 700; }
.stat-label { font-size: .75rem; color: var(--text-muted); } .stat-label { font-size: .75rem; color: var(--text-muted); }
.badge-sent-text { color: var(--sent); } .badge-sent-text { color: var(--sent); }
.badge-pending-text { color: var(--primary); }
.badge-delivered-text { color: var(--delivered); } .badge-delivered-text { color: var(--delivered); }
.badge-received-text { color: var(--received); } .badge-received-text { color: var(--received); }
.profile-card-footer { display: flex; align-items: center; justify-content: space-between; } .profile-card-footer { display: flex; align-items: center; justify-content: space-between; }
@@ -302,26 +306,48 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.detail-grid { grid-template-columns: 1fr; } .detail-grid { grid-template-columns: 1fr; }
.card-grid { grid-template-columns: 1fr; } .card-grid { grid-template-columns: 1fr; }
.postcard-row-info { flex-wrap: wrap; gap: .35rem; } .postcard-row-info { flex-wrap: wrap; gap: .35rem; }
.navbar { padding: .5rem 1rem; }
.nav-links { gap: .75rem; } /* Navbar */
.nav-links a { font-size: .8rem; } .navbar { padding: .5rem .75rem; }
.container { margin: 1rem auto; padding: 0 1rem; } .nav-brand { font-size: .9rem; }
.nav-links { gap: .5rem; }
.nav-links a { font-size: .78rem; }
.nav-links .btn-sm { padding: .3rem .6rem; font-size: .75rem; }
.container { margin: 1rem auto; padding: 0 .75rem; }
/* Dashboard */
.dash-stats { display: grid; grid-template-columns: 1fr 1fr; } .dash-stats { display: grid; grid-template-columns: 1fr 1fr; }
.dash-stat-card { min-width: 0; } .dash-stat-card { min-width: 0; }
.dash-sections { grid-template-columns: 1fr; } .dash-sections { grid-template-columns: 1fr; }
.recent-meta > span:nth-child(-n+2) { display: none; } .recent-meta > span:nth-child(-n+2) { display: none; }
.recent-meta .recent-time { display: inline; } .recent-meta .recent-time { display: inline; }
.recent-item { gap: .5rem; padding: .5rem; } .recent-item { gap: .5rem; padding: .5rem; }
.section-header { flex-direction: column; gap: .75rem; }
/* Section header */
.section-header { flex-direction: column; gap: .5rem; text-align: center; }
.section-header .btn { width: auto; } .section-header .btn { width: auto; }
/* Detail */
.detail-title-row h1 { font-size: 1.2rem; } .detail-title-row h1 { font-size: 1.2rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; } .btn-back { padding: .25rem .5rem; font-size: .85rem; }
/* Profiles */
.profile-grid { grid-template-columns: 1fr; } .profile-grid { grid-template-columns: 1fr; }
.profile-card-stats { gap: .75rem; flex-wrap: wrap; } .profile-card-stats { gap: .75rem; flex-wrap: wrap; }
.showcase-grid { grid-template-columns: repeat(auto-fill, minmax(140px, 1fr)); gap: .5rem; }
.showcase-card-meta { flex-wrap: wrap; } /* Showcase / Landing grid — 2 columns on mobile */
.detail-title-row h1 { font-size: 1.2rem; } .showcase-grid { grid-template-columns: repeat(2, 1fr); gap: .5rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; } .showcase-card-body { padding: .5rem; }
.showcase-card-meta { font-size: .72rem; flex-wrap: wrap; gap: .25rem; }
/* Lightbox — tighter on mobile */
.lightbox-content { max-width: 95vw; }
.lightbox-content img { max-width: 95vw; max-height: 75vh; }
.lightbox-prev, .lightbox-next { font-size: 1.4rem; padding: .4rem .6rem; }
.lightbox-prev { left: .5rem; }
.lightbox-next { right: .5rem; }
.lightbox-close { top: .5rem; right: .75rem; font-size: 1.6rem; }
} }
/* Admin invite form */ /* Admin invite form */
+6
View File
@@ -88,7 +88,13 @@ class S3Storage:
region: str = "auto", region: str = "auto",
public_url: str = "", public_url: str = "",
) -> None: ) -> None:
try:
import boto3 import boto3
except ImportError:
raise ImportError(
"boto3 is required for S3 storage. "
"Install it with: pip install boto3"
)
self.bucket = bucket self.bucket = bucket
self.public_url = public_url.rstrip("/") self.public_url = public_url.rstrip("/")
+2 -1
View File
@@ -2,6 +2,7 @@
<html lang="zh-CN"> <html lang="zh-CN">
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<link rel="icon" href="/favicon.ico" type="image/x-icon">
<meta name="viewport" content="width=device-width, initial-scale=1.0"> <meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}星笺{% endblock %}</title> <title>{% block title %}星笺{% endblock %}</title>
<link rel="stylesheet" href="/static/style.css"> <link rel="stylesheet" href="/static/style.css">
@@ -23,7 +24,7 @@
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in available_languages.items() %} {% for code, name in get_languages().items() %}
<form method="post" action="/settings/language"> <form method="post" action="/settings/language">
<input type="hidden" name="language" value="{{ code }}"> <input type="hidden" name="language" value="{{ code }}">
<button type="submit" class="lang-option {% if user.language == code %}active{% endif %}">{{ name }}</button> <button type="submit" class="lang-option {% if user.language == code %}active{% endif %}">{{ name }}</button>
+21 -9
View File
@@ -14,6 +14,13 @@
<span class="dash-stat-label">{{ 'dash.total_cards'|t(user.language) }}</span> <span class="dash-stat-label">{{ 'dash.total_cards'|t(user.language) }}</span>
</div> </div>
</div> </div>
<div class="dash-stat-card">
<div class="dash-stat-icon">📋</div>
<div class="dash-stat-body">
<span class="dash-stat-value dash-pending">{{ pending }}</span>
<span class="dash-stat-label">{{ 'status.pending'|t(user.language) }}</span>
</div>
</div>
<div class="dash-stat-card"> <div class="dash-stat-card">
<div class="dash-stat-icon">📤</div> <div class="dash-stat-icon">📤</div>
<div class="dash-stat-body"> <div class="dash-stat-body">
@@ -47,11 +54,13 @@
{% if total > 0 %} {% if total > 0 %}
<div class="dash-progress"> <div class="dash-progress">
<div class="progress-bar"> <div class="progress-bar">
<div class="progress-seg progress-pending" style="width:{{ pending_pct }}%"></div>
<div class="progress-seg progress-sent" style="width:{{ sent_pct }}%"></div> <div class="progress-seg progress-sent" style="width:{{ sent_pct }}%"></div>
<div class="progress-seg progress-delivered" style="width:{{ delivered_pct }}%"></div> <div class="progress-seg progress-delivered" style="width:{{ delivered_pct }}%"></div>
<div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div> <div class="progress-seg progress-received" style="width:{{ received_pct }}%"></div>
</div> </div>
<div class="progress-legend"> <div class="progress-legend">
<span><i class="dot dot-pending"></i>{{ 'status.pending'|t(user.language) }} {{ pending_pct }}%</span>
<span><i class="dot dot-sent"></i>{{ 'dash.sent'|t(user.language) }} {{ sent_pct }}%</span> <span><i class="dot dot-sent"></i>{{ 'dash.sent'|t(user.language) }} {{ sent_pct }}%</span>
<span><i class="dot dot-delivered"></i>{{ 'dash.delivered'|t(user.language) }} {{ delivered_pct }}%</span> <span><i class="dot dot-delivered"></i>{{ 'dash.delivered'|t(user.language) }} {{ delivered_pct }}%</span>
<span><i class="dot dot-received"></i>{{ 'dash.received'|t(user.language) }} {{ received_pct }}%</span> <span><i class="dot dot-received"></i>{{ 'dash.received'|t(user.language) }} {{ received_pct }}%</span>
@@ -78,22 +87,22 @@
<script> <script>
{% if sent_country_stats %} {% if sent_country_stats %}
new Chart(document.getElementById('chart-sent'), { new Chart(document.getElementById('chart-sent'), {
type: 'bar', type: 'pie',
data: { data: {
labels: [{% for code, _ in sent_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}], labels: [{% for code, _ in sent_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}],
datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#3b82f6', borderRadius: 4, maxBarThickness: 40 }] datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: ['#3b82f6','#f59e0b','#10b981','#8b5cf6','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }]
}, },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } } options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } }
}); });
{% endif %} {% endif %}
{% if received_country_stats %} {% if received_country_stats %}
new Chart(document.getElementById('chart-received'), { new Chart(document.getElementById('chart-received'), {
type: 'bar', type: 'pie',
data: { data: {
labels: [{% for code, _ in received_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}], labels: [{% for code, _ in received_country_stats %}'{{ code|flag }} {{ code }}',{% endfor %}],
datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#8b5cf6', borderRadius: 4, maxBarThickness: 40 }] datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: ['#8b5cf6','#3b82f6','#f59e0b','#10b981','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }]
}, },
options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } } options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } }
}); });
{% endif %} {% endif %}
</script> </script>
@@ -135,13 +144,16 @@ new Chart(document.getElementById('chart-received'), {
<div class="recent-meta" style="margin-left:0"> <div class="recent-meta" style="margin-left:0">
<span class="recent-time"> <span class="recent-time">
{% if pc.status == 'sent' and pc.send_time %} {% if pc.status == 'sent' and pc.send_time %}
{% set delta = (now.date() - pc.send_time.date()).days %} {% set local_time = pc.send_time|to_local %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% elif pc.status == 'delivered' and pc.arrival_time %} {% elif pc.status == 'delivered' and pc.arrival_time %}
{% set delta = (now.date() - pc.arrival_time.date()).days %} {% set local_time = pc.arrival_time|to_local %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% elif pc.status == 'received' and pc.receive_time %} {% elif pc.status == 'received' and pc.receive_time %}
{% set delta = (now.date() - pc.receive_time.date()).days %} {% set local_time = pc.receive_time|to_local %}
{% set delta = (now.date() - local_time.date()).days %}
{% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %} {% if delta == 0 %}{{ 'dash.today'|t(user.language) }}{% elif delta == 1 %}{{ 'dash.yesterday'|t(user.language) }}{% else %}{{ delta }}{{ 'dash.days_ago'|t(user.language) }}{% endif %}
{% endif %} {% endif %}
</span> </span>
+6 -5
View File
@@ -3,24 +3,25 @@
{% block nav %} {% block nav %}
<nav class="navbar"> <nav class="navbar">
<a href="/" class="nav-brand">{{ 'brand'|t(lang) }}</a> <a href="/" class="nav-brand">{{ 'brand'|t(lang) }}</a>
<button class="nav-hamburger" onclick="this.nextElementSibling.classList.toggle('open')"></button>
<div class="nav-links"> <div class="nav-links">
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in available_languages.items() %} {% for code, name in get_languages().items() %}
<a href="/?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a> <a href="/?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<a href="/login" class="btn btn-sm" style="margin-left:.5rem">{{ 'index.go_login'|t(lang) }}</a> <a href="/login" class="btn btn-sm">{{ 'index.go_login'|t(lang) }}</a>
<a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a> <a href="/register" class="btn btn-primary btn-sm">{{ 'index.go_register'|t(lang) }}</a>
</div> </div>
</nav> </nav>
{% endblock %} {% endblock %}
{% block content %} {% block content %}
<div class="section-header"> <div class="section-header" style="text-align:center">
<h1>📮 {{ 'index.title'|t(lang) }}</h1> <h1 style="font-size:1.3rem">📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p> <p style="color:var(--text-muted);font-size:.85rem;margin-top:.15rem">{{ 'index.subtitle'|t(lang) }}</p>
</div> </div>
{% set visible = [] %} {% set visible = [] %}
+2 -6
View File
@@ -10,13 +10,9 @@
<div class="detail-actions"> <div class="detail-actions">
<a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a> <a href="/postcards/{{ postcard.id }}/edit" class="btn">{{ 'pc_detail.edit'|t(user.language) }}</a>
{% if postcard.status == 'pending' %} {% if postcard.status == 'pending' %}
<form method="post" action="/postcards/{{ postcard.id }}/mark-sent" class="inline"> <button type="button" class="btn btn-primary" onclick="if(confirm('{{ 'pc_detail.confirm_sent'|t(user.language) }}'))fetch('/postcards/{{ postcard.id }}/mark-sent',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_detail.mark_sent'|t(user.language) }}</button>
<button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_sent'|t(user.language) }}')">{{ 'pc_detail.mark_sent'|t(user.language) }}</button>
</form>
{% elif postcard.status == 'sent' %} {% elif postcard.status == 'sent' %}
<form method="post" action="/postcards/{{ postcard.id }}/mark-delivered" class="inline"> <button type="button" class="btn btn-primary" onclick="if(confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}'))fetch('/postcards/{{ postcard.id }}/mark-delivered',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button>
<button type="submit" class="btn btn-primary" onclick="return confirm('{{ 'pc_detail.confirm_delivered'|t(user.language) }}')">{{ 'pc_detail.mark_delivered'|t(user.language) }}</button>
</form>
{% endif %} {% endif %}
<form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline"> <form method="post" action="/postcards/{{ postcard.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger" onclick="return confirm('{{ 'pc_detail.confirm_delete'|t(user.language) }}')">{{ 'pc_detail.delete'|t(user.language) }}</button> <button type="submit" class="btn btn-danger" onclick="return confirm('{{ 'pc_detail.confirm_delete'|t(user.language) }}')">{{ 'pc_detail.delete'|t(user.language) }}</button>
+29 -15
View File
@@ -23,6 +23,9 @@
<label>{{ 'pc_form.sender'|t(user.language) }} *</label> <label>{{ 'pc_form.sender'|t(user.language) }} *</label>
<input type="text" name="sender_name" value="{{ postcard.sender_name if is_edit else '' }}"> <input type="text" name="sender_name" value="{{ postcard.sender_name if is_edit else '' }}">
<label>{{ 'pc_form.send_time'|t(user.language) }}</label>
<input type="date" name="send_time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else '' }}">
<label>{{ 'pc_form.receive_time'|t(user.language) }} *</label> <label>{{ 'pc_form.receive_time'|t(user.language) }} *</label>
<input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}"> <input type="date" name="receive_time" value="{{ postcard.receive_time.strftime('%Y-%m-%d') if is_edit and postcard.receive_time else today }}">
@@ -55,7 +58,7 @@
<div id="fields-sent-delivered"> <div id="fields-sent-delivered">
<label>{{ 'pc_form.recipient'|t(user.language) }} *</label> <label>{{ 'pc_form.recipient'|t(user.language) }} *</label>
<input type="text" name="recipient_name" value="{{ postcard.recipient_name if is_edit else '' }}"> <input type="text" name="recipient_name" value="{{ postcard.recipient_name if is_edit else '' }}">
<label>{{ 'pc_form.send_time'|t(user.language) }} *</label> <label id="label-send-time">{{ 'pc_form.send_time'|t(user.language) }} *</label>
<input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else (today if is_new else '') }}"> <input type="date" name="send_time" id="input-send-time" value="{{ postcard.send_time.strftime('%Y-%m-%d') if is_edit and postcard.send_time else (today if is_new else '') }}">
<label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label> <label id="label-arrival">{{ 'pc_form.arrival_time'|t(user.language) }}</label>
<input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if is_edit and postcard.arrival_time else '' }}" {% if is_new or (is_edit and edit_status=='sent') %}disabled{% endif %}> <input type="date" name="arrival_time" id="input-arrival" value="{{ postcard.arrival_time.strftime('%Y-%m-%d') if is_edit and postcard.arrival_time else '' }}" {% if is_new or (is_edit and edit_status=='sent') %}disabled{% endif %}>
@@ -79,21 +82,25 @@
</form> </form>
<script> <script>
{% if is_new and not is_received_form %} {% if is_new and not is_received_form %}
// ── 新建寄出:根据日期自动判定状态,可手动覆盖 ── // ── 新建寄出:待寄出时禁用寄出时间,寄出时自动填入 ──
(function() { (function() {
var sendTime = document.getElementById('input-send-time'); var sendTime = document.getElementById('input-send-time');
var select = document.getElementById('status-select'); var select = document.getElementById('status-select');
var arrival = document.getElementById('input-arrival'); var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival'); var labelArrival = document.getElementById('label-arrival');
var labelSend = document.getElementById('label-send-time');
var today = new Date().toISOString().slice(0, 10); var today = new Date().toISOString().slice(0, 10);
function updateSendTime(s) {
if (s === 'pending') {
sendTime.disabled = true;
sendTime.value = '';
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }}';
} else {
sendTime.disabled = false;
if (!sendTime.value) sendTime.value = today; if (!sendTime.value) sendTime.value = today;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }} *';
var userOverrode = false; }
function autoFromSendTime() {
var v = sendTime.value;
if (!v) return 'sent';
return v > today ? 'pending' : 'sent';
} }
function updateArrival(s) { function updateArrival(s) {
@@ -109,22 +116,29 @@
} }
function sync() { function sync() {
var auto = autoFromSendTime();
if (!userOverrode) select.value = auto;
updateArrival(select.value); updateArrival(select.value);
updateSendTime(select.value);
} }
select.addEventListener('change', function() { userOverrode = true; updateArrival(select.value); }); select.addEventListener('change', sync);
sendTime.addEventListener('change', function() { if (!userOverrode) sync(); });
sendTime.addEventListener('input', function() { if (!userOverrode) sync(); });
sync(); sync();
})(); })();
{% elif is_edit and not is_received_form %} {% elif is_edit and not is_received_form %}
// ── 编辑寄出:根据状态切换 arrival 可用性 ── // ── 编辑寄出:根据状态切换 send_time / arrival 可用性 ──
function toggleFields() { function toggleFields() {
var s = document.getElementById('status-select').value; var s = document.getElementById('status-select').value;
var sendTime = document.getElementById('input-send-time');
var labelSend = document.getElementById('label-send-time');
var arrival = document.getElementById('input-arrival'); var arrival = document.getElementById('input-arrival');
var labelArrival = document.getElementById('label-arrival'); var labelArrival = document.getElementById('label-arrival');
var today = new Date().toISOString().slice(0, 10);
if (s === 'pending') {
sendTime.disabled = true;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }}';
} else {
sendTime.disabled = false;
if (labelSend) labelSend.textContent = '{{ "pc_form.send_time"|t(user.language) }} *';
}
if (s === 'delivered') { if (s === 'delivered') {
arrival.disabled = false; arrival.disabled = false;
labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *'; labelArrival.textContent = '{{ "pc_form.arrival_time"|t(user.language) }} *';
+2 -6
View File
@@ -56,13 +56,9 @@
{% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %} {% if pc.notes %}<span class="postcard-row-notes">{{ pc.notes[:50] }}{% if pc.notes|length > 50 %}…{% endif %}</span>{% endif %}
<div class="postcard-row-action"> <div class="postcard-row-action">
{% if pc.status == 'pending' %} {% if pc.status == 'pending' %}
<form method="post" action="/postcards/{{ pc.id }}/mark-sent" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_sent'|t(user.language) }}'))this.submit();"> <button type="button" class="btn btn-sm btn-primary" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_sent'|t(user.language) }}'))fetch('/postcards/{{ pc.id }}/mark-sent',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_list.mark_sent'|t(user.language) }}</button>
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_sent'|t(user.language) }}</button>
</form>
{% elif pc.status == 'sent' %} {% elif pc.status == 'sent' %}
<form method="post" action="/postcards/{{ pc.id }}/mark-delivered" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))this.submit();"> <button type="button" class="btn btn-sm btn-primary" onclick="event.preventDefault();event.stopPropagation();if(confirm('{{ 'pc_list.confirm_delivered'|t(user.language) }}'))fetch('/postcards/{{ pc.id }}/mark-delivered',{method:'POST'}).then(function(){location.reload();})">{{ 'pc_list.mark_delivered'|t(user.language) }}</button>
<button type="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_delivered'|t(user.language) }}</button>
</form>
{% endif %} {% endif %}
</div> </div>
</a> </a>
+4
View File
@@ -34,6 +34,10 @@
<span class="stat-value">{{ e.total }}</span> <span class="stat-value">{{ e.total }}</span>
<span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span> <span class="stat-label">{{ 'profiles.total'|t(user.language) }}</span>
</div> </div>
<div class="stat">
<span class="stat-value badge-pending-text">{{ e.pending }}</span>
<span class="stat-label">{{ 'status.pending'|t(user.language) }}</span>
</div>
<div class="stat"> <div class="stat">
<span class="stat-value badge-sent-text">{{ e.sent }}</span> <span class="stat-value badge-sent-text">{{ e.sent }}</span>
<span class="stat-label">{{ 'dash.sent'|t(user.language) }}</span> <span class="stat-label">{{ 'dash.sent'|t(user.language) }}</span>
+1 -1
View File
@@ -51,7 +51,7 @@
<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">
{% for code, name in available_languages.items() %} {% for code, name in get_languages().items() %}
<option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option> <option value="{{ code }}" {% if user.language == code %}selected{% endif %}>{{ name }}</option>
{% endfor %} {% endfor %}
</select> </select>
+3 -3
View File
@@ -6,14 +6,14 @@
<div class="lang-switcher" id="langSwitcher"> <div class="lang-switcher" id="langSwitcher">
<button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button> <button type="button" class="lang-globe" onclick="document.getElementById('langDropdown').classList.toggle('show')">🌐</button>
<div class="lang-dropdown" id="langDropdown"> <div class="lang-dropdown" id="langDropdown">
{% for code, name in available_languages.items() %} {% for code, name in get_languages().items() %}
<a href="?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a> <a href="?lang={{ code }}" class="lang-option {% if lang == code %}active{% endif %}">{{ name }}</a>
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
</div> </div>
<div class="section-header"> <div class="section-header" style="text-align:center">
<h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1> <h1 style="font-size:1.3rem">📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</div> </div>
{% set sent_visible = [] %} {% set sent_visible = [] %}
+1
View File
@@ -6,3 +6,4 @@ jinja2
bcrypt bcrypt
python-jose[cryptography] python-jose[cryptography]
aiofiles aiofiles
boto3