diff --git a/app/billing/page.js b/app/billing/page.js index 56bed8a..db061e6 100644 --- a/app/billing/page.js +++ b/app/billing/page.js @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Coins, RefreshCw, TrendingDown, TrendingUp, Loader2, ArrowRight } from 'lucide-react'; import Link from 'next/link'; +import BackButton from '@/components/BackButton'; const TYPE_LABELS = { spend_image: { label: 'Генерация картинки', sign: '-', color: 'text-red-400' }, @@ -47,6 +48,7 @@ export default function BillingPage() { return (
+

Баланс и кредиты diff --git a/app/drafts/page.js b/app/drafts/page.js index 776f2d9..a7b6a87 100644 --- a/app/drafts/page.js +++ b/app/drafts/page.js @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'react'; import { Clock, Check, X, Edit3, Trash2, RefreshCw, Loader2, Calendar, Image as ImgIcon, Zap } from 'lucide-react'; import Link from 'next/link'; +import BackButton from '@/components/BackButton'; const STATUS_TABS = [ { v: 'pending', label: 'Ожидают', color: 'text-accent' }, @@ -77,6 +78,7 @@ export default function DraftsPage() { return (
+

diff --git a/app/plans/page.js b/app/plans/page.js index c57cf74..a0a1e0f 100644 --- a/app/plans/page.js +++ b/app/plans/page.js @@ -2,6 +2,7 @@ import { useState, useEffect } from 'react'; import { Check, Zap, Loader2 } from 'lucide-react'; import Link from 'next/link'; +import BackButton from '@/components/BackButton'; const PLAN_STYLE = { free: { color: 'border-border', badge: null, btnClass: 'btn-ghost' }, @@ -43,6 +44,7 @@ export default function PlansPage() { return (
+

Тарифы

Выберите план под ваши задачи. Все планы включают публикацию в TG и VK.

diff --git a/app/system/page.js b/app/system/page.js index 194189b..eba8a8d 100644 --- a/app/system/page.js +++ b/app/system/page.js @@ -1,11 +1,11 @@ import { redirect } from 'next/navigation'; import { requireUser } from '@/lib/session'; import Header from '@/components/Header'; -import SystemSettings from '@/components/SystemSettings'; +import AdminPanel from '@/components/AdminPanel'; export const dynamic = 'force-dynamic'; -export default async function SystemPage() { +export default async function SystemPage({ searchParams }) { const user = await requireUser(); if (!user) redirect('/login'); if (!user.isAdmin) redirect('/'); @@ -13,15 +13,7 @@ export default async function SystemPage() { return ( <>
-
-
-

Системные настройки

-

- Конфигурация внешних сервисов (поиск фото, билинги и т.п.). Видно только админам. -

-
- -
+ ); } diff --git a/components/AdminPanel.js b/components/AdminPanel.js new file mode 100644 index 0000000..fdeece6 --- /dev/null +++ b/components/AdminPanel.js @@ -0,0 +1,338 @@ +'use client'; +import { useState } from 'react'; +import { Settings2, CreditCard, TrendingUp, Users, ChevronRight, Loader2, Eye, EyeOff, Save, RefreshCw, Check, AlertCircle, BarChart3, ArrowLeft } from 'lucide-react'; +import Link from 'next/link'; +import AdminBilling from './admin/AdminBilling'; + +// ────────────────────────────────────────────────────────────── +// Sidebar navigation +// ────────────────────────────────────────────────────────────── +const SECTIONS = [ + { id: 'settings', label: 'Настройки API', icon: Settings2, desc: 'AI-провайдеры, поиск фото' }, + { id: 'payments', label: 'ЮKassa', icon: CreditCard, desc: 'Ключи для приёма оплат' }, + { id: 'spending', label: 'Расходы AI', icon: TrendingUp, desc: 'aiprimetech + routerai' }, + { id: 'billing', label: 'Пользователи', icon: Users, desc: 'Балансы и кредиты' }, +]; + +export default function AdminPanel({ initialSection = 'settings' }) { + const [section, setSection] = useState(initialSection); + + return ( +
+ {/* Breadcrumb */} +
+ + Главная + + + Администрирование + + {SECTIONS.find(s => s.id === section)?.label} +
+ +
+ {/* Sidebar */} + + + {/* Content */} +
+ {section === 'settings' && } + {section === 'payments' && } + {section === 'spending' && } + {section === 'billing' && } +
+
+
+ ); +} + +// ────────────────────────────────────────────────────────────── +// Settings section (API keys) +// ────────────────────────────────────────────────────────────── +const CATEGORY_META = { + ai_providers: { + title: 'AI-провайдеры', + hint: 'Ключи и URL для текстовой и картиночной генерации. Меняются на лету.', + }, + photo_search: { + title: 'Поиск фото', + hint: 'Yandex Search API: ключ и folder.', + }, + payments: { + title: 'ЮKassa', + hint: 'Shop ID и Secret Key из личного кабинета. Webhook: https://engine.zeropost.ru/api/billing/webhook', + }, +}; + +function SettingsSection({ categories }) { + const [data, setData] = useState({}); + const [loaded, setLoaded] = useState(false); + const [loading, setLoading] = useState(false); + + async function load() { + setLoading(true); + const all = {}; + for (const cat of categories) { + try { + const rows = await fetch(`/api/admin/settings?category=${cat}`).then(r => r.json()); + all[cat] = Array.isArray(rows) ? rows : []; + } catch { all[cat] = []; } + } + setData(all); + setLoaded(true); + setLoading(false); + } + + if (!loaded && !loading) { load(); } + + return ( +
+ {categories.map(cat => { + const meta = CATEGORY_META[cat] || { title: cat, hint: '' }; + const rows = data[cat] || []; + return ( +
+
+

{meta.title}

+ {meta.hint &&

{meta.hint}

} +
+ {loading && !rows.length + ?
+ : rows.map(row => ( + + )) + } +
+ ); + })} +
+ ); +} + +function SettingRow({ row, onSaved }) { + const [val, setVal] = useState(row.value || ''); + const [show, setShow] = useState(false); + const [saving, setSaving] = useState(false); + const [status, setStatus] = useState(null); // 'ok' | 'error' + const [errMsg, setErrMsg] = useState(''); + const dirty = val !== (row.value || ''); + + async function save() { + setSaving(true); setStatus(null); + try { + const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ value: val }), + }).then(r => r.json()); + if (res.error) { setStatus('error'); setErrMsg(res.error); } + else { setStatus('ok'); onSaved(); setTimeout(() => setStatus(null), 2000); } + } catch (e) { setStatus('error'); setErrMsg(e.message); } + setSaving(false); + } + + const isSecret = row.is_secret; + const inputType = isSecret && !show ? 'password' : 'text'; + + return ( +
+
+ + {row.category} +
+ {row.description &&

{row.description}

} +
+
+ setVal(e.target.value)} + className="input w-full pr-8 font-mono text-sm" + placeholder={isSecret ? '••••••••' : 'Введите значение...'} + /> + {isSecret && ( + + )} +
+ {dirty && ( + + )} + {status === 'ok' && } + {status === 'error' && } +
+
+ обновлено: {row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'} +
+
+ ); +} + +// ────────────────────────────────────────────────────────────── +// Spending section +// ────────────────────────────────────────────────────────────── +const PERIODS = [ + { v: 'today', label: 'Сегодня' }, + { v: 'week', label: '7 дней' }, + { v: 'month', label: '30 дней' }, + { v: 'alltime', label: 'Всё время' }, +]; + +const TYPE_LABELS = { + 'chat': '💬 Текст', + 'image': '🖼 Изображение', + 'image_via_responses': '🖼 Изображение', + 'article': '📝 Статья', + 'topic': '🔍 Топики', +}; + +function fmt(n) { return Number(n || 0).toFixed(2); } +function fmtI(n) { return Number(n || 0).toLocaleString('ru-RU'); } + +function SpendingSection() { + const [period, setPeriod] = useState('month'); + const [data, setData] = useState(null); + const [byProv, setByProv] = useState(null); + const [loading, setLoading] = useState(false); + + async function load(p) { + setLoading(true); + try { + const [r1, r2] = await Promise.all([ + fetch(`/api/usage/summary?range=${p}&group_by=request_type`).then(r => r.json()), + fetch(`/api/usage/summary?range=${p}&group_by=provider`).then(r => r.json()), + ]); + setData(r1); setByProv(r2); + } catch {} + setLoading(false); + } + + if (!data && !loading) load(period); + + const totals = data?.totals || {}; + const aiprimetech = byProv?.breakdown?.find(b => b.key === 'aiprimetech'); + const routerai = byProv?.breakdown?.find(b => b.key === 'routerai'); + + return ( +
+
+

Расходы на AI

+
+ {PERIODS.map(p => ( + + ))} + +
+
+ + {loading &&
} + + {!loading && data && (<> + {/* Итого */} +
+ {[ + { label: 'Итого, ₽', value: `₽ ${fmt(totals.cost_rub)}`, accent: true }, + { label: 'Запросов', value: fmtI(totals.calls) }, + { label: 'Токенов', value: fmtI((totals.prompt_tokens||0)+(totals.completion_tokens||0)) }, + { label: 'Картинок', value: fmtI(totals.image_count) }, + ].map(s => ( +
+
{s.value}
+
{s.label}
+
+ ))} +
+ + {/* По провайдерам */} +
+ {[ + { key: 'aiprimetech', label: 'aiprimetech.io', icon: '💬', desc: 'Текст', data: aiprimetech }, + { key: 'routerai', label: 'routerai.ru', icon: '🖼', desc: 'Картинки', data: routerai }, + ].map(p => ( +
+
+ {p.icon} +
+
{p.label}
+
{p.desc}
+
+
₽ {fmt(p.data?.cost_rub)}
+
+
+
{fmtI(p.data?.calls)}
запросов
+
{p.data?.failed||0}
ошибок
+
{fmtI(p.data?.image_count || (p.data?.prompt_tokens||0)+(p.data?.completion_tokens||0))}
{p.key==='routerai'?'картинок':'токенов'}
+
+
+ ))} +
+ + {/* Таблица по операциям */} +
+ + + + + + + + + + + + {(data.breakdown || []).map((row, i) => ( + + + + + + + + ))} + + + + + + + + + + +
ОперацияЗапросовОшибокОбъёмСтоимость
{TYPE_LABELS[row.key] || row.key}{fmtI(row.calls)}{row.failed || 0} + {row.image_count > 0 ? `${row.image_count} шт.` : fmtI((row.prompt_tokens||0)+(row.completion_tokens||0))} + ₽ {fmt(row.cost_rub)}
Итого{fmtI(totals.calls)}{totals.failed||0}₽ {fmt(totals.cost_rub)}
+
+ )} +
+ ); +} diff --git a/components/BackButton.js b/components/BackButton.js new file mode 100644 index 0000000..e507cd1 --- /dev/null +++ b/components/BackButton.js @@ -0,0 +1,18 @@ +'use client'; +import { useRouter } from 'next/navigation'; +import { ArrowLeft } from 'lucide-react'; + +export default function BackButton({ href, label = 'Назад' }) { + const router = useRouter(); + function go() { + if (href) router.push(href); + else if (window.history.length > 1) router.back(); + else router.push('/'); + } + return ( + + ); +} diff --git a/components/Header.js b/components/Header.js index 2017931..24fba4c 100644 --- a/components/Header.js +++ b/components/Header.js @@ -2,7 +2,7 @@ import Link from 'next/link'; import { useRouter } from 'next/navigation'; import { useEffect, useState } from 'react'; -import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp, Coins, FileText } from 'lucide-react'; +import { Sparkles, LogOut, Settings2, CalendarDays, Coins, FileText } from 'lucide-react'; import ThemeToggle from './ThemeToggle'; export default function Header({ user }) { @@ -39,9 +39,9 @@ export default function Header({ user }) { Черновики {user?.isAdmin && ( - - - Расходы + + + Админ )}