Compare commits
3 Commits
92872ed59c
...
2e9f099b95
| Author | SHA1 | Date | |
|---|---|---|---|
| 2e9f099b95 | |||
| 06340ab24e | |||
| a07cc224a9 |
@@ -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 });
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
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 || '';
|
||||||
|
|
||||||
|
export async function GET(req) {
|
||||||
|
const user = await requireUser();
|
||||||
|
if (!user?.isAdmin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const res = await fetch(
|
||||||
|
`${ENGINE_URL}/api/admin/logs?${searchParams}`,
|
||||||
|
{
|
||||||
|
headers: { 'x-internal-secret': ENGINE_SECRET, 'x-user-id': String(user.id) },
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
@@ -1,11 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag } from 'lucide-react';
|
import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft, Zap, Tag, AlertTriangle, BookOpen, Sliders } from 'lucide-react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import AdminBilling from './admin/AdminBilling';
|
import AdminBilling from './admin/AdminBilling';
|
||||||
import AdminUsers from './admin/AdminUsers';
|
import AdminUsers from './admin/AdminUsers';
|
||||||
import AdminPromos from './admin/AdminPromos';
|
import AdminPromos from './admin/AdminPromos';
|
||||||
import AdminQueue from './admin/AdminQueue';
|
import AdminQueue from './admin/AdminQueue';
|
||||||
|
import AdminLogs from './admin/AdminLogs';
|
||||||
|
import AdminAutogen from './admin/AdminAutogen';
|
||||||
|
import AdminContent from './admin/AdminContent';
|
||||||
|
|
||||||
// ──────────────────────────────────────────────────────────────
|
// ──────────────────────────────────────────────────────────────
|
||||||
// Sidebar navigation
|
// Sidebar navigation
|
||||||
@@ -17,6 +20,9 @@ const SECTIONS = [
|
|||||||
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
{ id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' },
|
||||||
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
{ id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' },
|
||||||
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
|
{ id: 'queue', label: 'Очередь', icon: Zap, desc: 'Задачи генерации, ошибки' },
|
||||||
|
{ id: 'logs', label: 'Логи ошибок', icon: AlertTriangle, desc: 'Последние сбои и проблемы' },
|
||||||
|
{ id: 'autogen', label: 'Автогенерация', icon: BookOpen, desc: 'Расписание статей блога' },
|
||||||
|
{ id: 'content', label: 'Контент-дефолты', icon: Sliders, desc: 'Настройки для новых каналов' },
|
||||||
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
{ id: 'plans', label: 'Тарифы', icon: BarChart3, desc: 'Планы, кредиты, операции' },
|
||||||
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
|
{ id: 'promos', label: 'Промокоды', icon: Tag, desc: 'Коды для кредитов и скидок' },
|
||||||
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
{ id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' },
|
||||||
@@ -68,6 +74,9 @@ export default function AdminPanel({ initialSection = 'settings' }) {
|
|||||||
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
{section === 'payments' && <SettingsSection categories={['payments']} />}
|
||||||
{section === 'spending' && <SpendingSection />}
|
{section === 'spending' && <SpendingSection />}
|
||||||
{section === 'queue' && <AdminQueue />}
|
{section === 'queue' && <AdminQueue />}
|
||||||
|
{section === 'logs' && <AdminLogs />}
|
||||||
|
{section === 'autogen' && <AdminAutogen />}
|
||||||
|
{section === 'content' && <AdminContent />}
|
||||||
{section === 'plans' && <PlansSection />}
|
{section === 'plans' && <PlansSection />}
|
||||||
{section === 'promos' && <AdminPromos />}
|
{section === 'promos' && <AdminPromos />}
|
||||||
{section === 'billing' && <AdminUsers />}
|
{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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,302 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { Save, Loader2, Check, RefreshCw, Info } from 'lucide-react';
|
||||||
|
|
||||||
|
// Метаданные полей для красивого UI
|
||||||
|
const FIELD_META = {
|
||||||
|
DEFAULT_POST_LANGUAGE: {
|
||||||
|
label: 'Язык постов',
|
||||||
|
desc: 'Применяется к новым каналам',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ v: 'ru', l: '🇷🇺 Русский' },
|
||||||
|
{ v: 'en', l: '🇬🇧 English' },
|
||||||
|
{ v: 'auto', l: '🌐 Авто (по нише)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DEFAULT_POST_LENGTH: {
|
||||||
|
label: 'Длина поста',
|
||||||
|
desc: 'short ≈ 300-500 зн, medium ≈ 600-1000 зн, long ≈ 1200-2000 зн',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ v: 'short', l: '📏 Короткий (300-500 зн)' },
|
||||||
|
{ v: 'medium', l: '📄 Средний (600-1000 зн)' },
|
||||||
|
{ v: 'long', l: '📃 Длинный (1200-2000 зн)' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DEFAULT_POST_STYLE: {
|
||||||
|
label: 'Стиль написания',
|
||||||
|
desc: 'Тон голоса по умолчанию',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ v: 'informative', l: '📚 Информативный' },
|
||||||
|
{ v: 'casual', l: '😊 Разговорный' },
|
||||||
|
{ v: 'professional', l: '👔 Профессиональный' },
|
||||||
|
{ v: 'storytelling', l: '📖 Сторителлинг' },
|
||||||
|
{ v: 'provocative', l: '🔥 Провокационный' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DEFAULT_POST_GOAL: {
|
||||||
|
label: 'Цель поста',
|
||||||
|
desc: 'На что ориентирован контент',
|
||||||
|
type: 'select',
|
||||||
|
options: [
|
||||||
|
{ v: 'educational', l: '🎓 Образовательный' },
|
||||||
|
{ v: 'entertainment',l: '🎭 Развлекательный' },
|
||||||
|
{ v: 'sales', l: '💰 Продажи' },
|
||||||
|
{ v: 'engagement', l: '❤️ Вовлечение' },
|
||||||
|
{ v: 'news', l: '📰 Новости' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
DEFAULT_IMAGE_ENABLED: {
|
||||||
|
label: 'Генерация изображений',
|
||||||
|
desc: 'Создавать картинки для постов (расходует кредиты)',
|
||||||
|
type: 'toggle',
|
||||||
|
},
|
||||||
|
DEFAULT_EMOJI_ENABLED: {
|
||||||
|
label: 'Эмодзи в постах',
|
||||||
|
desc: 'Добавлять эмодзи по умолчанию',
|
||||||
|
type: 'toggle',
|
||||||
|
},
|
||||||
|
DEFAULT_HASHTAGS_IN_POST: {
|
||||||
|
label: 'Хештеги в постах',
|
||||||
|
desc: 'Автоматически добавлять хештеги в конец поста',
|
||||||
|
type: 'toggle',
|
||||||
|
},
|
||||||
|
DEFAULT_AUTO_DRAFT_COUNT: {
|
||||||
|
label: 'Авто-черновиков в день',
|
||||||
|
desc: 'Для новых каналов с включённой авто-генерацией',
|
||||||
|
type: 'number',
|
||||||
|
min: 1, max: 10,
|
||||||
|
},
|
||||||
|
DEFAULT_AUTO_DRAFT_TIME: {
|
||||||
|
label: 'Время генерации черновиков',
|
||||||
|
desc: 'HH:MM (московское время UTC+3)',
|
||||||
|
type: 'time',
|
||||||
|
},
|
||||||
|
DEFAULT_AI_STYLE_PROMPT: {
|
||||||
|
label: 'Базовые инструкции стиля',
|
||||||
|
desc: 'Применяются ко всем каналам поверх индивидуальных настроек',
|
||||||
|
type: 'textarea',
|
||||||
|
placeholder: 'Например: Всегда пиши от первого лица. Используй активный залог...',
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
|
const GROUP_ORDER = [
|
||||||
|
{
|
||||||
|
title: 'Контент',
|
||||||
|
keys: ['DEFAULT_POST_LANGUAGE', 'DEFAULT_POST_STYLE', 'DEFAULT_POST_GOAL', 'DEFAULT_POST_LENGTH'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Форматирование',
|
||||||
|
keys: ['DEFAULT_IMAGE_ENABLED', 'DEFAULT_EMOJI_ENABLED', 'DEFAULT_HASHTAGS_IN_POST'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Авто-черновики',
|
||||||
|
keys: ['DEFAULT_AUTO_DRAFT_COUNT', 'DEFAULT_AUTO_DRAFT_TIME'],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'AI-инструкции',
|
||||||
|
keys: ['DEFAULT_AI_STYLE_PROMPT'],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminContent() {
|
||||||
|
const [rows, setRows] = useState([]);
|
||||||
|
const [vals, setVals] = useState({});
|
||||||
|
const [dirty, setDirty] = useState({});
|
||||||
|
const [saving, setSaving] = useState({});
|
||||||
|
const [saved, setSaved] = useState({});
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/settings?category=content').then(r => r.json());
|
||||||
|
const arr = Array.isArray(res) ? res : [];
|
||||||
|
setRows(arr);
|
||||||
|
const v = Object.fromEntries(arr.map(r => [r.key, r.value ?? '']));
|
||||||
|
setVals(v);
|
||||||
|
setDirty({});
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
function change(key, val) {
|
||||||
|
setVals(v => ({ ...v, [key]: val }));
|
||||||
|
setDirty(d => ({ ...d, [key]: true }));
|
||||||
|
setSaved(s => ({ ...s, [key]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
async function save(key) {
|
||||||
|
setSaving(s => ({ ...s, [key]: true }));
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/admin/settings/${encodeURIComponent(key)}`, {
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ value: vals[key] }),
|
||||||
|
}).then(r => r.json());
|
||||||
|
if (!res.error) {
|
||||||
|
setDirty(d => ({ ...d, [key]: false }));
|
||||||
|
setSaved(s => ({ ...s, [key]: true }));
|
||||||
|
setTimeout(() => setSaved(s => ({ ...s, [key]: false })), 2000);
|
||||||
|
}
|
||||||
|
} catch {}
|
||||||
|
setSaving(s => ({ ...s, [key]: false }));
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderField(key) {
|
||||||
|
const meta = FIELD_META[key];
|
||||||
|
if (!meta) return null;
|
||||||
|
const val = vals[key] ?? '';
|
||||||
|
const isDirty = dirty[key];
|
||||||
|
const isSaving = saving[key];
|
||||||
|
const isSaved = saved[key];
|
||||||
|
|
||||||
|
if (meta.type === 'toggle') {
|
||||||
|
const isOn = val === 'true';
|
||||||
|
return (
|
||||||
|
<div key={key} className="flex items-center justify-between py-3 border-b border-border last:border-0">
|
||||||
|
<div>
|
||||||
|
<div className="text-sm font-medium">{meta.label}</div>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
|
||||||
|
</div>
|
||||||
|
<button onClick={() => { change(key, isOn ? 'false' : 'true'); setTimeout(() => save(key), 50); }}
|
||||||
|
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors ${isOn ? 'bg-accent' : 'bg-gray-600'}`}>
|
||||||
|
<span className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${isOn ? 'translate-x-6' : 'translate-x-1'}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.type === 'select') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="py-3 border-b border-border last:border-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium">{meta.label}</label>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
|
||||||
|
<select value={val} onChange={e => change(key, e.target.value)}
|
||||||
|
className="input mt-2 text-sm py-1.5 w-full max-w-xs">
|
||||||
|
{meta.options?.map(o => <option key={o.v} value={o.v}>{o.l}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
{isDirty && (
|
||||||
|
<button onClick={() => save(key)} disabled={isSaving}
|
||||||
|
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
|
||||||
|
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.type === 'number') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="py-3 border-b border-border last:border-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium">{meta.label}</label>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
|
||||||
|
<input type="number" min={meta.min} max={meta.max}
|
||||||
|
value={val} onChange={e => change(key, e.target.value)}
|
||||||
|
className="input mt-2 text-sm py-1.5 w-24" />
|
||||||
|
</div>
|
||||||
|
{isDirty && (
|
||||||
|
<button onClick={() => save(key)} disabled={isSaving}
|
||||||
|
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
|
||||||
|
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.type === 'time') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="py-3 border-b border-border last:border-0">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div className="flex-1">
|
||||||
|
<label className="text-sm font-medium">{meta.label}</label>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5">{meta.desc}</div>
|
||||||
|
<input type="time" value={val}
|
||||||
|
onChange={e => change(key, e.target.value)}
|
||||||
|
className="input mt-2 text-sm py-1.5 w-32" />
|
||||||
|
</div>
|
||||||
|
{isDirty && (
|
||||||
|
<button onClick={() => save(key)} disabled={isSaving}
|
||||||
|
className="btn-primary mt-6 px-3 py-1.5 text-sm flex items-center gap-1.5 shrink-0">
|
||||||
|
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSaved && <Check className="w-4 h-4 text-green-400 mt-7 shrink-0" />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (meta.type === 'textarea') {
|
||||||
|
return (
|
||||||
|
<div key={key} className="py-3 border-b border-border last:border-0">
|
||||||
|
<label className="text-sm font-medium">{meta.label}</label>
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 mb-2">{meta.desc}</div>
|
||||||
|
<textarea rows={3} value={val}
|
||||||
|
onChange={e => change(key, e.target.value)}
|
||||||
|
placeholder={meta.placeholder || ''}
|
||||||
|
className="input w-full resize-none text-sm" />
|
||||||
|
{isDirty && (
|
||||||
|
<button onClick={() => save(key)} disabled={isSaving}
|
||||||
|
className="mt-2 btn-primary px-3 py-1.5 text-sm flex items-center gap-1.5">
|
||||||
|
{isSaving ? <Loader2 className="w-3.5 h-3.5 animate-spin" /> : <Save className="w-3.5 h-3.5" />}
|
||||||
|
Сохранить
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
{isSaved && <span className="mt-1 text-xs text-green-400 flex items-center gap-1"><Check className="w-3 h-3" /> Сохранено</span>}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="font-semibold">Настройки контента</h2>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">Дефолты для новых каналов. Существующие каналы не затрагиваются.</p>
|
||||||
|
</div>
|
||||||
|
<button onClick={load} className="btn-ghost p-2">
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Подсказка */}
|
||||||
|
<div className="card p-3 border-blue-500/20 bg-blue-500/5 flex items-start gap-2">
|
||||||
|
<Info className="w-4 h-4 text-blue-400 shrink-0 mt-0.5" />
|
||||||
|
<p className="text-xs text-gray-300">
|
||||||
|
Эти настройки применяются при создании нового канала как стартовые значения.
|
||||||
|
Каждый канал можно затем настроить индивидуально через вкладку «AI-стиль».
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && <div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
|
||||||
|
|
||||||
|
{!loading && GROUP_ORDER.map(group => (
|
||||||
|
<div key={group.title} className="card p-5">
|
||||||
|
<h3 className="font-medium text-sm text-gray-400 uppercase tracking-wide mb-1">{group.title}</h3>
|
||||||
|
{group.keys.map(key => renderField(key))}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,223 @@
|
|||||||
|
'use client';
|
||||||
|
import { useState, useEffect } from 'react';
|
||||||
|
import { RefreshCw, Loader2, AlertTriangle, Cpu, Send, ChevronDown, ChevronUp } from 'lucide-react';
|
||||||
|
|
||||||
|
const SOURCE_CONFIG = {
|
||||||
|
generation: { icon: '⚙️', label: 'Генерация', color: 'text-purple-400', bg: 'bg-purple-500/10' },
|
||||||
|
ai_provider:{ icon: '🤖', label: 'AI провайдер', color: 'text-blue-400', bg: 'bg-blue-500/10' },
|
||||||
|
publish: { icon: '📤', label: 'Публикация', color: 'text-orange-400', bg: 'bg-orange-500/10' },
|
||||||
|
};
|
||||||
|
|
||||||
|
// Категоризируем ошибку по тексту
|
||||||
|
function classifyError(msg) {
|
||||||
|
if (!msg) return { type: 'unknown', label: 'Неизвестно', color: 'text-gray-400' };
|
||||||
|
const m = msg.toLowerCase();
|
||||||
|
if (m.includes('timeout')) return { type: 'timeout', label: 'Таймаут', color: 'text-yellow-400' };
|
||||||
|
if (m.includes('rate limit')) return { type: 'ratelimit',label: 'Rate limit', color: 'text-orange-400' };
|
||||||
|
if (m.includes('not supported')) return { type: 'model', label: 'Модель', color: 'text-red-400' };
|
||||||
|
if (m.includes('empty response')) return { type: 'empty', label: 'Пустой ответ', color: 'text-red-400' };
|
||||||
|
if (m.includes('network') || m.includes('connect')) return { type: 'network', label: 'Сеть', color: 'text-orange-400' };
|
||||||
|
if (m.includes('auth') || m.includes('key') || m.includes('401')) return { type: 'auth', label: 'Авторизация', color: 'text-red-400' };
|
||||||
|
return { type: 'other', label: 'Другое', color: 'text-gray-400' };
|
||||||
|
}
|
||||||
|
|
||||||
|
function timeAgo(s) {
|
||||||
|
const diff = Date.now() - new Date(s);
|
||||||
|
if (diff < 60000) return Math.floor(diff / 1000) + 'с назад';
|
||||||
|
if (diff < 3600000) return Math.floor(diff / 60000) + 'м назад';
|
||||||
|
if (diff < 86400000)return Math.floor(diff / 3600000) + 'ч назад';
|
||||||
|
return new Date(s).toLocaleString('ru-RU', { day:'2-digit', month:'2-digit', hour:'2-digit', minute:'2-digit' });
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function AdminLogs() {
|
||||||
|
const [data, setData] = useState(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [filter, setFilter] = useState('all'); // all | generation | ai_provider | publish
|
||||||
|
const [expanded, setExpanded] = useState(null);
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
setLoading(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/admin/logs?limit=100').then(r => r.json());
|
||||||
|
setData(res);
|
||||||
|
} catch {}
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => { load(); }, []);
|
||||||
|
|
||||||
|
const errors = (data?.errors || []).filter(e =>
|
||||||
|
filter === 'all' || e.source === filter
|
||||||
|
);
|
||||||
|
|
||||||
|
const counts = (data?.errors || []).reduce((acc, e) => {
|
||||||
|
acc[e.source] = (acc[e.source] || 0) + 1;
|
||||||
|
return acc;
|
||||||
|
}, {});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h2 className="font-semibold">Логи ошибок</h2>
|
||||||
|
<button onClick={load} className="btn-ghost p-2">
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loading && !data && (
|
||||||
|
<div className="py-8 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{data && (<>
|
||||||
|
{/* Топ ошибок */}
|
||||||
|
{data.topErrors?.length > 0 && (
|
||||||
|
<div className="card p-4">
|
||||||
|
<p className="text-xs text-gray-500 uppercase tracking-wide mb-3">Частые ошибки</p>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{data.topErrors.map((e, i) => {
|
||||||
|
const cls = classifyError(e.msg);
|
||||||
|
const pct = Math.round((e.cnt / data.total) * 100);
|
||||||
|
return (
|
||||||
|
<div key={i} className="flex items-center gap-3">
|
||||||
|
<div className="w-16 text-right text-xs font-mono text-gray-400">{e.cnt}×</div>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="text-xs text-gray-200 truncate">{e.msg}</div>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<div className="flex-1 h-1 bg-surface2 rounded-full">
|
||||||
|
<div className="h-1 bg-accent/60 rounded-full" style={{ width: `${pct}%` }} />
|
||||||
|
</div>
|
||||||
|
<span className={`text-xs ${cls.color}`}>{cls.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Статистика + фильтр */}
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<button onClick={() => setFilter('all')}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs transition-colors ${filter === 'all' ? 'bg-accent/10 text-accent font-medium' : 'text-gray-500 hover:text-gray-300'}`}>
|
||||||
|
Все ({data.total})
|
||||||
|
</button>
|
||||||
|
{Object.entries(SOURCE_CONFIG).map(([k, cfg]) => (
|
||||||
|
<button key={k} onClick={() => setFilter(k)}
|
||||||
|
className={`px-2.5 py-1 rounded-lg text-xs transition-colors flex items-center gap-1 ${filter === k ? `${cfg.bg} ${cfg.color} font-medium` : 'text-gray-500 hover:text-gray-300'}`}>
|
||||||
|
{cfg.icon} {cfg.label} {counts[k] ? `(${counts[k]})` : ''}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Список */}
|
||||||
|
{errors.length === 0 && (
|
||||||
|
<div className="py-12 text-center text-gray-500">
|
||||||
|
<AlertTriangle className="w-10 h-10 mx-auto mb-3 opacity-20" />
|
||||||
|
<div className="text-sm">Ошибок не найдено 🎉</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{errors.map((err, i) => {
|
||||||
|
const src = SOURCE_CONFIG[err.source] || SOURCE_CONFIG.generation;
|
||||||
|
const cls = classifyError(err.message);
|
||||||
|
const isOpen = expanded === i;
|
||||||
|
const shortMsg = err.message?.split('\n')[0]?.slice(0, 100) || 'Unknown error';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={i} className={`card border-l-2 overflow-hidden transition-all ${
|
||||||
|
cls.type === 'timeout' ? 'border-yellow-500/40' :
|
||||||
|
cls.type === 'auth' ? 'border-red-500/60' :
|
||||||
|
cls.type === 'model' ? 'border-red-400/40' :
|
||||||
|
'border-gray-600'
|
||||||
|
}`}>
|
||||||
|
<button
|
||||||
|
onClick={() => setExpanded(isOpen ? null : i)}
|
||||||
|
className="w-full text-left px-4 py-3 flex items-start gap-3 hover:bg-surface2/30 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-base shrink-0 mt-0.5">{src.icon}</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2 mb-0.5 flex-wrap">
|
||||||
|
<span className={`text-xs font-medium ${src.color}`}>{src.label}</span>
|
||||||
|
<span className="text-xs text-gray-500">·</span>
|
||||||
|
<span className="text-xs text-gray-400 font-mono">{err.operation}</span>
|
||||||
|
{err.user_email && (
|
||||||
|
<>
|
||||||
|
<span className="text-xs text-gray-500">·</span>
|
||||||
|
<span className="text-xs text-gray-500">{err.user_email}</span>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
<span className="text-xs text-gray-600 ml-auto">{timeAgo(err.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="text-sm text-gray-200">{shortMsg}</div>
|
||||||
|
{err.context && (
|
||||||
|
<div className="text-xs text-gray-500 mt-0.5 truncate">{err.context}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="shrink-0 mt-1">
|
||||||
|
{isOpen
|
||||||
|
? <ChevronUp className="w-3.5 h-3.5 text-gray-500" />
|
||||||
|
: <ChevronDown className="w-3.5 h-3.5 text-gray-500" />}
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{isOpen && (
|
||||||
|
<div className="px-4 pb-3 border-t border-border bg-surface2/30">
|
||||||
|
<div className="mt-2 space-y-1.5 text-xs">
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">ID:</span>
|
||||||
|
<span className="font-mono text-gray-300">{err.entity_id}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">Источник:</span>
|
||||||
|
<span className="text-gray-300">{err.source}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">Операция:</span>
|
||||||
|
<span className="font-mono text-gray-300">{err.operation}</span>
|
||||||
|
</div>
|
||||||
|
{err.context && (
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">Контекст:</span>
|
||||||
|
<span className="text-gray-300 break-all">{err.context}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">Ошибка:</span>
|
||||||
|
<span className="text-red-300 break-all font-mono">{err.message}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">Время:</span>
|
||||||
|
<span className="text-gray-300">
|
||||||
|
{new Date(err.created_at).toLocaleString('ru-RU')}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<span className="text-gray-500 w-20 shrink-0">Тип ошибки:</span>
|
||||||
|
<span className={`${cls.color} font-medium`}>{cls.label}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Действие для ошибок модели */}
|
||||||
|
{cls.type === 'model' && err.source === 'ai_provider' && (
|
||||||
|
<div className="mt-2 p-2 rounded bg-red-500/10 border border-red-500/20 text-xs text-red-300">
|
||||||
|
💡 Проверь настройку AI_IMAGE_MODEL_VIA_RESPONSES в{' '}
|
||||||
|
<a href="/system?section=settings" className="underline">Настройках API</a>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{cls.type === 'timeout' && (
|
||||||
|
<div className="mt-2 p-2 rounded bg-yellow-500/10 border border-yellow-500/20 text-xs text-yellow-300">
|
||||||
|
💡 Таймаут {err.operation?.includes('chat') ? 'текстовой генерации' : 'изображений'} — возможны проблемы у провайдера
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</>)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user