From 980d39c6a04f4ba217f10005fbbdd6002e514045 Mon Sep 17 00:00:00 2001 From: Alexey Pavlov Date: Sun, 31 May 2026 14:48:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20autogen=20admin=20panel=20=E2=80=94=20s?= =?UTF-8?q?chedule,=20queue,=20topic=20bank,=20run=20controls?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- app/admin/(protected)/autogen/page.js | 28 ++ app/admin/api/autogen/queue/[id]/route.js | 10 + app/admin/api/autogen/queue/route.js | 11 + app/admin/api/autogen/run/route.js | 13 + app/admin/api/autogen/settings/route.js | 11 + components/admin/AdminNav.js | 3 +- components/admin/AutogenPanel.js | 309 ++++++++++++++++++++++ 7 files changed, 384 insertions(+), 1 deletion(-) create mode 100644 app/admin/(protected)/autogen/page.js create mode 100644 app/admin/api/autogen/queue/[id]/route.js create mode 100644 app/admin/api/autogen/queue/route.js create mode 100644 app/admin/api/autogen/run/route.js create mode 100644 app/admin/api/autogen/settings/route.js create mode 100644 components/admin/AutogenPanel.js diff --git a/app/admin/(protected)/autogen/page.js b/app/admin/(protected)/autogen/page.js new file mode 100644 index 0000000..7803d18 --- /dev/null +++ b/app/admin/(protected)/autogen/page.js @@ -0,0 +1,28 @@ +import { requireAdminAuth } from '@/lib/adminAuth'; +import AutogenPanel from '@/components/admin/AutogenPanel'; + +export const dynamic = 'force-dynamic'; +export const metadata = { title: 'Автогенерация' }; + +const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; +const ENGINE_SECRET = process.env.ENGINE_SECRET || 'zeropost_internal_2026'; + +async function engineCall(path) { + const res = await fetch(`${ENGINE_URL}${path}`, { + headers: { 'x-internal-secret': ENGINE_SECRET }, + cache: 'no-store', + }); + if (!res.ok) return null; + return res.json(); +} + +export default async function AutogenPage() { + await requireAdminAuth(); + const [status, queue, topics] = await Promise.all([ + engineCall('/api/autogen/status'), + engineCall('/api/autogen/queue'), + engineCall('/api/autogen/topics'), + ]); + + return ; +} diff --git a/app/admin/api/autogen/queue/[id]/route.js b/app/admin/api/autogen/queue/[id]/route.js new file mode 100644 index 0000000..5721a3c --- /dev/null +++ b/app/admin/api/autogen/queue/[id]/route.js @@ -0,0 +1,10 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; +const E = process.env.ENGINE_URL||'http://127.0.0.1:3030'; +const S = process.env.ENGINE_SECRET||'zeropost_internal_2026'; +export async function DELETE(req, { params }) { + if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401}); + const { id } = await params; + await fetch(`${E}/api/autogen/queue/${id}`,{method:'DELETE',headers:{'x-internal-secret':S}}); + return NextResponse.json({ok:true}); +} diff --git a/app/admin/api/autogen/queue/route.js b/app/admin/api/autogen/queue/route.js new file mode 100644 index 0000000..463d943 --- /dev/null +++ b/app/admin/api/autogen/queue/route.js @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; +const E = process.env.ENGINE_URL||'http://127.0.0.1:3030'; +const S = process.env.ENGINE_SECRET||'zeropost_internal_2026'; +const h = { 'Content-Type':'application/json','x-internal-secret':S }; +export async function POST(req) { + if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401}); + const body = await req.json(); + const res = await fetch(`${E}/api/autogen/queue`,{method:'POST',headers:h,body:JSON.stringify(body)}); + return NextResponse.json(await res.json()); +} diff --git a/app/admin/api/autogen/run/route.js b/app/admin/api/autogen/run/route.js new file mode 100644 index 0000000..3da75e2 --- /dev/null +++ b/app/admin/api/autogen/run/route.js @@ -0,0 +1,13 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; + +const E = process.env.ENGINE_URL || 'http://127.0.0.1:3030'; +const S = process.env.ENGINE_SECRET || 'zeropost_internal_2026'; +const h = { 'Content-Type': 'application/json', 'x-internal-secret': S }; + +export async function POST(req) { + if (!(await checkAdminAuth())) return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + const body = await req.json(); + const res = await fetch(`${E}/api/autogen/run`, { method: 'POST', headers: h, body: JSON.stringify(body) }); + return NextResponse.json(await res.json()); +} diff --git a/app/admin/api/autogen/settings/route.js b/app/admin/api/autogen/settings/route.js new file mode 100644 index 0000000..488ee80 --- /dev/null +++ b/app/admin/api/autogen/settings/route.js @@ -0,0 +1,11 @@ +import { NextResponse } from 'next/server'; +import { checkAdminAuth } from '@/lib/adminAuth'; +const E = process.env.ENGINE_URL||'http://127.0.0.1:3030'; +const S = process.env.ENGINE_SECRET||'zeropost_internal_2026'; +const h = { 'Content-Type':'application/json','x-internal-secret':S }; +export async function PATCH(req) { + if (!(await checkAdminAuth())) return NextResponse.json({error:'Unauthorized'},{status:401}); + const { category, ...data } = await req.json(); + const res = await fetch(`${E}/api/autogen/settings/${category}`,{method:'PATCH',headers:h,body:JSON.stringify(data)}); + return NextResponse.json(await res.json()); +} diff --git a/components/admin/AdminNav.js b/components/admin/AdminNav.js index 9adf547..1189193 100644 --- a/components/admin/AdminNav.js +++ b/components/admin/AdminNav.js @@ -1,12 +1,13 @@ 'use client'; import Link from 'next/link'; import { usePathname, useRouter } from 'next/navigation'; -import { LayoutDashboard, FileText, Radio, LogOut, ExternalLink } from 'lucide-react'; +import { LayoutDashboard, FileText, Radio, Zap, LogOut, ExternalLink } from 'lucide-react'; const NAV = [ { href: '/admin', label: 'Дашборд', icon: LayoutDashboard, exact: true }, { href: '/admin/articles', label: 'Статьи', icon: FileText }, { href: '/admin/channels', label: 'Каналы', icon: Radio }, + { href: '/admin/autogen', label: 'Автогенерация', icon: Zap }, ]; export default function AdminNav() { diff --git a/components/admin/AutogenPanel.js b/components/admin/AutogenPanel.js new file mode 100644 index 0000000..423e3d3 --- /dev/null +++ b/components/admin/AutogenPanel.js @@ -0,0 +1,309 @@ +'use client'; +import { useState } from 'react'; +import { useRouter } from 'next/navigation'; +import { Play, Plus, Trash2, RefreshCw, Clock, CheckCircle, XCircle, Zap } from 'lucide-react'; + +const CAT_LABELS = { + 'ai-tools': { name: 'ИИ-инструменты', icon: '🤖', color: 'emerald' }, + 'cybersec': { name: 'Кибербезопасность', icon: '🔒', color: 'red' }, + 'automation': { name: 'Автоматизация', icon: '⚡', color: 'amber' }, + 'ai-dev': { name: 'Разработка с ИИ', icon: '💻', color: 'blue' }, +}; + +const COLOR = { + emerald: 'bg-emerald-50 dark:bg-emerald-950 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300', + red: 'bg-red-50 dark:bg-red-950 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300', + amber: 'bg-amber-50 dark:bg-amber-950 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300', + blue: 'bg-blue-50 dark:bg-blue-950 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300', +}; + +export default function AutogenPanel({ status, queue, topics }) { + const router = useRouter(); + const [running, setRunning] = useState({}); + const [toast, setToast] = useState(null); + const [newTopic, setNewTopic] = useState(''); + const [newCat, setNewCat] = useState('ai-tools'); + const [addingTopic, setAddingTopic] = useState(false); + const [showAddForm, setShowAddForm] = useState(false); + + function showToast(msg, type = 'success') { + setToast({ msg, type }); + setTimeout(() => setToast(null), 4000); + } + + async function runCategory(category) { + setRunning(r => ({ ...r, [category]: true })); + try { + const res = await fetch('/admin/api/autogen/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category }), + }); + if (!res.ok) throw new Error(await res.text()); + showToast(`Генерация запущена для "${CAT_LABELS[category]?.name}". Статья появится через ~2 мин.`); + } catch (e) { + showToast(e.message, 'error'); + } finally { + setTimeout(() => { + setRunning(r => ({ ...r, [category]: false })); + router.refresh(); + }, 5000); + } + } + + async function runAll() { + setRunning({ all: true }); + try { + const res = await fetch('/admin/api/autogen/run', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({}), + }); + if (!res.ok) throw new Error(await res.text()); + showToast('Запущена генерация для всех активных категорий'); + } catch (e) { + showToast(e.message, 'error'); + } finally { + setTimeout(() => { setRunning({}); router.refresh(); }, 8000); + } + } + + async function toggleCategory(category, enabled) { + await fetch('/admin/api/autogen/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category, enabled }), + }); + router.refresh(); + } + + async function updatePerDay(category, per_day) { + await fetch('/admin/api/autogen/settings', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category, per_day: parseInt(per_day) }), + }); + router.refresh(); + } + + async function addTopic() { + if (!newTopic.trim()) return; + setAddingTopic(true); + try { + const res = await fetch('/admin/api/autogen/queue', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ category: newCat, topic: newTopic.trim(), priority: 8 }), + }); + if (!res.ok) throw new Error(await res.text()); + showToast('Тема добавлена в очередь'); + setNewTopic(''); + setShowAddForm(false); + router.refresh(); + } catch (e) { + showToast(e.message, 'error'); + } finally { + setAddingTopic(false); + } + } + + async function removeTopic(id) { + await fetch(`/admin/api/autogen/queue/${id}`, { method: 'DELETE' }); + router.refresh(); + } + + const pendingQueue = queue.filter(q => q.status === 'pending'); + const doneQueue = queue.filter(q => q.status === 'done').slice(0, 5); + + return ( +
+ {/* Toast */} + {toast && ( +
+ {toast.msg} +
+ )} + + {/* Шапка */} +
+
+

Автогенерация

+

Автоматическое создание статей по расписанию

+
+ +
+ + {/* Категории */} +
+ {status.map(s => { + const cat = CAT_LABELS[s.category] || { name: s.category, icon: '📝', color: 'emerald' }; + const colorCls = COLOR[cat.color] || COLOR.emerald; + const isRunning = running[s.category] || running.all; + return ( +
+
+
+ {cat.icon} +
+
{cat.name}
+
{s.article_count || 0} статей · {s.queue_count || 0} в очереди
+
+
+ {/* Вкл/выкл */} + +
+ + {/* Расписание */} +
+ + Статей в день: + + {s.next_run_at && ( + + след.: {new Date(s.next_run_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })} + + )} +
+ + {/* Кнопка запуска */} + +
+ ); + })} +
+ + {/* Очередь тем */} +
+
+

+ Очередь тем ({pendingQueue.length}) +

+ +
+ + {/* Форма добавления */} + {showAddForm && ( +
+
+ + setNewTopic(e.target.value)} + onKeyDown={e => e.key === 'Enter' && addTopic()} + placeholder="Тема статьи..." + className="flex-1 px-3 py-2 rounded-lg border border-neutral-200 dark:border-neutral-700 bg-white dark:bg-neutral-800 text-sm focus:outline-none focus:ring-2 focus:ring-emerald-500" + autoFocus + /> + +
+
+ )} + + {/* Список очереди */} +
+ {pendingQueue.length === 0 && !showAddForm && ( +
+ Очередь пуста — темы берутся из банка автоматически +
+ )} + {pendingQueue.map(item => { + const cat = CAT_LABELS[item.category]; + return ( +
+ {cat?.icon || '📝'} +
+
{item.topic}
+
{cat?.name} · приоритет {item.priority}
+
+ +
+ ); + })} +
+
+ + {/* Банк тем */} +
+
+

Банк тем

+

Темы из которых AI выбирает автоматически когда очередь пуста

+
+
+ {Object.entries(topics).map(([catSlug, topicList]) => { + const cat = CAT_LABELS[catSlug]; + return ( +
+
+ {cat?.icon} + {cat?.name} +
+
+ {topicList.map((t, i) => ( + + ))} +
+
+ ); + })} +
+
+
+ ); +}