809 lines
32 KiB
JavaScript
809 lines
32 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');
|
||
const comparisonContent = document.getElementById('comparison-content');
|
||
const btnClearComparison = document.getElementById('btn-clear-comparison');
|
||
|
||
let allPlans = [];
|
||
let filteredPlans = [];
|
||
let isLoading = false;
|
||
let currentSort = { column: null, order: 'asc' };
|
||
let favorites = JSON.parse(localStorage.getItem('vps_favorites') || '[]');
|
||
let viewMode = 'cards'; // 'cards' or 'table'
|
||
const MAX_COMPARISON = 4;
|
||
|
||
// ========== 工具函数 ==========
|
||
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 calculateDiff(value, bestValue, isLowerBetter) {
|
||
if (value == null || bestValue == null) return null;
|
||
if (value === bestValue) return 0;
|
||
|
||
const diff = isLowerBetter
|
||
? ((value - bestValue) / bestValue * 100)
|
||
: ((bestValue - value) / bestValue * 100);
|
||
|
||
return Math.round(diff);
|
||
}
|
||
|
||
function getDiffBadge(diff, isLowerBetter) {
|
||
if (diff === 0) return 'best';
|
||
const absDiff = Math.abs(diff);
|
||
|
||
if (isLowerBetter) {
|
||
if (absDiff <= 10) return 'good';
|
||
if (absDiff <= 30) return 'average';
|
||
return 'poor';
|
||
} else {
|
||
if (diff <= -10) return 'good';
|
||
if (diff <= -30) return 'average';
|
||
return 'poor';
|
||
}
|
||
}
|
||
|
||
function getProgressBarWidth(value, maxValue) {
|
||
if (value == null || maxValue == null || maxValue === 0) return 0;
|
||
return Math.min((value / maxValue * 100), 100);
|
||
}
|
||
|
||
// ========== 性价比评分 ==========
|
||
function calculateValueScore(plan, bestPrice, bestVcpu, bestMemory, bestStorage) {
|
||
let score = 0;
|
||
const price = getPriceValue(plan);
|
||
|
||
// 价格权重 40%
|
||
if (price != null && bestPrice != null) {
|
||
const priceDiff = (price - bestPrice) / bestPrice;
|
||
if (priceDiff <= 0.1) score += 4;
|
||
else if (priceDiff <= 0.3) score += 3;
|
||
else if (priceDiff <= 0.5) score += 2;
|
||
else score += 1;
|
||
}
|
||
|
||
// CPU 权重 20%
|
||
if (plan.vcpu != null && bestVcpu != null) {
|
||
const cpuRatio = plan.vcpu / bestVcpu;
|
||
if (cpuRatio >= 0.9) score += 2;
|
||
else if (cpuRatio >= 0.7) score += 1.5;
|
||
else score += 1;
|
||
}
|
||
|
||
// 内存权重 30%
|
||
if (plan.memory_gb != null && bestMemory != null) {
|
||
const memRatio = plan.memory_gb / bestMemory;
|
||
if (memRatio >= 0.9) score += 3;
|
||
else if (memRatio >= 0.7) score += 2;
|
||
else score += 1;
|
||
}
|
||
|
||
// 存储权重 10%
|
||
if (plan.storage_gb != null && bestStorage != null) {
|
||
const storageRatio = plan.storage_gb / bestStorage;
|
||
if (storageRatio >= 0.9) score += 1;
|
||
else if (storageRatio >= 0.7) score += 0.5;
|
||
}
|
||
|
||
return Math.min(Math.round(score), 5);
|
||
}
|
||
|
||
function renderStars(score) {
|
||
let html = '';
|
||
for (let i = 1; i <= 5; i++) {
|
||
if (i <= score) {
|
||
html += '<svg class="value-score-star" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
|
||
} else {
|
||
html += '<svg class="value-score-star empty" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2l3.09 6.26L22 9.27l-5 4.87 1.18 6.88L12 17.77l-6.18 3.25L7 14.14 2 9.27l6.91-1.01L12 2z"/></svg>';
|
||
}
|
||
}
|
||
return html;
|
||
}
|
||
|
||
// ========== 收藏功能 ==========
|
||
function isFavorite(planId) {
|
||
return favorites.indexOf(planId) !== -1;
|
||
}
|
||
|
||
function toggleFavorite(planId) {
|
||
const index = favorites.indexOf(planId);
|
||
if (index === -1) {
|
||
if (favorites.length >= MAX_COMPARISON) {
|
||
showComparisonLimitNotice();
|
||
return;
|
||
}
|
||
favorites.push(planId);
|
||
} else {
|
||
favorites.splice(index, 1);
|
||
}
|
||
localStorage.setItem('vps_favorites', JSON.stringify(favorites));
|
||
refresh();
|
||
renderComparison();
|
||
}
|
||
|
||
function clearAllFavorites() {
|
||
favorites = [];
|
||
localStorage.setItem('vps_favorites', JSON.stringify(favorites));
|
||
refresh();
|
||
renderComparison();
|
||
}
|
||
|
||
function showComparisonLimitNotice() {
|
||
const notice = document.createElement('div');
|
||
notice.className = 'comparison-limit-notice';
|
||
notice.style.opacity = '0';
|
||
notice.innerHTML = '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-3L13.732 4c-.77-1.333-2.694-1.333-3.464 0L3.34 16c-.77 1.333.192 3 1.732 3z"/></svg>最多只能对比 ' + MAX_COMPARISON + ' 个方案';
|
||
|
||
comparisonContent.insertBefore(notice, comparisonContent.firstChild);
|
||
|
||
setTimeout(function() {
|
||
notice.style.transition = 'opacity 0.3s';
|
||
notice.style.opacity = '1';
|
||
}, 10);
|
||
|
||
setTimeout(function() {
|
||
notice.style.opacity = '0';
|
||
setTimeout(function() {
|
||
notice.remove();
|
||
}, 300);
|
||
}, 3000);
|
||
}
|
||
|
||
// ========== 增强对比面板渲染 ==========
|
||
function renderComparison() {
|
||
if (favorites.length === 0) {
|
||
comparisonContent.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>';
|
||
return;
|
||
}
|
||
|
||
const comparisonPlans = allPlans.filter(function(plan) {
|
||
return favorites.indexOf(plan.id) !== -1;
|
||
});
|
||
|
||
if (comparisonPlans.length === 0) {
|
||
comparisonContent.innerHTML = '<div class="comparison-empty">' +
|
||
'<p class="empty-text">未找到收藏的方案</p>' +
|
||
'</div>';
|
||
return;
|
||
}
|
||
|
||
// 计算最优值和最大值
|
||
const prices = comparisonPlans.map(getPriceValue).filter(function(p) { return p != null; });
|
||
const vcpus = comparisonPlans.map(function(p) { return p.vcpu; }).filter(function(v) { return v != null; });
|
||
const memories = comparisonPlans.map(function(p) { return p.memory_gb; }).filter(function(m) { return m != null; });
|
||
const storages = comparisonPlans.map(function(p) { return p.storage_gb; }).filter(function(s) { return s != null; });
|
||
|
||
const bestPrice = prices.length > 0 ? Math.min.apply(null, prices) : null;
|
||
const bestVcpu = vcpus.length > 0 ? Math.max.apply(null, vcpus) : null;
|
||
const bestMemory = memories.length > 0 ? Math.max.apply(null, memories) : null;
|
||
const bestStorage = storages.length > 0 ? Math.max.apply(null, storages) : null;
|
||
|
||
const maxVcpu = bestVcpu;
|
||
const maxMemory = bestMemory;
|
||
const maxStorage = bestStorage;
|
||
|
||
// 渲染卡片视图
|
||
const cardsHtml = renderComparisonCards(comparisonPlans, bestPrice, bestVcpu, bestMemory, bestStorage, maxVcpu, maxMemory, maxStorage);
|
||
|
||
// 渲染表格视图(如果有2个以上方案)
|
||
const tableHtml = comparisonPlans.length >= 2
|
||
? renderComparisonTable(comparisonPlans, bestPrice, bestVcpu, bestMemory, bestStorage)
|
||
: '';
|
||
|
||
comparisonContent.innerHTML = cardsHtml + tableHtml;
|
||
|
||
// 绑定事件
|
||
attachComparisonEvents();
|
||
}
|
||
|
||
function renderComparisonCards(plans, bestPrice, bestVcpu, bestMemory, bestStorage, maxVcpu, maxMemory, maxStorage) {
|
||
const cardsHtml = plans.map(function(plan) {
|
||
const price = getPriceValue(plan);
|
||
const isBestPrice = price != null && price === bestPrice;
|
||
const isBestVcpu = plan.vcpu != null && plan.vcpu === bestVcpu;
|
||
const isBestMemory = plan.memory_gb != null && plan.memory_gb === bestMemory;
|
||
const isBestStorage = plan.storage_gb != null && plan.storage_gb === bestStorage;
|
||
|
||
// 计算差异
|
||
const priceDiff = calculateDiff(price, bestPrice, true);
|
||
const vcpuDiff = calculateDiff(plan.vcpu, bestVcpu, false);
|
||
const memoryDiff = calculateDiff(plan.memory_gb, bestMemory, false);
|
||
const storageDiff = calculateDiff(plan.storage_gb, bestStorage, false);
|
||
|
||
// 计算性价比评分
|
||
const valueScore = calculateValueScore(plan, bestPrice, bestVcpu, bestMemory, bestStorage);
|
||
|
||
return '<div class="comparison-card">' +
|
||
'<div class="comparison-card-header">' +
|
||
'<div class="comparison-card-title">' +
|
||
'<div class="comparison-provider">' + escapeHtml(plan.provider) + '</div>' +
|
||
'<div class="comparison-name">' + escapeHtml(plan.name) + '</div>' +
|
||
'</div>' +
|
||
'<button class="btn-remove-comparison" data-id="' + plan.id + '" title="移除">' +
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||
'<path d="M6 18L18 6M6 6l12 12"/>' +
|
||
'</svg>' +
|
||
'</button>' +
|
||
'</div>' +
|
||
'<div class="comparison-specs">' +
|
||
renderSpecWithBar('vCPU', plan.vcpu, ' 核', isBestVcpu, vcpuDiff, maxVcpu, false) +
|
||
renderSpecWithBar('内存', plan.memory_gb, ' GB', isBestMemory, memoryDiff, maxMemory, false) +
|
||
renderSpecWithBar('存储', plan.storage_gb, ' GB', isBestStorage, storageDiff, maxStorage, false) +
|
||
'<div class="comparison-spec">' +
|
||
'<div class="spec-label">带宽</div>' +
|
||
'<div class="spec-value">' + showVal(plan.bandwidth_mbps, ' Mbps') + '</div>' +
|
||
'</div>' +
|
||
'<div class="comparison-spec">' +
|
||
'<div class="spec-label">流量</div>' +
|
||
'<div class="spec-value">' + (plan.traffic ? escapeHtml(plan.traffic) : '—') + '</div>' +
|
||
'</div>' +
|
||
'<div class="comparison-spec">' +
|
||
'<div class="spec-label">区域</div>' +
|
||
'<div class="spec-value">' + escapeHtml(getDisplayRegion(plan)) + '</div>' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="comparison-price">' +
|
||
'<div class="price-label">月付价格</div>' +
|
||
'<div class="price-value' + (isBestPrice ? ' highlight-best' : '') + '">' +
|
||
formatPrice(plan) +
|
||
(priceDiff != null && priceDiff !== 0 ? '<span class="spec-diff negative">+' + priceDiff + '%</span>' : '') +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="value-score">' +
|
||
'<div class="value-score-label">性价比</div>' +
|
||
'<div class="value-score-stars">' + renderStars(valueScore) + '</div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
return '<div class="comparison-cards">' + cardsHtml + '</div>';
|
||
}
|
||
|
||
function renderSpecWithBar(label, value, suffix, isBest, diff, maxValue, isLowerBetter) {
|
||
if (value == null) {
|
||
return '<div class="comparison-spec">' +
|
||
'<div class="spec-label">' + label + '</div>' +
|
||
'<div class="spec-value">—</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
const barWidth = getProgressBarWidth(value, maxValue);
|
||
const badge = diff !== null ? getDiffBadge(diff, isLowerBetter) : 'good';
|
||
|
||
let diffHtml = '';
|
||
if (diff !== null && diff !== 0) {
|
||
const sign = diff > 0 ? '+' : '';
|
||
const className = diff > 0 ? 'negative' : 'positive';
|
||
diffHtml = '<span class="spec-diff ' + className + '">' + sign + diff + '%</span>';
|
||
}
|
||
|
||
return '<div class="comparison-spec">' +
|
||
'<div class="spec-label">' + label + '</div>' +
|
||
'<div class="spec-value' + (isBest ? ' highlight-best' : '') + '">' +
|
||
value + suffix + diffHtml +
|
||
'</div>' +
|
||
'<div class="comparison-bar">' +
|
||
'<div class="comparison-bar-fill ' + badge + '" style="width: ' + barWidth + '%"></div>' +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderComparisonTable(plans, bestPrice, bestVcpu, bestMemory, bestStorage) {
|
||
const headers = '<div class="comparison-grid-cell header">指标</div>' +
|
||
plans.map(function(plan) {
|
||
return '<div class="comparison-grid-cell header provider-header">' +
|
||
escapeHtml(plan.provider) + '<br><small>' + escapeHtml(plan.name) + '</small>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
const rows = [
|
||
renderTableRow('vCPU', plans, function(p) { return p.vcpu; }, ' 核', bestVcpu, false),
|
||
renderTableRow('内存', plans, function(p) { return p.memory_gb; }, ' GB', bestMemory, false),
|
||
renderTableRow('存储', plans, function(p) { return p.storage_gb; }, ' GB', bestStorage, false),
|
||
renderTableRow('带宽', plans, function(p) { return p.bandwidth_mbps; }, ' Mbps', null, false),
|
||
renderTableRow('流量', plans, function(p) { return p.traffic; }, '', null, false),
|
||
renderTableRow('区域', plans, function(p) { return getDisplayRegion(p); }, '', null, false),
|
||
renderTableRow('价格', plans, getPriceValue, '', bestPrice, true, true)
|
||
].join('');
|
||
|
||
return '<div class="comparison-table-view active">' +
|
||
'<div class="comparison-table-header">' +
|
||
'<div class="comparison-table-title">' +
|
||
'<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">' +
|
||
'<path d="M9 5H7a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2V7a2 2 0 00-2-2h-2M9 5a2 2 0 002 2h2a2 2 0 002-2M9 5a2 2 0 012-2h2a2 2 0 012 2m-6 9l2 2 4-4"/>' +
|
||
'</svg>' +
|
||
'横向对比表格' +
|
||
'</div>' +
|
||
'</div>' +
|
||
'<div class="comparison-grid">' +
|
||
headers + rows +
|
||
'</div>' +
|
||
'</div>';
|
||
}
|
||
|
||
function renderTableRow(label, plans, getValue, suffix, bestValue, isLowerBetter, isPrice) {
|
||
const labelCell = '<div class="comparison-grid-cell header">' + label + '</div>';
|
||
|
||
const valueCells = plans.map(function(plan) {
|
||
const value = getValue(plan);
|
||
let displayValue;
|
||
|
||
if (isPrice) {
|
||
displayValue = formatPrice(plan);
|
||
} else if (value == null || value === '') {
|
||
displayValue = '—';
|
||
} else {
|
||
displayValue = value + suffix;
|
||
}
|
||
|
||
const isBest = bestValue != null && value === bestValue;
|
||
const highlightClass = isBest ? ' highlight' : '';
|
||
|
||
return '<div class="comparison-grid-cell">' +
|
||
'<div class="comparison-grid-value' + highlightClass + '">' +
|
||
escapeHtml(displayValue) +
|
||
'</div>' +
|
||
'</div>';
|
||
}).join('');
|
||
|
||
return labelCell + valueCells;
|
||
}
|
||
|
||
function attachComparisonEvents() {
|
||
document.querySelectorAll('.btn-remove-comparison').forEach(function(btn) {
|
||
btn.addEventListener('click', function(e) {
|
||
e.preventDefault();
|
||
const planId = parseInt(this.getAttribute('data-id'));
|
||
toggleFavorite(planId);
|
||
});
|
||
});
|
||
}
|
||
|
||
// ========== 筛选功能 ==========
|
||
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];
|
||
}
|
||
|
||
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', function() {
|
||
refresh();
|
||
renderComparison();
|
||
});
|
||
|
||
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();
|
||
renderComparison();
|
||
});
|
||
|
||
btnClearComparison.addEventListener('click', function() {
|
||
if (confirm('确定要清空所有对比方案吗?')) {
|
||
clearAllFavorites();
|
||
}
|
||
});
|
||
|
||
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();
|
||
renderComparison();
|
||
|
||
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);
|
||
cursor: pointer;
|
||
}
|
||
.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);
|
||
})();
|