feat(xp): textbook XP синхронизируется с системной геймификацией
- backend: POST /api/gamification/self-award (rate-limited, validated) - frontend/js/xp.js: load/add/flush/on клиент, ~150 LOC, дебаунс 300мс, keepalive fetch на unload/visibilitychange hidden - algebra_8.html и algebra_8_ch2.html: XP_LEVELS заменён на единую формулу с сервером; addXp/loadProgress подключены к window.LS.xp - При первой загрузке: merge max(local, server); далее сервер — источник правды
This commit is contained in:
@@ -145,8 +145,24 @@ function claimChallenge(req, res) {
|
|||||||
res.json({ xp: c.xp_reward });
|
res.json({ xp: c.xp_reward });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* POST /api/gamification/self-award — начисление XP из учебника */
|
||||||
|
function selfAward(req, res) {
|
||||||
|
const amount = Number(req.body.amount);
|
||||||
|
const source = String(req.body.source || '');
|
||||||
|
if (!Number.isInteger(amount) || amount < 1 || amount > 50) {
|
||||||
|
return res.status(400).json({ error: 'amount должен быть целым числом от 1 до 50' });
|
||||||
|
}
|
||||||
|
if (!/^[a-z0-9_-]{1,60}$/i.test(source)) {
|
||||||
|
return res.status(400).json({ error: 'source неверный формат (a-z, 0-9, _, -, до 60 символов)' });
|
||||||
|
}
|
||||||
|
awardXP(req.user.id, amount, 'tb:' + source);
|
||||||
|
const info = getXPInfo(req.user.id);
|
||||||
|
res.json(info);
|
||||||
|
}
|
||||||
|
|
||||||
module.exports = {
|
module.exports = {
|
||||||
getMe, getFrames, setFrame, setGoalTier,
|
getMe, getFrames, setFrame, setGoalTier,
|
||||||
getAchievements, getLeaderboard, getXPHistory,
|
getAchievements, getLeaderboard, getXPHistory,
|
||||||
getChallenges, claimChallenge,
|
getChallenges, claimChallenge,
|
||||||
|
selfAward,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -5,13 +5,22 @@ const rateLimit = require('../middleware/rateLimit');
|
|||||||
const {
|
const {
|
||||||
getMe, getAchievements, getLeaderboard, getXPHistory,
|
getMe, getAchievements, getLeaderboard, getXPHistory,
|
||||||
getChallenges, claimChallenge, setGoalTier, getFrames, setFrame,
|
getChallenges, claimChallenge, setGoalTier, getFrames, setFrame,
|
||||||
onLabExperiment,
|
onLabExperiment, selfAward,
|
||||||
adminAward, adminReset, adminGamStats, adminGetUser
|
adminAward, adminReset, adminGamStats, adminGetUser
|
||||||
} = require('../controllers/gamificationController');
|
} = require('../controllers/gamificationController');
|
||||||
|
|
||||||
const labLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые запросы лаборатории' });
|
const labLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые запросы лаборатории' });
|
||||||
const labSchema = { body: { reactionsDiscovered: { type: 'number', min: 0, max: 100, integer: true } } };
|
const labSchema = { body: { reactionsDiscovered: { type: 'number', min: 0, max: 100, integer: true } } };
|
||||||
|
|
||||||
|
const tbLimiter = rateLimit({ windowMs: 60_000, max: 30, message: 'Слишком частые начисления XP' });
|
||||||
|
const selfAwardSchema = {
|
||||||
|
body: {
|
||||||
|
amount: { type: 'number', required: true, min: 1, max: 50, integer: true },
|
||||||
|
source: { type: 'string', required: true, minLen: 1, maxLen: 60,
|
||||||
|
match: /^[a-z0-9_-]{1,60}$/i },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
|
||||||
router.use(authMiddleware);
|
router.use(authMiddleware);
|
||||||
|
|
||||||
router.get('/me', getMe);
|
router.get('/me', getMe);
|
||||||
@@ -24,6 +33,9 @@ router.post('/goal-tier', requirePermission('gamification.challenges'), setGoalT
|
|||||||
router.get('/frames', getFrames);
|
router.get('/frames', getFrames);
|
||||||
router.post('/frame', requirePermission('shop.purchase'), setFrame);
|
router.post('/frame', requirePermission('shop.purchase'), setFrame);
|
||||||
|
|
||||||
|
/* Учебник — начисление XP от пользователя */
|
||||||
|
router.post('/self-award', tbLimiter, validate(selfAwardSchema), selfAward);
|
||||||
|
|
||||||
/* Lab experiment tracking */
|
/* Lab experiment tracking */
|
||||||
router.post('/lab-activity', requirePermission('simulations.access'), labLimiter, validate(labSchema), (req, res) => {
|
router.post('/lab-activity', requirePermission('simulations.access'), labLimiter, validate(labSchema), (req, res) => {
|
||||||
const discovered = Number(req.body.reactionsDiscovered) || 0;
|
const discovered = Number(req.body.reactionsDiscovered) || 0;
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--pri:#e91e63; --pri2:#c2185b; --pri-soft:#fce7f3;
|
--pri:#e91e63; --pri2:#c2185b; --pri-soft:#fce7f3;
|
||||||
@@ -1460,6 +1462,18 @@ function init(){
|
|||||||
initMobileSidebar();
|
initMobileSidebar();
|
||||||
goTo('p1'); // строит только §1, остальные — лениво при переходе
|
goTo('p1'); // строит только §1, остальные — лениво при переходе
|
||||||
setTimeout(()=>achievement('start','Начало пути по корням!'), 800);
|
setTimeout(()=>achievement('start','Начало пути по корням!'), 800);
|
||||||
|
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
|
||||||
|
if(window.LS && window.LS.xp){
|
||||||
|
window.LS.xp.load().then(function(s){
|
||||||
|
if(s && s.xp > STATE.xp){
|
||||||
|
STATE.xp = s.xp;
|
||||||
|
STATE.level = calcLevel(STATE.xp);
|
||||||
|
saveProgress();
|
||||||
|
refreshProgressUI();
|
||||||
|
if(STATE.current) buildSidebar(STATE.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
@@ -5902,22 +5916,11 @@ document.addEventListener('DOMContentLoaded', ()=>setTimeout(initWave3, 100));
|
|||||||
WAVE 4 — GAMIFICATION
|
WAVE 4 — GAMIFICATION
|
||||||
════════════════════════════════════════════════════════ */
|
════════════════════════════════════════════════════════ */
|
||||||
|
|
||||||
/* ── XP levels ── */
|
/* ── XP levels — единая формула с сервером ── */
|
||||||
const XP_LEVELS = [0, 50, 120, 220, 350, 520, 740, 1000, 1300, 1700, 2200];
|
const XP_LEVELS = null; // legacy — теперь уровень считается формулой
|
||||||
|
|
||||||
function calcLevel(xp){
|
function calcLevel(xp){ return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
|
||||||
let lv = 1;
|
function _xpForLevel(lv){ return (lv - 1) * (lv - 1) * 100; }
|
||||||
for(let i = 0; i < XP_LEVELS.length; i++){
|
|
||||||
if(xp >= XP_LEVELS[i]) lv = i + 1;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
return Math.min(lv, XP_LEVELS.length);
|
|
||||||
}
|
|
||||||
|
|
||||||
function _xpForLevel(lv){
|
|
||||||
const idx = Math.max(0, Math.min(lv - 1, XP_LEVELS.length - 1));
|
|
||||||
return XP_LEVELS[idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
function addXp(amount, source){
|
function addXp(amount, source){
|
||||||
if(!amount || amount <= 0) return;
|
if(!amount || amount <= 0) return;
|
||||||
@@ -5926,6 +5929,7 @@ function addXp(amount, source){
|
|||||||
STATE.xp += amount;
|
STATE.xp += amount;
|
||||||
STATE.level = calcLevel(STATE.xp);
|
STATE.level = calcLevel(STATE.xp);
|
||||||
saveProgress();
|
saveProgress();
|
||||||
|
if(window.LS && window.LS.xp) window.LS.xp.add(amount, 'algebra8-ch1-' + (source || 'misc'));
|
||||||
refreshProgressUI(); // обновляет XP-бейдж в hero
|
refreshProgressUI(); // обновляет XP-бейдж в hero
|
||||||
|
|
||||||
if(STATE.level > prevLevel){
|
if(STATE.level > prevLevel){
|
||||||
@@ -5951,7 +5955,7 @@ function addXp(amount, source){
|
|||||||
if(fill) fill.style.width = xpPct + '%';
|
if(fill) fill.style.width = xpPct + '%';
|
||||||
const xpNums = box.querySelectorAll('.xp-nums span');
|
const xpNums = box.querySelectorAll('.xp-nums span');
|
||||||
if(xpNums[0]) xpNums[0].textContent = STATE.xp + ' XP';
|
if(xpNums[0]) xpNums[0].textContent = STATE.xp + ' XP';
|
||||||
if(xpNums[1]) xpNums[1].textContent = STATE.level < 10 ? xpNext + ' XP' : 'MAX';
|
if(xpNums[1]) xpNums[1].textContent = STATE.level < 30 ? xpNext + ' XP' : 'MAX';
|
||||||
const lvEl = box.querySelector('.xp-level');
|
const lvEl = box.querySelector('.xp-level');
|
||||||
if(lvEl) lvEl.textContent = 'Ур. ' + STATE.level;
|
if(lvEl) lvEl.textContent = 'Ур. ' + STATE.level;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,6 +12,8 @@
|
|||||||
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.9/dist/contrib/auto-render.min.js"
|
||||||
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
|
onload="renderMathInElement(document.body,{delimiters:[{left:'$$',right:'$$',display:true},{left:'$',right:'$',display:false},{left:'\\[',right:'\\]',display:true},{left:'\\(',right:'\\)',display:false}],throwOnError:false})"></script>
|
||||||
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
<link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700;800;900&family=Manrope:wght@400;500;600;700;800&family=Unbounded:wght@400;700;800;900&display=swap" rel="stylesheet">
|
||||||
|
<script src="/js/api.js" defer></script>
|
||||||
|
<script src="/js/xp.js" defer></script>
|
||||||
<style>
|
<style>
|
||||||
:root{
|
:root{
|
||||||
--pri:#e91e63; --pri2:#c2185b; --pri-soft:#fce7f3;
|
--pri:#e91e63; --pri2:#c2185b; --pri-soft:#fce7f3;
|
||||||
@@ -454,20 +456,11 @@ const STATE = {
|
|||||||
level: 1,
|
level: 1,
|
||||||
};
|
};
|
||||||
|
|
||||||
/* Уровни — общая таблица с главой 1 */
|
/* Уровни — единая формула с сервером (xpToLevel из _shared.js) */
|
||||||
const XP_LEVELS = [0, 50, 120, 220, 350, 520, 740, 1000, 1300, 1700, 2200];
|
const XP_LEVELS = null; // legacy — теперь уровень считается формулой
|
||||||
function calcLevel(xp){
|
|
||||||
let lv = 1;
|
function calcLevel(xp){ return Math.floor(Math.sqrt((xp || 0) / 100)) + 1; }
|
||||||
for(let i = 0; i < XP_LEVELS.length; i++){
|
function _xpForLevel(lv){ return (lv - 1) * (lv - 1) * 100; }
|
||||||
if(xp >= XP_LEVELS[i]) lv = i + 1;
|
|
||||||
else break;
|
|
||||||
}
|
|
||||||
return Math.min(lv, XP_LEVELS.length);
|
|
||||||
}
|
|
||||||
function _xpForLevel(lv){
|
|
||||||
const idx = Math.max(0, Math.min(lv - 1, XP_LEVELS.length - 1));
|
|
||||||
return XP_LEVELS[idx];
|
|
||||||
}
|
|
||||||
|
|
||||||
const ACH_LABELS = {
|
const ACH_LABELS = {
|
||||||
start: 'Начало главы 2!',
|
start: 'Начало главы 2!',
|
||||||
@@ -555,6 +548,7 @@ function addXp(n, src){
|
|||||||
STATE.xp = Math.max(0, (STATE.xp || 0) + n);
|
STATE.xp = Math.max(0, (STATE.xp || 0) + n);
|
||||||
STATE.level = calcLevel(STATE.xp);
|
STATE.level = calcLevel(STATE.xp);
|
||||||
saveProgress();
|
saveProgress();
|
||||||
|
if(window.LS && window.LS.xp) window.LS.xp.add(n, 'algebra8-ch2-' + (src || 'misc'));
|
||||||
refreshProgressUI();
|
refreshProgressUI();
|
||||||
if(STATE.level > prev){
|
if(STATE.level > prev){
|
||||||
const pop = document.getElementById('ach-popup');
|
const pop = document.getElementById('ach-popup');
|
||||||
@@ -729,7 +723,7 @@ function buildSidebar(id){
|
|||||||
<span class="xp-level">Ур. ${STATE.level}</span>
|
<span class="xp-level">Ур. ${STATE.level}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="xp-bar"><div class="xp-fill" style="width:${xpPct}%"></div></div>
|
<div class="xp-bar"><div class="xp-fill" style="width:${xpPct}%"></div></div>
|
||||||
<div class="xp-nums"><span>${STATE.xp} XP</span><span>${STATE.level < XP_LEVELS.length ? xpNext + ' XP' : 'MAX'}</span></div>
|
<div class="xp-nums"><span>${STATE.xp} XP</span><span>${STATE.level < 30 ? xpNext + ' XP' : 'MAX'}</span></div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
// Шпаргалка
|
// Шпаргалка
|
||||||
@@ -1214,6 +1208,18 @@ function init(){
|
|||||||
refreshProgressUI();
|
refreshProgressUI();
|
||||||
goTo('p7');
|
goTo('p7');
|
||||||
setTimeout(()=>achievement('start','Начало главы 2!'), 600);
|
setTimeout(()=>achievement('start','Начало главы 2!'), 600);
|
||||||
|
// Sync XP с сервером: если серверный XP выше — обновляем локальный прогресс
|
||||||
|
if(window.LS && window.LS.xp){
|
||||||
|
window.LS.xp.load().then(function(s){
|
||||||
|
if(s && s.xp > STATE.xp){
|
||||||
|
STATE.xp = s.xp;
|
||||||
|
STATE.level = calcLevel(STATE.xp);
|
||||||
|
saveProgress();
|
||||||
|
refreshProgressUI();
|
||||||
|
if(STATE.current) buildSidebar(STATE.current);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
document.addEventListener('DOMContentLoaded', init);
|
document.addEventListener('DOMContentLoaded', init);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,184 @@
|
|||||||
|
'use strict';
|
||||||
|
/**
|
||||||
|
* window.LS.xp — синхронизация XP учебника с системной геймификацией.
|
||||||
|
*
|
||||||
|
* API:
|
||||||
|
* LS.xp.load() — async, GET /api/gamification/me, merge с localStorage
|
||||||
|
* LS.xp.add(amount, source) — sync, обновляет localStorage и ставит в очередь
|
||||||
|
* LS.xp.flush() — force-send очереди
|
||||||
|
* LS.xp.on('change', fn) — подписка на изменения
|
||||||
|
* LS.xp.off('change', fn) — отписка
|
||||||
|
* LS.xp.level(xp) — floor(sqrt(xp/100)) + 1
|
||||||
|
* LS.xp.xpForLevel(lv) — (lv-1)^2 * 100
|
||||||
|
*/
|
||||||
|
(function () {
|
||||||
|
const LS_KEY = 'algebra8_xp';
|
||||||
|
const ENDPOINT_ME = '/api/gamification/me';
|
||||||
|
const ENDPOINT_AWARD = '/api/gamification/self-award';
|
||||||
|
const MAX_CHUNK = 50; // сервер принимает не более 50 XP за раз
|
||||||
|
const DEBOUNCE_MS = 300;
|
||||||
|
|
||||||
|
let _state = null; // { xp, level } от сервера
|
||||||
|
let _pending = 0; // накопленный несброшенный XP
|
||||||
|
let _debounceTimer = null;
|
||||||
|
const _listeners = { change: [] };
|
||||||
|
|
||||||
|
/* ── Формулы (идентичны серверному _shared.js) ── */
|
||||||
|
function level(xp) {
|
||||||
|
return Math.floor(Math.sqrt((xp || 0) / 100)) + 1;
|
||||||
|
}
|
||||||
|
function xpForLevel(lv) {
|
||||||
|
return (lv - 1) * (lv - 1) * 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Токен аутентификации ── */
|
||||||
|
function _token() {
|
||||||
|
return localStorage.getItem('ls_token') || '';
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Базовый fetch с авторизацией ── */
|
||||||
|
function _apiFetch(path, opts) {
|
||||||
|
// Если загружен api.js — используем его apiFetch через LS.api
|
||||||
|
if (window.LS && typeof window.LS.api === 'function') {
|
||||||
|
return window.LS.api(path, opts);
|
||||||
|
}
|
||||||
|
// Fallback: самостоятельный fetch
|
||||||
|
const token = _token();
|
||||||
|
const headers = { 'Content-Type': 'application/json' };
|
||||||
|
if (token) headers['Authorization'] = 'Bearer ' + token;
|
||||||
|
return fetch(path, Object.assign({}, opts, { headers })).then(function (res) {
|
||||||
|
if (!res.ok) return res.json().catch(function () { return {}; }).then(function (d) {
|
||||||
|
throw Object.assign(new Error(d.error || 'Request failed'), { status: res.status });
|
||||||
|
});
|
||||||
|
return res.json();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Событийная модель ── */
|
||||||
|
function on(evt, fn) {
|
||||||
|
if (_listeners[evt]) _listeners[evt].push(fn);
|
||||||
|
}
|
||||||
|
function off(evt, fn) {
|
||||||
|
if (_listeners[evt]) _listeners[evt] = _listeners[evt].filter(function (f) { return f !== fn; });
|
||||||
|
}
|
||||||
|
function _emit(evt, data) {
|
||||||
|
(_listeners[evt] || []).forEach(function (fn) { try { fn(data); } catch (e) {} });
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Чтение/запись localStorage ── */
|
||||||
|
function _localXp() {
|
||||||
|
return +(localStorage.getItem(LS_KEY) || 0) || 0;
|
||||||
|
}
|
||||||
|
function _saveLocal(xp) {
|
||||||
|
try { localStorage.setItem(LS_KEY, String(xp)); } catch (e) {}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Загрузка с сервера + merge ── */
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
const data = await _apiFetch(ENDPOINT_ME, { method: 'GET' });
|
||||||
|
const serverXp = data.xp || 0;
|
||||||
|
const localXp = _localXp();
|
||||||
|
// Берём максимум — защита от потери прогресса при оффлайн-работе
|
||||||
|
const merged = Math.max(serverXp, localXp);
|
||||||
|
_saveLocal(merged);
|
||||||
|
_state = { xp: merged, level: level(merged) };
|
||||||
|
_emit('change', _state);
|
||||||
|
return _state;
|
||||||
|
} catch (e) {
|
||||||
|
if (e && e.status === 401) return null; // не авторизован — тихо пропускаем
|
||||||
|
return null; // сетевая ошибка — не ломаем учебник
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Отправка накопленного XP на сервер ── */
|
||||||
|
async function _sendPending(amount, source) {
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
try {
|
||||||
|
// Разбиваем на чанки не более MAX_CHUNK
|
||||||
|
let remaining = amount;
|
||||||
|
let lastInfo = null;
|
||||||
|
while (remaining > 0) {
|
||||||
|
const chunk = Math.min(remaining, MAX_CHUNK);
|
||||||
|
remaining -= chunk;
|
||||||
|
lastInfo = await _apiFetch(ENDPOINT_AWARD, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ amount: chunk, source: source }),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
if (lastInfo) {
|
||||||
|
const sxp = lastInfo.xp || 0;
|
||||||
|
const localXp = _localXp();
|
||||||
|
// Если локальный XP выше (юзер продолжал накапливать пока шёл запрос) — не откатываем
|
||||||
|
const merged = Math.max(sxp, localXp);
|
||||||
|
_saveLocal(merged);
|
||||||
|
_state = { xp: merged, level: level(merged) };
|
||||||
|
_emit('change', _state);
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
// Не авторизован или недоступен сервер — XP уже сохранён локально, не трогаем
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── flush: немедленная отправка очереди ── */
|
||||||
|
function flush() {
|
||||||
|
if (_debounceTimer) { clearTimeout(_debounceTimer); _debounceTimer = null; }
|
||||||
|
if (_pending <= 0) return;
|
||||||
|
const amount = _pending;
|
||||||
|
_pending = 0;
|
||||||
|
// source для flush — generic, реальный source записывается в каждом add()
|
||||||
|
// Flush вызывается при unload, поэтому используем sendBeacon если возможно
|
||||||
|
if (navigator.sendBeacon) {
|
||||||
|
const token = _token();
|
||||||
|
const headers = { type: 'application/json' };
|
||||||
|
const body = JSON.stringify({ amount: Math.min(amount, MAX_CHUNK), source: 'flush' });
|
||||||
|
// sendBeacon не поддерживает заголовки Authorization — используем token в теле не допускается
|
||||||
|
// Вместо этого используем fetch с keepalive (если нет sendBeacon fallback)
|
||||||
|
// Примечание: sendBeacon с кастомными заголовками не работает в браузерах,
|
||||||
|
// используем keepalive fetch вместо beacon для корректной авторизации
|
||||||
|
_apiFetch(ENDPOINT_AWARD, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ amount: Math.min(amount, MAX_CHUNK), source: 'flush' }),
|
||||||
|
keepalive: true,
|
||||||
|
}).catch(function () {});
|
||||||
|
} else {
|
||||||
|
_apiFetch(ENDPOINT_AWARD, {
|
||||||
|
method: 'POST',
|
||||||
|
body: JSON.stringify({ amount: Math.min(amount, MAX_CHUNK), source: 'flush' }),
|
||||||
|
}).catch(function () {});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── add: sync обновление localStorage + постановка в очередь ── */
|
||||||
|
function add(amount, source) {
|
||||||
|
if (!amount || amount <= 0) return;
|
||||||
|
// Немедленно обновляем локальный XP
|
||||||
|
const newXp = _localXp() + amount;
|
||||||
|
_saveLocal(newXp);
|
||||||
|
if (_state) {
|
||||||
|
_state = { xp: newXp, level: level(newXp) };
|
||||||
|
_emit('change', _state);
|
||||||
|
}
|
||||||
|
// Накапливаем для отправки
|
||||||
|
_pending += amount;
|
||||||
|
const src = (source || 'misc').replace(/[^a-z0-9_-]/gi, '-').substring(0, 60);
|
||||||
|
// Сбрасываем дебаунс и устанавливаем новый
|
||||||
|
if (_debounceTimer) clearTimeout(_debounceTimer);
|
||||||
|
_debounceTimer = setTimeout(function () {
|
||||||
|
_debounceTimer = null;
|
||||||
|
const toSend = _pending;
|
||||||
|
_pending = 0;
|
||||||
|
_sendPending(toSend, src);
|
||||||
|
}, DEBOUNCE_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Flush при закрытии страницы ── */
|
||||||
|
window.addEventListener('beforeunload', function () { flush(); });
|
||||||
|
document.addEventListener('visibilitychange', function () {
|
||||||
|
if (document.visibilityState === 'hidden') flush();
|
||||||
|
});
|
||||||
|
|
||||||
|
/* ── Публичный интерфейс ── */
|
||||||
|
window.LS = window.LS || {};
|
||||||
|
window.LS.xp = { load: load, add: add, flush: flush, on: on, off: off, level: level, xpForLevel: xpForLevel };
|
||||||
|
})();
|
||||||
Reference in New Issue
Block a user