Update UI and migrations, fix dockerfile

This commit is contained in:
Your Name
2026-07-18 21:29:05 +05:00
parent 580c75cbf6
commit 26ef145ccf
95 changed files with 9541 additions and 111 deletions
+8 -2
View File
@@ -3,6 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favi.png">
<title>{{ title or "VK Parser Admin" }}</title>
<style>
:root { color-scheme: light; --bg:#f4f6f8; --panel:#fff; --panel-soft:#f8fafc; --line:#d8dee8; --line-strong:#c8d0dc; --text:#20242b; --muted:#647086; --accent:#245fd6; --accent-soft:#eef4ff; --danger:#b42318; --ok:#027a48; --shadow:0 1px 2px rgba(16,24,40,.04), 0 10px 30px rgba(16,24,40,.06); }
@@ -65,7 +66,7 @@
.source-cell a { font-weight:700; }
.stage-cell { min-width:112px; }
.raw-table { table-layout:fixed; }
.raw-table th:nth-child(1), .raw-table td:nth-child(1) { width:58px; }
.raw-table th:nth-child(1), .raw-table td:nth-child(1) { width:82px; }
.raw-table th:nth-child(2), .raw-table td:nth-child(2) { width:170px; }
.raw-table th:nth-child(3), .raw-table td:nth-child(3) { width:120px; }
.raw-table th:nth-child(5), .raw-table td:nth-child(5) { width:96px; }
@@ -191,6 +192,8 @@
.sort-head:hover { color:var(--accent); }
.sort-head.active:after { content:"↓"; font-size:11px; color:var(--accent); }
.sort-head.active.asc:after { content:"↑"; }
.raw-select-head, .raw-select-cell { display:flex; align-items:center; gap:8px; margin:0; }
.raw-select-head input, .raw-select-cell input { width:16px; height:16px; margin:0; flex:0 0 auto; }
.editor-list { display:grid; gap:12px; }
.editor-card { display:grid; grid-template-columns:112px minmax(0,1fr) 132px; gap:16px; align-items:start; background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:14px; box-shadow:var(--shadow); }
.editor-media .media-strip { display:grid; grid-template-columns:repeat(2, 48px); overflow:visible; }
@@ -198,7 +201,9 @@
.editor-meta { display:flex; gap:8px; align-items:center; flex-wrap:wrap; color:var(--muted); font-size:13px; margin-bottom:8px; }
.editor-meta a { color:var(--text); font-weight:800; text-decoration:none; }
.editor-title-row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; margin-bottom:8px; }
.editor-text { white-space:pre-wrap; max-height:190px; overflow:hidden; line-height:1.55; }
.publication-badges { display:flex; gap:6px; flex-wrap:wrap; margin:0 0 8px; }
.publication-badges .pill { text-decoration:none; }
.editor-text { white-space:pre-wrap; line-height:1.55; overflow-wrap:anywhere; }
.source-compare { margin-top:10px; border-top:1px solid var(--line); padding-top:8px; }
.source-compare summary { cursor:pointer; color:var(--muted); font-weight:700; }
.compare-grid { display:grid; grid-template-columns:1.3fr .7fr; gap:14px; margin-top:8px; }
@@ -230,6 +235,7 @@
<a href="/editor">Редакторская</a>
<a href="/prompt-test">Тест промптов</a>
<a href="/workers">Воркеры</a>
<a href="/logs">Логи</a>
<a href="/users">Пользователи</a>
</nav>
<form method="post" action="/logout"><button class="btn" type="submit">{{ user.login }} · выйти</button></form>
+29 -8
View File
@@ -69,13 +69,22 @@
<span>{{ p.posted_at_fmt or p.created_at_fmt }}</span>
</div>
<div class="editor-title-row">
<span class="pill {% if p.editorial_status == 'accepted' %}ok{% elif p.editorial_status == 'rejected' %}bad{% elif p.editorial_status == 'regenerating' %}warn{% endif %}">
<span class="pill {% if p.editorial_status in ['accepted', 'published'] %}ok{% elif p.editorial_status in ['rejected', 'publish_failed'] %}bad{% elif p.editorial_status == 'regenerating' %}warn{% endif %}" title="{{ p.publication_error or p.tg_publication_url or '' }}">
{{ p.editorial_status_label }}
</span>
<span class="pill">{{ p.review_category or "без категории" }}</span>
<span class="muted">AI {{ p.qualification_score or "?" }}/10</span>
</div>
<div class="editor-text">{{ p.review_text }}</div>
<div class="publication-badges">
{% for pub in p.publication_platforms %}
{% if pub.url %}
<a class="pill {{ pub.class }}" href="{{ pub.url }}" target="_blank" title="{{ pub.error or pub.status_label }}">{{ pub.label }} · {{ pub.status_label }}</a>
{% else %}
<span class="pill {{ pub.class }}" title="{{ pub.error or pub.status_label }}">{{ pub.label }} · {{ pub.status_label }}</span>
{% endif %}
{% endfor %}
</div>
<div class="editor-text">{{ p.publication_preview_text }}</div>
<details class="source-compare">
<summary>Исходник и AI-заметки</summary>
<div class="compare-grid">
@@ -154,10 +163,11 @@
<label><span>Категория</span>
<select name="final_category">
{% for category in categories %}
<option value="{{ category }}" {% if p.review_category == category %}selected{% endif %}>{{ category }}</option>
<option value="{{ category.name }}" data-tag="{{ category.tag }}" {% if p.review_category == category.name %}selected{% endif %}>{{ category.name }}</option>
{% endfor %}
{% if p.review_category and p.review_category not in categories %}
<option value="{{ p.review_category }}" selected>{{ p.review_category }} · старая категория</option>
{% set category_names = categories | map(attribute="name") | list %}
{% if p.review_category and p.review_category not in category_names %}
<option value="{{ p.review_category }}" data-tag="{{ p.review_category_tag }}" selected>{{ p.review_category }} · старая категория</option>
{% endif %}
</select>
</label>
@@ -165,6 +175,9 @@
<input name="final_source_tag" value="{{ p.review_source_tag or p.source_tag or '' }}">
</label>
</div>
<label><span>Предпросмотр публикации</span>
<div class="publication-preview" data-publication-preview>{{ p.publication_preview_text }}</div>
</label>
<div class="hashtag-preview" data-hashtag-preview>#{{ p.review_category_tag or p.review_category or "category" }} #{{ p.review_source_tag or p.source_tag or "source" }}</div>
<label><span>Заметка редактора</span>
<textarea name="editor_notes" rows="3">{{ p.editor_notes or "" }}</textarea>
@@ -253,9 +266,11 @@
button.addEventListener("click", () => document.getElementById(button.dataset.closeDialog)?.close());
});
document.querySelectorAll(".edit-form").forEach((form) => {
const textArea = form.querySelector('[name="final_text"]');
const category = form.querySelector('[name="final_category"]');
const source = form.querySelector('[name="final_source_tag"]');
const preview = form.querySelector("[data-hashtag-preview]");
const hashtagPreview = form.querySelector("[data-hashtag-preview]");
const publicationPreview = form.querySelector("[data-publication-preview]");
const normalize = (value, fallback) => {
const tag = String(value || "").trim().replace(/^#+/, "").toLowerCase()
.replace(/\s+/g, "_")
@@ -265,9 +280,15 @@
return tag || fallback;
};
const update = () => {
if (!preview) return;
preview.textContent = `#${normalize(category?.value, "category")} #${normalize(source?.value, "source")}`;
const option = category?.selectedOptions?.[0];
const tags = `#${normalize(option?.dataset?.tag || category?.value, "category")} #${normalize(source?.value, "source")}`;
if (hashtagPreview) hashtagPreview.textContent = tags;
if (publicationPreview) {
const cleanText = String(textArea?.value || "").trim();
publicationPreview.textContent = cleanText ? `${cleanText}\n\n${tags}` : tags;
}
};
textArea?.addEventListener("input", update);
category?.addEventListener("change", update);
source?.addEventListener("input", update);
update();
+105
View File
@@ -0,0 +1,105 @@
{% extends "base.html" %}
{% block body %}
<div class="page-head">
<div>
<h1>Логи</h1>
<div class="muted">Журнал действий и запросов админки. События старше 30 дней удаляются автоматически.</div>
</div>
</div>
<section class="panel">
<form class="filter-grid" method="get" action="/logs">
<label><span>Поиск</span><input name="q" value="{{ q }}" placeholder="action, entity, JSON"></label>
<label><span>Действие</span>
<select name="action">
<option value="">Все</option>
{% for item in actions %}
<option value="{{ item.action }}" {% if action == item.action %}selected{% endif %}>{{ item.action }} · {{ item.count }}</option>
{% endfor %}
</select>
</label>
<label><span>Пользователь</span>
<select name="actor_id">
<option value="0">Все</option>
{% for item in actors %}
<option value="{{ item.id }}" {% if actor_id == item.id %}selected{% endif %}>{{ item.login }} · {{ item.count }}</option>
{% endfor %}
</select>
</label>
<label><span>Сущность</span>
<select name="entity_type">
<option value="">Все</option>
{% for item in entity_types %}
<option value="{{ item.entity_type }}" {% if entity_type == item.entity_type %}selected{% endif %}>{{ item.entity_type }} · {{ item.count }}</option>
{% endfor %}
</select>
</label>
<label><span>С даты</span><input type="date" name="date_from" value="{{ date_from }}"></label>
<label><span>По дату</span><input type="date" name="date_to" value="{{ date_to }}"></label>
<button class="btn primary filter-submit" type="submit">Показать</button>
<a class="btn filter-submit" href="/logs">Сбросить</a>
</form>
</section>
<section class="panel">
<div class="list-footer">
<div class="muted">Всего: {{ pagination.total }}</div>
<form class="row per-page-form" method="get" action="/logs">
{% for item in preserved_query %}
{% if item.key != "per_page" %}
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
{% endif %}
{% endfor %}
<label><span>На странице</span>
<select name="per_page" data-autosubmit>
{% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</label>
</form>
</div>
<table class="compact-table">
<thead>
<tr>
<th>Время</th>
<th>Пользователь</th>
<th>Действие</th>
<th>Сущность</th>
<th>Детали</th>
</tr>
</thead>
<tbody>
{% for item in logs %}
<tr>
<td class="mono">{{ item.created_at_fmt }}</td>
<td>{{ item.actor_label }}</td>
<td><span class="pill">{{ item.action }}</span></td>
<td>{{ item.entity_type }}{% if item.entity_id %} #{{ item.entity_id }}{% endif %}</td>
<td>
{% if item.details_pretty %}
<details class="nested-details">
<summary>JSON</summary>
<pre class="json-box">{{ item.details_pretty }}</pre>
</details>
{% else %}
<span class="muted">—</span>
{% endif %}
</td>
</tr>
{% else %}
<tr><td colspan="5" class="empty-state">Записей нет.</td></tr>
{% endfor %}
</tbody>
</table>
{% include "pagination.html" %}
</section>
<script>
document.querySelectorAll("[data-autosubmit]").forEach((input) => {
input.addEventListener("change", () => input.form?.submit());
});
</script>
{% endblock %}
+45 -7
View File
@@ -8,12 +8,24 @@
</div>
<div class="catalog-layout">
<section class="panel catalog-main">
<div class="list-toolbar">
<div class="muted">Всего: {{ pagination.total }}</div>
</div>
<table class="raw-table">
<form id="raw-bulk-form" method="post" action="/raw/send-to-rewrite">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="hidden" name="next_url" value="{{ request.url.path }}{% if request.url.query %}?{{ request.url.query }}{% endif %}">
<div class="list-toolbar">
<div>
<div class="muted">Всего: {{ pagination.total }}</div>
<div class="muted">Выбранные storage-ready посты можно вручную отправить в рерайт.</div>
</div>
<button class="btn primary" type="submit" data-bulk-submit disabled>Отправить в рерайт</button>
</div>
<table class="raw-table">
<tr>
<th><a class="sort-head {% if sort_headers.id.active %}active {{ sort_headers.id.direction }}{% endif %}" href="{{ sort_headers.id.url }}">#</a></th>
<th>
<label class="raw-select-head">
<input type="checkbox" data-raw-select-all aria-label="Выбрать все посты на странице">
<a class="sort-head {% if sort_headers.id.active %}active {{ sort_headers.id.direction }}{% endif %}" href="{{ sort_headers.id.url }}">#</a>
</label>
</th>
<th><a class="sort-head {% if sort_headers.source.active %}active {{ sort_headers.source.direction }}{% endif %}" href="{{ sort_headers.source.url }}">Источник</a></th>
<th><a class="sort-head {% if sort_headers.stage.active %}active {{ sort_headers.stage.direction }}{% endif %}" href="{{ sort_headers.stage.url }}">Этап</a></th>
<th><a class="sort-head {% if sort_headers.post.active %}active {{ sort_headers.post.direction }}{% endif %}" href="{{ sort_headers.post.url }}">Пост</a></th>
@@ -21,7 +33,12 @@
</tr>
{% for p in posts %}
<tr>
<td><a class="raw-id" href="/raw/{{ p.id }}">{{ p.id }}</a></td>
<td>
<label class="raw-select-cell">
<input type="checkbox" name="raw_post_id" value="{{ p.id }}" data-raw-select aria-label="Выбрать пост {{ p.id }}">
<a class="raw-id" href="/raw/{{ p.id }}">{{ p.id }}</a>
</label>
</td>
<td class="source-cell">
<a href="{{ p.original_url }}" target="_blank">{{ p.source_name }}</a>
<div class="muted">{{ p.source_platform }} · #{{ p.source_tag or "—" }}</div>
@@ -66,7 +83,8 @@
</td>
</tr>
{% endfor %}
</table>
</table>
</form>
<div class="list-footer">
<form class="row per-page-form" method="get" action="/raw">
{% for item in preserved_query %}
@@ -206,6 +224,26 @@
});
});
});
const rawChecks = Array.from(document.querySelectorAll("[data-raw-select]"));
const rawSelectAll = document.querySelector("[data-raw-select-all]");
const bulkSubmit = document.querySelector("[data-bulk-submit]");
function syncBulkState() {
const selected = rawChecks.filter((item) => item.checked).length;
if (bulkSubmit) {
bulkSubmit.disabled = selected === 0;
bulkSubmit.textContent = selected ? `Отправить в рерайт (${selected})` : "Отправить в рерайт";
}
if (rawSelectAll) {
rawSelectAll.checked = selected > 0 && selected === rawChecks.length;
rawSelectAll.indeterminate = selected > 0 && selected < rawChecks.length;
}
}
rawChecks.forEach((item) => item.addEventListener("change", syncBulkState));
rawSelectAll?.addEventListener("change", () => {
rawChecks.forEach((item) => { item.checked = rawSelectAll.checked; });
syncBulkState();
});
syncBulkState();
})();
</script>
{% endblock %}
+62
View File
@@ -0,0 +1,62 @@
<!doctype html>
<html lang="ru" data-theme="light">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<link rel="icon" type="image/png" href="/favi.png">
<title>{{ title or "VK Parser Admin V2" }}</title>
<!-- Tailwind CSS & DaisyUI -->
<link href="https://cdn.jsdelivr.net/npm/daisyui@4.10.1/dist/full.min.css" rel="stylesheet" type="text/css" />
<script src="https://cdn.tailwindcss.com"></script>
<!-- HTMX -->
<script src="https://unpkg.com/htmx.org@1.9.11"></script>
<!-- AlpineJS (optional but useful for local state) -->
<script defer src="https://cdn.jsdelivr.net/npm/alpinejs@3.13.8/dist/cdn.min.js"></script>
<style>
/* Custom tweaks to match Tailwind typography nicely */
body { font-family: 'Inter', system-ui, sans-serif; }
.htmx-indicator { display:none; }
.htmx-request .htmx-indicator { display:inline-block; }
.htmx-request.htmx-indicator { display:inline-block; }
</style>
</head>
<body class="bg-base-200 min-h-screen">
<div class="navbar bg-base-100 shadow-sm sticky top-0 z-50">
<div class="flex-1 px-2 mx-2">
<span class="text-lg font-bold">Parser V2</span>
<div class="hidden md:flex ml-6 gap-2">
<a href="/v2/raw" class="btn btn-ghost btn-sm {{ 'btn-active' if 'raw' in request.url.path }}">Raw-посты</a>
<!-- Temporary links to V1 for others -->
<a href="/editor" class="btn btn-ghost btn-sm">Редактор (V1)</a>
<a href="/workers" class="btn btn-ghost btn-sm">Воркеры (V1)</a>
</div>
</div>
<div class="flex-none hidden lg:block">
<ul class="menu menu-horizontal gap-2">
<li><a href="/" class="btn btn-outline btn-sm">Вернуться в V1</a></li>
<li>
<form method="post" action="/logout" class="m-0">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="btn btn-ghost btn-sm text-error">Выйти</button>
</form>
</li>
</ul>
</div>
</div>
<main class="container mx-auto p-4 md:p-6 max-w-7xl">
{% block body %}{% endblock %}
</main>
<script>
// Handle HTMX errors
document.body.addEventListener('htmx:responseError', function(evt) {
alert("Ошибка при загрузке: " + evt.detail.xhr.status);
});
</script>
</body>
</html>
@@ -0,0 +1,14 @@
{% extends "v2/base.html" %}
{% block body %}
<div class="mb-6 flex justify-between items-end">
<div>
<h1 class="text-3xl font-bold mb-1">Raw-посты</h1>
<div class="text-base-content/60">Исходный пост, этап обработки и AI-оценка.</div>
</div>
</div>
<div id="content-area">
{% include "v2/raw_posts_content.html" %}
</div>
{% endblock %}
@@ -0,0 +1,152 @@
<div class="flex flex-col lg:flex-row gap-6">
<!-- Sidebar Filters -->
<aside class="w-full lg:w-72 flex-shrink-0">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-4">
<form hx-get="/v2/raw" hx-target="#content-area" hx-push-url="true" hx-trigger="submit, change delay:300ms">
<input type="hidden" name="sort" value="{{ sort }}">
<div class="flex justify-between items-center mb-4">
<h2 class="card-title text-lg">Фильтры</h2>
<a href="/v2/raw" class="text-sm link link-hover text-base-content/60" hx-boost="true" hx-target="#content-area">Сбросить</a>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Оценка AI</span></label>
<div class="flex gap-2">
<input type="number" name="score_min" placeholder="От" value="{{ score_min }}" class="input input-bordered input-sm w-full" />
<input type="number" name="score_max" placeholder="До" value="{{ score_max }}" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Дата поста</span></label>
<div class="flex flex-col gap-2">
<input type="date" name="date_from" value="{{ date_from }}" class="input input-bordered input-sm w-full" />
<input type="date" name="date_to" value="{{ date_to }}" class="input input-bordered input-sm w-full" />
</div>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Квалификация</span></label>
<div class="bg-base-200 rounded-lg p-2 max-h-48 overflow-y-auto">
{% for item in facets.qualification_statuses %}
<label class="label cursor-pointer justify-start gap-2 p-1">
<input type="checkbox" name="qualification_status" value="{{ item.value }}" class="checkbox checkbox-sm checkbox-primary" {% if item.value in qualification_statuses %}checked{% endif %} />
<span class="label-text flex-1">{{ item.value }}</span>
<span class="badge badge-sm badge-ghost">{{ item.count }}</span>
</label>
{% endendfor %}
</div>
</div>
<div class="form-control w-full mb-4">
<label class="label"><span class="label-text font-bold">Рерайт</span></label>
<div class="bg-base-200 rounded-lg p-2 max-h-48 overflow-y-auto">
{% for item in facets.rewrite_statuses %}
<label class="label cursor-pointer justify-start gap-2 p-1">
<input type="checkbox" name="rewrite_status" value="{{ item.value }}" class="checkbox checkbox-sm checkbox-primary" {% if item.value in rewrite_statuses %}checked{% endif %} />
<span class="label-text flex-1">{{ item.value }}</span>
<span class="badge badge-sm badge-ghost">{{ item.count }}</span>
</label>
{% endfor %}
</div>
</div>
<button class="btn btn-primary w-full mt-4" type="submit">Показать</button>
</form>
</div>
</div>
</aside>
<!-- Main Content (Table) -->
<div class="flex-1 min-w-0">
<div class="card bg-base-100 shadow-sm border border-base-200">
<div class="card-body p-0">
<!-- Toolbar -->
<div class="p-4 border-b border-base-200 flex justify-between items-center bg-base-100/50">
<div class="text-sm text-base-content/70">
Всего: <span class="font-bold">{{ pagination.total }}</span>
</div>
<!-- Pagination top (optional, or per page select) -->
<div class="flex items-center gap-2">
<span class="text-sm">На странице:</span>
<select name="per_page" class="select select-bordered select-sm" form="pagination-form" hx-get="/v2/raw" hx-target="#content-area" hx-include="[name='date_from'], [name='date_to']">
{% for n in [25,50,100,200] %}
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
{% endfor %}
</select>
</div>
</div>
<div class="overflow-x-auto">
<table class="table table-zebra w-full text-sm">
<thead>
<tr class="bg-base-200 text-base-content">
<th class="w-16">#</th>
<th class="w-48">Источник</th>
<th class="w-32">Этап</th>
<th>Пост</th>
<th class="w-32 text-center">AI</th>
</tr>
</thead>
<tbody>
{% for p in posts %}
<tr class="hover">
<td class="font-bold">
<a href="/raw/{{ p.id }}" class="link link-primary">{{ p.id }}</a>
</td>
<td>
<a href="{{ p.original_url }}" target="_blank" class="font-medium hover:underline">{{ p.source_name }}</a>
<div class="text-xs text-base-content/60 mt-1">{{ p.source_platform }} · #{{ p.source_tag or "—" }}</div>
{% if p.posted_at_fmt %}<div class="text-xs text-base-content/60 mt-1">{{ p.posted_at_fmt }}</div>{% endif %}
</td>
<td>
<div class="badge badge-sm {% if p.stage.class == 'ok' %}badge-success{% elif p.stage.class == 'bad' %}badge-error{% elif p.stage.class == 'warn' %}badge-warning{% else %}badge-ghost{% endif %} gap-1">
{{ p.stage.label }}
</div>
{% if p.error_reason %}<div class="text-xs text-error mt-1">{{ p.error_reason }}</div>{% endif %}
</td>
<td class="max-w-md">
<div class="prose prose-sm leading-snug">
{{ p.raw_text[:200] }}{% if p.raw_text|length > 200 %}...{% endif %}
</div>
</td>
<td class="text-center">
<div class="flex flex-col items-center gap-1">
<span class="text-lg font-bold {% if p.ai_badge.class == 'ok' %}text-success{% elif p.ai_badge.class == 'bad' %}text-error{% elif p.ai_badge.class == 'warn' %}text-warning{% endif %}">
{{ p.ai_badge.label }}
</span>
<span class="text-xs text-base-content/60">{{ p.ai_badge.sublabel }}</span>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<!-- Pagination Bottom -->
<div class="p-4 border-t border-base-200 flex justify-end">
<div class="join">
{% if pagination.has_prev %}
<button class="join-item btn btn-sm" hx-get="{{ pagination.prev_url.replace('/raw', '/v2/raw') }}" hx-target="#content-area" hx-push-url="true">« Пред</button>
{% else %}
<button class="join-item btn btn-sm btn-disabled">« Пред</button>
{% endif %}
<button class="join-item btn btn-sm btn-active">Стр. {{ pagination.page }} из {{ pagination.total_pages }}</button>
{% if pagination.has_next %}
<button class="join-item btn btn-sm" hx-get="{{ pagination.next_url.replace('/raw', '/v2/raw') }}" hx-target="#content-area" hx-push-url="true">След »</button>
{% else %}
<button class="join-item btn btn-sm btn-disabled">След »</button>
{% endif %}
</div>
</div>
</div>
</div>
</div>
</div>
@@ -1,17 +1,37 @@
{% extends "base.html" %}
{% block body %}
<div class="panel" style="max-width:760px;margin:8vh auto 0;">
<h1>VK OAuth callback</h1>
{% if code %}
<p class="muted">Скопируйте code и code_verifier для обмена на access token.</p>
<h1>VK OAuth</h1>
{% if result %}
{% if result.ok %}
<p class="pill ok">Токен VK-постера сохранён</p>
<dl class="kv compact-kv">
<dt>Сообщество</dt><dd>{{ result.owner_id }}</dd>
<dt>User ID</dt><dd>{{ result.user_id or "—" }}</dd>
<dt>Живёт, сек</dt><dd>{{ result.expires_in or "—" }}</dd>
<dt>Истекает</dt><dd>{{ result.expires_at or "—" }}</dd>
<dt>Refresh token</dt><dd>{% if result.has_refresh_token %}OK{% else %}Не получен{% endif %}</dd>
<dt>wall.get</dt><dd>{% if result.wall_get_ok %}OK{% else %}Ошибка: {{ result.wall_get_error }}{% endif %}</dd>
<dt>Фото upload</dt>
<dd>
{% if result.photo_upload_server_ok %}
OK
{% else %}
Недоступно: {{ result.photo_upload_server_error }}
<div class="muted">VK выдаёт право photos для upload API отдельно; постер всё равно опубликует текст и существующие VK-вложения.</div>
{% endif %}
</dd>
</dl>
<p><a class="btn primary" href="/workers">Вернуться к воркерам</a></p>
{% else %}
<p class="pill bad">VK OAuth не завершился</p>
<p>{{ result.error_description or result.error or "Неизвестная ошибка" }}</p>
<p><a class="btn" href="/workers">Вернуться к воркерам</a> <a class="btn primary" href="/vk-oauth/start">Повторить</a></p>
{% endif %}
{% elif code %}
<p class="muted">VK вернул code. Новый обработчик должен обменивать его автоматически через <span class="mono">/vk-oauth/callback</span>.</p>
<label>code</label>
<textarea readonly style="min-height:110px;" onclick="this.select()">{{ code }}</textarea>
<label>code_verifier</label>
<textarea readonly style="min-height:110px;" onclick="this.select()">{{ code_verifier }}</textarea>
{% if device_id %}
<label>device_id</label>
<textarea readonly style="min-height:80px;" onclick="this.select()">{{ device_id }}</textarea>
{% endif %}
{% if state and expected_state and state != expected_state %}
<p class="pill bad">state не совпал, такой code лучше не использовать.</p>
{% endif %}
@@ -19,34 +39,7 @@
<p class="pill bad">{{ error }}</p>
<p>{{ error_description }}</p>
{% else %}
<p class="muted">VK вернул callback без code.</p>
<p class="muted">VK вернул callback без данных.</p>
{% endif %}
<div id="fragment-token" style="display:none;margin-top:24px;">
<h2>Access token сообщества</h2>
<p class="muted">VK передал токен в fragment URL. Скопируйте значение ниже.</p>
<textarea id="fragment-token-value" readonly style="min-height:140px;" onclick="this.select()"></textarea>
</div>
<div id="fragment-data" style="display:none;margin-top:16px;">
<h2>Параметры fragment</h2>
<textarea id="fragment-data-value" readonly style="min-height:120px;" onclick="this.select()"></textarea>
</div>
</div>
<script>
const hash = window.location.hash.replace(/^#/, "");
if (hash) {
const params = new URLSearchParams(hash);
const pairs = Array.from(params.entries());
const tokenPair = pairs.find(([key]) => key.startsWith("access_token"));
if (tokenPair) {
document.getElementById("fragment-token").style.display = "block";
document.getElementById("fragment-token-value").value = tokenPair[1];
}
if (pairs.length) {
document.getElementById("fragment-data").style.display = "block";
document.getElementById("fragment-data-value").value = pairs
.map(([key, value]) => `${key}=${value}`)
.join("\n");
}
}
</script>
{% endblock %}
+79 -3
View File
@@ -19,15 +19,18 @@
<details class="panel" open>
<summary><strong>Категории публикаций</strong></summary>
<p class="muted">Активные категории попадают в редакторскую и передаются AI-райтеру как ID, название и тэг. ID стабилен: модель возвращает его в JSON, а тэг подставляется из БД.</p>
<p class="muted">Название описывает категорию для AI, тэг используется в соцсетях. Название и URL сайта хранятся отдельно и не меняют рабочие хэштеги.</p>
<form class="category-create" method="post" action="/categories/create">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<label><span>Название</span><input name="name" placeholder="например, разгрузка" required></label>
<label><span>Тэг</span><input name="tag" placeholder="если пусто, соберётся из названия"></label>
<label><span>На сайте</span><input name="site_name" placeholder="Снаряжение" required></label>
<label><span>URL сайта</span><input name="site_slug" placeholder="snaryazhenie" pattern="[a-z0-9-]+" required></label>
<label class="checkline"><input type="checkbox" name="site_enabled" checked> Публиковать</label>
<button class="btn primary" type="submit">Добавить</button>
</form>
<table class="compact-table category-table">
<tr><th>Название</th><th>Тэг</th><th>ID</th><th>Статус</th><th></th></tr>
<tr><th>Название AI</th><th>Тэг</th><th>Название сайта</th><th>URL сайта</th><th>ID</th><th>Статус</th><th></th></tr>
{% for c in categories %}
<tr>
<td>
@@ -37,8 +40,10 @@
</form>
</td>
<td><input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}"></td>
<td><input form="category-update-{{ c.id }}" name="site_name" value="{{ c.site_name }}" required></td>
<td><input form="category-update-{{ c.id }}" name="site_slug" value="{{ c.site_slug }}" pattern="[a-z0-9-]+" required></td>
<td><input value="{{ c.sort_order }}" type="number" readonly title="Стабильный ID категории для AI-райтера"></td>
<td>{% if c.is_active %}<span class="pill ok">активна</span>{% else %}<span class="pill">выключена</span>{% endif %}</td>
<td>{% if c.is_active %}<span class="pill ok">активна</span>{% else %}<span class="pill">выключена</span>{% endif %}<label class="checkline"><input form="category-update-{{ c.id }}" type="checkbox" name="site_enabled" {% if c.site_enabled %}checked{% endif %}> сайт</label></td>
<td class="icon-actions">
<button class="icon-btn" form="category-update-{{ c.id }}" type="submit" title="Сохранить">✓</button>
<form method="post" action="/categories/{{ c.id }}/toggle">
@@ -55,6 +60,75 @@
</table>
</details>
<details class="panel" open>
<summary><strong>Расписание TG-постера</strong></summary>
<p class="muted">Время публикации задаётся по Екатеринбургу. Для запуска нужны оба флага: worker <span class="mono">tg-poster</span> и настройка <span class="mono">tg_poster_enabled</span>.</p>
<form class="category-create" method="post" action="/tg-poster-schedule/create">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<label><span>Время Екб</span><input type="time" name="time" value="10:00" required></label>
<label><span>Постов</span><input type="number" name="count" value="1" min="1" max="20" required></label>
<label class="checkline"><input type="checkbox" name="enabled" checked> Активно</label>
<button class="btn primary" type="submit">Добавить</button>
</form>
<table class="compact-table category-table">
<tr><th>Время Екб</th><th>Постов</th><th>Статус</th><th></th></tr>
{% for row in tg_schedule %}
<tr>
<td>
<form id="tg-schedule-update-{{ row.id }}" method="post" action="/tg-poster-schedule/{{ row.id }}/update">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="time" name="time" value="{{ row.time }}" required>
</form>
</td>
<td><input form="tg-schedule-update-{{ row.id }}" type="number" name="count" value="{{ row.count }}" min="1" max="20" required></td>
<td><label class="checkline"><input form="tg-schedule-update-{{ row.id }}" type="checkbox" name="enabled" {% if row.enabled %}checked{% endif %}> Активно</label></td>
<td class="icon-actions">
<button class="icon-btn" form="tg-schedule-update-{{ row.id }}" type="submit" title="Сохранить">✓</button>
<form method="post" action="/tg-poster-schedule/{{ row.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn danger" type="submit" title="Удалить">×</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</details>
<details class="panel" open>
<summary><strong>Расписание VK-постера</strong></summary>
<p class="muted">Время публикации задаётся по Екатеринбургу. Для запуска нужны оба флага: worker <span class="mono">vk-poster</span> и настройка <span class="mono">vk_poster_enabled</span>.</p>
<p><a class="btn" href="/vk-oauth/start">Получить VK user token</a></p>
<form class="category-create" method="post" action="/vk-poster-schedule/create">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<label><span>Время Екб</span><input type="time" name="time" value="10:05" required></label>
<label><span>Постов</span><input type="number" name="count" value="1" min="1" max="20" required></label>
<label class="checkline"><input type="checkbox" name="enabled" checked> Активно</label>
<button class="btn primary" type="submit">Добавить</button>
</form>
<table class="compact-table category-table">
<tr><th>Время Екб</th><th>Постов</th><th>Статус</th><th></th></tr>
{% for row in vk_schedule %}
<tr>
<td>
<form id="vk-schedule-update-{{ row.id }}" method="post" action="/vk-poster-schedule/{{ row.id }}/update">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<input type="time" name="time" value="{{ row.time }}" required>
</form>
</td>
<td><input form="vk-schedule-update-{{ row.id }}" type="number" name="count" value="{{ row.count }}" min="1" max="20" required></td>
<td><label class="checkline"><input form="vk-schedule-update-{{ row.id }}" type="checkbox" name="enabled" {% if row.enabled %}checked{% endif %}> Активно</label></td>
<td class="icon-actions">
<button class="icon-btn" form="vk-schedule-update-{{ row.id }}" type="submit" title="Сохранить">✓</button>
<form method="post" action="/vk-poster-schedule/{{ row.id }}/delete">
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
<button class="icon-btn danger" type="submit" title="Удалить">×</button>
</form>
</td>
</tr>
{% endfor %}
</table>
</details>
<h2>Настройки</h2>
{% for category, rows in settings|groupby("category") %}
<details class="panel" {% if category == "AI Qualifier" %}open{% endif %}>
@@ -126,6 +200,8 @@
{% endif %}
<textarea name="value" rows="12" style="width:720px; max-width:100%;">{{ s.value_json }}</textarea>
</div>
{% elif s.value_type == "json" %}
<textarea name="value" rows="5" style="width:520px; max-width:100%;" spellcheck="false">{{ s.value_json | tojson }}</textarea>
{% else %}
<input name="value" value="{{ s.value_json }}" style="width:180px;">
{% endif %}