Add admin link IDs, tabs for extend/traffic, and pagination
This commit is contained in:
+77
-11
@@ -64,20 +64,62 @@ async def admin_dashboard(request: Request):
|
|||||||
return RedirectResponse("/admin/login", status_code=303)
|
return RedirectResponse("/admin/login", status_code=303)
|
||||||
|
|
||||||
settings = get_settings()
|
settings = get_settings()
|
||||||
|
tab = (request.query_params.get("type") or "extend").strip().lower()
|
||||||
|
if tab not in ("extend", "traffic", "all"):
|
||||||
|
tab = "extend"
|
||||||
|
|
||||||
|
try:
|
||||||
|
page = max(1, int(request.query_params.get("page") or 1))
|
||||||
|
except ValueError:
|
||||||
|
page = 1
|
||||||
|
per_page = 10
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
cursor = await db.execute(
|
if tab == "all":
|
||||||
|
count_sql = "SELECT COUNT(*) AS c FROM guest_links"
|
||||||
|
list_sql = """
|
||||||
|
SELECT l.*,
|
||||||
|
(SELECT COUNT(*) FROM guest_claims c WHERE c.link_id = l.id) AS claims
|
||||||
|
FROM guest_links l
|
||||||
|
ORDER BY l.id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
SELECT l.*,
|
count_args: tuple = ()
|
||||||
(SELECT COUNT(*) FROM guest_claims c WHERE c.link_id = l.id) AS claims
|
list_args: tuple = (per_page, offset)
|
||||||
FROM guest_links l
|
else:
|
||||||
ORDER BY l.id DESC
|
count_sql = "SELECT COUNT(*) AS c FROM guest_links WHERE link_type = ?"
|
||||||
|
list_sql = """
|
||||||
|
SELECT l.*,
|
||||||
|
(SELECT COUNT(*) FROM guest_claims c WHERE c.link_id = l.id) AS claims
|
||||||
|
FROM guest_links l
|
||||||
|
WHERE l.link_type = ?
|
||||||
|
ORDER BY l.id DESC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
"""
|
"""
|
||||||
)
|
count_args = (tab,)
|
||||||
|
list_args = (tab, per_page, offset)
|
||||||
|
|
||||||
|
cursor = await db.execute(count_sql, count_args)
|
||||||
|
total = int((await cursor.fetchone())["c"])
|
||||||
|
cursor = await db.execute(list_sql, list_args)
|
||||||
links = [dict(row) for row in await cursor.fetchall()]
|
links = [dict(row) for row in await cursor.fetchall()]
|
||||||
|
|
||||||
|
cursor = await db.execute(
|
||||||
|
"SELECT link_type, COUNT(*) AS c FROM guest_links GROUP BY link_type"
|
||||||
|
)
|
||||||
|
counts = {"extend": 0, "traffic": 0, "all": 0}
|
||||||
|
for row in await cursor.fetchall():
|
||||||
|
counts[row["link_type"]] = int(row["c"])
|
||||||
|
counts["all"] += int(row["c"])
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
|
total_pages = max(1, (total + per_page - 1) // per_page)
|
||||||
|
if page > total_pages:
|
||||||
|
page = total_pages
|
||||||
|
|
||||||
site_base = public_base_url(request)
|
site_base = public_base_url(request)
|
||||||
return templates.TemplateResponse(
|
return templates.TemplateResponse(
|
||||||
"admin/dashboard.html",
|
"admin/dashboard.html",
|
||||||
@@ -87,6 +129,12 @@ async def admin_dashboard(request: Request):
|
|||||||
"links": links,
|
"links": links,
|
||||||
"site_base": site_base,
|
"site_base": site_base,
|
||||||
"flash": request.query_params.get("flash"),
|
"flash": request.query_params.get("flash"),
|
||||||
|
"tab": tab,
|
||||||
|
"page": page,
|
||||||
|
"per_page": per_page,
|
||||||
|
"total": total,
|
||||||
|
"total_pages": total_pages,
|
||||||
|
"counts": counts,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -159,11 +207,19 @@ async def create_link(
|
|||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
|
|
||||||
return RedirectResponse("/admin?flash=created", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin?type={link_type}&flash=created", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/links/{link_id}/toggle")
|
@router.post("/links/{link_id}/toggle")
|
||||||
async def toggle_link(link_id: int, _admin: str = Depends(get_current_admin)):
|
async def toggle_link(
|
||||||
|
request: Request,
|
||||||
|
link_id: int,
|
||||||
|
_admin: str = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
tab = (request.query_params.get("type") or "extend").strip()
|
||||||
|
page = request.query_params.get("page") or "1"
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
await db.execute(
|
await db.execute(
|
||||||
@@ -173,18 +229,28 @@ async def toggle_link(link_id: int, _admin: str = Depends(get_current_admin)):
|
|||||||
await db.commit()
|
await db.commit()
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
return RedirectResponse("/admin?flash=toggled", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin?type={tab}&page={page}&flash=toggled", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/links/{link_id}/delete")
|
@router.post("/links/{link_id}/delete")
|
||||||
async def delete_link(link_id: int, _admin: str = Depends(get_current_admin)):
|
async def delete_link(
|
||||||
|
request: Request,
|
||||||
|
link_id: int,
|
||||||
|
_admin: str = Depends(get_current_admin),
|
||||||
|
):
|
||||||
|
tab = (request.query_params.get("type") or "extend").strip()
|
||||||
|
page = request.query_params.get("page") or "1"
|
||||||
db = await get_db()
|
db = await get_db()
|
||||||
try:
|
try:
|
||||||
await db.execute("DELETE FROM guest_links WHERE id = ?", (link_id,))
|
await db.execute("DELETE FROM guest_links WHERE id = ?", (link_id,))
|
||||||
await db.commit()
|
await db.commit()
|
||||||
finally:
|
finally:
|
||||||
await db.close()
|
await db.close()
|
||||||
return RedirectResponse("/admin?flash=deleted", status_code=303)
|
return RedirectResponse(
|
||||||
|
f"/admin?type={tab}&page={page}&flash=deleted", status_code=303
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get("/links/{link_id}", response_class=HTMLResponse)
|
@router.get("/links/{link_id}", response_class=HTMLResponse)
|
||||||
|
|||||||
@@ -530,6 +530,78 @@ th {
|
|||||||
max-width: min(520px, 100%);
|
max-width: min(520px, 100%);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.admin-tabs {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin: 0 0 1rem;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.45rem;
|
||||||
|
padding: 0.55rem 0.9rem;
|
||||||
|
border: 1px solid var(--line);
|
||||||
|
color: var(--mist);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab:hover {
|
||||||
|
color: var(--fog);
|
||||||
|
border-color: rgba(212, 255, 63, 0.35);
|
||||||
|
}
|
||||||
|
|
||||||
|
.admin-tab.is-active {
|
||||||
|
color: var(--ink);
|
||||||
|
background: var(--signal);
|
||||||
|
border-color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tab-count {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.85;
|
||||||
|
}
|
||||||
|
|
||||||
|
.id-pill {
|
||||||
|
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--signal);
|
||||||
|
}
|
||||||
|
|
||||||
|
.list-meta {
|
||||||
|
margin: 0 0 0.85rem;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.4rem;
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager .btn {
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pager-current {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
min-width: 2rem;
|
||||||
|
padding: 0.4rem 0.7rem;
|
||||||
|
background: rgba(212, 255, 63, 0.15);
|
||||||
|
color: var(--signal);
|
||||||
|
font-size: 0.85rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
.login-shell {
|
.login-shell {
|
||||||
min-height: 100vh;
|
min-height: 100vh;
|
||||||
display: grid;
|
display: grid;
|
||||||
|
|||||||
@@ -37,8 +37,8 @@
|
|||||||
<label>
|
<label>
|
||||||
Направление
|
Направление
|
||||||
<select name="link_type" required>
|
<select name="link_type" required>
|
||||||
<option value="extend">Продление VPN (дни)</option>
|
<option value="extend" {% if tab == "extend" %}selected{% endif %}>Продление VPN (дни)</option>
|
||||||
<option value="traffic">Докупка трафика (ГБ)</option>
|
<option value="traffic" {% if tab == "traffic" %}selected{% endif %}>Докупка трафика (ГБ)</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
<label>
|
<label>
|
||||||
Трафик тарифа (ГБ)
|
Трафик тарифа (ГБ)
|
||||||
<input type="number" name="plan_traffic_gb" min="0" value="100" />
|
<input type="number" name="plan_traffic_gb" min="0" value="100" />
|
||||||
<span class="muted" style="font-size:0.75rem;">Только продление: лимит станет ровно столько (и сбросится счётчик). Пример: тариф 49₽ → 100</span>
|
<span class="muted" style="font-size:0.75rem;">Только продление: лимит станет ровно столько (и сбросится счётчик)</span>
|
||||||
</label>
|
</label>
|
||||||
<label>
|
<label>
|
||||||
Срок бонус-трафика (дней)
|
Срок бонус-трафика (дней)
|
||||||
@@ -77,10 +77,27 @@
|
|||||||
</div>
|
</div>
|
||||||
</form>
|
</form>
|
||||||
|
|
||||||
|
<div class="admin-tabs">
|
||||||
|
<a class="admin-tab {% if tab == 'extend' %}is-active{% endif %}" href="/admin?type=extend">
|
||||||
|
Продление <span class="tab-count">{{ counts.extend }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="admin-tab {% if tab == 'traffic' %}is-active{% endif %}" href="/admin?type=traffic">
|
||||||
|
Трафик <span class="tab-count">{{ counts.traffic }}</span>
|
||||||
|
</a>
|
||||||
|
<a class="admin-tab {% if tab == 'all' %}is-active{% endif %}" href="/admin?type=all">
|
||||||
|
Все <span class="tab-count">{{ counts.all }}</span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p class="muted list-meta">
|
||||||
|
Показано {{ links|length }} из {{ total }} · страница {{ page }} / {{ total_pages }}
|
||||||
|
</p>
|
||||||
|
|
||||||
<div class="table-wrap">
|
<div class="table-wrap">
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
<th>ID</th>
|
||||||
<th>Тип</th>
|
<th>Тип</th>
|
||||||
<th>Заголовок</th>
|
<th>Заголовок</th>
|
||||||
<th>Значение</th>
|
<th>Значение</th>
|
||||||
@@ -93,6 +110,7 @@
|
|||||||
<tbody>
|
<tbody>
|
||||||
{% for link in links %}
|
{% for link in links %}
|
||||||
<tr>
|
<tr>
|
||||||
|
<td><span class="id-pill">#{{ link.id }}</span></td>
|
||||||
<td>
|
<td>
|
||||||
{% if link.link_type == "extend" %}
|
{% if link.link_type == "extend" %}
|
||||||
<span class="badge badge-extend">Продление</span>
|
<span class="badge badge-extend">Продление</span>
|
||||||
@@ -127,10 +145,10 @@
|
|||||||
<td>
|
<td>
|
||||||
<div class="row-actions">
|
<div class="row-actions">
|
||||||
<a class="btn btn-ghost" href="/admin/links/{{ link.id }}">История</a>
|
<a class="btn btn-ghost" href="/admin/links/{{ link.id }}">История</a>
|
||||||
<form method="post" action="/admin/links/{{ link.id }}/toggle">
|
<form method="post" action="/admin/links/{{ link.id }}/toggle?type={{ tab }}&page={{ page }}">
|
||||||
<button class="btn btn-ghost" type="submit">{% if link.is_active %}Выкл{% else %}Вкл{% endif %}</button>
|
<button class="btn btn-ghost" type="submit">{% if link.is_active %}Выкл{% else %}Вкл{% endif %}</button>
|
||||||
</form>
|
</form>
|
||||||
<form method="post" action="/admin/links/{{ link.id }}/delete" onsubmit="return confirm('Удалить ссылку?')">
|
<form method="post" action="/admin/links/{{ link.id }}/delete?type={{ tab }}&page={{ page }}" onsubmit="return confirm('Удалить ссылку #{{ link.id }}?')">
|
||||||
<button class="btn btn-danger" type="submit">Удалить</button>
|
<button class="btn btn-danger" type="submit">Удалить</button>
|
||||||
</form>
|
</form>
|
||||||
</div>
|
</div>
|
||||||
@@ -138,12 +156,32 @@
|
|||||||
</tr>
|
</tr>
|
||||||
{% else %}
|
{% else %}
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="7" class="muted">Пока нет ссылок — создайте первую выше.</td>
|
<td colspan="8" class="muted">В этом разделе пока нет ссылок.</td>
|
||||||
</tr>
|
</tr>
|
||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{% if total_pages > 1 %}
|
||||||
|
<nav class="pager">
|
||||||
|
{% if page > 1 %}
|
||||||
|
<a class="btn btn-ghost" href="/admin?type={{ tab }}&page={{ page - 1 }}">← Назад</a>
|
||||||
|
{% endif %}
|
||||||
|
{% for p in range(1, total_pages + 1) %}
|
||||||
|
{% if p == page %}
|
||||||
|
<span class="pager-current">{{ p }}</span>
|
||||||
|
{% elif p == 1 or p == total_pages or (p >= page - 2 and p <= page + 2) %}
|
||||||
|
<a class="btn btn-ghost" href="/admin?type={{ tab }}&page={{ p }}">{{ p }}</a>
|
||||||
|
{% elif p == page - 3 or p == page + 3 %}
|
||||||
|
<span class="muted">…</span>
|
||||||
|
{% endif %}
|
||||||
|
{% endfor %}
|
||||||
|
{% if page < total_pages %}
|
||||||
|
<a class="btn btn-ghost" href="/admin?type={{ tab }}&page={{ page + 1 }}">Вперёд →</a>
|
||||||
|
{% endif %}
|
||||||
|
</nav>
|
||||||
|
{% endif %}
|
||||||
</div>
|
</div>
|
||||||
<script src="/static/js/admin.js"></script>
|
<script src="/static/js/admin.js"></script>
|
||||||
</body>
|
</body>
|
||||||
|
|||||||
@@ -13,7 +13,7 @@
|
|||||||
<div class="panel-wrap">
|
<div class="panel-wrap">
|
||||||
<header class="panel-header">
|
<header class="panel-header">
|
||||||
<div>
|
<div>
|
||||||
<h1>{{ link.title }}</h1>
|
<h1>#{{ link.id }} · {{ link.title }}</h1>
|
||||||
<div class="link-copy-row">
|
<div class="link-copy-row">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
Reference in New Issue
Block a user