509 lines
17 KiB
JavaScript
509 lines
17 KiB
JavaScript
/**
|
||
* VPS Price Comparison - v3.3 List Mode
|
||
* 整体可折叠的表格列表对比模式
|
||
* 实现时间: 2026-02-09
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ==================== 全局变量 ====================
|
||
var allPlans = [];
|
||
var comparisonPlans = [];
|
||
var isComparisonExpanded = true; // 对比面板展开状态
|
||
var MAX_COMPARISON = 4;
|
||
|
||
// 排序状态
|
||
var currentSort = {
|
||
column: null,
|
||
direction: 'asc'
|
||
};
|
||
|
||
// 筛选状态
|
||
var filters = {
|
||
provider: '',
|
||
region: '',
|
||
memory: 0,
|
||
price: '0',
|
||
currency: 'CNY',
|
||
search: ''
|
||
};
|
||
|
||
// 汇率(CNY 为基准)
|
||
var exchangeRates = {
|
||
CNY: 1,
|
||
USD: 0.14
|
||
};
|
||
|
||
// ==================== 初始化 ====================
|
||
function init() {
|
||
fetchData();
|
||
initEventListeners();
|
||
loadComparisonFromURL();
|
||
}
|
||
|
||
// ==================== 数据获取 ====================
|
||
function fetchData() {
|
||
fetch('/api/plans')
|
||
.then(function(response) {
|
||
if (!response.ok) throw new Error('Network error');
|
||
return response.json();
|
||
})
|
||
.then(function(data) {
|
||
allPlans = data;
|
||
populateFilters();
|
||
renderTable();
|
||
updateComparison();
|
||
})
|
||
.catch(function(error) {
|
||
console.error('Error fetching data:', error);
|
||
showError('数据加载失败,请刷新页面重试');
|
||
});
|
||
}
|
||
|
||
// ==================== 事件监听 ====================
|
||
function initEventListeners() {
|
||
// 筛选器
|
||
document.getElementById('filter-provider').addEventListener('change', handleFilterChange);
|
||
document.getElementById('filter-region').addEventListener('change', handleFilterChange);
|
||
document.getElementById('filter-memory').addEventListener('change', handleFilterChange);
|
||
document.getElementById('filter-price').addEventListener('change', handleFilterChange);
|
||
document.getElementById('filter-currency').addEventListener('change', handleCurrencyChange);
|
||
document.getElementById('btn-reset').addEventListener('click', resetFilters);
|
||
|
||
// 搜索
|
||
var searchInput = document.getElementById('search-input');
|
||
searchInput.addEventListener('input', debounce(handleSearch, 300));
|
||
|
||
// 排序
|
||
var sortableHeaders = document.querySelectorAll('.sortable');
|
||
sortableHeaders.forEach(function(header) {
|
||
header.addEventListener('click', handleSort);
|
||
});
|
||
|
||
// 对比面板控制
|
||
document.getElementById('btn-clear-comparison').addEventListener('click', clearComparison);
|
||
|
||
// 对比面板折叠按钮
|
||
var toggleBtn = document.getElementById('btn-toggle-comparison');
|
||
if (toggleBtn) {
|
||
toggleBtn.addEventListener('click', toggleComparisonPanel);
|
||
}
|
||
}
|
||
|
||
// ==================== 筛选器填充 ====================
|
||
function populateFilters() {
|
||
var providers = new Set();
|
||
var regions = new Set();
|
||
|
||
allPlans.forEach(function(plan) {
|
||
providers.add(plan.provider);
|
||
regions.add(plan.region);
|
||
});
|
||
|
||
populateSelect('filter-provider', Array.from(providers).sort());
|
||
populateSelect('filter-region', Array.from(regions).sort());
|
||
}
|
||
|
||
function populateSelect(id, options) {
|
||
var select = document.getElementById(id);
|
||
var currentValue = select.value;
|
||
|
||
// 保留第一个选项("全部")
|
||
while (select.options.length > 1) {
|
||
select.remove(1);
|
||
}
|
||
|
||
options.forEach(function(option) {
|
||
var opt = document.createElement('option');
|
||
opt.value = option;
|
||
opt.textContent = option;
|
||
select.appendChild(opt);
|
||
});
|
||
|
||
select.value = currentValue;
|
||
}
|
||
|
||
// ==================== 筛选处理 ====================
|
||
function handleFilterChange(e) {
|
||
var id = e.target.id;
|
||
var value = e.target.value;
|
||
|
||
if (id === 'filter-provider') filters.provider = value;
|
||
else if (id === 'filter-region') filters.region = value;
|
||
else if (id === 'filter-memory') filters.memory = parseFloat(value);
|
||
else if (id === 'filter-price') filters.price = value;
|
||
|
||
renderTable();
|
||
}
|
||
|
||
function handleCurrencyChange(e) {
|
||
filters.currency = e.target.value;
|
||
renderTable();
|
||
updateComparison();
|
||
}
|
||
|
||
function handleSearch(e) {
|
||
filters.search = e.target.value.toLowerCase();
|
||
renderTable();
|
||
}
|
||
|
||
function resetFilters() {
|
||
filters = {
|
||
provider: '',
|
||
region: '',
|
||
memory: 0,
|
||
price: '0',
|
||
currency: filters.currency,
|
||
search: ''
|
||
};
|
||
|
||
document.getElementById('filter-provider').value = '';
|
||
document.getElementById('filter-region').value = '';
|
||
document.getElementById('filter-memory').value = '0';
|
||
document.getElementById('filter-price').value = '0';
|
||
document.getElementById('search-input').value = '';
|
||
|
||
renderTable();
|
||
}
|
||
|
||
// ==================== 排序处理 ====================
|
||
function handleSort(e) {
|
||
var header = e.currentTarget;
|
||
var column = header.dataset.sort;
|
||
|
||
if (currentSort.column === column) {
|
||
currentSort.direction = currentSort.direction === 'asc' ? 'desc' : 'asc';
|
||
} else {
|
||
currentSort.column = column;
|
||
currentSort.direction = 'asc';
|
||
}
|
||
|
||
updateSortIcons();
|
||
renderTable();
|
||
}
|
||
|
||
function updateSortIcons() {
|
||
document.querySelectorAll('.sortable').forEach(function(header) {
|
||
var icon = header.querySelector('.sort-icon');
|
||
icon.textContent = '';
|
||
|
||
if (header.dataset.sort === currentSort.column) {
|
||
icon.textContent = currentSort.direction === 'asc' ? '↑' : '↓';
|
||
}
|
||
});
|
||
}
|
||
|
||
// ==================== 表格渲染 ====================
|
||
function renderTable() {
|
||
var filtered = filterPlans(allPlans);
|
||
var sorted = sortPlans(filtered);
|
||
|
||
var tbody = document.getElementById('table-body');
|
||
tbody.innerHTML = '';
|
||
|
||
if (sorted.length === 0) {
|
||
tbody.innerHTML = '<tr><td colspan="10" style="text-align: center; padding: 2rem; color: var(--text-muted);">未找到匹配的方案</td></tr>';
|
||
return;
|
||
}
|
||
|
||
sorted.forEach(function(plan) {
|
||
var row = createTableRow(plan);
|
||
tbody.appendChild(row);
|
||
});
|
||
}
|
||
|
||
function filterPlans(plans) {
|
||
return plans.filter(function(plan) {
|
||
// 厂商筛选
|
||
if (filters.provider && plan.provider !== filters.provider) return false;
|
||
|
||
// 区域筛选
|
||
if (filters.region && plan.region !== filters.region) return false;
|
||
|
||
// 内存筛选
|
||
if (filters.memory > 0 && plan.memory_gb < filters.memory) return false;
|
||
|
||
// 价格筛选
|
||
if (filters.price !== '0') {
|
||
var range = filters.price.split('-');
|
||
var min = parseFloat(range[0]);
|
||
var max = parseFloat(range[1]);
|
||
if (plan.price_cny < min || plan.price_cny > max) return false;
|
||
}
|
||
|
||
// 搜索筛选
|
||
if (filters.search) {
|
||
var searchText = (plan.provider + ' ' + plan.name + ' ' + plan.region).toLowerCase();
|
||
if (searchText.indexOf(filters.search) === -1) return false;
|
||
}
|
||
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function sortPlans(plans) {
|
||
if (!currentSort.column) return plans;
|
||
|
||
return plans.slice().sort(function(a, b) {
|
||
var aVal = a[currentSort.column];
|
||
var bVal = b[currentSort.column];
|
||
|
||
if (typeof aVal === 'number' && typeof bVal === 'number') {
|
||
return currentSort.direction === 'asc' ? aVal - bVal : bVal - aVal;
|
||
}
|
||
|
||
var aStr = String(aVal).toLowerCase();
|
||
var bStr = String(bVal).toLowerCase();
|
||
|
||
if (currentSort.direction === 'asc') {
|
||
return aStr < bStr ? -1 : aStr > bStr ? 1 : 0;
|
||
} else {
|
||
return aStr > bStr ? -1 : aStr < bStr ? 1 : 0;
|
||
}
|
||
});
|
||
}
|
||
|
||
function createTableRow(plan) {
|
||
var tr = document.createElement('tr');
|
||
|
||
var price = convertPrice(plan.price_cny, filters.currency);
|
||
var priceSymbol = filters.currency === 'CNY' ? '¥' : '$';
|
||
|
||
var isInComparison = comparisonPlans.some(function(p) { return p.id === plan.id; });
|
||
var starClass = isInComparison ? 'star-active' : 'star-inactive';
|
||
var starIcon = isInComparison ? '★' : '☆';
|
||
|
||
tr.innerHTML =
|
||
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
||
'<td>' + escapeHtml(plan.region) + '</td>' +
|
||
'<td>' + escapeHtml(plan.name) + '</td>' +
|
||
'<td>' + plan.vcpu + '</td>' +
|
||
'<td>' + plan.memory_gb + ' GB</td>' +
|
||
'<td>' + plan.storage_gb + ' GB</td>' +
|
||
'<td>' + plan.bandwidth + '</td>' +
|
||
'<td>' + plan.traffic + '</td>' +
|
||
'<td class="col-price">' + priceSymbol + price + '</td>' +
|
||
'<td class="col-link">' +
|
||
'<button class="btn-star ' + starClass + '" data-plan-id="' + plan.id + '" title="' + (isInComparison ? '取消对比' : '添加对比') + '">' +
|
||
starIcon +
|
||
'</button>' +
|
||
'<a href="' + escapeHtml(plan.url) + '" target="_blank" rel="noopener" class="btn-link">访问</a>' +
|
||
'</td>';
|
||
|
||
// 星标按钮事件
|
||
var starBtn = tr.querySelector('.btn-star');
|
||
starBtn.addEventListener('click', function() {
|
||
toggleComparison(plan);
|
||
});
|
||
|
||
return tr;
|
||
}
|
||
|
||
// ==================== 对比功能 ====================
|
||
function toggleComparison(plan) {
|
||
var index = comparisonPlans.findIndex(function(p) { return p.id === plan.id; });
|
||
|
||
if (index > -1) {
|
||
comparisonPlans.splice(index, 1);
|
||
} else {
|
||
if (comparisonPlans.length >= MAX_COMPARISON) {
|
||
alert('最多只能对比 ' + MAX_COMPARISON + ' 个方案');
|
||
return;
|
||
}
|
||
comparisonPlans.push(plan);
|
||
}
|
||
|
||
renderTable();
|
||
updateComparison();
|
||
updateURL();
|
||
}
|
||
|
||
function clearComparison() {
|
||
comparisonPlans = [];
|
||
renderTable();
|
||
updateComparison();
|
||
updateURL();
|
||
}
|
||
|
||
// ==================== 对比面板折叠 ====================
|
||
function toggleComparisonPanel() {
|
||
isComparisonExpanded = !isComparisonExpanded;
|
||
updateComparison();
|
||
}
|
||
|
||
// ==================== 对比面板渲染 ====================
|
||
function updateComparison() {
|
||
var panel = document.getElementById('comparison-panel');
|
||
var content = document.getElementById('comparison-content');
|
||
var toggleBtn = document.getElementById('btn-toggle-comparison');
|
||
|
||
if (comparisonPlans.length === 0) {
|
||
content.innerHTML =
|
||
'<div class="comparison-empty">' +
|
||
'<svg class="empty-icon" width="48" height="48" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5">' +
|
||
'<path d="M11.049 2.927c.3-.921 1.603-.921 1.902 0l1.519 4.674a1 1 0 00.95.69h4.915c.969 0 1.371 1.24.588 1.81l-3.976 2.888a1 1 0 00-.363 1.118l1.518 4.674c.3.922-.755 1.688-1.538 1.118l-3.976-2.888a1 1 0 00-1.176 0l-3.976 2.888c-.783.57-1.838-.197-1.538-1.118l1.518-4.674a1 1 0 00-.363-1.118l-3.976-2.888c-.784-.57-.38-1.81.588-1.81h4.914a1 1 0 00.951-.69l1.519-4.674z"/>' +
|
||
'</svg>' +
|
||
'<p class="empty-text">点击星标收藏方案</p>' +
|
||
'<p class="empty-hint">最多对比 ' + MAX_COMPARISON + ' 个方案</p>' +
|
||
'</div>';
|
||
|
||
if (toggleBtn) toggleBtn.style.display = 'none';
|
||
panel.classList.remove('has-comparison');
|
||
return;
|
||
}
|
||
|
||
if (toggleBtn) toggleBtn.style.display = 'flex';
|
||
panel.classList.add('has-comparison');
|
||
|
||
// 更新折叠按钮
|
||
if (toggleBtn) {
|
||
var icon = toggleBtn.querySelector('.toggle-icon');
|
||
var text = toggleBtn.querySelector('.toggle-text');
|
||
if (isComparisonExpanded) {
|
||
icon.innerHTML = '▼';
|
||
text.textContent = '收起对比';
|
||
panel.classList.remove('collapsed');
|
||
} else {
|
||
icon.innerHTML = '▶';
|
||
text.textContent = '展开对比 (' + comparisonPlans.length + ')';
|
||
panel.classList.add('collapsed');
|
||
}
|
||
}
|
||
|
||
// 如果收起状态,不渲染内容
|
||
if (!isComparisonExpanded) {
|
||
content.innerHTML = '';
|
||
return;
|
||
}
|
||
|
||
// 渲染表格
|
||
content.innerHTML = renderComparisonTable();
|
||
}
|
||
|
||
function renderComparisonTable() {
|
||
var priceSymbol = filters.currency === 'CNY' ? '¥' : '$';
|
||
|
||
var html = '<div class="comparison-table-wrapper">';
|
||
html += '<table class="comparison-table">';
|
||
|
||
// 表头
|
||
html += '<thead><tr>';
|
||
html += '<th>厂商</th>';
|
||
html += '<th>配置</th>';
|
||
html += '<th>vCPU</th>';
|
||
html += '<th>内存</th>';
|
||
html += '<th>存储</th>';
|
||
html += '<th>带宽</th>';
|
||
html += '<th>流量</th>';
|
||
html += '<th>区域</th>';
|
||
html += '<th>价格</th>';
|
||
html += '<th>操作</th>';
|
||
html += '</tr></thead>';
|
||
|
||
// 表体
|
||
html += '<tbody>';
|
||
|
||
comparisonPlans.forEach(function(plan) {
|
||
var price = convertPrice(plan.price_cny, filters.currency);
|
||
|
||
html += '<tr class="comparison-row">';
|
||
html += '<td class="provider-cell"><strong>' + escapeHtml(plan.provider) + '</strong></td>';
|
||
html += '<td>' + escapeHtml(plan.name) + '</td>';
|
||
html += '<td>' + plan.vcpu + ' 核</td>';
|
||
html += '<td>' + plan.memory_gb + ' GB</td>';
|
||
html += '<td>' + plan.storage_gb + ' GB</td>';
|
||
html += '<td>' + escapeHtml(plan.bandwidth) + '</td>';
|
||
html += '<td>' + escapeHtml(plan.traffic) + '</td>';
|
||
html += '<td>' + escapeHtml(plan.region) + '</td>';
|
||
html += '<td class="price-cell">' + priceSymbol + price + '</td>';
|
||
html += '<td class="action-cell">';
|
||
html += '<a href="' + escapeHtml(plan.url) + '" target="_blank" rel="noopener" class="btn-visit">访问</a>';
|
||
html += '<button class="btn-remove" data-plan-id="' + plan.id + '" title="移除">✕</button>';
|
||
html += '</td>';
|
||
html += '</tr>';
|
||
});
|
||
|
||
html += '</tbody>';
|
||
html += '</table>';
|
||
html += '</div>';
|
||
|
||
// 绑定移除按钮事件
|
||
setTimeout(function() {
|
||
document.querySelectorAll('.btn-remove').forEach(function(btn) {
|
||
btn.addEventListener('click', function() {
|
||
var planId = this.dataset.planId;
|
||
var plan = comparisonPlans.find(function(p) { return p.id === planId; });
|
||
if (plan) toggleComparison(plan);
|
||
});
|
||
});
|
||
}, 0);
|
||
|
||
return html;
|
||
}
|
||
|
||
// ==================== URL 同步 ====================
|
||
function updateURL() {
|
||
var ids = comparisonPlans.map(function(p) { return p.id; }).join(',');
|
||
var url = new URL(window.location);
|
||
|
||
if (ids) {
|
||
url.searchParams.set('compare', ids);
|
||
} else {
|
||
url.searchParams.delete('compare');
|
||
}
|
||
|
||
window.history.replaceState({}, '', url);
|
||
}
|
||
|
||
function loadComparisonFromURL() {
|
||
var url = new URL(window.location);
|
||
var compareIds = url.searchParams.get('compare');
|
||
|
||
if (compareIds) {
|
||
var ids = compareIds.split(',');
|
||
comparisonPlans = allPlans.filter(function(plan) {
|
||
return ids.indexOf(plan.id) > -1;
|
||
});
|
||
renderTable();
|
||
updateComparison();
|
||
}
|
||
}
|
||
|
||
// ==================== 工具函数 ====================
|
||
function convertPrice(priceCNY, currency) {
|
||
var converted = priceCNY * exchangeRates[currency];
|
||
return converted.toFixed(2);
|
||
}
|
||
|
||
function escapeHtml(text) {
|
||
var div = document.createElement('div');
|
||
div.textContent = text;
|
||
return div.innerHTML;
|
||
}
|
||
|
||
function debounce(func, wait) {
|
||
var timeout;
|
||
return function() {
|
||
var context = this;
|
||
var args = arguments;
|
||
clearTimeout(timeout);
|
||
timeout = setTimeout(function() {
|
||
func.apply(context, args);
|
||
}, wait);
|
||
};
|
||
}
|
||
|
||
function showError(message) {
|
||
var tbody = document.getElementById('table-body');
|
||
tbody.innerHTML = '<tr><td colspan="10" style="text-align: center; padding: 2rem; color: #EF4444;">' + message + '</td></tr>';
|
||
}
|
||
|
||
// ==================== 启动 ====================
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|