feat(ui): redesign profiles page with card layout showing postcard stats
This commit is contained in:
@@ -169,7 +169,17 @@ def dashboard(request: Request, user: User = Depends(get_current_user_web), db:
|
|||||||
@router.get("/profiles", response_class=HTMLResponse)
|
@router.get("/profiles", response_class=HTMLResponse)
|
||||||
def profile_list(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
|
def profile_list(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()
|
||||||
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles})
|
extras = {}
|
||||||
|
for p in profiles:
|
||||||
|
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
|
||||||
|
extras[p.id] = {
|
||||||
|
"total": len(cards),
|
||||||
|
"sent": sum(1 for c in cards if c.status == "sent"),
|
||||||
|
"delivered": sum(1 for c in cards if c.status == "delivered"),
|
||||||
|
"received": sum(1 for c in cards if c.status == "received"),
|
||||||
|
"countries": len({c.country for c in cards if c.country}),
|
||||||
|
}
|
||||||
|
return templates.TemplateResponse("profiles.html", {"request": request, "user": user, "profiles": profiles, "extras": extras})
|
||||||
|
|
||||||
|
|
||||||
@router.post("/profiles")
|
@router.post("/profiles")
|
||||||
|
|||||||
@@ -142,6 +142,30 @@ 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); }
|
||||||
|
|
||||||
|
/* Profile cards */
|
||||||
|
.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:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); }
|
||||||
|
.profile-card-header { margin-bottom: 1rem; }
|
||||||
|
.profile-card-name { font-size: 1.1rem; font-weight: 700; display: flex; align-items: center; gap: .4rem; }
|
||||||
|
.icon-btn { background: none; border: none; cursor: pointer; font-size: .85rem; padding: 0; opacity: .5; transition: opacity .15s; }
|
||||||
|
.icon-btn:hover { opacity: 1; }
|
||||||
|
.profile-card-stats { display: flex; gap: 1.25rem; margin-bottom: 1rem; padding: .75rem 0; border-top: 1px solid var(--border); border-bottom: 1px solid var(--border); }
|
||||||
|
.stat { display: flex; flex-direction: column; align-items: center; }
|
||||||
|
.stat-value { font-size: 1.1rem; font-weight: 700; }
|
||||||
|
.stat-label { font-size: .75rem; color: var(--text-muted); }
|
||||||
|
.badge-sent-text { color: var(--sent); }
|
||||||
|
.badge-delivered-text { color: var(--delivered); }
|
||||||
|
.badge-received-text { color: var(--received); }
|
||||||
|
.profile-card-footer { display: flex; align-items: center; justify-content: space-between; }
|
||||||
|
.profile-card-date { font-size: .8rem; color: var(--text-muted); }
|
||||||
|
.profile-card-actions { display: flex; gap: .5rem; align-items: center; }
|
||||||
|
.rename-form { display: inline-flex; gap: .35rem; align-items: center; }
|
||||||
|
.rename-form input { width: 140px; padding: .25rem .5rem; font-size: .85rem; }
|
||||||
|
.empty-state { text-align: center; padding: 3rem 1rem; color: var(--text-muted); }
|
||||||
|
.empty-icon { font-size: 3rem; margin-bottom: .75rem; }
|
||||||
|
.empty-sub { font-size: .85rem; margin-top: .25rem; }
|
||||||
|
|
||||||
/* Postcard row */
|
/* Postcard row */
|
||||||
.postcard-row { display: flex; align-items: center; gap: 1rem; padding: .75rem; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); margin-bottom: .5rem; text-decoration: none; color: var(--text); transition: box-shadow .15s; }
|
.postcard-row { display: flex; align-items: center; gap: 1rem; padding: .75rem; border: 1px solid var(--border); border-radius: 8px; background: var(--card-bg); margin-bottom: .5rem; text-decoration: none; color: var(--text); transition: box-shadow .15s; }
|
||||||
.postcard-row:hover { box-shadow: 0 2px 8px rgba(0,0,0,.08); text-decoration: none; }
|
.postcard-row:hover { box-shadow: 0 2px 8px rgba(0,0,0,.08); text-decoration: none; }
|
||||||
|
|||||||
@@ -7,48 +7,77 @@
|
|||||||
<div class="inline-form">
|
<div class="inline-form">
|
||||||
<form method="post" action="/profiles" class="form-row">
|
<form method="post" action="/profiles" class="form-row">
|
||||||
<input type="text" name="nickname" placeholder="新 Profile 昵称" required>
|
<input type="text" name="nickname" placeholder="新 Profile 昵称" required>
|
||||||
<button type="submit" class="btn btn-primary">添加</button>
|
<button type="submit" class="btn btn-primary">+ 新建</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
{% if profiles %}
|
{% if profiles %}
|
||||||
<table>
|
<div class="profile-grid">
|
||||||
<thead><tr><th>昵称</th><th>创建时间</th><th>操作</th></tr></thead>
|
|
||||||
<tbody>
|
|
||||||
{% for p in profiles %}
|
{% for p in profiles %}
|
||||||
<tr>
|
{% set e = extras[p.id] %}
|
||||||
<td>
|
<div class="profile-card">
|
||||||
<span class="nickname-display" id="name-display-{{ p.id }}">{{ p.nickname }}</span>
|
<div class="profile-card-header">
|
||||||
<form method="post" action="/profiles/{{ p.id }}/rename" class="inline nickname-edit" id="name-edit-{{ p.id }}" style="display:none">
|
<h3 class="profile-card-name">
|
||||||
|
{{ p.nickname }}
|
||||||
|
<button type="button" class="icon-btn" onclick="startRename({{ p.id }})" title="改名">✏️</button>
|
||||||
|
</h3>
|
||||||
|
<form method="post" action="/profiles/{{ p.id }}/rename" class="rename-form" id="name-edit-{{ p.id }}" style="display:none">
|
||||||
<input type="text" name="nickname" value="{{ p.nickname }}" required>
|
<input type="text" name="nickname" value="{{ p.nickname }}" required>
|
||||||
<button type="submit" class="btn btn-primary btn-sm">确定</button>
|
<button type="submit" class="btn btn-primary btn-sm">✓</button>
|
||||||
<button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})">取消</button>
|
<button type="button" class="btn btn-sm" onclick="cancelRename({{ p.id }})">✕</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</div>
|
||||||
<td>{{ p.created_at.strftime('%Y-%m-%d %H:%M') }}</td>
|
<div class="profile-card-stats">
|
||||||
<td>
|
<div class="stat">
|
||||||
<a href="/profiles/{{ p.id }}/postcards" class="btn btn-sm">查看</a>
|
<span class="stat-value">{{ e.total }}</span>
|
||||||
<button type="button" class="btn btn-sm" onclick="startRename({{ p.id }})">改名</button>
|
<span class="stat-label">总计</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value badge-sent-text">{{ e.sent }}</span>
|
||||||
|
<span class="stat-label">已寄出</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value badge-delivered-text">{{ e.delivered }}</span>
|
||||||
|
<span class="stat-label">已送达</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value badge-received-text">{{ e.received }}</span>
|
||||||
|
<span class="stat-label">已收到</span>
|
||||||
|
</div>
|
||||||
|
<div class="stat">
|
||||||
|
<span class="stat-value">{{ e.countries }}</span>
|
||||||
|
<span class="stat-label">国家</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="profile-card-footer">
|
||||||
|
<span class="profile-card-date">创建于 {{ p.created_at.strftime('%Y-%m-%d') }}</span>
|
||||||
|
<div class="profile-card-actions">
|
||||||
|
<a href="/profiles/{{ p.id }}/postcards" class="btn btn-primary btn-sm">查看明信片</a>
|
||||||
<form method="post" action="/profiles/{{ p.id }}/delete" class="inline">
|
<form method="post" action="/profiles/{{ p.id }}/delete" class="inline">
|
||||||
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除该 Profile 及其所有明信片?')">删除</button>
|
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除「{{ p.nickname }}」及其所有明信片?')">删除</button>
|
||||||
</form>
|
</form>
|
||||||
</td>
|
</div>
|
||||||
</tr>
|
</div>
|
||||||
|
</div>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</div>
|
||||||
</table>
|
|
||||||
{% else %}
|
{% else %}
|
||||||
<p class="empty">还没有 Profile。</p>
|
<div class="empty-state">
|
||||||
|
<div class="empty-icon">📮</div>
|
||||||
|
<p>还没有 Profile</p>
|
||||||
|
<p class="empty-sub">创建一个 Profile 来管理你的明信片吧</p>
|
||||||
|
</div>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<script>
|
<script>
|
||||||
function startRename(id) {
|
function startRename(id) {
|
||||||
document.getElementById('name-display-' + id).style.display = 'none';
|
document.getElementById('name-display-' + id).style.display = 'none';
|
||||||
var edit = document.getElementById('name-edit-' + id);
|
var form = document.getElementById('name-edit-' + id);
|
||||||
edit.style.display = 'inline-flex';
|
form.style.display = 'inline-flex';
|
||||||
edit.querySelector('input').focus();
|
form.querySelector('input').focus();
|
||||||
}
|
}
|
||||||
function cancelRename(id) {
|
function cancelRename(id) {
|
||||||
|
var form = document.getElementById('name-edit-' + id);
|
||||||
|
form.style.display = 'none';
|
||||||
document.getElementById('name-display-' + id).style.display = '';
|
document.getElementById('name-display-' + id).style.display = '';
|
||||||
document.getElementById('name-edit-' + id).style.display = 'none';
|
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
Reference in New Issue
Block a user