forked from admin/zeropost-tool
f4860f0e70
- app/spending/page.js: расходы по периодам, разбивка по провайдерам и типам - app/api/usage/summary/route.js: прокси к engine /api/usage/summary - Header.js: ссылка «Расходы» для admin (TrendingUp иконка)
176 lines
8.2 KiB
JavaScript
176 lines
8.2 KiB
JavaScript
'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>
|
||
);
|
||
}
|