Files
postcast-tool/app/spending/page.js
T
Ник (Claude) f4860f0e70 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 иконка)
2026-06-11 13:20:52 +03:00

176 lines
8.2 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'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>
);
}