From 8f4dc1a386f2e4bcfd27978cdfd1cae5b7c7355f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Mon, 8 Jun 2026 20:21:49 +0300 Subject: [PATCH] =?UTF-8?q?system:=20AI-=D0=BF=D1=80=D0=BE=D0=B2=D0=B0?= =?UTF-8?q?=D0=B9=D0=B4=D0=B5=D1=80=D1=8B=20+=20=D0=B1=D0=BB=D0=BE=D0=BA?= =?UTF-8?q?=20=C2=AB=D0=A0=D0=B0=D1=81=D1=85=D0=BE=D0=B4=20AI=C2=BB?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 (не-админ — корректно). --- app/api/admin/usage/summary/route.js | 20 ++++ components/SystemSettings.js | 137 ++++++++++++++++++++++++++- lib/engine.js | 7 ++ 3 files changed, 162 insertions(+), 2 deletions(-) create mode 100644 app/api/admin/usage/summary/route.js diff --git a/app/api/admin/usage/summary/route.js b/app/api/admin/usage/summary/route.js new file mode 100644 index 0000000..bf18558 --- /dev/null +++ b/app/api/admin/usage/summary/route.js @@ -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 }); + } +} diff --git a/components/SystemSettings.js b/components/SystemSettings.js index ef2720f..7851091 100644 --- a/components/SystemSettings.js +++ b/components/SystemSettings.js @@ -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 (
+ {CATEGORIES.map(cat => ( { 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 ( +
+
+
+ +

Расход AI

+
+
+
+ {RANGE_LABELS.map(r => ( + + ))} +
+ + +
+
+ + {err && ( +
{err}
+ )} + + {data && ( + <> +
+ + + + +
+ + {data.breakdown.length > 0 && ( +
+ + + + + + + + + + + + {data.breakdown.map(row => ( + + + + + + + + ))} + +
+ {groupBy === 'service' ? 'Сервис' : groupBy === 'provider' ? 'Провайдер' : 'Модель'} + ВызововТокеныКартинкиСтоимость
{row.key}{fmtInt(row.calls)}{row.failed ? ({row.failed} err) : null}{fmtInt(row.prompt_tokens + row.completion_tokens)}{fmtInt(row.image_count)}{fmtRub(row.cost_rub)}
+
+ )} + + {data.breakdown.length === 0 && ( +

За выбранный период вызовов не было.

+ )} + + )} +
+ ); +} + +function Stat({ label, value, sub, accent }) { + return ( +
+
{label}
+
{value}
+ {sub &&
{sub}
} +
+ ); +} + function CategoryBlock({ category, rows, onSaved }) { return (
diff --git a/lib/engine.js b/lib/engine.js index 199a438..34978db 100644 --- a/lib/engine.js +++ b/lib/engine.js @@ -69,6 +69,13 @@ export const engine = { updateSetting: (key, value) => call(`/api/settings/admin/${encodeURIComponent(key)}`, { method: 'PUT', body: { value } }), 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 getCalendar: (userId, params = {}) => { const qs = new URLSearchParams(params).toString();