Compare commits

..
0 Commits
14 changed files with 72 additions and 139 deletions

No files matched your search

+9 -14
View File
@@ -1,8 +1,8 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request, Response
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,25 +11,20 @@ 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)
STATIC_DIR = Path(__file__).resolve().parent / "static" app.mount("/static", StaticFiles(directory=str(Path(__file__).resolve().parent / "static")), name="static")
app.mount("/uploads", StaticFiles(directory=str(UPLOAD_DIR)), name="uploads")
# Cache-Control for uploaded images (30 days, immutable — filenames contain UUID) # Browser cache: 30 days for uploaded images (they never change)
@app.middleware("http") class CacheControlMiddleware(BaseHTTPMiddleware):
async def cache_control_middleware(request, call_next): async def dispatch(self, request: Request, call_next):
response = await call_next(request) response: 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.get("/favicon.ico") app.add_middleware(CacheControlMiddleware)
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)
+12 -17
View File
@@ -593,7 +593,6 @@ 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,36 +604,33 @@ 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:
pending_pct = sent_pct = delivered_pct = received_pct = 0 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":
if not c.country_from: received_country_counts[c.country_to] = received_country_counts.get(c.country_to, 0) + 1
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 _render(request, "dashboard.html", {"user": user, "profiles": profiles, return _render(request, "dashboard.html", {"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,
"pending_pct": pending_pct, "sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_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) + timedelta(hours=8), "now": datetime.now(_tz.utc),
}) })
@@ -648,7 +644,6 @@ 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"),
@@ -900,7 +895,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")
async def mark_delivered( 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),
@@ -912,16 +907,16 @@ async def mark_delivered(
.first() .first()
) )
if not pc: if not pc:
return JSONResponse({"ok": False}, status_code=404) return _redirect("/profiles")
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 JSONResponse({"ok": True}) return _redirect(f"/postcards/{postcard_id}")
@router.post("/postcards/{postcard_id}/mark-sent") @router.post("/postcards/{postcard_id}/mark-sent")
async def mark_sent( 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),
@@ -934,12 +929,12 @@ async def mark_sent(
.first() .first()
) )
if not pc: if not pc:
return JSONResponse({"ok": False}, status_code=404) return _redirect("/profiles")
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 JSONResponse({"ok": True}) return _redirect(f"/postcards/{postcard_id}")
# ---------- Image Upload ---------- # ---------- Image Upload ----------
Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

+9 -35
View File
@@ -186,20 +186,17 @@ 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); }
@@ -232,7 +229,6 @@ 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; }
@@ -306,48 +302,26 @@ 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; }
/* Navbar */ .nav-links { gap: .75rem; }
.navbar { padding: .5rem .75rem; } .nav-links a { font-size: .8rem; }
.nav-brand { font-size: .9rem; } .container { margin: 1rem auto; padding: 0 1rem; }
.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 / Landing grid — 2 columns on mobile */ .showcase-card-meta { flex-wrap: wrap; }
.showcase-grid { grid-template-columns: repeat(2, 1fr); gap: .5rem; } .detail-title-row h1 { font-size: 1.2rem; }
.showcase-card-body { padding: .5rem; } .btn-back { padding: .25rem .5rem; font-size: .85rem; }
.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,13 +88,7 @@ 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("/")
-1
View File
@@ -2,7 +2,6 @@
<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">
+9 -21
View File
@@ -14,13 +14,6 @@
<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">
@@ -54,13 +47,11 @@
{% 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>
@@ -87,22 +78,22 @@
<script> <script>
{% if sent_country_stats %} {% if sent_country_stats %}
new Chart(document.getElementById('chart-sent'), { new Chart(document.getElementById('chart-sent'), {
type: 'pie', type: 'bar',
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','#f59e0b','#10b981','#8b5cf6','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }] datasets: [{ data: [{% for _, count in sent_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#3b82f6', borderRadius: 4, maxBarThickness: 40 }]
}, },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } } options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } }
}); });
{% endif %} {% endif %}
{% if received_country_stats %} {% if received_country_stats %}
new Chart(document.getElementById('chart-received'), { new Chart(document.getElementById('chart-received'), {
type: 'pie', type: 'bar',
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','#3b82f6','#f59e0b','#10b981','#ef4444','#06b6d4','#f97316','#84cc16','#ec4899','#6366f1'] }] datasets: [{ data: [{% for _, count in received_country_stats %}{{ count }},{% endfor %}], backgroundColor: '#8b5cf6', borderRadius: 4, maxBarThickness: 40 }]
}, },
options: { responsive: true, plugins: { legend: { position: 'bottom', labels: { padding: 12, usePointStyle: true } } } } options: { responsive: true, plugins: { legend: { display: false } }, scales: { y: { beginAtZero: true, ticks: { stepSize: 1 } }, x: { grid: { display: false } } } }
}); });
{% endif %} {% endif %}
</script> </script>
@@ -144,16 +135,13 @@ 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 local_time = pc.send_time|to_local %} {% set delta = (now.date() - pc.send_time.date()).days %}
{% 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 local_time = pc.arrival_time|to_local %} {% set delta = (now.date() - pc.arrival_time.date()).days %}
{% 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 local_time = pc.receive_time|to_local %} {% set delta = (now.date() - pc.receive_time.date()).days %}
{% 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>
+4 -5
View File
@@ -3,7 +3,6 @@
{% 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>
@@ -13,15 +12,15 @@
{% endfor %} {% endfor %}
</div> </div>
</div> </div>
<a href="/login" class="btn btn-sm">{{ 'index.go_login'|t(lang) }}</a> <a href="/login" class="btn btn-sm" style="margin-left:.5rem">{{ '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" style="text-align:center"> <div class="section-header">
<h1 style="font-size:1.3rem">📮 {{ 'index.title'|t(lang) }}</h1> <h1>📮 {{ 'index.title'|t(lang) }}</h1>
<p style="color:var(--text-muted);font-size:.85rem;margin-top:.15rem">{{ 'index.subtitle'|t(lang) }}</p> <p style="color:var(--text-muted);margin-top:.25rem">{{ 'index.subtitle'|t(lang) }}</p>
</div> </div>
{% set visible = [] %} {% set visible = [] %}
+6 -2
View File
@@ -10,9 +10,13 @@
<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' %}
<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> <form method="post" action="/postcards/{{ postcard.id }}/mark-sent" class="inline">
<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' %}
<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> <form method="post" action="/postcards/{{ postcard.id }}/mark-delivered" class="inline">
<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>
+15 -29
View File
@@ -23,9 +23,6 @@
<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 }}">
@@ -58,7 +55,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 id="label-send-time">{{ 'pc_form.send_time'|t(user.language) }} *</label> <label>{{ '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 %}>
@@ -82,25 +79,21 @@
</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) {
@@ -116,29 +109,22 @@
} }
function sync() { function sync() {
var auto = autoFromSendTime();
if (!userOverrode) select.value = auto;
updateArrival(select.value); updateArrival(select.value);
updateSendTime(select.value);
} }
select.addEventListener('change', sync); select.addEventListener('change', function() { userOverrode = true; updateArrival(select.value); });
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 %}
// ── 编辑寄出:根据状态切换 send_time / arrival 可用性 ── // ── 编辑寄出:根据状态切换 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) }} *';
+6 -2
View File
@@ -56,9 +56,13 @@
{% 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' %}
<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> <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="submit" class="btn btn-sm btn-primary">{{ 'pc_list.mark_sent'|t(user.language) }}</button>
</form>
{% elif pc.status == 'sent' %} {% elif pc.status == 'sent' %}
<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> <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="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,10 +34,6 @@
<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>
+2 -2
View File
@@ -12,8 +12,8 @@
</div> </div>
</div> </div>
</div> </div>
<div class="section-header" style="text-align:center"> <div class="section-header">
<h1 style="font-size:1.3rem">📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1> <h1>📮 {{ profile_user.username }} {{ 'showcase.title'|t(lang) }}</h1>
</div> </div>
{% set sent_visible = [] %} {% set sent_visible = [] %}
-1
View File
@@ -6,4 +6,3 @@ jinja2
bcrypt bcrypt
python-jose[cryptography] python-jose[cryptography]
aiofiles aiofiles
boto3
Reference in new issue
Block a user
Blocking a user prevents them from interacting with repositories, such as opening or commenting on pull requests or issues. Learn more about blocking a user.