392 lines
15 KiB
JavaScript
392 lines
15 KiB
JavaScript
"use strict";
|
|
|
|
const $ = (id) => document.getElementById(id);
|
|
const esc = (s) =>
|
|
String(s ?? "").replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
const img = (p) => "/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 = '<div class="placeholder"><div class="loading-mark"></div>正在加载英雄…</div>';
|
|
try {
|
|
champions = await api("/api/champions");
|
|
renderChampGrid();
|
|
} catch (e) {
|
|
if (!champions.length) grid.innerHTML = `<div class="placeholder">${esc(e.message)}</div>`;
|
|
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 ? '<span class="badge">当前</span>' : ""}
|
|
<img loading="lazy" src="${img(c.squarePortraitPath)}" alt="" onerror="this.style.visibility='hidden'">
|
|
<span class="cname">${esc(c.description || c.name)}</span>`;
|
|
button.onclick = () => openChampion(c.id);
|
|
grid.appendChild(button);
|
|
}
|
|
if (!list.length) grid.innerHTML = '<div class="placeholder">没有匹配的英雄</div>';
|
|
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 = '<div class="placeholder"><div class="loading-mark"></div>正在加载皮肤…</div>';
|
|
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 = `<div class="placeholder">${esc(e.message)}</div>`;
|
|
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 = '<div class="detail-empty">该英雄暂无皮肤数据</div>';
|
|
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 = [
|
|
`<button class="dot ${activeChromaIdx < 0 ? "active" : ""}" data-ci="-1" type="button" title="原画" aria-label="原画" aria-pressed="${activeChromaIdx < 0}"><span class="swatch"></span></button>`,
|
|
...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 `<button class="dot ${activeChromaIdx === ci ? "active" : ""}" data-ci="${ci}" type="button" title="${esc(ch.name)}" aria-label="${esc(ch.name)}" aria-pressed="${activeChromaIdx === ci}"><span class="swatch" style="--swatch:${safeColor}"></span></button>`;
|
|
}),
|
|
].join("");
|
|
const chromaPreview = chroma
|
|
? `<div class="chroma-preview"><img src="${img(chroma.chromaPath || skin.tilePath)}" alt="" onerror="this.style.visibility='hidden'"><span>${esc(chroma.name)}</span></div>`
|
|
: "";
|
|
const rows = detail.skins.map((s, i) => {
|
|
const oc = s.ownership && s.ownership.owned;
|
|
const count = (s.chromas || []).length;
|
|
return `<button class="skin-row ${i === activeSkinIdx ? "active" : ""}" data-idx="${i}" type="button" aria-pressed="${i === activeSkinIdx}" title="${esc(s.name)}">
|
|
<img loading="lazy" src="${img(s.tilePath)}" alt="" onerror="this.style.visibility='hidden'">
|
|
${i === activeSkinIdx ? '<span class="skin-selected" aria-hidden="true">✓</span>' : ""}
|
|
<span class="meta"><span class="n">${esc(s.name)}</span><span class="s">${oc ? "已拥有" : "未拥有"}${count ? ` · ${count} 款炫彩` : ""}</span></span>
|
|
</button>`;
|
|
}).join("");
|
|
|
|
el.innerHTML = `
|
|
<div class="detail-heading"><h2>${esc(detail.name)}</h2><span>${detail.skins.length} 款皮肤</span></div>
|
|
<div class="preview">
|
|
<div class="splash">
|
|
<img src="${img(skin.splashPath || skin.tilePath)}" alt="" onerror="this.style.visibility='hidden'">
|
|
<div class="grad"></div>
|
|
<div class="info"><span class="chip ${owned ? "own" : ""}">${owned ? "已拥有" : "未拥有"}</span><h3 class="sname">${esc(applyName)}</h3><span class="sdesc">${esc(detail.name)}</span></div>
|
|
</div>
|
|
<div class="preview-actions">
|
|
<div class="selection-info"><strong>${esc(applyName)}</strong><span>${skin.isBase ? "默认外观" : chroma ? "炫彩皮肤" : "皮肤"}</span></div>
|
|
<button id="applyBtn" type="button" ${skin.isBase || isApplying ? "disabled" : ""}>${skin.isBase ? "默认皮肤" : isApplying ? "应用中…" : "应用皮肤"}</button>
|
|
</div>
|
|
${chromas.length ? `<div class="chroma-section"><div class="chroma-heading">炫彩 <span>${chromas.length} 款</span></div><div class="chroma-bar">${dots}</div>${chromaPreview}</div>` : ""}
|
|
</div>
|
|
<div class="skin-section"><div class="skin-section-heading"><h3>皮肤收藏</h3><span>${detail.skins.length} 款</span></div><div class="skin-list">${rows}</div></div>`;
|
|
|
|
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);
|