From 06340ab24e343755d36e6404196b139979b30ba7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Sat, 13 Jun 2026 10:36:38 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20AdminAutogen=20=E2=80=94=20blog=20autog?= =?UTF-8?q?eneration=20UI?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit AdminAutogen.js для каждой категории (ai-tools, ai-dev, automation, cybersec): - Toggle вкл/выкл + статус (статей за 7д, тем в банке, время след.запуска) - Настройки: статей/день, час, минута + кнопка Сохранить - Кнопка Запустить прямо сейчас (фоновая генерация) - Последний/следующий запуск с датами Очередь тем: добавить тему с категорией и приоритетом, удалить AdminPanel: раздел Автогенерация с BookOpen иконкой API routes: /api/admin/autogen, /[category], /[category]/run, /queue/[id] --- app/api/admin/autogen/[category]/route.js | 18 + app/api/admin/autogen/[category]/run/route.js | 15 + app/api/admin/autogen/queue/[id]/route.js | 15 + app/api/admin/autogen/route.js | 25 ++ components/AdminPanel.js | 5 +- components/admin/AdminAutogen.js | 322 ++++++++++++++++++ 6 files changed, 399 insertions(+), 1 deletion(-) create mode 100644 app/api/admin/autogen/[category]/route.js create mode 100644 app/api/admin/autogen/[category]/run/route.js create mode 100644 app/api/admin/autogen/queue/[id]/route.js create mode 100644 app/api/admin/autogen/route.js create mode 100644 components/admin/AdminAutogen.js 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 ( +
+ {/* Заголовок категории */} +
+
+ {cfg.icon} +
+
{cfg.label}
+
{cat}
+
+
+
+ {/* Запустить сейчас */} + + {/* 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)} +
+
+ +
+ ); + })} +
+
+ )} +
+ ); +}