diff --git a/NexusAI/README.md b/NexusAI/README.md index 18f2ce2..5908f50 100644 --- a/NexusAI/README.md +++ b/NexusAI/README.md @@ -162,9 +162,10 @@ npm run admin ``` Возможности: +- **Тема** — переключатель светлая/тёмная (сохраняется в localStorage). - **Потребление** — дашборд трат: суммарно, по провайдерам, по возможностям, по дням (из журнала). -- **Галерея** — просмотр сгенерированного контента (изображения/аудио/видео), фильтр по возможности. -- **Провайдеры** — добавить/включить/выключить, назначить default, ввести ключ (шифруется), **проверить ключ** реальным вызовом. Ключи показываются только маской. +- **Галерея** — просмотр сгенерированного контента (изображения/аудио/видео), фильтр по возможности, пагинация 50/100/150/200, модальное окно на весь экран, удаление контента (файл + запись). +- **Провайдеры** — добавить/включить/выключить, назначить default, ввести ключ (шифруется), **проверить ключ** реальным вызовом, типы `nordrouter` и `openai`. Ключи показываются только маской. Переменные: `NEXUS_ADMIN_HOST` (default `127.0.0.1`), `NEXUS_ADMIN_PORT` (default `4123`), `NEXUS_MASTER_KEY` (для ключей). diff --git a/NexusAI/packages/admin/public/app.js b/NexusAI/packages/admin/public/app.js index 32a48fb..5e68604 100644 --- a/NexusAI/packages/admin/public/app.js +++ b/NexusAI/packages/admin/public/app.js @@ -24,6 +24,21 @@ async function api(path, opts) { } function usd(n) { return (n == null ? 0 : n).toFixed(4); } +function esc(s) { return String(s == null ? '' : s).replace(/[&<>"]/g, (c) => + ({ '&': '&', '<': '<', '>': '>', '"': '"' }[c])); } + +// --- Theme --- +function applyTheme(theme) { + document.documentElement.setAttribute('data-theme', theme); + const btn = $('#themeToggle'); + btn.textContent = theme === 'dark' ? '🌙 Тёмная' : '☀️ Светлая'; + localStorage.setItem('nexus-theme', theme); +} +$('#themeToggle').addEventListener('click', () => { + const cur = document.documentElement.getAttribute('data-theme'); + applyTheme(cur === 'dark' ? 'light' : 'dark'); +}); +applyTheme(localStorage.getItem('nexus-theme') || 'dark'); // --- Tabs --- $$('nav button').forEach((btn) => { @@ -50,7 +65,7 @@ async function loadStats() {
${s.byCapability.length}
Типов операций
`; const fill = (id, rows, key) => { $(`#${id} tbody`).innerHTML = rows.map((r) => - `${r[key]}${r.count}$${usd(r.costUsd)}`).join('') + `${esc(r[key])}${r.count}$${usd(r.costUsd)}`).join('') || 'Нет данных'; }; fill('byProvider', s.byProvider, 'provider'); @@ -59,34 +74,123 @@ async function loadStats() { } $('#statsRange').addEventListener('change', loadStats); -// --- Gallery --- +// --- Gallery (with pagination) --- +let galleryOffset = 0; +let galleryLastCount = 0; + +function pageSize() { return parseInt($('#pageSize').value, 10) || 50; } + async function loadGallery() { const cap = $('#galleryFilter').value; - const q = cap ? `?capability=${cap}&limit=60` : '?limit=60'; - const { items } = await api(`/api/generations${q}`); + const limit = pageSize(); + const params = new URLSearchParams(); + params.set('limit', String(limit)); + params.set('offset', String(galleryOffset)); + if (cap) params.set('capability', cap); + const { items } = await api(`/api/generations?${params.toString()}`); + galleryLastCount = items.length; $('#gallery').innerHTML = items.map(renderTile).join('') || '
Пока нет сгенерированного контента.
'; + const page = Math.floor(galleryOffset / limit) + 1; + $('#pageInfo').textContent = `Страница ${page} · показано ${items.length}`; + $('#prevPage').disabled = galleryOffset === 0; + $('#nextPage').disabled = items.length < limit; + + $$('[data-view]').forEach((el) => el.onclick = () => openModal(el.dataset.view)); + $$('[data-del]').forEach((b) => b.onclick = (e) => { e.stopPropagation(); delGeneration(b.dataset.del); }); } + function renderTile(it) { const cost = it.costUsd != null ? `$${usd(it.costUsd)}` : ''; let media = ''; if (it.mediaUrl) { - if (it.capability === 'image') media = ``; + if (it.capability === 'image') media = `
`; else if (it.capability === 'tts' || it.capability === 'music') media = `
`; - else if (it.capability === 'video') media = ``; + else if (it.capability === 'video') media = `
`; else media = `Открыть файл`; } else { const status = it.status === 'failed' ? '⚠ ошибка' : 'нет локального файла'; media = `
${status}
`; } - return `
${media}
-
${it.capability} · ${cost}
-
${it.model}
-
${new Date(it.createdAt).toLocaleString()}
-
`; + return `
+ + ${media} +
+
${esc(it.capability)} · ${cost}
+
${esc(it.model)}
+
${new Date(it.createdAt).toLocaleString()}
+
+
`; +} + +$('#galleryReload').addEventListener('click', () => { galleryOffset = 0; loadGallery(); }); +$('#galleryFilter').addEventListener('change', () => { galleryOffset = 0; loadGallery(); }); +$('#pageSize').addEventListener('change', () => { galleryOffset = 0; loadGallery(); }); +$('#prevPage').addEventListener('click', () => { + galleryOffset = Math.max(0, galleryOffset - pageSize()); + loadGallery(); +}); +$('#nextPage').addEventListener('click', () => { + if (galleryLastCount < pageSize()) return; + galleryOffset += pageSize(); + loadGallery(); +}); + +// --- Modal viewer --- +async function openModal(id) { + let rec; + try { rec = await api(`/api/generations/${encodeURIComponent(id)}`); } + catch (e) { return toast('Ошибка: ' + e.message); } + + let body = ''; + if (rec.mediaUrl && rec.capability === 'image') body = ``; + else if (rec.mediaUrl && rec.capability === 'video') body = ``; + else if (rec.mediaUrl && (rec.capability === 'tts' || rec.capability === 'music')) body = ``; + else body = `
Нет предпросмотра для этого типа.
`; + + const kv = ` +
Модель: ${esc(rec.model)}
+
Провайдер: ${esc(rec.provider)} · Возможность: ${esc(rec.capability)}
+
Стоимость: ${rec.costUsd != null ? '$' + usd(rec.costUsd) : '—'}
+
Создано: ${new Date(rec.createdAt).toLocaleString()}
+ ${rec.inputSummary ? `
Вход: ${esc(rec.inputSummary)}
` : ''} + ${rec.error ? `
Ошибка: ${esc(rec.error)}
` : ''}`; + + $('#modalRoot').innerHTML = ` + `; + + const close = () => { $('#modalRoot').innerHTML = ''; }; + $('#modalClose').onclick = close; + $('#modalBackdrop').onclick = (e) => { if (e.target.id === 'modalBackdrop') close(); }; + document.addEventListener('keydown', function onEsc(e) { + if (e.key === 'Escape') { close(); document.removeEventListener('keydown', onEsc); } + }); + $('#modalDel').onclick = async () => { + if (!confirm('Удалить этот контент?')) return; + await delGeneration(id, true); + close(); + }; +} + +async function delGeneration(id, silent) { + if (!silent && !confirm('Удалить этот контент?')) return; + try { + await api(`/api/generations/${encodeURIComponent(id)}`, { method: 'DELETE' }); + toast('Удалено'); + loadGallery(); + } catch (e) { toast('Ошибка: ' + e.message); } } -$('#galleryReload').addEventListener('click', loadGallery); -$('#galleryFilter').addEventListener('change', loadGallery); // --- Providers --- let CRYPTO_ENABLED = true; @@ -98,7 +202,7 @@ async function loadProviders() { : '⚠ NEXUS_MASTER_KEY не задан — ключи из БД недоступны'; const typeSel = $('#pType'); if (!typeSel.options.length) { - typeSel.innerHTML = knownTypes.map((t) => ``).join(''); + typeSel.innerHTML = knownTypes.map((t) => ``).join(''); } $('#providersTable tbody').innerHTML = providers.map((p) => { const status = [ @@ -106,15 +210,15 @@ async function loadProviders() { p.enabled ? 'включён' : 'выключен', ].join(' '); return ` - ${p.name} - ${p.type} - ${p.hasKey ? `${p.keyHint || '••••'}` : 'нет'} + ${esc(p.name)} + ${esc(p.type)} + ${p.hasKey ? `${esc(p.keyHint || '••••')}` : 'нет'} ${status} - - - - + + + + `; }).join('') || 'Нет провайдеров. Добавьте выше.'; @@ -122,7 +226,7 @@ async function loadProviders() { $$('[data-check]').forEach((b) => b.onclick = () => checkProvider(b.dataset.check)); $$('[data-toggle]').forEach((b) => b.onclick = () => toggleProvider(b.dataset.toggle, b.dataset.en !== 'true')); $$('[data-default]').forEach((b) => b.onclick = () => setDefault(b.dataset.default)); - $$('[data-del]').forEach((b) => b.onclick = () => delProvider(b.dataset.del)); + $$('[data-del-provider]').forEach((b) => b.onclick = () => delProvider(b.dataset.delProvider)); } $('#pSave').addEventListener('click', async () => { diff --git a/NexusAI/packages/admin/public/index.html b/NexusAI/packages/admin/public/index.html index 77dca9b..7b35f0a 100644 --- a/NexusAI/packages/admin/public/index.html +++ b/NexusAI/packages/admin/public/index.html @@ -1,22 +1,31 @@ - + NexusAI Admin

NexusAI Admin

+ +