From 6fcdafed50ee9d13106cb46f819cbcf71205c0b5 Mon Sep 17 00:00:00 2001 From: Maxim Dolgolyov Date: Fri, 12 Jun 2026 10:59:26 +0300 Subject: [PATCH] =?UTF-8?q?feat(imggen):=20=D1=84=D0=BE=D0=BD=20=D0=BF?= =?UTF-8?q?=D0=B8=D1=82=D0=BE=D0=BC=D1=86=D0=B0,=20=D0=BE=D0=B1=D0=BB?= =?UTF-8?q?=D0=BE=D0=B6=D0=BA=D0=B8=20=D0=BA=D1=83=D1=80=D1=81=D0=BE=D0=B2?= =?UTF-8?q?,=20=D0=B0=D0=B2=D0=B0=D1=82=D0=B0=D1=80=D1=8B=20=D0=B8=20?= =?UTF-8?q?=D0=B4=D0=BE=D1=81=D0=BA=D0=B0=20=D1=87=D0=B5=D1=80=D0=B5=D0=B7?= =?UTF-8?q?=20=D0=98=D0=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Питомец: кастомный фон (миграция 068 pet_bg_custom, POST /api/pet/bg/custom, карточка «Свой фон (ИИ)» в гардеробной, применение картинкой). Курсы: обложка-картинка (миграция 069 cover_image, генерация в модалке редактирования, рендер вместо эмодзи). Аватар: кнопка «Сгенерировать (ИИ)» в загрузке → кадрирование → модерация. Доска (classroom): кнопка-инструмент «Сгенерировать картинку (ИИ)». Co-Authored-By: Claude Opus 4.8 --- backend/src/controllers/courseController.js | 6 +- backend/src/controllers/petController.js | 22 +++++- .../src/db/migrations/068_pet_custom_bg.sql | 3 + .../db/migrations/069_course_cover_image.sql | 2 + backend/src/routes/pet.js | 1 + frontend/classroom.html | 75 +++++++++++------- frontend/course.html | 32 +++++++- frontend/pet.html | 77 ++++++++++++++++--- frontend/profile.html | 27 +++++++ 9 files changed, 200 insertions(+), 45 deletions(-) create mode 100644 backend/src/db/migrations/068_pet_custom_bg.sql create mode 100644 backend/src/db/migrations/069_course_cover_image.sql diff --git a/backend/src/controllers/courseController.js b/backend/src/controllers/courseController.js index 974ce88..e61f5c1 100644 --- a/backend/src/controllers/courseController.js +++ b/backend/src/controllers/courseController.js @@ -23,6 +23,7 @@ function courseRow(row) { title: row.title, description: row.description || '', coverEmoji: row.cover_emoji, + coverImage: row.cover_image || null, orderIndex: row.order_index, isPublished: row.is_published === 1, createdBy: row.created_by, @@ -403,14 +404,15 @@ function duplicate(req, res) { function update(req, res) { const row = db.prepare('SELECT * FROM courses WHERE id = ?').get(req.params.id); if (!row) return res.status(404).json({ error: 'Course not found' }); - const { title, description, coverEmoji, orderIndex, isPublished, subjectSlug } = req.body; + const { title, description, coverEmoji, coverImage, orderIndex, isPublished, subjectSlug } = req.body; db.prepare(` - UPDATE courses SET title=?,description=?,cover_emoji=?,order_index=?,is_published=?,subject_slug=? WHERE id=? + UPDATE courses SET title=?,description=?,cover_emoji=?,cover_image=?,order_index=?,is_published=?,subject_slug=? WHERE id=? `).run( title ?? row.title, description !== undefined ? description : row.description, coverEmoji ?? row.cover_emoji, + coverImage !== undefined ? (coverImage || null) : row.cover_image, orderIndex ?? row.order_index, isPublished !== undefined ? (isPublished ? 1 : 0) : row.is_published, subjectSlug ?? row.subject_slug, diff --git a/backend/src/controllers/petController.js b/backend/src/controllers/petController.js index fd57f03..59e477f 100644 --- a/backend/src/controllers/petController.js +++ b/backend/src/controllers/petController.js @@ -178,7 +178,7 @@ function getPet(req, res) { const user = db.prepare( `SELECT xp, level, streak_current, streak_best, streak_date, coins, pet_name, last_login, pet_color, pet_last_petted, pet_petting_streak, - pet_bg, pet_bg_owned, pet_last_fed, pet_equipped, pet_pattern + pet_bg, pet_bg_owned, pet_last_fed, pet_equipped, pet_pattern, pet_bg_custom FROM users WHERE id = ?` ).get(req.user.id); @@ -277,6 +277,7 @@ function getPet(req, res) { feedCooldown, petBg: user.pet_bg || 'default', petBgOwned: _parseOwned(user.pet_bg_owned), + petBgCustom: user.pet_bg_custom || null, }); } @@ -391,7 +392,10 @@ function buyBg(req, res) { /* ── PATCH /api/pet/bg ────────────────────────────────────────────────── */ function setBg(req, res) { const { id } = req.body; - if (id !== 'default') { + if (id === 'custom') { + const url = db.prepare('SELECT pet_bg_custom FROM users WHERE id=?').get(req.user.id)?.pet_bg_custom; + if (!url) return res.status(400).json({ error: 'no_custom_bg' }); + } else if (id !== 'default') { const owned = _parseOwned(db.prepare('SELECT pet_bg_owned FROM users WHERE id=?').get(req.user.id)?.pet_bg_owned); if (!owned.includes(id)) return res.status(403).json({ error: 'not owned' }); } @@ -399,6 +403,18 @@ function setBg(req, res) { res.json({ ok: true, bg: id }); } +/* ── POST /api/pet/bg/custom ────────────────────────────────────────────── + Сохранить сгенерированную ИИ картинку как кастомный фон и сделать активной. + URL принимается только из /uploads/generated/ (то, что отдаёт /api/imggen). */ +function setCustomBg(req, res) { + const url = String(req.body && req.body.url || ''); + if (!/^\/uploads\/generated\/[A-Za-z0-9._-]+\.(png|jpg|jpeg|webp)$/.test(url)) { + return res.status(400).json({ error: 'invalid_url' }); + } + db.prepare('UPDATE users SET pet_bg_custom=?, pet_bg=? WHERE id=?').run(url, 'custom', req.user.id); + res.json({ ok: true, bg: 'custom', url }); +} + /* ── POST /api/pet/star ───────────────────────────────────────────────── */ function starCatch(req, res) { const user = db.prepare('SELECT pet_last_star FROM users WHERE id=?').get(req.user.id); @@ -436,4 +452,4 @@ function feedPet(req, res) { res.json({ ok: true, xpAwarded: 15, xp: updated.xp, coins: updated.coins }); } -module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, feedPet, equipAccessories, updatePattern }; +module.exports = { getPet, renamePet, petAction, updateColor, starCatch, getShop, buyBg, setBg, setCustomBg, feedPet, equipAccessories, updatePattern }; diff --git a/backend/src/db/migrations/068_pet_custom_bg.sql b/backend/src/db/migrations/068_pet_custom_bg.sql new file mode 100644 index 0000000..f461f04 --- /dev/null +++ b/backend/src/db/migrations/068_pet_custom_bg.sql @@ -0,0 +1,3 @@ +-- Кастомный фон питомца: URL сгенерированной ИИ картинки (/uploads/generated/...). +-- Когда pet_bg = 'custom', сцена использует это изображение. +ALTER TABLE users ADD COLUMN pet_bg_custom TEXT; diff --git a/backend/src/db/migrations/069_course_cover_image.sql b/backend/src/db/migrations/069_course_cover_image.sql new file mode 100644 index 0000000..6542229 --- /dev/null +++ b/backend/src/db/migrations/069_course_cover_image.sql @@ -0,0 +1,2 @@ +-- Обложка курса картинкой (URL). Если задана — показывается вместо эмодзи. +ALTER TABLE courses ADD COLUMN cover_image TEXT; diff --git a/backend/src/routes/pet.js b/backend/src/routes/pet.js index c6ece1d..b1914ce 100644 --- a/backend/src/routes/pet.js +++ b/backend/src/routes/pet.js @@ -12,6 +12,7 @@ router.post('/star', authMiddleware, c.starCatch); router.get('/shop', authMiddleware, c.getShop); router.post('/shop/buy', authMiddleware, c.buyBg); router.patch('/bg', authMiddleware, c.setBg); +router.post('/bg/custom', authMiddleware, c.setCustomBg); router.post('/feed', authMiddleware, c.feedPet); module.exports = router; diff --git a/frontend/classroom.html b/frontend/classroom.html index 6a49da7..e22edf3 100644 --- a/frontend/classroom.html +++ b/frontend/classroom.html @@ -2452,6 +2452,7 @@ +
@@ -3093,6 +3094,7 @@ + @@ -6557,45 +6559,64 @@ document.getElementById('wb-image-input')?.click(); } + // Поместить загруженный на доску (ресайз до 800px, по центру) + function wbPlaceImageFromImg(img) { + if (!_wb || !_sessionId) return; + const maxPx = 800; + let w = img.naturalWidth, h = img.naturalHeight; + if (w > maxPx || h > maxPx) { + if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; } + else { w = Math.round(w * maxPx / h); h = maxPx; } + } + const canvas = document.createElement('canvas'); + canvas.width = w; canvas.height = h; + canvas.getContext('2d').drawImage(img, 0, 0, w, h); + const src = canvas.toDataURL('image/jpeg', 0.8); + const vw = (w / (img.naturalWidth || w)) * 800; + const vh = (h / (img.naturalHeight || h)) * 450; + const vx = (1920 - vw) / 2; + const vy = (1080 - vh) / 2; + const stroke = { + id: _wb._localIdCounter--, + tool: 'image', + data: { src, x: vx, y: vy, w: vw, h: vh }, + }; + _wb._strokes.push(stroke); + _wb._undoStack.push(stroke.id); + _wb.render(); + if (_wb._onStrokeDone) _wb._onStrokeDone(stroke); + } + function wbImageSelected(input) { const file = input.files?.[0]; if (!file || !_wb || !_sessionId) return; input.value = ''; - const maxPx = 800; const reader = new FileReader(); reader.onload = e => { const img = new Image(); - img.onload = () => { - // Resize to max 800px on longest side - let w = img.naturalWidth, h = img.naturalHeight; - if (w > maxPx || h > maxPx) { - if (w >= h) { h = Math.round(h * maxPx / w); w = maxPx; } - else { w = Math.round(w * maxPx / h); h = maxPx; } - } - const canvas = document.createElement('canvas'); - canvas.width = w; canvas.height = h; - canvas.getContext('2d').drawImage(img, 0, 0, w, h); - const src = canvas.toDataURL('image/jpeg', 0.8); - // Place image centered on whiteboard in virtual coords - const vw = (w / (img.naturalWidth || w)) * 800; - const vh = (h / (img.naturalHeight || h)) * 450; - const vx = (1920 - vw) / 2; - const vy = (1080 - vh) / 2; - const stroke = { - id: _wb._localIdCounter--, - tool: 'image', - data: { src, x: vx, y: vy, w: vw, h: vh }, - }; - _wb._strokes.push(stroke); - _wb._undoStack.push(stroke.id); - _wb.render(); - if (_wb._onStrokeDone) _wb._onStrokeDone(stroke); - }; + img.onload = () => wbPlaceImageFromImg(img); img.src = e.target.result; }; reader.readAsDataURL(file); } + // Сгенерировать картинку ИИ и вставить на доску + function wbGenerateImage() { + if (!_wb || !_sessionId) return; + if (!LS.imagePromptModal) { LS.toast?.('Модуль генерации не загружен'); return; } + LS.imagePromptModal({ + title: 'Картинка на доску (ИИ)', + placeholder: 'Опиши иллюстрацию: «схема круговорота воды, плоский стиль»', + useLabel: 'Вставить на доску', + onUse: (url) => { + const img = new Image(); + img.onload = () => wbPlaceImageFromImg(img); + img.onerror = () => LS.toast?.('Не удалось загрузить картинку', 'error'); + img.src = url; + }, + }); + } + function wbSetCustomColor(input) { if (!_wb) return; _wb.setColor(input.value); diff --git a/frontend/course.html b/frontend/course.html index 2fcdd0d..64c9552 100644 --- a/frontend/course.html +++ b/frontend/course.html @@ -449,6 +449,7 @@ + + + @@ -2337,6 +2338,30 @@ _avDrag = false; } + /* Загрузить изображение в шаг кадрирования (из URL — для ИИ-генерации) */ + function avLoadFromUrl(src) { + const img = new Image(); + img.onload = () => { + _avImg = img; _avZoom = 1; _avOffX = 0; _avOffY = 0; + document.getElementById('av-zoom').value = 100; + document.getElementById('av-s1').style.display = 'none'; + document.getElementById('av-s2').style.display = 'flex'; + avDraw(); avBindCanvas(); + }; + img.onerror = () => LS.toast('Не удалось загрузить картинку', 'error'); + img.src = src; + } + /* Сгенерировать аватар через ИИ → кадрирование → отправка на проверку */ + function avGenerate() { + if (!LS.imagePromptModal) { LS.toast('Модуль генерации не загружен'); return; } + LS.imagePromptModal({ + title: 'Сгенерировать аватар', + placeholder: 'Аватар: «дружелюбный лис в наушниках, плоский стиль, по центру»', + useLabel: 'Кадрировать', + onUse: (url) => avLoadFromUrl(url), + }); + } + /* ── Step 2: Canvas crop ── */ function avDraw() { const c = document.getElementById('av-canvas'); @@ -2623,6 +2648,8 @@ +
или нарисуйте ИИ
+