diff --git a/app/api/admin/autogen/[category]/route.js b/app/api/admin/autogen/[category]/route.js
new file mode 100644
index 0000000..52bdcaa
--- /dev/null
+++ b/app/api/admin/autogen/[category]/route.js
@@ -0,0 +1,18 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+
+const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
+const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
+const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
+
+export async function PATCH(req, { params }) {
+ const user = await requireUser();
+ if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ const body = await req.json();
+ const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}`, {
+ method: 'PATCH',
+ headers: { ...h(user.id), 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return NextResponse.json(await res.json());
+}
diff --git a/app/api/admin/autogen/[category]/run/route.js b/app/api/admin/autogen/[category]/run/route.js
new file mode 100644
index 0000000..ed699f5
--- /dev/null
+++ b/app/api/admin/autogen/[category]/run/route.js
@@ -0,0 +1,15 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+
+const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
+const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
+const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
+
+export async function POST(req, { params }) {
+ const user = await requireUser();
+ if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ const res = await fetch(`${ENGINE_URL}/api/admin/autogen/${params.category}/run`, {
+ method: 'POST', headers: h(user.id),
+ });
+ return NextResponse.json(await res.json());
+}
diff --git a/app/api/admin/autogen/queue/[id]/route.js b/app/api/admin/autogen/queue/[id]/route.js
new file mode 100644
index 0000000..a8bb328
--- /dev/null
+++ b/app/api/admin/autogen/queue/[id]/route.js
@@ -0,0 +1,15 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+
+const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
+const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
+const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
+
+export async function DELETE(req, { params }) {
+ const user = await requireUser();
+ if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ const res = await fetch(`${ENGINE_URL}/api/admin/autogen/queue/${params.id}`, {
+ method: 'DELETE', headers: h(user.id),
+ });
+ return NextResponse.json(await res.json());
+}
diff --git a/app/api/admin/autogen/route.js b/app/api/admin/autogen/route.js
new file mode 100644
index 0000000..7650453
--- /dev/null
+++ b/app/api/admin/autogen/route.js
@@ -0,0 +1,25 @@
+import { NextResponse } from 'next/server';
+import { requireUser } from '@/lib/session';
+
+const ENGINE_URL = process.env.ENGINE_URL || 'http://127.0.0.1:3030';
+const ENGINE_SECRET = process.env.ENGINE_SECRET || '';
+const h = (uid) => ({ 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(uid) });
+
+export async function GET() {
+ const user = await requireUser();
+ if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ const res = await fetch(`${ENGINE_URL}/api/admin/autogen`, { headers: h(user.id), cache: 'no-store' });
+ return NextResponse.json(await res.json());
+}
+
+export async function POST(req) {
+ const user = await requireUser();
+ if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
+ const body = await req.json();
+ const res = await fetch(`${ENGINE_URL}/api/admin/autogen/queue`, {
+ method: 'POST',
+ headers: { ...h(user.id), 'Content-Type': 'application/json' },
+ body: JSON.stringify(body),
+ });
+ return NextResponse.json(await res.json(), { status: res.status });
+}
diff --git a/components/AdminPanel.js b/components/AdminPanel.js
index 3c399c2..f62782c 100644
--- a/components/AdminPanel.js
+++ b/components/AdminPanel.js
@@ -1,12 +1,13 @@
'use client';
import { useState } from 'react';
-import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle } from 'lucide-react';
+import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen } from 'lucide-react';
import Link from 'next/link';
import AdminBilling from './admin/AdminBilling';
import AdminUsers from './admin/AdminUsers';
import AdminPromos from './admin/AdminPromos';
import AdminQueue from './admin/AdminQueue';
import AdminLogs from './admin/AdminLogs';
+import AdminAutogen from './admin/AdminAutogen';
// ──────────────────────────────────────────────────────────────
// Sidebar navigation
@@ -19,6 +20,7 @@ const SECTIONS = [
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
+ { id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
@@ -71,6 +73,7 @@ export default function AdminPanel({ initialSection = 'settings' }) {
{section === 'spending' && }
{section === 'queue' && }
{section === 'logs' && }
+ {section === 'autogen' && }
{section === 'plans' && }
{section === 'promos' && }
{section === 'billing' && }
diff --git a/components/admin/AdminAutogen.js b/components/admin/AdminAutogen.js
new file mode 100644
index 0000000..a8eb308
--- /dev/null
+++ b/components/admin/AdminAutogen.js
@@ -0,0 +1,322 @@
+'use client';
+import { useState, useEffect } from 'react';
+import { RefreshCw, Loader2, Play, Plus, Trash2, Check, ToggleLeft, ToggleRight, BookOpen, Clock, Zap } from 'lucide-react';
+
+const CATEGORY_LABELS = {
+ 'ai-tools': { label: 'AI инструменты', icon: '🤖', color: 'text-purple-400' },
+ 'ai-dev': { label: 'AI разработка', icon: '💻', color: 'text-blue-400' },
+ 'automation': { label: 'Автоматизация', icon: '⚙️', color: 'text-green-400' },
+ 'cybersec': { label: 'Кибербезопасность', icon: '🔒', color: 'text-red-400' },
+};
+
+function fmtDate(s) {
+ if (!s) return '—';
+ return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
+}
+
+function nextRunIn(nextRunAt) {
+ if (!nextRunAt) return null;
+ const diff = new Date(nextRunAt) - Date.now();
+ if (diff < 0) return 'скоро';
+ const h = Math.floor(diff / 3600000);
+ const m = Math.floor((diff % 3600000) / 60000);
+ if (h > 0) return `через ${h}ч ${m}м`;
+ return `через ${m}м`;
+}
+
+export default function AdminAutogen() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [saving, setSaving] = useState({});
+ const [running, setRunning] = useState({});
+ const [msg, setMsg] = useState('');
+ const [drafts, setDrafts] = useState({}); // category → edited settings
+
+ // Форма добавления темы в очередь
+ const [showQueue, setShowQueue] = useState(false);
+ const [qCat, setQCat] = useState('ai-tools');
+ const [qTopic, setQTopic] = useState('');
+ const [qPriority, setQPriority] = useState(5);
+ const [addingQ, setAddingQ] = useState(false);
+
+ async function load() {
+ setLoading(true);
+ try {
+ const res = await fetch('/api/admin/autogen').then(r => r.json());
+ setData(res);
+ } catch {}
+ setLoading(false);
+ }
+
+ useEffect(() => { load(); }, []);
+
+ function setDraft(category, field, value) {
+ setDrafts(d => ({
+ ...d,
+ [category]: { ...(d[category] || {}), [field]: value },
+ }));
+ }
+
+ function getSetting(category, field) {
+ if (drafts[category]?.[field] !== undefined) return drafts[category][field];
+ const s = data?.settings?.find(s => s.category === category);
+ return s?.[field];
+ }
+
+ async function save(category) {
+ const draft = drafts[category];
+ if (!draft || !Object.keys(draft).length) return;
+ setSaving(s => ({ ...s, [category]: true }));
+ try {
+ const res = await fetch(`/api/admin/autogen/${category}`, {
+ method: 'PATCH',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify(draft),
+ }).then(r => r.json());
+ if (res.ok) {
+ setMsg(`✓ ${category} сохранено`);
+ setDrafts(d => { const n = {...d}; delete n[category]; return n; });
+ load();
+ } else setMsg('Ошибка: ' + res.error);
+ } catch { setMsg('Ошибка соединения'); }
+ setSaving(s => ({ ...s, [category]: false }));
+ setTimeout(() => setMsg(''), 3000);
+ }
+
+ async function runNow(category) {
+ setRunning(r => ({ ...r, [category]: true }));
+ const res = await fetch(`/api/admin/autogen/${category}/run`, { method: 'POST' }).then(r => r.json());
+ setRunning(r => ({ ...r, [category]: false }));
+ setMsg(res.ok ? `⚡ Генерация ${category} запущена (1-2 мин)` : 'Ошибка: ' + res.error);
+ setTimeout(() => setMsg(''), 4000);
+ }
+
+ async function addToQueue() {
+ if (!qTopic.trim()) return;
+ setAddingQ(true);
+ const res = await fetch('/api/admin/autogen/queue', {
+ method: 'POST',
+ headers: { 'Content-Type': 'application/json' },
+ body: JSON.stringify({ category: qCat, topic: qTopic.trim(), priority: qPriority }),
+ }).then(r => r.json());
+ setAddingQ(false);
+ if (res.id) {
+ setMsg('✓ Тема добавлена в очередь');
+ setQTopic('');
+ setShowQueue(false);
+ load();
+ } else setMsg('Ошибка: ' + res.error);
+ setTimeout(() => setMsg(''), 3000);
+ }
+
+ async function removeFromQueue(id) {
+ await fetch(`/api/admin/autogen/queue/${id}`, { method: 'DELETE' });
+ load();
+ }
+
+ const categories = data?.settings?.map(s => s.category) || [];
+
+ return (
+
+ {/* Header */}
+
+
+
Автогенерация блога
+
Статьи для zeropost.ru генерируются автоматически по расписанию
+
+
+ {msg && {msg}}
+
+
+
+
+ {loading && !data &&
}
+
+ {data && (<>
+ {/* Категории */}
+
+ {categories.map(cat => {
+ const cfg = CATEGORY_LABELS[cat] || { label: cat, icon: '📝', color: 'text-gray-400' };
+ const s = data.settings.find(s => s.category === cat);
+ const stat = data.byCategory?.[cat];
+ const hasDraft = Object.keys(drafts[cat] || {}).length > 0;
+ const isEnabled = getSetting(cat, 'enabled');
+ const bankSize = data.topicBankSizes?.[cat] || 0;
+
+ return (
+
+ {/* Заголовок категории */}
+
+
+
+ {/* Запустить сейчас */}
+
+ {/* Toggle */}
+
+
+
+
+ {/* Статистика */}
+
+
+
{stat?.cnt_7d || 0}
+
статей за 7 дней
+
+
+
{bankSize}
+
тем в банке
+
+
+
{nextRunIn(s?.next_run_at) || '—'}
+
следующий запуск
+
+
+
+ {/* Настройки */}
+
+
+
+
+
+
+
+ setDraft(cat, 'run_hour', +e.target.value)}
+ className="input w-full text-sm py-1.5" />
+
+
+
+ setDraft(cat, 'run_minute', +e.target.value)}
+ className="input w-full text-sm py-1.5" />
+
+
+
+ {/* Последний + следующий */}
+
+
+
+ Последний запуск: {fmtDate(s?.last_run_at)}
+
+
+
+ Следующий: {fmtDate(s?.next_run_at)}
+
+
+
+ {/* Кнопка сохранить (если есть изменения) */}
+ {hasDraft && (
+
+
+
+
+ )}
+
+ );
+ })}
+
+
+ {/* Очередь тем */}
+
+
+
+
+ Очередь тем
+
+
Темы из очереди публикуются раньше тем из банка
+
+
+
+
+ {/* Форма добавления */}
+ {showQueue && (
+
+
+
+ setQTopic(e.target.value)}
+ onKeyDown={e => e.key === 'Enter' && addToQueue()}
+ placeholder="Тема статьи..." className="input text-sm py-1.5 col-span-2" autoFocus />
+
+
+
+
setQPriority(+e.target.value)} className="flex-1" />
+
{qPriority}
+
+
+
+
+ )}
+
+ {/* Список тем в очереди */}
+ {data.queue?.length === 0 && (
+
+ Очередь пуста — используются темы из банка
+
+ )}
+
+ {(data.queue || []).map(item => {
+ const cfg = CATEGORY_LABELS[item.category] || { icon: '📝', color: 'text-gray-400' };
+ return (
+
+
{cfg.icon}
+
+
{item.topic}
+
+ {item.category} · приоритет {item.priority} · {fmtDate(item.created_at)}
+
+
+
+
+ );
+ })}
+
+
+ >)}
+
+ );
+}