feat(admin): раздел «Генерация картинок» — управление провайдером и тест
Новый админ-раздел: Account ID / токен (маскируется) / модель Cloudflare, лимиты (пауза, дневной лимит) из БД, статистика, кнопка теста генерации. imggenController: лимиты и модель теперь из конфига, поддержка JSON и бинарного ответа CF, переиспользуемые generateImage() и stats(). Бэкенд GET/PUT /api/admin/imggen + POST /api/admin/imggen/test (admin-only). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1094,8 +1094,61 @@ async function testAssistant(req, res) {
|
|||||||
res.json(r);
|
res.json(r);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── Генерация картинок (Cloudflare Workers AI) ──────────────────────────── */
|
||||||
|
const IMGGEN_MODELS = [
|
||||||
|
{ id: '@cf/black-forest-labs/flux-1-schnell', label: 'FLUX.1 schnell — рекомендуется, быстрый' },
|
||||||
|
{ id: '@cf/stabilityai/stable-diffusion-xl-base-1.0', label: 'Stable Diffusion XL' },
|
||||||
|
{ id: '@cf/bytedance/stable-diffusion-xl-lightning', label: 'SDXL Lightning — очень быстрый' },
|
||||||
|
{ id: '@cf/lykon/dreamshaper-8-lcm', label: 'DreamShaper 8 LCM' },
|
||||||
|
];
|
||||||
|
function _imgCfg() { try { return JSON.parse(_aset('imggen_provider') || '{}') || {}; } catch (e) { return {}; } }
|
||||||
|
|
||||||
|
function getImggen(_req, res) {
|
||||||
|
const c = _imgCfg();
|
||||||
|
let stats = { count: 0, bytes: 0 };
|
||||||
|
try { stats = require('./imggenController').stats(); } catch (e) {}
|
||||||
|
const cd = Number(c.cooldownMs), dc = Number(c.dailyCap);
|
||||||
|
res.json({
|
||||||
|
provider: c.provider || 'cloudflare',
|
||||||
|
accountId: c.accountId || '',
|
||||||
|
model: c.model || '@cf/black-forest-labs/flux-1-schnell',
|
||||||
|
hasToken: !!c.token,
|
||||||
|
cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : 4000,
|
||||||
|
dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : 40,
|
||||||
|
enabled: !!(c.provider === 'cloudflare' && c.accountId && c.token),
|
||||||
|
models: IMGGEN_MODELS,
|
||||||
|
stats,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function saveImggen(req, res) {
|
||||||
|
const b = req.body || {};
|
||||||
|
const c = _imgCfg();
|
||||||
|
c.provider = b.provider || c.provider || 'cloudflare';
|
||||||
|
if (b.accountId !== undefined) c.accountId = String(b.accountId || '').trim();
|
||||||
|
if (b.model !== undefined) c.model = String(b.model || '').trim();
|
||||||
|
if (b.clearToken) c.token = '';
|
||||||
|
else if (b.token && b.token !== '••••••••') c.token = String(b.token).trim();
|
||||||
|
if (b.cooldownMs !== undefined) { const n = Number(b.cooldownMs); if (Number.isFinite(n) && n >= 0) c.cooldownMs = n; }
|
||||||
|
if (b.dailyCap !== undefined) { const n = Number(b.dailyCap); if (Number.isFinite(n) && n >= 0) c.dailyCap = n; }
|
||||||
|
db.prepare("INSERT OR REPLACE INTO app_settings (key, value) VALUES ('imggen_provider', ?)").run(JSON.stringify(c));
|
||||||
|
audit(req, 'imggen.config', 'imggen', 'настройки генерации картинок');
|
||||||
|
res.json({ ok: true });
|
||||||
|
}
|
||||||
|
|
||||||
|
async function testImggen(req, res) {
|
||||||
|
const prompt = (String((req.body && req.body.prompt) || '').trim()) || 'a cute friendly mascot, flat illustration, warm tones';
|
||||||
|
try {
|
||||||
|
const out = await require('./imggenController').generateImage(prompt, 0);
|
||||||
|
res.json({ ok: true, url: out.url, prompt: out.prompt });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ ok: false, error: e.message || 'Ошибка', detail: e.detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getStats, getOverview, globalSearch,
|
getStats, getOverview, globalSearch,
|
||||||
|
getImggen, saveImggen, testImggen,
|
||||||
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
getUsers, updateRole, getUserSessions, getAllSessions, getSessionDetail,
|
||||||
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
clearUserSessions, deleteSession, updateUser, banUser, deleteUser,
|
||||||
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
getFeatures, updateFeatures, getFreeStudentFeatures, updateFreeStudentFeatures,
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
'use strict';
|
'use strict';
|
||||||
/* Генерация изображений (Cloudflare Workers AI · FLUX.1 schnell).
|
/* Генерация изображений (Cloudflare Workers AI · FLUX.1 schnell и др.).
|
||||||
* Конфиг в app_settings.imggen_provider: { provider, accountId, token, model }.
|
* Конфиг в app_settings.imggen_provider: { provider, accountId, token, model, cooldownMs?, dailyCap? }.
|
||||||
* Картинка сохраняется в uploads/generated и отдаётся URL'ом. */
|
* Картинка сохраняется в uploads/generated и отдаётся URL'ом.
|
||||||
|
* Управление — админ-раздел «Генерация картинок» (adminController.getImggen/saveImggen/testImggen). */
|
||||||
const fs = require('fs');
|
const fs = require('fs');
|
||||||
const path = require('path');
|
const path = require('path');
|
||||||
const db = require('../db/db');
|
const db = require('../db/db');
|
||||||
@@ -9,13 +10,25 @@ const db = require('../db/db');
|
|||||||
const GEN_DIR = path.join(__dirname, '../../uploads/generated');
|
const GEN_DIR = path.join(__dirname, '../../uploads/generated');
|
||||||
const _cooldown = new Map(); // userId → last ts (антиспам)
|
const _cooldown = new Map(); // userId → last ts (антиспам)
|
||||||
const _daily = new Map(); // userId → { day, count }
|
const _daily = new Map(); // userId → { day, count }
|
||||||
const COOLDOWN_MS = 4000;
|
const COOLDOWN_DEFAULT = 4000;
|
||||||
const DAILY_CAP = 40;
|
const DAILY_DEFAULT = 40;
|
||||||
|
|
||||||
function _cfg() {
|
function _cfg() {
|
||||||
try { const r = db.prepare("SELECT value FROM app_settings WHERE key='imggen_provider'").get(); return r ? JSON.parse(r.value) : null; } catch (e) { return null; }
|
try { const r = db.prepare("SELECT value FROM app_settings WHERE key='imggen_provider'").get(); return r ? JSON.parse(r.value) : null; } catch (e) { return null; }
|
||||||
}
|
}
|
||||||
function _enabled() { const c = _cfg(); return !!(c && c.provider === 'cloudflare' && c.accountId && c.token); }
|
function _enabled() { const c = _cfg(); return !!(c && c.provider === 'cloudflare' && c.accountId && c.token); }
|
||||||
|
function _limits() {
|
||||||
|
const c = _cfg() || {};
|
||||||
|
const cd = Number(c.cooldownMs);
|
||||||
|
const dc = Number(c.dailyCap);
|
||||||
|
return {
|
||||||
|
cooldownMs: Number.isFinite(cd) && cd >= 0 ? cd : COOLDOWN_DEFAULT,
|
||||||
|
dailyCap: Number.isFinite(dc) && dc >= 0 ? dc : DAILY_DEFAULT,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/* GET /api/imggen/status — для UI (показывать кнопки или нет) */
|
||||||
|
function status(req, res) { res.json({ enabled: _enabled() }); }
|
||||||
|
|
||||||
/* FLUX лучше понимает английский. Если в промпте есть кириллица — переводим
|
/* FLUX лучше понимает английский. Если в промпте есть кириллица — переводим
|
||||||
* через тот же LLM-провайдер, что и ассистент (с failover). При сбое — исходный текст. */
|
* через тот же LLM-провайдер, что и ассистент (с failover). При сбое — исходный текст. */
|
||||||
@@ -34,51 +47,93 @@ async function _toEnglish(prompt) {
|
|||||||
return prompt; // перевод не удался — отправим как есть
|
return prompt; // перевод не удался — отправим как есть
|
||||||
}
|
}
|
||||||
|
|
||||||
/* GET /api/imggen/status — для UI (показывать кнопки или нет) */
|
/* Низкоуровневый вызов Cloudflare. Возвращает Buffer (PNG) или бросает Error
|
||||||
function status(req, res) { res.json({ enabled: _enabled() }); }
|
* (с опц. .detail). Поддерживает оба формата ответа CF: JSON {result:{image:b64}}
|
||||||
|
* (FLUX) и сырой бинарный image/* (SDXL и пр.). */
|
||||||
/* POST /api/imggen { prompt, width?, height? } → { url } */
|
async function _callCF(cfg, prompt) {
|
||||||
async function generate(req, res) {
|
if (typeof fetch !== 'function') throw new Error('fetch недоступен');
|
||||||
const cfg = _cfg();
|
|
||||||
if (!_enabled()) return res.status(503).json({ error: 'Генерация изображений не настроена' });
|
|
||||||
const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, 500);
|
|
||||||
if (prompt.length < 3) return res.status(400).json({ error: 'Опиши, что нарисовать (хотя бы пару слов)' });
|
|
||||||
if (typeof fetch !== 'function') return res.status(503).json({ error: 'fetch недоступен' });
|
|
||||||
|
|
||||||
const uid = req.user.id, now = Date.now();
|
|
||||||
if (now - (_cooldown.get(uid) || 0) < COOLDOWN_MS) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' });
|
|
||||||
const today = new Date().toISOString().slice(0, 10);
|
|
||||||
const d = _daily.get(uid);
|
|
||||||
if (d && d.day === today && d.count >= DAILY_CAP) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' });
|
|
||||||
_cooldown.set(uid, now);
|
|
||||||
|
|
||||||
const enPrompt = await _toEnglish(prompt);
|
|
||||||
|
|
||||||
const ctrl = new AbortController();
|
const ctrl = new AbortController();
|
||||||
const timer = setTimeout(() => ctrl.abort(), 30000);
|
const timer = setTimeout(() => ctrl.abort(), 30000);
|
||||||
try {
|
try {
|
||||||
const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/ai/run/${cfg.model}`, {
|
const r = await fetch(`https://api.cloudflare.com/client/v4/accounts/${cfg.accountId}/ai/run/${cfg.model}`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: { Authorization: 'Bearer ' + cfg.token, 'Content-Type': 'application/json' },
|
headers: { Authorization: 'Bearer ' + cfg.token, 'Content-Type': 'application/json' },
|
||||||
body: JSON.stringify({ prompt: enPrompt, steps: 4 }),
|
body: JSON.stringify({ prompt, steps: 4 }),
|
||||||
signal: ctrl.signal,
|
signal: ctrl.signal,
|
||||||
});
|
});
|
||||||
if (!r.ok) { const t = await r.text(); return res.status(502).json({ error: 'Сервис картинок ответил ошибкой (' + r.status + ')', detail: t.slice(0, 120) }); }
|
if (!r.ok) {
|
||||||
const j = await r.json();
|
const t = await r.text();
|
||||||
const b64 = j && j.result && j.result.image;
|
const e = new Error('Сервис картинок ответил ошибкой (' + r.status + ')');
|
||||||
if (!b64) return res.status(502).json({ error: 'Пустой ответ от сервиса' });
|
e.detail = t.slice(0, 160);
|
||||||
const buf = Buffer.from(b64, 'base64');
|
throw e;
|
||||||
if (buf.length < 500) return res.status(502).json({ error: 'Некорректное изображение' });
|
}
|
||||||
|
const ct = (r.headers.get('content-type') || '').toLowerCase();
|
||||||
fs.mkdirSync(GEN_DIR, { recursive: true });
|
let buf;
|
||||||
const name = uid + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + '.png';
|
if (ct.includes('application/json')) {
|
||||||
fs.writeFileSync(path.join(GEN_DIR, name), buf);
|
const j = await r.json();
|
||||||
|
const b64 = j && j.result && j.result.image;
|
||||||
_daily.set(uid, { day: today, count: (d && d.day === today ? d.count : 0) + 1 });
|
if (!b64) throw new Error('Пустой ответ от сервиса');
|
||||||
res.json({ url: '/uploads/generated/' + name });
|
buf = Buffer.from(b64, 'base64');
|
||||||
|
} else {
|
||||||
|
buf = Buffer.from(await r.arrayBuffer());
|
||||||
|
}
|
||||||
|
if (buf.length < 500) throw new Error('Некорректное изображение');
|
||||||
|
return buf;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
res.status(502).json({ error: e.name === 'AbortError' ? 'Слишком долго — попробуй ещё раз' : 'Не удалось сгенерировать' });
|
if (e.name === 'AbortError') throw new Error('Слишком долго — попробуй ещё раз');
|
||||||
|
throw e;
|
||||||
} finally { clearTimeout(timer); }
|
} finally { clearTimeout(timer); }
|
||||||
}
|
}
|
||||||
|
|
||||||
module.exports = { generate, status };
|
function _save(uid, buf) {
|
||||||
|
fs.mkdirSync(GEN_DIR, { recursive: true });
|
||||||
|
const name = (uid || 0) + '-' + Date.now().toString(36) + Math.random().toString(36).slice(2, 6) + '.png';
|
||||||
|
fs.writeFileSync(path.join(GEN_DIR, name), buf);
|
||||||
|
return '/uploads/generated/' + name;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Публичная: перевод → генерация → сохранение. Без пер-юзер лимитов
|
||||||
|
* (их применяет route generate). Возвращает { url, prompt } или бросает Error. */
|
||||||
|
async function generateImage(prompt, uid) {
|
||||||
|
if (!_enabled()) throw new Error('Генерация изображений не настроена');
|
||||||
|
const p = String(prompt || '').trim().slice(0, 500);
|
||||||
|
if (p.length < 3) throw new Error('Опиши, что нарисовать (хотя бы пару слов)');
|
||||||
|
const en = await _toEnglish(p);
|
||||||
|
const buf = await _callCF(_cfg(), en);
|
||||||
|
return { url: _save(uid, buf), prompt: en };
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Статистика для админки: сколько картинок сгенерировано (файлов) и общий размер. */
|
||||||
|
function stats() {
|
||||||
|
try {
|
||||||
|
const files = fs.readdirSync(GEN_DIR).filter(f => f.endsWith('.png'));
|
||||||
|
let bytes = 0;
|
||||||
|
for (const f of files) { try { bytes += fs.statSync(path.join(GEN_DIR, f)).size; } catch (e) {} }
|
||||||
|
return { count: files.length, bytes };
|
||||||
|
} catch (e) { return { count: 0, bytes: 0 }; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* POST /api/imggen { prompt } → { url } */
|
||||||
|
async function generate(req, res) {
|
||||||
|
if (!_enabled()) return res.status(503).json({ error: 'Генерация изображений не настроена' });
|
||||||
|
const prompt = String((req.body && req.body.prompt) || '').trim().slice(0, 500);
|
||||||
|
if (prompt.length < 3) return res.status(400).json({ error: 'Опиши, что нарисовать (хотя бы пару слов)' });
|
||||||
|
|
||||||
|
const uid = req.user.id, now = Date.now();
|
||||||
|
const L = _limits();
|
||||||
|
if (now - (_cooldown.get(uid) || 0) < L.cooldownMs) return res.status(429).json({ error: 'Чуть помедленнее — подожди пару секунд' });
|
||||||
|
const today = new Date().toISOString().slice(0, 10);
|
||||||
|
const d = _daily.get(uid);
|
||||||
|
if (L.dailyCap > 0 && d && d.day === today && d.count >= L.dailyCap) return res.status(429).json({ error: 'Дневной лимит генераций исчерпан, попробуй завтра' });
|
||||||
|
_cooldown.set(uid, now);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const out = await generateImage(prompt, uid);
|
||||||
|
_daily.set(uid, { day: today, count: (d && d.day === today ? d.count : 0) + 1 });
|
||||||
|
res.json({ url: out.url });
|
||||||
|
} catch (e) {
|
||||||
|
res.status(502).json({ error: e.message || 'Не удалось сгенерировать', detail: e.detail });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { generate, status, generateImage, stats };
|
||||||
|
|||||||
@@ -18,6 +18,9 @@ router.put('/assistant', ctrl.saveAssistant);
|
|||||||
router.post('/assistant/test', ctrl.testAssistant);
|
router.post('/assistant/test', ctrl.testAssistant);
|
||||||
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
router.post('/assistant/reindex', ctrl.reindexTextbooks);
|
||||||
router.get('/assistant/models', ctrl.getProviderModels);
|
router.get('/assistant/models', ctrl.getProviderModels);
|
||||||
|
router.get('/imggen', ctrl.getImggen);
|
||||||
|
router.put('/imggen', ctrl.saveImggen);
|
||||||
|
router.post('/imggen/test', ctrl.testImggen);
|
||||||
router.post('/assistant/provider', ctrl.saveProvider);
|
router.post('/assistant/provider', ctrl.saveProvider);
|
||||||
router.delete('/assistant/provider/:id', requireRole('admin'), ctrl.deleteProvider);
|
router.delete('/assistant/provider/:id', requireRole('admin'), ctrl.deleteProvider);
|
||||||
router.post('/assistant/active', ctrl.setActiveProvider);
|
router.post('/assistant/active', ctrl.setActiveProvider);
|
||||||
|
|||||||
@@ -1073,6 +1073,9 @@
|
|||||||
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
|
<button class="admin-nav-item" data-tab="assistant" onclick="switchTab(this)" id="btn-tab-assistant" style="display:none">
|
||||||
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
|
<i data-lucide="sparkles" style="width:15px;height:15px"></i> Помощник Квантик
|
||||||
</button>
|
</button>
|
||||||
|
<button class="admin-nav-item" data-tab="imggen" onclick="switchTab(this)" id="btn-tab-imggen" style="display:none">
|
||||||
|
<i data-lucide="image" style="width:15px;height:15px"></i> Генерация картинок
|
||||||
|
</button>
|
||||||
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
|
<button class="admin-nav-item" data-tab="audit" onclick="switchTab(this)">
|
||||||
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
|
<i data-lucide="scroll-text" style="width:15px;height:15px"></i> Аудит-лог
|
||||||
</button>
|
</button>
|
||||||
@@ -1555,6 +1558,13 @@
|
|||||||
<div id="assistant-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
<div id="assistant-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- ── Генерация картинок ── -->
|
||||||
|
<div class="tab-pane" id="tab-imggen">
|
||||||
|
<div class="section-title">Генерация картинок</div>
|
||||||
|
<div class="perm-desc" style="margin-bottom:20px">ИИ-генерация изображений (Cloudflare Workers AI · FLUX/SDXL): провайдер, модель, лимиты и тест. Картинки — для иллюстраций и декора, не для точных схем.</div>
|
||||||
|
<div id="imggen-admin"><div style="color:var(--muted);font-size:0.84rem">Загрузка…</div></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- ── Игры ── -->
|
<!-- ── Игры ── -->
|
||||||
<div class="tab-pane" id="tab-games">
|
<div class="tab-pane" id="tab-games">
|
||||||
<div class="section-title">Управление играми</div>
|
<div class="section-title">Управление играми</div>
|
||||||
@@ -2128,6 +2138,7 @@
|
|||||||
<script src="/js/admin/sections/sims.js"></script>
|
<script src="/js/admin/sections/sims.js"></script>
|
||||||
<script src="/js/admin/sections/games.js"></script>
|
<script src="/js/admin/sections/games.js"></script>
|
||||||
<script src="/js/admin/sections/assistant.js"></script>
|
<script src="/js/admin/sections/assistant.js"></script>
|
||||||
|
<script src="/js/admin/sections/imggen.js"></script>
|
||||||
<script src="/js/admin/sections/tpl.js"></script>
|
<script src="/js/admin/sections/tpl.js"></script>
|
||||||
<script src="/js/admin/sections/subjects.js"></script>
|
<script src="/js/admin/sections/subjects.js"></script>
|
||||||
<script src="/js/admin/sections/permissions.js"></script>
|
<script src="/js/admin/sections/permissions.js"></script>
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
AdminCtx.isAdmin = isAdmin;
|
AdminCtx.isAdmin = isAdmin;
|
||||||
|
|
||||||
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
|
/* Admin-only tabs: show to everyone for discoverability, but lock for non-admins */
|
||||||
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant'];
|
const ADMIN_ONLY_TABS = ['btn-tab-subjects','btn-tab-permissions','btn-tab-shop','btn-tab-gam','btn-tab-tpl','btn-tab-sims','btn-tab-games','btn-tab-assistant','btn-tab-imggen'];
|
||||||
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
const lockSvg = '<svg class="adm-lock" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="11" width="18" height="11" rx="2"/><path d="M7 11V7a5 5 0 0 1 10 0v4"/></svg>';
|
||||||
ADMIN_ONLY_TABS.forEach(id => {
|
ADMIN_ONLY_TABS.forEach(id => {
|
||||||
const el = document.getElementById(id);
|
const el = document.getElementById(id);
|
||||||
@@ -66,6 +66,7 @@
|
|||||||
sims: 'sims',
|
sims: 'sims',
|
||||||
games: 'games',
|
games: 'games',
|
||||||
assistant: 'assistant',
|
assistant: 'assistant',
|
||||||
|
imggen: 'imggen',
|
||||||
sublog: 'sublog',
|
sublog: 'sublog',
|
||||||
access: 'access',
|
access: 'access',
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,121 @@
|
|||||||
|
'use strict';
|
||||||
|
/* admin → «Генерация картинок»: провайдер Cloudflare (Account ID, токен, модель),
|
||||||
|
* лимиты (пауза, дневной лимит), статистика и тест-генерация. */
|
||||||
|
(function () {
|
||||||
|
'use strict';
|
||||||
|
let inited = false;
|
||||||
|
var esc = (window.LS && LS.escapeHtml) ? LS.escapeHtml : function (s) { return String(s == null ? '' : s).replace(/[&<>"]/g, function (c) { return ({ '&': '&', '<': '<', '>': '>', '"': '"' })[c]; }); };
|
||||||
|
var IN = 'padding:8px 11px;border:1px solid var(--border,#e2e8f0);border-radius:9px;font:inherit;font-size:.85rem;width:100%;box-sizing:border-box;background:var(--surface,#fff);color:var(--text,#0f172a)';
|
||||||
|
function fmtBytes(n) { if (!n) return '0'; if (n >= 1048576) return (n / 1048576).toFixed(1) + ' МБ'; if (n >= 1024) return Math.round(n / 1024) + ' КБ'; return n + ' Б'; }
|
||||||
|
|
||||||
|
function ensureStyle() {
|
||||||
|
if (document.getElementById('img-adm-style')) return;
|
||||||
|
var s = document.createElement('style'); s.id = 'img-adm-style';
|
||||||
|
s.textContent = [
|
||||||
|
'.img-card{border:1.5px solid var(--border,#e2e8f0);border-radius:14px;background:var(--surface,#fff);padding:16px 18px;margin-top:14px;}',
|
||||||
|
'.img-flabel{font-size:.76rem;font-weight:700;color:var(--text-2,#475569);margin:11px 0 4px;}',
|
||||||
|
'.img-row{display:flex;gap:12px;flex-wrap:wrap;}',
|
||||||
|
'.img-row > div{flex:1;min-width:140px;}',
|
||||||
|
'.img-bdg{font-size:.62rem;font-weight:800;text-transform:uppercase;letter-spacing:.03em;padding:3px 10px;border-radius:99px;}',
|
||||||
|
'.img-bdg.on{background:rgba(5,150,82,.13);color:#059652;}',
|
||||||
|
'.img-bdg.off{background:rgba(140,148,166,.16);color:#8a94a6;}',
|
||||||
|
'.img-btn{padding:9px 17px;border-radius:10px;border:none;cursor:pointer;font:700 .82rem Manrope,sans-serif;}',
|
||||||
|
'.img-btn.primary{background:#9B5DE5;color:#fff;}',
|
||||||
|
'.img-btn.primary:hover{background:#7e3eca;}',
|
||||||
|
'.img-btn.ghost{background:transparent;border:1.5px solid var(--border-h,#cbd5e1);color:var(--text-2,#475569);}',
|
||||||
|
'.img-btn:disabled{opacity:.55;cursor:not-allowed;}',
|
||||||
|
'.img-prev{margin-top:12px;border-radius:12px;overflow:hidden;border:1px solid var(--border,#e2e8f0);background:#0d0d1f;min-height:90px;display:flex;align-items:center;justify-content:center;}',
|
||||||
|
'.img-prev img{max-width:100%;display:block;}',
|
||||||
|
'.img-busy{color:#9aa5b4;font-size:.82rem;padding:24px;text-align:center;}',
|
||||||
|
'.img-hint{font-size:.72rem;color:#8a94a6;margin-top:5px;line-height:1.45;}',
|
||||||
|
].join('');
|
||||||
|
document.head.appendChild(s);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function render() {
|
||||||
|
var host = document.getElementById('imggen-admin');
|
||||||
|
if (!host) return;
|
||||||
|
ensureStyle();
|
||||||
|
host.innerHTML = '<div style="color:var(--muted);font-size:.84rem">Загрузка…</div>';
|
||||||
|
|
||||||
|
var cfg = {};
|
||||||
|
try { cfg = await LS.api('/api/admin/imggen'); } catch (e) { host.innerHTML = '<div style="color:#e0335e">Не удалось загрузить настройки</div>'; return; }
|
||||||
|
var models = cfg.models || [];
|
||||||
|
var cdSec = Math.round((cfg.cooldownMs != null ? cfg.cooldownMs : 4000) / 1000);
|
||||||
|
|
||||||
|
host.innerHTML =
|
||||||
|
'<div class="img-card">' +
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:10px">' +
|
||||||
|
'<div style="font-weight:800;font-size:.95rem">Cloudflare Workers AI</div>' +
|
||||||
|
'<span class="img-bdg ' + (cfg.enabled ? 'on' : 'off') + '">' + (cfg.enabled ? 'Включена' : 'Не настроена') + '</span>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="img-hint">Бесплатная генерация (FLUX.1 / SDXL). Токен и Account ID — из дашборда Cloudflare (Workers AI). Хранятся в БД, не в git.</div>' +
|
||||||
|
|
||||||
|
'<div class="img-flabel">Account ID</div>' +
|
||||||
|
'<input id="img-acc" style="' + IN + '" value="' + esc(cfg.accountId || '') + '" placeholder="напр. df9aedbb7626…">' +
|
||||||
|
|
||||||
|
'<div class="img-flabel">API-токен</div>' +
|
||||||
|
'<input id="img-token" type="password" style="' + IN + '" placeholder="' + (cfg.hasToken ? '•••••••• (сохранён — оставьте пустым, чтобы не менять)' : 'вставьте токен') + '">' +
|
||||||
|
(cfg.hasToken ? '<label style="font-size:.72rem;color:#8a94a6;display:inline-flex;align-items:center;gap:6px;margin-top:6px"><input type="checkbox" id="img-clear-token"> очистить токен (выключить)</label>' : '') +
|
||||||
|
|
||||||
|
'<div class="img-flabel">Модель</div>' +
|
||||||
|
'<select id="img-model" style="' + IN + '">' +
|
||||||
|
models.map(function (m) { return '<option value="' + esc(m.id) + '"' + (m.id === cfg.model ? ' selected' : '') + '>' + esc(m.label) + '</option>'; }).join('') +
|
||||||
|
'</select>' +
|
||||||
|
|
||||||
|
'<div class="img-row" style="margin-top:4px">' +
|
||||||
|
'<div><div class="img-flabel">Пауза между запросами (сек)</div><input id="img-cd" type="number" min="0" step="1" style="' + IN + '" value="' + cdSec + '"></div>' +
|
||||||
|
'<div><div class="img-flabel">Дневной лимит на пользователя</div><input id="img-cap" type="number" min="0" step="1" style="' + IN + '" value="' + (cfg.dailyCap != null ? cfg.dailyCap : 40) + '"></div>' +
|
||||||
|
'</div>' +
|
||||||
|
'<div class="img-hint">0 в дневном лимите — без ограничения.</div>' +
|
||||||
|
|
||||||
|
'<div style="display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:14px;flex-wrap:wrap">' +
|
||||||
|
'<div style="font-size:.78rem;color:#8a94a6">Сгенерировано: <b style="color:var(--text-2,#475569)">' + (cfg.stats ? cfg.stats.count : 0) + '</b> картинок · ' + (cfg.stats ? fmtBytes(cfg.stats.bytes) : '0') + '</div>' +
|
||||||
|
'<button class="img-btn primary" id="img-save">Сохранить</button>' +
|
||||||
|
'</div>' +
|
||||||
|
'</div>' +
|
||||||
|
|
||||||
|
'<div class="img-card">' +
|
||||||
|
'<div style="font-weight:800;font-size:.92rem;margin-bottom:8px">Тест генерации</div>' +
|
||||||
|
'<input id="img-test-prompt" style="' + IN + '" placeholder="Опиши картинку (можно по-русски — переведётся автоматически)">' +
|
||||||
|
'<div style="margin-top:10px"><button class="img-btn ghost" id="img-test-btn">Сгенерировать тест</button></div>' +
|
||||||
|
'<div class="img-prev" id="img-test-prev" style="display:none"></div>' +
|
||||||
|
'</div>';
|
||||||
|
|
||||||
|
var Q = function (s) { return host.querySelector(s); };
|
||||||
|
|
||||||
|
Q('#img-save').addEventListener('click', async function () {
|
||||||
|
var btn = this; btn.disabled = true;
|
||||||
|
var body = {
|
||||||
|
provider: 'cloudflare',
|
||||||
|
accountId: Q('#img-acc').value.trim(),
|
||||||
|
model: Q('#img-model').value,
|
||||||
|
cooldownMs: Math.max(0, Number(Q('#img-cd').value) || 0) * 1000,
|
||||||
|
dailyCap: Math.max(0, Number(Q('#img-cap').value) || 0),
|
||||||
|
};
|
||||||
|
var clr = Q('#img-clear-token');
|
||||||
|
if (clr && clr.checked) body.clearToken = true;
|
||||||
|
else { var t = Q('#img-token').value.trim(); if (t) body.token = t; }
|
||||||
|
try { await LS.api('/api/admin/imggen', { method: 'PUT', body: JSON.stringify(body) }); LS.toast('Сохранено', 'success'); render(); }
|
||||||
|
catch (e) { LS.toast('Ошибка сохранения', 'error'); btn.disabled = false; }
|
||||||
|
});
|
||||||
|
|
||||||
|
Q('#img-test-btn').addEventListener('click', async function () {
|
||||||
|
var btn = this, prev = Q('#img-test-prev');
|
||||||
|
var prompt = Q('#img-test-prompt').value.trim();
|
||||||
|
btn.disabled = true; btn.textContent = 'Рисую…';
|
||||||
|
prev.style.display = 'flex'; prev.innerHTML = '<div class="img-busy">Генерирую… (5–15 сек)</div>';
|
||||||
|
try {
|
||||||
|
var r = await LS.api('/api/admin/imggen/test', { method: 'POST', body: JSON.stringify({ prompt: prompt }) });
|
||||||
|
if (r && r.url) prev.innerHTML = '<img src="' + r.url + '" alt="">';
|
||||||
|
else prev.innerHTML = '<div class="img-busy">Пустой ответ</div>';
|
||||||
|
} catch (e) {
|
||||||
|
var msg = (e && e.data && (e.data.error || e.data.detail)) || e.message || 'Ошибка';
|
||||||
|
prev.innerHTML = '<div class="img-busy">' + esc(msg) + '</div>';
|
||||||
|
} finally { btn.disabled = false; btn.textContent = 'Сгенерировать тест'; }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
window.AdminSections = window.AdminSections || {};
|
||||||
|
window.AdminSections.imggen = { init: async () => { if (inited) return; inited = true; await render(); }, reload: render };
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user