mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 09:55:03 +00:00
candidates UI: full width, action and bulk buttons
- base.html: container -> container-fluid (nav and content full width) - Right pane: large buttons Publish / Process with local model / Rewrite / Reject (for new candidates) - Left pane bottom: bulk operations panel with 4 visible buttons (Publish / Process / Reject / Stale) instead of hidden select - bulk: action 'process' reclassifies selected with local model (impl_reclassify extracted); ids optional (empty selection -> no_selection redirect instead of 422); redirect preserves filters - Tests: full width, buttons present, bulk empty/process, published/metrics — green
This commit is contained in:
+34
-12
@@ -280,13 +280,17 @@ def candidates_select(request: Request, id: int = Form(...), direction: str = Fo
|
||||
|
||||
|
||||
@app.post("/candidates/bulk")
|
||||
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(...),
|
||||
def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] = Form(default_factory=list),
|
||||
direction: str = Form(""), status: str = Form(""), own: str = Form(""),
|
||||
q: str = Form(""), group_by: str = Form("source")):
|
||||
"""Bulk-операции: approve/reject/reject-old по отмеченным ids."""
|
||||
"""Bulk-операции: approve/reject/reject-old/process по отмеченным ids."""
|
||||
_require_auth(request)
|
||||
back = f"/candidates?group_by={group_by}"
|
||||
for k, v in (("direction", direction), ("status", status), ("own", own), ("q", q)):
|
||||
if v:
|
||||
back += f"&{k}={quote(v)}"
|
||||
if not ids:
|
||||
return RedirectResponse(url="/candidates?error=no_selection", status_code=302)
|
||||
return RedirectResponse(url=back + "&error=no_selection", status_code=302)
|
||||
conn = _db()
|
||||
ph = ",".join("?" * len(ids))
|
||||
rows = conn.execute(f"SELECT * FROM posts WHERE id IN ({ph})", ids).fetchall()
|
||||
@@ -294,7 +298,7 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] =
|
||||
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph})", ids)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse(url=f"/candidates?status=rejected&group_by={group_by}", status_code=302)
|
||||
return RedirectResponse(url=back + "&status=rejected", status_code=302)
|
||||
if action == "reject-old":
|
||||
# все выбранные, кроме самых свежих (по 1 от источника) — «почистить старьё»
|
||||
keep = {}
|
||||
@@ -307,9 +311,22 @@ def candidates_bulk(request: Request, action: str = Form(...), ids: list[int] =
|
||||
conn.execute(f"UPDATE posts SET status='rejected' WHERE id IN ({ph2})", kill)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse(url=f"/candidates?status=rejected&group_by={group_by}", status_code=302)
|
||||
# approve — каждый через существующую логику (публикация + бандл)
|
||||
return RedirectResponse(url=back + "&status=rejected", status_code=302)
|
||||
conn.close()
|
||||
if action == "process":
|
||||
# обработка локальной моделью (переклассификация) по выбранным
|
||||
errs = []
|
||||
done = 0
|
||||
for r in rows:
|
||||
try:
|
||||
impl_reclassify(int(r["id"]))
|
||||
done += 1
|
||||
except Exception as e:
|
||||
errs.append(f"{r['id']}({e})")
|
||||
if errs:
|
||||
return RedirectResponse(url=f"/candidates?error=process_partial:{','.join(map(str,errs))[:120]}&group_by={group_by}", status_code=302)
|
||||
return RedirectResponse(url=back + "&error=processed:" + str(done), status_code=302)
|
||||
# approve — каждый через существующую логику (публикация + бандл)
|
||||
errs = []
|
||||
for r in rows:
|
||||
if r["status"] == "published":
|
||||
@@ -334,25 +351,30 @@ def post_comment(post_id: int, request: Request, comment: str = Form("")):
|
||||
|
||||
@app.post("/posts/{post_id}/reclassify")
|
||||
def post_reclassify(post_id: int, request: Request):
|
||||
"""Переклассификация кандидата локальной моделью (qwen)."""
|
||||
_require_auth(request)
|
||||
try:
|
||||
impl_reclassify(post_id)
|
||||
except Exception as e:
|
||||
return RedirectResponse(url=f"/candidates?selected={post_id}&error=reclassify:{e}", status_code=302)
|
||||
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
|
||||
|
||||
|
||||
def impl_reclassify(post_id: int):
|
||||
"""Перезапуск классификации локальной моделью. Бросает исключение при неудаче."""
|
||||
from classifier.classify import classify_text
|
||||
conn = _db()
|
||||
p = conn.execute("SELECT * FROM posts WHERE id=?", (post_id,)).fetchone()
|
||||
if not p:
|
||||
conn.close()
|
||||
return RedirectResponse(url="/candidates?error=notfound", status_code=302)
|
||||
try:
|
||||
raise ValueError("notfound")
|
||||
res = classify_text(dict(p))
|
||||
except Exception as e:
|
||||
conn.close()
|
||||
return RedirectResponse(url=f"/candidates?selected={post_id}&error=reclassify:{e}", status_code=302)
|
||||
conn.execute(
|
||||
"UPDATE posts SET direction=?, relevance=?, interest=?, summary=?, classified=? WHERE id=?",
|
||||
(res["direction"], res["relevance"], res["interest"], res["summary"], 1 if res["classified"] else 0, post_id),
|
||||
)
|
||||
conn.commit()
|
||||
conn.close()
|
||||
return RedirectResponse(url=f"/candidates?selected={post_id}", status_code=302)
|
||||
|
||||
|
||||
@app.post("/posts/{post_id}/rewrite")
|
||||
|
||||
@@ -6,14 +6,14 @@
|
||||
<title>VESTI — {% block title %}Управление{% endblock %}</title>
|
||||
<link href="/static/bootstrap.min.css" rel="stylesheet">
|
||||
<style>
|
||||
body { padding-top: 2rem; background: #f5f6fa; }
|
||||
body { padding-top: 1.5rem; background: #f5f6fa; }
|
||||
.card { box-shadow: 0 1px 4px rgba(0,0,0,.08); }
|
||||
.post-text { white-space: pre-wrap; max-height: 140px; overflow-y: auto; font-size: .9rem; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark bg-dark fixed-top">
|
||||
<div class="container">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="/candidates">📰 VESTI</a>
|
||||
<div class="navbar-nav">
|
||||
<a class="nav-link" href="/candidates">Кандидаты</a>
|
||||
@@ -23,7 +23,7 @@
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
<div class="container mt-4">
|
||||
<div class="container-fluid mt-2 px-3">
|
||||
{% block content %}{% endblock %}
|
||||
</div>
|
||||
</body>
|
||||
|
||||
@@ -48,15 +48,16 @@
|
||||
{% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
|
||||
|
||||
<div class="row g-0">
|
||||
<div class="col-md-5">
|
||||
<div class="border rounded p-2 d-flex flex-column" style="max-height: 78vh; background:#fff;">
|
||||
<div class="d-flex align-items-center mb-1">
|
||||
<span class="me-2 text-muted small">Группировка:</span>
|
||||
{% for val, label in [('source', 'по источнику'), ('date', 'по дате'), ('status', 'по статусу')] %}
|
||||
<!-- ЛЕВАЯ ПАНЕЛЬ: список + группировка + массовые операции -->
|
||||
<div class="col-md-4">
|
||||
<div class="border rounded p-2 d-flex flex-column" style="max-height: 82vh; background:#fff;">
|
||||
<div class="d-flex flex-wrap align-items-center mb-1 gap-1">
|
||||
<span class="me-1 text-muted small">Группировка:</span>
|
||||
{% for val, label in [('source', 'источник'), ('date', 'дата'), ('status', 'статус')] %}
|
||||
{% if group_by == val %}
|
||||
<span class="badge bg-primary text-white me-1">{{ label }}</span>
|
||||
<span class="badge bg-primary text-white">{{ label }}</span>
|
||||
{% else %}
|
||||
<a class="badge bg-light text-dark border me-1 text-decoration-none"
|
||||
<a class="badge bg-light text-dark border text-decoration-none"
|
||||
href="/candidates?group_by={{ val }}{% if filters_qs %}&{{ filters_qs }}{% endif %}{{ selected_qs }}">{{ label }}</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
@@ -102,21 +103,23 @@
|
||||
<input type="hidden" name="own" value="{{ own }}">
|
||||
<input type="hidden" name="q" value="{{ q }}">
|
||||
<input type="hidden" name="group_by" value="{{ group_by }}">
|
||||
<div class="d-flex align-items-center gap-2 mt-2">
|
||||
<select name="action" class="form-select form-select-sm" style="width:auto;">
|
||||
<option value=""></option>
|
||||
<option value="reject">🚫 Отклонить</option>
|
||||
<option value="reject-old">🗑 Отклонить старьё</option>
|
||||
<option value="approve">✅ Опубликовать</option>
|
||||
</select>
|
||||
<button class="btn btn-sm btn-primary" type="submit">Выполнить</button>
|
||||
<div class="border-top pt-2 mt-1">
|
||||
<div class="small text-muted mb-1">⚡ Массовые операции <span class="badge bg-secondary" id="sel-count">0</span></div>
|
||||
<div class="btn-group btn-group-sm w-100 d-flex flex-wrap gap-1">
|
||||
<button class="btn btn-sm btn-success flex-fill" name="action" value="approve">✅ Опубликовать</button>
|
||||
<button class="btn btn-sm btn-info flex-fill text-dark" name="action" value="process">🤖 Обработать моделью</button>
|
||||
<button class="btn btn-sm btn-outline-danger flex-fill" name="action" value="reject">🚫 Отклонить</button>
|
||||
<button class="btn btn-sm btn-outline-secondary flex-fill" name="action" value="reject-old">🗑 Старьё</button>
|
||||
</div>
|
||||
<div class="form-text mt-1">Отметьте галочками слева и нажмите действие. «Обработать» — переклассификация локальной моделью (qwen).</div>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-md-7">
|
||||
<div class="border rounded p-3 bg-white" style="max-height: 78vh; overflow-y:auto;">
|
||||
<!-- ПРАВАЯ ПАНЕЛЬ: детали выбранного -->
|
||||
<div class="col-md-8">
|
||||
<div class="border rounded p-3 bg-white" style="max-height: 82vh; overflow-y:auto;">
|
||||
{% if selected %}
|
||||
<div class="d-flex justify-content-between align-items-start">
|
||||
<h5 class="mb-0">#{{ selected.id }} {{ selected.source_name or '?' }}</h5>
|
||||
@@ -132,23 +135,25 @@
|
||||
{% if selected.url %}<a class="btn btn-sm btn-outline-secondary ms-2" href="{{ selected.url }}" target="_blank">Исходник</a>{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="mb-3 d-flex gap-2">
|
||||
<div class="mb-3 d-flex flex-wrap gap-2">
|
||||
{% if selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/approve">
|
||||
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected.status != 'rejected' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/reject">
|
||||
<button class="btn btn-sm btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||
<button class="btn btn-success" type="submit">✅ Опубликовать</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
||||
<button class="btn btn-sm btn-outline-info" type="submit">🔄 Переклассифицировать</button>
|
||||
<button class="btn btn-info" type="submit">🤖 Обработать моделью</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
||||
<button class="btn btn-sm btn-outline-primary" type="submit">✍️ Переписать</button>
|
||||
<button class="btn btn-outline-primary" type="submit">✍️ Переписать</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
||||
<form method="post" action="/posts/{{ selected.id }}/reject">
|
||||
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
||||
</form>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user