Files
vps_web/static/js/main-simple.js
ddrwode d454699f50 哈哈
2026-02-11 17:38:06 +08:00

582 lines
20 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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, '&quot;');
}
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();
}
})();