mirror of
https://gitverse.ru/kpa39l/vesti.git
synced 2026-09-29 18:05:03 +00:00
244 lines
14 KiB
HTML
244 lines
14 KiB
HTML
{% extends "base.html" %}
|
|
{% block title %}Кандидаты{% endblock %}
|
|
{% block content %}
|
|
<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 %}
|
|
|
|
<form class="row g-2 mb-2" method="get" action="/candidates">
|
|
<div class="col-auto">
|
|
<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>
|
|
{% endfor %}
|
|
</select>
|
|
</div>
|
|
<div class="col-auto">
|
|
<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">
|
|
<button class="btn btn-sm btn-outline-secondary" type="submit">Применить</button>
|
|
</div>
|
|
</form>
|
|
|
|
{% set selected_qs = '&selected=' ~ selected.id if selected else '' %}
|
|
|
|
<div class="row g-0">
|
|
<!-- ЛЕВАЯ ПАНЕЛЬ: список + группировка + массовые операции -->
|
|
<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">{{ label }}</span>
|
|
{% else %}
|
|
<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 %}
|
|
</div>
|
|
|
|
<div style="overflow-y:auto; flex:1 1 auto;">
|
|
{% 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{% if selected and selected.id == p.id %} active-row{% endif %}{% if not p.is_read %} fw-bold{% endif %}">
|
|
<a class="flex-grow-1 text-decoration-none"
|
|
href="/candidates?selected={{ p.id }}&group_by={{ group_by }}{% if filters_qs %}&{{ filters_qs }}{% 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>
|
|
</a>
|
|
<input type="checkbox" name="ids" value="{{ p.id }}" form="bulk-form"
|
|
class="ms-auto flex-shrink-0"
|
|
style="width:1.3em;height:1.3em;cursor:pointer;"
|
|
{% if selected and selected.id == p.id %}checked{% endif %}>
|
|
</div>
|
|
{% endfor %}
|
|
</div>
|
|
</details>
|
|
{% else %}
|
|
<div class="alert alert-info py-2">Нет кандидатов по фильтру</div>
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<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="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-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>
|
|
<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>
|
|
<details class="small text-muted mb-2">
|
|
<summary class="text-muted">Бейджи поста — что значит каждый цвет</summary>
|
|
<ul class="mb-0 mt-1" style="padding-left:1.2rem">
|
|
<li><span class="badge bg-secondary">направление</span> — тема (linux, tech, games…) — из классификатора</li>
|
|
<li><span class="badge bg-success">critical</span> / <span class="badge bg-info">high</span> — важность по мнению модели (зелёный = критичная, но не критичная; синий = важная)</li>
|
|
<li><span class="badge bg-warning text-dark">interest N</span> — интересность для твоего канала 1–5 (жёлтый, N — оценка; 5 = лучшее)</li>
|
|
<li><span class="badge bg-warning text-dark">⭐ СВОЙ</span> — пост твоего канала (dedinit)</li>
|
|
<li><span class="badge bg-light text-dark">↩︎ форвард</span> — пересланное сообщение</li>
|
|
</ul>
|
|
</details>
|
|
|
|
<div class="mb-3">
|
|
{% set hid %}<input type="hidden" name="group_by" value="{{ group_by }}"><input type="hidden" name="direction" value="{{ direction }}"><input type="hidden" name="own" value="{{ own }}"><input type="hidden" name="q" value="{{ q }}">{% endset %}
|
|
<div class="d-flex flex-wrap gap-2 mb-2">
|
|
{% if selected.status != 'published' %}
|
|
<form method="post" action="/posts/{{ selected.id }}/reclassify">
|
|
{{ hid }}
|
|
<button class="btn btn-info" type="submit" title="Заново прогнать через модель (qwen3:8b): направление, релевантность, интерес — обновит бейджи">🤖 Обработать моделью</button>
|
|
</form>
|
|
{% endif %}
|
|
{% if selected.status != 'published' %}
|
|
<form method="post" action="/posts/{{ selected.id }}/rewrite">
|
|
{{ hid }}
|
|
<button class="btn btn-outline-primary" type="submit" title="Сгенерировать черновик пересказа через модель — затем можно отредактировать и сохранить (пойдёт в канал вместо оригинала, от вашего имени)">✍️ Переписать</button>
|
|
</form>
|
|
{% endif %}
|
|
{% if selected.status != 'rejected' and selected.status != 'published' %}
|
|
<form method="post" action="/posts/{{ selected.id }}/reject">
|
|
{{ hid }}
|
|
<button class="btn btn-outline-danger" type="submit">🚫 Отклонить</button>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
{% if selected.status != 'published' %}
|
|
<form method="post" action="/posts/{{ selected.id }}/approve" id="approve-form">
|
|
{{ hid }}
|
|
<button class="btn btn-success" type="submit" title="Опубликовать в канал: комментарий (если есть) первым, затем текст поста/пересказ, картинка, ссылка">✅ Опубликовать</button>
|
|
<label class="form-label small text-muted mb-0 mt-2 d-block">💬 Мой комментарий (будет первым в канале):</label>
|
|
<textarea class="form-control form-control-sm mt-1" name="comment" rows="2"
|
|
placeholder="Комментарий/анонс…">{{ selected.comment or '' }}</textarea>
|
|
<div class="mt-2">
|
|
<button class="btn btn-sm btn-outline-secondary" type="submit"
|
|
formaction="/posts/{{ selected.id }}/comment"
|
|
title="Сохранить комментарий в черновик (не публикует). Он подтянется при следующей публикации">💾 Сохранить комментарий</button>
|
|
</div>
|
|
</form>
|
|
{% endif %}
|
|
</div>
|
|
|
|
<div class="mb-3">
|
|
{% if selected.rewritten_text %}
|
|
<div class="mt-2">
|
|
<label class="form-label small text-muted">✍️ Пересказ (редактируйте и сохраните — он пойдёт в канал от вашего имени):</label>
|
|
<form method="post" action="/posts/{{ selected.id }}/rewrite-save" class="row g-1">
|
|
{{ hid }}
|
|
<textarea class="form-control form-control-sm" name="rewritten_text" rows="5">{{ selected.rewritten_text }}</textarea>
|
|
<div class="col-auto mt-1">
|
|
<button class="btn btn-sm btn-outline-primary" type="submit">💾 Сохранить пересказ</button>
|
|
</div>
|
|
</form>
|
|
</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 %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endblock %}
|
|
{% block scripts %}
|
|
<script>
|
|
(function(){
|
|
var boxes = document.querySelectorAll('#bulk-form input[name="ids"]');
|
|
var badge = document.getElementById('sel-count');
|
|
function upd(){
|
|
var n = 0;
|
|
for (var i = 0; i < boxes.length; i++) if (boxes[i].checked) n++;
|
|
badge.textContent = n;
|
|
}
|
|
for (var i = 0; i < boxes.length; i++) boxes[i].addEventListener('change', upd);
|
|
upd();
|
|
})();
|
|
</script>
|
|
{% endblock %} |