candidates-mail-ui: двухпанельный интерфейс кандидатов (почтовый клиент)

- БД: posts.comment TEXT, posts.rewritten_text TEXT (ALTER TABLE, миграция применена)
- GET /candidates: двухпанельный рендер — слева список с группировкой
  (source/date/status, свертываемые <details>) + чекбоксы bulk,
  справа детали поста (действия, комментарий, пересказ, оригинал с медиа)
- Роуты: POST /candidates/select, /candidates/bulk (approve/reject/reject-old),
  /posts/{id}/comment, /posts/{id}/reclassify (qwen), /posts/{id}/rewrite (qwen)
- approve вынесен в impl_approve (переиспользуется bulk-approve: publisher + бандл)
- без JS (только формы); HTML: Jinja2 + Bootstrap 5.3
- Протестировано TestClient: группировка, bulk-reject, comment, reclassify
  (реальный qwen), rewrite (реальный qwen), поиск, media, published — зелёные
- openspec: change candidates-mail-ui архивирован (2026-09-13-candidates-mail-ui),
  spec влита в openspec/specs/candidates-mail-ui; validate — чисто
- docs: STATUS.md, WALKTHROUGH.md обновлены
This commit is contained in:
kpa39l
2026-09-13 20:53:53 +00:00
parent 970bf14a9d
commit fc6d725bd1
11 changed files with 601 additions and 159 deletions
+164 -68
View File
@@ -1,13 +1,32 @@
{% extends "base.html" %}
{% block title %}Кандидаты{% endblock %}
{% block content %}
<h4 class="mb-3">Кандидаты</h4>
<div class="d-flex justify-content-between align-items-center mb-2">
<h4 class="mb-0">Кандидаты</h4>
<div class="text-muted small">
{% if selected %}
#{{ selected.id }} · {{ selected.source_name or selected.tg_channel or '?' }}
{% endif %}
</div>
</div>
{% if error %}<div class="alert alert-danger py-2">❌ {{ error }}</div>{% endif %}
{% if error %}
<div class="alert alert-danger py-2">❌ {{ error }}</div>
{% endif %}
<form class="row g-2 mb-3" method="get" action="/candidates">
<form class="row g-2 mb-2" method="get" action="/candidates">
<div class="col-auto">
<select name="direction" class="form-select" onchange="this.form.submit()">
<input type="text" name="q" value="{{ q }}" class="form-control form-control-sm" placeholder="поиск…">
</div>
<div class="col-auto">
<select name="status" class="form-select form-select-sm">
{% for val, label in [('', 'Все'), ('new', '💎 Новые'), ('rejected', '🗑 Отклонённые'), ('published', '✅ Опубл.')] %}
<option value="{{ val }}" {% if status == val %}selected{% endif %}>{{ label }}{% if counts.get(val) %} ({{ counts[val] }}){% endif %}</option>
{% endfor %}
</select>
</div>
<div class="col-auto">
<select name="direction" class="form-select form-select-sm">
<option value="">Все направления</option>
{% for d in directions %}
<option value="{{ d }}" {% if direction == d %}selected{% endif %}>{{ d }}</option>
@@ -15,17 +34,10 @@
</select>
</div>
<div class="col-auto">
<select name="status" class="form-select" onchange="this.form.submit()">
<option value="new" {% if status=='new' %}selected{% endif %}>Черновики (new)</option>
<option value="rejected" {% if status=='rejected' %}selected{% endif %}>Отклонённые</option>
<option value="published" {% if status=='published' %}selected{% endif %}>Опубликованные</option>
</select>
</div>
<div class="col-auto">
<select name="own" class="form-select" onchange="this.form.submit()">
<option value="" {% if own=='' %}selected{% endif %}>Все посты</option>
<option value="1" {% if own=='1' %}selected{% endif %}>⭐ Только свои</option>
<option value="0" {% if own=='0' %}selected{% endif %}>Только внешние</option>
<select name="own" class="form-select form-select-sm">
<option value="">Все посты</option>
<option value="1" {% if own == '1' %}selected{% endif %}>⭐ Свои</option>
<option value="0" {% if own == '0' %}selected{% endif %}>Внешние</option>
</select>
</div>
<div class="col-auto">
@@ -33,65 +45,149 @@
</div>
</form>
<div id="post-list">
{% for p in posts %}
<div class="card mb-3" id="post-{{ p.id }}">
<div class="card-body">
<div class="d-flex justify-content-between">
<span class="badge bg-secondary">{{ p.direction or '?' }}</span>
<small class="text-muted">{{ p.source_name }} · {{ p.published_at | dt }}</small>
</div>
<div class="post-text mt-2">{{ (p.text or '')[:2000] | markdown }}</div>
{% if p.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
{% if p.relevance %}<span class="badge bg-{{ 'success' if p.relevance=='critical' else 'info' }}">{{ p.relevance }}</span>{% endif %}
{% if p.interest %}<span class="badge bg-warning text-dark">interest {{ p.interest }}</span>{% endif %}
<small class="text-muted ms-2">👁 {{ p.views }} 💬 {{ p.reactions_total }}</small>
{% if p.media_path %}
<span class="badge bg-light text-dark ms-2">🖼 медиа</span>
{% set media_src = '/media/' ~ p.media_path.split('/')[-1] %}
{% set media_ext = p.media_path.lower().rsplit('.', 1)[-1] if '.' in p.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:180px" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:180px"></video>
{% endif %}
{% endif %}
{% if p.fwd_from_channel_id %}<span class="badge bg-light text-dark ms-2">↩︎ форвард</span>{% endif %}
<div class="mt-2">
{% if p.status == 'new' %}
<form class="d-inline" method="post" action="/posts/{{ p.id }}/approve">
<div class="mb-2">
<textarea class="form-control form-control-sm" name="comment" rows="2"
placeholder="Комментарий к посту (по желанию) — будет первым в канале"></textarea>
</div>
{% if p.is_own %}
<div class="mb-2">
<small class="text-muted">Рассылка (fan-out):</small>
{% for d in directions %}
<div class="form-check form-check-inline">
<input class="form-check-input" type="checkbox" name="dirs" value="{{ d }}" id="dir-{{ p.id }}-{{ d }}"
{% if d == (p.direction or 'linux') %}checked{% endif %}>
<label class="form-check-label" for="dir-{{ p.id }}-{{ d }}">{{ d }}</label>
<form method="post" action="/candidates/bulk" id="bulk-form"
onsubmit="return confirm('Выполнить групповое действие над выбранными?')">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="hidden" name="status" value="{{ status }}">
<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="row g-0">
<div class="col-md-5">
<div class="border rounded p-2" style="max-height: 78vh; overflow-y:auto; background:#fff;">
<div class="d-flex align-items-center mb-1">
<span class="me-2 text-muted small">Группировка:</span>
<select name="group_by" class="form-select form-select-sm d-inline" style="width:auto;"
onchange="this.form.submit()">
{% for val in ['source', 'date', 'status'] %}
<option value="{{ val }}" {% if group_by == val %}selected{% endif %}>
{% if val == 'source' %}по источнику{% elif val == 'date' %}по дате{% else %}по статусу{% endif %}
</option>
{% endfor %}
</select>
<button class="btn btn-sm btn-outline-secondary ms-2" type="submit">➡️</button>
</div>
{% for g in posts %}
<details {% if loop.first or (selected and selected.id in g.posts|map(attribute='id')|list) %}open{% endif %} class="mb-2">
<summary class="small text-secondary">
<span class="badge bg-light text-dark border">{{ g.key }}</span>
<span class="text-muted">— {{ g.posts|length }}</span>
{% if g.status == 'new' %}<span class="badge bg-success">новые</span>
{% elif g.status == 'rejected' %}<span class="badge bg-danger">откл.</span>
{% endif %}
</summary>
<div class="list-group list-group-flush small">
{% for p in g.posts %}
<div class="list-group-item d-flex align-items-start">
<input class="form-check-input me-2" type="checkbox" name="ids" value="{{ p.id }}"
id="cb-{{ p.id }}" style="vertical-align:middle;">
<form class="d-inline flex-grow-1" method="post" action="/candidates/select">
<input type="hidden" name="id" value="{{ p.id }}">
<input type="hidden" name="group_by" value="{{ group_by }}">
<input type="hidden" name="direction" value="{{ direction }}">
<input type="hidden" name="status" value="{{ status }}">
<input type="hidden" name="own" value="{{ own }}">
<input type="hidden" name="q" value="{{ q }}">
<button type="submit" class="btn btn-link p-0 text-start w-100 text-decoration-none
{% if selected and selected.id == p.id %}fw-bold text-primary{% endif %}">
<span class="d-block text-truncate">{{ (p.text or '')[:90] }}</span>
<span class="d-block text-muted">
{{ p.source_name }} · {{ (p.published_at or p.fetched_at) | dt }}
{% if p.is_own %} · ⭐{% endif %}
{% if p.direction %}<span class="badge bg-light">{{ p.direction }}</span>{% endif %}
</span>
</button>
</form>
</div>
{% endfor %}
</div>
</details>
{% else %}
<div class="alert alert-info py-2">Нет кандидатов по фильтру</div>
{% endfor %}
</div>
</div>
<div class="col-md-7">
<div class="border rounded p-3 bg-white" style="max-height: 78vh; 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>
<small class="text-muted">{{ (selected.published_at or selected.fetched_at) | dt }}</small>
</div>
<div class="mb-2">
{% if selected.direction %}<span class="badge bg-secondary">{{ selected.direction }}</span>{% endif %}
{% if selected.relevance %}<span class="badge bg-{{ 'success' if selected.relevance=='critical' else 'info' }}">{{ selected.relevance }}</span>{% endif %}
{% if selected.interest %}<span class="badge bg-warning text-dark">interest {{ selected.interest }}</span>{% endif %}
{% if selected.is_own %}<span class="badge bg-warning text-dark">⭐ СВОЙ</span>{% endif %}
{% if selected.fwd_from_channel_id %}<span class="badge bg-light text-dark">↩︎ форвард</span>{% endif %}
<small class="text-muted ms-2">👁 {{ selected.views }} 💬 {{ selected.reactions_total }}</small>
{% 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">
{% if selected.status != 'published' %}
<form method="post" action="/posts/{{ selected.id }}/approve">
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
</form>
{% endif %}
<button class="btn btn-sm btn-success" type="submit">✅ Опубликовать</button>
</form>
<form class="d-inline" method="post" action="/posts/{{ p.id }}/reject">
<button class="btn btn-sm btn-outline-danger" type="submit">🚫 Отклонить</button>
</form>
{% elif p.status == 'rejected' %}
<span class="text-danger">Отклонён</span>
{% if selected.status != 'rejected' %}
<form method="post" action="/posts/{{ selected.id }}/reject">
<button class="btn btn-sm btn-outline-danger" 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>
</form>
<form method="post" action="/posts/{{ selected.id }}/rewrite">
<button class="btn btn-sm btn-outline-primary" type="submit">✍️ Переписать</button>
</form>
{% endif %}
</div>
<div class="mb-3">
<form method="post" action="/posts/{{ selected.id }}/comment">
<label class="form-label small text-muted">💬 Мой комментарий (будет первым в канале):</label>
<textarea class="form-control form-control-sm" name="comment" rows="2"
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
<button class="btn btn-sm btn-outline-secondary mt-1" type="submit">Сохранить комментарий</button>
</form>
{% if selected.rewritten_text %}
<div class="mt-2">
<label class="form-label small text-muted">✍️ Черновик пересказа (редактируйте и копируйте):</label>
<textarea class="form-control form-control-sm" rows="4" readonly>{{ selected.rewritten_text }}</textarea>
</div>
{% endif %}
</div>
<hr>
<h6 class="text-muted">Оригинальный пост</h6>
{% if selected.text %}
<div class="post-text mt-1">{{ selected.text | markdown }}</div>
{% endif %}
{% if selected.media_path %}
{% set media_src = '/media/' ~ selected.media_path.split('/')[-1] %}
{% set media_ext = selected.media_path.lower().rsplit('.', 1)[-1] if '.' in selected.media_path else '' %}
{% if media_ext in ['jpg','jpeg','png','gif','webp'] %}
<a href="{{ media_src }}" target="_blank" title="Открыть полноразмер">
<img src="{{ media_src }}" class="img-fluid rounded mt-2 d-block" style="max-height:320px" alt="медиа поста">
</a>
{% elif media_ext in ['mp4','webm','mov'] %}
<video src="{{ media_src }}" controls class="mt-2 d-block" style="max-height:320px"></video>
{% else %}
<a class="btn btn-sm btn-outline-secondary mt-2" href="{{ media_src }}" target="_blank">📎 Медиафайл</a>
{% endif %}
{% endif %}
{% if selected.summary %}<div class="mt-2"><span class="badge bg-light text-dark">резюме</span> {{ selected.summary }}</div>{% endif %}
{% else %}
<div class="alert alert-info py-3">Выберите кандидата слева</div>
{% endif %}
<a class="btn btn-sm btn-outline-secondary" href="{{ p.url }}" target="_blank">Исходник</a>
</div>
</div>
</div>
{% else %}
<div class="alert alert-info">Нет постов</div>
{% endfor %}
</div>
</form>
{% endblock %}