"use strict"; const $ = (id) => document.getElementById(id); const esc = (s) => String(s ?? "").replace(/&/g, "&").replace(/"/g, """).replace(/ "/api/img?path=" + encodeURIComponent(p); let champions = []; let query = ""; let currentChampionId = 0; let openedChampionId = null; let detail = null; let activeSkinIdx = 0; let activeChromaIdx = -1; let autoOpenedFor = 0; let selectedSkinId = 0; let requestSerial = 0; let loadingChampions = false; let pollingCurrent = false; let wasConnected = false; let isApplying = false; async function api(path, opts) { const r = await fetch(path, opts); const j = await r.json().catch(() => ({})); if (!r.ok) throw new Error(j.error || `HTTP ${r.status}`); return j; } let toastTimer = null; function toast(msg, err) { const el = $("toast"); el.textContent = msg; el.className = "toast show" + (err ? " err" : ""); clearTimeout(toastTimer); toastTimer = setTimeout(() => (el.className = "toast"), 2800); } /** 显示连接和注入状态,并在客户端重新连接时刷新英雄。 */ async function refreshStatus() { try { const s = await api("/api/status"); const connected = s.lcu === "connected"; $("status").className = "status " + (connected ? "ok" : "bad"); $("connectionLabel").textContent = connected ? "客户端已连接" : "客户端未连接"; $("profileLabel").textContent = `${s.profile.server} / ${s.profile.injectionMode}`; $("injectionStatus").textContent = s.injecting ? "注入运行中" : "注入未运行"; $("injectionStatus").className = s.injecting ? "ok" : ""; $("packageCount").textContent = `皮肤包 ${s.skins}`; if (connected && !wasConnected && !champions.length) void loadChampions(); wasConnected = connected; } catch { $("status").className = "status bad"; $("connectionLabel").textContent = "状态获取失败"; wasConnected = false; } } /** 加载英雄;断线重连和手动刷新使用同一个入口。 */ async function loadChampions() { if (loadingChampions) return; loadingChampions = true; const grid = $("champions"); grid.setAttribute("aria-busy", "true"); $("reload").disabled = true; if (!champions.length) grid.innerHTML = '
正在加载英雄…
'; try { champions = await api("/api/champions"); renderChampGrid(); } catch (e) { if (!champions.length) grid.innerHTML = `
${esc(e.message)}
`; else toast(e.message, true); } finally { loadingChampions = false; grid.setAttribute("aria-busy", "false"); $("reload").disabled = false; } } function matchChamp(c, q) { const s = q.toLowerCase(); return ( (c.name || "").toLowerCase().includes(s) || (c.description || "").toLowerCase().includes(s) || (c.alias || "").toLowerCase().includes(s) ); } /** 重绘英雄列表时保留滚动和键盘焦点。 */ function renderChampGrid() { const grid = $("champions"); const list = champions.filter((c) => !query || matchChamp(c, query)); const focusedId = grid.contains(document.activeElement) ? document.activeElement.dataset.id : null; const scrollTop = grid.scrollTop; $("championCount").textContent = query ? `${list.length} / ${champions.length}` : `${champions.length} 位`; grid.innerHTML = ""; for (const c of list) { const button = document.createElement("button"); button.type = "button"; button.dataset.id = String(c.id); button.className = "champ" + (c.id === openedChampionId ? " active" : ""); button.setAttribute("aria-pressed", String(c.id === openedChampionId)); button.title = c.description || c.name; button.innerHTML = ` ${c.id === currentChampionId ? '当前' : ""} ${esc(c.description || c.name)}`; button.onclick = () => openChampion(c.id); grid.appendChild(button); } if (!list.length) grid.innerHTML = '
没有匹配的英雄
'; grid.scrollTop = scrollTop; if (focusedId) grid.querySelector(`[data-id="${focusedId}"]`)?.focus({ preventScroll: true }); } /** 后发的英雄选择胜出,避免旧皮肤请求覆盖当前预览。 */ async function openChampion(id) { const serial = ++requestSerial; openedChampionId = id; detail = null; activeSkinIdx = 0; activeChromaIdx = -1; renderChampGrid(); const el = $("detail"); el.innerHTML = '
正在加载皮肤…
'; el.scrollTop = 0; try { const loaded = await api("/api/skins?id=" + id); if (serial !== requestSerial) return false; detail = loaded; renderDetail(); return true; } catch (e) { if (serial !== requestSerial) return false; el.innerHTML = `
${esc(e.message)}
`; return false; } } /** 只有 LCU 选择与当前预览不同时才更新皮肤和炫彩。 */ function locateSelected(skinId) { if (!detail || !skinId) return false; for (let i = 0; i < detail.skins.length; i++) { const skin = detail.skins[i]; if (skin.id === skinId) { const changed = activeSkinIdx !== i || activeChromaIdx !== -1; activeSkinIdx = i; activeChromaIdx = -1; return changed; } const ci = (skin.chromas || []).findIndex((ch) => ch.id === skinId); if (ci >= 0) { const changed = activeSkinIdx !== i || activeChromaIdx !== ci; activeSkinIdx = i; activeChromaIdx = ci; return changed; } } return false; } /** 展示大图、炫彩与皮肤列表;选择变化时保留当前滚动位置。 */ function renderDetail() { const el = $("detail"); if (!detail) return; const skin = detail.skins[activeSkinIdx]; if (!skin) { el.innerHTML = '
该英雄暂无皮肤数据
'; return; } const chroma = activeChromaIdx >= 0 ? skin.chromas[activeChromaIdx] : null; const applyName = chroma ? chroma.name : skin.name; const owned = skin.ownership && skin.ownership.owned; const focused = el.contains(document.activeElement) ? document.activeElement : null; const focusSelector = focused?.classList.contains("skin-row") ? `.skin-row[data-idx="${focused.dataset.idx}"]` : focused?.classList.contains("dot") ? `.dot[data-ci="${focused.dataset.ci}"]` : focused?.id === "applyBtn" ? "#applyBtn" : null; const scrollTop = el.scrollTop; const chromas = skin.chromas || []; const dots = [ ``, ...chromas.map((ch, ci) => { const color = ch.colors && ch.colors[0]; const safeColor = /^#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})$/i.test(color) ? color : "#606774"; return ``; }), ].join(""); const chromaPreview = chroma ? `
${esc(chroma.name)}
` : ""; const rows = detail.skins.map((s, i) => { const oc = s.ownership && s.ownership.owned; const count = (s.chromas || []).length; return ``; }).join(""); el.innerHTML = `

${esc(detail.name)}

${detail.skins.length} 款皮肤
${owned ? "已拥有" : "未拥有"}

${esc(applyName)}

${esc(detail.name)}
${esc(applyName)}${skin.isBase ? "默认外观" : chroma ? "炫彩皮肤" : "皮肤"}
${chromas.length ? `
炫彩 ${chromas.length} 款
${dots}
${chromaPreview}
` : ""}

皮肤收藏

${detail.skins.length} 款
${rows}
`; if (!skin.isBase) $("applyBtn").onclick = () => applyCurrent(chroma ? chroma.id : skin.id, applyName); el.querySelectorAll(".dot").forEach((button) => { button.onclick = () => { activeChromaIdx = Number(button.dataset.ci); renderDetail(); }; }); el.querySelectorAll(".skin-row").forEach((button) => { button.onclick = () => { activeSkinIdx = Number(button.dataset.idx); activeChromaIdx = -1; renderDetail(); }; }); el.scrollTop = scrollTop; if (focusSelector) el.querySelector(focusSelector)?.focus({ preventScroll: true }); } /** 应用时锁住重复提交,并固定此次请求对应的英雄和皮肤。 */ async function applyCurrent(applyId, name) { if (isApplying || !detail) return; isApplying = true; const heroId = openedChampionId; const skinId = detail.skins[activeSkinIdx].id; const btn = $("applyBtn"); btn.disabled = true; btn.textContent = "应用中…"; try { const r = await api("/api/apply", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ heroId, skinId, applyId }), }); toast(`已应用:${name}${r.selected ? "(已在英雄选择中选中)" : ""}`); refreshStatus(); } catch (e) { toast(e.message, true); } finally { isApplying = false; if (detail) renderDetail(); } } /** 跟随游戏中的英雄与皮肤,但不覆盖用户正在浏览的其他选择。 */ async function pollCurrent() { if (pollingCurrent) return; pollingCurrent = true; try { const cur = await api("/api/current"); const cid = Number(cur.championId) || 0; const sid = Number(cur.selectedSkinId) || 0; if (cid !== currentChampionId) { currentChampionId = cid; if (!cid) autoOpenedFor = 0; $("current").disabled = !cid; renderChampGrid(); } const selectedChanged = sid !== selectedSkinId; selectedSkinId = sid; if (!$("follow").checked || !cid) return; if (autoOpenedFor !== cid) { autoOpenedFor = cid; const opened = await openChampion(cid); if (opened && sid && locateSelected(sid)) renderDetail(); if (opened) $("champions").querySelector(".champ.active")?.scrollIntoView({ block: "nearest" }); } else if (cid === openedChampionId && selectedChanged && locateSelected(sid)) { renderDetail(); } } catch { // 客户端退出时 /api/current 会由服务端恢复为未选择状态。 } finally { pollingCurrent = false; } } /** 手动跳转到客户端当前英雄时,清除筛选并同步其已选皮肤。 */ async function showCurrentChampion() { if (!currentChampionId) return; query = ""; $("search").value = ""; const cid = currentChampionId; autoOpenedFor = cid; const opened = await openChampion(cid); if (opened && selectedSkinId && locateSelected(selectedSkinId)) renderDetail(); if (opened) $("champions").querySelector(".champ.active")?.scrollIntoView({ block: "nearest" }); } $("search").addEventListener("input", (e) => { query = e.target.value.trim(); renderChampGrid(); $("champions").scrollTop = 0; }); $("current").onclick = showCurrentChampion; $("reload").onclick = loadChampions; $("follow").onchange = () => { if ($("follow").checked) { autoOpenedFor = 0; void pollCurrent(); } }; $("update").onclick = async (e) => { const btn = e.currentTarget; const label = btn.textContent; btn.disabled = true; btn.textContent = "处理中…"; try { const r = await api("/api/update", { method: "POST" }); const last = (r.stdout || "") .trim() .split("\n") .filter(Boolean) .pop() || ""; toast(r.code === 0 ? `更新完成:${last}` : `更新未完成(code ${r.code})`, r.code !== 0); refreshStatus(); } catch (err) { toast(err.message, true); } finally { btn.disabled = false; btn.textContent = label; } }; $("clean").onclick = async () => { const btn = $("clean"); btn.disabled = true; try { await api("/api/clean", { method: "POST" }); toast("已清理残留"); refreshStatus(); } catch (e) { toast(e.message, true); } finally { btn.disabled = false; } }; // 新侧栏的英雄卡片尺寸单独记忆,避免旧的大尺寸撑破布局。 const SIZES_KEY = "lolskin.champSize.v2"; /** 更新卡片密度和分段控件的可访问状态。 */ function applySize(px) { document.documentElement.style.setProperty("--champ-min", px + "px"); document.querySelectorAll("#sizes button").forEach((button) => { const active = Number(button.dataset.size) === px; button.classList.toggle("on", active); button.setAttribute("aria-pressed", String(active)); }); try { localStorage.setItem(SIZES_KEY, String(px)); } catch { // 无本地存储权限时仍可调整本次界面。 } } document.querySelectorAll("#sizes button").forEach((button) => { button.onclick = () => applySize(Number(button.dataset.size)); }); let savedSize = 92; try { const stored = Number(localStorage.getItem(SIZES_KEY)); if ([72, 92, 120].includes(stored)) savedSize = stored; } catch { // 使用默认密度。 } applySize(savedSize); refreshStatus(); loadChampions(); pollCurrent(); setInterval(refreshStatus, 5000); setInterval(pollCurrent, 3000);