feat: /spending page — AI cost dashboard (aiprimetech + routerai)

- app/spending/page.js: расходы по периодам, разбивка по провайдерам и типам
- app/api/usage/summary/route.js: прокси к engine /api/usage/summary
- Header.js: ссылка «Расходы» для admin (TrendingUp иконка)
This commit is contained in:
Ник (Claude)
2026-06-11 13:20:52 +03:00
parent 95c24d477a
commit f4860f0e70
3 changed files with 197 additions and 1 deletions
+15
View File
@@ -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 });
}
}
+175
View File
@@ -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 (
<main className="max-w-5xl mx-auto p-4 sm:p-6">
<div className="flex items-center justify-between mb-6">
<div>
<h1 className="text-xl font-bold flex items-center gap-2">
<TrendingUp className="w-5 h-5 text-accent" /> Расходы на AI
</h1>
<p className="text-sm text-gray-500 mt-0.5">Только aiprimetech.io и routerai.ru</p>
</div>
<div className="flex gap-1">
{PERIODS.map(p => (
<button key={p.v} onClick={() => setPeriod(p.v)}
className={`px-3 py-1.5 rounded-lg text-sm transition-colors ${period === p.v ? 'bg-accent text-white' : 'btn-ghost'}`}>
{p.label}
</button>
))}
<button onClick={() => load(period)} className="btn-ghost p-1.5 ml-1">
<RefreshCw className="w-4 h-4" />
</button>
</div>
</div>
{loading && <div className="py-12 text-center"><Loader2 className="w-5 h-5 animate-spin mx-auto text-accent" /></div>}
{!loading && data && (
<>
{/* Итого */}
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-6">
{[
{ 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 => (
<div key={s.label} className={`card p-4 ${s.accent ? 'border-accent/40 bg-accent/5' : ''}`}>
<div className={`text-2xl font-bold ${s.accent ? 'text-accent' : ''}`}>{s.value}</div>
<div className="text-xs text-gray-500 mt-1">{s.label}</div>
</div>
))}
</div>
{/* По провайдерам */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">По провайдерам</h2>
<div className="grid sm:grid-cols-2 gap-3 mb-6">
{[
{ 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 (
<div key={p.key} className="card p-5">
<div className="flex items-center gap-2 mb-3">
<span className="text-xl">{p.icon}</span>
<div>
<div className="font-semibold text-sm">{p.label}</div>
<div className="text-xs text-gray-500">{p.desc}</div>
</div>
<div className="ml-auto text-right">
<div className="text-lg font-bold text-accent"> {fmt(d?.cost_rub)}</div>
</div>
</div>
<div className="grid grid-cols-3 gap-2 text-xs text-gray-400">
<div><div className="font-medium text-gray-200">{fmtInt(d?.calls)}</div>запросов</div>
<div><div className="font-medium text-gray-200">{d?.failed||0}</div>ошибок</div>
<div><div className="font-medium text-gray-200">{fmtInt(d?.image_count||((d?.prompt_tokens||0)+(d?.completion_tokens||0)))}</div>{p.key==='routerai'?'картинок':'токенов'}</div>
</div>
</div>
);
})}
</div>
{/* Разбивка по типу запроса */}
<h2 className="text-sm font-semibold text-gray-400 uppercase tracking-wide mb-3">По типу операции</h2>
<div className="card overflow-hidden">
<table className="w-full text-sm">
<thead className="bg-surface2 text-xs text-gray-400">
<tr>
<th className="px-4 py-2.5 text-left">Операция</th>
<th className="px-4 py-2.5 text-right">Запросов</th>
<th className="px-4 py-2.5 text-right">Ошибок</th>
<th className="px-4 py-2.5 text-right">Токены / Картинки</th>
<th className="px-4 py-2.5 text-right">Стоимость, </th>
</tr>
</thead>
<tbody>
{(data.breakdown || []).map((row, i) => (
<tr key={i} className="border-t border-border hover:bg-surface2/50">
<td className="px-4 py-2.5">{TYPE_LABELS[row.key] || row.key}</td>
<td className="px-4 py-2.5 text-right text-gray-300">{fmtInt(row.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400">{row.failed || 0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs">
{row.image_count > 0
? `${row.image_count} шт.`
: fmtInt((row.prompt_tokens||0)+(row.completion_tokens||0))}
</td>
<td className="px-4 py-2.5 text-right font-medium"> {fmt(row.cost_rub)}</td>
</tr>
))}
{(!data.breakdown?.length) && (
<tr><td colSpan={5} className="px-4 py-6 text-center text-gray-500 text-sm">Нет данных за период</td></tr>
)}
</tbody>
<tfoot className="bg-surface2 border-t border-border">
<tr>
<td className="px-4 py-2.5 font-semibold text-sm">Итого</td>
<td className="px-4 py-2.5 text-right font-semibold">{fmtInt(totals.calls)}</td>
<td className="px-4 py-2.5 text-right text-red-400 font-semibold">{totals.failed||0}</td>
<td className="px-4 py-2.5 text-right text-gray-400 text-xs"></td>
<td className="px-4 py-2.5 text-right font-bold text-accent"> {fmt(totals.cost_rub)}</td>
</tr>
</tfoot>
</table>
</div>
</>
)}
</main>
);
}
+7 -1
View File
@@ -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 }) {
<CalendarDays className="w-4 h-4" />
<span>Календарь</span>
</Link>
{user?.isAdmin && (
<Link href="/spending" className="btn-ghost px-3 py-1.5 text-sm flex items-center gap-1.5">
<TrendingUp className="w-4 h-4" />
<span>Расходы</span>
</Link>
)}
</nav>
<div className="flex items-center gap-2">
{user?.isAdmin && (