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:
zhangyuheng
2026-03-02 11:29:02 +08:00
parent d475d329e9
commit 47ef36f600
16 changed files with 1239 additions and 1020 deletions

125
js/components.js Normal file
View 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>&copy; 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
View 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
View 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
View 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
View 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');
}
});
});
}