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:
@@ -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 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
'use client';
|
'use client';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { useRouter } from 'next/navigation';
|
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';
|
import ThemeToggle from './ThemeToggle';
|
||||||
|
|
||||||
export default function Header({ user }) {
|
export default function Header({ user }) {
|
||||||
@@ -22,6 +22,12 @@ export default function Header({ user }) {
|
|||||||
<CalendarDays className="w-4 h-4" />
|
<CalendarDays className="w-4 h-4" />
|
||||||
<span>Календарь</span>
|
<span>Календарь</span>
|
||||||
</Link>
|
</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>
|
</nav>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{user?.isAdmin && (
|
{user?.isAdmin && (
|
||||||
|
|||||||
Reference in New Issue
Block a user