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:
@@ -0,0 +1,20 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { requireAdmin } from '@/lib/session';
|
||||||
|
import { engine } from '@/lib/engine';
|
||||||
|
|
||||||
|
// GET /api/admin/usage/summary?range=today&group_by=service
|
||||||
|
export async function GET(req) {
|
||||||
|
const admin = await requireAdmin();
|
||||||
|
if (!admin) return NextResponse.json({ error: 'Forbidden' }, { status: 403 });
|
||||||
|
try {
|
||||||
|
const { searchParams } = new URL(req.url);
|
||||||
|
const params = {};
|
||||||
|
if (searchParams.get('range')) params.range = searchParams.get('range');
|
||||||
|
if (searchParams.get('group_by')) params.group_by = searchParams.get('group_by');
|
||||||
|
if (searchParams.get('service')) params.service = searchParams.get('service');
|
||||||
|
const data = await engine.usageSummary(params);
|
||||||
|
return NextResponse.json(data);
|
||||||
|
} catch (err) {
|
||||||
|
return NextResponse.json({ error: err.message }, { status: err.status || 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,13 +1,14 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import { useState, useEffect } from 'react';
|
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, а не в админке блога).
|
// Категории, которые управляются здесь (в админке tool, а не в админке блога).
|
||||||
// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
|
// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
|
||||||
const CATEGORIES = [
|
const CATEGORIES = [
|
||||||
|
{ slug: 'ai_providers', title: 'AI провайдеры',
|
||||||
|
hint: 'Ключи, URL и модели для текстовой и картиночной генерации. Меняются на лету — рестарт engine не нужен. Курс USD↔РУБ и наценка реселлера тут же — влияют на расчёт стоимости в блоке «Расход AI» выше.' },
|
||||||
{ slug: 'photo_search', title: 'Поиск фото',
|
{ slug: 'photo_search', title: 'Поиск фото',
|
||||||
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
|
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
|
||||||
// Сюда позже: { slug: 'billing', ... }, { slug: 'serpapi', ... }
|
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function SystemSettings() {
|
export default function SystemSettings() {
|
||||||
@@ -59,6 +60,7 @@ export default function SystemSettings() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
|
<UsageSummary />
|
||||||
{CATEGORIES.map(cat => (
|
{CATEGORIES.map(cat => (
|
||||||
<CategoryBlock
|
<CategoryBlock
|
||||||
key={cat.slug}
|
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 }) {
|
function CategoryBlock({ category, rows, onSaved }) {
|
||||||
return (
|
return (
|
||||||
<section className="card p-5">
|
<section className="card p-5">
|
||||||
|
|||||||
@@ -69,6 +69,13 @@ export const engine = {
|
|||||||
updateSetting: (key, value) => call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }),
|
updateSetting: (key, value) => call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }),
|
||||||
invalidateSettingsCache: () => call('/api/settings/admin/invalidate', { method: 'POST' }),
|
invalidateSettingsCache: () => call('/api/settings/admin/invalidate', { method: 'POST' }),
|
||||||
|
|
||||||
|
// AI usage (admin)
|
||||||
|
usageSummary: (params = {}) => {
|
||||||
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
return call(`/api/usage/summary${qs ? '?' + qs : ''}`);
|
||||||
|
},
|
||||||
|
usageRecent: (limit = 20) => call(`/api/usage/recent?limit=${limit}`),
|
||||||
|
|
||||||
// Calendar
|
// Calendar
|
||||||
getCalendar: (userId, params = {}) => {
|
getCalendar: (userId, params = {}) => {
|
||||||
const qs = new URLSearchParams(params).toString();
|
const qs = new URLSearchParams(params).toString();
|
||||||
|
|||||||
Reference in New Issue
Block a user