Initial parser admin implementation
This commit is contained in:
@@ -0,0 +1,229 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<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); }
|
||||
* { box-sizing: border-box; }
|
||||
body { margin:0; font:14px/1.45 Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Arial, sans-serif; color:var(--text); background:var(--bg); }
|
||||
header { position:sticky; top:0; z-index:40; min-height:58px; display:flex; align-items:center; justify-content:space-between; gap:16px; padding:0 24px; background:rgba(255,255,255,.92); border-bottom:1px solid var(--line); backdrop-filter:blur(12px); }
|
||||
nav { display:flex; gap:4px; align-items:center; flex-wrap:wrap; }
|
||||
nav a { color:var(--muted); text-decoration:none; font-weight:700; padding:8px 10px; border-radius:7px; }
|
||||
nav a:hover { color:var(--text); background:var(--panel-soft); }
|
||||
main { padding:26px 24px 40px; max-width:1440px; margin:0 auto; }
|
||||
h1 { font-size:34px; line-height:1.1; margin:0 0 8px; letter-spacing:0; }
|
||||
h2 { font-size:21px; line-height:1.2; margin:0 0 8px; letter-spacing:0; }
|
||||
.panel { background:var(--panel); border:1px solid var(--line); border-radius:8px; padding:16px; margin-bottom:16px; box-shadow:var(--shadow); }
|
||||
details.panel summary { cursor:pointer; list-style:none; font-size:16px; margin:-4px 0 12px; }
|
||||
details.panel summary::-webkit-details-marker { display:none; }
|
||||
details.panel summary:before { content:"▸"; display:inline-block; width:18px; color:var(--muted); }
|
||||
details.panel[open] summary:before { content:"▾"; }
|
||||
.toolbar { display:flex; gap:10px; align-items:end; justify-content:space-between; flex-wrap:wrap; margin-bottom:14px; }
|
||||
.row { display:flex; gap:8px; align-items:center; flex-wrap:wrap; }
|
||||
table { width:100%; border-collapse:separate; border-spacing:0; background:var(--panel); }
|
||||
th, td { padding:10px; border-bottom:1px solid var(--line); text-align:left; vertical-align:top; }
|
||||
th { color:var(--muted); font-size:12px; text-transform:uppercase; }
|
||||
input, select, textarea { width:100%; padding:9px 10px; border:1px solid var(--line); border-radius:7px; font:inherit; background:#fff; color:var(--text); outline:none; }
|
||||
input:focus, select:focus, textarea:focus { border-color:var(--accent); box-shadow:0 0 0 3px rgba(36,95,214,.12); }
|
||||
label { display:block; color:var(--muted); font-weight:600; margin-bottom:8px; }
|
||||
label span { display:block; margin-bottom:4px; }
|
||||
.form-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:14px; }
|
||||
.filter-grid { display:grid; grid-template-columns:repeat(auto-fit, minmax(150px, 1fr)); gap:12px; align-items:end; }
|
||||
.sources-filter { grid-template-columns:minmax(260px, 1fr) 160px 140px auto; }
|
||||
.filter-submit { height:38px; margin-bottom:0; }
|
||||
.btn { display:inline-flex; align-items:center; justify-content:center; gap:6px; min-height:36px; padding:8px 12px; border:1px solid var(--line); border-radius:7px; background:#fff; color:var(--text); text-decoration:none; cursor:pointer; font-weight:700; white-space:nowrap; }
|
||||
.btn:hover { border-color:var(--line-strong); background:var(--panel-soft); }
|
||||
.btn.primary { background:var(--accent); border-color:var(--accent); color:#fff; }
|
||||
.btn.danger { color:var(--danger); }
|
||||
.pill { display:inline-flex; padding:3px 8px; border-radius:999px; border:1px solid var(--line); color:var(--muted); font-size:12px; }
|
||||
.pill.ok { color:var(--ok); border-color:#a6f4c5; background:#ecfdf3; }
|
||||
.pill.bad { color:var(--danger); border-color:#fecdca; background:#fef3f2; }
|
||||
.pill.warn { color:#92400e; border-color:#fedf89; background:#fffaeb; }
|
||||
.muted { color:var(--muted); }
|
||||
.mono { font-family: Consolas, monospace; }
|
||||
.actions { display:flex; gap:6px; flex-wrap:wrap; }
|
||||
.compact-table th, .compact-table td { padding:7px 8px; }
|
||||
.source-state { margin-left:6px; }
|
||||
.source-url { max-width:300px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.status-msg { max-width:220px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
|
||||
.icon-actions { display:flex; gap:4px; align-items:center; justify-content:flex-end; white-space:nowrap; }
|
||||
.icon-actions form { margin:0; }
|
||||
.icon-btn { width:30px; height:30px; display:inline-flex; align-items:center; justify-content:center; border:1px solid var(--line); border-radius:6px; background:#fff; color:var(--text); text-decoration:none; cursor:pointer; font:18px/1 Arial, sans-serif; }
|
||||
.icon-btn:hover { border-color:#b9c0cc; background:#f8fafc; }
|
||||
.icon-btn.danger { color:var(--danger); }
|
||||
.source-add-grid { display:grid; grid-template-columns:minmax(260px, 360px) minmax(360px, 1fr); gap:18px; align-items:start; }
|
||||
.checkline { display:flex; gap:8px; align-items:center; color:var(--text); font-weight:600; }
|
||||
.checkline input { width:auto; }
|
||||
.preview-block { margin-top:16px; border-top:1px solid var(--line); padding-top:14px; }
|
||||
.pagination { display:flex; justify-content:flex-end; margin-top:12px; }
|
||||
.pagination-form { display:flex; gap:8px; align-items:center; }
|
||||
.pagination button[disabled] { opacity:.45; cursor:not-allowed; }
|
||||
.raw-id { color:var(--text); font-weight:700; text-decoration:none; }
|
||||
.source-cell { min-width:150px; }
|
||||
.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(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; }
|
||||
.raw-content-cell { max-width:none; min-width:0; overflow-wrap:anywhere; }
|
||||
.raw-text-details { margin-top:8px; }
|
||||
.raw-text-details summary { cursor:pointer; color:var(--text); white-space:pre-wrap; }
|
||||
.raw-full-text { margin-top:8px; white-space:pre-wrap; overflow-wrap:anywhere; }
|
||||
.raw-text-details summary { overflow-wrap:anywhere; }
|
||||
.raw-content-cell > .raw-full-text:first-child,
|
||||
.media-strip + .raw-full-text { margin-top:8px; }
|
||||
.media-strip { display:flex; gap:6px; max-width:100%; overflow-x:auto; padding:1px 0 5px; margin-bottom:2px; }
|
||||
.media-thumb { flex:0 0 48px; width:48px; height:48px; padding:0; border:1px solid var(--line); border-radius:6px; overflow:hidden; display:flex; align-items:center; justify-content:center; background:#eef1f6; color:var(--text); text-decoration:none; cursor:pointer; }
|
||||
.media-thumb img { width:100%; height:100%; object-fit:cover; display:block; }
|
||||
.media-video span { font-size:18px; line-height:1; }
|
||||
.media-count { font-size:12px; margin-bottom:4px; }
|
||||
.raw-meta { display:grid; grid-template-columns:1fr; gap:8px; min-width:130px; }
|
||||
.raw-links { display:flex; flex-direction:column; gap:3px; }
|
||||
.raw-dates { display:grid; gap:2px; color:var(--text); }
|
||||
.post-meta { margin-top:8px; color:var(--muted); font-size:12px; }
|
||||
.ai-cell { width:96px; }
|
||||
.ai-badge { width:82px; min-height:58px; display:flex; flex-direction:column; align-items:center; justify-content:center; gap:2px; border:1px solid var(--line); border-radius:8px; color:var(--muted); text-decoration:none; background:#f8fafc; }
|
||||
.ai-badge span { color:var(--text); font-size:18px; line-height:1; font-weight:800; }
|
||||
.ai-badge small { max-width:72px; overflow:hidden; text-overflow:ellipsis; white-space:nowrap; font-size:11px; }
|
||||
.ai-badge.ok { border-color:#a6f4c5; background:#ecfdf3; color:var(--ok); }
|
||||
.ai-badge.ok span { color:var(--ok); }
|
||||
.ai-badge.warn { border-color:#fedf89; background:#fffaeb; color:#92400e; }
|
||||
.ai-badge.warn span { color:#92400e; }
|
||||
.ai-badge.bad { border-color:#fecdca; background:#fef3f2; color:var(--danger); }
|
||||
.ai-badge.bad span { color:var(--danger); }
|
||||
.ai-model-decision { width:92px; margin-top:5px; font-size:12px; overflow:hidden; text-overflow:ellipsis; }
|
||||
.detail-grid { display:grid; grid-template-columns:minmax(0, 1fr) 320px; gap:16px; align-items:start; }
|
||||
.side-panel { position:sticky; top:72px; }
|
||||
.detail-media { padding-bottom:8px; }
|
||||
.detail-thumb { flex-basis:72px; width:72px; height:72px; }
|
||||
.editable-media { position:relative; flex:0 0 auto; }
|
||||
.editable-media.marked-delete { opacity:.38; filter:grayscale(1); }
|
||||
.media-remove { position:absolute; top:-6px; right:-6px; width:22px; height:22px; border:1px solid var(--line); border-radius:999px; background:#fff; color:var(--danger); font-weight:900; cursor:pointer; box-shadow:var(--shadow); }
|
||||
.media-upload-form { display:block; margin:8px 0 8px; }
|
||||
.detail-text { font-size:15px; line-height:1.55; }
|
||||
.detail-ai { width:110px; min-height:72px; margin:8px 0 16px; }
|
||||
.rewrite-text { white-space:pre-wrap; font-size:16px; line-height:1.55; max-width:820px; }
|
||||
.rewrite-meta { margin-top:14px; }
|
||||
.kv { display:grid; grid-template-columns:96px minmax(0,1fr); gap:8px 12px; margin:0; }
|
||||
.kv dt { color:var(--muted); font-weight:700; }
|
||||
.kv dd { margin:0; overflow-wrap:anywhere; }
|
||||
.kv-wide { grid-template-columns:180px minmax(0,1fr); margin-bottom:14px; }
|
||||
.json-box { white-space:pre-wrap; overflow:auto; max-height:420px; padding:12px; border:1px solid var(--line); border-radius:8px; background:#0f172a; color:#e2e8f0; }
|
||||
.prompt-editor { display:grid; gap:8px; }
|
||||
.prompt-hint { max-width:720px; border:1px solid var(--line); border-radius:8px; padding:10px 12px; background:#f8fafc; display:grid; gap:8px; color:var(--text); }
|
||||
.prompt-hint summary { cursor:pointer; font-weight:700; }
|
||||
.prompt-hint pre, .prompt-box { white-space:pre-wrap; overflow:auto; padding:10px; border:1px solid var(--line); border-radius:8px; background:#f8fafc; color:var(--text); }
|
||||
.hint-grid { display:grid; grid-template-columns:repeat(2, minmax(0,1fr)); gap:10px; }
|
||||
.hint-label { color:var(--muted); font-size:12px; font-weight:700; text-transform:uppercase; margin-bottom:3px; }
|
||||
.small-help { font-size:12px; margin-top:4px; }
|
||||
.secret-editor { display:grid; gap:3px; }
|
||||
.secret-visible { font-family:Consolas, monospace; }
|
||||
.category-create { display:grid; grid-template-columns:minmax(180px, 1fr) minmax(180px, 1fr) auto; gap:10px; align-items:end; margin:10px 0 14px; }
|
||||
.category-create label { margin-bottom:0; }
|
||||
.category-table input { min-height:34px; padding:7px 8px; }
|
||||
.category-table th:nth-child(3), .category-table td:nth-child(3) { width:96px; }
|
||||
.category-table th:nth-child(4), .category-table td:nth-child(4) { width:110px; }
|
||||
.category-table th:nth-child(5), .category-table td:nth-child(5) { width:120px; }
|
||||
.advanced-tools { grid-column:1 / -1; border-top:1px solid var(--line); margin-top:4px; padding-top:10px; }
|
||||
.advanced-tools summary { cursor:pointer; font-weight:700; color:var(--text); }
|
||||
.advanced-grid { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:16px; margin-top:10px; }
|
||||
.tool-title { font-weight:700; margin-bottom:6px; }
|
||||
.tool-row { display:grid; grid-template-columns:minmax(130px, 1.1fr) 120px minmax(130px, 1fr); gap:8px; margin-bottom:8px; }
|
||||
.tool-row.sort-row { grid-template-columns:minmax(170px, 1fr) 150px; }
|
||||
.nested-details { margin:12px 0; }
|
||||
.nested-details summary { cursor:pointer; font-weight:700; }
|
||||
.gallery-modal { position:fixed; inset:0; z-index:5000; display:none; align-items:center; justify-content:center; padding:48px; background:rgba(15,18,25,.86); border:0; width:100vw; height:100vh; max-width:none; max-height:none; }
|
||||
.gallery-modal::backdrop { background:rgba(15,18,25,.86); }
|
||||
.gallery-modal.open { display:flex; }
|
||||
.gallery-modal img { max-width:calc(100vw - 130px); max-height:calc(100vh - 120px); object-fit:contain; border-radius:8px; box-shadow:0 20px 60px rgba(0,0,0,.45); background:#111; }
|
||||
.gallery-close, .gallery-nav { position:absolute; border:0; background:rgba(255,255,255,.12); color:#fff; cursor:pointer; }
|
||||
.gallery-close { top:18px; right:22px; width:38px; height:38px; border-radius:50%; font-size:30px; line-height:1; }
|
||||
.gallery-nav { top:50%; transform:translateY(-50%); width:44px; height:64px; border-radius:8px; font-size:42px; line-height:1; }
|
||||
.gallery-prev { left:22px; }
|
||||
.gallery-next { right:22px; }
|
||||
.gallery-title { position:absolute; left:24px; bottom:18px; color:#fff; font-weight:600; }
|
||||
.page-head { display:flex; align-items:flex-end; justify-content:space-between; gap:16px; margin-bottom:16px; }
|
||||
.status-tabs { display:flex; gap:6px; flex-wrap:wrap; justify-content:flex-end; }
|
||||
.tab { display:inline-flex; align-items:center; gap:7px; min-height:34px; padding:7px 10px; border:1px solid var(--line); border-radius:999px; color:var(--muted); background:#fff; text-decoration:none; font-weight:700; }
|
||||
.tab span { color:var(--text); background:var(--panel-soft); border-radius:999px; padding:1px 7px; font-size:12px; }
|
||||
.tab.active { color:var(--accent); border-color:#b7caf8; background:var(--accent-soft); }
|
||||
.editor-filter { grid-template-columns:180px 180px 140px auto; }
|
||||
.catalog-layout { display:grid; grid-template-columns:minmax(0, 1fr) 306px; gap:16px; align-items:start; }
|
||||
.catalog-main { min-width:0; overflow:hidden; }
|
||||
.filter-sidebar { position:sticky; top:74px; max-height:calc(100vh - 92px); overflow:auto; overflow-x:hidden; padding:14px; }
|
||||
.filter-sidebar input, .filter-sidebar select { min-width:0; }
|
||||
.filter-head { display:flex; align-items:center; justify-content:space-between; gap:10px; margin-bottom:8px; }
|
||||
.filter-head h2 { margin:0; }
|
||||
.filter-head a { color:var(--muted); font-weight:700; text-decoration:none; }
|
||||
.filter-section { border-top:1px solid var(--line); padding-top:12px; margin-top:12px; }
|
||||
.filter-title { font-weight:800; margin-bottom:8px; }
|
||||
.range-row { display:grid; grid-template-columns:minmax(0,1fr) minmax(0,1fr); gap:8px; }
|
||||
.facet-search { margin-bottom:8px; }
|
||||
.facet-list { display:grid; gap:4px; max-height:220px; overflow:auto; padding-right:3px; }
|
||||
.facet-list.compact { max-height:160px; }
|
||||
.facet-option { display:grid; grid-template-columns:auto minmax(0,1fr) auto; align-items:center; gap:8px; margin:0; padding:6px 7px; border-radius:7px; color:var(--text); font-weight:600; cursor:pointer; }
|
||||
.facet-option:hover { background:var(--panel-soft); }
|
||||
.facet-option input { width:16px; height:16px; margin:0; }
|
||||
.facet-option span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; margin:0; }
|
||||
.facet-option small { color:var(--muted); font-weight:700; }
|
||||
.filter-apply { width:100%; margin-top:14px; }
|
||||
.list-toolbar { display:flex; justify-content:space-between; align-items:end; gap:12px; margin-bottom:12px; }
|
||||
.list-footer { display:flex; align-items:center; justify-content:space-between; gap:12px; flex-wrap:wrap; margin-top:12px; }
|
||||
.list-footer .pagination { margin-top:0; }
|
||||
.per-page-form label { width:126px; margin-bottom:0; }
|
||||
.compact-controls { justify-content:flex-end; }
|
||||
.compact-controls label { width:170px; margin-bottom:0; }
|
||||
.sort-head { display:inline-flex; align-items:center; gap:4px; color:inherit; text-decoration:none; }
|
||||
.sort-head:hover { color:var(--accent); }
|
||||
.sort-head.active:after { content:"↓"; font-size:11px; color:var(--accent); }
|
||||
.sort-head.active.asc:after { content:"↑"; }
|
||||
.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; }
|
||||
.editor-main { min-width:0; }
|
||||
.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; }
|
||||
.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; }
|
||||
.editor-actions { display:grid; gap:8px; }
|
||||
.editor-actions form, .editor-actions button { width:100%; }
|
||||
.editor-dialog { width:min(1180px, calc(100vw - 36px)); max-height:calc(100vh - 36px); border:1px solid var(--line); border-radius:8px; padding:0; box-shadow:0 24px 80px rgba(16,24,40,.28); }
|
||||
.editor-dialog::backdrop { background:rgba(15,23,42,.56); backdrop-filter:blur(4px); }
|
||||
.dialog-head { position:sticky; top:0; z-index:2; display:flex; align-items:center; justify-content:space-between; gap:12px; padding:16px 18px; border-bottom:1px solid var(--line); background:#fff; }
|
||||
.dialog-grid { display:grid; grid-template-columns:420px minmax(0,1fr); gap:0; min-height:620px; }
|
||||
.context-pane { border-right:1px solid var(--line); background:var(--panel-soft); padding:16px; overflow:auto; max-height:calc(100vh - 112px); }
|
||||
.context-pane details { border-top:1px solid var(--line); padding-top:10px; margin-top:12px; }
|
||||
.context-pane summary { cursor:pointer; font-weight:800; }
|
||||
.edit-pane { padding:16px; overflow:auto; max-height:calc(100vh - 112px); }
|
||||
.edit-form textarea[name="final_text"] { min-height:360px; resize:vertical; line-height:1.55; }
|
||||
.hashtag-preview { margin:-2px 0 10px; color:var(--muted); font-family:Consolas, monospace; }
|
||||
.dialog-actions { display:flex; gap:8px; justify-content:flex-end; flex-wrap:wrap; }
|
||||
.regen-form { margin-top:16px; border-top:1px solid var(--line); padding-top:14px; }
|
||||
.compact-kv { margin-top:12px; grid-template-columns:90px minmax(0,1fr); }
|
||||
.empty-state { text-align:center; color:var(--muted); padding:34px; }
|
||||
@media (max-width: 900px) { header { padding:8px 12px; align-items:flex-start; } .filter-grid, .sources-filter, .source-add-grid, .hint-grid, .advanced-grid, .tool-row, .tool-row.sort-row, .editor-filter, .page-head, .compare-grid, .dialog-grid, .catalog-layout, .media-upload-form, .category-create { grid-template-columns:1fr; } .page-head { display:grid; } .form-grid { grid-template-columns:1fr; } .detail-grid { grid-template-columns:1fr; } .editor-card { grid-template-columns:1fr; } .editor-actions { grid-template-columns:repeat(3, minmax(0,1fr)); } .context-pane { border-right:0; border-bottom:1px solid var(--line); max-height:none; } .edit-pane { max-height:none; } .side-panel, .filter-sidebar { position:static; max-height:none; } .list-toolbar { display:grid; align-items:start; } .compact-controls { justify-content:start; } main { padding:14px; } }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
{% if user %}
|
||||
<header>
|
||||
<nav>
|
||||
<a href="/sources">Источники</a>
|
||||
<a href="/raw">Raw</a>
|
||||
<a href="/editor">Редакторская</a>
|
||||
<a href="/workers">Воркеры</a>
|
||||
<a href="/users">Пользователи</a>
|
||||
</nav>
|
||||
<form method="post" action="/logout"><button class="btn" type="submit">{{ user.login }} · выйти</button></form>
|
||||
</header>
|
||||
{% endif %}
|
||||
<main>{% block body %}{% endblock %}</main>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,338 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="page-head">
|
||||
<div>
|
||||
<h1>Редакторская</h1>
|
||||
<div class="muted">Посты после AI-рерайта: быстрая проверка, правка и принятие.</div>
|
||||
</div>
|
||||
<div class="status-tabs">
|
||||
{% for st in status_options %}
|
||||
<a class="tab {% if st.value in editorial_statuses %}active{% endif %}" href="/editor?editorial_status={{ st.value }}">
|
||||
{{ st.label }} <span>{{ counts.get(st.value, 0) }}</span>
|
||||
</a>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="catalog-layout editor-catalog">
|
||||
<section class="catalog-main">
|
||||
<div class="panel list-toolbar">
|
||||
<div class="muted">Всего: {{ pagination.total }}</div>
|
||||
<form class="row compact-controls" method="get" action="/editor">
|
||||
{% for item in preserved_query %}
|
||||
{% if item.key != "per_page" and item.key != "sort" %}
|
||||
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<label><span>Сортировка</span>
|
||||
<select name="sort" data-autosubmit>
|
||||
<option value="rewritten_desc" {% if sort == "rewritten_desc" %}selected{% endif %}>рерайт: новые</option>
|
||||
<option value="rewritten_asc" {% if sort == "rewritten_asc" %}selected{% endif %}>рерайт: старые</option>
|
||||
<option value="posted_desc" {% if sort == "posted_desc" %}selected{% endif %}>VK: новые</option>
|
||||
<option value="posted_asc" {% if sort == "posted_asc" %}selected{% endif %}>VK: старые</option>
|
||||
<option value="score_desc" {% if sort == "score_desc" %}selected{% endif %}>оценка: выше</option>
|
||||
<option value="score_asc" {% if sort == "score_asc" %}selected{% endif %}>оценка: ниже</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>На странице</span>
|
||||
<select name="per_page" data-autosubmit>
|
||||
{% for n in [10,25,50,100] %}
|
||||
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="editor-list">
|
||||
{% for p in posts %}
|
||||
<article class="editor-card">
|
||||
<div class="editor-media">
|
||||
<div class="media-strip">
|
||||
{% for m in p.media_items %}
|
||||
{% if m.type == "photo" and m.url %}
|
||||
<button class="media-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ p.id }} · фото {{ loop.index }}" title="{{ m.status }}">
|
||||
<img src="{{ m.url }}" alt="photo">
|
||||
</button>
|
||||
{% elif m.url %}
|
||||
<a class="media-thumb media-video" href="{{ m.url }}" target="_blank" title="{{ m.error or m.status }}"><span>▶</span></a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
<div class="muted small-help">{{ p.media_count or 0 }} медиа</div>
|
||||
</div>
|
||||
|
||||
<div class="editor-main">
|
||||
<div class="editor-meta">
|
||||
<a href="{{ p.original_url }}" target="_blank">{{ p.source_name }}</a>
|
||||
<span>#{{ p.review_source_tag or p.source_tag or "source" }}</span>
|
||||
<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 %}">
|
||||
{{ 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>
|
||||
<details class="source-compare">
|
||||
<summary>Исходник и AI-заметки</summary>
|
||||
<div class="compare-grid">
|
||||
<div>
|
||||
<div class="hint-label">Оригинал</div>
|
||||
<div class="raw-full-text">{{ p.raw_text }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hint-label">Заметки</div>
|
||||
<div>{{ p.rewrite_notes or p.qualification_reason or "—" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<div class="editor-actions">
|
||||
<form method="post" action="/editor/{{ p.id }}/accept">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="btn primary" type="submit">Принять</button>
|
||||
</form>
|
||||
<button class="btn" type="button" data-open-editor="edit-{{ p.id }}">Редактировать</button>
|
||||
<form method="post" action="/editor/{{ p.id }}/reject" onsubmit="return confirm('Отклонить пост #{{ p.id }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="btn danger" type="submit">Отклонить</button>
|
||||
</form>
|
||||
</div>
|
||||
</article>
|
||||
|
||||
<dialog class="editor-dialog" id="edit-{{ p.id }}">
|
||||
<div class="dialog-head">
|
||||
<div>
|
||||
<h2>Пост #{{ p.id }}</h2>
|
||||
<div class="muted"><a href="{{ p.original_url }}" target="_blank">{{ p.source_name }}</a> · {{ p.posted_at_fmt or p.created_at_fmt }}</div>
|
||||
</div>
|
||||
<button class="icon-btn" type="button" data-close-dialog="edit-{{ p.id }}">×</button>
|
||||
</div>
|
||||
<form method="post" action="/editor/{{ p.id }}/save" class="edit-form dialog-grid" enctype="multipart/form-data">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<aside class="context-pane">
|
||||
<div class="media-strip detail-media">
|
||||
{% for m in p.media_items %}
|
||||
<div class="editable-media" data-media-id="{{ m.id }}">
|
||||
{% if m.type == "photo" and m.url %}
|
||||
<button class="media-thumb detail-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ p.id }} · фото {{ loop.index }}" title="{{ m.status }}">
|
||||
<img src="{{ m.url }}" alt="photo">
|
||||
</button>
|
||||
{% elif m.url %}
|
||||
<a class="media-thumb detail-thumb media-video" href="{{ m.url }}" target="_blank"><span>▶</span></a>
|
||||
{% endif %}
|
||||
<button class="media-remove" type="button" data-mark-media-delete="{{ m.id }}" title="Убрать после сохранения">×</button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
<label class="media-upload-form"><span>Добавить медиа после сохранения</span><input type="file" name="media_files" accept="image/*,video/*,.pdf,.doc,.docx" multiple></label>
|
||||
<div class="pending-media-list muted small-help" data-pending-media-list>Выбранные файлы появятся после сохранения.</div>
|
||||
<details open>
|
||||
<summary>Исходный пост</summary>
|
||||
<div class="raw-full-text">{{ p.raw_text }}</div>
|
||||
</details>
|
||||
<details>
|
||||
<summary>AI-рерайт</summary>
|
||||
<div class="raw-full-text">{{ p.rewritten_text }}</div>
|
||||
</details>
|
||||
<dl class="kv compact-kv">
|
||||
<dt>Оценка</dt><dd>{{ p.qualification_score or "—" }}/10</dd>
|
||||
<dt>Категория</dt><dd>{{ p.rewrite_category or "—" }}</dd>
|
||||
<dt>Модель</dt><dd>{{ p.rewrite_model or "—" }}</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
|
||||
<section class="edit-pane">
|
||||
<label><span>Текст публикации</span>
|
||||
<textarea name="final_text" rows="15">{{ p.review_text }}</textarea>
|
||||
</label>
|
||||
<div class="form-grid">
|
||||
<label><span>Категория</span>
|
||||
<select name="final_category">
|
||||
{% for category in categories %}
|
||||
<option value="{{ category }}" {% if p.review_category == category %}selected{% endif %}>{{ category }}</option>
|
||||
{% endfor %}
|
||||
{% if p.review_category and p.review_category not in categories %}
|
||||
<option value="{{ p.review_category }}" selected>{{ p.review_category }} · старая категория</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Тэг источника</span>
|
||||
<input name="final_source_tag" value="{{ p.review_source_tag or p.source_tag or '' }}">
|
||||
</label>
|
||||
</div>
|
||||
<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>
|
||||
</label>
|
||||
<div class="dialog-actions">
|
||||
<button class="btn" type="submit" name="action" value="save">Сохранить правки</button>
|
||||
<button class="btn primary" type="submit" name="action" value="accept">Принять к публикации</button>
|
||||
</div>
|
||||
</section>
|
||||
</form>
|
||||
</dialog>
|
||||
{% else %}
|
||||
<div class="empty-state panel">В этой очереди пока нет постов.</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
|
||||
{% include "pagination.html" %}
|
||||
</section>
|
||||
|
||||
<aside class="filter-sidebar panel">
|
||||
<form method="get" action="/editor" data-filter-form>
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<div class="filter-head">
|
||||
<h2>Фильтры</h2>
|
||||
<a href="/editor">Сбросить</a>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Оценка AI</div>
|
||||
<div class="range-row">
|
||||
<input name="score_min" inputmode="numeric" placeholder="От" value="{{ score_min }}">
|
||||
<input name="score_max" inputmode="numeric" placeholder="До" value="{{ score_max }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Дата поста</div>
|
||||
<div class="range-row">
|
||||
<input type="date" name="date_from" value="{{ date_from }}">
|
||||
<input type="date" name="date_to" value="{{ date_to }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Источник</div>
|
||||
<input class="facet-search" type="search" placeholder="Найти источник" data-facet-search="editor-sources">
|
||||
<div class="facet-list" data-facet-list="editor-sources">
|
||||
{% for item in facets.sources %}
|
||||
<label class="facet-option"><input type="checkbox" name="source_id" value="{{ item.id }}" {% if item.id in source_ids %}checked{% endif %}> <span>{{ item.name }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Категория</div>
|
||||
<input class="facet-search" type="search" placeholder="Найти категорию" data-facet-search="editor-categories">
|
||||
<div class="facet-list" data-facet-list="editor-categories">
|
||||
{% for item in facets.categories %}
|
||||
<label class="facet-option"><input type="checkbox" name="category" value="{{ item.value }}" {% if item.value in categories_selected %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Статус редакции</div>
|
||||
<div class="facet-list compact">
|
||||
{% for st in status_options %}
|
||||
<label class="facet-option"><input type="checkbox" name="editorial_status" value="{{ st.value }}" {% if st.value in editorial_statuses %}checked{% endif %}> <span>{{ st.label }}</span><small>{{ counts.get(st.value, 0) }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary filter-apply" type="submit">Показать</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<dialog class="gallery-modal" id="galleryModal" aria-hidden="true">
|
||||
<button class="gallery-close" type="button" aria-label="Закрыть">×</button>
|
||||
<button class="gallery-nav gallery-prev" type="button" aria-label="Назад">‹</button>
|
||||
<img id="galleryImage" src="" alt="">
|
||||
<button class="gallery-nav gallery-next" type="button" aria-label="Вперёд">›</button>
|
||||
<div class="gallery-title" id="galleryTitle"></div>
|
||||
</dialog>
|
||||
|
||||
<script>
|
||||
(() => {
|
||||
document.querySelectorAll("[data-open-editor]").forEach((button) => {
|
||||
button.addEventListener("click", () => document.getElementById(button.dataset.openEditor)?.showModal());
|
||||
});
|
||||
document.querySelectorAll("[data-close-dialog]").forEach((button) => {
|
||||
button.addEventListener("click", () => document.getElementById(button.dataset.closeDialog)?.close());
|
||||
});
|
||||
document.querySelectorAll(".edit-form").forEach((form) => {
|
||||
const category = form.querySelector('[name="final_category"]');
|
||||
const source = form.querySelector('[name="final_source_tag"]');
|
||||
const preview = form.querySelector("[data-hashtag-preview]");
|
||||
const normalize = (value, fallback) => {
|
||||
const tag = String(value || "").trim().replace(/^#+/, "").toLowerCase()
|
||||
.replace(/\s+/g, "_")
|
||||
.replace(/[^\wа-яё_]+/gi, "_")
|
||||
.replace(/_+/g, "_")
|
||||
.replace(/^_+|_+$/g, "");
|
||||
return tag || fallback;
|
||||
};
|
||||
const update = () => {
|
||||
if (!preview) return;
|
||||
preview.textContent = `#${normalize(category?.value, "category")} #${normalize(source?.value, "source")}`;
|
||||
};
|
||||
category?.addEventListener("change", update);
|
||||
source?.addEventListener("input", update);
|
||||
update();
|
||||
});
|
||||
|
||||
const items = Array.from(document.querySelectorAll("[data-gallery-src]"));
|
||||
const modal = document.getElementById("galleryModal");
|
||||
const image = document.getElementById("galleryImage");
|
||||
const title = document.getElementById("galleryTitle");
|
||||
let index = 0;
|
||||
function show(nextIndex) {
|
||||
if (!items.length) return;
|
||||
index = (nextIndex + items.length) % items.length;
|
||||
const item = items[index];
|
||||
image.src = item.dataset.gallerySrc;
|
||||
title.textContent = item.dataset.galleryTitle || "";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
if (typeof modal.showModal === "function" && !modal.open) modal.showModal();
|
||||
}
|
||||
function close() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
image.src = "";
|
||||
if (typeof modal.close === "function" && modal.open) modal.close();
|
||||
}
|
||||
items.forEach((item, i) => item.addEventListener("click", () => show(i)));
|
||||
modal.querySelector(".gallery-close").addEventListener("click", close);
|
||||
modal.querySelector(".gallery-prev").addEventListener("click", () => show(index - 1));
|
||||
modal.querySelector(".gallery-next").addEventListener("click", () => show(index + 1));
|
||||
modal.addEventListener("click", (event) => { if (event.target === modal) close(); });
|
||||
document.querySelectorAll("[data-autosubmit]").forEach((control) => {
|
||||
control.addEventListener("change", () => control.form?.submit());
|
||||
});
|
||||
document.querySelectorAll("[data-facet-search]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const list = document.querySelector(`[data-facet-list="${input.dataset.facetSearch}"]`);
|
||||
const q = input.value.trim().toLowerCase();
|
||||
list?.querySelectorAll(".facet-option").forEach((option) => {
|
||||
option.hidden = q && !option.textContent.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
});
|
||||
document.querySelectorAll("[data-mark-media-delete]").forEach((button) => {
|
||||
button.addEventListener("click", () => {
|
||||
const form = button.closest("form");
|
||||
const media = button.closest("[data-media-id]");
|
||||
const mediaId = button.dataset.markMediaDelete;
|
||||
if (!form || !mediaId || form.querySelector(`input[name="delete_media_ids"][value="${mediaId}"]`)) return;
|
||||
const hidden = document.createElement("input");
|
||||
hidden.type = "hidden";
|
||||
hidden.name = "delete_media_ids";
|
||||
hidden.value = mediaId;
|
||||
form.appendChild(hidden);
|
||||
media?.classList.add("marked-delete");
|
||||
});
|
||||
});
|
||||
document.querySelectorAll('input[type="file"][name="media_files"]').forEach((input) => {
|
||||
input.addEventListener("change", () => {
|
||||
const list = input.closest(".context-pane")?.querySelector("[data-pending-media-list]");
|
||||
if (!list) return;
|
||||
const names = Array.from(input.files || []).map((file) => file.name);
|
||||
list.textContent = names.length ? `Будет добавлено: ${names.join(", ")}` : "Выбранные файлы появятся после сохранения.";
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="panel" style="max-width:420px;margin:10vh auto 0;">
|
||||
<h1>Вход</h1>
|
||||
{% if error %}<p class="pill bad">{{ error }}</p>{% endif %}
|
||||
<form method="post" action="/login">
|
||||
<label><span>Логин</span><input name="login" autocomplete="username" required></label>
|
||||
<label><span>Пароль</span><input name="password" type="password" autocomplete="current-password" required></label>
|
||||
<button class="btn primary" type="submit">Войти</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,37 @@
|
||||
{% if pagination and pagination.pages > 1 %}
|
||||
<div class="pagination">
|
||||
<form method="get" class="pagination-form">
|
||||
{% if preserved_query is defined %}
|
||||
{% for item in preserved_query %}
|
||||
<input type="hidden" name="{{ item.key }}" value="{{ item.value }}">
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
{% if q is defined %}<input type="hidden" name="q" value="{{ q }}">{% endif %}
|
||||
{% if status_filter is defined %}<input type="hidden" name="status_filter" value="{{ status_filter }}">{% endif %}
|
||||
{% if date_from is defined %}<input type="hidden" name="date_from" value="{{ date_from }}">{% endif %}
|
||||
{% if date_to is defined %}<input type="hidden" name="date_to" value="{{ date_to }}">{% endif %}
|
||||
{% if category_filter is defined %}<input type="hidden" name="category_filter" value="{{ category_filter }}">{% endif %}
|
||||
{% if sort is defined %}<input type="hidden" name="sort" value="{{ sort }}">{% endif %}
|
||||
{% if table_filters is defined %}
|
||||
{% for item in table_filters %}
|
||||
<input type="hidden" name="f_field" value="{{ item.field }}">
|
||||
<input type="hidden" name="f_op" value="{{ item.op }}">
|
||||
<input type="hidden" name="f_value" value="{{ item.value }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% if table_sorts is defined %}
|
||||
{% for item in table_sorts %}
|
||||
<input type="hidden" name="sort_field" value="{{ item.field }}">
|
||||
<input type="hidden" name="sort_dir" value="{{ item.dir }}">
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||
<button class="btn" name="page" value="{{ pagination.page - 1 }}" {% if not pagination.has_prev %}disabled{% endif %}>‹</button>
|
||||
<span class="muted">Страница {{ pagination.page }} из {{ pagination.pages }} · всего {{ pagination.total }}</span>
|
||||
<button class="btn" name="page" value="{{ pagination.page + 1 }}" {% if not pagination.has_next %}disabled{% endif %}>›</button>
|
||||
</form>
|
||||
</div>
|
||||
{% else %}
|
||||
{% if pagination %}<div class="pagination muted">Всего {{ pagination.total }}</div>{% endif %}
|
||||
{% endif %}
|
||||
@@ -0,0 +1,194 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<a class="muted" href="/raw">← Raw-посты</a>
|
||||
<h1>Raw #{{ post.id }}</h1>
|
||||
<div class="muted">
|
||||
<a href="{{ post.original_url }}" target="_blank">{{ post.source_name }}</a>
|
||||
· {{ post.source_platform }}
|
||||
· #{{ post.source_tag or "—" }}
|
||||
{% if post.posted_at_fmt %}· пост VK: {{ post.posted_at_fmt }}{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
<span class="pill {{ post.stage.class }}" title="{{ post.stage.key }}">{{ post.stage.label }}</span>
|
||||
</div>
|
||||
|
||||
<div class="detail-grid">
|
||||
<section class="panel">
|
||||
<div class="media-strip detail-media">
|
||||
{% for m in post.media_items %}
|
||||
{% if m.type == "photo" and m.url %}
|
||||
<button class="media-thumb detail-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ post.id }} · фото {{ loop.index }}" title="{{ m.status }}">
|
||||
<img src="{{ m.url }}" alt="photo">
|
||||
</button>
|
||||
{% elif m.url %}
|
||||
<a class="media-thumb detail-thumb media-video" href="{{ m.url }}" target="_blank" title="{{ m.error or m.status }}">
|
||||
<span>▶</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if post.media_count %}<div class="muted media-count">{{ post.media_count }} медиа</div>{% endif %}
|
||||
<div class="raw-full-text detail-text">{{ post.raw_text }}</div>
|
||||
<div class="post-meta">Загружен парсером: {{ post.created_at_fmt }}</div>
|
||||
</section>
|
||||
|
||||
<aside class="panel side-panel" id="ai">
|
||||
<h2>AI-оценка</h2>
|
||||
<a class="ai-badge detail-ai {{ post.ai_badge.class }}" href="#ai" title="{{ post.ai_badge.reason or post.ai_badge.sublabel }}">
|
||||
<span>{{ post.ai_badge.label }}</span>
|
||||
<small>{{ post.ai_badge.sublabel }}</small>
|
||||
</a>
|
||||
<dl class="kv">
|
||||
<dt>Статус</dt>
|
||||
<dd>{{ post.qualification_status or "pending" }}</dd>
|
||||
<dt>Решение модели</dt>
|
||||
<dd>{{ post.qualification_model_decision or post.qualification_decision or "—" }}</dd>
|
||||
<dt>Итог по порогу</dt>
|
||||
<dd>{{ post.qualification_decision or "—" }}</dd>
|
||||
<dt>Причина</dt>
|
||||
<dd>{{ post.qualification_reason or "—" }}</dd>
|
||||
<dt>Reject tag</dt>
|
||||
<dd>{{ post.qualification_reject_tag or "—" }}</dd>
|
||||
<dt>Модель</dt>
|
||||
<dd>{{ post.qualification_model or post.batch_model or "—" }}</dd>
|
||||
<dt>Проверен</dt>
|
||||
<dd>{{ post.qualified_at_fmt or "—" }}</dd>
|
||||
<dt>Batch</dt>
|
||||
<dd>{% if post.qualification_batch_id %}#{{ post.qualification_batch_id }}{% else %}—{% endif %}</dd>
|
||||
<dt>Токены</dt>
|
||||
<dd>{{ post.batch_total_tokens or "—" }}</dd>
|
||||
</dl>
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
<section class="panel" id="rewrite">
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h2>Рерайт</h2>
|
||||
<div class="muted">Готовый текст для будущей редакторской проверки.</div>
|
||||
</div>
|
||||
<span class="pill {% if post.rewrite_status == 'ready' %}ok{% elif post.rewrite_status == 'failed' %}bad{% elif post.rewrite_status == 'processing' %}warn{% endif %}">
|
||||
{{ post.rewrite_status or "pending" }}
|
||||
</span>
|
||||
</div>
|
||||
{% if post.rewritten_text %}
|
||||
<div class="rewrite-text">{{ post.rewritten_text }}</div>
|
||||
{% else %}
|
||||
<div class="muted">Рерайт ещё не готов.</div>
|
||||
{% endif %}
|
||||
<dl class="kv kv-wide rewrite-meta">
|
||||
<dt>Категория</dt><dd>{{ post.rewrite_category or "—" }}</dd>
|
||||
<dt>Хэштеги</dt>
|
||||
<dd>
|
||||
{% if post.rewrite_category_tag or post.rewrite_source_tag %}
|
||||
#{{ post.rewrite_category_tag or "—" }} #{{ post.rewrite_source_tag or "—" }}
|
||||
{% else %}
|
||||
—
|
||||
{% endif %}
|
||||
</dd>
|
||||
<dt>Заметка</dt><dd>{{ post.rewrite_notes or "—" }}</dd>
|
||||
<dt>Модель</dt><dd>{{ post.rewrite_model or post.writer_batch_model or "—" }}</dd>
|
||||
<dt>Готов</dt><dd>{{ post.rewritten_at_fmt or "—" }}</dd>
|
||||
<dt>Batch</dt><dd>{% if post.rewrite_batch_id %}#{{ post.rewrite_batch_id }}{% else %}—{% endif %}</dd>
|
||||
<dt>Токены</dt><dd>{{ post.writer_total_tokens or "—" }}</dd>
|
||||
<dt>Стоимость</dt><dd>{% if post.writer_estimated_cost_usd %}${{ post.writer_estimated_cost_usd }}{% else %}—{% endif %}</dd>
|
||||
</dl>
|
||||
</section>
|
||||
|
||||
{% if post.qualification_batch_id %}
|
||||
<details class="panel">
|
||||
<summary><strong>Технический ответ AI</strong></summary>
|
||||
<dl class="kv kv-wide">
|
||||
<dt>Provider</dt><dd>{{ post.qualification_provider or "—" }}</dd>
|
||||
<dt>Batch status</dt><dd>{{ post.batch_status or "—" }}</dd>
|
||||
<dt>Создан</dt><dd>{{ post.batch_created_at_fmt or "—" }}</dd>
|
||||
<dt>Завершён</dt><dd>{{ post.batch_completed_at_fmt or "—" }}</dd>
|
||||
<dt>Постов в batch</dt><dd>{{ post.batch_posts_count or "—" }}</dd>
|
||||
<dt>Accepted / rejected / maybe</dt>
|
||||
<dd>{{ post.batch_accepted_count or 0 }} / {{ post.batch_rejected_count or 0 }} / {{ post.batch_maybe_count or 0 }}</dd>
|
||||
<dt>Токены</dt>
|
||||
<dd>{{ post.batch_prompt_tokens or 0 }} / {{ post.batch_completion_tokens or 0 }} / {{ post.batch_total_tokens or 0 }}</dd>
|
||||
<dt>Стоимость</dt><dd>{% if post.batch_estimated_cost_usd %}${{ post.batch_estimated_cost_usd }}{% else %}—{% endif %}</dd>
|
||||
<dt>Ошибка</dt><dd>{{ post.batch_error or "—" }}</dd>
|
||||
</dl>
|
||||
{% if post.batch_prompt_text %}
|
||||
<details class="nested-details">
|
||||
<summary>Использованный prompt квалификатора</summary>
|
||||
<pre class="prompt-box">{{ post.batch_prompt_text }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
<pre class="json-box">{{ post.batch_response_pretty or "{}" }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
{% if post.rewrite_batch_id %}
|
||||
<details class="panel">
|
||||
<summary><strong>Технический ответ AI-райтера</strong></summary>
|
||||
<dl class="kv kv-wide">
|
||||
<dt>Provider</dt><dd>{{ post.rewrite_provider or "—" }}</dd>
|
||||
<dt>Batch status</dt><dd>{{ post.writer_batch_status or "—" }}</dd>
|
||||
<dt>Создан</dt><dd>{{ post.writer_batch_created_at_fmt or "—" }}</dd>
|
||||
<dt>Завершён</dt><dd>{{ post.writer_batch_completed_at_fmt or "—" }}</dd>
|
||||
<dt>Постов в batch</dt><dd>{{ post.writer_batch_posts_count or "—" }}</dd>
|
||||
<dt>Ready / failed</dt><dd>{{ post.writer_batch_ready_count or 0 }} / {{ post.writer_batch_failed_count or 0 }}</dd>
|
||||
<dt>Токены</dt>
|
||||
<dd>{{ post.writer_prompt_tokens or 0 }} / {{ post.writer_completion_tokens or 0 }} / {{ post.writer_total_tokens or 0 }}</dd>
|
||||
<dt>Стоимость</dt><dd>{% if post.writer_estimated_cost_usd %}${{ post.writer_estimated_cost_usd }}{% else %}—{% endif %}</dd>
|
||||
<dt>Ошибка</dt><dd>{{ post.writer_batch_error or "—" }}</dd>
|
||||
</dl>
|
||||
{% if post.writer_batch_prompt_text %}
|
||||
<details class="nested-details">
|
||||
<summary>Использованный prompt райтера</summary>
|
||||
<pre class="prompt-box">{{ post.writer_batch_prompt_text }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
<pre class="json-box">{{ post.writer_batch_response_pretty or "{}" }}</pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
|
||||
<div class="gallery-modal" id="galleryModal" aria-hidden="true">
|
||||
<button class="gallery-close" type="button" aria-label="Закрыть">×</button>
|
||||
<button class="gallery-nav gallery-prev" type="button" aria-label="Назад">‹</button>
|
||||
<img id="galleryImage" src="" alt="">
|
||||
<button class="gallery-nav gallery-next" type="button" aria-label="Вперёд">›</button>
|
||||
<div class="gallery-title" id="galleryTitle"></div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const items = Array.from(document.querySelectorAll("[data-gallery-src]"));
|
||||
const modal = document.getElementById("galleryModal");
|
||||
const image = document.getElementById("galleryImage");
|
||||
const title = document.getElementById("galleryTitle");
|
||||
let index = 0;
|
||||
|
||||
function show(nextIndex) {
|
||||
if (!items.length) return;
|
||||
index = (nextIndex + items.length) % items.length;
|
||||
const item = items[index];
|
||||
image.src = item.dataset.gallerySrc;
|
||||
title.textContent = item.dataset.galleryTitle || "";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
function close() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
image.src = "";
|
||||
}
|
||||
|
||||
items.forEach((item, i) => item.addEventListener("click", () => show(i)));
|
||||
modal.querySelector(".gallery-close").addEventListener("click", close);
|
||||
modal.querySelector(".gallery-prev").addEventListener("click", () => show(index - 1));
|
||||
modal.querySelector(".gallery-next").addEventListener("click", () => show(index + 1));
|
||||
modal.addEventListener("click", (event) => { if (event.target === modal) close(); });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!modal.classList.contains("open")) return;
|
||||
if (event.key === "Escape") close();
|
||||
if (event.key === "ArrowLeft") show(index - 1);
|
||||
if (event.key === "ArrowRight") show(index + 1);
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,211 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h1>Raw-посты</h1>
|
||||
<div class="muted">Исходный пост, этап обработки и AI-оценка.</div>
|
||||
</div>
|
||||
</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">
|
||||
<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><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>
|
||||
<th><a class="sort-head {% if sort_headers.ai.active %}active {{ sort_headers.ai.direction }}{% endif %}" href="{{ sort_headers.ai.url }}">AI</a></th>
|
||||
</tr>
|
||||
{% for p in posts %}
|
||||
<tr>
|
||||
<td><a class="raw-id" href="/raw/{{ p.id }}">{{ p.id }}</a></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>
|
||||
{% if p.posted_at_fmt %}<div class="muted">{{ p.posted_at_fmt }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="stage-cell">
|
||||
<span class="pill {{ p.stage.class }}" title="{{ p.stage.key }}">{{ p.stage.label }}</span>
|
||||
{% if p.error_reason %}<div class="muted">{{ p.error_reason }}</div>{% endif %}
|
||||
</td>
|
||||
<td class="raw-content-cell">
|
||||
<div class="media-strip">
|
||||
{% for m in p.media_items %}
|
||||
{% if m.type == "photo" and m.url %}
|
||||
<button class="media-thumb" type="button" data-gallery-src="{{ m.url }}" data-gallery-title="#{{ p.id }} · фото {{ loop.index }}" title="{{ m.status }}">
|
||||
<img src="{{ m.url }}" alt="photo">
|
||||
</button>
|
||||
{% elif m.url %}
|
||||
<a class="media-thumb media-video" href="{{ m.url }}" target="_blank" title="{{ m.error or m.status }}">
|
||||
<span>▶</span>
|
||||
</a>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% if p.media_count %}<div class="muted media-count">{{ p.media_count }} медиа</div>{% endif %}
|
||||
{% if p.raw_text|length > 520 %}
|
||||
<details class="raw-text-details">
|
||||
<summary>{{ p.raw_text[:520] }}...</summary>
|
||||
<div class="raw-full-text">{{ p.raw_text }}</div>
|
||||
</details>
|
||||
{% else %}
|
||||
<div class="raw-full-text">{{ p.raw_text }}</div>
|
||||
{% endif %}
|
||||
<div class="post-meta">Загружен парсером: {{ p.created_at_fmt }}</div>
|
||||
</td>
|
||||
<td class="ai-cell">
|
||||
<a class="ai-badge {{ p.ai_badge.class }}" href="/raw/{{ p.id }}#ai" title="{{ p.ai_badge.reason or p.ai_badge.sublabel }}">
|
||||
<span>{{ p.ai_badge.label }}</span>
|
||||
<small>итог: {{ p.ai_badge.sublabel }}</small>
|
||||
</a>
|
||||
{% if p.qualification_model_decision %}<div class="muted ai-model-decision">модель: {{ p.qualification_model_decision }}</div>{% endif %}
|
||||
{% if p.rewrite_category %}<div class="muted ai-model-decision">{{ p.rewrite_category }}</div>{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<div class="list-footer">
|
||||
<form class="row per-page-form" method="get" action="/raw">
|
||||
{% 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>
|
||||
{% include "pagination.html" %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<aside class="filter-sidebar panel">
|
||||
<form method="get" action="/raw" data-filter-form>
|
||||
<input type="hidden" name="sort" value="{{ sort }}">
|
||||
<div class="filter-head">
|
||||
<h2>Фильтры</h2>
|
||||
<a href="/raw">Сбросить</a>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Оценка AI</div>
|
||||
<div class="range-row">
|
||||
<input name="score_min" inputmode="numeric" placeholder="От" value="{{ score_min }}">
|
||||
<input name="score_max" inputmode="numeric" placeholder="До" value="{{ score_max }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Дата поста</div>
|
||||
<div class="range-row">
|
||||
<input type="date" name="date_from" value="{{ date_from }}">
|
||||
<input type="date" name="date_to" value="{{ date_to }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Источник</div>
|
||||
<input class="facet-search" type="search" placeholder="Найти источник" data-facet-search="raw-sources">
|
||||
<div class="facet-list" data-facet-list="raw-sources">
|
||||
{% for item in facets.sources %}
|
||||
<label class="facet-option"><input type="checkbox" name="source_id" value="{{ item.id }}" {% if item.id in source_ids %}checked{% endif %}> <span>{{ item.name }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Категория</div>
|
||||
<input class="facet-search" type="search" placeholder="Найти категорию" data-facet-search="raw-categories">
|
||||
<div class="facet-list" data-facet-list="raw-categories">
|
||||
{% for item in facets.categories %}
|
||||
<label class="facet-option"><input type="checkbox" name="category" value="{{ item.value }}" {% if item.value in categories_selected %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Raw-статус</div>
|
||||
<div class="facet-list compact">
|
||||
{% for item in facets.statuses %}
|
||||
<label class="facet-option"><input type="checkbox" name="raw_status" value="{{ item.value }}" {% if item.value in raw_statuses %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Квалификация</div>
|
||||
<div class="facet-list compact">
|
||||
{% for item in facets.qualification_statuses %}
|
||||
<label class="facet-option"><input type="checkbox" name="qualification_status" value="{{ item.value }}" {% if item.value in qualification_statuses %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<div class="filter-section">
|
||||
<div class="filter-title">Рерайт</div>
|
||||
<div class="facet-list compact">
|
||||
{% for item in facets.rewrite_statuses %}
|
||||
<label class="facet-option"><input type="checkbox" name="rewrite_status" value="{{ item.value }}" {% if item.value in rewrite_statuses %}checked{% endif %}> <span>{{ item.value }}</span><small>{{ item.count }}</small></label>
|
||||
{% endfor %}
|
||||
</div>
|
||||
</div>
|
||||
<button class="btn primary filter-apply" type="submit">Показать</button>
|
||||
</form>
|
||||
</aside>
|
||||
</div>
|
||||
<div class="gallery-modal" id="galleryModal" aria-hidden="true">
|
||||
<button class="gallery-close" type="button" aria-label="Закрыть">×</button>
|
||||
<button class="gallery-nav gallery-prev" type="button" aria-label="Назад">‹</button>
|
||||
<img id="galleryImage" src="" alt="">
|
||||
<button class="gallery-nav gallery-next" type="button" aria-label="Вперёд">›</button>
|
||||
<div class="gallery-title" id="galleryTitle"></div>
|
||||
</div>
|
||||
<script>
|
||||
(() => {
|
||||
const items = Array.from(document.querySelectorAll("[data-gallery-src]"));
|
||||
const modal = document.getElementById("galleryModal");
|
||||
const image = document.getElementById("galleryImage");
|
||||
const title = document.getElementById("galleryTitle");
|
||||
let index = 0;
|
||||
|
||||
function show(nextIndex) {
|
||||
if (!items.length) return;
|
||||
index = (nextIndex + items.length) % items.length;
|
||||
const item = items[index];
|
||||
image.src = item.dataset.gallerySrc;
|
||||
title.textContent = item.dataset.galleryTitle || "";
|
||||
modal.classList.add("open");
|
||||
modal.setAttribute("aria-hidden", "false");
|
||||
}
|
||||
function close() {
|
||||
modal.classList.remove("open");
|
||||
modal.setAttribute("aria-hidden", "true");
|
||||
image.src = "";
|
||||
}
|
||||
|
||||
items.forEach((item, i) => item.addEventListener("click", () => show(i)));
|
||||
modal.querySelector(".gallery-close").addEventListener("click", close);
|
||||
modal.querySelector(".gallery-prev").addEventListener("click", () => show(index - 1));
|
||||
modal.querySelector(".gallery-next").addEventListener("click", () => show(index + 1));
|
||||
modal.addEventListener("click", (event) => { if (event.target === modal) close(); });
|
||||
document.addEventListener("keydown", (event) => {
|
||||
if (!modal.classList.contains("open")) return;
|
||||
if (event.key === "Escape") close();
|
||||
if (event.key === "ArrowLeft") show(index - 1);
|
||||
if (event.key === "ArrowRight") show(index + 1);
|
||||
});
|
||||
document.querySelectorAll("[data-autosubmit]").forEach((control) => {
|
||||
control.addEventListener("change", () => control.form?.submit());
|
||||
});
|
||||
document.querySelectorAll("[data-facet-search]").forEach((input) => {
|
||||
input.addEventListener("input", () => {
|
||||
const list = document.querySelector(`[data-facet-list="${input.dataset.facetSearch}"]`);
|
||||
const q = input.value.trim().toLowerCase();
|
||||
list?.querySelectorAll(".facet-option").forEach((option) => {
|
||||
option.hidden = q && !option.textContent.toLowerCase().includes(q);
|
||||
});
|
||||
});
|
||||
});
|
||||
})();
|
||||
</script>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,25 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="toolbar">
|
||||
<h1>{{ title }}</h1>
|
||||
<a class="btn" href="/sources">Назад</a>
|
||||
</div>
|
||||
<div class="panel">
|
||||
<form method="post" action="{{ action }}">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<div class="form-grid">
|
||||
<label><span>Площадка</span>
|
||||
<select name="platform">
|
||||
<option value="vk" {% if not source or source.platform == "vk" %}selected{% endif %}>VK</option>
|
||||
</select>
|
||||
</label>
|
||||
<label><span>Приоритет</span><input name="priority" type="number" value="{{ source.priority if source else 100 }}"></label>
|
||||
<label><span>Название</span><input name="name" value="{{ source.name if source else '' }}" required></label>
|
||||
<label><span>Тэг</span><input name="tag" value="{{ source.tag if source else '' }}" placeholder="academy_gear"></label>
|
||||
<label><span>Ссылка</span><input name="url" value="{{ source.url if source else '' }}" required></label>
|
||||
<label><span>Активен</span><input name="active" type="checkbox" {% if not source or source.active %}checked{% endif %}></label>
|
||||
</div>
|
||||
<button class="btn primary" type="submit">Сохранить</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,123 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<div class="toolbar">
|
||||
<div>
|
||||
<h1>Источники</h1>
|
||||
<div class="muted">VK-источники для парсинга. Название идёт в prompt, тэг — в будущие хэштеги.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<details class="panel" {% if source_preview %}open{% endif %}>
|
||||
<summary><strong>Добавить источники</strong></summary>
|
||||
<div class="source-add-grid">
|
||||
<form method="post" action="/sources/new">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<input type="hidden" name="platform" value="vk">
|
||||
<input type="hidden" name="priority" value="100">
|
||||
<label><span>Название</span><input name="name" placeholder="Можно оставить как в VK"></label>
|
||||
<label><span>Тэг</span><input name="tag" placeholder="academy_gear"></label>
|
||||
<label><span>Ссылка</span><input name="url" placeholder="https://vk.com/academy_gear" required></label>
|
||||
<label class="checkline"><input name="active" type="checkbox" checked> <span>Включить сразу</span></label>
|
||||
<button class="btn primary" type="submit">Добавить</button>
|
||||
</form>
|
||||
|
||||
<form method="post" action="/sources/preview">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<input type="hidden" name="q" value="{{ q }}">
|
||||
<input type="hidden" name="status_filter" value="{{ status_filter }}">
|
||||
<input type="hidden" name="per_page" value="{{ pagination.per_page }}">
|
||||
<label><span>Список источников</span>
|
||||
<textarea name="bulk_text" rows="7" placeholder="Academy Gear academy_gear https://vk.com/academy_gear A2 Technologies a2technologies https://vk.com/a2technologies">{{ bulk_text }}</textarea>
|
||||
</label>
|
||||
<label class="checkline"><input name="bulk_active" type="checkbox" {% if bulk_active %}checked{% endif %}> <span>Включить после добавления</span></label>
|
||||
<button class="btn" type="submit">Проверить список</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
{% if source_preview %}
|
||||
<div class="preview-block">
|
||||
<h3>Предпросмотр</h3>
|
||||
<table>
|
||||
<tr><th>#</th><th>Название</th><th>Тэг</th><th>Ссылка</th><th>VK id</th><th>Проверка</th></tr>
|
||||
{% for item in source_preview %}
|
||||
<tr>
|
||||
<td>{{ item.line_no }}</td>
|
||||
<td>{{ item.name or "—" }}</td>
|
||||
<td class="mono">#{{ item.tag or "—" }}</td>
|
||||
<td><a href="{{ item.url }}" target="_blank">{{ item.url }}</a></td>
|
||||
<td class="mono">{{ item.external_id }}<br>{{ item.external_owner_id or "" }}</td>
|
||||
<td>{% if item.ok %}<span class="pill ok">готов</span>{% else %}<span class="pill bad">{{ item.error }}</span>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
<form method="post" action="/sources/bulk" class="row">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<input type="hidden" name="bulk_payload" value='{{ source_preview|tojson }}'>
|
||||
<button class="btn primary" type="submit">Добавить прошедшие проверку</button>
|
||||
<span class="muted">Строки с ошибками и дубли будут пропущены.</span>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
</details>
|
||||
|
||||
<div class="panel">
|
||||
<form class="filter-grid sources-filter" method="get" action="/sources">
|
||||
<label><span>Поиск</span><input name="q" value="{{ q }}" placeholder="название, ссылка, id"></label>
|
||||
<label><span>Статус</span>
|
||||
<select name="status_filter">
|
||||
<option value="">Все</option>
|
||||
{% for st in ["new","ok","paused","error"] %}
|
||||
<option value="{{ st }}" {% if status_filter == st %}selected{% endif %}>{{ st }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
<label><span>На странице</span>
|
||||
<select name="per_page">
|
||||
{% for n in [25,50,100,200] %}
|
||||
<option value="{{ n }}" {% if pagination.per_page == n %}selected{% endif %}>{{ n }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</label>
|
||||
{% include "table_tools.html" %}
|
||||
<button class="btn filter-submit" type="submit">Показать</button>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<div class="panel">
|
||||
<table class="compact-table">
|
||||
<tr>
|
||||
<th>#</th><th>Площадка</th><th>Название</th><th>Тэг</th><th>Ссылка</th><th>Статус</th><th>Посты</th><th>Последний парсинг</th><th></th>
|
||||
</tr>
|
||||
{% for s in sources %}
|
||||
<tr>
|
||||
<td>{{ s.id }}</td>
|
||||
<td>{{ s.platform }}</td>
|
||||
<td>
|
||||
<b>{{ s.name }}</b>
|
||||
{% if s.active %}<span class="pill ok source-state">on</span>{% else %}<span class="pill bad source-state">off</span>{% endif %}
|
||||
<div class="muted mono">{{ s.external_id or "" }}</div>
|
||||
</td>
|
||||
<td class="mono">#{{ s.tag or "—" }}</td>
|
||||
<td class="source-url"><a href="{{ s.url }}" target="_blank">{{ s.url }}</a></td>
|
||||
<td><span class="pill {% if s.status == 'ok' %}ok{% elif s.status == 'error' %}bad{% endif %}">{{ s.status }}</span>{% if s.status_msg %}<div class="muted status-msg">{{ s.status_msg }}</div>{% endif %}</td>
|
||||
<td>{{ s.posts_count }} <span class="muted">+{{ s.posts_24h }}/24ч</span></td>
|
||||
<td>{{ s.last_parsed_at_fmt or "нет" }}</td>
|
||||
<td>
|
||||
<div class="icon-actions">
|
||||
<a class="icon-btn" href="/sources/{{ s.id }}/edit" title="Править" aria-label="Править">✎</a>
|
||||
<form method="post" action="/sources/{{ s.id }}/toggle">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="icon-btn" type="submit" title="{% if s.active %}Выключить{% else %}Включить{% endif %}" aria-label="Вкл/выкл">⏻</button>
|
||||
</form>
|
||||
<form method="post" action="/sources/{{ s.id }}/delete" onsubmit="return confirm('Удалить источник {{ s.name }}?');">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="icon-btn danger" type="submit" title="Удалить" aria-label="Удалить">×</button>
|
||||
</form>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% include "pagination.html" %}
|
||||
</div>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,43 @@
|
||||
<details class="advanced-tools" {% if table_filters|selectattr("field")|list or table_sorts|selectattr("field")|list %}open{% endif %}>
|
||||
<summary>Расширенные фильтры и сортировка</summary>
|
||||
<div class="advanced-grid">
|
||||
<div>
|
||||
<div class="tool-title">Фильтры</div>
|
||||
{% for item in table_filters %}
|
||||
<div class="tool-row">
|
||||
<select name="f_field">
|
||||
<option value="">Поле</option>
|
||||
{% for field in field_options %}
|
||||
<option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="f_op">
|
||||
{% for op in filter_op_options %}
|
||||
<option value="{{ op.value }}" {% if item.op == op.value %}selected{% endif %}>{{ op.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<input name="f_value" value="{{ item.value }}" placeholder="значение">
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="muted small-help">Пустые строки игнорируются. Для дат используй формат YYYY-MM-DD, для true/false — true или false.</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="tool-title">Сортировка</div>
|
||||
{% for item in table_sorts %}
|
||||
<div class="tool-row sort-row">
|
||||
<select name="sort_field">
|
||||
<option value="">Поле</option>
|
||||
{% for field in field_options %}
|
||||
<option value="{{ field.value }}" {% if item.field == field.value %}selected{% endif %}>{{ field.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
<select name="sort_dir">
|
||||
<option value="desc" {% if item.dir == "desc" %}selected{% endif %}>по убыванию</option>
|
||||
<option value="asc" {% if item.dir == "asc" %}selected{% endif %}>по возрастанию</option>
|
||||
</select>
|
||||
</div>
|
||||
{% endfor %}
|
||||
<div class="muted small-help">Сортировки применяются сверху вниз. Если сортировка задана здесь, обычная сортировка страницы становится запасной.</div>
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Пользователи</h1>
|
||||
<div class="panel">
|
||||
<h2>Пользователи</h2>
|
||||
<table>
|
||||
<tr><th>ID</th><th>Логин</th><th>Роль</th><th>Создан</th><th>Активен</th><th></th></tr>
|
||||
{% for u in users %}
|
||||
<tr>
|
||||
<td>{{ u.id }}</td><td>{{ u.login }}</td><td>{{ u.role }}</td>
|
||||
<td>{{ u.created_at_fmt }}</td>
|
||||
<td>{% if u.is_active %}<span class="pill ok">yes</span>{% else %}<span class="pill bad">no</span>{% endif %}</td>
|
||||
<td>{% if user.role == "admin" and user.id != u.id %}<form method="post" action="/users/{{ u.id }}/toggle"><input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"><button class="btn" type="submit">Вкл/выкл</button></form>{% endif %}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
{% include "pagination.html" %}
|
||||
</div>
|
||||
{% if user.role == "admin" %}
|
||||
<div class="panel">
|
||||
<h2>Добавить</h2>
|
||||
<form method="post" action="/users/create">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<div class="form-grid">
|
||||
<label><span>Логин</span><input name="login" required></label>
|
||||
<label><span>Пароль</span><input name="password" type="password" required minlength="10"></label>
|
||||
<label><span>Роль</span>
|
||||
<select name="role"><option value="editor">editor</option><option value="viewer">viewer</option><option value="admin">admin</option></select>
|
||||
</label>
|
||||
</div>
|
||||
<button class="btn primary" type="submit">Создать</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,52 @@
|
||||
{% 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>
|
||||
<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 %}
|
||||
{% elif error %}
|
||||
<p class="pill bad">{{ error }}</p>
|
||||
<p>{{ error_description }}</p>
|
||||
{% else %}
|
||||
<p class="muted">VK вернул callback без code.</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 %}
|
||||
@@ -0,0 +1,141 @@
|
||||
{% extends "base.html" %}
|
||||
{% block body %}
|
||||
<h1>Воркеры</h1>
|
||||
<div class="panel">
|
||||
<table>
|
||||
<tr><th>Имя</th><th>Enabled</th><th>Heartbeat</th><th>Статус</th><th>Job</th><th></th></tr>
|
||||
{% for w in workers %}
|
||||
<tr>
|
||||
<td class="mono">{{ w.name }}</td>
|
||||
<td>{% if w.enabled %}<span class="pill ok">enabled</span>{% else %}<span class="pill bad">disabled</span>{% endif %}</td>
|
||||
<td>{{ w.heartbeat_at or "нет" }}</td>
|
||||
<td>{{ w.status or "" }}</td>
|
||||
<td>{{ w.current_job_id or "" }}</td>
|
||||
<td><form method="post" action="/workers/{{ w.name }}/toggle"><input type="hidden" name="csrf_token" value="{{ user.csrf_token }}"><button class="btn" type="submit">Вкл/выкл</button></form></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</div>
|
||||
|
||||
<details class="panel" open>
|
||||
<summary><strong>Категории публикаций</strong></summary>
|
||||
<p class="muted">Активные категории попадают в выпадающий список редактора и передаются AI-райтеру. Удаление здесь выключает категорию, но не ломает старые посты.</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>
|
||||
<button class="btn primary" type="submit">Добавить</button>
|
||||
</form>
|
||||
<table class="compact-table category-table">
|
||||
<tr><th>Название</th><th>Тэг</th><th>Порядок</th><th>Статус</th><th></th></tr>
|
||||
{% for c in categories %}
|
||||
<tr>
|
||||
<td>
|
||||
<form id="category-update-{{ c.id }}" method="post" action="/categories/{{ c.id }}/update">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<input name="name" value="{{ c.name }}">
|
||||
</form>
|
||||
</td>
|
||||
<td><input form="category-update-{{ c.id }}" name="tag" value="{{ c.tag }}"></td>
|
||||
<td><input form="category-update-{{ c.id }}" name="sort_order" type="number" value="{{ c.sort_order }}"></td>
|
||||
<td>{% if c.is_active %}<span class="pill ok">активна</span>{% else %}<span class="pill">выключена</span>{% endif %}</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">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<button class="icon-btn" type="submit" title="{% if c.is_active %}Выключить{% else %}Включить{% endif %}">{% if c.is_active %}⏸{% else %}▶{% endif %}</button>
|
||||
</form>
|
||||
<form method="post" action="/categories/{{ c.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 %}>
|
||||
<summary><strong>{{ category_titles.get(category, category) }}</strong></summary>
|
||||
<table>
|
||||
<tr><th>Ключ</th><th>Значение</th><th>Описание</th></tr>
|
||||
{% for s in rows %}
|
||||
<tr>
|
||||
<td><div class="mono">{{ s.key }}</div><div class="muted">{{ s.title }}</div></td>
|
||||
<td>
|
||||
<form class="row" method="post" action="/settings/save">
|
||||
<input type="hidden" name="csrf_token" value="{{ user.csrf_token }}">
|
||||
<input type="hidden" name="key" value="{{ s.key }}">
|
||||
{% if s.value_type == "bool" %}
|
||||
<select name="value" style="width:120px;">
|
||||
<option value="true" {% if s.value_json == true %}selected{% endif %}>true</option>
|
||||
<option value="false" {% if s.value_json == false %}selected{% endif %}>false</option>
|
||||
</select>
|
||||
{% elif s.key in ["ai_qualifier_provider", "ai_writer_provider"] %}
|
||||
<select name="value" style="width:260px;">
|
||||
{% for p in provider_options %}
|
||||
<option value="{{ p.value }}" {% if s.value_json == p.value %}selected{% endif %}>{{ p.label }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
{% elif s.key == "ai_qualifier_model" %}
|
||||
<select name="value" style="width:420px; max-width:100%;">
|
||||
{% for m in ai_model_options %}
|
||||
<option value="{{ m.value }}" {% if s.value_json == m.value %}selected{% endif %}>{{ m.label }}</option>
|
||||
{% endfor %}
|
||||
{% if s.value_json and s.value_json not in ai_model_options|map(attribute="value")|list %}
|
||||
<option value="{{ s.value_json }}">{{ s.value_json }} · текущее значение</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
{% elif s.key == "ai_writer_model" %}
|
||||
<select name="value" style="width:420px; max-width:100%;">
|
||||
{% for m in writer_model_options %}
|
||||
<option value="{{ m.value }}" {% if s.value_json == m.value %}selected{% endif %}>{{ m.label }}</option>
|
||||
{% endfor %}
|
||||
{% if s.value_json and s.value_json not in writer_model_options|map(attribute="value")|list %}
|
||||
<option value="{{ s.value_json }}">{{ s.value_json }} · текущее значение</option>
|
||||
{% endif %}
|
||||
</select>
|
||||
{% elif s.value_type == "secret" %}
|
||||
<div class="secret-editor">
|
||||
<input class="secret-visible" name="value" value="{{ s.value_json }}" autocomplete="off" spellcheck="false" style="width:420px; max-width:100%;">
|
||||
<div class="muted small-help">Ключ показан полностью для удобства. Не открывай эту страницу в демонстрации экрана.</div>
|
||||
</div>
|
||||
{% elif s.value_type == "text" %}
|
||||
<div class="prompt-editor">
|
||||
{% if prompt_hints and s.key in prompt_hints %}
|
||||
<div class="prompt-hint">
|
||||
<strong>{{ prompt_hints[s.key].title }}</strong>
|
||||
<div class="muted">{{ prompt_hints[s.key].body }}</div>
|
||||
<div class="hint-grid">
|
||||
<div>
|
||||
<div class="hint-label">Как безопасно менять</div>
|
||||
<div>{{ prompt_hints[s.key].safe }}</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="hint-label">Что приходит на вход</div>
|
||||
<div>{{ prompt_hints[s.key].input }}</div>
|
||||
</div>
|
||||
</div>
|
||||
<details class="nested-details">
|
||||
<summary>Базовая форма/пример</summary>
|
||||
<pre>{{ prompt_hints[s.key].contract }}</pre>
|
||||
</details>
|
||||
</div>
|
||||
{% endif %}
|
||||
<textarea name="value" rows="12" style="width:720px; max-width:100%;">{{ s.value_json }}</textarea>
|
||||
</div>
|
||||
{% else %}
|
||||
<input name="value" value="{{ s.value_json }}" style="width:180px;">
|
||||
{% endif %}
|
||||
<button class="btn" type="submit">OK</button>
|
||||
</form>
|
||||
</td>
|
||||
<td>{{ s.description }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</table>
|
||||
</details>
|
||||
{% endfor %}
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user