Files
lolskin/packages/app/ui/renderer.js
T

392 lines
15 KiB
JavaScript

"use strict";
const $ = (id) => document.getElementById(id);
const esc = (s) =>
String(s ?? "").replace(/&/g, "&amp;").replace(/"/g, "&quot;").replace(/</g, "&lt;");
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);