Compare commits
18
Commits
f27c263dd4
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0955c36f6b | ||
|
|
d2a864c7ca | ||
|
|
41dedff238 | ||
|
|
b309144349 | ||
|
|
373db831c8 | ||
|
|
12de30d91f | ||
|
|
64adbba302 | ||
|
|
996770020f | ||
|
|
7da337cc4d | ||
|
|
c23de38b21 | ||
|
|
ae1a23143e | ||
|
|
64dfa71bd0 | ||
|
|
6624985014 | ||
|
|
e7396de1a4 | ||
|
|
b0e69fc123 | ||
|
|
8771d105bb | ||
|
|
1c53209acf | ||
|
|
b80a1859a0 |
No files matched your search
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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 */
|
||||||
|
|||||||
@@ -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,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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [] %}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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) }} *';
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 = [] %}
|
||||||
|
|||||||
@@ -6,3 +6,4 @@ jinja2
|
|||||||
bcrypt
|
bcrypt
|
||||||
python-jose[cryptography]
|
python-jose[cryptography]
|
||||||
aiofiles
|
aiofiles
|
||||||
|
boto3
|
||||||
Reference in New Issue
Block a user