(function () { // ========== 全局变量 ========== const tableBody = document.getElementById('table-body'); const filterProvider = document.getElementById('filter-provider'); const filterRegion = document.getElementById('filter-region'); const filterMemory = document.getElementById('filter-memory'); const filterPrice = document.getElementById('filter-price'); const filterCurrency = document.getElementById('filter-currency'); const searchInput = document.getElementById('search-input'); const btnReset = document.getElementById('btn-reset'); const comparisonContent = document.getElementById('comparison-content'); const btnClearComparison = document.getElementById('btn-clear-comparison'); let allPlans = []; let filteredPlans = []; let isLoading = false; let currentSort = { column: null, order: 'asc' }; let favorites = JSON.parse(localStorage.getItem('vps_favorites') || '[]'); let expandedRows = new Set(); // 记录展开的行 const MAX_COMPARISON = 4; // ========== 工具函数 ========== function unique(values) { return [...new Set(values)].filter(Boolean).sort(); } function getDisplayRegion(plan) { return (plan.countries && plan.countries.trim()) ? plan.countries.trim() : (plan.region || "—"); } function getAllRegionOptions(plans) { const set = new Set(); plans.forEach(function (p) { if (p.countries) { p.countries.split(',').forEach(function (s) { const t = s.trim(); if (t) set.add(t); }); } if (p.region && p.region.trim()) set.add(p.region.trim()); }); return unique([...set]); } function escapeAttr(s) { const div = document.createElement('div'); div.textContent = s; return div.innerHTML.replace(/&/g, '&').replace(/"/g, '"').replace(//g, '>'); } function escapeHtml(s) { if (s == null || s === '') return ''; const div = document.createElement('div'); div.textContent = String(s); return div.innerHTML; } function showVal(val, suffix) { if (val == null || val === '' || (typeof val === 'number' && isNaN(val))) return "—"; return suffix ? val + suffix : val; } // ========== 价格处理 ========== function getPriceValue(plan) { const isCNY = filterCurrency.value === 'CNY'; let price = isCNY ? plan.price_cny : plan.price_usd; if (price == null || price === '' || isNaN(price)) { price = isCNY ? plan.price_usd : plan.price_cny; } return price != null && !isNaN(price) ? Number(price) : null; } function formatPrice(plan) { const isCNY = filterCurrency.value === 'CNY'; let price = isCNY ? plan.price_cny : plan.price_usd; let symbol = isCNY ? '¥' : '$'; if (price == null || price === '' || isNaN(price)) { price = isCNY ? plan.price_usd : plan.price_cny; symbol = isCNY ? '$' : '¥'; } if (price == null || price === '' || isNaN(price)) return "—"; return symbol + Number(price).toLocaleString(); } // ========== 差异计算 ========== function calculateDiff(value, bestValue, isLowerBetter) { if (value == null || bestValue == null) return null; if (value === bestValue) return 0; const diff = isLowerBetter ? ((value - bestValue) / bestValue * 100) : ((bestValue - value) / bestValue * 100); return Math.round(diff); } function getDiffBadge(diff, isLowerBetter) { if (diff === 0) return 'best'; const absDiff = Math.abs(diff); if (isLowerBetter) { if (absDiff <= 10) return 'good'; if (absDiff <= 30) return 'average'; return 'poor'; } else { if (diff <= -10) return 'good'; if (diff <= -30) return 'average'; return 'poor'; } } function getProgressBarWidth(value, maxValue) { if (value == null || maxValue == null || maxValue === 0) return 0; return Math.min((value / maxValue * 100), 100); } // ========== 性价比评分 ========== function calculateValueScore(plan, bestPrice, bestVcpu, bestMemory, bestStorage) { let score = 0; const price = getPriceValue(plan); if (price != null && bestPrice != null) { const priceDiff = (price - bestPrice) / bestPrice; if (priceDiff <= 0.1) score += 4; else if (priceDiff <= 0.3) score += 3; else if (priceDiff <= 0.5) score += 2; else score += 1; } if (plan.vcpu != null && bestVcpu != null) { const cpuRatio = plan.vcpu / bestVcpu; if (cpuRatio >= 0.9) score += 2; else if (cpuRatio >= 0.7) score += 1.5; else score += 1; } if (plan.memory_gb != null && bestMemory != null) { const memRatio = plan.memory_gb / bestMemory; if (memRatio >= 0.9) score += 3; else if (memRatio >= 0.7) score += 2; else score += 1; } if (plan.storage_gb != null && bestStorage != null) { const storageRatio = plan.storage_gb / bestStorage; if (storageRatio >= 0.9) score += 1; else if (storageRatio >= 0.7) score += 0.5; } return Math.min(Math.round(score), 5); } function renderStars(score) { let html = ''; for (let i = 1; i <= 5; i++) { if (i <= score) { html += ''; } else { html += ''; } } return html; } // ========== 收藏功能 ========== function isFavorite(planId) { return favorites.indexOf(planId) !== -1; } function toggleFavorite(planId) { const index = favorites.indexOf(planId); if (index === -1) { if (favorites.length >= MAX_COMPARISON) { showComparisonLimitNotice(); return; } favorites.push(planId); } else { favorites.splice(index, 1); expandedRows.delete(planId); // 移除时也清除展开状态 } localStorage.setItem('vps_favorites', JSON.stringify(favorites)); refresh(); renderComparison(); } function clearAllFavorites() { favorites = []; expandedRows.clear(); localStorage.setItem('vps_favorites', JSON.stringify(favorites)); refresh(); renderComparison(); } function showComparisonLimitNotice() { const notice = document.createElement('div'); notice.className = 'comparison-limit-notice'; notice.style.opacity = '0'; notice.innerHTML = '最多只能对比 ' + MAX_COMPARISON + ' 个方案'; comparisonContent.insertBefore(notice, comparisonContent.firstChild); setTimeout(function() { notice.style.transition = 'opacity 0.3s'; notice.style.opacity = '1'; }, 10); setTimeout(function() { notice.style.opacity = '0'; setTimeout(function() { notice.remove(); }, 300); }, 3000); } // ========== 展开/收起功能 ========== function toggleRow(planId) { if (expandedRows.has(planId)) { expandedRows.delete(planId); } else { expandedRows.add(planId); } renderComparison(); } function expandAll() { const comparisonPlans = allPlans.filter(function(plan) { return favorites.indexOf(plan.id) !== -1; }); comparisonPlans.forEach(function(plan) { expandedRows.add(plan.id); }); renderComparison(); } function collapseAll() { expandedRows.clear(); renderComparison(); } // ========== 可折叠表格式对比渲染 ========== function renderComparison() { if (favorites.length === 0) { comparisonContent.innerHTML = '
' + '' + '' + '' + '

点击星标收藏方案

' + '

最多对比 ' + MAX_COMPARISON + ' 个方案

' + '
'; return; } const comparisonPlans = allPlans.filter(function(plan) { return favorites.indexOf(plan.id) !== -1; }); if (comparisonPlans.length === 0) { comparisonContent.innerHTML = '
' + '

未找到收藏的方案

' + '
'; return; } // 计算最优值 const prices = comparisonPlans.map(getPriceValue).filter(function(p) { return p != null; }); const vcpus = comparisonPlans.map(function(p) { return p.vcpu; }).filter(function(v) { return v != null; }); const memories = comparisonPlans.map(function(p) { return p.memory_gb; }).filter(function(m) { return m != null; }); const storages = comparisonPlans.map(function(p) { return p.storage_gb; }).filter(function(s) { return s != null; }); const bestPrice = prices.length > 0 ? Math.min.apply(null, prices) : null; const bestVcpu = vcpus.length > 0 ? Math.max.apply(null, vcpus) : null; const bestMemory = memories.length > 0 ? Math.max.apply(null, memories) : null; const bestStorage = storages.length > 0 ? Math.max.apply(null, storages) : null; const maxVcpu = bestVcpu; const maxMemory = bestMemory; const maxStorage = bestStorage; // 渲染展开/收起全部按钮 const hasExpanded = expandedRows.size > 0; const expandAllBtn = ''; // 渲染表格行 const rowsHtml = comparisonPlans.map(function(plan) { return renderComparisonRow(plan, bestPrice, bestVcpu, bestMemory, bestStorage, maxVcpu, maxMemory, maxStorage); }).join(''); comparisonContent.innerHTML = '
' + expandAllBtn + rowsHtml + '
'; // 绑定事件 attachComparisonEvents(); } function renderComparisonRow(plan, bestPrice, bestVcpu, bestMemory, bestStorage, maxVcpu, maxMemory, maxStorage) { const price = getPriceValue(plan); const isBestPrice = price != null && price === bestPrice; const valueScore = calculateValueScore(plan, bestPrice, bestVcpu, bestMemory, bestStorage); const isExpanded = expandedRows.has(plan.id); // 头部(始终可见) const header = '
' + '
' + '' + '' + '' + '
' + '
' + '
' + escapeHtml(plan.provider) + '
' + '
' + escapeHtml(plan.name) + '
' + '
' + '
' + '
' + formatPrice(plan) + '
' + '
' + renderStars(valueScore) + '
' + '
' + '' + '
'; // 详情(可折叠) const details = renderComparisonDetails(plan, bestPrice, bestVcpu, bestMemory, bestStorage, maxVcpu, maxMemory, maxStorage); return '
' + header + '
' + '
' + details + '
' + '
' + '
'; } function renderComparisonDetails(plan, bestPrice, bestVcpu, bestMemory, bestStorage, maxVcpu, maxMemory, maxStorage) { const isBestVcpu = plan.vcpu != null && plan.vcpu === bestVcpu; const isBestMemory = plan.memory_gb != null && plan.memory_gb === bestMemory; const isBestStorage = plan.storage_gb != null && plan.storage_gb === bestStorage; const vcpuDiff = calculateDiff(plan.vcpu, bestVcpu, false); const memoryDiff = calculateDiff(plan.memory_gb, bestMemory, false); const storageDiff = calculateDiff(plan.storage_gb, bestStorage, false); const priceDiff = calculateDiff(getPriceValue(plan), bestPrice, true); const detailsGrid = '
' + renderDetailItem('vCPU', plan.vcpu, ' 核', isBestVcpu, vcpuDiff, maxVcpu, false) + renderDetailItem('内存', plan.memory_gb, ' GB', isBestMemory, memoryDiff, maxMemory, false) + renderDetailItem('存储', plan.storage_gb, ' GB', isBestStorage, storageDiff, maxStorage, false) + renderDetailItem('带宽', plan.bandwidth_mbps, ' Mbps', false, null, null, false) + renderDetailItem('流量', plan.traffic, '', false, null, null, false) + renderDetailItem('区域', getDisplayRegion(plan), '', false, null, null, false) + '
'; const url = (plan.official_url || "").trim(); const actions = '
' + (url ? '访问官网' : '') + '' + '
'; return detailsGrid + actions; } function renderDetailItem(label, value, suffix, isBest, diff, maxValue, isLowerBetter) { if (value == null || value === '') { return '
' + '
' + label + '
' + '
' + '
'; } const displayValue = typeof value === 'number' ? value + suffix : escapeHtml(value); const barWidth = maxValue != null ? getProgressBarWidth(value, maxValue) : 0; const badge = diff !== null ? getDiffBadge(diff, isLowerBetter) : 'good'; let diffHtml = ''; if (diff !== null && diff !== 0) { const sign = diff > 0 ? '+' : ''; const className = diff > 0 ? 'negative' : 'positive'; diffHtml = '' + sign + diff + '%'; } let barHtml = ''; if (maxValue != null) { barHtml = '
' + '
' + '
'; } return '
' + '
' + label + '
' + '
' + displayValue + diffHtml + '
' + barHtml + '
'; } function attachComparisonEvents() { // 展开/收起行 document.querySelectorAll('.comparison-row-header').forEach(function(header) { header.addEventListener('click', function(e) { if (e.target.closest('.comparison-row-remove')) return; const planId = parseInt(this.getAttribute('data-id')); toggleRow(planId); }); }); // 移除按钮 document.querySelectorAll('.comparison-row-remove').forEach(function(btn) { btn.addEventListener('click', function(e) { e.stopPropagation(); const planId = parseInt(this.getAttribute('data-id')); toggleFavorite(planId); }); }); // 全部展开/收起按钮 const toggleAllBtn = document.getElementById('toggle-all-btn'); if (toggleAllBtn) { toggleAllBtn.addEventListener('click', function() { if (expandedRows.size > 0) { collapseAll(); } else { expandAll(); } }); } } // ========== 筛选功能 ========== function applyFilters() { const provider = filterProvider.value; const region = filterRegion.value; const memoryMin = parseInt(filterMemory.value, 10) || 0; const priceRange = filterPrice.value; const searchTerm = searchInput.value.toLowerCase().trim(); return allPlans.filter(function (plan) { if (provider && plan.provider !== provider) return false; if (region) { const matchRegion = getDisplayRegion(plan) === region || (plan.countries && plan.countries.split(',').map(function (s) { return s.trim(); }).indexOf(region) !== -1) || plan.region === region; if (!matchRegion) return false; } if (memoryMin && (plan.memory_gb == null || plan.memory_gb < memoryMin)) return false; if (priceRange && priceRange !== '0') { const price = getPriceValue(plan); if (price == null) return false; const parts = priceRange.split('-'); const min = parseFloat(parts[0]); const max = parseFloat(parts[1]); if (price < min || price > max) return false; } if (searchTerm) { const searchableText = [ plan.provider, plan.name, getDisplayRegion(plan), plan.vcpu ? plan.vcpu + '核' : '', plan.memory_gb ? plan.memory_gb + 'G' : '' ].join(' ').toLowerCase(); if (searchableText.indexOf(searchTerm) === -1) return false; } return true; }); } // ========== 排序功能 ========== function sortPlans(plans, column, order) { if (!column) return plans; return plans.slice().sort(function (a, b) { let valA, valB; if (column === 'price') { valA = getPriceValue(a); valB = getPriceValue(b); } else { valA = a[column]; valB = b[column]; } if (valA == null && valB == null) return 0; if (valA == null) return 1; if (valB == null) return -1; if (order === 'asc') { return valA > valB ? 1 : valA < valB ? -1 : 0; } else { return valA < valB ? 1 : valA > valB ? -1 : 0; } }); } function updateSortIcons() { document.querySelectorAll('.sortable').forEach(function (th) { const icon = th.querySelector('.sort-icon'); const column = th.getAttribute('data-sort'); if (column === currentSort.column) { icon.textContent = currentSort.order === 'asc' ? ' ↑' : ' ↓'; th.classList.add('sorted'); } else { icon.textContent = ''; th.classList.remove('sorted'); } }); } // ========== 渲染功能 ========== function fillFilterOptions() { const providers = unique(allPlans.map(function (p) { return p.provider; })); const regions = getAllRegionOptions(allPlans); filterProvider.innerHTML = ''; providers.forEach(function (p) { const opt = document.createElement('option'); opt.value = p; opt.textContent = p; filterProvider.appendChild(opt); }); filterRegion.innerHTML = ''; regions.forEach(function (r) { const opt = document.createElement('option'); opt.value = r; opt.textContent = r; filterRegion.appendChild(opt); }); } function showLoadingSkeleton() { const colCount = 10; const skeletonRows = Array(5).fill(0).map(function() { return ''; }).join(''); tableBody.innerHTML = skeletonRows; } function renderTable(plans) { const colCount = 10; if (plans.length === 0) { tableBody.innerHTML = '

🔍 没有符合条件的方案

'; return; } tableBody.style.opacity = '0'; tableBody.innerHTML = plans.map(function (plan, index) { const url = (plan.official_url || "").trim(); const isFav = isFavorite(plan.id); const favIcon = isFav ? '★' : '☆'; const favClass = isFav ? 'favorited' : ''; return ( '' + '' + escapeHtml(plan.provider) + '' + '' + escapeHtml(getDisplayRegion(plan)) + '' + '' + escapeHtml(plan.name) + '' + '' + showVal(plan.vcpu) + '' + '' + showVal(plan.memory_gb, ' GB') + '' + '' + showVal(plan.storage_gb, ' GB') + '' + '' + showVal(plan.bandwidth_mbps, ' Mbps') + '' + '' + (plan.traffic ? escapeHtml(plan.traffic) : '—') + '' + '' + formatPrice(plan) + '' + '' + '' + (url ? '官网' : '') + '' + '' ); }).join(''); setTimeout(function() { tableBody.style.opacity = '1'; attachFavoriteListeners(); }, 10); } function attachFavoriteListeners() { document.querySelectorAll('.btn-favorite').forEach(function(btn) { btn.addEventListener('click', function(e) { e.preventDefault(); const planId = parseInt(this.getAttribute('data-id')); toggleFavorite(planId); }); }); } function updateResultCount(count) { const existingCount = document.querySelector('.result-count'); if (existingCount) { existingCount.textContent = '共 ' + count + ' 条结果'; } } function refresh() { if (isLoading) return; filteredPlans = applyFilters(); const sortedPlans = sortPlans(filteredPlans, currentSort.column, currentSort.order); renderTable(sortedPlans); updateResultCount(filteredPlans.length); updateURL(); } // ========== URL 同步 ========== function updateURL() { const params = new URLSearchParams(); if (filterProvider.value) params.set('provider', filterProvider.value); if (filterRegion.value) params.set('region', filterRegion.value); if (filterMemory.value !== '0') params.set('memory', filterMemory.value); if (filterPrice.value !== '0') params.set('price', filterPrice.value); if (filterCurrency.value !== 'CNY') params.set('currency', filterCurrency.value); if (searchInput.value) params.set('search', searchInput.value); if (currentSort.column) { params.set('sort', currentSort.column); params.set('order', currentSort.order); } const newURL = window.location.pathname + (params.toString() ? '?' + params.toString() : ''); window.history.replaceState({}, '', newURL); } function loadFromURL() { const params = new URLSearchParams(window.location.search); if (params.get('provider')) filterProvider.value = params.get('provider'); if (params.get('region')) filterRegion.value = params.get('region'); if (params.get('memory')) filterMemory.value = params.get('memory'); if (params.get('price')) filterPrice.value = params.get('price'); if (params.get('currency')) filterCurrency.value = params.get('currency'); if (params.get('search')) searchInput.value = params.get('search'); if (params.get('sort')) { currentSort.column = params.get('sort'); currentSort.order = params.get('order') || 'asc'; } } // ========== 事件监听 ========== filterProvider.addEventListener('change', refresh); filterRegion.addEventListener('change', refresh); filterMemory.addEventListener('change', refresh); filterPrice.addEventListener('change', refresh); filterCurrency.addEventListener('change', function() { refresh(); renderComparison(); }); let searchTimeout; searchInput.addEventListener('input', function() { clearTimeout(searchTimeout); searchTimeout = setTimeout(refresh, 300); }); btnReset.addEventListener('click', function () { filterProvider.value = ''; filterRegion.value = ''; filterMemory.value = '0'; filterPrice.value = '0'; filterCurrency.value = 'CNY'; searchInput.value = ''; currentSort = { column: null, order: 'asc' }; updateSortIcons(); refresh(); renderComparison(); }); btnClearComparison.addEventListener('click', function() { if (confirm('确定要清空所有对比方案吗?')) { clearAllFavorites(); } }); document.querySelectorAll('.sortable').forEach(function(th) { th.style.cursor = 'pointer'; th.addEventListener('click', function() { const column = this.getAttribute('data-sort'); if (currentSort.column === column) { currentSort.order = currentSort.order === 'asc' ? 'desc' : 'asc'; } else { currentSort.column = column; currentSort.order = 'asc'; } updateSortIcons(); refresh(); }); }); // ========== 初始化 ========== isLoading = true; showLoadingSkeleton(); fetch('/api/plans') .then(function (res) { if (!res.ok) throw new Error('Network response was not ok'); return res.json(); }) .then(function (plans) { allPlans = plans; fillFilterOptions(); loadFromURL(); isLoading = false; updateSortIcons(); refresh(); renderComparison(); const filters = document.querySelector('.filters'); if (filters && !document.querySelector('.result-count')) { const countEl = document.createElement('div'); countEl.className = 'result-count'; countEl.style.cssText = 'margin-left: auto; color: var(--text-muted); font-size: 0.9rem; font-weight: 500;'; countEl.textContent = '共 ' + plans.length + ' 条结果'; filters.appendChild(countEl); } }) .catch(function (error) { isLoading = false; console.error('加载失败:', error); tableBody.innerHTML = '

❌ 加载失败,请刷新页面重试

'; }); // ========== CSS 样式注入 ========== const style = document.createElement('style'); style.textContent = ` @keyframes fadeIn { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: translateY(0); } } .table-wrap { transition: opacity 0.3s ease; } .sortable { user-select: none; transition: var(--transition); cursor: pointer; } .sortable:hover { background: var(--bg-elevated) !important; color: var(--accent); } .sortable.sorted { color: var(--accent); font-weight: 600; } .sort-icon { font-size: 0.8em; margin-left: 0.25rem; } .search-bar { margin-bottom: 1rem; } .search-bar input { width: 100%; max-width: 500px; padding: 0.65rem 1rem; font-size: 0.95rem; border: 1.5px solid var(--border); border-radius: var(--radius); background: var(--bg-card); color: var(--text); transition: var(--transition); } .search-bar input:focus { outline: none; border-color: var(--accent); box-shadow: 0 0 0 3px var(--accent-glow); } .search-bar input::placeholder { color: var(--text-muted); } .btn-favorite { background: none; border: none; font-size: 1.2rem; cursor: pointer; padding: 0.25rem 0.5rem; color: var(--text-muted); transition: var(--transition); margin-right: 0.5rem; } .btn-favorite:hover { color: var(--orange); transform: scale(1.2); } .favorited { background: rgba(234, 88, 12, 0.05) !important; } .favorited .btn-favorite { color: var(--orange); } `; document.head.appendChild(style); })();