321 lines
11 KiB
HTML
321 lines
11 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="ru">
|
||
<head>
|
||
<meta charset="utf-8">
|
||
<meta name="viewport" content="width=device-width,initial-scale=1">
|
||
<title>Dashboard — Сводная аналитика</title>
|
||
<style>
|
||
:root{
|
||
--ink:#0F1218;--cyan:#00E5FF;--cyan-50:#E8FCFF;
|
||
--white:#fff;--gray-500:#5B6573;--gray-100:#F2F4F7;
|
||
--gray-800:#1a1f2b;--gray-700:#252b38;
|
||
--red:#ff4757;--green:#2ed573;--orange:#ffa502;--blue:#3742fa;
|
||
}
|
||
*{box-sizing:border-box;margin:0;padding:0}
|
||
body{
|
||
font:16px/1.5 -apple-system,BlinkMacSystemFont,"Segoe UI",Inter,system-ui,sans-serif;
|
||
color:var(--ink);background:var(--gray-100);min-height:100vh;
|
||
}
|
||
|
||
.topbar{
|
||
background:var(--ink);color:var(--white);
|
||
padding:18px 28px;display:flex;align-items:center;
|
||
justify-content:space-between;flex-wrap:wrap;gap:14px;
|
||
position:sticky;top:0;z-index:100;
|
||
}
|
||
.topbar h1{font-size:22px;font-weight:700}
|
||
.topbar .controls{display:flex;align-items:center;gap:14px}
|
||
.btn{
|
||
background:var(--cyan);color:var(--ink);
|
||
border:none;padding:9px 20px;border-radius:8px;
|
||
font-weight:700;font-size:14px;cursor:pointer;
|
||
}
|
||
.btn:hover{opacity:.85}
|
||
.timer{font-size:13px;color:var(--gray-500)}
|
||
.badge{padding:4px 14px;border-radius:20px;font-size:13px;font-weight:600}
|
||
.badge.ok{background:var(--green);color:var(--ink)}
|
||
.badge.warn{background:var(--orange);color:var(--white)}
|
||
.badge.err{background:var(--red);color:var(--white)}
|
||
|
||
/* Stats row */
|
||
.stats{
|
||
display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));
|
||
gap:16px;padding:24px 28px 0;max-width:1400px;margin:0 auto;
|
||
}
|
||
.stat-card{
|
||
background:var(--white);border-radius:12px;padding:20px;
|
||
box-shadow:0 1px 3px rgba(0,0,0,.06);
|
||
}
|
||
.stat-card .value{font-size:32px;font-weight:800;line-height:1.1}
|
||
.stat-card .label{font-size:13px;color:var(--gray-500);margin-top:6px}
|
||
|
||
/* Content */
|
||
.content{padding:16px 28px 40px;max-width:1400px;margin:0 auto}
|
||
|
||
/* Sheet section */
|
||
.sheet-section{
|
||
background:var(--white);border-radius:16px;margin-bottom:20px;
|
||
overflow:hidden;box-shadow:0 1px 3px rgba(0,0,0,.06);
|
||
}
|
||
.sheet-header{
|
||
display:flex;align-items:center;justify-content:space-between;
|
||
padding:16px 24px;border-bottom:1px solid var(--gray-100);
|
||
cursor:pointer;
|
||
}
|
||
.sheet-header:hover{background:var(--gray-100)}
|
||
.sheet-header h3{font-size:17px;font-weight:700}
|
||
.sheet-header .sheet-meta{font-size:13px;color:var(--gray-500)}
|
||
.sheet-body{padding:0 24px 24px;overflow-x:auto}
|
||
.sheet-body.collapsed{display:none}
|
||
|
||
/* Table */
|
||
table{width:100%;border-collapse:collapse;font-size:14px}
|
||
th{
|
||
background:var(--gray-100);text-align:left;padding:10px 14px;
|
||
font-weight:700;font-size:13px;white-space:nowrap;
|
||
position:sticky;top:0;
|
||
}
|
||
td{padding:9px 14px;border-bottom:1px solid var(--gray-100)}
|
||
tr:hover td{background:var(--cyan-50)}
|
||
|
||
/* Loading / Empty */
|
||
.loading{text-align:center;padding:40px;color:var(--gray-500)}
|
||
.spinner{
|
||
width:32px;height:32px;border:3px solid var(--gray-100);
|
||
border-top:3px solid var(--cyan);border-radius:50%;
|
||
animation:spin .8s linear infinite;margin:0 auto 12px;
|
||
}
|
||
@keyframes spin{to{transform:rotate(360deg)}}
|
||
.error-box{
|
||
background:#fff5f5;border:1px solid #ffcccc;border-radius:12px;
|
||
padding:20px;text-align:center;color:var(--red);
|
||
}
|
||
.error-box code{font-size:13px;background:var(--gray-100);padding:2px 8px;border-radius:4px}
|
||
.nodata{text-align:center;padding:30px;color:var(--gray-500);font-size:15px}
|
||
|
||
/* Expand arrow */
|
||
.arrow{display:inline-block;transition:transform .2s;font-size:13px}
|
||
.arrow.open{transform:rotate(90deg)}
|
||
|
||
/* Footer */
|
||
.footer{text-align:center;padding:24px;font-size:13px;color:var(--gray-500)}
|
||
|
||
@media(max-width:640px){
|
||
.topbar{padding:14px 16px}
|
||
.topbar h1{font-size:18px}
|
||
.stats{padding:16px 16px 0}
|
||
.content{padding:12px 12px 40px}
|
||
.sheet-header{padding:12px 16px}
|
||
.sheet-header h3{font-size:15px}
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
|
||
<header class="topbar">
|
||
<h1>📊 Сводный Dashboard</h1>
|
||
<div class="controls">
|
||
<span id="statusBadge" class="badge ok">Загрузка...</span>
|
||
<button class="btn" onclick="loadAll()">↻ Обновить</button>
|
||
<span class="timer" id="timerLabel">60 сек</span>
|
||
</div>
|
||
</header>
|
||
|
||
<div class="stats" id="statsRow"></div>
|
||
|
||
<main class="content" id="mainContent">
|
||
<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>
|
||
</main>
|
||
|
||
<footer class="footer">
|
||
<span id="lastRefresh">—</span> | Автообновление каждые 60 секунд
|
||
</footer>
|
||
|
||
<script>
|
||
const sheets = [
|
||
{ id:"150YZTbzkbYosICYfrR6K_od_1pOm6u64lkotQXIZw78", gid:"684524029", label:"Таблица 1" },
|
||
{ id:"1U1u3ZOVQCNLqiYJyId-DSlhENr3ZgNz-7xmwxePp3I4", gid:"684524029", label:"Таблица 2" },
|
||
{ id:"1Ksb1KN-SEO74a2To0qEsWP17P6wpzv44V2S4tDPWjs4", gid:"1492525306", label:"Таблица 3" },
|
||
{ id:"1kRQdo-H-seMQ0JkoLQ_q9izQvWnQ3DVwLoS6qOdP0rI", gid:"684524029", label:"Таблица 4" },
|
||
{ id:"1APZKFlxOPmgY82Te2JisGr8xE7njWjhxShZU_jiIQxU", gid:"684524029", label:"Таблица 5" },
|
||
{ id:"1BkLoaLY3PvS80pw-nSTz_au4HwuOgvMVTo0tBBYjIIQ", gid:"885466660", label:"Таблица 6" },
|
||
];
|
||
|
||
let countdown = 60;
|
||
let allData = [];
|
||
|
||
function csvUrl(s) {
|
||
return `https://docs.google.com/spreadsheets/d/${s.id}/pub?gid=${s.gid}&single=true&output=csv`;
|
||
}
|
||
|
||
async function fetchSheet(sheet) {
|
||
try {
|
||
const r = await fetch(csvUrl(sheet), { mode:"cors" });
|
||
if (!r.ok) throw new Error(`HTTP ${r.status}`);
|
||
const text = await r.text();
|
||
const rows = parseCSV(text);
|
||
return { sheet, rows, error: null };
|
||
} catch(e) {
|
||
return { sheet, rows: [], error: e.message };
|
||
}
|
||
}
|
||
|
||
function parseCSV(text) {
|
||
const lines = [];
|
||
let current = "";
|
||
let inQuotes = false;
|
||
for (let i = 0; i < text.length; i++) {
|
||
const ch = text[i];
|
||
if (ch === "\"") {
|
||
if (inQuotes && text[i+1] === "\"") { current += "\""; i++; }
|
||
else { inQuotes = !inQuotes; }
|
||
} else if (ch === "," && !inQuotes) {
|
||
lines.push(current); current = "";
|
||
} else if (ch === "\n" && !inQuotes) {
|
||
if (current || lines.length > 0) {
|
||
lines.push(current); current = "";
|
||
if (lines.length > 0) return [lines.map(c => c.trim())];
|
||
// Actually this is wrong, let me use a proper parser
|
||
}
|
||
} else {
|
||
current += ch;
|
||
}
|
||
}
|
||
// Fallback: simple split
|
||
return text.trim().split("\n").map(line => {
|
||
const cells = [];
|
||
let cell = "";
|
||
let inQ = false;
|
||
for (let i = 0; i < line.length; i++) {
|
||
const c = line[i];
|
||
if (c === "\"") {
|
||
if (inQ && line[i+1] === "\"") { cell += "\""; i++; }
|
||
else { inQ = !inQ; }
|
||
} else if (c === "," && !inQ) {
|
||
cells.push(cell.trim()); cell = "";
|
||
} else { cell += c; }
|
||
}
|
||
cells.push(cell.trim());
|
||
return cells;
|
||
}).filter(r => r.some(c => c !== ""));
|
||
}
|
||
|
||
async function loadAll() {
|
||
document.getElementById("mainContent").innerHTML =
|
||
'<div class="loading"><div class="spinner"></div><div>Загружаю данные...</div></div>';
|
||
|
||
const results = await Promise.all(sheets.map(fetchSheet));
|
||
allData = results;
|
||
countdown = 60;
|
||
updateTimer();
|
||
document.getElementById("lastRefresh").textContent =
|
||
"Последнее обновление: " + new Date().toLocaleTimeString("ru-RU");
|
||
render(results);
|
||
}
|
||
|
||
function render(results) {
|
||
const ok = results.filter(r => !r.error).length;
|
||
const err = results.filter(r => r.error).length;
|
||
|
||
const badge = document.getElementById("statusBadge");
|
||
if (err === 0) {
|
||
badge.className = "badge ok";
|
||
badge.textContent = `${ok} таблиц загружено`;
|
||
} else if (ok > 0) {
|
||
badge.className = "badge warn";
|
||
badge.textContent = `${ok} загружено, ${err} недоступны`;
|
||
} else {
|
||
badge.className = "badge err";
|
||
badge.textContent = "Нет данных";
|
||
}
|
||
|
||
renderStats(results);
|
||
renderMain(results);
|
||
}
|
||
|
||
function renderStats(results) {
|
||
let totalRows = 0, totalCols = 0;
|
||
results.forEach(r => {
|
||
if (r.rows.length > 0) {
|
||
totalRows += r.rows.length;
|
||
totalCols = Math.max(totalCols, r.rows[0]?.length || 0);
|
||
}
|
||
});
|
||
|
||
document.getElementById("statsRow").innerHTML = `
|
||
<div class="stat-card"><div class="value">${results.length}</div><div class="label">Всего таблиц</div></div>
|
||
<div class="stat-card"><div class="value">${results.filter(r => !r.error).length}</div><div class="label">Загружено</div></div>
|
||
<div class="stat-card"><div class="value">${totalRows}</div><div class="label">Всего строк данных</div></div>
|
||
<div class="stat-card"><div class="value">${results.filter(r => r.error).length}</div><div class="label">Недоступны</div></div>
|
||
`;
|
||
}
|
||
|
||
function renderMain(results) {
|
||
let html = "";
|
||
results.forEach((r, i) => {
|
||
const s = r.sheet;
|
||
const openLink = `https://docs.google.com/spreadsheets/d/${s.id}/edit?gid=${s.gid}`;
|
||
html += `<div class="sheet-section">`;
|
||
html += `<div class="sheet-header" onclick="toggleSheet(this)">
|
||
<h3>📋 ${s.label} <span class="arrow">▶</span></h3>
|
||
<span class="sheet-meta">${r.error ? "❌ " + r.error : r.rows.length + " строк"}</span>
|
||
</div>`;
|
||
html += `<div class="sheet-body ${r.rows.length === 0 ? 'collapsed' : ''}">`;
|
||
|
||
if (r.error) {
|
||
html += `<div class="error-box">
|
||
<p>⚠️ Таблица не загрузилась</p>
|
||
<p style="font-size:13px;margin-top:8px">Убедись, что она опубликована:<br>
|
||
<code>Файл → Поделиться → Опубликовать в интернете</code></p>
|
||
<p style="margin-top:8px"><a href="${openLink}" target="_blank" rel="noopener" style="color:var(--cyan)">Открыть в Google Sheets ↗</a></p>
|
||
</div>`;
|
||
} else if (r.rows.length === 0) {
|
||
html += `<div class="nodata">Нет данных</div>`;
|
||
} else {
|
||
html += `<div style="overflow-x:auto;max-height:500px;overflow-y:auto">`;
|
||
html += `<table>`;
|
||
r.rows.forEach((row, ri) => {
|
||
html += "<tr>";
|
||
row.forEach(cell => {
|
||
html += ri === 0 ? `<th>${esc(cell)}</th>` : `<td>${esc(cell)}</td>`;
|
||
});
|
||
html += "</tr>";
|
||
});
|
||
html += `</table></div>`;
|
||
html += `<p style="margin-top:12px;font-size:13px;color:var(--gray-500)">
|
||
<a href="${openLink}" target="_blank" rel="noopener" style="color:var(--cyan)">Открыть в Google Sheets ↗</a>
|
||
</p>`;
|
||
}
|
||
|
||
html += `</div></div>`;
|
||
});
|
||
document.getElementById("mainContent").innerHTML = html;
|
||
}
|
||
|
||
function esc(s) {
|
||
return String(s).replace(/&/g,"&").replace(/</g,"<").replace(/>/g,">");
|
||
}
|
||
|
||
function toggleSheet(header) {
|
||
const body = header.nextElementSibling;
|
||
const arrow = header.querySelector(".arrow");
|
||
body.classList.toggle("collapsed");
|
||
arrow.classList.toggle("open");
|
||
}
|
||
|
||
function updateTimer() {
|
||
document.getElementById("timerLabel").textContent = countdown + " сек";
|
||
}
|
||
|
||
setInterval(() => {
|
||
countdown--;
|
||
updateTimer();
|
||
if (countdown <= 0) loadAll();
|
||
}, 1000);
|
||
|
||
loadAll();
|
||
</script>
|
||
|
||
</body>
|
||
</html>
|