471 lines
17 KiB
JavaScript
471 lines
17 KiB
JavaScript
(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');
|
|
|
|
let allPlans = [];
|
|
let filteredPlans = [];
|
|
let isLoading = false;
|
|
let currentSort = { column: null, order: 'asc' };
|
|
let favorites = JSON.parse(localStorage.getItem('vps_favorites') || '[]');
|
|
|
|
// ========== 工具函数 ==========
|
|
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, '<').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 isFavorite(planId) {
|
|
return favorites.indexOf(planId) !== -1;
|
|
}
|
|
|
|
function toggleFavorite(planId) {
|
|
const index = favorites.indexOf(planId);
|
|
if (index === -1) {
|
|
favorites.push(planId);
|
|
} else {
|
|
favorites.splice(index, 1);
|
|
}
|
|
localStorage.setItem('vps_favorites', JSON.stringify(favorites));
|
|
refresh();
|
|
}
|
|
|
|
// ========== 筛选功能 ==========
|
|
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];
|
|
}
|
|
|
|
// 处理 null 值
|
|
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 = '<option value="">全部</option>';
|
|
providers.forEach(function (p) {
|
|
const opt = document.createElement('option');
|
|
opt.value = p;
|
|
opt.textContent = p;
|
|
filterProvider.appendChild(opt);
|
|
});
|
|
|
|
filterRegion.innerHTML = '<option value="">全部</option>';
|
|
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 '<tr class="loading-row"><td colspan="' + colCount + '"></td></tr>';
|
|
}).join('');
|
|
tableBody.innerHTML = skeletonRows;
|
|
}
|
|
|
|
function renderTable(plans) {
|
|
const colCount = 10;
|
|
if (plans.length === 0) {
|
|
tableBody.innerHTML = '<tr><td colspan="' + colCount + '" class="empty-state"><p>🔍 没有符合条件的方案</p></td></tr>';
|
|
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 (
|
|
'<tr style="animation: fadeIn 0.3s ease-in-out ' + (index * 0.02) + 's forwards; opacity: 0;" class="' + favClass + '">' +
|
|
'<td class="provider">' + escapeHtml(plan.provider) + '</td>' +
|
|
'<td class="region">' + escapeHtml(getDisplayRegion(plan)) + '</td>' +
|
|
'<td>' + escapeHtml(plan.name) + '</td>' +
|
|
'<td>' + showVal(plan.vcpu) + '</td>' +
|
|
'<td>' + showVal(plan.memory_gb, ' GB') + '</td>' +
|
|
'<td>' + showVal(plan.storage_gb, ' GB') + '</td>' +
|
|
'<td>' + showVal(plan.bandwidth_mbps, ' Mbps') + '</td>' +
|
|
'<td>' + (plan.traffic ? escapeHtml(plan.traffic) : '—') + '</td>' +
|
|
'<td class="col-price">' + formatPrice(plan) + '</td>' +
|
|
'<td class="col-link">' +
|
|
'<button class="btn-favorite" data-id="' + plan.id + '" title="收藏">' + favIcon + '</button>' +
|
|
(url ? '<a href="' + escapeAttr(url) + '" target="_blank" rel="noopener noreferrer">官网</a>' : '') +
|
|
'</td>' +
|
|
'</tr>'
|
|
);
|
|
}).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', refresh);
|
|
|
|
// 搜索输入(防抖)
|
|
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();
|
|
});
|
|
|
|
// 表头排序点击
|
|
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();
|
|
|
|
// 添加结果计数显示
|
|
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 = '<tr><td colspan="10" class="empty-state"><p>❌ 加载失败,请刷新页面重试</p></td></tr>';
|
|
});
|
|
|
|
// ========== 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);
|
|
}
|
|
.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);
|
|
})();
|