From f4860f0e7074042a0f4cc44f1d4cb0648b56d095 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=9D=D0=B8=D0=BA=20=28Claude=29?= Date: Thu, 11 Jun 2026 13:20:52 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20/spending=20page=20=E2=80=94=20AI=20cos?= =?UTF-8?q?t=20dashboard=20(aiprimetech=20+=20routerai)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - app/spending/page.js: расходы по периодам, разбивка по провайдерам и типам - app/api/usage/summary/route.js: прокси к engine /api/usage/summary - Header.js: ссылка «Расходы» для admin (TrendingUp иконка) --- app/api/usage/summary/route.js | 15 +++ app/spending/page.js | 175 +++++++++++++++++++++++++++++++++ components/Header.js | 8 +- 3 files changed, 197 insertions(+), 1 deletion(-) create mode 100644 app/api/usage/summary/route.js create mode 100644 app/spending/page.js diff --git a/app/api/usage/summary/route.js b/app/api/usage/summary/route.js new file mode 100644 index 0000000..3cd3bac --- /dev/null +++ b/app/api/usage/summary/route.js @@ -0,0 +1,15 @@ +import { NextResponse } from 'next/server'; +import { requireUser } from '@/lib/session'; +import { engine } from '@/lib/engine'; + +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); + try { + const data = await engine.usageSummary(Object.fromEntries(searchParams)); + return NextResponse.json(data); + } catch (err) { + return NextResponse.json({ error: err.message }, { status: 500 }); + } +} diff --git a/app/spending/page.js b/app/spending/page.js new file mode 100644 index 0000000..a837666 --- /dev/null +++ b/app/spending/page.js @@ -0,0 +1,175 @@ +'use client'; +import { useState, useEffect } from 'react'; +import { Loader2, TrendingUp, Zap, Image as Img, RefreshCw } from 'lucide-react'; + +const PERIODS = [ + { v: 'today', label: 'Сегодня' }, + { v: 'week', label: '7 дней' }, + { v: 'month', label: '30 дней' }, + { v: 'alltime', label: 'Всё время' }, +]; + +const PROVIDER_LABELS = { + 'aiprimetech': 'aiprimetech.io', + 'routerai': 'routerai.ru', + 'nyxos': 'Nyxos Plus', + 'aiguoguo': 'aiguoguo', +}; + +const TYPE_LABELS = { + 'chat': '💬 Текст', + 'image': '🖼 Изображение', + 'image_via_responses': '🖼 Изображение (responses)', + 'article': '📝 Статья', + 'topic': '🔍 Топики', +}; + +function fmt(n) { return Number(n || 0).toFixed(2); } +function fmtInt(n) { return Number(n || 0).toLocaleString('ru-RU'); } + +export default function SpendingPage() { + const [period, setPeriod] = useState('month'); + const [data, setData] = useState(null); + const [byProvider, setByProvider] = useState(null); + const [loading, setLoading] = useState(true); + + 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); + setByProvider(r2); + } catch {} + setLoading(false); + } + + useEffect(() => { load(period); }, [period]); + + const totals = data?.totals || {}; + + // Расходы по ключевым провайдерам + const aiprimetech = byProvider?.breakdown?.find(b => b.key === 'aiprimetech'); + const routerai = byProvider?.breakdown?.find(b => b.key === 'routerai'); + const nyxos = byProvider?.breakdown?.find(b => b.key === 'nyxos' || b.key?.includes('nyxos')); + + return ( +
+
+
+

+ Расходы на AI +

+

Только aiprimetech.io и routerai.ru

+
+
+ {PERIODS.map(p => ( + + ))} + +
+
+ + {loading &&
} + + {!loading && data && ( + <> + {/* Итого */} +
+ {[ + { label: 'Итого, ₽', value: `₽ ${fmt(totals.cost_rub)}`, accent: true }, + { label: 'Запросов', value: fmtInt(totals.calls) }, + { label: 'Токенов', value: fmtInt((totals.prompt_tokens||0) + (totals.completion_tokens||0)) }, + { label: 'Картинок', value: fmtInt(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 => { + const d = p.data; + return ( +
+
+ {p.icon} +
+
{p.label}
+
{p.desc}
+
+
+
₽ {fmt(d?.cost_rub)}
+
+
+
+
{fmtInt(d?.calls)}
запросов
+
{d?.failed||0}
ошибок
+
{fmtInt(d?.image_count||((d?.prompt_tokens||0)+(d?.completion_tokens||0)))}
{p.key==='routerai'?'картинок':'токенов'}
+
+
+ ); + })} +
+ + {/* Разбивка по типу запроса */} +

По типу операции

+
+ + + + + + + + + + + + {(data.breakdown || []).map((row, i) => ( + + + + + + + + ))} + {(!data.breakdown?.length) && ( + + )} + + + + + + + + + + +
ОперацияЗапросовОшибокТокены / КартинкиСтоимость, ₽
{TYPE_LABELS[row.key] || row.key}{fmtInt(row.calls)}{row.failed || 0} + {row.image_count > 0 + ? `${row.image_count} шт.` + : fmtInt((row.prompt_tokens||0)+(row.completion_tokens||0))} + ₽ {fmt(row.cost_rub)}
Нет данных за период
Итого{fmtInt(totals.calls)}{totals.failed||0}₽ {fmt(totals.cost_rub)}
+
+ + )} +
+ ); +} diff --git a/components/Header.js b/components/Header.js index 030a6c3..07fe50c 100644 --- a/components/Header.js +++ b/components/Header.js @@ -1,7 +1,7 @@ 'use client'; import Link from 'next/link'; import { useRouter } from 'next/navigation'; -import { Sparkles, LogOut, Settings2, CalendarDays } from 'lucide-react'; +import { Sparkles, LogOut, Settings2, CalendarDays, TrendingUp } from 'lucide-react'; import ThemeToggle from './ThemeToggle'; export default function Header({ user }) { @@ -22,6 +22,12 @@ export default function Header({ user }) { Календарь + {user?.isAdmin && ( + + + Расходы + + )}
{user?.isAdmin && (