[html]
<div id="pulseProgressHeader" class="pp-wrap">
<!-- ===== TOP / IMAGE / LOG ===== -->
<div class="pp-topgrid">
<div class="pp-card">
<div class="pp-card-h">ТОП ВКЛАДЧИКОВ</div>
<div id="ppTop" class="pp-toplist">
<div class="pp-empty">Загрузка…</div>
</div>
</div>
<div class="pp-center">
<img class="pp-center-img" src="https://upforme.ru/uploads/001c/84/76/2/446867.png" alt="PULSE Monument">
</div>
<div class="pp-card">
<div class="pp-card-h">ЖУРНАЛ ВКЛАДА</div>
<div id="ppLog" class="pp-log">
<div class="pp-empty">Загрузка…</div>
</div>
</div>
</div>
<!-- ===== BARS ===== -->
<div class="pp-title">ШКАЛА ПРОГРЕССА СТРОЙКИ</div>
<div class="pp-row pp-total">
<div class="pp-label"></div>
<div class="pp-bar"><div class="pp-fill" id="pp3"></div></div>
<div class="pp-num" id="pp3t">0 / 0</div>
</div>
<div class="pp-upd" id="ppUpd">Обновление…</div>
</div>
<style>
.pp-wrap{
position:relative; /* ✅ нужно для :before/:after */
overflow:hidden; /* ✅ чтобы декоративные слои не вылезали */
max-width:980px;
margin:14px auto;
padding:16px 16px 18px;
border-radius:18px;
font-family:'Roboto Condensed',system-ui,Segoe UI,Roboto,Arial;
color:#e9e6ee;
background:
radial-gradient(1200px 600px at 35% 20%, rgba(120,110,150,.22), transparent 60%),
radial-gradient(900px 520px at 85% 35%, rgba(90,80,120,.18), transparent 55%),
linear-gradient(180deg,#0b0b0e,#070709);
border:1px solid rgba(160,150,190,.18);
/* ✅ ВАЖНО: убрали лишнюю запятую */
box-shadow:
0 0 0 1px rgba(20,18,24,.65) inset;
}
/* "шум/бумага" сверху (без картинок) */
.pp-wrap:before{
content:"";
position:absolute; inset:0;
background:
repeating-linear-gradient(0deg, rgba(255,255,255,.04), rgba(255,255,255,.04) 1px, transparent 1px, transparent 3px);
opacity:.12;
mix-blend-mode:overlay;
pointer-events:none;
z-index:0;
}
.pp-wrap:after{
content:"";
position:absolute; inset:-2px;
background:
radial-gradient(circle at 12% 18%, rgba(210,140,70,.16), transparent 35%),
radial-gradient(circle at 82% 22%, rgba(160,150,210,.14), transparent 38%),
radial-gradient(circle at 55% 78%, rgba(120,110,150,.10), transparent 42%);
opacity:.55;
pointer-events:none;
z-index:0;
}
/* top grid */
.pp-topgrid{
display:grid;
grid-template-columns: 1fr 420px 1fr;
gap:14px;
align-items:stretch;
margin-bottom:14px;
position:relative;
z-index:1;
}
.pp-card{
background: linear-gradient(180deg, rgba(255,255,255,.05), rgba(255,255,255,.02));
border:1px solid rgba(170,160,210,.14);
border-radius:16px;
box-shadow:
0 0 0 1px rgba(0,0,0,.35) inset,
0 10px 24px rgba(0,0,0,.35);
padding:12px;
min-height:220px;
overflow:hidden;
position:relative;
z-index:1;
}
.pp-card:before{
content:"";
position:absolute; inset:0;
background:
radial-gradient(12px 10px at 10% 0%, rgba(255,255,255,.12), transparent 65%),
radial-gradient(14px 10px at 55% 0%, rgba(255,255,255,.10), transparent 65%),
radial-gradient(10px 10px at 90% 0%, rgba(255,255,255,.10), transparent 65%);
opacity:.18;
pointer-events:none;
}
.pp-card-h{
font-weight:800;
letter-spacing:.14em;
text-transform:uppercase;
font-size:12px;
color:#caa6ff;
margin-bottom:10px;
text-shadow:0 1px 0 rgba(0,0,0,.4);
}
.pp-center{
border-radius:16px;
overflow:hidden;
border:1px solid rgba(210,140,70,.22);
box-shadow:
0 0 0 1px rgba(0,0,0,.35) inset,
0 14px 30px rgba(0,0,0,.40),
0 0 26px rgba(210,140,70,.12);
background:rgba(0,0,0,.35);
min-height:220px;
display:flex;
align-items:center;
justify-content:center;
position:relative;
z-index:1;
}
.pp-center-img{
width:100%;
height:620px;
object-fit:contain;
display:block;
padding:10px;
filter: drop-shadow(0 0 14px rgba(180,150,220,.18));
}
.pp-toplist{display:flex;flex-direction:column;gap:8px}
.pp-toprow{
display:grid;
grid-template-columns: 26px 1fr auto;
gap:10px;
align-items:center;
padding:8px 10px;
border-radius:12px;
background:rgba(0,0,0,.32);
border:1px solid rgba(170,160,210,.12);
}
.pp-rank{
width:26px;height:26px;border-radius:10px;
display:flex;align-items:center;justify-content:center;
background:rgba(202,166,255,.12);
border:1px solid rgba(202,166,255,.22);
color:#e9d8ff;
font-weight:900;font-size:12px;
}
.pp-name{
font-weight:700;
white-space:nowrap;overflow:hidden;text-overflow:ellipsis;
}
.pp-score{
font-weight:900;
color:#d89a55;
}
/* log */
.pp-log{
max-height:620px;
overflow:auto;
padding-right:4px;
scrollbar-width:thin;
scrollbar-color: rgba(202,166,255,.55) transparent;
display:flex;
flex-direction:column;
gap:8px;
}
.pp-logrow{
padding:8px 10px;
border-radius:12px;
background:rgba(0,0,0,.30);
border:1px solid rgba(170,160,210,.12);
line-height:1.25;
font-size:12px;
color:#d6d1dc;
}
.pp-logrow b{color:#e9d8ff}
.pp-logtime{color:#9c93a8;font-size:11px;margin-bottom:3px}
.pp-add{
margin-left:6px;
font-weight:900;
padding:1px 6px;
border-radius:999px;
border:1px solid rgba(170,160,210,.22);
background:rgba(0,0,0,.28);
}
.pp-add.pp-add0{ color:#b7b0c2; border-color:rgba(170,160,210,.14); }
.pp-add.pp-add1{ color:#d89a55; border-color:rgba(216,154,85,.22); }
.pp-add.pp-add2{ color:#caa6ff; border-color:rgba(202,166,255,.26); }
/* bars */
.pp-title{
font-weight:900;letter-spacing:1px;margin:6px 0 10px;
color:#caa6ff;text-align:center;
position:relative;
z-index:1;
}
.pp-row{
display:grid;grid-template-columns:160px 1fr 120px;gap:12px;
align-items:center;margin:10px 0;
position:relative;
z-index:1;
}
.pp-total{margin-top:14px;padding-top:12px;border-top:1px solid rgba(170,160,210,.18)}
.pp-label{font-size:13px;color:#ddd}
.pp-num{font-size:12px;color:#b7b0c2;text-align:right}
.pp-bar{height:12px;border-radius:999px;background:rgba(255,255,255,.08);overflow:hidden}
.pp-fill{
height:100%;width:0%;
background:linear-gradient(90deg,#caa6ff,#d89a55);
transition:width .35s ease
}
.pp-upd{margin-top:8px;font-size:12px;color:#a59db3;text-align:center;position:relative;z-index:1}
.pp-empty{color:#9c93a8;font-size:12px;padding:8px 4px}
@media (max-width:700px){
.pp-topgrid{grid-template-columns:1fr; }
.pp-center-img{height:200px}
.pp-card{min-height:auto}
}
@media (max-width:650px){
.pp-row{grid-template-columns:1fr;gap:6px}
.pp-num{text-align:left}
}
.pp-done{
padding:10px 12px;
border-radius:12px;
font-weight:900;
letter-spacing:.04em;
background:linear-gradient(90deg, rgba(40,255,120,.18), rgba(40,255,120,.10));
border:1px solid rgba(40,255,120,.55);
color:#bfffd7;
text-align:center;
box-shadow:0 0 12px rgba(40,255,120,.25) inset;
}
</style>
<script>
(function(){
// Конфигурация
const INVENTORY_STAT_CONFIG = {
INVENTORY_TOPIC_ID: 33, // Топик с постами
BASE_URL: window.location.origin,
POSTS_PER_REQUEST: 100,
API_CHUNK_SIZE: 50,
RELOAD: 8 * 1000 // 8 секунд в миллисекунды
};
const ALLOWED_ITEMS = new Set([
"Арматура", "Булыжник", "Кабель подсветки", "Кирпич", "Крепежные скобы",
"Стальная пластина", "Обломок колонны", "Бетонная плита", "Ржавая балка",
"Фрагмент знамени", "Болты", "Гипсовый раствор", "Осколок эмблемы",
"Блок питания", "Светодиод", "Цемент", "Веревка", "Стекло",
"Светодиодные буквы", "Набор мастера"
]);
const DOUBLE_ITEMS = new Set([
"Болты", "Гипсовый раствор", "Осколок эмблемы", "Блок питания",
"Светодиод", "Цемент"
]);
const LOG_ONLY_ITEMS = new Set([
"Веревка", "Стекло", "Светодиодные буквы", "Набор мастера"
]);
// Функция подсчета очков
function pointsForItem(name) {
name = String(name || "").trim();
if (LOG_ONLY_ITEMS.has(name)) return 0;
if (DOUBLE_ITEMS.has(name)) return 2;
return 1;
}
// метод для сбора данных
const LOCAL_LAST_KEY = "pulse_ril_local_last_v1";
let refreshTimer = null;
let inFlight = false;
// API вызов (наш метод)
async function apiCall(method, params = {}) {
try {
const urlParams = new URLSearchParams({
method: method,
...params
});
const url = `/api.php?${urlParams.toString()}`;
const response = await fetch(url, {
method: 'POST'
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
return result.response || result;
} catch (error) {
console.error(`Ошибка при вызове API ${method}:`, error);
throw error;
}
}
// Загрузка всех постов из топика инвентаря (наш метод)
async function getAllPostsInInventoryTopic() {
try {
let allPosts = [];
let skip = 0;
let hasMore = true;
while (hasMore) {
const postsResponse = await apiCall('post.get', {
topic_id: INVENTORY_STAT_CONFIG.INVENTORY_TOPIC_ID,
fields: 'id,message,posted,topic_id,user_id,username',
sort_by: 'id',
sort_dir: 'asc',
skip: skip,
limit: INVENTORY_STAT_CONFIG.POSTS_PER_REQUEST
});
if (!postsResponse || postsResponse.length === 0) {
hasMore = false;
break;
}
const postsArray = Array.isArray(postsResponse) ? postsResponse :
(postsResponse.posts || postsResponse.response || []);
allPosts.push(...postsArray);
skip += INVENTORY_STAT_CONFIG.POSTS_PER_REQUEST;
if (postsArray.length < INVENTORY_STAT_CONFIG.POSTS_PER_REQUEST) {
hasMore = false;
}
await new Promise(resolve => setTimeout(resolve, INVENTORY_STAT_CONFIG.API_CHUNK_SIZE));
}
return allPosts;
} catch (error) {
console.error("Ошибка загрузки постов:", error);
throw error;
}
}
// Декодирование HTML (наш метод)
function decodeHtmlEntities(text) {
const textarea = document.createElement('textarea');
textarea.innerHTML = text;
return textarea.value;
}
// Функция для парсинга даты UTC из data-finded
function parseUTCDate(dateString) {
try {
// Пытаемся распарсить как UTC дату
const date = new Date(dateString);
if (!isNaN(date.getTime())) {
return date.getTime(); // Возвращаем timestamp
}
// Если не получилось, возвращаем текущее время
return Date.now();
} catch (error) {
console.warn('Ошибка парсинга UTC даты:', dateString, error);
return Date.now();
}
}
// Форматирование даты для вывода (в локальное время пользователя)
function formatLocalDate(timestamp) {
const date = new Date(timestamp);
// Формат: "02.02.2026, 23:09:05"
const day = String(date.getDate()).padStart(2, '0');
const month = String(date.getMonth() + 1).padStart(2, '0');
const year = date.getFullYear();
const hours = String(date.getHours()).padStart(2, '0');
const minutes = String(date.getMinutes()).padStart(2, '0');
const seconds = String(date.getSeconds()).padStart(2, '0');
return `${day}.${month}.${year}, ${hours}:${minutes}:${seconds}`;
}
// Извлечение данных из постов (наш метод)
async function extractInventoryData() {
const posts = await getAllPostsInInventoryTopic();
const itemStats = new Map();
const playerStats = new Map();
const logs = [];
let totalPoints = 0;
// Собираем данные по предметам и игрокам
for (const post of posts) {
if (!post.message || !post.message.includes('[html]')) continue;
const htmlMatch = post.message.match(/\[html\]([\s\S]*?)\[\/html\]/i);
if (!htmlMatch || !htmlMatch[1]) continue;
const decodedHtml = decodeHtmlEntities(htmlMatch[1]);
const username = post.username || "Неизвестно";
// Ищем предметы с data-finded (формат UTC)
const itemRegex = /<img[^>]*src="[^"]*"[^>]*title="([^"]*)"[^>]*data-finded="([^"]*)"[^>]*>/gi;
let match;
while ((match = itemRegex.exec(decodedHtml)) !== null) {
const title = match[1].trim();
const findedDateUTC = match[2].trim();
if (!title || !findedDateUTC) continue;
// Парсим UTC дату
const timestamp = parseUTCDate(findedDateUTC);
// Подсчет предметов по типам
if (itemStats.has(title)) {
itemStats.set(title, itemStats.get(title) + 1);
} else {
itemStats.set(title, 1);
}
// Подсчет очков для игрока (используем распределение из скрипта джуна)
const points = pointsForItem(title);
totalPoints += points;
if (playerStats.has(username)) {
playerStats.set(username, playerStats.get(username) + points);
} else {
playerStats.set(username, points);
}
// Добавляем в журнал
logs.push({
ts: timestamp,
player: username,
item: title,
add: points
});
}
}
// Сортируем журнал по дате (новые сверху)
logs.sort((a, b) => b.ts - a.ts);
// Создаем топ игроков
const topPlayers = Array.from(playerStats.entries())
.map(([name, total]) => ({ name, total }))
.sort((a, b) => b.total - a.total)
.slice(0, 10);
return {
itemStats: Array.from(itemStats.entries()),
topPlayers,
logs,
totalPoints,
updatedAt: Date.now(),
totalItems: Array.from(itemStats.values()).reduce((a, b) => a + b, 0)
};
}
// Создаем снимок данных для таблицы
async function createSnapshot() {
try {
const data = await extractInventoryData();
const TARGET_TOTAL = 100;
const progressPercent = Math.min(100, (data.totalPoints / TARGET_TOTAL) * 100);
const completed = data.totalPoints >= TARGET_TOTAL;
// Берем последнюю запись из журнала
const lastLog = data.logs.length > 0 ? data.logs[0] : null;
return {
points: data.totalPoints,
target: TARGET_TOTAL,
p: progressPercent,
completed: completed,
updatedAt: data.updatedAt,
last: lastLog ? {
player: lastLog.player,
item: lastLog.item,
add: lastLog.add,
eid: null,
at: new Date(lastLog.ts).toISOString()
} : null,
topPlayers: data.topPlayers,
logs: data.logs.slice(0, 35)
};
} catch (error) {
console.error('Ошибка создания снимка:', error);
return null;
}
}
// Функции для отображения (из HTML джуна)
const send = () => {
const requestId = Math.random().toString(16).slice(2);
window.top.postMessage({ _pulseProgress:true, type:"progressRequest", requestId }, "*");
return new Promise(resolve => {
const handler = (e) => {
if (e.data?._pulseProgress && e.data.type === "progressResponse" && e.data.requestId === requestId) {
window.removeEventListener("message", handler);
resolve(e.data.data);
}
};
window.addEventListener("message", handler);
setTimeout(()=>{ window.removeEventListener("message", handler); resolve(null); }, 4500);
});
};
function fmtDate(ts){
// Используем нашу функцию для форматирования в локальное время
return formatLocalDate(ts);
}
function getLocalLast(){
try {
const raw = sessionStorage.getItem(LOCAL_LAST_KEY);
if (!raw) return null;
const o = JSON.parse(raw);
if (!o || !o.ts) return null;
if (Date.now() - Number(o.ts) > 25000) return null;
return o;
} catch(e){ return null; }
}
function renderTop(top){
const el = document.getElementById("ppTop");
if (!Array.isArray(top) || !top.length){
el.innerHTML = '<div class="pp-empty">Пока нет вкладов</div>';
return;
}
el.innerHTML = top.slice(0,10).map((p, i) => {
const name = String(p.name || "—");
const total = Number(p.total || 0);
return `
<div class="pp-toprow">
<div class="pp-rank">${i+1}</div>
<div class="pp-name" title="${name}">${name}</div>
<div class="pp-score">${total}</div>
</div>
`;
}).join("");
}
function renderLog(logs, serverLast){
const el = document.getElementById("ppLog");
const local = getLocalLast();
let localRow = "";
if (local){
const sEid = String(serverLast?.eid || "");
if (!sEid || sEid !== String(local.eid || "")){
localRow = `
<div class="pp-logrow" style="border-color:rgba(216,154,85,.35);">
<div class="pp-logtime">${fmtDate(local.ts)}</div>
<div><b>${String(local.player||"—")}</b> приносит: <b>${String(local.item||"—")}</b>
<span class="pp-add pp-add1">⏳</span>
</div>
</div>
`;
}
}
if (!Array.isArray(logs) || !logs.length){
el.innerHTML = localRow || '<div class="pp-empty">Журнал пуст</div>';
return;
}
const body = logs.slice(0,35).map(l => {
const ts = Number(l.ts || 0);
const when = ts ? fmtDate(ts) : "—";
const player = String(l.player || "—");
const item = String(l.item || "—");
const add = Number(l.add || 0);
const cls = add === 2 ? "pp-add2" : (add === 1 ? "pp-add1" : "pp-add0");
return `
<div class="pp-logrow">
<div class="pp-logtime">${when}</div>
<div>
<b>${player}</b> приносит: <b>${item}</b>
${add > 0 ? `<span class="pp-add ${cls}">+${add}</span>` : ``}
</div>
</div>
`;
}).join("");
el.innerHTML = localRow + body;
}
function applyBars(snap){
if (!snap) return;
document.getElementById("pp3").style.width = (snap.p || 0) + "%";
document.getElementById("pp3t").textContent = `${snap.points} / ${snap.target}`;
const d = snap.updatedAt ? new Date(snap.updatedAt) : null;
document.getElementById("ppUpd").textContent = d ? ("Обновлено: " + d.toLocaleString("ru-RU")) : "Обновление…";
}
function renderDoneBanner(done){
const logWrap = document.getElementById("ppLog");
if (!logWrap) return;
const old = logWrap.querySelector(".pp-done");
if (old) old.remove();
if (done){
const div = document.createElement("div");
div.className = "pp-done";
div.textContent = " Постройка завершена!";
logWrap.prepend(div);
}
}
function applyAll(snap){
applyBars(snap);
renderTop(snap.topPlayers || []);
renderLog(snap.logs || [], snap.last || null);
renderDoneBanner(!!snap.completed);
}
async function tick(){
if (inFlight) return;
inFlight = true;
try{
// Используем наш метод для создания снимка данных
const snapshot = await createSnapshot();
if (snapshot) {
applyAll(snapshot);
}
} finally {
inFlight = false;
}
}
function scheduleTickFast(){
if (refreshTimer) return;
refreshTimer = setTimeout(() => {
refreshTimer = null;
tick();
}, 250);
}
window.addEventListener("message", function(e) {
if (e.data?._pulseProgress && e.data.type === "forceRefresh") {
scheduleTickFast();
}
});
// Запускаем первичную загрузку
tick();
setInterval(tick, INVENTORY_STAT_CONFIG.RELOAD);
})();
</script>
[/html]
[hideprofile]