Files
zeropost-web/components/admin/AutogenPanel.js
T

310 lines
14 KiB
JavaScript

'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 (
<div className="space-y-8">
{/* Toast */}
{toast && (
<div className={`fixed top-4 right-4 z-50 px-4 py-3 rounded-xl text-sm font-medium shadow-lg max-w-sm ${
toast.type === 'error' ? 'bg-red-500 text-white' : 'bg-emerald-500 text-white'
}`}>
{toast.msg}
</div>
)}
{/* Шапка */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-neutral-900 dark:text-neutral-100">Автогенерация</h1>
<p className="text-sm text-neutral-500 mt-0.5">Автоматическое создание статей по расписанию</p>
</div>
<button
onClick={runAll}
disabled={!!running.all}
className="inline-flex items-center gap-2 px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors"
>
{running.all ? <RefreshCw className="w-4 h-4 animate-spin" /> : <Zap className="w-4 h-4" />}
{running.all ? 'Запускается...' : 'Запустить все'}
</button>
</div>
{/* Категории */}
<div className="grid sm:grid-cols-2 gap-4">
{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 (
<div key={s.category} className={`rounded-xl border p-5 ${colorCls}`}>
<div className="flex items-start justify-between mb-4">
<div className="flex items-center gap-2">
<span className="text-2xl">{cat.icon}</span>
<div>
<div className="font-semibold text-sm">{cat.name}</div>
<div className="text-xs opacity-70">{s.article_count || 0} статей · {s.queue_count || 0} в очереди</div>
</div>
</div>
{/* Вкл/выкл */}
<button
onClick={() => toggleCategory(s.category, !s.enabled)}
className={`text-xs px-2 py-1 rounded-full font-medium border transition-colors ${
s.enabled ? 'bg-white dark:bg-neutral-900 border-current' : 'opacity-50 border-current'
}`}
>
{s.enabled ? '● Вкл' : '○ Выкл'}
</button>
</div>
{/* Расписание */}
<div className="flex items-center gap-3 mb-4">
<Clock className="w-3.5 h-3.5 opacity-60" />
<span className="text-xs opacity-70">Статей в день:</span>
<select
value={s.per_day}
onChange={e => updatePerDay(s.category, e.target.value)}
className="text-xs bg-white/50 dark:bg-black/20 border border-current/20 rounded px-2 py-0.5"
>
{[1,2,3,4].map(n => <option key={n} value={n}>{n}</option>)}
</select>
{s.next_run_at && (
<span className="text-xs opacity-60 ml-auto">
след.: {new Date(s.next_run_at).toLocaleString('ru-RU', { day:'numeric', month:'short', hour:'2-digit', minute:'2-digit' })}
</span>
)}
</div>
{/* Кнопка запуска */}
<button
onClick={() => runCategory(s.category)}
disabled={isRunning || !s.enabled}
className="w-full flex items-center justify-center gap-2 py-2 rounded-lg bg-white/60 dark:bg-black/20 hover:bg-white/80 dark:hover:bg-black/30 disabled:opacity-40 text-sm font-medium transition-colors border border-current/20"
>
{isRunning ? <RefreshCw className="w-3.5 h-3.5 animate-spin" /> : <Play className="w-3.5 h-3.5" />}
{isRunning ? 'Генерируется...' : 'Сгенерировать сейчас'}
</button>
</div>
);
})}
</div>
{/* Очередь тем */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
<div className="flex items-center justify-between px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">
Очередь тем <span className="text-neutral-400 font-normal">({pendingQueue.length})</span>
</h2>
<button
onClick={() => setShowAddForm(v => !v)}
className="inline-flex items-center gap-1 px-3 py-1.5 rounded-lg border border-neutral-200 dark:border-neutral-700 text-sm hover:bg-neutral-50 dark:hover:bg-neutral-800 transition-colors"
>
<Plus className="w-3.5 h-3.5" /> Добавить тему
</button>
</div>
{/* Форма добавления */}
{showAddForm && (
<div className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800 bg-neutral-50 dark:bg-neutral-800/50">
<div className="flex gap-3">
<select
value={newCat}
onChange={e => setNewCat(e.target.value)}
className="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 shrink-0"
>
{Object.entries(CAT_LABELS).map(([v, l]) => (
<option key={v} value={v}>{l.icon} {l.name}</option>
))}
</select>
<input
type="text"
value={newTopic}
onChange={e => 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
/>
<button
onClick={addTopic}
disabled={addingTopic || !newTopic.trim()}
className="px-4 py-2 rounded-lg bg-emerald-500 hover:bg-emerald-600 disabled:opacity-50 text-white text-sm font-medium transition-colors shrink-0"
>
{addingTopic ? '...' : 'Добавить'}
</button>
</div>
</div>
)}
{/* Список очереди */}
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{pendingQueue.length === 0 && !showAddForm && (
<div className="px-5 py-8 text-center text-sm text-neutral-400">
Очередь пуста темы берутся из банка автоматически
</div>
)}
{pendingQueue.map(item => {
const cat = CAT_LABELS[item.category];
return (
<div key={item.id} className="flex items-center gap-3 px-5 py-3">
<span className="text-lg shrink-0">{cat?.icon || '📝'}</span>
<div className="flex-1 min-w-0">
<div className="text-sm text-neutral-900 dark:text-neutral-100 truncate">{item.topic}</div>
<div className="text-xs text-neutral-400">{cat?.name} · приоритет {item.priority}</div>
</div>
<button onClick={() => removeTopic(item.id)} className="p-1.5 rounded text-neutral-300 hover:text-red-500 transition-colors">
<Trash2 className="w-3.5 h-3.5" />
</button>
</div>
);
})}
</div>
</div>
{/* Банк тем */}
<div className="bg-white dark:bg-neutral-900 rounded-xl border border-neutral-200 dark:border-neutral-800">
<div className="px-5 py-4 border-b border-neutral-100 dark:border-neutral-800">
<h2 className="text-sm font-semibold text-neutral-900 dark:text-neutral-100">Банк тем</h2>
<p className="text-xs text-neutral-400 mt-0.5">Темы из которых AI выбирает автоматически когда очередь пуста</p>
</div>
<div className="divide-y divide-neutral-100 dark:divide-neutral-800">
{Object.entries(topics).map(([catSlug, topicList]) => {
const cat = CAT_LABELS[catSlug];
return (
<div key={catSlug} className="px-5 py-4">
<div className="flex items-center gap-2 mb-2">
<span>{cat?.icon}</span>
<span className="text-xs font-semibold text-neutral-500 uppercase tracking-wide">{cat?.name}</span>
</div>
<div className="flex flex-wrap gap-1.5">
{topicList.map((t, i) => (
<button
key={i}
onClick={() => { setNewCat(catSlug); setNewTopic(t); setShowAddForm(true); }}
className="text-xs px-2.5 py-1 rounded-full bg-neutral-100 dark:bg-neutral-800 text-neutral-600 dark:text-neutral-400 hover:bg-emerald-50 dark:hover:bg-emerald-950 hover:text-emerald-600 transition-colors"
>
{t}
</button>
))}
</div>
</div>
);
})}
</div>
</div>
</div>
);
}