Files
postcast-tool/components/SystemSettings.js
T

401 lines
16 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, 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>
);
}