feat(ui): redesign dashboard as global overview with stats, progress bar, recent activity

This commit is contained in:
2026-07-05 22:32:16 +08:00
parent a57cba22d5
commit a4714db073
3 changed files with 179 additions and 63 deletions

View File

@@ -148,20 +148,35 @@ def root(user: User = Depends(get_current_user_web)):
@router.get("/dashboard", response_class=HTMLResponse) @router.get("/dashboard", response_class=HTMLResponse)
def dashboard(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)): def dashboard(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
profiles = db.query(Profile).filter(Profile.user_id == user.id).all() profiles = db.query(Profile).filter(Profile.user_id == user.id).all()
stats = {} all_cards = []
extras = {}
for p in profiles: for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).order_by(Postcard.created_at.desc()).all() cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
stats[p.id] = { all_cards.extend(cards)
"total": len(cards),
"sent": sum(1 for c in cards if c.status == "sent"), total = len(all_cards)
"delivered": sum(1 for c in cards if c.status == "delivered"), sent = sum(1 for c in all_cards if c.status == "sent")
"received": sum(1 for c in cards if c.status == "received"), delivered = sum(1 for c in all_cards if c.status == "delivered")
} received = sum(1 for c in all_cards if c.status == "received")
countries = sorted({c.country for c in cards if c.country}) countries = len({c.country for c in all_cards if c.country})
recent = cards[:3]
extras[p.id] = {"countries": countries, "recent": recent} # recent postcards across all profiles (last 8)
return templates.TemplateResponse("dashboard.html", {"request": request, "user": user, "profiles": profiles, "stats": stats, "extras": extras}) all_cards.sort(key=lambda c: c.created_at, reverse=True)
recent = all_cards[:8]
# status distribution for progress bar
if total > 0:
sent_pct = round(sent / total * 100)
delivered_pct = round(delivered / total * 100)
received_pct = round(received / total * 100)
else:
sent_pct = delivered_pct = received_pct = 0
return templates.TemplateResponse("dashboard.html", {
"request": request, "user": user, "profiles": profiles,
"total": total, "sent": sent, "delivered": delivered, "received": received,
"countries": countries, "recent": recent,
"sent_pct": sent_pct, "delivered_pct": delivered_pct, "received_pct": received_pct,
})
# ---------- Profiles ---------- # ---------- Profiles ----------

View File

@@ -142,6 +142,43 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; } .tab.active { color: var(--primary); border-bottom-color: var(--primary); font-weight: 600; }
.tab:hover { color: var(--primary); } .tab:hover { color: var(--primary); }
/* Dashboard */
.dash-stats { display: flex; gap: .75rem; margin-bottom: 1.5rem; flex-wrap: wrap; }
.dash-stat-card { display: flex; align-items: center; gap: .75rem; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 1rem 1.25rem; flex: 1; min-width: 140px; }
.dash-stat-icon { font-size: 1.5rem; }
.dash-stat-body { display: flex; flex-direction: column; }
.dash-stat-value { font-size: 1.4rem; font-weight: 700; line-height: 1.2; }
.dash-stat-label { font-size: .75rem; color: var(--text-muted); }
.dash-sent { color: var(--sent); }
.dash-delivered { color: var(--delivered); }
.dash-received { color: var(--received); }
.dash-progress { margin-bottom: 2rem; }
.progress-bar { display: flex; height: 8px; border-radius: 4px; overflow: hidden; background: var(--border); }
.progress-seg { height: 100%; }
.progress-sent { background: var(--sent); }
.progress-delivered { background: var(--delivered); }
.progress-received { background: var(--received); }
.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-sent { background: var(--sent); }
.dot-delivered { background: var(--delivered); }
.dot-received { background: var(--received); }
.dash-sections { display: grid; grid-template-columns: 1.5fr 1fr; gap: 1.5rem; }
.dash-section h2 { font-size: 1rem; margin-bottom: .75rem; }
.recent-list { display: flex; flex-direction: column; gap: .4rem; }
.recent-item { display: flex; align-items: center; gap: .75rem; padding: .6rem .75rem; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); text-decoration: none; color: var(--text); transition: box-shadow .15s; }
.recent-item:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); text-decoration: none; }
.recent-img { width: 40px; height: 40px; border-radius: 6px; overflow: hidden; border: 1px solid var(--border); flex-shrink: 0; display: flex; align-items: center; justify-content: center; background: var(--bg); }
.recent-img img { width: 100%; height: 100%; object-fit: cover; }
.recent-info { display: flex; align-items: center; gap: .5rem; }
.recent-meta { margin-left: auto; display: flex; gap: .5rem; font-size: .8rem; color: var(--text-muted); white-space: nowrap; }
.dash-profiles { display: flex; flex-direction: column; gap: .3rem; }
.dash-profile-link { display: flex; align-items: center; justify-content: space-between; padding: .6rem .75rem; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); text-decoration: none; color: var(--text); transition: box-shadow .15s; }
.dash-profile-link:hover { box-shadow: 0 2px 8px rgba(0,0,0,.06); text-decoration: none; }
.dash-profile-name { font-weight: 500; }
.dash-profile-arrow { color: var(--text-muted); }
.dash-profile-more { color: var(--primary); border-style: dashed; }
/* Profile cards */ /* Profile cards */
.profile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; } .profile-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(320px, 1fr)); gap: 1rem; }
.profile-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; transition: box-shadow .15s; } .profile-card { background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; padding: 1.25rem; transition: box-shadow .15s; }

View File

@@ -1,56 +1,120 @@
{% extends "base.html" %} {% extends "base.html" %}
{% block title %}Dashboard - Postcard Manager{% endblock %} {% block title %}Dashboard - Postcard Manager{% endblock %}
{% block content %} {% block content %}
<h1>👋 欢迎,{{ user.username }}</h1> <div class="section-header">
<h1>👋 欢迎,{{ user.username }}</h1>
</div>
<section class="section"> {% if profiles %}
{% if profiles %} <div class="dash-stats">
<div class="card-grid"> <div class="dash-stat-card">
{% for p in profiles %} <div class="dash-stat-icon">📮</div>
{% set ex = extras[p.id] %} <div class="dash-stat-body">
<a href="/profiles/{{ p.id }}/postcards" class="card dashboard-card"> <span class="dash-stat-value">{{ total }}</span>
<div class="dc-header"> <span class="dash-stat-label">全部明信片</span>
<span class="dc-icon">📮</span> </div>
<h3>{{ p.nickname }}</h3>
</div>
<div class="dc-stats">
<div class="dc-stat">
<span class="dc-num">{{ stats[p.id].total }}</span>
<span class="dc-label">总计</span>
</div>
<div class="dc-stat">
<span class="dc-num dc-sent">{{ stats[p.id].sent }}</span>
<span class="dc-label">寄出</span>
</div>
<div class="dc-stat">
<span class="dc-num dc-delivered">{{ stats[p.id].delivered }}</span>
<span class="dc-label">送达</span>
</div>
<div class="dc-stat">
<span class="dc-num dc-received">{{ stats[p.id].received }}</span>
<span class="dc-label">收到</span>
</div>
</div>
{% if ex.countries %}
<div class="dc-tags">
{% for c in ex.countries %}
<span class="dc-tag">{{ c }}</span>
{% endfor %}
</div>
{% endif %}
{% if ex.recent %}
<div class="dc-recent">
<span class="dc-recent-label">最近</span>
{% for c in ex.recent %}
<span class="dc-recent-item">{{ c.card_number }}</span>
{% endfor %}
</div>
{% endif %}
</a>
{% endfor %}
</div> </div>
{% else %} <div class="dash-stat-card">
<p class="empty">还没有 Profile<a href="/profiles">去创建一个</a></p> <div class="dash-stat-icon">📤</div>
{% endif %} <div class="dash-stat-body">
</section> <span class="dash-stat-value dash-sent">{{ sent }}</span>
<span class="dash-stat-label">已寄出</span>
</div>
</div>
<div class="dash-stat-card">
<div class="dash-stat-icon">📬</div>
<div class="dash-stat-body">
<span class="dash-stat-value dash-delivered">{{ delivered }}</span>
<span class="dash-stat-label">已送达</span>
</div>
</div>
<div class="dash-stat-card">
<div class="dash-stat-icon">💌</div>
<div class="dash-stat-body">
<span class="dash-stat-value dash-received">{{ received }}</span>
<span class="dash-stat-label">已收到</span>
</div>
</div>
<div class="dash-stat-card">
<div class="dash-stat-icon">🌍</div>
<div class="dash-stat-body">
<span class="dash-stat-value">{{ countries }}</span>
<span class="dash-stat-label">国家/地区</span>
</div>
</div>
</div>
{% if total > 0 %}
<div class="dash-progress">
<div class="progress-bar">
<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-received" style="width:{{ received_pct }}%"></div>
</div>
<div class="progress-legend">
<span><i class="dot dot-sent"></i>已寄出 {{ sent_pct }}%</span>
<span><i class="dot dot-delivered"></i>已送达 {{ delivered_pct }}%</span>
<span><i class="dot dot-received"></i>已收到 {{ received_pct }}%</span>
</div>
</div>
{% endif %}
<div class="dash-sections">
<div class="dash-section">
<h2>最近动态</h2>
{% if recent %}
<div class="recent-list">
{% for pc in recent %}
<a href="/postcards/{{ pc.id }}" class="recent-item">
<div class="recent-img">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="">
{% else %}
<span>📷</span>
{% endif %}
</div>
<div class="recent-info">
<strong>{{ pc.card_number }}</strong>
<span class="badge badge-{{ pc.status }}">{{ {'sent':'已寄出','delivered':'已送达','received':'已收到'}.get(pc.status) }}</span>
</div>
<div class="recent-meta">
{% if pc.status in ('sent','delivered') %}
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
<span>→ {{ pc.recipient_name or '-' }}</span>
{% else %}
<span>← {{ pc.sender_name or '-' }}</span>
{% endif %}
</div>
</a>
{% endfor %}
</div>
{% else %}
<p class="empty">暂无明信片</p>
{% endif %}
</div>
<div class="dash-section">
<h2>Profiles</h2>
<div class="dash-profiles">
{% for p in profiles %}
<a href="/profiles/{{ p.id }}/postcards" class="dash-profile-link">
<span class="dash-profile-name">{{ p.nickname }}</span>
<span class="dash-profile-arrow"></span>
</a>
{% endfor %}
<a href="/profiles" class="dash-profile-link dash-profile-more">
<span class="dash-profile-name">管理全部</span>
<span class="dash-profile-arrow"></span>
</a>
</div>
</div>
</div>
{% else %}
<div class="empty-state">
<div class="empty-icon">📮</div>
<p>欢迎使用明信片管理器!</p>
<p class="empty-sub">创建一个 <a href="/profiles">Profile</a> 开始管理你的明信片</p>
</div>
{% endif %}
{% endblock %} {% endblock %}