forked from admin/zeropost-tool
401 lines
16 KiB
JavaScript
401 lines
16 KiB
JavaScript
'use client';
|
||
import { useState, useEffect } from 'react';
|
||
import { Loader2, Save, Eye, EyeOff, RefreshCw, Check, AlertCircle, BarChart3, Coins } from 'lucide-react';
|
||
import AdminBilling from './admin/AdminBilling';
|
||
|
||
|
||
const SETTING_LABELS = {
|
||
AI_IMAGE_API_KEY: 'API ключ картинок',
|
||
AI_IMAGE_BASE_URL: 'Base URL картинок',
|
||
AI_IMAGE_FALLBACK_API_KEY: 'Fallback — API ключ',
|
||
AI_IMAGE_FALLBACK_BASE_URL: 'Fallback — Base URL',
|
||
AI_IMAGE_MODEL: 'Модель картинок',
|
||
AI_IMAGE_MODEL_VIA_RESPONSES: 'Модель картинок (responses)',
|
||
AI_PROVIDER_MARKUP: 'Наценка провайдера',
|
||
AI_TEXT_API_KEY: 'API ключ текста',
|
||
AI_TEXT_BASE_URL: 'Base URL провайдера',
|
||
AI_TEXT_MODEL_ARTICLE: 'Модель для статей',
|
||
AI_TEXT_MODEL_POST: 'Модель для постов',
|
||
AI_TEXT_MODEL_TOPICS: 'Модель для тем',
|
||
AI_USD_RUB_RATE: 'Курс USD → RUB',
|
||
ROUTERAI_API_KEY: 'RouterAI — API ключ',
|
||
ROUTERAI_BASE_URL: 'RouterAI — Base URL',
|
||
ROUTERAI_IMAGE_MODEL: 'RouterAI — модель',
|
||
APP_PUBLIC_URL: 'URL приложения',
|
||
ENGINE_PUBLIC_URL: 'URL движка',
|
||
TELEGRAM_API_BASE: 'Telegram Bot API',
|
||
MAINTENANCE_MODE: 'Режим обслуживания',
|
||
MAINTENANCE_MESSAGE: 'Сообщение при обслуживании',
|
||
AUTO_DRAFT_DEFAULT_COUNT: 'Черновиков в день',
|
||
AUTO_DRAFT_DEFAULT_TIME: 'Время генерации',
|
||
DEFAULT_AI_STYLE_PROMPT: 'Стиль по умолчанию',
|
||
DEFAULT_EMOJI_ENABLED: 'Эмодзи',
|
||
DEFAULT_HASHTAGS_IN_POST: 'Хештеги в посте',
|
||
DEFAULT_IMAGE_ENABLED: 'Генерировать картинку',
|
||
DEFAULT_POST_GOAL: 'Цель поста',
|
||
DEFAULT_POST_LANGUAGE: 'Язык',
|
||
DEFAULT_POST_LENGTH: 'Длина поста',
|
||
DEFAULT_POST_STYLE: 'Стиль',
|
||
YUKASSA_SHOP_ID: 'ID магазина',
|
||
YUKASSA_SECRET: 'Секретный ключ',
|
||
YUKASSA_RETURN_URL: 'URL возврата',
|
||
SMTP_ENABLED: 'Email включён',
|
||
SMTP_FROM: 'Email отправителя',
|
||
SMTP_HOST: 'SMTP сервер',
|
||
SMTP_PASS: 'SMTP пароль',
|
||
SMTP_PORT: 'SMTP порт',
|
||
SMTP_USER: 'SMTP логин',
|
||
PHOTO_SEARCH_PROVIDER: 'Провайдер поиска',
|
||
YANDEX_SEARCH_API_KEY: 'Яндекс — API ключ',
|
||
YANDEX_SEARCH_DAILY_LIMIT: 'Лимит запросов/день',
|
||
YANDEX_SEARCH_FOLDER_ID: 'Яндекс — Folder ID',
|
||
};
|
||
|
||
const TABS_SYS = [
|
||
{ id: 'settings', label: 'Настройки API' },
|
||
{ id: 'billing', label: 'Биллинг' },
|
||
];
|
||
|
||
const CATEGORIES = [
|
||
{ slug: 'ai_providers', title: 'AI — текст и картинки',
|
||
hint: 'Ключи и модели для генерации текста и изображений. Изменения применяются без рестарта.' },
|
||
{ slug: 'payments', title: 'Приём оплаты',
|
||
hint: 'Настройки ЮKassa: ID магазина и секретный ключ из личного кабинета.' },
|
||
{ slug: 'photo_search', title: 'Поиск фотографий',
|
||
hint: 'Яндекс поиск: API ключ и лимиты на количество запросов в день.' },
|
||
{ slug: 'engine', title: 'Движок и Telegram',
|
||
hint: 'URL приложения, Telegram Bot API, режим обслуживания, настройки автогенерации.' },
|
||
{ slug: 'email', title: 'Email уведомления',
|
||
hint: 'SMTP настройки для отправки писем пользователям.' },
|
||
];
|
||
|
||
export default function SystemSettings() {
|
||
const [sysTab, setSysTab] = useState('settings');
|
||
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">
|
||
{/* Вкладки системной страницы */}
|
||
<div className="flex gap-1 border-b border-border pb-0">
|
||
{TABS_SYS.map(t => (
|
||
<button key={t.id} onClick={() => setSysTab(t.id)}
|
||
className={`px-4 py-2 text-sm font-medium border-b-2 transition-colors -mb-px ${
|
||
sysTab === t.id ? 'border-accent text-accent' : 'border-transparent text-gray-400 hover:text-gray-200'
|
||
}`}>
|
||
{t.label}
|
||
</button>
|
||
))}
|
||
</div>
|
||
|
||
{sysTab === 'billing' && <AdminBilling />}
|
||
|
||
{sysTab === 'settings' && (<>
|
||
<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">
|
||
<span className="text-sm font-medium text-gray-800 dark:text-gray-200">
|
||
{SETTING_LABELS[row.key] || row.description || row.key}
|
||
</span>
|
||
{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>
|
||
<code className="text-[11px] text-gray-400 font-mono mt-0.5">{row.key}</code>
|
||
</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>
|
||
);
|
||
}
|