310 lines
14 KiB
JavaScript
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>
|
|
);
|
|
}
|