/* ============================================= app.js — 核心:API / 路由 / Toast 后端各接口独立路由:/api/?params 图片代理:/api/img?path= 英雄头像:/api/heroicon?id= ============================================= */ window.APP = { summonerId: null, summonerName: null, puuid: null }; // ── API 封装 ────────────────────────────────── const API = { async get(path, params = {}) { const q = Object.keys(params).length ? '?' + new URLSearchParams(params).toString() : ''; const r = await fetch('/api/' + path + q); if (!r.ok) throw new Error(r.status); return r.json(); }, img(lcuPath) { if (!lcuPath) return ''; return '/api/img?path=' + encodeURIComponent(lcuPath); }, }; // ── sessionStorage 缓存 ─────────────────────── const Cache = { get(k) { try { return JSON.parse(sessionStorage.getItem(k)); } catch{ return null; } }, set(k, v) { try { sessionStorage.setItem(k, JSON.stringify(v)); } catch{} }, }; // ── Toast ───────────────────────────────────── function showToast(msg, type = '') { const container = document.getElementById('toast-container'); if (!container) return; const icon = type === 'err' ? 'i' : '✓'; const el = document.createElement('div'); el.className = 'toast' + (type ? ' ' + type : ''); el.innerHTML = `
${icon}
${msg}
`; container.appendChild(el); setTimeout(() => { el.classList.add('leaving'); setTimeout(() => { el.remove(); }, 300); }, 2600); } // ── 加载中 Toast(旋转动画,手动关闭)──────── let _loadingToastEl = null; function showLoadingToast(msg) { const container = document.getElementById('toast-container'); if (!container) return; if (_loadingToastEl) _loadingToastEl.remove(); const el = document.createElement('div'); el.className = 'toast toast-loading'; el.innerHTML = `
${msg}
`; container.appendChild(el); _loadingToastEl = el; } function hideLoadingToast() { if (_loadingToastEl) { _loadingToastEl.classList.add('leaving'); setTimeout(() => { if(_loadingToastEl){_loadingToastEl.remove(); _loadingToastEl = null;} }, 300); } } // ── HTML 转义 ───────────────────────────────── function esc(s) { return String(s ?? '').replace(/&/g,'&').replace(/"/g,'"').replace(//g,'>'); } // ── 页面路由 ────────────────────────────────── function navigate(page) { document.querySelectorAll('.page').forEach(p => { p.classList.remove('active'); // 离开皮肤库时清空内容,避免布局残留 if (p.id === 'page-skins' && page !== 'skins') { p.innerHTML = ''; } // 离开美化库时清空内容 if (p.id === 'page-beauty' && page !== 'beauty') { p.innerHTML = ''; } }); document.querySelectorAll('.nav-item').forEach(n => n.classList.remove('active')); document.getElementById('page-' + page).classList.add('active'); document.querySelector(`.nav-item[data-page="${page}"]`).classList.add('active'); if (page === 'skins') renderSkinLib(); if (page === 'beauty') renderBeauty(); if (page === 'tools') renderTools(); if (page === 'settings') renderSettings(); } function renderTools() { // 由 tools.js 实现 } // ── 美化库 ──────────────────────────────────── const BEAUTY_CATS = [ { key: 'xt', label: '人物血条', icon: ``, color: '#ff4d4f', bg: '#fff1f0', mod: 'XongMao_xt' }, { key: 'zz', label: '鼠标指针', icon: ``, color: '#1677ff', bg: '#e6f4ff', mod: 'XongMao_zz' }, { key: 'jm', label: '操作界面', icon: ``, color: '#722ed1', bg: '#f9f0ff', mod: 'XongMao_jm' }, { key: 'yy', label: '人物语音', icon: ``, color: '#fa8c16', bg: '#fff7e6', mod: 'XongMao_yy' }, { key: 'zt', label: '游戏字体', icon: ``, color: '#13c2c2', bg: '#e6fffb', mod: 'XongMao_zt' }, { key: 'dt', label: '游戏地图', icon: ``, color: '#52c41a', bg: '#f6ffed', mod: 'XongMao_dt' }, ]; // 每个分类资源,name为显示昵称,img为图片文件名 const BEAUTY_ITEMS = { dt: [ { id:'dt_1', name:'夜晚地图', img:'dt_1.jpg' }, { id:'dt_2', name:'白天地图', img:'dt_2.jpg' }, { id:'dt_3', name:'沙滩地图', img:'dt_3.jpg' }, { id:'dt_4', name:'冬季地图', img:'dt_4.jpg' }, { id:'dt_5', name:'诅咒地图', img:'dt_5.jpg' }, ], jm: [ { id:'jm_1', name:'最强王者', img:'jm_1.jpg' }, { id:'jm_2', name:'少女人体', img:'jm_2.jpg' }, { id:'jm_3', name:'猫娘乐园', img:'jm_3.jpg' }, { id:'jm_4', name:'洛菲女孩', img:'jm_4.jpg' }, { id:'jm_5', name:'雷姆UI', img:'jm_5.jpg' }, { id:'jm_6', name:'雷电将军', img:'jm_6.jpg' }, { id:'jm_7', name:'科技崩坏', img:'jm_7.jpg' }, { id:'jm_8', name:'古拉小鲨鱼', img:'jm_8.jpg' }, { id:'jm_9', name:'博雷阿苏', img:'jm_9.jpg' }, { id:'jm_10', name:'新年春节', img:'jm_10.jpg' }, ], xt: [ { id:'xt_1', name:'可爱粉色血条', img:'xt_1.jpg' }, { id:'xt_2', name:'冰蓝条纹血条', img:'xt_2.jpg' }, { id:'xt_3', name:'黄色条纹血条', img:'xt_3.jpg' }, { id:'xt_4', name:'蓝色条纹血条', img:'xt_4.jpg' }, { id:'xt_5', name:'绿色条纹血条', img:'xt_5.jpg' }, ], yy: [ { id:'yy_1', name:'俄美 语音', img:'yy_1.jpg' }, { id:'yy_2', name:'韩国 语音', img:'yy_2.jpg' }, { id:'yy_3', name:'美国 语音', img:'yy_3.jpg' }, { id:'yy_4', name:'日本 语音', img:'yy_4.jpg' }, ], zt: [ { id:'zt_1', name:'方正粗圆简体', img:'zt_1.jpg' }, { id:'zt_2', name:'方正剪纸简体', img:'zt_2.jpg' }, { id:'zt_3', name:'方正卡通简体', img:'zt_3.jpg' }, { id:'zt_4', name:'方正瞄呜体', img:'zt_4.jpg' }, { id:'zt_5', name:'方正稚艺字体', img:'zt_5.jpg' }, { id:'zt_6', name:'华康少女文字体', img:'zt_6.jpg' }, ], zz: [ { id:'zz_1', name:'彩色指针', img:'zz_1.jpg' }, { id:'zz_2', name:'奥特曼', img:'zz_2.jpg' }, { id:'zz_3', name:'哆啦A梦', img:'zz_3.jpg' }, { id:'zz_4', name:'多啦美', img:'zz_4.jpg' }, { id:'zz_5', name:'小呆龙', img:'zz_5.jpg' }, { id:'zz_6', name:'刀光剑神', img:'zz_6.jpg' }, { id:'zz_7', name:'彩色星星', img:'zz_7.jpg' }, { id:'zz_8', name:'红色手指', img:'zz_8.jpg' }, ], }; let _beautyCurCat = 'xt'; let _beautyResReady = false; // 图片资源是否已下载 async function renderBeauty() { const page = document.getElementById('page-beauty'); // 先检查资源是否存在 if (!_beautyResReady) { try { const res = await API.get('checkBeautyRes'); if (res.exists) { _beautyResReady = true; } } catch(e) { /* 检查失败当作不存在 */ } } // 资源不存在:显示下载提示页 if (!_beautyResReady) { page.innerHTML = `
📦
美化资源未下载
需要先下载美化图片资源包才能使用
`; return; } // 资源存在:正常显示 page.innerHTML = `
${BEAUTY_CATS.map(c => `
${c.icon} ${c.label}
`).join('')}
共有 ${BEAUTY_ITEMS[_beautyCurCat]?.length ?? 0} 个资源
`; renderBeautyGrid(_beautyCurCat); } // 下载美化资源包 async function downloadBeautyRes() { const btn = document.querySelector('[onclick="downloadBeautyRes()"]'); const tip = document.getElementById('beautyResProgress'); if (btn) btn.disabled = true; if (tip) tip.textContent = '正在获取下载地址...'; try { const res = await API.get('downloadBeautyRes'); if (!res.ok) { if (tip) tip.textContent = '获取下载地址失败,请检查网络'; if (btn) btn.disabled = false; return; } if (tip) tip.textContent = '开始下载...'; // 轮询下载进度 await pollBeautyResProgress(); // 下载完成,重新渲染 _beautyResReady = true; renderBeauty(); } catch(e) { if (tip) tip.textContent = '下载失败,请重试'; if (btn) btn.disabled = false; } } // 轮询资源包下载进度 async function pollBeautyResProgress() { return new Promise((resolve) => { const timer = setInterval(async () => { try { const p = await API.get('downloadProgress'); const status = parseInt(p.status ?? 0); const tip = document.getElementById('beautyResProgress'); if (status === 3) { clearInterval(timer); if (tip) tip.textContent = '下载完成,正在解压...'; // 等待后端解压完成 await new Promise(r => setTimeout(r, 3000)); resolve(); } else if (status === 1 || status === 2) { const pct = parseFloat(p.progress || 0).toFixed(0); const spd = p.speed || ''; if (tip) tip.textContent = `正在下载 ${pct}% ${spd}`; } } catch(e) { clearInterval(timer); resolve(); } }, 600); }); } function switchBeautyCat(key) { _beautyCurCat = key; document.querySelectorAll('.beauty-tab').forEach(el => { const k = el.dataset.cat; const c = BEAUTY_CATS_MAP.get(k); if (k === key) { el.classList.add('active'); el.style.background = c.bg; el.style.borderColor = c.color; el.style.color = c.color; } else { el.classList.remove('active'); el.style.background = el.style.borderColor = el.style.color = ''; } }); renderBeautyGrid(key); } function renderBeautyGrid(key) { const grid = document.getElementById('beautyGrid'); if (!grid) return; const items = BEAUTY_ITEMS[key] || []; const numEl = document.getElementById('beautyTotalNum'); if (numEl) numEl.textContent = items.length; const isWide = key === 'jm'; grid.className = 'beauty-grid' + (isWide ? ' beauty-grid-wide' : ''); grid.innerHTML = items.map((item, i) => `
${esc(item.name)}
📦 双击下载
${esc(item.name)}
`).join(''); // 用 addEventListener 绑定双击,避免 ondblclick 内联失效 grid.querySelectorAll('.beauty-card').forEach(card => { card.addEventListener('dblclick', () => { downloadBeautyItem(card.dataset.cat, card.dataset.idx, card.dataset.name); }); }); } async function applyBeautyItem() {} // 已废弃 - 保留空函数避免调用报错 // ── 美化库分类 Map(O(1)查找)──────────────── const BEAUTY_CATS_MAP = new Map(BEAUTY_CATS.map(c => [c.key, c])); // ── 美化圆形进度弹窗 ───────────────────────── const RING_LEN = 452.39; // 2 * Math.PI * 72 function showBeautyProgress(imgSrc, label) { const modal = document.getElementById('beauty-progress-modal'); const img = document.getElementById('beauty-progress-img'); const lbl = document.getElementById('beauty-progress-label'); const pct = document.getElementById('beauty-progress-pct'); const bar = document.getElementById('beauty-progress-bar'); const spd = document.getElementById('beauty-progress-speed'); if (!modal) return; if (imgSrc) img.src = imgSrc; lbl.textContent = label; pct.textContent = '0%'; if (spd) spd.textContent = ''; bar.style.strokeDashoffset = RING_LEN; modal.classList.remove('hidden'); } function updateBeautyProgress(percent, label, speed) { const lbl = document.getElementById('beauty-progress-label'); const pct = document.getElementById('beauty-progress-pct'); const bar = document.getElementById('beauty-progress-bar'); const spd = document.getElementById('beauty-progress-speed'); if (!bar) return; const p = Math.min(Math.max(parseFloat(percent) || 0, 0), 100); bar.style.strokeDashoffset = RING_LEN * (1 - p / 100); pct.textContent = p.toFixed(0) + '%'; if (label) lbl.textContent = label; if (spd && speed !== undefined) spd.textContent = speed; } function hideBeautyProgress() { const modal = document.getElementById('beauty-progress-modal'); if (modal) modal.classList.add('hidden'); } async function downloadBeautyItem(catKey, idx, name) { idx = parseInt(idx); const card = document.querySelector(`.beauty-card[data-idx="${idx}"]`); const imgSrc = card?.querySelector('img')?.src || ''; showBeautyProgress(imgSrc, `正在处理:${name}`); try { const res = await API.get('downloadBeauty', { catKey, idx }); if (!res.ok) { hideBeautyProgress(); showToast('操作失败', 'err'); return; } if (res.cached) { // 先用Promise封装假进度动画,跑完再挂载 await new Promise(resolve => { let p = 0; function step() { p += 3 + Math.random() * 5; if (p >= 95) { p = 95; updateBeautyProgress(95, `正在处理:${name}`, ''); resolve(); return; } updateBeautyProgress(p, `正在处理:${name}`, ''); setTimeout(step, 60); } step(); }); updateBeautyProgress(98, `正在挂载:${name}`, ''); try { await API.get('applyUI', { catKey }); updateBeautyProgress(100, `已应用:${name}`, ''); await new Promise(r => setTimeout(r, 500)); hideBeautyProgress(); showToast(`✓ 已应用:${name}`, 'ok'); } catch(e) { hideBeautyProgress(); showToast('挂载失败', 'err'); } return; } await new Promise(r => setTimeout(r, 400)); await pollDownloadProgress(name, catKey); } catch(e) { hideBeautyProgress(); showToast('操作失败,请检查网络', 'err'); } } async function pollDownloadProgress(name, catKey) { return new Promise((resolve) => { let waitCount = 0; let started = false; let fakeTimer = null; let fakePct = 0; // 假进度动画:在真实进度来之前先跑动画 function startFakeProgress() { fakePct = 0; fakeTimer = setInterval(() => { if (fakePct < 85) { fakePct += Math.random() * 8 + 2; if (fakePct > 85) fakePct = 85; updateBeautyProgress(fakePct, `正在下载:${name}`, ''); } }, 200); } function stopFakeProgress() { if (fakeTimer) { clearInterval(fakeTimer); fakeTimer = null; } } const timer = setInterval(async () => { try { const p = await API.get('downloadProgress'); const status = parseInt(p.status ?? 0); const pct = parseFloat(p.progress || 0); // 后端已经是0~100 const speed = p.speed || ''; if (status === 1 || status === 2) { started = true; waitCount = 0; stopFakeProgress(); // 后端已经返回0~100的百分比,不需要再×100 const realPct = parseFloat(p.progress || 0); if (realPct > fakePct) { updateBeautyProgress(realPct, `正在下载:${name}`, speed); } return; } if (status === 3) { clearInterval(timer); stopFakeProgress(); updateBeautyProgress(100, `正在挂载:${name}`, ''); try { await API.get('applyUI', { catKey }); hideBeautyProgress(); showToast(`✓ 已应用:${name}`, 'ok'); } catch(e) { hideBeautyProgress(); showToast('挂载失败', 'err'); } resolve(); return; } // status=0 等待开始,启动假进度 if (!started) { waitCount++; if (waitCount === 1) startFakeProgress(); if (waitCount > 30) { clearInterval(timer); stopFakeProgress(); hideBeautyProgress(); showToast('下载超时,请重试', 'err'); resolve(); } } } catch(e) { clearInterval(timer); stopFakeProgress(); hideBeautyProgress(); showToast('获取进度失败', 'err'); resolve(); } }, 500); }); } // 以下函数已废弃,保留空函数避免潜在调用报错 async function restoreBeauty() {} async function clearAllBeauty() {} async function applyUIOnly() {} // ── 懒加载图片(IntersectionObserver)──────── let _lazyObserver = null; function initLazyObserver() { if (_lazyObserver) return; _lazyObserver = new IntersectionObserver((entries) => { entries.forEach(entry => { if (!entry.isIntersecting) return; const img = entry.target; _lazyObserver.unobserve(img); // 普通 data-src 懒加载 if (img.dataset.src) { img.src = img.dataset.src; img.removeAttribute('data-src'); } // 英雄头像异步加载 if (typeof img._loadIcon === 'function') { const fn = img._loadIcon; img._loadIcon = null; fn(img); } }); }, { rootMargin: '400px' }); } // 重置 observer(切换页面时调用) function resetLazyObserver() { if (_lazyObserver) { _lazyObserver.disconnect(); _lazyObserver = null; } } // 把懒加载图片注册到 Observer,并立刻触发已在视口内的图片 function observeLazy(container) { initLazyObserver(); container.querySelectorAll('img[data-src]').forEach(img => _lazyObserver.observe(img)); requestAnimationFrame(() => { container.querySelectorAll('img[data-src]').forEach(img => { const rect = img.getBoundingClientRect(); const inView = rect.top < window.innerHeight + 400 && rect.bottom > -400; if (inView) { _lazyObserver.unobserve(img); img.src = img.dataset.src; img.removeAttribute('data-src'); } }); }); } // ── 初始化 ──────────────────────────────────── async function initApp() { try { const s = await API.get('summoner'); APP.summonerId = s.summonerId; APP.summonerName = s.displayName || s.gameName || s.internalName || '--'; APP.puuid = s.puuid; APP.iconId = s.profileIconId ?? 29; APP.level = s.summonerLevel ?? '--'; const avatarEl = document.getElementById('sidebarAvatar'); const nameEl = document.getElementById('sidebarName'); const iconUrl = API.img(`/lol-game-data/assets/v1/profile-icons/${APP.iconId}.jpg`); if (avatarEl) avatarEl.src = iconUrl; if (nameEl) nameEl.textContent = APP.summonerName; navigate('skins'); // LOL 客户端连接成功后,启动英雄选择自动跳转监听 ChampSelectWatcher.start(); } catch(e) { // 未连接时停止监听,等待重连 ChampSelectWatcher.stop(); document.getElementById('page-skins').innerHTML = '

⏳ 正在等待 LOL 客户端启动...

'; setTimeout(initApp, 3000); } } // initApp 由登录成功后的 enterMainApp() 调用,不自动执行 // initApp(); // ── 用户信息弹出卡片 ────────────────────────── function toggleUserCard() { const card = document.getElementById('userCard'); if (!card) return; const isHidden = card.classList.contains('hidden'); if (isHidden) { const avatarEl = document.getElementById('userCardAvatar'); const nameEl = document.getElementById('userCardName'); const levelEl = document.getElementById('userCardLevel'); const accountEl = document.getElementById('userCardAccount'); const expireEl = document.getElementById('userCardExpire'); if (avatarEl) avatarEl.src = API.img(`/lol-game-data/assets/v1/profile-icons/${APP.iconId || 29}.jpg`); if (nameEl) nameEl.textContent = APP.summonerName || '--'; if (levelEl) levelEl.textContent = 'Lv ' + (APP.level || '--'); if (accountEl) accountEl.textContent = APP.authAccount || '--'; if (expireEl) expireEl.textContent = APP.authExpire || '--'; const trigger = document.getElementById('sidebarUser'); if (trigger) { const rect = trigger.getBoundingClientRect(); card.style.left = rect.left + 'px'; card.style.width = rect.width + 'px'; card.style.bottom = (window.innerHeight - rect.top + 8) + 'px'; card.style.top = 'auto'; } card.classList.remove('hidden'); setTimeout(() => document.addEventListener('click', closeUserCard, { once: true }), 0); } else { card.classList.add('hidden'); } } function closeUserCard() { document.getElementById('userCard')?.classList.add('hidden'); } // ── 主题切换 ────────────────────────────────── function toggleTheme() { const isDark = document.documentElement.getAttribute('data-theme') === 'dark'; const newTheme = isDark ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', newTheme); localStorage.setItem('theme', newTheme); const btn = document.getElementById('themeToggleBtn'); if (btn) btn.textContent = newTheme === 'dark' ? '🌙' : '☀️'; } // ── 英雄选择自动跳转 ────────────────────────── // 易语言后端定时轮询 LCU,检测到英雄变化后通过 // WebMessage 推送 {"命令":"跳转皮肤","英雄ID":xxx} // 前端只监听消息,不做任何 HTTP 轮询 // ───────────────────────────────────────────── const ChampSelectWatcher = (function () { let _lastChampId = 0; let _active = false; /* 收到后端 WebMessage 推送后处理跳转 */ async function _onMsg(data) { if (!_active) return; const cid = parseInt(data['英雄ID'] || 0); // 0 = 英雄选择结束,重置 if (cid === 0) { _lastChampId = 0; return; } // 同一英雄不重复跳转(防止后端反复推送同一ID) if (cid === _lastChampId) return; _lastChampId = cid; // 确保切换到皮肤库页 const skinsPage = document.getElementById('page-skins'); if (!skinsPage) return; if (!skinsPage.classList.contains('active')) { navigate('skins'); // 等待 navigate 执行完(包括 renderSkinLib) await new Promise(r => setTimeout(r, 100)); } // 从本地缓存找英雄名 let champName = '', champAlias = ''; const champs = Cache.get('champions_lcu') || []; const found = champs.find(c => parseInt(c.id || c.heroId) === cid); if (found) { champName = found.name || ''; champAlias = found.alias || ''; } await openChampSkins(cid, champName, champAlias); } /* 开启监听 */ function start() { if (_active) return; _active = true; // 直接监听 WebView2 原始消息事件(兼容后端推送) if (window.chrome && window.chrome.webview) { window.chrome.webview.addEventListener('message', _rawHandler); } // 同时通过 Bridge 监听(双保险) if (window.Bridge) { Bridge.on('跳转皮肤', _onMsg); } // 前端自己轮询 session 接口(后端线程 LCU 可能有连接问题) _startFrontendPoller(); } /* 前端轮询:直接请求 LCU session,自己解析 championPickIntent */ let _pollerTimer = null; let _pollerLastId = 0; function _startFrontendPoller() { if (_pollerTimer) return; _pollerTimer = setInterval(async () => { if (!_active) return; try { const r = await fetch('/api/champsession'); if (!r.ok) { _pollerLastId = 0; return; } const session = await r.json(); // 从 myTeam 里找 championPickIntent(悬停)或 championId(锁定) let cid = 0; const myTeam = session.myTeam || []; for (const p of myTeam) { if (p.championPickIntent && p.championPickIntent > 0) { cid = p.championPickIntent; break; } if (p.championId && p.championId > 0) { cid = p.championId; break; } } console.log('[Poller] myTeam长度:', myTeam.length, '找到cid:', cid); if (cid > 0 && cid !== _pollerLastId) { _pollerLastId = cid; _onMsg({ '命令': '跳转皮肤', '英雄ID': cid }); } else if (cid === 0) { _pollerLastId = 0; } } catch(e) { /* 静默失败 */ } }, 300); } /* 原始消息处理(直接挂在 webview 上,不经过 Bridge 过滤)*/ function _rawHandler(e) { try { const raw = e.data; const data = typeof raw === 'string' ? JSON.parse(raw) : raw; if (data && data['命令'] === '跳转皮肤') _onMsg(data); } catch(ex) { // 可能是其他命令的消息,忽略 } } /* 停止监听 */ function stop() { if (!_active) return; _active = false; _lastChampId = 0; } function reset() { _lastChampId = 0; } function isActive() { return _active; } return { start, stop, reset, isActive }; })();