feat(showcase): add public showcase page with per-profile/per-postcard visibility controls

This commit is contained in:
2026-07-05 23:45:23 +08:00
parent b412f5001e
commit 148cfdf2a4
6 changed files with 307 additions and 19 deletions

View File

@@ -456,26 +456,80 @@ async def upload_image(
return _redirect(f"/postcards/{postcard_id}")
# ---------- API Keys ----------
# ---------- Showcase ----------
@router.get("/api-keys", response_class=HTMLResponse)
def api_keys_page(request: Request, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
keys = db.query(ApiKey).filter(ApiKey.user_id == user.id).all()
return templates.TemplateResponse("api_keys.html", {"request": request, "user": user, "keys": keys})
@router.get("/showcase/manage", response_class=HTMLResponse)
def showcase_manage(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()
profile_cards = {}
for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id).all()
profile_cards[p.id] = cards
return templates.TemplateResponse("showcase_manage.html", {
"request": request, "user": user, "profiles": profiles, "profile_cards": profile_cards,
})
@router.post("/api-keys")
def api_key_create(user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
ak = ApiKey(user_id=user.id, key=ApiKey.generate_key())
db.add(ak)
db.commit()
return _redirect("/api-keys")
@router.post("/api-keys/{key_id}/delete")
def api_key_delete(key_id: int, user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
ak = db.query(ApiKey).filter(ApiKey.id == key_id, ApiKey.user_id == user.id).first()
if ak:
db.delete(ak)
@router.post("/showcase/manage")
def showcase_manage_save(request: Request, showcase_mode: str = Form("profile"), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
if showcase_mode in ("profile", "flat"):
user.showcase_mode = showcase_mode
db.commit()
return _redirect("/api-keys")
return _redirect("/showcase/manage")
@router.post("/profiles/{profile_id}/showcase")
def profile_showcase_save(
profile_id: int,
showcase_enabled: str = Form(""),
showcase_visible: str = Form("both"),
user: User = Depends(get_current_user_web),
db: Session = Depends(get_db),
):
p = db.query(Profile).filter(Profile.id == profile_id, Profile.user_id == user.id).first()
if p:
p.showcase_enabled = showcase_enabled == "1"
if showcase_visible in ("sent", "received", "both"):
p.showcase_visible = showcase_visible
db.commit()
return _redirect("/showcase/manage")
@router.post("/postcards/{postcard_id}/showcase-toggle")
def postcard_showcase_toggle(postcard_id: int, showcase_hidden: str = Form(""), user: User = Depends(get_current_user_web), db: Session = Depends(get_db)):
pc = db.query(Postcard).filter(Postcard.id == postcard_id).first()
if pc:
profile = db.query(Profile).filter(Profile.id == pc.profile_id, Profile.user_id == user.id).first()
if profile and profile.showcase_enabled:
pc.showcase_hidden = showcase_hidden == "1"
db.commit()
return _redirect("/showcase/manage")
@router.get("/u/{username}", response_class=HTMLResponse)
def public_showcase(request: Request, username: str, db: Session = Depends(get_db)):
user = db.query(User).filter(User.username == username).first()
if not user:
return HTMLResponse("用户不存在", status_code=404)
profiles = db.query(Profile).filter(Profile.user_id == user.id, Profile.showcase_enabled == True).all()
sent_all, received_all, profile_groups = [], [], []
for p in profiles:
cards = db.query(Postcard).filter(Postcard.profile_id == p.id, Postcard.showcase_hidden == False).all()
if p.showcase_visible in ("sent", "both"):
sent_all.extend([c for c in cards if c.status in ("sent", "delivered")])
if p.showcase_visible in ("received", "both"):
received_all.extend([c for c in cards if c.status == "received"])
if p.showcase_visible in ("sent", "both"):
p_sent = [c for c in cards if c.status in ("sent", "delivered")]
else:
p_sent = []
if p.showcase_visible in ("received", "both"):
p_received = [c for c in cards if c.status == "received"]
else:
p_received = []
if p_sent or p_received:
profile_groups.append({"profile": p, "sent": p_sent, "received": p_received})
return templates.TemplateResponse("showcase.html", {
"request": request, "profile_user": user, "mode": user.showcase_mode,
"profile_groups": profile_groups, "sent_all": sent_all, "received_all": received_all,
})

View File

@@ -226,6 +226,27 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
/* Empty state */
.empty { color: var(--text-muted); padding: 2rem; text-align: center; }
/* Toggle switch */
.toggle-switch { position: relative; display: inline-block; width: 36px; height: 20px; }
.toggle-switch input { opacity: 0; width: 0; height: 0; }
.toggle-slider { position: absolute; cursor: pointer; top: 0; left: 0; right: 0; bottom: 0; background: var(--border); border-radius: 20px; transition: .2s; }
.toggle-slider::before { content: ""; position: absolute; height: 16px; width: 16px; left: 2px; bottom: 2px; background: #fff; border-radius: 50%; transition: .2s; }
.toggle-switch input:checked + .toggle-slider { background: var(--primary); }
.toggle-switch input:checked + .toggle-slider::before { transform: translateX(16px); }
.toggle-sm { width: 30px; height: 18px; }
.toggle-sm .toggle-slider::before { height: 14px; width: 14px; }
.toggle-sm input:checked + .toggle-slider::before { transform: translateX(12px); }
/* Showcase */
.showcase-card { display: block; background: var(--card-bg); border: 1px solid var(--border); border-radius: 10px; overflow: hidden; transition: box-shadow .15s; text-decoration: none; color: var(--text); }
.showcase-card:hover { box-shadow: 0 4px 16px rgba(0,0,0,.08); text-decoration: none; }
.showcase-card-img { width: 100%; aspect-ratio: 3/2; object-fit: cover; display: block; background: var(--bg); }
.showcase-card-img.placeholder-img { display: flex; align-items: center; justify-content: center; font-size: 2rem; }
.showcase-card-body { padding: .75rem; }
.showcase-card-body strong { font-size: .9rem; }
.showcase-card-meta { display: flex; gap: .5rem; font-size: .8rem; color: var(--text-muted); margin-top: .25rem; }
.showcase-grid { display: grid; grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); gap: .75rem; margin-bottom: 1rem; }
/* Responsive */
@media (max-width: 640px) {
.detail-grid { grid-template-columns: 1fr; }
@@ -247,4 +268,8 @@ code { background: var(--bg); padding: .15rem .4rem; border-radius: 4px; font-si
.btn-back { padding: .25rem .5rem; font-size: .85rem; }
.profile-grid { grid-template-columns: 1fr; }
.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; }
.detail-title-row h1 { font-size: 1.2rem; }
.btn-back { padding: .25rem .5rem; font-size: .85rem; }
}

View File

@@ -52,6 +52,7 @@
<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>
<a href="/profiles/{{ p.id }}/showcase" class="btn btn-sm">展示管理</a>
<form method="post" action="/profiles/{{ p.id }}/delete" class="inline">
<button type="submit" class="btn btn-danger btn-sm" onclick="return confirm('确定删除「{{ p.nickname }}」及其所有明信片?')">删除</button>
</form>

View File

@@ -5,6 +5,13 @@
<h1>用户设置</h1>
</div>
<div class="form-card" style="margin-bottom:1.5rem">
<div style="display:flex;align-items:center;justify-content:space-between">
<h3>展示设置</h3>
<a href="/showcase/manage" class="btn btn-primary btn-sm">全局展示设置</a>
</div>
</div>
{% if success %}
<div class="alert alert-success">{{ success }}</div>
{% endif %}

123
app/templates/showcase.html Normal file
View File

@@ -0,0 +1,123 @@
{% extends "base.html" %}
{% block title %}{{ profile_user.username }} 的展示页 - 明信片管理器{% endblock %}
{% block content %}
<div class="section-header">
<h1>📮 {{ profile_user.username }} 的明信片</h1>
</div>
{% set has_content = (sent_all|length + received_all|length) > 0 %}
{% if not has_content %}
<div class="empty-state">
<p>暂无展示内容</p>
</div>
{% else %}
<div class="tabs" id="tabs">
<button class="tab active" onclick="switchTab('sent')">发出 ({{ sent_all|length }})</button>
<button class="tab" onclick="switchTab('received')">收到 ({{ received_all|length }})</button>
</div>
<div id="tab-sent" class="tab-panel">
{% if mode == 'flat' %}
<div class="showcase-grid">
{% for pc in sent_all %}
<div class="showcase-card">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
{% for g in profile_groups %}
{% if g.sent %}
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid">
{% for pc in g.sent %}
<div class="showcase-card">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.send_time %}<span>{{ pc.send_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
<div id="tab-received" class="tab-panel" style="display:none">
{% if mode == 'flat' %}
<div class="showcase-grid">
{% for pc in received_all %}
<div class="showcase-card">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% else %}
{% for g in profile_groups %}
{% if g.received %}
<h3 style="margin:1rem 0 .75rem;font-size:.95rem;color:var(--text-muted)">{{ g.profile.nickname }}</h3>
<div class="showcase-grid">
{% for pc in g.received %}
<div class="showcase-card">
{% if pc.image_front %}
<img src="{{ pc.image_front }}" alt="" class="showcase-card-img">
{% else %}
<div class="showcase-card-img placeholder-img">📷</div>
{% endif %}
<div class="showcase-card-body">
<strong>{{ pc.card_number }}</strong>
<div class="showcase-card-meta">
<span>{{ pc.country|flag }} {{ pc.country or '' }}</span>
{% if pc.receive_time %}<span>{{ pc.receive_time.strftime('%Y-%m-%d') }}</span>{% endif %}
</div>
</div>
</div>
{% endfor %}
</div>
{% endif %}
{% endfor %}
{% endif %}
</div>
<script>
function switchTab(name) {
document.querySelectorAll('.tab').forEach(function(t) { t.classList.remove('active'); });
document.querySelectorAll('.tab-panel').forEach(function(p) { p.style.display = 'none'; });
event.currentTarget.classList.add('active');
document.getElementById('tab-' + name).style.display = '';
}
</script>
{% endif %}
{% endblock %}

View File

@@ -0,0 +1,78 @@
{% extends "base.html" %}
{% block title %}展示管理 - 明信片管理器{% endblock %}
{% block content %}
<div class="section-header">
<div class="detail-title-row">
<a href="/profiles" class="btn btn-back"></a>
<h1>展示管理</h1>
</div>
</div>
<div class="form-card">
<h3 style="margin-bottom:.75rem">全局展示设置</h3>
<form method="post" action="/showcase/manage">
<label>展示页面模式</label>
<select name="showcase_mode">
<option value="profile" {% if user.showcase_mode == 'profile' %}selected{% endif %}>按 Profile 分类</option>
<option value="flat" {% if user.showcase_mode == 'flat' %}selected{% endif %}>全部合并</option>
</select>
<div class="form-actions">
<button type="submit" class="btn btn-primary">保存全局设置</button>
</div>
</form>
<p class="form-hint" style="margin-top:.5rem;font-size:.8rem;color:var(--text-muted)">
公开展示页地址:<a href="/u/{{ user.username }}" target="_blank">/u/{{ user.username }}</a>
</p>
</div>
<div style="margin-top:1.5rem">
<h2 style="font-size:1rem;margin-bottom:1rem">各 Profile 展示设置</h2>
{% if profiles %}
{% for p in profiles %}
{% set pc_list = profile_cards.get(p.id, []) %}
<div class="showcase-profile" style="background:var(--card-bg);border:1px solid var(--border);border-radius:10px;padding:1rem;margin-bottom:.75rem">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:.75rem">
<strong>{{ p.nickname }}</strong>
<span class="badge" style="{% if p.showcase_enabled %}background:var(--sent){% else %}background:var(--text-muted){% endif %}">{% if p.showcase_enabled %}展示中{% else %}未开启{% endif %}</span>
</div>
<form method="post" action="/profiles/{{ p.id }}/showcase" style="display:flex;flex-wrap:wrap;gap:.75rem;align-items:flex-end">
<div style="display:flex;flex-direction:column;gap:.25rem">
<label style="margin:0;font-size:.78rem">开启展示</label>
<label class="toggle-switch">
<input type="checkbox" name="showcase_enabled" value="1" {% if p.showcase_enabled %}checked{% endif %} onchange="this.form.submit()">
<span class="toggle-slider"></span>
</label>
</div>
<div style="display:flex;flex-direction:column;gap:.25rem">
<label style="margin:0;font-size:.78rem">展示内容</label>
<select name="showcase_visible" onchange="this.form.submit()">
<option value="both" {% if p.showcase_visible == 'both' %}selected{% endif %}>发出 + 收到</option>
<option value="sent" {% if p.showcase_visible == 'sent' %}selected{% endif %}>仅发出</option>
<option value="received" {% if p.showcase_visible == 'received' %}selected{% endif %}>仅收到</option>
</select>
</div>
</form>
{% if p.showcase_enabled and pc_list %}
<div style="margin-top:.75rem;border-top:1px solid var(--border);padding-top:.75rem">
<p style="font-size:.8rem;color:var(--text-muted);margin-bottom:.5rem">单张明信片展示控制(关闭展示后可单独隐藏):</p>
{% for pc in pc_list %}
<form method="post" action="/postcards/{{ pc.id }}/showcase-toggle" style="display:inline-flex;align-items:center;gap:.3rem;margin-right:.75rem;margin-bottom:.3rem;font-size:.8rem">
<span>{{ pc.card_number }}</span>
<label class="toggle-switch toggle-sm">
<input type="checkbox" name="showcase_hidden" value="1" {% if pc.showcase_hidden %}checked{% endif %} onchange="this.form.submit()">
<span class="toggle-slider"></span>
</label>
{% if pc.showcase_hidden %}<span style="color:var(--text-muted);font-size:.75rem">隐藏</span>{% endif %}
</form>
{% endfor %}
</div>
{% endif %}
</div>
{% endfor %}
{% else %}
<div class="empty-state">
<p>暂无 Profile请先创建</p>
</div>
{% endif %}
</div>
{% endblock %}