mirror of
https://github.com/Coldsmiles/infstarweb.git
synced 2026-04-23 02:30:41 +08:00
feat: Add crowdfunding and sponsor management features
- Introduced fund_progress.txt to track server upgrade funding and vehicle replacement costs. - Created sponsors.txt to log sponsorship contributions with details including name, project, amount, and date. - Developed components.js for reusable UI components including navbar and footer. - Implemented data_utils.js for parsing sponsor data and calculating totals. - Added script.js for managing server status, crowdfunding display, and sponsor data fetching. - Created sponsor_script.js for detailed sponsor data management, filtering, and UI interactions. - Developed stats_script.js for player statistics display, including leaderboards and detailed player stats in a modal.
This commit is contained in:
125
js/components.js
Normal file
125
js/components.js
Normal file
@@ -0,0 +1,125 @@
|
||||
|
||||
const Components = {
|
||||
navbarHTML: `
|
||||
<nav class="navbar">
|
||||
<div class="nav-content">
|
||||
<button class="mobile-toggle" id="mobile-toggle" aria-label="菜单">
|
||||
<i class="fas fa-bars"></i>
|
||||
</button>
|
||||
<div class="logo">
|
||||
<a href="/">
|
||||
<img src="https://img.lunadeer.cn/i/2024/04/22/6625ce6c8ddc1.png" alt="白鹿原 Minecraft 服务器 Logo">
|
||||
</a>
|
||||
</div>
|
||||
<div class="nav-links desktop-only">
|
||||
<a href="/doc.html">文档</a>
|
||||
<a href="/map.html">地图</a>
|
||||
<a href="/photo.html">相册</a>
|
||||
<a href="/stats.html">数据</a>
|
||||
<a href="/sponsor.html">赞助</a>
|
||||
<a href="https://qm.qq.com/q/9izlHDoef6" target="_blank">群聊</a>
|
||||
</div>
|
||||
<div class="nav-cta-container">
|
||||
<a href="https://outline.lunadeer.cn/s/447e5db6-8af4-468e-b7c5-cdb7b48aa439/doc/5yqg5ywl5pyn5yqh5zmo-WE4jkTxRmM" class="nav-cta" target="_blank">加入游戏</a>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- Mobile Menu -->
|
||||
<div class="mobile-menu" id="mobile-menu">
|
||||
<div class="mobile-menu-links">
|
||||
<a href="/doc.html">文档</a>
|
||||
<a href="/map.html">地图</a>
|
||||
<a href="/photo.html">相册</a>
|
||||
<a href="/stats.html">数据</a>
|
||||
<a href="/sponsor.html">赞助</a>
|
||||
<a href="https://qm.qq.com/q/9izlHDoef6" target="_blank">群聊</a>
|
||||
</div>
|
||||
</div>
|
||||
`,
|
||||
|
||||
footerHTML: `
|
||||
<footer>
|
||||
<div class="container">
|
||||
<div class="footer-content">
|
||||
<div class="footer-logo">白鹿原</div>
|
||||
<p>© 2026 白鹿原 Minecraft 服务器.</p>
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
`,
|
||||
|
||||
init: function() {
|
||||
// Inject Navbar
|
||||
const navContainer = document.getElementById('navbar-component');
|
||||
if (navContainer) {
|
||||
navContainer.innerHTML = this.navbarHTML;
|
||||
}
|
||||
|
||||
// Inject Footer
|
||||
const footerContainer = document.getElementById('footer-component');
|
||||
if (footerContainer) {
|
||||
footerContainer.innerHTML = this.footerHTML;
|
||||
}
|
||||
|
||||
// Setup Mobile Menu Logic
|
||||
this.setupMobileMenu();
|
||||
|
||||
// Highlight current page
|
||||
this.highlightCurrentPage();
|
||||
},
|
||||
|
||||
setupMobileMenu: function() {
|
||||
const toggle = document.getElementById('mobile-toggle');
|
||||
const menu = document.getElementById('mobile-menu');
|
||||
|
||||
if (toggle && menu) {
|
||||
const icon = toggle.querySelector('i');
|
||||
// Remove old listeners if any to avoid duplicates?
|
||||
// Since we just injected the HTML, there are no listeners.
|
||||
|
||||
toggle.addEventListener('click', () => {
|
||||
menu.classList.toggle('active');
|
||||
document.body.classList.toggle('menu-open');
|
||||
|
||||
if (menu.classList.contains('active')) {
|
||||
if(icon) {
|
||||
icon.classList.remove('fa-bars');
|
||||
icon.classList.add('fa-times');
|
||||
}
|
||||
} else {
|
||||
if(icon) {
|
||||
icon.classList.remove('fa-times');
|
||||
icon.classList.add('fa-bars');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
menu.querySelectorAll('a').forEach(link => {
|
||||
link.addEventListener('click', () => {
|
||||
menu.classList.remove('active');
|
||||
document.body.classList.remove('menu-open');
|
||||
if(icon) {
|
||||
icon.classList.remove('fa-times');
|
||||
icon.classList.add('fa-bars');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
},
|
||||
|
||||
highlightCurrentPage: function() {
|
||||
const currentPath = window.location.pathname;
|
||||
const links = document.querySelectorAll('.nav-links a, .mobile-menu-links a');
|
||||
|
||||
links.forEach(link => {
|
||||
if (link.getAttribute('href') === currentPath) {
|
||||
link.classList.add('active'); // You might need to add CSS for .active
|
||||
}
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
Components.init();
|
||||
});
|
||||
42
js/data_utils.js
Normal file
42
js/data_utils.js
Normal file
@@ -0,0 +1,42 @@
|
||||
const DataUtils = {
|
||||
parseSponsorsText: function(text) {
|
||||
const sponsors = [];
|
||||
|
||||
if (!text) {
|
||||
return sponsors;
|
||||
}
|
||||
|
||||
const lines = text.trim().split('\n');
|
||||
lines.forEach(line => {
|
||||
const parts = line.split(',');
|
||||
if (parts.length < 3) {
|
||||
return;
|
||||
}
|
||||
|
||||
const name = parts[0].trim();
|
||||
const project = parts[1].trim();
|
||||
const amountStr = parts[2].trim().replace('¥', '');
|
||||
const amount = parseFloat(amountStr);
|
||||
const date = parts[3] ? parts[3].trim() : '';
|
||||
|
||||
if (!isNaN(amount)) {
|
||||
sponsors.push({ name, project, amount, date });
|
||||
}
|
||||
});
|
||||
|
||||
return sponsors;
|
||||
},
|
||||
|
||||
buildSponsorTotals: function(sponsors) {
|
||||
const totals = {};
|
||||
|
||||
sponsors.forEach(item => {
|
||||
if (!totals[item.name]) {
|
||||
totals[item.name] = 0;
|
||||
}
|
||||
totals[item.name] += item.amount;
|
||||
});
|
||||
|
||||
return totals;
|
||||
}
|
||||
};
|
||||
209
js/script.js
Normal file
209
js/script.js
Normal file
@@ -0,0 +1,209 @@
|
||||
function copyIp() {
|
||||
const ipText = document.getElementById('server-ip').innerText;
|
||||
const tooltip = document.getElementById('copy-tooltip');
|
||||
|
||||
navigator.clipboard.writeText(ipText).then(() => {
|
||||
tooltip.innerText = "已复制!";
|
||||
tooltip.classList.add('show');
|
||||
|
||||
setTimeout(() => {
|
||||
tooltip.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
tooltip.innerText = "点击复制 IP";
|
||||
}, 200); // Wait for fade out
|
||||
}, 2000);
|
||||
}).catch(err => {
|
||||
console.error('无法复制文本: ', err);
|
||||
tooltip.innerText = "复制失败";
|
||||
tooltip.classList.add('show');
|
||||
setTimeout(() => {
|
||||
tooltip.classList.remove('show');
|
||||
}, 2000);
|
||||
});
|
||||
}
|
||||
|
||||
// Sponsors Logic
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchSponsors();
|
||||
fetchCrowdfunding();
|
||||
// setupModal(); // Removed, modal is gone
|
||||
fetchServerStatus();
|
||||
startRuntimeTimer();
|
||||
});
|
||||
|
||||
|
||||
|
||||
function startRuntimeTimer() {
|
||||
const startTime = new Date("2021-09-14T09:57:59").getTime();
|
||||
|
||||
function update() {
|
||||
const now = new Date().getTime();
|
||||
const diff = now - startTime;
|
||||
|
||||
const days = Math.floor(diff / (1000 * 60 * 60 * 24));
|
||||
const hours = Math.floor((diff % (1000 * 60 * 60 * 24)) / (1000 * 60 * 60));
|
||||
const minutes = Math.floor((diff % (1000 * 60 * 60)) / (1000 * 60));
|
||||
const seconds = Math.floor((diff % (1000 * 60)) / 1000);
|
||||
|
||||
const daysEl = document.getElementById("runtime-days");
|
||||
const hoursEl = document.getElementById("runtime-hours");
|
||||
const minutesEl = document.getElementById("runtime-minutes");
|
||||
const secondsEl = document.getElementById("runtime-seconds");
|
||||
|
||||
if (daysEl) daysEl.innerText = days;
|
||||
if (hoursEl) hoursEl.innerText = hours;
|
||||
if (minutesEl) minutesEl.innerText = minutes;
|
||||
if (secondsEl) secondsEl.innerText = seconds;
|
||||
}
|
||||
|
||||
update();
|
||||
setInterval(update, 1000);
|
||||
}
|
||||
|
||||
async function fetchServerStatus() {
|
||||
const countElement = document.getElementById('online-count');
|
||||
const listElement = document.getElementById('players-list');
|
||||
const dotElement = document.querySelector('.status-dot');
|
||||
|
||||
try {
|
||||
const response = await fetch('https://api.mcstatus.io/v2/status/java/mcpure.lunadeer.cn');
|
||||
const data = await response.json();
|
||||
|
||||
if (data.online) {
|
||||
countElement.innerText = `在线人数: ${data.players.online} / ${data.players.max}`;
|
||||
dotElement.classList.remove('offline');
|
||||
|
||||
if (data.players.list && data.players.list.length > 0) {
|
||||
listElement.innerHTML = data.players.list.map(player => `
|
||||
<div class="player-item">
|
||||
<img src="https://minotar.net/avatar/${player.name_raw}/16" class="player-avatar" onerror="this.style.display='none'">
|
||||
<span>${player.name_raw}</span>
|
||||
</div>
|
||||
`).join('');
|
||||
} else {
|
||||
listElement.innerHTML = '<div class="player-item player-item-muted">暂无玩家在线</div>';
|
||||
}
|
||||
} else {
|
||||
countElement.innerText = '服务器离线';
|
||||
dotElement.classList.add('offline');
|
||||
listElement.innerHTML = '<div class="player-item player-item-error">服务器离线</div>';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching server status:', error);
|
||||
countElement.innerText = '无法获取状态';
|
||||
dotElement.classList.add('offline');
|
||||
listElement.innerHTML = '<div class="player-item player-item-error">获取失败</div>';
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchCrowdfunding() {
|
||||
try {
|
||||
console.log('Fetching crowdfunding data...');
|
||||
const response = await fetch('data/fund_progress.txt');
|
||||
if (!response.ok) {
|
||||
console.error('Failed to fetch data/fund_progress.txt:', response.status, response.statusText);
|
||||
return;
|
||||
}
|
||||
|
||||
const text = await response.text();
|
||||
console.log('Crowdfunding data received:', text);
|
||||
const lines = text.trim().split('\n');
|
||||
|
||||
const funds = [];
|
||||
lines.forEach(line => {
|
||||
// Replace Chinese comma with English comma just in case
|
||||
const normalizedLine = line.replace(/,/g, ',');
|
||||
const parts = normalizedLine.split(',');
|
||||
if (parts.length >= 3) {
|
||||
const name = parts[0].trim();
|
||||
const current = parseFloat(parts[1].trim());
|
||||
const target = parseFloat(parts[2].trim());
|
||||
|
||||
if (name && !isNaN(current) && !isNaN(target)) {
|
||||
funds.push({ name, current, target });
|
||||
}
|
||||
}
|
||||
});
|
||||
console.log('Parsed funds:', funds);
|
||||
|
||||
if (funds.length > 0) {
|
||||
renderCrowdfunding(funds);
|
||||
const section = document.getElementById('crowdfunding-section');
|
||||
if (section) {
|
||||
section.style.display = 'block';
|
||||
console.log('Crowdfunding section displayed');
|
||||
} else {
|
||||
console.error('Crowdfunding section element not found');
|
||||
}
|
||||
} else {
|
||||
console.warn('No valid crowdfunding data found');
|
||||
}
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading crowdfunding data:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderCrowdfunding(funds) {
|
||||
const container = document.getElementById('crowdfunding-grid');
|
||||
|
||||
container.innerHTML = funds.map(fund => {
|
||||
const percentage = Math.min(100, Math.max(0, (fund.current / fund.target) * 100));
|
||||
return `
|
||||
<div class="fund-card">
|
||||
<div class="fund-header">
|
||||
<div class="fund-title">${fund.name}</div>
|
||||
<div class="fund-stats">
|
||||
<span>¥${fund.current}</span> / ¥${fund.target}
|
||||
</div>
|
||||
</div>
|
||||
<div class="progress-bar-bg">
|
||||
<div class="progress-bar-fill" data-percentage="${percentage}"></div>
|
||||
</div>
|
||||
<div class="fund-percentage">${percentage.toFixed(1)}%</div>
|
||||
</div>
|
||||
`;
|
||||
}).join('');
|
||||
|
||||
container.querySelectorAll('.progress-bar-fill').forEach(bar => {
|
||||
const percentage = bar.dataset.percentage || '0';
|
||||
bar.style.width = `${percentage}%`;
|
||||
});
|
||||
}
|
||||
|
||||
async function fetchSponsors() {
|
||||
try {
|
||||
const response = await fetch('data/sponsors.txt');
|
||||
const text = await response.text();
|
||||
const sponsors = DataUtils.parseSponsorsText(text);
|
||||
const userTotals = DataUtils.buildSponsorTotals(sponsors);
|
||||
|
||||
// Sort users by total amount for Top 3
|
||||
const sortedUsers = Object.keys(userTotals).map(name => ({
|
||||
name,
|
||||
total: userTotals[name]
|
||||
})).sort((a, b) => b.total - a.total);
|
||||
|
||||
renderTopSponsors(sortedUsers.slice(0, 3));
|
||||
// renderSponsorsTable(sponsors); // Removed as table is now on sponsor.html
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading sponsors:', error);
|
||||
}
|
||||
}
|
||||
|
||||
function renderTopSponsors(topUsers) {
|
||||
const container = document.getElementById('top-sponsors');
|
||||
const medals = ['🥇', '🥈', '🥉'];
|
||||
|
||||
container.innerHTML = topUsers.map((user, index) => `
|
||||
<div class="sponsor-card">
|
||||
<div class="sponsor-rank">${medals[index]}</div>
|
||||
<div class="sponsor-name">${user.name}</div>
|
||||
<div class="sponsor-amount">¥${user.total.toFixed(2)}</div>
|
||||
</div>
|
||||
`).join('');
|
||||
}
|
||||
|
||||
/* Removed renderSponsorsTable and setupModal as they are no longer used on index page */
|
||||
|
||||
223
js/sponsor_script.js
Normal file
223
js/sponsor_script.js
Normal file
@@ -0,0 +1,223 @@
|
||||
let allSponsors = [];
|
||||
let grandTotal = 0;
|
||||
let filterState = { search: '', project: 'all' };
|
||||
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
try {
|
||||
setupUI();
|
||||
} catch (e) {
|
||||
console.error("UI Setup failed", e);
|
||||
}
|
||||
fetchSponsorsData();
|
||||
setupListeners();
|
||||
});
|
||||
|
||||
function setupUI() {
|
||||
// Mobile menu toggle handled by components.js
|
||||
|
||||
// Modal Logic
|
||||
const modal = document.getElementById('sponsor-modal');
|
||||
const btn = document.getElementById('open-sponsor-modal');
|
||||
const span = document.querySelector('.close-modal');
|
||||
const desktopView = document.getElementById('desktop-qr-view');
|
||||
const mobileView = document.getElementById('mobile-btn-view');
|
||||
|
||||
// Detect Mobile
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent) || window.innerWidth < 768;
|
||||
|
||||
if (isMobile) {
|
||||
if(desktopView) desktopView.style.display = 'none';
|
||||
if(mobileView) mobileView.style.display = 'block';
|
||||
} else {
|
||||
if(desktopView) desktopView.style.display = 'block';
|
||||
if(mobileView) mobileView.style.display = 'none';
|
||||
}
|
||||
|
||||
if (modal && btn) {
|
||||
btn.addEventListener('click', (e) => {
|
||||
e.preventDefault();
|
||||
modal.style.display = "flex";
|
||||
// Trigger reflow
|
||||
void modal.offsetWidth;
|
||||
requestAnimationFrame(() => {
|
||||
modal.classList.add('show');
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if (span && modal) {
|
||||
span.addEventListener('click', () => {
|
||||
modal.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
modal.style.display = "none";
|
||||
}, 300);
|
||||
});
|
||||
}
|
||||
|
||||
window.addEventListener('click', (event) => {
|
||||
if (modal && event.target == modal) {
|
||||
modal.classList.remove('show');
|
||||
setTimeout(() => {
|
||||
modal.style.display = "none";
|
||||
}, 300);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
function setupListeners() {
|
||||
const searchInput = document.getElementById('sponsor-search');
|
||||
const filterContainer = document.getElementById('project-filters');
|
||||
|
||||
if (searchInput) {
|
||||
searchInput.addEventListener('input', (e) => {
|
||||
filterState.search = e.target.value.toLowerCase().trim();
|
||||
applyFilters();
|
||||
});
|
||||
}
|
||||
|
||||
if (filterContainer) {
|
||||
filterContainer.addEventListener('click', (e) => {
|
||||
if (e.target.classList.contains('filter-tag')) {
|
||||
// Update active class
|
||||
document.querySelectorAll('.filter-tag').forEach(btn => btn.classList.remove('active'));
|
||||
e.target.classList.add('active');
|
||||
|
||||
// Update filter state
|
||||
filterState.project = e.target.dataset.project;
|
||||
applyFilters();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchSponsorsData() {
|
||||
try {
|
||||
const response = await fetch('data/sponsors.txt');
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to fetch data/sponsors.txt');
|
||||
}
|
||||
const text = await response.text();
|
||||
const sponsors = DataUtils.parseSponsorsText(text);
|
||||
const projects = new Set();
|
||||
grandTotal = 0;
|
||||
|
||||
sponsors.forEach(item => {
|
||||
grandTotal += item.amount;
|
||||
projects.add(item.project);
|
||||
});
|
||||
|
||||
allSponsors = [...sponsors].reverse(); // Start with newest
|
||||
|
||||
// Animate Total
|
||||
animateValue(grandTotal);
|
||||
|
||||
// Render everything
|
||||
renderFilters(Array.from(projects));
|
||||
applyFilters(); // Renders the grid initially
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading sponsors:', error);
|
||||
const grid = document.getElementById('donation-list');
|
||||
if(grid) grid.innerHTML = '<div class="sponsor-load-error">加载数据失败,请刷新重试</div>';
|
||||
}
|
||||
}
|
||||
|
||||
function animateValue(end) {
|
||||
const obj = document.getElementById('total-amount-display');
|
||||
if (!obj) return;
|
||||
|
||||
// Simple count up
|
||||
let startTimestamp = null;
|
||||
const duration = 2000;
|
||||
const start = 0;
|
||||
|
||||
const step = (timestamp) => {
|
||||
if (!startTimestamp) startTimestamp = timestamp;
|
||||
const progress = Math.min((timestamp - startTimestamp) / duration, 1);
|
||||
// Easing out Quart
|
||||
const easeProgress = 1 - Math.pow(1 - progress, 4);
|
||||
|
||||
const current = Math.floor(easeProgress * (end - start) + start);
|
||||
|
||||
obj.innerHTML = `¥${current.toLocaleString('en-US')}`;
|
||||
|
||||
if (progress < 1) {
|
||||
window.requestAnimationFrame(step);
|
||||
} else {
|
||||
obj.innerHTML = `¥${end.toLocaleString('en-US', {minimumFractionDigits: 2, maximumFractionDigits: 2})}`;
|
||||
}
|
||||
};
|
||||
window.requestAnimationFrame(step);
|
||||
}
|
||||
|
||||
function renderFilters(projects) {
|
||||
const container = document.getElementById('project-filters');
|
||||
if (!container) return;
|
||||
|
||||
// Remove existing project buttons, keep "All"
|
||||
const existingButtons = container.querySelectorAll('button:not([data-project="all"])');
|
||||
existingButtons.forEach(btn => btn.remove());
|
||||
|
||||
// Add project buttons
|
||||
projects.forEach(proj => {
|
||||
if (!proj) return;
|
||||
const btn = document.createElement('button');
|
||||
btn.className = 'filter-tag';
|
||||
btn.textContent = proj;
|
||||
btn.dataset.project = proj;
|
||||
container.appendChild(btn);
|
||||
});
|
||||
}
|
||||
|
||||
function applyFilters() {
|
||||
const { search, project } = filterState;
|
||||
const grid = document.getElementById('donation-list');
|
||||
const noResults = document.getElementById('no-results');
|
||||
|
||||
if (!grid) return;
|
||||
|
||||
const filtered = allSponsors.filter(item => {
|
||||
const matchesProject = project === 'all' || item.project === project;
|
||||
const matchesSearch = item.name.toLowerCase().includes(search);
|
||||
return matchesProject && matchesSearch;
|
||||
});
|
||||
|
||||
grid.innerHTML = '';
|
||||
|
||||
if (filtered.length === 0) {
|
||||
if (noResults) noResults.style.display = 'block';
|
||||
return;
|
||||
}
|
||||
|
||||
if (noResults) noResults.style.display = 'none';
|
||||
|
||||
filtered.forEach((item, index) => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'donation-card';
|
||||
// Max delay 1s to prevent long waits on huge lists
|
||||
const delay = Math.min(index * 0.05, 1);
|
||||
card.style.animationDelay = `${delay}s`;
|
||||
|
||||
const avatarUrl = `https://minotar.net/helm/${item.name}/64.png`;
|
||||
|
||||
card.innerHTML = `
|
||||
<div class="donation-header">
|
||||
<div class="donor-info">
|
||||
<img src="${avatarUrl}" class="mini-avatar" onerror="this.src='https://minotar.net/helm/MHF_Steve/64.png'" alt="${item.name}">
|
||||
<div class="donor-name">${item.name}</div>
|
||||
</div>
|
||||
<div class="donation-amount">¥${item.amount}</div>
|
||||
</div>
|
||||
|
||||
<div class="donation-card-body">
|
||||
<div class="donation-purpose">${item.project}</div>
|
||||
<div class="donation-date">
|
||||
<i class="far fa-clock donation-date-icon"></i>${item.date}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
}
|
||||
|
||||
359
js/stats_script.js
Normal file
359
js/stats_script.js
Normal file
@@ -0,0 +1,359 @@
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
fetchStats();
|
||||
setupModal();
|
||||
setupSearch();
|
||||
setupLoadMore();
|
||||
});
|
||||
|
||||
|
||||
|
||||
let allPlayers = [];
|
||||
let displayedPlayers = [];
|
||||
let currentPage = 1;
|
||||
const pageSize = 24;
|
||||
|
||||
async function fetchStats() {
|
||||
try {
|
||||
const response = await fetch('stats/summary.json');
|
||||
if (!response.ok) throw new Error('Failed to load stats');
|
||||
|
||||
const data = await response.json();
|
||||
allPlayers = data.players;
|
||||
|
||||
// Hide loading
|
||||
document.getElementById('loading-indicator').style.display = 'none';
|
||||
|
||||
// Render things
|
||||
renderLeaderboards();
|
||||
|
||||
// Initial Grid Render
|
||||
displayedPlayers = allPlayers; // Start with all
|
||||
renderPlayerGrid(true); // reset
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error:', error);
|
||||
document.getElementById('loading-indicator').innerText = "加载数据失败,请稍后重试。";
|
||||
}
|
||||
}
|
||||
|
||||
function renderLeaderboards() {
|
||||
// Helper to sort and slice
|
||||
const getTop = (key, subKey) => {
|
||||
return [...allPlayers]
|
||||
.sort((a, b) => {
|
||||
let valA = subKey ? a.stats[key] : a.stats[key]; // if structure allows direct access
|
||||
let valB = subKey ? b.stats[key] : b.stats[key];
|
||||
|
||||
// Special case for walk which has raw sorting value
|
||||
if (key === 'walk_fmt') valA = a.stats.walk_raw;
|
||||
if (key === 'walk_fmt') valB = b.stats.walk_raw;
|
||||
|
||||
// Special case for play_time which has raw sorting value
|
||||
if (key === 'play_time_fmt') valA = a.stats.play_time_raw;
|
||||
if (key === 'play_time_fmt') valB = b.stats.play_time_raw;
|
||||
|
||||
return valB - valA;
|
||||
})
|
||||
.slice(0, 4); // Top 4
|
||||
};
|
||||
|
||||
const renderCard = (elementId, players, valueFormatter) => {
|
||||
const container = document.getElementById(elementId);
|
||||
if (!players || players.length === 0) {
|
||||
container.innerHTML = '<div class="lb-top-player">暂无数据</div>';
|
||||
return;
|
||||
}
|
||||
|
||||
const top1 = players[0];
|
||||
let html = `
|
||||
<div class="lb-top-player">
|
||||
<img src="${top1.avatar}" onerror="this.src='https://crafatar.com/avatars/${top1.uuid}?size=64&overlay'">
|
||||
<div class="lb-top-name">${top1.name}</div>
|
||||
<div class="lb-top-data">${valueFormatter(top1)}</div>
|
||||
</div>
|
||||
<div class="lb-list">
|
||||
`;
|
||||
|
||||
for (let i = 1; i < players.length; i++) {
|
||||
const p = players[i];
|
||||
html += `
|
||||
<div class="lb-item">
|
||||
<div class="lb-item-main">
|
||||
<span class="lb-rank">${i+1}</span>
|
||||
<span class="lb-item-name">${p.name}</span>
|
||||
</div>
|
||||
<span>${valueFormatter(p)}</span>
|
||||
</div>
|
||||
`;
|
||||
}
|
||||
html += '</div>';
|
||||
container.innerHTML = html;
|
||||
};
|
||||
|
||||
// 1. Walk (stats.walk_raw)
|
||||
const topWalkers = getTop('walk_fmt'); // uses walk_raw internally
|
||||
renderCard('lb-walk', topWalkers, p => p.stats.walk_fmt);
|
||||
|
||||
// 2. Placed (stats.placed)
|
||||
const topPlacers = getTop('placed');
|
||||
renderCard('lb-placed', topPlacers, p => p.stats.placed.toLocaleString());
|
||||
|
||||
// 3. Mined (stats.mined)
|
||||
const topMiners = getTop('mined');
|
||||
renderCard('lb-mined', topMiners, p => p.stats.mined.toLocaleString());
|
||||
|
||||
// 4. Deaths (stats.deaths)
|
||||
const topDeaths = getTop('deaths');
|
||||
renderCard('lb-deaths', topDeaths, p => p.stats.deaths.toLocaleString());
|
||||
|
||||
// 5. Play Time (stats.play_time_raw)
|
||||
const topPlayTime = getTop('play_time_fmt'); // uses play_time_raw internally
|
||||
renderCard('lb-playtime', topPlayTime, p => p.stats.play_time_fmt);
|
||||
|
||||
// 6. Kills (stats.kills)
|
||||
const topKills = getTop('kills');
|
||||
renderCard('lb-kills', topKills, p => p.stats.kills.toLocaleString());
|
||||
}
|
||||
|
||||
function renderPlayerGrid(reset = false) {
|
||||
const grid = document.getElementById('players-grid');
|
||||
const loadMoreBtn = document.getElementById('load-more-btn');
|
||||
|
||||
if (reset) {
|
||||
grid.innerHTML = '';
|
||||
currentPage = 1;
|
||||
}
|
||||
|
||||
const start = (currentPage - 1) * pageSize;
|
||||
const end = start + pageSize;
|
||||
const items = displayedPlayers.slice(start, end);
|
||||
|
||||
items.forEach(p => {
|
||||
const card = document.createElement('div');
|
||||
card.className = 'player-card';
|
||||
card.onclick = () => openModal(p);
|
||||
|
||||
card.innerHTML = `
|
||||
<img src="${p.avatar}" onerror="this.src='https://crafatar.com/avatars/${p.uuid}?size=64&overlay'">
|
||||
<h3>${p.name}</h3>
|
||||
<!-- <div class="p-uuid">${p.uuid.substring(0,8)}...</div> -->
|
||||
`;
|
||||
grid.appendChild(card);
|
||||
});
|
||||
|
||||
if (end >= displayedPlayers.length) {
|
||||
loadMoreBtn.style.display = 'none';
|
||||
} else {
|
||||
loadMoreBtn.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
|
||||
function setupLoadMore() {
|
||||
document.getElementById('load-more-btn').addEventListener('click', () => {
|
||||
currentPage++;
|
||||
renderPlayerGrid(false);
|
||||
});
|
||||
}
|
||||
|
||||
function setupSearch() {
|
||||
const input = document.getElementById('player-search');
|
||||
input.addEventListener('input', (e) => {
|
||||
const term = e.target.value.toLowerCase().trim();
|
||||
if (!term) {
|
||||
displayedPlayers = allPlayers;
|
||||
} else {
|
||||
displayedPlayers = allPlayers.filter(p =>
|
||||
p.name.toLowerCase().includes(term) ||
|
||||
p.uuid.toLowerCase().includes(term)
|
||||
);
|
||||
}
|
||||
renderPlayerGrid(true);
|
||||
});
|
||||
}
|
||||
|
||||
// Modal Logic
|
||||
const modal = document.getElementById("player-modal");
|
||||
const span = document.getElementsByClassName("close-modal")[0];
|
||||
|
||||
function setupModal() {
|
||||
span.onclick = function() {
|
||||
modal.style.display = "none";
|
||||
}
|
||||
window.onclick = function(event) {
|
||||
if (event.target == modal) {
|
||||
modal.style.display = "none";
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function openModal(player) {
|
||||
const modal = document.getElementById("player-modal");
|
||||
|
||||
// Top Section: Identity
|
||||
document.getElementById('modal-name').innerText = player.name;
|
||||
document.getElementById('modal-uuid').innerText = player.uuid;
|
||||
const avatar = document.getElementById('modal-avatar');
|
||||
avatar.src = player.avatar;
|
||||
avatar.onerror = () => { avatar.src = `https://crafatar.com/avatars/${player.uuid}?size=64&overlay`; };
|
||||
|
||||
// Top Section: Summary Stats
|
||||
// These are from summary.json which is already formatted
|
||||
document.getElementById('modal-walk').innerText = player.stats.walk_fmt;
|
||||
document.getElementById('modal-placed').innerText = player.stats.placed.toLocaleString();
|
||||
document.getElementById('modal-mined').innerText = player.stats.mined.toLocaleString();
|
||||
document.getElementById('modal-deaths').innerText = player.stats.deaths;
|
||||
document.getElementById('modal-kills').innerText = player.stats.kills;
|
||||
document.getElementById('modal-playtime').innerText = player.stats.play_time_fmt;
|
||||
|
||||
// Bottom Section: Reset and Load Details
|
||||
const accordion = document.getElementById('stats-accordion');
|
||||
accordion.innerHTML = '';
|
||||
document.getElementById('loading-details').style.display = 'block';
|
||||
document.getElementById('loading-details').innerText = '正在加载详细数据...';
|
||||
|
||||
modal.style.display = "flex";
|
||||
|
||||
// Load existing details
|
||||
loadPlayerDetails(player.uuid);
|
||||
}
|
||||
|
||||
async function loadPlayerDetails(uuid) {
|
||||
const accordion = document.getElementById('stats-accordion');
|
||||
const loading = document.getElementById('loading-details');
|
||||
|
||||
try {
|
||||
const response = await fetch(`stats/${uuid}.json`);
|
||||
if (!response.ok) throw new Error('Stats file not found');
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
if (!data.stats) {
|
||||
loading.innerText = '暂无详细统计数据。';
|
||||
return;
|
||||
}
|
||||
|
||||
loading.style.display = 'none';
|
||||
renderDetailsAccordion(data.stats);
|
||||
|
||||
} catch (error) {
|
||||
console.error('Error loading details:', error);
|
||||
loading.innerText = '无法加载详细数据。';
|
||||
}
|
||||
}
|
||||
|
||||
function renderDetailsAccordion(statsObj) {
|
||||
const accordion = document.getElementById('stats-accordion');
|
||||
|
||||
// Define category mappings for better display names
|
||||
const categoryMap = {
|
||||
'minecraft:custom': { name: '通用统计', icon: 'fa-chart-bar' },
|
||||
'minecraft:mined': { name: '挖掘统计', icon: 'fa-hammer' },
|
||||
'minecraft:used': { name: '使用统计', icon: 'fa-hand-paper' },
|
||||
'minecraft:crafted': { name: '合成统计', icon: 'fa-tools' },
|
||||
'minecraft:broken': { name: '破坏统计', icon: 'fa-heart-broken' },
|
||||
'minecraft:picked_up': { name: '拾取统计', icon: 'fa-box-open' },
|
||||
'minecraft:dropped': { name: '丢弃统计', icon: 'fa-trash' },
|
||||
'minecraft:killed': { name: '击杀统计', icon: 'fa-crosshairs' },
|
||||
'minecraft:killed_by': { name: '死亡统计', icon: 'fa-skull' }
|
||||
};
|
||||
|
||||
// Helper to format keys (minecraft:stone -> Stone)
|
||||
const formatKey = (key) => {
|
||||
if (key.includes(':')) key = key.split(':')[1];
|
||||
return key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase());
|
||||
};
|
||||
|
||||
// Sort categories to put 'custom' first, then others alphabetically
|
||||
const sortedKeys = Object.keys(statsObj).sort((a, b) => {
|
||||
if (a === 'minecraft:custom') return -1;
|
||||
if (b === 'minecraft:custom') return 1;
|
||||
return a.localeCompare(b);
|
||||
});
|
||||
|
||||
sortedKeys.forEach(catKey => {
|
||||
const subStats = statsObj[catKey];
|
||||
if (Object.keys(subStats).length === 0) return;
|
||||
|
||||
const catInfo = categoryMap[catKey] || { name: formatKey(catKey), icon: 'fa-folder' };
|
||||
|
||||
// Create Accordion Item
|
||||
const item = document.createElement('div');
|
||||
item.className = 'accordion-item';
|
||||
|
||||
// Header
|
||||
const header = document.createElement('div');
|
||||
header.className = 'accordion-header';
|
||||
header.innerHTML = `
|
||||
<span><i class="fas ${catInfo.icon} icon"></i> ${catInfo.name}</span>
|
||||
<i class="fas fa-chevron-down arrow"></i>
|
||||
`;
|
||||
|
||||
// Content
|
||||
const content = document.createElement('div');
|
||||
content.className = 'accordion-content';
|
||||
|
||||
// Grid for stats
|
||||
const grid = document.createElement('div');
|
||||
grid.className = 'detail-stats-grid';
|
||||
|
||||
// Sort sub-items by value descending
|
||||
const subItems = Object.entries(subStats).sort((a, b) => b[1] - a[1]);
|
||||
|
||||
subItems.forEach(([k, v]) => {
|
||||
let label = formatKey(k);
|
||||
let val = v.toLocaleString();
|
||||
|
||||
// Special formatting for time/distance in 'custom'
|
||||
if (catKey === 'minecraft:custom') {
|
||||
if (k.includes('time') || k.includes('minute')) {
|
||||
// ticks to hours/min?
|
||||
// Assuming 'play_time' is ticks (1/20s)
|
||||
if (k.includes('play_time') || k.includes('time_since')) {
|
||||
const sec = v / 20;
|
||||
if (sec > 3600) val = (sec/3600).toFixed(1) + ' h';
|
||||
else if (sec > 60) val = (sec/60).toFixed(1) + ' m';
|
||||
else val = sec.toFixed(0) + ' s';
|
||||
}
|
||||
}
|
||||
if (k.includes('cmt') || k.includes('one_cm')) { // one_cm
|
||||
const m = v / 100;
|
||||
if (m > 1000) val = (m/1000).toFixed(2) + ' km';
|
||||
else val = m.toFixed(1) + ' m';
|
||||
}
|
||||
}
|
||||
|
||||
const statDiv = document.createElement('div');
|
||||
statDiv.className = 'detail-stat-item';
|
||||
statDiv.innerHTML = `
|
||||
<span class="detail-stat-label" title="${label}">${label}</span>
|
||||
<span class="detail-stat-value">${val}</span>
|
||||
`;
|
||||
grid.appendChild(statDiv);
|
||||
});
|
||||
|
||||
content.appendChild(grid);
|
||||
item.appendChild(header);
|
||||
item.appendChild(content);
|
||||
accordion.appendChild(item);
|
||||
|
||||
// Click Event
|
||||
header.addEventListener('click', () => {
|
||||
const isActive = header.classList.contains('active');
|
||||
|
||||
// Close all others? Optional. Let's keep multiple openable.
|
||||
// But if we want accordion behavior:
|
||||
// document.querySelectorAll('.accordion-header').forEach(h => {
|
||||
// h.classList.remove('active');
|
||||
// h.nextElementSibling.classList.remove('show');
|
||||
// });
|
||||
|
||||
if (!isActive) {
|
||||
header.classList.add('active');
|
||||
content.classList.add('show');
|
||||
} else {
|
||||
header.classList.remove('active');
|
||||
content.classList.remove('show');
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user