forked from admin/zeropost-tool
feat: AdminAutogen — blog autogeneration UI
AdminAutogen.js для каждой категории (ai-tools, ai-dev, automation, cybersec): - Toggle вкл/выкл + статус (статей за 7д, тем в банке, время след.запуска) - Настройки: статей/день, час, минута + кнопка Сохранить - Кнопка Запустить прямо сейчас (фоновая генерация) - Последний/следующий запуск с датами Очередь тем: добавить тему с категорией и приоритетом, удалить AdminPanel: раздел Автогенерация с BookOpen иконкой API routes: /api/admin/autogen, /[category], /[category]/run, /queue/[id]
This commit is contained in:
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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());
|
||||
}
|
||||
@@ -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 });
|
||||
}
|
||||
@@ -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' && <SpendingSection />}
|
||||
{section === 'queue' && <AdminQueue />}
|
||||
{section === 'logs' && <AdminLogs />}
|
||||
{section === 'autogen' && <AdminAutogen />}
|
||||
{section === 'plans' && <PlansSection />}
|
||||
{section === 'promos' && <AdminPromos />}
|
||||
{section === 'billing' && <AdminUsers />}
|
||||
|
||||
@@ -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 (
|
||||
<div className="space-y-6">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h2 className="font-semibold">Автогенерация блога</h2>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Статьи для zeropost.ru генерируются автоматически по расписанию</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{msg && <span className="text-sm text-green-400">{msg}</span>}
|
||||
<button onClick={load} className="btn-ghost p-2">
|
||||
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{loading && !data && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||
|
||||
{data && (<>
|
||||
{/* Категории */}
|
||||
<div className="space-y-4">
|
||||
{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 (
|
||||
<div key={cat} className={`card p-5 ${!isEnabled ? 'opacity-60' : ''}`}>
|
||||
{/* Заголовок категории */}
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-xl">{cfg.icon}</span>
|
||||
<div>
|
||||
<div className={`font-semibold ${cfg.color}`}>{cfg.label}</div>
|
||||
<div className="text-xs text-gray-500 font-mono">{cat}</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Запустить сейчас */}
|
||||
<button onClick={() => runNow(cat)} disabled={running[cat]}
|
||||
className="btn-ghost text-xs px-2.5 py-1.5 flex items-center gap-1.5 text-accent">
|
||||
{running[cat] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
|
||||
Запустить
|
||||
</button>
|
||||
{/* Toggle */}
|
||||
<button onClick={() => {
|
||||
setDraft(cat, 'enabled', !isEnabled);
|
||||
setTimeout(() => save(cat), 50);
|
||||
}}>
|
||||
{isEnabled
|
||||
? <ToggleRight className="w-7 h-7 text-green-400" />
|
||||
: <ToggleLeft className="w-7 h-7 text-gray-500" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Статистика */}
|
||||
<div className="grid grid-cols-3 gap-3 mb-4 text-xs">
|
||||
<div className="bg-surface2 rounded-lg p-2.5 text-center">
|
||||
<div className="font-bold text-base">{stat?.cnt_7d || 0}</div>
|
||||
<div className="text-gray-500 mt-0.5">статей за 7 дней</div>
|
||||
</div>
|
||||
<div className="bg-surface2 rounded-lg p-2.5 text-center">
|
||||
<div className="font-bold text-base">{bankSize}</div>
|
||||
<div className="text-gray-500 mt-0.5">тем в банке</div>
|
||||
</div>
|
||||
<div className="bg-surface2 rounded-lg p-2.5 text-center">
|
||||
<div className="font-bold text-sm truncate">{nextRunIn(s?.next_run_at) || '—'}</div>
|
||||
<div className="text-gray-500 mt-0.5">следующий запуск</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Настройки */}
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Статей в день</label>
|
||||
<select value={getSetting(cat, 'per_day') ?? 1}
|
||||
onChange={e => setDraft(cat, 'per_day', +e.target.value)}
|
||||
className="input w-full text-sm py-1.5">
|
||||
{[1,2,3,4,5].map(n => <option key={n} value={n}>{n}</option>)}
|
||||
</select>
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Час запуска (0-23)</label>
|
||||
<input type="number" min={0} max={23}
|
||||
value={getSetting(cat, 'run_hour') ?? 8}
|
||||
onChange={e => setDraft(cat, 'run_hour', +e.target.value)}
|
||||
className="input w-full text-sm py-1.5" />
|
||||
</div>
|
||||
<div>
|
||||
<label className="label text-xs mb-1">Минута</label>
|
||||
<input type="number" min={0} max={59}
|
||||
value={getSetting(cat, 'run_minute') ?? 0}
|
||||
onChange={e => setDraft(cat, 'run_minute', +e.target.value)}
|
||||
className="input w-full text-sm py-1.5" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Последний + следующий */}
|
||||
<div className="flex gap-4 mt-3 text-xs text-gray-500">
|
||||
<div className="flex items-center gap-1">
|
||||
<Clock className="w-3 h-3" />
|
||||
Последний запуск: {fmtDate(s?.last_run_at)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Zap className="w-3 h-3" />
|
||||
Следующий: {fmtDate(s?.next_run_at)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Кнопка сохранить (если есть изменения) */}
|
||||
{hasDraft && (
|
||||
<div className="mt-3 pt-3 border-t border-border flex items-center gap-2">
|
||||
<button onClick={() => save(cat)} disabled={saving[cat]}
|
||||
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1.5">
|
||||
{saving[cat] ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Check className="w-3.5 h-3.5" />}
|
||||
Сохранить
|
||||
</button>
|
||||
<button onClick={() => setDrafts(d => { const n = {...d}; delete n[cat]; return n; })}
|
||||
className="btn-ghost text-sm px-3 py-1.5 text-gray-500">Отмена</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Очередь тем */}
|
||||
<div className="card p-5">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div>
|
||||
<h3 className="font-medium text-sm flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4 text-accent" /> Очередь тем
|
||||
</h3>
|
||||
<p className="text-xs text-gray-400 mt-0.5">Темы из очереди публикуются раньше тем из банка</p>
|
||||
</div>
|
||||
<button onClick={() => setShowQueue(v => !v)}
|
||||
className="btn-ghost text-sm px-2.5 py-1.5 flex items-center gap-1.5">
|
||||
<Plus className="w-4 h-4" /> Добавить
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Форма добавления */}
|
||||
{showQueue && (
|
||||
<div className="mb-4 p-3 rounded-lg bg-accent/5 border border-accent/20 space-y-2">
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
<select value={qCat} onChange={e => setQCat(e.target.value)} className="input text-sm py-1.5">
|
||||
{Object.entries(CATEGORY_LABELS).map(([k, v]) => (
|
||||
<option key={k} value={k}>{v.icon} {v.label}</option>
|
||||
))}
|
||||
</select>
|
||||
<input value={qTopic} onChange={e => setQTopic(e.target.value)}
|
||||
onKeyDown={e => e.key === 'Enter' && addToQueue()}
|
||||
placeholder="Тема статьи..." className="input text-sm py-1.5 col-span-2" autoFocus />
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="text-xs text-gray-500 w-16">Приоритет:</label>
|
||||
<input type="range" min={1} max={10} value={qPriority}
|
||||
onChange={e => setQPriority(+e.target.value)} className="flex-1" />
|
||||
<span className="text-xs text-gray-400 w-4">{qPriority}</span>
|
||||
<button onClick={addToQueue} disabled={addingQ || !qTopic.trim()}
|
||||
className="btn-primary text-sm px-3 py-1.5 flex items-center gap-1">
|
||||
{addingQ ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Plus className="w-3.5 h-3.5" />}
|
||||
Добавить
|
||||
</button>
|
||||
<button onClick={() => setShowQueue(false)} className="btn-ghost p-1.5">✕</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Список тем в очереди */}
|
||||
{data.queue?.length === 0 && (
|
||||
<div className="py-6 text-center text-sm text-gray-500">
|
||||
Очередь пуста — используются темы из банка
|
||||
</div>
|
||||
)}
|
||||
<div className="space-y-2">
|
||||
{(data.queue || []).map(item => {
|
||||
const cfg = CATEGORY_LABELS[item.category] || { icon: '📝', color: 'text-gray-400' };
|
||||
return (
|
||||
<div key={item.id} className="flex items-center gap-2 p-2.5 rounded-lg bg-surface2 hover:bg-surface2/80">
|
||||
<span className="text-sm">{cfg.icon}</span>
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="text-sm text-gray-200 truncate">{item.topic}</div>
|
||||
<div className="text-xs text-gray-500 mt-0.5">
|
||||
{item.category} · приоритет {item.priority} · {fmtDate(item.created_at)}
|
||||
</div>
|
||||
</div>
|
||||
<button onClick={() => removeFromQueue(item.id)}
|
||||
className="btn-ghost p-1.5 text-gray-500 hover:text-red-400 shrink-0">
|
||||
<Trash2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</>)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user