582 lines
20 KiB
JavaScript
582 lines
20 KiB
JavaScript
/**
|
||
* VPS Price - Simple Mode (无对比功能)
|
||
* 实现时间: 2026-02-09
|
||
*/
|
||
|
||
(function() {
|
||
'use strict';
|
||
|
||
// ==================== 全局变量 ====================
|
||
var allPlans = [];
|
||
var isEnglish = window.LANG === 'en';
|
||
|
||
// 排序状态
|
||
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 getDisplayRegion(plan) {
|
||
return (plan.countries && String(plan.countries).trim()) ? String(plan.countries).trim() : (plan.region || '');
|
||
}
|
||
|
||
function getAllRegionOptions(plans) {
|
||
var regionSet = new Set();
|
||
plans.forEach(function(plan) {
|
||
var countriesText = (plan.countries || '').trim();
|
||
if (countriesText) {
|
||
countriesText.split(',').forEach(function(part) {
|
||
var regionName = part.trim();
|
||
if (regionName) regionSet.add(regionName);
|
||
});
|
||
}
|
||
var regionRaw = (plan.region || '').trim();
|
||
if (regionRaw) regionSet.add(regionRaw);
|
||
});
|
||
return Array.from(regionSet).sort();
|
||
}
|
||
|
||
function matchesRegion(plan, targetRegion) {
|
||
if (!targetRegion) return true;
|
||
var region = String(targetRegion).trim();
|
||
if (!region) return true;
|
||
|
||
var countriesText = (plan.countries || '').trim();
|
||
if (countriesText === region) return true;
|
||
if (countriesText) {
|
||
var segments = countriesText.split(',');
|
||
for (var i = 0; i < segments.length; i++) {
|
||
if (segments[i].trim() === region) return true;
|
||
}
|
||
}
|
||
var regionRaw = (plan.region || '').trim();
|
||
return regionRaw === region;
|
||
}
|
||
|
||
// ==================== 初始化 ====================
|
||
function init() {
|
||
fetchData();
|
||
initEventListeners();
|
||
}
|
||
|
||
// ==================== 数据获取 ====================
|
||
function fetchData() {
|
||
// 优先使用服务端直出的数据,首屏无需再请求 /api/plans
|
||
if (window.__INITIAL_PLANS__ && Array.isArray(window.__INITIAL_PLANS__) && window.__INITIAL_PLANS__.length >= 0) {
|
||
allPlans = window.__INITIAL_PLANS__;
|
||
updateSummaryMetrics(allPlans);
|
||
populateFilters();
|
||
applyUrlPrefillFromQuery();
|
||
renderTable();
|
||
return;
|
||
}
|
||
fetch('/api/plans')
|
||
.then(function(response) {
|
||
if (!response.ok) throw new Error('Network error');
|
||
return response.json();
|
||
})
|
||
.then(function(data) {
|
||
allPlans = data;
|
||
updateSummaryMetrics(allPlans);
|
||
populateFilters();
|
||
applyUrlPrefillFromQuery();
|
||
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();
|
||
|
||
allPlans.forEach(function(plan) {
|
||
providers.add(plan.provider);
|
||
});
|
||
|
||
populateSelect('filter-provider', Array.from(providers).sort());
|
||
populateSelect('filter-region', getAllRegionOptions(allPlans));
|
||
}
|
||
|
||
function getQueryParams() {
|
||
try {
|
||
return new URLSearchParams(window.location.search || '');
|
||
} catch (err) {
|
||
return new URLSearchParams('');
|
||
}
|
||
}
|
||
|
||
function hasSelectOption(select, value) {
|
||
if (!select || !value) return false;
|
||
for (var i = 0; i < select.options.length; i++) {
|
||
if (String(select.options[i].value) === String(value)) {
|
||
return true;
|
||
}
|
||
}
|
||
return false;
|
||
}
|
||
|
||
function normalizeMemoryValue(raw) {
|
||
var value = parseInt(raw || '0', 10);
|
||
if (isNaN(value) || value <= 0) return '0';
|
||
if (value >= 8) return '8';
|
||
if (value >= 4) return '4';
|
||
if (value >= 2) return '2';
|
||
return '1';
|
||
}
|
||
|
||
function syncFiltersFromControls() {
|
||
var providerEl = document.getElementById('filter-provider');
|
||
var regionEl = document.getElementById('filter-region');
|
||
var memoryEl = document.getElementById('filter-memory');
|
||
var priceEl = document.getElementById('filter-price');
|
||
var currencyEl = document.getElementById('filter-currency');
|
||
var searchEl = document.getElementById('search-input');
|
||
|
||
filters.provider = (providerEl && providerEl.value) || '';
|
||
filters.region = (regionEl && regionEl.value) || '';
|
||
filters.memory = parseFloat((memoryEl && memoryEl.value) || '0') || 0;
|
||
filters.price = (priceEl && priceEl.value) || '0';
|
||
filters.currency = (currencyEl && currencyEl.value) || 'CNY';
|
||
filters.search = ((searchEl && searchEl.value) || '').toLowerCase().trim();
|
||
}
|
||
|
||
function renderSourceHint(params) {
|
||
var hintEl = document.getElementById('filter-source-hint');
|
||
if (!hintEl) return;
|
||
|
||
var sourcePostRaw = (params.get('source_post') || '').trim();
|
||
var sourcePost = parseInt(sourcePostRaw, 10);
|
||
if (isNaN(sourcePost) || sourcePost <= 0) {
|
||
hintEl.hidden = true;
|
||
hintEl.textContent = '';
|
||
return;
|
||
}
|
||
|
||
var sourceTitle = (params.get('source_title') || '').trim();
|
||
if (sourceTitle.length > 56) {
|
||
sourceTitle = sourceTitle.slice(0, 56);
|
||
}
|
||
var sourceHref = '/forum/post/' + sourcePost + (isEnglish ? '?lang=en' : '');
|
||
var summaryParts = [];
|
||
if (params.get('provider')) summaryParts.push((isEnglish ? 'provider ' : '厂商 ') + params.get('provider'));
|
||
if (params.get('region')) summaryParts.push((isEnglish ? 'region ' : '地区 ') + params.get('region'));
|
||
if (params.get('memory')) summaryParts.push((isEnglish ? 'memory ≥' : '内存≥') + normalizeMemoryValue(params.get('memory')) + 'GB');
|
||
if (params.get('price')) summaryParts.push(isEnglish ? 'budget range' : '预算区间');
|
||
|
||
hintEl.textContent = '';
|
||
hintEl.appendChild(document.createTextNode(isEnglish ? 'From ' : '来源:'));
|
||
var link = document.createElement('a');
|
||
link.href = sourceHref;
|
||
link.target = '_blank';
|
||
link.rel = 'noopener';
|
||
link.textContent = '#' + sourcePost + ' ' + (sourceTitle || (isEnglish ? 'source topic' : '原帖'));
|
||
hintEl.appendChild(link);
|
||
if (summaryParts.length) {
|
||
hintEl.appendChild(document.createTextNode(isEnglish ? ' | Prefill: ' : ' | 预填:'));
|
||
hintEl.appendChild(document.createTextNode(summaryParts.slice(0, 3).join(' / ')));
|
||
}
|
||
hintEl.hidden = false;
|
||
}
|
||
|
||
function applyUrlPrefillFromQuery() {
|
||
var params = getQueryParams();
|
||
var providerEl = document.getElementById('filter-provider');
|
||
var regionEl = document.getElementById('filter-region');
|
||
var memoryEl = document.getElementById('filter-memory');
|
||
var priceEl = document.getElementById('filter-price');
|
||
var currencyEl = document.getElementById('filter-currency');
|
||
var searchEl = document.getElementById('search-input');
|
||
|
||
var provider = (params.get('provider') || '').trim();
|
||
var region = (params.get('region') || '').trim();
|
||
var memory = normalizeMemoryValue(params.get('memory'));
|
||
var price = (params.get('price') || '0').trim();
|
||
var currency = (params.get('currency') || '').trim().toUpperCase();
|
||
var search = (params.get('search') || '').trim();
|
||
|
||
if (provider && hasSelectOption(providerEl, provider)) {
|
||
providerEl.value = provider;
|
||
}
|
||
if (region && hasSelectOption(regionEl, region)) {
|
||
regionEl.value = region;
|
||
}
|
||
if (memoryEl && hasSelectOption(memoryEl, memory)) {
|
||
memoryEl.value = memory;
|
||
}
|
||
if (priceEl && hasSelectOption(priceEl, price)) {
|
||
priceEl.value = price;
|
||
}
|
||
if (currencyEl && (currency === 'CNY' || currency === 'USD')) {
|
||
currencyEl.value = currency;
|
||
}
|
||
if (searchEl && search) {
|
||
searchEl.value = search;
|
||
}
|
||
|
||
renderSourceHint(params);
|
||
syncFiltersFromControls();
|
||
}
|
||
|
||
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);
|
||
updateResultCount(sorted.length, allPlans.length);
|
||
updateLowestMetric(sorted);
|
||
|
||
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 && !matchesRegion(plan, 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 officialUrl = (plan.official_url || '').trim();
|
||
|
||
var btnText = (window.I18N_JS && window.I18N_JS.btn_visit) || '访问';
|
||
var linkCell = officialUrl
|
||
? '<a href="' + escapeAttr(officialUrl) + '" target="_blank" rel="noopener noreferrer nofollow" class="btn-link">' + btnText + '</a>'
|
||
: '-';
|
||
|
||
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">' + linkCell + '</td>';
|
||
|
||
return tr;
|
||
}
|
||
|
||
function escapeAttr(text) {
|
||
if (text == null || text === '') return '';
|
||
var div = document.createElement('div');
|
||
div.textContent = String(text);
|
||
return div.innerHTML.replace(/"/g, '"');
|
||
}
|
||
|
||
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>';
|
||
}
|
||
|
||
function setText(id, text) {
|
||
var el = document.getElementById(id);
|
||
if (el) el.textContent = text;
|
||
}
|
||
|
||
function formatCount(value) {
|
||
if (typeof value !== 'number' || isNaN(value)) return '--';
|
||
return value.toLocaleString();
|
||
}
|
||
|
||
function updateSummaryMetrics(plans) {
|
||
var providerSet = new Set();
|
||
var regionSet = new Set();
|
||
|
||
plans.forEach(function(plan) {
|
||
if (plan.provider) providerSet.add(plan.provider);
|
||
var regionText = getDisplayRegion(plan);
|
||
if (regionText) {
|
||
regionText.split(',').forEach(function(part) {
|
||
var token = part.trim();
|
||
if (token) regionSet.add(token);
|
||
});
|
||
}
|
||
});
|
||
|
||
setText('metric-total-plans', formatCount(plans.length));
|
||
setText('metric-providers', formatCount(providerSet.size));
|
||
setText('metric-regions', formatCount(regionSet.size));
|
||
}
|
||
|
||
function updateLowestMetric(plans) {
|
||
var lowest = null;
|
||
var symbol = filters.currency === 'USD' ? '$' : '¥';
|
||
|
||
plans.forEach(function(plan) {
|
||
var currentPrice = getPriceValue(plan, filters.currency);
|
||
if (!currentPrice) return;
|
||
symbol = currentPrice.symbol;
|
||
if (lowest == null || currentPrice.value < lowest) {
|
||
lowest = currentPrice.value;
|
||
}
|
||
});
|
||
|
||
if (lowest == null) {
|
||
setText('metric-lowest', '--');
|
||
return;
|
||
}
|
||
setText('metric-lowest', symbol + lowest.toFixed(2));
|
||
}
|
||
|
||
function updateResultCount(visibleCount, totalCount) {
|
||
var pattern = (window.I18N_JS && window.I18N_JS.result_count_pattern)
|
||
|| (isEnglish ? 'Showing {visible} of {total} plans' : '筛选结果 {visible} / {total}');
|
||
var label = pattern
|
||
.replace('{visible}', formatCount(visibleCount))
|
||
.replace('{total}', formatCount(totalCount));
|
||
setText('result-count', label);
|
||
}
|
||
|
||
// ==================== 启动 ====================
|
||
if (document.readyState === 'loading') {
|
||
document.addEventListener('DOMContentLoaded', init);
|
||
} else {
|
||
init();
|
||
}
|
||
})();
|