bulid 63
This commit is contained in:
303
khl_site/web/index.html
Normal file
303
khl_site/web/index.html
Normal file
@@ -0,0 +1,303 @@
|
||||
<!doctype html>
|
||||
<html lang="ru">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||||
<title>KHL Data Center</title>
|
||||
<style>
|
||||
:root{
|
||||
color-scheme:dark;
|
||||
--bg:#080b11;
|
||||
--panel:#101620;
|
||||
--panel-2:#151d2a;
|
||||
--panel-3:#1a2433;
|
||||
--line:#293548;
|
||||
--line-soft:#202a39;
|
||||
--text:#edf3ff;
|
||||
--muted:#8e9bb0;
|
||||
--accent:#6aa8ff;
|
||||
--accent-strong:#3e8ef7;
|
||||
--good:#51d692;
|
||||
--warn:#f2bd68;
|
||||
--danger:#ff6f7e;
|
||||
--sticky:#111925;
|
||||
--sticky-alt:#151e2c;
|
||||
}
|
||||
*{box-sizing:border-box}
|
||||
html,body{min-height:100%}
|
||||
body{margin:0;font-family:Inter,Segoe UI,Arial,sans-serif;background:var(--bg);color:var(--text)}
|
||||
button,input,select{font:inherit}
|
||||
button,input,select{border:1px solid var(--line);border-radius:9px;background:var(--panel-2);color:var(--text)}
|
||||
button{padding:9px 13px;cursor:pointer}
|
||||
button:hover{border-color:var(--accent)}
|
||||
button:disabled{opacity:.45;cursor:default}
|
||||
input,select{padding:10px;width:100%}
|
||||
a{color:var(--accent)}
|
||||
header{height:66px;display:flex;align-items:center;justify-content:space-between;padding:0 18px;background:var(--panel);border-bottom:1px solid var(--line);position:sticky;top:0;z-index:30}
|
||||
.brand{font-size:19px;font-weight:850}.small{font-size:12px;color:var(--muted)}.actions{display:flex;gap:7px;flex-wrap:wrap;justify-content:flex-end}
|
||||
.layout{display:grid;grid-template-columns:390px minmax(0,1fr);min-height:calc(100vh - 66px)}
|
||||
aside{background:var(--panel);border-right:1px solid var(--line);min-width:0;position:sticky;top:66px;height:calc(100vh - 66px)}
|
||||
.tabs{display:flex;gap:6px;padding:12px}.tabs button{flex:1}.tabs button.active{background:var(--accent);color:#07111e;border-color:var(--accent);font-weight:850}
|
||||
.filters{padding:0 12px 12px;display:grid;gap:8px;border-bottom:1px solid var(--line)}.filter-row{display:grid;grid-template-columns:1fr 1fr;gap:8px}.list-head{padding:10px 13px;display:flex;justify-content:space-between}.list{height:calc(100vh - 284px);overflow:auto}.row{display:grid;grid-template-columns:48px minmax(0,1fr) auto;gap:10px;align-items:center;padding:10px 12px;border-top:1px solid rgba(43,53,72,.62);cursor:pointer}.row:hover{background:var(--panel-2)}.row.active{background:linear-gradient(90deg,rgba(106,168,255,.17),var(--panel-2));box-shadow:inset 3px 0 0 var(--accent)}.row.active .name{color:#fff}.avatar{width:46px;height:46px;border-radius:50%;object-fit:cover;background:#222c3c}.name{font-weight:780;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.name-en{font-size:11px;color:var(--muted);margin-top:2px;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}.badges{display:flex;gap:4px;flex-wrap:wrap;margin-top:4px}.badge{font-size:10px;padding:2px 6px;border-radius:999px;background:#28344a;color:#d2dcf0}.badge.good{color:var(--good);background:rgba(81,214,146,.12)}.pager{display:flex;gap:8px;align-items:center;justify-content:center;padding:10px;border-top:1px solid var(--line)}
|
||||
main{padding:18px;min-width:0;background:linear-gradient(180deg,#0b1018 0%,#090d14 100%)}.empty{min-height:75vh;display:grid;place-items:center;color:var(--muted);font-size:18px}
|
||||
.profile-card{background:linear-gradient(135deg,#121a27 0%,#0e141e 100%);border:1px solid var(--line);border-radius:15px;padding:24px;box-shadow:0 16px 40px rgba(0,0,0,.24);margin-bottom:18px}.profile-head{display:grid;grid-template-columns:158px minmax(0,1fr);gap:24px;align-items:center}.profile-photo-wrap{display:flex;flex-direction:column;align-items:center;gap:11px}.profile-photo{width:158px;height:158px;border-radius:15px;object-fit:cover;background:#202a39;border:1px solid #344056}.profile-type{padding:7px 11px;border-radius:999px;background:#c81f32;color:#fff;font-size:11px;font-weight:850;letter-spacing:.25px}.profile-title-row{display:flex;align-items:baseline;gap:14px;flex-wrap:wrap}.profile-title{margin:0;font-size:31px;line-height:1.15;color:#fff}.profile-title-en{font-size:17px;color:#b4c2d7;font-weight:650}.profile-subtitle{margin-top:9px;font-size:17px;color:#b4c2d7}.profile-roleline{display:flex;flex-wrap:wrap;gap:14px;align-items:center;margin-top:17px;font-size:18px;font-weight:750}.profile-roleline .sep{width:1px;height:26px;background:#38465c}.profile-linkline{display:flex;flex-wrap:wrap;gap:8px;margin-top:14px}.profile-link{display:inline-flex;align-items:center;gap:6px;padding:7px 11px;border:1px solid #34445c;border-radius:999px;background:#162131;text-decoration:none;font-size:12px;font-weight:750}.profile-link:hover{background:#1b2a40;border-color:var(--accent)}
|
||||
.fact-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(135px,1fr));margin-top:21px;border-top:1px solid var(--line);border-bottom:1px solid var(--line)}.fact{padding:13px 16px;border-right:1px solid var(--line);min-width:0}.fact:last-child{border-right:0}.fact label{display:block;color:var(--muted);font-size:11px;margin-bottom:5px}.fact div{font-weight:800;color:#fff;overflow-wrap:anywhere}.fact img{width:18px;height:18px;border-radius:50%;vertical-align:-4px;margin-right:6px}
|
||||
/* Компактная карточка игрока: максимум данных без пустых вертикальных зон. */
|
||||
.profile-card.compact-player{padding:13px 14px 12px;margin-bottom:9px;border-radius:13px;box-shadow:0 10px 26px rgba(0,0,0,.20)}
|
||||
.profile-card.compact-player .profile-head{grid-template-columns:112px minmax(0,1fr);gap:14px;align-items:stretch}
|
||||
.profile-card.compact-player .profile-photo-wrap{gap:6px;justify-content:flex-start}
|
||||
.profile-card.compact-player .profile-photo{width:112px;height:126px;border-radius:11px}
|
||||
.profile-card.compact-player .profile-type{width:100%;padding:5px 7px;border-radius:8px;font-size:10px;text-align:center;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.profile-card.compact-player .profile-content{display:flex;flex-direction:column;min-width:0}
|
||||
.profile-card.compact-player .profile-title-row{gap:9px;align-items:center;min-height:30px}
|
||||
.profile-card.compact-player .profile-title{font-size:24px;line-height:1.05;letter-spacing:-.25px}
|
||||
.profile-card.compact-player .profile-title-en{font-size:13px;line-height:1.1;color:#9eacc1}
|
||||
.profile-card.compact-player .profile-roleline{gap:9px;margin-top:4px;font-size:13px;font-weight:750;color:#d8e2f1}
|
||||
.profile-card.compact-player .profile-roleline .sep{height:15px}
|
||||
.profile-card.compact-player .fact-grid{grid-template-columns:repeat(6,minmax(88px,1fr));gap:5px;margin-top:8px;border:0}
|
||||
.profile-card.compact-player .fact{padding:6px 8px;border:1px solid var(--line-soft);border-radius:7px;background:rgba(20,29,42,.76);min-height:43px}
|
||||
.profile-card.compact-player .fact label{font-size:9px;margin-bottom:3px;line-height:1.05;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.profile-card.compact-player .fact div{font-size:12px;line-height:1.15;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||
.profile-card.compact-player .fact img{width:15px;height:15px;vertical-align:-3px;margin-right:4px}
|
||||
.profile-card.compact-player .profile-linkline{gap:5px;margin-top:6px}
|
||||
.profile-card.compact-player .profile-link{padding:4px 8px;border-radius:7px;font-size:10px}
|
||||
.section{display:flex;align-items:center;justify-content:space-between;gap:12px;margin:19px 0 9px}.section h2{font-size:18px;margin:0}.notice{padding:10px 12px;border:1px solid rgba(106,168,255,.3);background:rgba(106,168,255,.08);border-radius:10px;color:#cfdef3;font-size:12px}.player-tabs{display:flex;gap:7px;margin:0 0 14px;padding:6px;background:#0f1621;border:1px solid var(--line);border-radius:12px;position:sticky;top:76px;z-index:12}.player-tab{padding:9px 16px;background:transparent;border-color:transparent;color:var(--muted);font-weight:800}.player-tab:hover{color:#fff;border-color:#34445c}.player-tab.active{background:var(--accent);border-color:var(--accent);color:#07111e}.matches-state{padding:24px;border:1px solid var(--line);border-radius:12px;background:var(--panel);color:var(--muted);text-align:center}.matches-pager{display:flex;align-items:center;justify-content:center;gap:10px;padding:11px 12px;border-top:1px solid var(--line);background:#111925}.matches-pager .small{min-width:190px;text-align:center}.match-link{color:#9bc5ff;text-decoration:none;font-weight:750}.match-link:hover{text-decoration:underline}
|
||||
.table-wrap{overflow:auto;border:1px solid var(--line);border-radius:12px;background:var(--panel);margin-bottom:13px;max-width:100%;scrollbar-color:#47566f #111824}.table-title{padding:11px 13px;background:var(--panel-2);border-bottom:1px solid var(--line);font-size:13px;font-weight:850}.table-note{padding:9px 12px;color:var(--muted);font-size:11px;border-top:1px solid var(--line)}
|
||||
.khl-table{width:max-content;min-width:100%;border-collapse:separate;border-spacing:0;background:var(--panel)}.khl-table th,.khl-table td{padding:9px 11px;border-bottom:1px solid var(--line-soft);font-size:12px;vertical-align:middle;white-space:nowrap;color:var(--text)}.khl-table th{position:sticky;top:0;z-index:4;background:#182130;color:#99a7bb;text-align:center;font-weight:800}.khl-table td{text-align:center;font-variant-numeric:tabular-nums}.khl-table tbody tr:nth-child(even){background:#121a26}.khl-table tbody tr:hover{background:#192436}.khl-table tbody tr:last-child td{border-bottom:0}.khl-table th:first-child,.khl-table td:first-child{position:sticky;left:0;z-index:5;text-align:left;min-width:205px;max-width:320px;white-space:normal;box-shadow:8px 0 12px -12px #000}.khl-table th:first-child{z-index:8;background:#1b2636}.khl-table td:first-child{background:var(--sticky);font-weight:750}.khl-table tbody tr:nth-child(even) td:first-child{background:var(--sticky-alt)}.khl-table tbody tr:hover td:first-child{background:#1b2738}.khl-table .number{text-align:right}.khl-table .muted-cell{color:var(--muted)}.khl-table .team{display:block;color:#69adff;margin-top:5px;font-weight:600}.khl-table .season{display:block;color:#fff;font-weight:850}.khl-table abbr{text-decoration:none;border-bottom:1px dotted #6f7e93;cursor:help}.khl-table .total-row td{font-weight:850;background:#172333}.khl-table .total-row td:first-child{background:#1c2a3d}.rows-table{width:100%;border-collapse:collapse}.rows-table td{padding:9px 12px;border-bottom:1px solid var(--line-soft);vertical-align:top}.rows-table td:first-child{color:var(--muted);width:31%}.rows-table td:last-child{font-weight:650;overflow-wrap:anywhere}.rows-table tr:last-child td{border-bottom:0}.team-history-cell{display:flex;align-items:center;gap:10px;min-width:190px}.team-history-logo{width:36px;height:36px;border-radius:8px;object-fit:contain;background:#202b3b;border:1px solid #344157;padding:3px;flex:0 0 auto}.team-history-name{font-weight:850;color:#fff}.season-tags{display:flex;flex-wrap:wrap;gap:5px;max-width:440px}.season-tag{display:inline-flex;padding:3px 7px;border:1px solid #33435a;border-radius:999px;background:#172233;color:#c9d7ea;font-size:10px;font-weight:700}
|
||||
.chipbar{display:flex;flex-wrap:wrap;gap:6px}.chip{padding:4px 8px;border:1px solid #30405a;border-radius:999px;background:#162131;font-size:11px;color:#c7d3e5}.flag{width:21px;height:21px;border-radius:50%;vertical-align:-5px;margin-right:6px}.flag-emoji{display:inline-flex;align-items:center;justify-content:center;width:24px;height:20px;margin-right:6px;font-size:18px;line-height:1;vertical-align:-2px}.job{position:fixed;right:16px;bottom:16px;width:min(520px,calc(100vw - 32px));background:var(--panel);border:1px solid var(--line);border-radius:12px;padding:12px;box-shadow:0 12px 40px rgba(0,0,0,.45);display:none;z-index:50}.job.show{display:block}.progress{height:6px;background:#273147;border-radius:999px;overflow:hidden;margin-top:8px}.progress span{display:block;height:100%;background:var(--accent);width:35%;animation:move 1.1s linear infinite}@keyframes move{from{transform:translateX(-110%)}to{transform:translateX(300%)}}
|
||||
@media(max-width:1200px){.profile-card.compact-player .fact-grid{grid-template-columns:repeat(4,minmax(92px,1fr))}}
|
||||
@media(max-width:900px){header{height:auto;padding:10px;gap:8px;align-items:flex-start;flex-direction:column}.actions{justify-content:flex-start}.layout{grid-template-columns:1fr}aside{position:static;height:auto}.list{height:380px}.profile-head{grid-template-columns:105px 1fr}.profile-photo{width:105px;height:105px}.profile-title{font-size:24px}.fact-grid{grid-template-columns:repeat(2,minmax(120px,1fr))}.profile-card.compact-player .profile-head{grid-template-columns:92px minmax(0,1fr);gap:10px}.profile-card.compact-player .profile-photo{width:92px;height:104px}.profile-card.compact-player .profile-title{font-size:20px}.profile-card.compact-player .profile-title-en{font-size:12px}.profile-card.compact-player .fact-grid{grid-template-columns:repeat(3,minmax(92px,1fr))}}
|
||||
@media(max-width:600px){.profile-card.compact-player .profile-head{grid-template-columns:1fr}.profile-card.compact-player .profile-photo-wrap{display:grid;grid-template-columns:72px minmax(0,1fr);align-items:center}.profile-card.compact-player .profile-photo{width:72px;height:82px}.profile-card.compact-player .profile-type{width:auto}.profile-card.compact-player .fact-grid{grid-template-columns:repeat(2,minmax(100px,1fr))}}
|
||||
.primary-action{background:var(--accent)!important;border-color:var(--accent)!important;color:#06111f!important;font-weight:900}</style>
|
||||
</head>
|
||||
<body>
|
||||
<header>
|
||||
<div><div class="brand">KHL Data Center</div><div class="small" id="statusLine">Загрузка данных…</div></div>
|
||||
<div class="actions">
|
||||
<button class="primary-action" onclick="collectData('all')">KHL.ru: обновить всё</button>
|
||||
<button onclick="collectData('players')">Игроки</button>
|
||||
<button onclick="collectData('coaches')">Тренеры</button>
|
||||
<button onclick="collectData('officials-current')">Судьи: текущие</button>
|
||||
<button onclick="collectData('officials-history')">Судьи: все</button>
|
||||
<button onclick="reloadData()">Перечитать JSON</button>
|
||||
</div>
|
||||
</header>
|
||||
<div class="layout">
|
||||
<aside>
|
||||
<div class="tabs">
|
||||
<button id="tab-players" class="active" onclick="setKind('players')">Игроки</button>
|
||||
<button id="tab-coaches" onclick="setKind('coaches')">Тренеры</button>
|
||||
<button id="tab-officials" onclick="setKind('officials')">Судьи</button>
|
||||
</div>
|
||||
<div class="filters">
|
||||
<input id="search" placeholder="Имя, фамилия, English name или ID" oninput="debouncedLoad()">
|
||||
<div class="filter-row">
|
||||
<select id="season" onchange="loadList(true)"><option value="">Все сезоны</option></select>
|
||||
<select id="extra" onchange="loadList(true)"><option value="">Все</option></select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="list-head"><span id="total">0 записей</span><span class="small">по 100</span></div>
|
||||
<div class="list" id="list"></div>
|
||||
<div class="pager"><button id="prev" onclick="changePage(-1)">←</button><span class="small" id="pageInfo">1 / 1</span><button id="next" onclick="changePage(1)">→</button></div>
|
||||
</aside>
|
||||
<main><div id="detail" class="empty">Выбери запись слева</div></main>
|
||||
</div>
|
||||
<div class="job" id="job"><b id="jobTitle">Обновление</b><div class="small" id="jobMessage"></div><div class="progress" id="jobProgress"><span></span></div></div>
|
||||
<script>
|
||||
const UI_STORAGE_KEY='khlDataCenterUiStateV1';
|
||||
function loadUiState(){try{return JSON.parse(localStorage.getItem(UI_STORAGE_KEY)||'{}')||{}}catch(e){return {}}}
|
||||
const UI=loadUiState();
|
||||
const KINDS=['players','coaches','officials'];
|
||||
const defaultMap=()=>({players:null,coaches:null,officials:null});
|
||||
const defaultPages=()=>({players:1,coaches:1,officials:1});
|
||||
const defaultScroll=()=>({players:0,coaches:0,officials:0});
|
||||
const S={
|
||||
kind:KINDS.includes(UI.kind)?UI.kind:'players',
|
||||
page:1,
|
||||
pages:1,
|
||||
selectedByKind:{...defaultMap(),...(UI.selectedByKind||{})},
|
||||
pageByKind:{...defaultPages(),...(UI.pageByKind||{})},
|
||||
scrollByKind:{...defaultScroll(),...(UI.scrollByKind||{})},
|
||||
status:null,
|
||||
timer:null,
|
||||
openSeq:0,
|
||||
currentPlayer:null,
|
||||
currentPlayerId:null,
|
||||
playerTab:'overview',
|
||||
matchesPage:1,
|
||||
itemCache:new Map(),
|
||||
extraCache:new Map(),
|
||||
matchesCache:new Map(),
|
||||
extraTimer:null
|
||||
};
|
||||
S.page=Math.max(1,Number(S.pageByKind[S.kind])||1);
|
||||
function selectedId(kind=S.kind){const v=S.selectedByKind[kind];return v===null||v===undefined||v===''?null:String(v)}
|
||||
function saveUiState(){try{localStorage.setItem(UI_STORAGE_KEY,JSON.stringify({kind:S.kind,selectedByKind:S.selectedByKind,pageByKind:S.pageByKind,scrollByKind:S.scrollByKind}))}catch(e){}}
|
||||
function captureListState(){const list=$('list');if(!list)return;S.pageByKind[S.kind]=S.page;S.scrollByKind[S.kind]=list.scrollTop||0;saveUiState()}
|
||||
function markActiveRow(id=selectedId()){document.querySelectorAll('#list .row').forEach(row=>row.classList.toggle('active',id!==null&&row.dataset.id===String(id)))}
|
||||
function restoreListScroll(kind=S.kind){requestAnimationFrame(()=>{if(kind!==S.kind)return;const list=$('list');if(!list)return;list.scrollTop=Math.max(0,Number(S.scrollByKind[kind])||0);markActiveRow(selectedId(kind))})}
|
||||
const $=id=>document.getElementById(id);
|
||||
const esc=v=>String(v??'').replaceAll('&','&').replaceAll('<','<').replaceAll('>','>').replaceAll('"','"').replaceAll("'",''');
|
||||
const SKATER_ORDER=['gp','g','a','pts','pm','plus','minus','pim','g_even','g_pp','g_sh','g_ot','gwg','sds','shots','shot_pct','shots_avg','faceoffs','faceoff_wins','faceoff_pct','toi_avg','sft_avg','toi_even_avg','sft_even_avg','toi_pp_avg','sft_pp_avg','toi_sh_avg','sft_sh_avg','hits','blocks','fouls_against','takeaways','interceptions'];
|
||||
const GOALIE_ORDER=['gp','w','l','shootout_games','shots_against','ga','sv','sv_pct','gaa','g','a','so','pim','toi'];
|
||||
const SHORT={gp:'И',g:'Ш',a:'А',pts:'О',pm:'+/-',plus:'+',minus:'-',pim:'Штр',g_even:'ШР',g_pp:'ШБ',g_sh:'ШМ',g_ot:'ШО',gwg:'ШП',sds:'РБ',shots:'БВ',shot_pct:'%БВ',shots_avg:'БВ/И',faceoffs:'Вбр',faceoff_wins:'ВВбр',faceoff_pct:'%Вбр',toi_avg:'ВП/И',sft_avg:'См/И',toi_even_avg:'ВПР/И',sft_even_avg:'СмР/И',toi_pp_avg:'ВПБ/И',sft_pp_avg:'СмБ/И',toi_sh_avg:'ВПМ/И',sft_sh_avg:'СмМ/И',hits:'СПр',blocks:'БлБ',fouls_against:'ФоП',takeaways:'ОТБ',interceptions:'ПХТ',w:'В',l:'П',shootout_games:'ИБ',shots_against:'Бр',ga:'ПШ',sv:'ОБ',sv_pct:'%ОБ',gaa:'КН',so:'И"0"',toi:'ВП'};
|
||||
const STAT_HINTS={
|
||||
'И':'Количество проведённых игр','Ш':'Заброшенные шайбы','А':'Передачи','О':'Очки','+/-':'Плюс/Минус','+':'Плюс','-':'Минус','Штр':'Штрафное время',
|
||||
'ШР':'Шайбы в равенстве','ШБ':'Шайбы в большинстве','ШМ':'Шайбы в меньшинстве','ШО':'Шайбы в овертайме','ШП':'Победные шайбы','РБ':'Решающие буллиты',
|
||||
'БВ':'Броски по воротам','%БВ':'Процент реализованных бросков','БВ/И':'Среднее количество бросков по воротам за игру','Вбр':'Вбрасывания','ВВбр':'Выигранные вбрасывания','%Вбр':'Процент выигранных вбрасываний',
|
||||
'ВП/И':'Среднее время на площадке за игру','См/И':'Среднее количество смен за игру','ВПР/И':'Среднее время на площадке при игре в равных составах за игру','СмР/И':'Среднее количество смен при игре в равных составах за игру',
|
||||
'ВПБ/И':'Среднее время на площадке при игре в большинстве за игру','СмБ/И':'Среднее количество смен при игре в большинстве за игру','ВПМ/И':'Среднее время на площадке при игре в меньшинстве за игру','СмМ/И':'Среднее количество смен при игре в меньшинстве за игру',
|
||||
'СПр':'Силовые приёмы','БлБ':'Блокированные броски','ФоП':'Фолы против','ОТБ':'Отборы шайбы','ПХТ':'Перехваты передач',
|
||||
'В':'Выигрыши','П':'Проигрыши','ИБ':'Игры с буллитными сериями','Бр':'Броски','ПШ':'Пропущено шайб','ОБ':'Отражённые броски','%ОБ':'Процент отражённых бросков','КН':'Коэффициент надёжности = 60 мин × ПШ / ВП','И"0"':'Сухие игры','ВП':'Время на площадке',
|
||||
'№':'Номер игрока','Дата':'Дата матча','Команды':'Участники матча','Счёт':'Итоговый счёт','Турнир':'Турнир / этап сезона'
|
||||
};
|
||||
function statHint(label,fallbackTitle=''){const l=String(label||'').trim();return STAT_HINTS[l]||fallbackTitle||l}
|
||||
function fallback(name){return 'data:image/svg+xml;charset=UTF-8,'+encodeURIComponent(`<svg xmlns="http://www.w3.org/2000/svg" width="140" height="140"><rect width="100%" height="100%" fill="#202a39"/><text x="50%" y="59%" text-anchor="middle" font-family="Arial" font-size="58" fill="#8795aa">${esc((name||'?').slice(0,1))}</text></svg>`)}
|
||||
const KHL_API_BASE=location.pathname.startsWith('/khl-site')?'/khl-site':'';async function getJSON(url,opt){const target=(typeof url==='string'&&url.startsWith('/'))?KHL_API_BASE+url:url;const r=await fetch(target,opt);const p=await r.json();if(!r.ok)throw new Error(p.detail||p.error||r.statusText);return p}
|
||||
function putCache(map,key,value,max=60){if(map.has(key))map.delete(key);map.set(key,value);while(map.size>max)map.delete(map.keys().next().value)}
|
||||
function mergePlayerExtra(player,fields){if(!player||!fields)return player;const p=player._profile_full||(player._profile_full={...(player.profile||{})});for(const [k,v] of Object.entries(fields)){if(notEmpty(v)&&!notEmpty(p[k]))p[k]=v}return player}
|
||||
function format(v){if(v===null||v===undefined||v==='')return '—';if(typeof v==='number'&&!Number.isInteger(v))return Math.round(v*100)/100;return v}
|
||||
function formatValue(v){if(v===null||v===undefined||v==='')return '—';if(Array.isArray(v))return v.map(formatValue).join(', ');if(typeof v==='object')return Object.entries(v).map(([k,x])=>`${k}: ${formatValue(x)}`).join('; ');return format(v)}
|
||||
function notEmpty(v){return v!==null&&v!==undefined&&v!==''&&!(Array.isArray(v)&&!v.length)&&!(typeof v==='object'&&v&&!Object.keys(v).length)}
|
||||
function dateValue(value){if(!notEmpty(value))return '—';if(typeof value==='number'||/^\d{9,13}$/.test(String(value))){let n=Number(value);if(n<1e12)n*=1000;const d=new Date(n);if(!Number.isNaN(d.valueOf()))return d.toLocaleDateString('ru-RU',{day:'numeric',month:'long',year:'numeric'})}return String(value)}
|
||||
function physicalValue(value,unit){if(!notEmpty(value))return '';const text=String(value).trim();return new RegExp(`\\b${unit}\\b`,'i').test(text)?text:`${text} ${unit}`}
|
||||
function gripValue(value){if(!notEmpty(value))return '';const text=String(value).trim(),v=text.toLocaleLowerCase('ru');if(['l','left','лев','левый'].includes(v))return 'левый';if(['r','right','прав','правый'].includes(v))return 'правый';return text}
|
||||
function countryInfo(value){let raw=String(value||'').trim();if(!raw)return {name:'',code:'',emoji:''};const map={'россия':'RU','рф':'RU','беларусь':'BY','белоруссия':'BY','казахстан':'KZ','финляндия':'FI','швеция':'SE','чехия':'CZ','словакия':'SK','латвия':'LV','литва':'LT','эстония':'EE','сша':'US','соединенные штаты':'US','соединённые штаты':'US','канада':'CA','германия':'DE','швейцария':'CH','австрия':'AT','дания':'DK','норвегия':'NO','франция':'FR','словения':'SI','хорватия':'HR','польша':'PL','венгрия':'HU','китай':'CN','япония':'JP','южная корея':'KR','великобритания':'GB','италия':'IT','украина':'UA','грузия':'GE','армения':'AM','азербайджан':'AZ','узбекистан':'UZ'};let code='',name=raw;const m=raw.match(/^([A-Za-z]{2})\s+(.+)$/);if(m){code=m[1].toUpperCase();name=m[2].trim()}else code=map[raw.toLocaleLowerCase('ru')]||(/^[A-Za-z]{2}$/.test(raw)?raw.toUpperCase():'');const emoji=code?[...code].map(ch=>String.fromCodePoint(127397+ch.charCodeAt(0))).join(''):'';return {name,code,emoji}}
|
||||
function countryFlagEmoji(value){return countryInfo(value).emoji}
|
||||
function nameParts(value){const p=String(value||'').trim().split(/\s+/).filter(Boolean);return {last:p[0]||'',first:p[1]||'',middle:p.slice(2).join(' ')}}
|
||||
function headerHtml(value){if(typeof value==='object'&&value)return `<abbr title="${esc(value.title||value.label||'')}">${esc(value.label||value.title||'')}</abbr>`;return esc(value)}
|
||||
function table(headers,rows,opts={}){const body=(rows||[]).length?rows.map((row,ri)=>`<tr class="${opts.totalRows?.includes(ri)?'total-row':''}">${row.map((cell,ci)=>`<td class="${opts.numberCols?.includes(ci)?'number':''}">${cell}</td>`).join('')}</tr>`).join(''):`<tr><td colspan="${Math.max(1,headers.length)}">${esc(opts.empty||'Данных нет')}</td></tr>`;return `<div class="table-wrap">${opts.title?`<div class="table-title">${esc(opts.title)}</div>`:''}<table class="khl-table"><thead><tr>${headers.map(x=>`<th>${headerHtml(x)}</th>`).join('')}</tr></thead><tbody>${body}</tbody></table>${opts.note?`<div class="table-note">${esc(opts.note)}</div>`:''}</div>`}
|
||||
function rowsTable(rows,title){const clean=(rows||[]).filter(x=>x&¬Empty(x[1]));const body=clean.length?clean.map(([k,v])=>`<tr><td>${esc(k)}</td><td>${esc(formatValue(v))}</td></tr>`).join(''):`<tr><td colspan="2">Данных нет</td></tr>`;return `<div class="table-wrap">${title?`<div class="table-title">${esc(title)}</div>`:''}<table class="rows-table"><tbody>${body}</tbody></table></div>`}
|
||||
function factsHtml(facts){return `<div class="fact-grid">${facts.filter(x=>notEmpty(x.value)).map(x=>`<div class="fact"><label>${esc(x.label)}</label><div>${x.html??esc(formatValue(x.value))}</div></div>`).join('')}</div>`}
|
||||
function profileCard({type,name,nameEn,photo,role,team,facts,links,compact=false}){const roleBits=[team,role].filter(Boolean).map((x,i)=>i?`<span class="sep"></span><span>${esc(x)}</span>`:`<span>${esc(x)}</span>`).join('');return `<div class="profile-card${compact?' compact-player':''}"><div class="profile-head"><div class="profile-photo-wrap"><img class="profile-photo" src="${esc(photo||fallback(name))}" onerror="this.src='${fallback(name)}'"><div class="profile-type">${esc(type)}</div></div><div class="profile-content"><div class="profile-title-row"><h1 class="profile-title">${esc(name)}</h1>${nameEn?`<div class="profile-title-en">${esc(nameEn)}</div>`:''}</div>${roleBits?`<div class="profile-roleline">${roleBits}</div>`:''}${factsHtml(facts)}<div class="profile-linkline">${links.map(([t,u])=>`<a class="profile-link" target="_blank" href="${esc(u)}">${esc(t)} ↗</a>`).join('')}</div></div></div></div>`}
|
||||
|
||||
function scalarRows(obj,prefix='',depth=0){const rows=[];for(const [k,v] of Object.entries(obj||{})){const key=prefix?`${prefix}.${k}`:k;if(v===null||v===undefined||v==='')continue;if(Array.isArray(v)){if(v.length&&v.every(x=>typeof x!=='object'))rows.push([key,v.join(', ')]);continue}if(typeof v==='object'){if(depth<1)rows.push(...scalarRows(v,key,depth+1));continue}rows.push([key,k.includes('birthday')||k.includes('birth_date')?dateValue(v):v])}return rows}
|
||||
function statMap(stats){const m={};for(const s of (stats||[]))m[String(s.id)]=s;return m}
|
||||
function statColumns(player){
|
||||
const ids=new Set(),titles={},descriptions={};
|
||||
for(const record of Object.values(player.stages||{}))for(const st of ((record.data||{}).stats||[])){const id=String(st.id);ids.add(id);titles[id]=st.title||titles[id]||'';descriptions[id]=st.description||descriptions[id]||''}
|
||||
for(const agg of Object.values(player.aggregates||{}))for(const st of (agg.stats||[])){const id=String(st.id);ids.add(id);titles[id]=st.title||titles[id]||'';descriptions[id]=st.description||descriptions[id]||''}
|
||||
const order=player._is_goalie?GOALIE_ORDER:SKATER_ORDER,known=order.filter(x=>ids.has(x));
|
||||
const rest=[...ids].filter(x=>!known.includes(x)).sort((a,b)=>(titles[a]||a).localeCompare(titles[b]||b,'ru'));
|
||||
return [...known,...rest].map(id=>{const title=titles[id]||SHORT[id]||id,label=SHORT[id]||title;return {id,label,title,description:descriptions[id]||statHint(label,title)}})
|
||||
}
|
||||
function stageEntries(player){return Object.entries(player.stages||{}).sort((a,b)=>{const ai=Number(a[0]),bi=Number(b[0]);return (Number.isFinite(bi)?bi:-1)-(Number.isFinite(ai)?ai:-1)})}
|
||||
function stageLabelParts(record,id){const full=record.stage_label||`Этап ${id}`;const parts=String(full).split(' · ').filter(Boolean);const first=parts[0]||'';const hasSeason=/^(?:19|20)\d{2}\/(?:19|20)\d{2}$/.test(first);return {season:hasSeason?first:'Год не указан',stage:(hasSeason?parts.slice(1):parts).filter(x=>!/^ID /.test(x)).join(' · ')||'Этап'}}
|
||||
function stageStatsTable(player){const cols=statColumns(player),entries=stageEntries(player);const headers=[{label:'Турнир / Команда',title:'Сезон, этап и команда'},'№',...cols.map(x=>({label:x.label,title:x.description||x.title}))];const rows=entries.map(([id,r])=>{const d=r.data||{},p=stageLabelParts(r,id),team=d.team?.name||((d.teams||[])[0]?.name)||'—',m=statMap(d.stats||[]);const first=`<span class="season">${esc(p.season)}${p.stage&&p.stage!==p.season?` | ${esc(p.stage)}`:''}</span><span class="team">${esc(team)}</span>`;return [first,esc(d.shirt_number??player._profile_full?.shirt_number??'—'),...cols.map(c=>esc(format(m[c.id]?.val)))]});return table(headers,rows,{numberCols:[1,...cols.map((_,i)=>i+2)],note:'Показаны все статистические поля, найденные у выбранного игрока. Первая колонка закреплена.'})}
|
||||
|
||||
function aggregateTable(player){const cols=statColumns(player);const modes=[['regular','Регулярный чемпионат'],['playoff','Плей-офф'],['hope','Кубок Надежды'],['all','Всего в КХЛ']].filter(([k])=>(player.aggregates?.[k]?.stats||[]).length);const rows=modes.map(([key,title])=>{const m=statMap(player.aggregates[key].stats||[]);return [`<span class="season">${esc(title)}</span><span class="team">Этапов учтено: ${esc(player.aggregates[key].stage_count||0)}</span>`,...cols.map(c=>esc(format(m[c.id]?.val)))]});return table([{label:'Категория',title:'Категория суммарной статистики'},...cols.map(x=>({label:x.label,title:x.description||x.title}))],rows,{numberCols:cols.map((_,i)=>i+1),totalRows:rows.length?[rows.length-1]:[],note:'Используются суммарные строки KHL.ru; если сайт не отдал итоговую строку, применяется локальный резервный расчёт.'})}
|
||||
function teamRows(teams){
|
||||
const merged=new Map();
|
||||
for(const t of (teams||[])){
|
||||
const name=String(t.name||'').trim();if(!name||/^Див\./i.test(name)||/конференц/i.test(name))continue;
|
||||
const key=name.toLocaleLowerCase('ru'),seasons=String(t.seasons||'').split(',').map(x=>x.trim()).filter(Boolean);
|
||||
if(!merged.has(key))merged.set(key,{name,image:t.image||t.logo||'',url:t.url||'',seasons:new Set()});
|
||||
const item=merged.get(key);if(!item.image&&(t.image||t.logo))item.image=t.image||t.logo;if(!item.url&&t.url)item.url=t.url;for(const season of seasons)item.seasons.add(season)
|
||||
}
|
||||
return [...merged.values()].sort((a,b)=>a.name.localeCompare(b.name,'ru')).map(t=>{
|
||||
const seasons=[...t.seasons].sort((a,b)=>b.localeCompare(a));
|
||||
const name=t.url?`<a href="${esc(t.url)}" target="_blank" rel="noopener noreferrer">${esc(t.name)}</a>`:esc(t.name);
|
||||
const team=`<div class="team-history-cell"><img class="team-history-logo" src="${esc(t.image||fallback(t.name))}" onerror="this.src='${fallback(t.name)}'"><div class="team-history-name">${name}</div></div>`;
|
||||
const seasonHtml=seasons.length?`<div class="season-tags">${seasons.map(x=>`<span class="season-tag">${esc(x)}</span>`).join('')}</div>`:'—';
|
||||
return [team,seasonHtml]
|
||||
})
|
||||
}
|
||||
function playerHeaderHtml(player){
|
||||
const i=player.identity||{},ie=player.identity_en||{},p=player._profile_full||player.profile||{},latest=(player.stages||{})[player._latest_stage_id]?.data||{},team=p.team||latest.team||{};
|
||||
const generic=/(?:конференц|дивизион|^игроки?$|^players?$|^игрок$|^player$)/i;
|
||||
const ruCandidates=[p.name,i.full_name].filter(x=>x&&!generic.test(String(x).trim()));
|
||||
const enCandidates=[p.name_en,player._name_en,ie.full_name].filter(x=>x&&!generic.test(String(x).trim()));
|
||||
const name=ruCandidates[0]||`Игрок #${esc(i.khl_id||p.khl_id||'')}`,nameEn=enCandidates[0]||'';let role=String(p.role||latest.role||'').trim();if(!['вратарь','защитник','нападающий'].includes(role.toLocaleLowerCase('ru')))role='';const countryRaw=p.country||p.country_name||p.nationality||p.citizenship||latest.country||latest.nationality||latest.citizenship||'',country=countryInfo(countryRaw),birthday=p.birthday||p.birth_date||p.date_of_birth||p.birthdate||p.dob,height=p.height||p.height_cm||p.player_height,weight=p.weight||p.weight_kg||p.player_weight,stick=p.stick||p.shoots||p.grip||p.handedness||p.shooting_side;
|
||||
const facts=[{label:'Дата рождения',value:dateValue(birthday)},{label:'Возраст',value:p.age||latest.age},{label:'Страна / гражданство',value:country.name,html:country.name?`<span class="flag-emoji">${country.emoji}</span>${esc(country.name)}`:undefined},{label:'Рост',value:physicalValue(height,'см')},{label:'Вес',value:physicalValue(weight,'кг')},{label:'Хват',value:gripValue(stick)},{label:'Сборная',value:p.national_team||latest.national_team},{label:'Контракт до',value:p.contract_until||latest.contract_until},{label:'Место рождения',value:p.birth_place||latest.birth_place},{label:'Номер',value:p.shirt_number||latest.shirt_number},{label:'KHL ID',value:i.khl_id||p.khl_id},{label:'Сезонов КХЛ',value:p.seasons_count?.khl||latest.seasons_count?.khl}],links=[];
|
||||
if(i.khl_id||p.khl_id)links.push(['Профиль на KHL.ru',`https://www.khl.ru/players/${i.khl_id||p.khl_id}/`]);
|
||||
return profileCard({type:role||'Амплуа не указано',name,nameEn,photo:p.image||p.photo,role:'',team:team.name||'',facts,links,compact:true})
|
||||
}
|
||||
function playerOverviewHtml(player){
|
||||
let html=`<div class="section"><h2>${player._is_goalie?'Суммарная вратарская статистика':'Суммарная статистика КХЛ'}</h2></div>${aggregateTable(player)}`;
|
||||
html+=`<div class="section"><h2>${player._is_goalie?'Вратарская статистика по сезонам':'Статистика по сезонам и этапам'}</h2></div>${stageStatsTable(player)}`;
|
||||
const p=player._profile_full||player.profile||{},latest=(player.stages||{})[player._latest_stage_id]?.data||{},allTeams=p.teams||latest.teams||[],allTeamRows=teamRows(allTeams);if(allTeamRows.length)html+=table(['Команда','Сезоны'],allTeamRows,{title:'Все команды'});
|
||||
return html
|
||||
}
|
||||
function playerTabsHtml(){return `<div class="player-tabs"><button id="playerTabOverview" class="player-tab ${S.playerTab==='overview'?'active':''}" onclick="showPlayerTab('overview')">Статистика</button><button id="playerTabMatches" class="player-tab ${S.playerTab==='matches'?'active':''}" onclick="showPlayerTab('matches')">Матчи</button></div><div id="playerTabContent"></div>`}
|
||||
function renderPlayer(player,{keepTab=false}={}){S.currentPlayer=player;S.currentPlayerId=String((player.identity||{}).id||(player.profile||{}).id||selectedId('players')||'');if(!keepTab){S.playerTab='overview';S.matchesPage=1}$('detail').className='';$('detail').innerHTML=playerHeaderHtml(player)+playerTabsHtml();if(S.playerTab==='matches')loadPlayerMatches(S.matchesPage||1);else $('playerTabContent').innerHTML=playerOverviewHtml(player)}
|
||||
function showPlayerTab(tab){if(!S.currentPlayer||!['overview','matches'].includes(tab))return;S.playerTab=tab;const a=$('playerTabOverview'),b=$('playerTabMatches');if(a)a.classList.toggle('active',tab==='overview');if(b)b.classList.toggle('active',tab==='matches');if(tab==='overview'){$('playerTabContent').innerHTML=playerOverviewHtml(S.currentPlayer);return}S.matchesPage=1;loadPlayerMatches(1)}
|
||||
function renderPlayerMatches(data){
|
||||
const host=$('playerTabContent');if(!host)return;
|
||||
const rawHeaders=data.headers||[],headers=rawHeaders.map(h=>({label:String(h||''),title:statHint(String(h||''),String(h||''))}));
|
||||
const teamIndex=rawHeaders.findIndex(x=>String(x).toLocaleLowerCase('ru').includes('команд'));
|
||||
const rows=(data.rows||[]).map(row=>(row.values||[]).map((value,ci)=>{const cell=esc(formatValue(value));return row.url&&ci===teamIndex?`<a class="match-link" href="${esc(row.url)}" target="_blank" rel="noopener noreferrer">${cell} ↗</a>`:cell}));
|
||||
const from=data.range_start||((data.page-1)*(data.page_size||30)+1),to=data.range_end||(rows.length?from+rows.length-1:0);
|
||||
let html=table(headers,rows,{title:`Матчи КХЛ · ${data.total||rows.length}`,empty:data.total?`KHL сообщает ${data.total} матчей, но строки этой страницы не удалось разобрать`:'Матчей нет',note:data.total&&rows.length?`Показаны игры ${from}–${to} из ${data.total}`:''});
|
||||
html+=`<div class="matches-pager"><button ${data.page<=1?'disabled':''} onclick="loadPlayerMatches(${Math.max(1,data.page-1)})">← Назад</button><span class="small">Страница ${data.page} из ${data.pages||1}</span><button ${data.page>=(data.pages||1)?'disabled':''} onclick="loadPlayerMatches(${data.page+1})">Вперёд →</button></div>`;host.innerHTML=html
|
||||
}
|
||||
async function loadPlayerMatches(page=1){S.matchesPage=Math.max(1,Number(page)||1);page=S.matchesPage;const id=S.currentPlayerId;if(!id||S.playerTab!=='matches')return;const host=$('playerTabContent');if(!host)return;const key=`${id}:${page}`;if(S.matchesCache.has(key)){renderPlayerMatches(S.matchesCache.get(key));return}host.innerHTML='<div class="matches-state">Загрузка матчей KHL…</div>';try{const data=await getJSON(`/api/player-matches/${encodeURIComponent(id)}?page=${page}`);if(S.playerTab!=='matches'||S.currentPlayerId!==String(id))return;putCache(S.matchesCache,key,data,80);renderPlayerMatches(data)}catch(e){console.error(e);if(S.playerTab==='matches'&&S.currentPlayerId===String(id))host.innerHTML=`<div class="matches-state">Не удалось загрузить матчи: ${esc(e.message||e)}</div>`}}
|
||||
async function loadPlayerExtra(id,player,seq){const key=String(id);if(S.extraCache.has(key)){mergePlayerExtra(player,S.extraCache.get(key));if(seq===S.openSeq&&S.kind==='players'&&selectedId('players')===key)renderPlayer(player,{keepTab:true});return}try{const data=await getJSON(`/api/player-extra/${encodeURIComponent(id)}`);putCache(S.extraCache,key,data.fields||{},100);mergePlayerExtra(player,data.fields||{});if(seq===S.openSeq&&S.kind==='players'&&selectedId('players')===key)renderPlayer(player,{keepTab:true})}catch(e){console.debug('Дополнительный профиль KHL недоступен',e)}}
|
||||
|
||||
function parsedTable(t,prefix=''){const n=Math.max(t.headers?.length||0,...(t.rows||[]).map(r=>r.values?.length||0));const heads=(t.headers?.length?t.headers:Array.from({length:n},(_,i)=>`Колонка ${i+1}`));const rows=(t.rows||[]).map(r=>Array.from({length:n},(_,i)=>esc(formatValue((r.values||[])[i]))));return table(heads,rows,{title:`${prefix}${t.title||''}`.trim()})}
|
||||
function linksTable(items,title){const rows=(items||[]).filter(x=>x&&(x.title||x.url)).map(x=>[esc(x.title||x.url||'—'),x.url?`<a href="${esc(x.url)}" target="_blank" rel="noopener noreferrer">Открыть ↗</a>`:'—']);return rows.length?table(['Название','Ссылка'],rows,{title}):''}
|
||||
function sectionsTable(sections,title){const rows=(sections||[]).map(s=>[esc(s.title||'—'),esc(s.text||s.content||'—'),esc((s.links||[]).map(x=>x.title||x.url).join(', ')||'—')]);return rows.length?table(['Раздел','Текст','Ссылки'],rows,{title}):''}
|
||||
function renderStaff(person){const profile=person.profile||{},ru=person.languages?.ru||{},en=person.languages?.en||{},name=person._display_name||person.name_ru||profile.name||person.name||'Без имени',nameEn=person._display_name_en||person.name_en||profile.name_en||en.name||'',fields=profile.fields||ru.fields||{},apps=person.appearances||[],photos=profile.photos||person.photos||[],type=S.kind==='coaches'?'Тренер':'Судья',role=fields['Амплуа']||fields['Роль']||fields['Role']||apps.find(x=>x.context?.role)?.context?.role||'',teams=[...new Set(apps.flatMap(x=>(x.team_links||[]).map(t=>t.title)).filter(Boolean))],facts=Object.entries(fields).slice(0,10).map(([label,value])=>({label,value:label.toLowerCase().includes('рожд')?dateValue(value):value})),links=[];if(person.url_ru||person.url)links.push(['Профиль на KHL.ru',person.url_ru||person.url]);if(person.url_en)links.push(['English profile',person.url_en]);let html=profileCard({type,name,nameEn,photo:(photos.find(x=>x&&!/logo|sprite|icon|banner|advert|sponsor/i.test(String(x)))||''),role,team:teams[0]||'',facts,links});html+=table(['Сезон','Этап','Роль / амплуа','Команды','Язык','Описание'],apps.map(a=>[esc(a.context?.season||'—'),esc(a.context?.stage||a.context?.stage_type||'—'),esc(a.context?.role||'—'),esc((a.team_links||[]).map(x=>x.title).join(', ')||'—'),esc(a.language||a.context?.language||'—'),esc(a.text||'—')]),{title:'Все появления в списках'});for(const t of (profile.tables||ru.tables||[]))html+=parsedTable(t,'RU · ');for(const t of (profile.tables_en||en.tables||[]))html+=parsedTable(t,'EN · ');html+=linksTable(profile.teams||ru.teams||[],'Команды');html+=linksTable(profile.events||ru.events||[],'Матчи и события');html+=linksTable(profile.news||ru.news||[],'Новости');$('detail').className='';$('detail').innerHTML=html}
|
||||
|
||||
async function loadStatus(){S.status=await getJSON('/api/status');const c=S.status.counts,b=Number(S.status.broken_player_names||0);$('statusLine').textContent=`Игроки: ${c.players} · тренеры: ${c.coaches} · судьи: ${c.officials}`+(b?` · ⚠ обновите игроков: имена требуют пересборки (${b})`:'');fillFilters()}
|
||||
async function setKind(kind){
|
||||
if(!KINDS.includes(kind)||kind===S.kind)return;
|
||||
captureListState();
|
||||
S.kind=kind;
|
||||
S.page=Math.max(1,Number(S.pageByKind[kind])||1);
|
||||
saveUiState();
|
||||
document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x.id===`tab-${kind}`));
|
||||
$('detail').className='empty';
|
||||
$('detail').textContent='Загрузка карточки…';
|
||||
fillFilters();
|
||||
await loadList(false,{restoreScroll:true});
|
||||
if(kind!==S.kind)return;
|
||||
const id=selectedId(kind);
|
||||
if(id)await openItem(id,{updateSelection:false});
|
||||
else{$('detail').className='empty';$('detail').textContent='Выбери запись слева'}
|
||||
}
|
||||
function fillFilters(){if(!S.status)return;const f=S.status.filters[S.kind]||{};$('season').innerHTML='<option value="">Все сезоны</option>'+(f.seasons||[]).map(x=>`<option>${esc(x)}</option>`).join('');let values=[],label='Все';if(S.kind==='players'){values=f.roles||[];label='Все амплуа'}else if(S.kind==='coaches'){values=f.teams||[];label='Все команды'}else{values=f.roles||[];label='Все амплуа'}$('extra').innerHTML=`<option value="">${label}</option>`+values.map(x=>`<option>${esc(x)}</option>`).join('')}
|
||||
let debounce=null;function debouncedLoad(){clearTimeout(debounce);debounce=setTimeout(()=>loadList(true),180)}
|
||||
async function loadList(reset=false,{restoreScroll=true}={}){
|
||||
const kind=S.kind;
|
||||
if(reset){S.page=1;S.pageByKind[kind]=1;S.scrollByKind[kind]=0}else S.page=Math.max(1,Number(S.pageByKind[kind])||S.page||1);
|
||||
$('list').innerHTML='<div class="small" style="padding:15px">Загрузка…</div>';
|
||||
const q=new URLSearchParams({page:S.page,limit:100,q:$('search').value,season:$('season').value,extra:$('extra').value});
|
||||
const p=await getJSON(`/api/list/${kind}?${q}`);
|
||||
if(kind!==S.kind)return;
|
||||
S.page=p.page;S.pages=p.pages;S.pageByKind[kind]=p.page;
|
||||
$('total').textContent=`${p.total} записей`;$('pageInfo').textContent=`${p.page} / ${p.pages}`;$('prev').disabled=p.page<=1;$('next').disabled=p.page>=p.pages;
|
||||
const active=selectedId(kind);
|
||||
$('list').innerHTML=p.items.map(item=>`<div class="row ${active===String(item.id)?'active':''}" data-id="${esc(item.id)}" onclick="openItem('${esc(item.id)}')"><img class="avatar" src="${esc(item.image||fallback(item.name))}" onerror="this.src='${fallback(item.name)}'"><div><div class="name">${esc(item.name)}</div>${item.name_en?`<div class="name-en">${esc(item.name_en)}</div>`:''}<div class="badges"><span class="badge">ID ${esc(item.id)}</span>${item.subtitle?`<span class="badge">${esc(item.subtitle)}</span>`:''}${item.count?`<span class="badge good">${esc(item.count)}</span>`:''}</div></div><span class="small">${esc(item.role||'')}</span></div>`).join('')||'<div class="small" style="padding:15px">Ничего не найдено</div>';
|
||||
saveUiState();
|
||||
if(restoreScroll&&!reset)restoreListScroll(kind);else{S.scrollByKind[kind]=0;$('list').scrollTop=0;markActiveRow(active)}
|
||||
}
|
||||
function changePage(d){const n=S.page+d;if(n>=1&&n<=S.pages){captureListState();S.page=n;S.pageByKind[S.kind]=n;S.scrollByKind[S.kind]=0;saveUiState();loadList(false,{restoreScroll:false})}}
|
||||
async function openItem(id,{updateSelection=true}={}){
|
||||
const kind=S.kind,key=`${kind}:${id}`,seq=++S.openSeq;
|
||||
if(updateSelection){S.selectedByKind[kind]=String(id);S.pageByKind[kind]=S.page;S.scrollByKind[kind]=$('list').scrollTop||0;saveUiState()}
|
||||
markActiveRow(String(id));
|
||||
try{
|
||||
let item=S.itemCache.get(key);
|
||||
if(!item){item=await getJSON(`/api/item/${kind}/${encodeURIComponent(id)}`);putCache(S.itemCache,key,item,80)}
|
||||
if(seq!==S.openSeq||kind!==S.kind)return;
|
||||
if(kind==='players'){
|
||||
renderPlayer(item);
|
||||
}else renderStaff(item);
|
||||
markActiveRow(selectedId(kind));
|
||||
}catch(e){console.error(e);if(seq!==S.openSeq)return;$('detail').className='empty';$('detail').textContent=`Не удалось открыть карточку: ${e.message||e}`}
|
||||
}
|
||||
async function collectData(kind){try{await getJSON(`/api/collect/${kind}`,{method:'POST'});pollJob()}catch(e){alert(e.message)}}
|
||||
async function reloadData(){captureListState();await getJSON('/api/reload',{method:'POST'});await loadStatus();await loadList(false,{restoreScroll:true});const id=selectedId();if(id)await openItem(id,{updateSelection:false})}
|
||||
async function pollJob(){clearInterval(S.timer);$('job').classList.add('show');S.timer=setInterval(async()=>{const j=await getJSON('/api/job');$('jobTitle').textContent=j.title||'Обновление';$('jobMessage').textContent=j.message||'';$('jobProgress').style.display=j.running?'block':'none';if(!j.running){clearInterval(S.timer);setTimeout(()=>$('job').classList.remove('show'),4000);captureListState();await loadStatus();await loadList(false,{restoreScroll:true});const id=selectedId();if(id)await openItem(id,{updateSelection:false})}},800)}
|
||||
(async()=>{
|
||||
document.querySelectorAll('.tabs button').forEach(x=>x.classList.toggle('active',x.id===`tab-${S.kind}`));
|
||||
await loadStatus();
|
||||
S.page=Math.max(1,Number(S.pageByKind[S.kind])||1);
|
||||
await loadList(false,{restoreScroll:true});
|
||||
const id=selectedId();
|
||||
if(id)await openItem(id,{updateSelection:false});
|
||||
const j=await getJSON('/api/job');if(j.running)pollJob()
|
||||
})()
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Reference in New Issue
Block a user