Files
vps_web/static/js/main-comparison-enhanced.js
ddrwode 976e9afa88 哈哈
2026-02-09 17:56:23 +08:00

809 lines
32 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.

(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, '&amp;').replace(/"/g, '&quot;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}
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);
})();