Files
postcast-tool/components/SystemSettings.js
T
Ник (Claude) 8f4dc1a386 system: AI-провайдеры + блок «Расход AI»
* components/SystemSettings.js: добавлен компонент UsageSummary сверху —
  сводка вызовов и стоимости с переключателем периода (Сегодня/Неделя/
  Месяц/Всё) и группировкой (по сервису/провайдеру/модели). Виджеты
  cost_rub/calls/tokens/images + таблица breakdown.

* components/SystemSettings.js: в массив CATEGORIES добавлена категория
  'ai_providers' первой — Aleksei видит все 11 строк (Текст/Картинки
  ключи+URL+модели + AI_USD_RUB_RATE + AI_PROVIDER_MARKUP) сверху.
  Существующая инфраструктура SettingRow (маскировка секретов, save+toast)
  переиспользуется без изменений.

* lib/engine.js: добавлены engine.usageSummary(params) и engine.usageRecent(limit).

* app/api/admin/usage/summary/route.js (новый): прокси-роут к engine
  /api/usage/summary через requireAdmin.

Verify:
* next build прошёл без ошибок.
* /system → 307 redirect на /login (неавторизованный — корректно).
* /api/admin/usage/summary → 403 Forbidden (не-админ — корректно).
2026-06-08 20:21:49 +03:00

326 lines
12 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, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle, BarChart3 } from 'lucide-react';
// Категории, которые управляются здесь (в админке tool, а не в админке блога).
// Категория `engine` (TELEGRAM_API_BASE и т.п.) намеренно живёт в zeropost.ru/admin.
const CATEGORIES = [
{ slug: 'ai_providers', title: 'AI провайдеры',
hint: 'Ключи, URL и модели для текстовой и картиночной генерации. Меняются на лету — рестарт engine не нужен. Курс USD↔РУБ и наценка реселлера тут же — влияют на расчёт стоимости в блоке «Расход AI» выше.' },
{ slug: 'photo_search', title: 'Поиск фото',
hint: 'Yandex Search API: provider, ключ, folder, лимиты.' },
];
export default function SystemSettings() {
const [byCategory, setByCategory] = useState({});
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
async function load() {
setLoading(true);
setError('');
try {
const result = {};
for (const cat of CATEGORIES) {
const res = await fetch(`/api/admin/settings?category=${cat.slug}`);
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
result[cat.slug] = await res.json();
}
setByCategory(result);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); }, []);
if (loading) {
return (
<div className="card p-12 text-center">
<Loader2 className="w-6 h-6 animate-spin mx-auto text-accent" />
</div>
);
}
if (error) {
return (
<div className="card p-5 border-red-500/40">
<div className="flex items-center gap-2 text-red-400">
<AlertCircle className="w-4 h-4" />
{error}
</div>
<button onClick={load} className="btn-ghost mt-3 text-sm">
<RefreshCw className="w-4 h-4" /> Повторить
</button>
</div>
);
}
return (
<div className="space-y-6">
<UsageSummary />
{CATEGORIES.map(cat => (
<CategoryBlock
key={cat.slug}
category={cat}
rows={byCategory[cat.slug] || []}
onSaved={() => load()}
/>
))}
</div>
);
}
const RANGE_LABELS = [
{ key: 'today', label: 'Сегодня' },
{ key: 'week', label: 'Неделя' },
{ key: 'month', label: 'Месяц' },
{ key: 'all', label: 'Всё' },
];
function UsageSummary() {
const [range, setRange] = useState('today');
const [groupBy, setGroupBy] = useState('service');
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [err, setErr] = useState('');
async function load() {
setLoading(true);
setErr('');
try {
const res = await fetch(`/api/admin/usage/summary?range=${range}&group_by=${groupBy}`);
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
setData(await res.json());
} catch (e) {
setErr(e.message);
} finally {
setLoading(false);
}
}
useEffect(() => { load(); /* eslint-disable-next-line react-hooks/exhaustive-deps */ }, [range, groupBy]);
const fmtRub = v => (Number(v) || 0).toLocaleString('ru-RU', { minimumFractionDigits: 2, maximumFractionDigits: 2 }) + ' ₽';
const fmtInt = v => (Number(v) || 0).toLocaleString('ru-RU');
return (
<section className="card p-5">
<div className="flex items-center justify-between flex-wrap gap-2 mb-4">
<div className="flex items-center gap-2">
<BarChart3 className="w-5 h-5 text-accent" />
<h2 className="font-semibold">Расход AI</h2>
</div>
<div className="flex items-center gap-2">
<div className="flex rounded-md overflow-hidden border border-border">
{RANGE_LABELS.map(r => (
<button
key={r.key}
onClick={() => setRange(r.key)}
className={`px-2.5 py-1 text-xs ${range === r.key ? 'bg-accent text-white' : 'bg-surface2/50 hover:bg-surface2 text-gray-300'}`}
>
{r.label}
</button>
))}
</div>
<select
value={groupBy}
onChange={e => setGroupBy(e.target.value)}
className="input text-xs h-7 py-0"
>
<option value="service">по сервисам</option>
<option value="provider">по провайдерам</option>
<option value="model">по моделям</option>
</select>
<button onClick={load} className="btn-ghost p-1.5" title="Обновить">
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
</button>
</div>
</div>
{err && (
<div className="text-xs text-red-400 mb-3">{err}</div>
)}
{data && (
<>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mb-4">
<Stat label="Сумма" value={fmtRub(data.totals.cost_rub)} accent />
<Stat label="Вызовов" value={fmtInt(data.totals.calls)}
sub={data.totals.failed ? `${data.totals.failed} ошибок` : 'все успешно'} />
<Stat label="Токенов" value={fmtInt(data.totals.prompt_tokens + data.totals.completion_tokens)}
sub={`${fmtInt(data.totals.prompt_tokens)} вход / ${fmtInt(data.totals.completion_tokens)} выход`} />
<Stat label="Картинок" value={fmtInt(data.totals.image_count)}
sub={data.totals.avg_duration_ms ? `сред. ${(data.totals.avg_duration_ms/1000).toFixed(1)}с` : ''} />
</div>
{data.breakdown.length > 0 && (
<div className="rounded-lg border border-border overflow-hidden">
<table className="w-full text-xs">
<thead className="bg-surface2/50">
<tr className="text-left text-gray-500">
<th className="px-3 py-2 font-medium">
{groupBy === 'service' ? 'Сервис' : groupBy === 'provider' ? 'Провайдер' : 'Модель'}
</th>
<th className="px-3 py-2 font-medium text-right">Вызовов</th>
<th className="px-3 py-2 font-medium text-right">Токены</th>
<th className="px-3 py-2 font-medium text-right">Картинки</th>
<th className="px-3 py-2 font-medium text-right">Стоимость</th>
</tr>
</thead>
<tbody>
{data.breakdown.map(row => (
<tr key={row.key} className="border-t border-border/50">
<td className="px-3 py-2 font-mono">{row.key}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.calls)}{row.failed ? <span className="text-red-400 ml-1">({row.failed} err)</span> : null}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.prompt_tokens + row.completion_tokens)}</td>
<td className="px-3 py-2 text-right">{fmtInt(row.image_count)}</td>
<td className="px-3 py-2 text-right font-mono">{fmtRub(row.cost_rub)}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{data.breakdown.length === 0 && (
<p className="text-sm text-gray-500">За выбранный период вызовов не было.</p>
)}
</>
)}
</section>
);
}
function Stat({ label, value, sub, accent }) {
return (
<div className={`rounded-lg p-3 ${accent ? 'bg-accent/10 border border-accent/30' : 'bg-surface2/50 border border-border'}`}>
<div className="text-[10px] uppercase tracking-wide text-gray-500">{label}</div>
<div className={`text-lg font-semibold mt-0.5 ${accent ? 'text-accent' : ''}`}>{value}</div>
{sub && <div className="text-[11px] text-gray-500 mt-0.5">{sub}</div>}
</div>
);
}
function CategoryBlock({ category, rows, onSaved }) {
return (
<section className="card p-5">
<div className="mb-4">
<h2 className="font-semibold">{category.title}</h2>
{category.hint && <p className="text-xs text-gray-500 mt-1">{category.hint}</p>}
</div>
{rows.length === 0 ? (
<p className="text-sm text-gray-500">Нет настроек в этой категории.</p>
) : (
<div className="space-y-3">
{rows.map(r => (
<SettingRow key={r.key} row={r} onSaved={onSaved} />
))}
</div>
)}
</section>
);
}
function SettingRow({ row, onSaved }) {
const [value, setValue] = useState(row.value ?? '');
const [saving, setSaving] = useState(false);
const [saved, setSaved] = useState(false);
const [reveal, setReveal] = useState(false);
const [err, setErr] = useState('');
// если row.value меняется снаружи — синхронизируем
useEffect(() => { setValue(row.value ?? ''); }, [row.value]);
const isSecret = row.is_secret;
const dirty = value !== (row.value ?? '');
async function save() {
setSaving(true);
setErr('');
try {
const res = await fetch(`/api/admin/settings/${encodeURIComponent(row.key)}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ value: value === '' ? null : value }),
});
if (!res.ok) throw new Error((await res.json()).error || `HTTP ${res.status}`);
setSaved(true);
setTimeout(() => setSaved(false), 1500);
onSaved?.();
} catch (e) {
setErr(e.message);
} finally {
setSaving(false);
}
}
// Маскировка отображения секретов: показываем хвост ***last4 пока не reveal=true
const masked = isSecret && value && !reveal
? '•'.repeat(Math.max(value.length - 4, 4)) + value.slice(-4)
: value;
return (
<div className="rounded-lg border border-border bg-surface2/50 p-3">
<div className="flex items-start justify-between flex-wrap gap-2 mb-2">
<div className="min-w-0">
<div className="flex items-center gap-2">
<code className="text-sm font-mono">{row.key}</code>
{isSecret && (
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-500/15 text-amber-500">
secret
</span>
)}
</div>
{row.description && (
<p className="text-xs text-gray-500 mt-1">{row.description}</p>
)}
</div>
</div>
<div className="flex items-center gap-2">
<input
type={isSecret && !reveal ? 'password' : 'text'}
className="input text-sm font-mono"
value={reveal || !isSecret ? value : masked}
onChange={e => {
// При маскированном просмотре редактирование запрещаем — пусть сначала откроют
if (isSecret && !reveal) return;
setValue(e.target.value);
}}
placeholder={isSecret ? '(скрыто)' : '(пусто)'}
spellCheck={false}
/>
{isSecret && (
<button
type="button"
onClick={() => setReveal(v => !v)}
className="btn-ghost p-2"
title={reveal ? 'Скрыть' : 'Показать'}
>
{reveal ? <EyeOff className="w-4 h-4" /> : <Eye className="w-4 h-4" />}
</button>
)}
<button
onClick={save}
disabled={saving || !dirty}
className="btn-primary text-sm"
>
{saving ? <Loader2 className="w-4 h-4 animate-spin" />
: saved ? <Check className="w-4 h-4" />
: <Save className="w-4 h-4" />}
{saved ? 'Сохранено' : 'Сохранить'}
</button>
</div>
{err && (
<div className="text-xs text-red-400 mt-2">{err}</div>
)}
<div className="text-[11px] text-gray-500 mt-2">
Категория: <code>{row.category}</code> · обновлено{' '}
{row.updated_at ? new Date(row.updated_at).toLocaleString('ru-RU') : '—'}
</div>
</div>
);
}