system: AI-провайдеры + блок «Расход AI»

* components/SystemSettings.js: добавлен компонент UsageSummary сверху —
  сводка вызовов и стоимости с переключателем периода (Сегодня/Неделя/
  Месяц/Всё) и группировкой (по сервису/провайдеру/модели). Виджеты
  cost_rub/calls/tokens/images + таблица breakdown.

* components/SystemSettings.js: в массив CATEGORIES добавлена категория
  'ai_providers' первой — Aleksei видит все 11 строк (Текст/Картинки
  ключи+URL+модели + AI_USD_RUB_RATE + AI_PROVIDER_MARKUP) сверху.
  Существующая инфраструктура SettingRow (маскировка секретов, save+toast)
  переиспользуется без изменений.

* lib/engine.js: добавлены engine.usageSummary(params) и engine.usageRecent(limit).

* app/api/admin/usage/summary/route.js (новый): прокси-роут к engine
  /api/usage/summary через requireAdmin.

Verify:
* next build прошёл без ошибок.
* /system → 307 redirect на /login (неавторизованный — корректно).
* /api/admin/usage/summary → 403 Forbidden (не-админ — корректно).
This commit is contained in:
Ник (Claude)
2026-06-08 20:21:49 +03:00
parent b13f956099
commit 8f4dc1a386
3 changed files with 162 additions and 2 deletions
+135 -2
View File
@@ -1,13 +1,14 @@
'use client';
import { useState, useEffect } from 'react';
import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle } from 'lucide-react';
import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle, BarChart3 } from 'lucide-react';
// Категории, которые управляются здесь (в админке tool, а не в админке блога).
// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
const CATEGORIES = [
{ slug: 'ai_providers', title: 'AI провайдеры',
hint: 'Ключи, URL и модели для текстовой и картиночной генерации. Меняются на лету — рестарт engine не нужен. Курс USD↔РУБ и наценка реселлера тут же — влияют на расчёт стоимости в блоке «Расход AI» выше.' },
{ slug: 'photo_search', title: 'Поиск фото',
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
// Сюда позже: { slug: 'billing', ... }, { slug: 'serpapi', ... }
];
export default function SystemSettings() {
@@ -59,6 +60,7 @@ export default function SystemSettings() {
return (
<div className="space-y-6">
<UsageSummary />
{CATEGORIES.map(cat => (
<CategoryBlock
key={cat.slug}
@@ -71,6 +73,137 @@ export default function SystemSettings() {
);
}
const RANGE_LABELS = [
{ key: 'today', label: 'Сегодня' },
{ key: 'week', label: 'Неделя' },
{ key: 'month', label: 'Месяц' },
{ key: 'all', label: 'Всё' },
];
function UsageSummary() {
const [range, setRange] = useState('today');
const [groupBy, setGroupBy] = useState('service');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
async function load() {
setLoading(true);
setErr('');
try {
const res = await fetch(`/api/admin/usage/summary?range=${range}&group_by=${groupBy}`);
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
setData(await res.json());
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [range, groupBy]);
const fmtRub = v => (Number(v) || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽';
const fmtInt = v => (Number(v) || 0).toLocaleString('ru-RU');
return (
<section className="card p-5">
<div className="flex items-center justify-between flex-wrap gap-2 mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-accent" />
<h2 className="font-semibold">Расход AI</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-md overflow-hidden border border-border">
{RANGE_LABELS.map(r => (
<button
key={r.key}
onClick={() => setRange(r.key)}
className={`px-2.5 py-1 text-xs ${range === r.key ? 'bg-accent text-white' : 'bg-surface2/50 hover:bg-surface2 text-gray-300'}`}
>
{r.label}
</button>
))}
</div>
<select
value={groupBy}
onChange={e => setGroupBy(e.target.value)}
className="input text-xs h-7 py-0"
>
<option value="service">по сервисам</option>
<option value="provider">по провайдерам</option>
<option value="model">по моделям</option>
</select>
<button onClick={load} className="btn-ghost p-1.5" title="Обновить">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{err && (
<div className="text-xs text-red-400 mb-3">{err}</div>
)}
{data && (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<Stat label="Сумма" value={fmtRub(data.totals.cost_rub)} accent />
<Stat label="Вызовов" value={fmtInt(data.totals.calls)}
sub={data.totals.failed ? `${data.totals.failed} ошибок` : 'все успешно'} />
<Stat label="Токенов" value={fmtInt(data.totals.prompt_tokens + data.totals.completion_tokens)}
sub={`${fmtInt(data.totals.prompt_tokens)} вход / ${fmtInt(data.totals.completion_tokens)} выход`} />
<Stat label="Картинок" value={fmtInt(data.totals.image_count)}
sub={data.totals.avg_duration_ms ? `сред. ${(data.totals.avg_duration_ms/1000).toFixed(1)}с` : ''} />
</div>
{data.breakdown.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-surface2/50">
<tr className="text-left text-gray-500">
<th className="px-3 py-2 font-medium">
{groupBy === 'service' ? 'Сервис' : groupBy === 'provider' ? 'Провайдер' : 'Модель'}
</th>
<th className="px-3 py-2 font-medium text-right">Вызовов</th>
<th className="px-3 py-2 font-medium text-right">Токены</th>
<th className="px-3 py-2 font-medium text-right">Картинки</th>
<th className="px-3 py-2 font-medium text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{data.breakdown.map(row => (
<tr key={row.key} className="border-t border-border/50">
<td className="px-3 py-2 font-mono">{row.key}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.calls)}{row.failed ? <span className="text-red-400 ml-1">({row.failed} err)</span> : null}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.prompt_tokens + row.completion_tokens)}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.image_count)}</td>
<td className="px-3 py-2 text-right font-mono">{fmtRub(row.cost_rub)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.breakdown.length === 0 && (
<p className="text-sm text-gray-500">За выбранный период вызовов не было.</p>
)}
</>
)}
</section>
);
}
function Stat({ label, value, sub, accent }) {
return (
<div className={`rounded-lg p-3 ${accent ? 'bg-accent/10 border border-accent/30' : 'bg-surface2/50 border border-border'}`}>
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
<div className={`text-lg font-semibold mt-0.5 ${accent ? 'text-accent' : ''}`}>{value}</div>
{sub && <div className="text-[11px] text-gray-500 mt-0.5">{sub}</div>}
</div>
);
}
function CategoryBlock({ category, rows, onSaved }) {
return (
<section className="card p-5">