feat(imggen): фон питомца, обложки курсов, аватары и доска через ИИ

Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom,
  карточка «Свой фон (ИИ)» в гардеробной, применение картинкой).
Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке
  редактирования, рендер вместо эмодзи).
Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация.
Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)».

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Maxim Dolgolyov
2026-06-12 10:59:26 +03:00
parent d6faf6b22c
commit 6fcdafed50
9 changed files with 200 additions and 45 deletions
+65 -12
View File
@@ -724,6 +724,7 @@
</div>
<script src="/js/api.js"></script>
<script src="/js/imggen.js"></script>
<script src="/js/sidebar.js"></script>
<script src="https://cdn.jsdelivr.net/npm/lucide@0.469.0/dist/umd/lucide.min.js"></script>
<script>
@@ -874,6 +875,25 @@ function applyWeather(mood) {
}
}
/* ── Применить фон сцены (пресет bg-<id> или кастомное изображение) ── */
function applyPetBg(id, customUrl) {
const scene = document.getElementById('pet-scene');
if (!scene) return;
scene.className = scene.className.replace(/\bbg-\S+/g, '').replace(/\s+/g, ' ').trim();
if (id === 'custom' && customUrl) {
scene.style.backgroundImage = `url("${customUrl}")`;
scene.style.backgroundSize = 'cover';
scene.style.backgroundPosition = 'center';
applyBgFX('default');
} else {
scene.style.backgroundImage = '';
scene.style.backgroundSize = '';
scene.style.backgroundPosition = '';
if (id && id !== 'default') scene.classList.add(`bg-${id}`);
applyBgFX(id || 'default');
}
}
/* ── B2 BgFX particles ── */
function applyBgFX(bgId) {
const c = document.getElementById('pet-bgfx');
@@ -1096,10 +1116,8 @@ async function selectBg(id, price, owned, card) {
if (!res?.ok) return;
// Update scene bg
const scene = document.getElementById('pet-scene');
scene.className = scene.className.replace(/\bbg-\S+/g, '') + (id !== 'default' ? ` bg-${id}` : '');
if (_petData) _petData.petBg = id;
applyBgFX(id);
applyPetBg(id, _petData && _petData.petBgCustom);
// Update coins display
if (res.coins !== undefined) {
@@ -1152,7 +1170,19 @@ async function renderBgPicker() {
const coinsEl = document.getElementById('pc-bg-coins');
if (coinsEl) coinsEl.innerHTML = `Монет: <b style="color:#F9C74F">${data.coins}</b>`;
const items = [{ id: 'default', name: 'Стандарт', price: 0, owned: true }, ...data.items];
grid.innerHTML = items.map(item => {
// Карточка кастомного фона (генерация ИИ) — всегда первой
const hasCustom = _petData && _petData.petBgCustom;
const activeCustom = data.currentBg === 'custom';
const customPrev = hasCustom
? `background:center/cover url("${hasCustom}")`
: 'background:linear-gradient(135deg,#9B5DE5,#06D6E0);display:flex;align-items:center;justify-content:center';
const sparkle = hasCustom ? '' : '<svg viewBox="0 0 24 24" fill="none" stroke="#fff" stroke-width="2" style="width:26px;height:26px"><path d="M13 3l2.2 6.3L22 12l-6.8 2.7L13 21l-2.2-6.3L4 12l6.8-2.7z"/><path d="M5 4v3M3.5 5.5h3"/></svg>';
const customCard = `<div class="pet-bg-card${activeCustom ? ' active' : ''}" data-id="__gen__">
<div class="pet-bg-preview" style="${customPrev}">${sparkle}</div>
<div class="pet-bg-info"><div class="pet-bg-name">Свой фон (ИИ)</div>
<div class="pet-bg-status">${activeCustom ? 'Активен' : hasCustom ? 'Перерисовать' : 'Создать'}</div></div>
</div>`;
grid.innerHTML = customCard + items.map(item => {
const isActive = data.currentBg === item.id;
const isOwned = item.owned || item.price === 0;
const status = isActive ? 'Активен' : isOwned ? 'Выбрать' : item.price + ' монет';
@@ -1162,8 +1192,29 @@ async function renderBgPicker() {
<div class="pet-bg-status">${status}</div></div>
</div>`;
}).join('');
grid.querySelectorAll('.pet-bg-card').forEach(c =>
c.addEventListener('click', () => selectBgInline(c.dataset.id, +c.dataset.price, c.dataset.owned === '1')));
grid.querySelectorAll('.pet-bg-card').forEach(c => {
if (c.dataset.id === '__gen__') { c.addEventListener('click', openCustomBgModal); return; }
c.addEventListener('click', () => selectBgInline(c.dataset.id, +c.dataset.price, c.dataset.owned === '1'));
});
}
/* ── Кастомный фон: генерация ИИ → /api/pet/bg/custom ── */
function openCustomBgModal() {
if (!LS.imagePromptModal) { LS.toast?.('Модуль генерации не загружен'); return; }
LS.imagePromptModal({
title: 'Свой фон для питомца',
placeholder: 'Уютная сцена: «звёздная ночь над горами, мягкие тёплые тона»',
useLabel: 'Поставить фоном',
onUse: async function (url) {
const res = await LS.api('/api/pet/bg/custom', { method: 'POST', body: JSON.stringify({ url }) }).catch(() => null);
if (!res?.ok) { LS.toast?.('Не удалось установить фон', 'error'); return; }
if (_petData) { _petData.petBg = 'custom'; _petData.petBgCustom = res.url; }
applyPetBg('custom', res.url);
updatePreviewScene();
renderBgPicker();
LS.toast?.('Фон установлен', 'success');
}
});
}
async function selectBgInline(id, price, owned) {
// покупка платного фона — подтверждение + предпроверка баланса
@@ -1180,10 +1231,8 @@ async function selectBgInline(id, price, owned) {
return null;
});
if (!res?.ok) return;
const scene = document.getElementById('pet-scene');
scene.className = scene.className.replace(/\bbg-\S+/g, '') + (id !== 'default' ? ` bg-${id}` : '');
if (_petData) _petData.petBg = id;
applyBgFX(id);
applyPetBg(id, _petData && _petData.petBgCustom);
updatePreviewScene();
if (res.coins !== undefined) {
document.getElementById('stat-coins').textContent = res.coins;
@@ -1241,9 +1290,8 @@ function renderPet(d) {
const prevTod = scene.dataset.tod;
scene.className = `pet-scene mood-${d.mood}`;
if (prevTod) scene.dataset.tod = prevTod;
if (d.petBg && d.petBg !== 'default') scene.classList.add(`bg-${d.petBg}`);
applyWeather(d.mood);
applyBgFX(d.petBg || 'default');
applyPetBg(d.petBg || 'default', d.petBgCustom);
// B4: XP float if XP increased since last visit
const cachedXP = parseInt(localStorage.getItem('ls_pet_xp') || '0');
@@ -1451,7 +1499,12 @@ function paintPet() {
}
function updatePreviewScene() {
const sc = document.getElementById('wr-preview-scene');
if (sc && _petData) sc.style.background = BG_PREVIEWS[_petData.petBg] || BG_PREVIEWS.default;
if (!sc || !_petData) return;
if (_petData.petBg === 'custom' && _petData.petBgCustom) {
sc.style.background = `center/cover url("${_petData.petBgCustom}")`;
} else {
sc.style.background = BG_PREVIEWS[_petData.petBg] || BG_PREVIEWS.default;
}
}
function openWardrobe() {
if (!_petData) return;