345 lines
11 KiB
JavaScript
345 lines
11 KiB
JavaScript
/**
|
||
* VPS Price - Simple Mode (无对比功能)
|
||
* 实现时间: 2026-02-09
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ==================== 全局变量 ====================
|
||
var allPlans = [];
|
||
|
||
// 排序状态
|
||
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 toNumber(value) {
|
||
if (value == null || value === '') return null;
|
||
var n = Number(value);
|
||
return isNaN(n) ? null : n;
|
||
}
|
||
|
||
function getPriceValue(plan, currency) {
|
||
var cny = toNumber(plan.price_cny);
|
||
var usd = toNumber(plan.price_usd);
|
||
|
||
if (currency === 'USD') {
|
||
if (usd != null) return { value: usd, symbol: '$' };
|
||
if (cny != null) return { value: cny * exchangeRates.USD, symbol: '$' };
|
||
return null;
|
||
}
|
||
|
||
if (cny != null) return { value: cny, symbol: '¥' };
|
||
if (usd != null) return { value: usd / exchangeRates.USD, symbol: '¥' };
|
||
return null;
|
||
}
|
||
|
||
function getCnyPrice(plan) {
|
||
var cny = toNumber(plan.price_cny);
|
||
if (cny != null) return cny;
|
||
var usd = toNumber(plan.price_usd);
|
||
if (usd != null) return usd / exchangeRates.USD;
|
||
return null;
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
function init() {
|
||
fetchData();
|
||
initEventListeners();
|
||
}
|
||
|
||
// ==================== 数据获取 ====================
|
||
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();
|
||
})
|
||
.catch(function(error) {
|
||
console.error('Error fetching data:', error);
|
||
showError((window.I18N_JS && window.I18N_JS.load_error) || '数据加载失败,请刷新页面重试');
|
||
});
|
||
}
|
||
|
||
// ==================== 事件监听 ====================
|
||
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);
|
||
});
|
||
}
|
||
|
||
// ==================== 筛选器填充 ====================
|
||
function populateFilters() {
|
||
var providers = new Set();
|
||
var regions = new Set();
|
||
|
||
allPlans.forEach(function(plan) {
|
||
providers.add(plan.provider);
|
||
regions.add(plan.countries);
|
||
});
|
||
|
||
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();
|
||
}
|
||
|
||
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);">' + ((window.I18N_JS && window.I18N_JS.empty_state) || '未找到匹配的方案') + '</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.countries !== 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]);
|
||
var cnyPrice = getCnyPrice(plan);
|
||
if (cnyPrice == null) return false;
|
||
if (cnyPrice < min || cnyPrice > max) return false;
|
||
}
|
||
|
||
// 搜索筛选
|
||
if (filters.search) {
|
||
var searchText = (plan.provider + ' ' + plan.name + ' ' + plan.countries).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;
|
||
var bVal;
|
||
if (currentSort.column === 'price') {
|
||
var aPrice = getPriceValue(a, filters.currency);
|
||
var bPrice = getPriceValue(b, filters.currency);
|
||
aVal = aPrice ? aPrice.value : Number.POSITIVE_INFINITY;
|
||
bVal = bPrice ? bPrice.value : Number.POSITIVE_INFINITY;
|
||
} else {
|
||
aVal = a[currentSort.column];
|
||
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 currentPrice = getPriceValue(plan, filters.currency);
|
||
var displayPrice = currentPrice ? currentPrice.symbol + currentPrice.value.toFixed(2) : '—';
|
||
|
||
var btnText = (window.I18N_JS && window.I18N_JS.btn_visit) || '访问';
|
||
|
||
tr.innerHTML =
|
||
'<td>' + escapeHtml(plan.provider) + '</td>' +
|
||
'<td>' + escapeHtml(plan.countries) + '</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_mbps ? plan.bandwidth_mbps + ' Mbps' : '-') + '</td>' +
|
||
'<td>' + plan.traffic + '</td>' +
|
||
'<td class="col-price">' + displayPrice + '</td>' +
|
||
'<td class="col-link">' +
|
||
'<a href="' + escapeHtml(plan.official_url) + '" target="_blank" rel="noopener" class="btn-link">' + btnText + '</a>' +
|
||
'</td>';
|
||
|
||
return tr;
|
||
}
|
||
|
||
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();
|
||
}
|
||
})();
|